Skip to main content

seam_codegen/typescript/
generator.rs

1/* src/cli/codegen/src/typescript/generator.rs */
2
3use std::collections::BTreeSet;
4
5use anyhow::Result;
6
7use crate::manifest::{
8  CacheHint, InvalidateTarget, Manifest, ProcedureSchema, ProcedureType, TransportConfig,
9  TransportPreference,
10};
11use crate::rpc_hash::RpcHashMap;
12
13use super::render::{render_top_level, to_pascal_case};
14
15/// Wrap name in quotes if it contains characters that make it an invalid JS identifier.
16fn quote_key(name: &str) -> String {
17  if name.contains('.') { format!("\"{name}\"") } else { name.to_string() }
18}
19
20/// Build set of procedure names owned by channels (excluded from SeamProcedures).
21fn channel_owned_procedures(manifest: &Manifest) -> BTreeSet<String> {
22  let mut owned = BTreeSet::new();
23  for (ch_name, ch) in &manifest.channels {
24    for msg_name in ch.incoming.keys() {
25      owned.insert(format!("{ch_name}.{msg_name}"));
26    }
27    owned.insert(format!("{ch_name}.events"));
28  }
29  owned
30}
31
32/// Generate `seamProcedureConfig` runtime constant with kind, cache hints, and invalidates.
33fn generate_procedure_config(manifest: &Manifest) -> String {
34  let mut out = String::from("export const seamProcedureConfig = {\n");
35  for (name, schema) in &manifest.procedures {
36    let key = quote_key(name);
37    let kind = schema.proc_type.to_string();
38
39    let mut fields = vec![format!("kind: \"{kind}\"")];
40
41    // cache: only for queries with explicit cache hint
42    if let Some(ref cache) = schema.cache {
43      match cache {
44        CacheHint::Config { ttl } => fields.push(format!("cache: {{ ttl: {ttl} }}")),
45        CacheHint::Disabled(_) => fields.push("cache: false".into()),
46      }
47    }
48
49    // invalidates: only for commands with non-empty targets
50    if let Some(ref targets) = schema.invalidates
51      && !targets.is_empty()
52    {
53      fields.push(format!("invalidates: {}", format_invalidate_targets(targets)));
54    }
55
56    out.push_str(&format!("  {key}: {{ {} }},\n", fields.join(", ")));
57  }
58  out.push_str("} as const;\n\n");
59  out.push_str("export type SeamProcedureConfig = typeof seamProcedureConfig;\n\n");
60  out
61}
62
63/// Serialize InvalidateTarget[] as a TypeScript array literal.
64fn format_invalidate_targets(targets: &[InvalidateTarget]) -> String {
65  let items: Vec<String> = targets
66    .iter()
67    .map(|t| {
68      let mut obj = format!("{{ query: \"{}\"", t.query);
69      if let Some(ref mapping) = t.mapping {
70        let entries: Vec<String> = mapping
71          .iter()
72          .map(|(k, v)| {
73            let mut mv = format!("{{ from: \"{}\"", v.from);
74            if v.each == Some(true) {
75              mv.push_str(", each: true");
76            }
77            mv.push_str(" }");
78            format!("{k}: {mv}")
79          })
80          .collect();
81        obj.push_str(&format!(", mapping: {{ {} }}", entries.join(", ")));
82      }
83      obj.push_str(" }");
84      obj
85    })
86    .collect();
87  format!("[{}]", items.join(", "))
88}
89
90/// Generate channel type declarations, SeamChannels, and channel factory helper.
91fn generate_channel_types(manifest: &Manifest) -> Result<String> {
92  if manifest.channels.is_empty() {
93    return Ok(String::new());
94  }
95
96  let mut out = String::new();
97  let mut channel_entries: Vec<String> = Vec::new();
98
99  for (ch_name, ch) in &manifest.channels {
100    let ch_pascal = to_pascal_case(ch_name);
101
102    // Channel input type
103    let input_type = format!("{ch_pascal}ChannelInput");
104    out.push_str(&render_top_level(&input_type, &ch.input)?);
105    out.push('\n');
106
107    // Incoming message types
108    let mut handle_methods: Vec<String> = Vec::new();
109    for (msg_name, msg) in &ch.incoming {
110      let msg_pascal = to_pascal_case(msg_name);
111      let msg_input_type = format!("{ch_pascal}{msg_pascal}Input");
112      let msg_output_type = format!("{ch_pascal}{msg_pascal}Output");
113
114      out.push_str(&render_top_level(&msg_input_type, &msg.input)?);
115      out.push('\n');
116      out.push_str(&render_top_level(&msg_output_type, &msg.output)?);
117      out.push('\n');
118
119      if let Some(ref error_schema) = msg.error {
120        let msg_error_type = format!("{ch_pascal}{msg_pascal}Error");
121        out.push_str(&render_top_level(&msg_error_type, error_schema)?);
122        out.push('\n');
123      }
124
125      handle_methods
126        .push(format!("  {msg_name}(input: {msg_input_type}): Promise<{msg_output_type}>;"));
127    }
128
129    // Outgoing event payload types + union
130    out.push_str(&generate_channel_outgoing(ch, &ch_pascal)?);
131
132    // Channel handle interface
133    let event_type = format!("{ch_pascal}Event");
134    let handle_type = format!("{ch_pascal}Channel");
135    out.push_str(&format!("export interface {handle_type} {{\n"));
136    for method in &handle_methods {
137      out.push_str(method);
138      out.push('\n');
139    }
140    out.push_str(&format!(
141      "  on<E extends {event_type}[\"type\"]>(\n    event: E,\n    callback: (data: Extract<{event_type}, {{ type: E }}>[\"payload\"]) => void,\n  ): void;\n"
142    ));
143    out.push_str("  close(): void;\n");
144    out.push_str("}\n\n");
145
146    // SeamChannels entry
147    channel_entries.push(format!("  {ch_name}: {{ input: {input_type}; handle: {handle_type} }};"));
148  }
149
150  // SeamChannels interface
151  out.push_str("export interface SeamChannels {\n");
152  for entry in &channel_entries {
153    out.push_str(entry);
154    out.push('\n');
155  }
156  out.push_str("}\n\n");
157
158  Ok(out)
159}
160
161/// Generate outgoing event payload types and the discriminated union for a channel.
162fn generate_channel_outgoing(
163  ch: &crate::manifest::ChannelSchema,
164  ch_pascal: &str,
165) -> Result<String> {
166  let mut out = String::new();
167  let mut union_parts: Vec<String> = Vec::new();
168
169  for (evt_name, evt_schema) in &ch.outgoing {
170    let evt_pascal = to_pascal_case(evt_name);
171    let payload_type = format!("{ch_pascal}{evt_pascal}Payload");
172    out.push_str(&render_top_level(&payload_type, evt_schema)?);
173    out.push('\n');
174    union_parts.push(format!("  | {{ type: \"{evt_name}\"; payload: {payload_type} }}"));
175  }
176
177  let event_type = format!("{ch_pascal}Event");
178  out.push_str(&format!("export type {event_type} =\n"));
179  for part in &union_parts {
180    out.push_str(part);
181    out.push('\n');
182  }
183  out.push_str(";\n\n");
184  Ok(out)
185}
186
187/// Generate SeamProcedureMeta type map (includes all procedures, even channel-owned).
188fn generate_procedure_meta(manifest: &Manifest) -> String {
189  // Build lookup for channel event procedures ({ch}.events) whose types
190  // are named differently: input = {Ch}ChannelInput, output = {Ch}Event.
191  let channel_event_names: BTreeSet<String> =
192    manifest.channels.keys().map(|ch| format!("{ch}.events")).collect();
193
194  let mut out = String::from("export interface SeamProcedureMeta {\n");
195  for (name, schema) in &manifest.procedures {
196    let pascal = to_pascal_case(name);
197    let key = quote_key(name);
198    let kind = match schema.proc_type {
199      ProcedureType::Query => "query",
200      ProcedureType::Command => "command",
201      ProcedureType::Subscription => "subscription",
202      ProcedureType::Stream => "stream",
203      ProcedureType::Upload => "upload",
204    };
205    let (input_name, output_name) = if channel_event_names.contains(name) {
206      // Channel event subscription: types follow channel naming convention
207      let ch_name = name.strip_suffix(".events").expect("channel event name has .events suffix");
208      let ch_pascal = to_pascal_case(ch_name);
209      (format!("{ch_pascal}ChannelInput"), format!("{ch_pascal}Event"))
210    } else if schema.proc_type == ProcedureType::Stream {
211      (format!("{pascal}Input"), format!("{pascal}Chunk"))
212    } else {
213      (format!("{pascal}Input"), format!("{pascal}Output"))
214    };
215    let mut extra_fields = String::new();
216    if schema.error.is_some() {
217      let error_name = format!("{pascal}Error");
218      extra_fields.push_str(&format!("; error: {error_name}"));
219    }
220    if let Some(targets) = &schema.invalidates
221      && !targets.is_empty()
222    {
223      let names: Vec<String> = targets.iter().map(|t| format!("\"{}\"", t.query)).collect();
224      extra_fields.push_str(&format!("; invalidates: readonly [{}]", names.join(", ")));
225    }
226    if let Some(ctx_keys) = &schema.context
227      && !ctx_keys.is_empty()
228    {
229      let keys_str: Vec<String> = ctx_keys.iter().map(|k| format!("\"{k}\"")).collect();
230      extra_fields.push_str(&format!("; context: readonly [{}]", keys_str.join(", ")));
231    }
232    out.push_str(&format!(
233      "  {key}: {{ kind: \"{kind}\"; input: {input_name}; output: {output_name}{extra_fields} }};\n"
234    ));
235  }
236  out.push_str("}\n\n");
237  out
238}
239
240/// Format a fallback array as TypeScript: `["http"] as const`.
241fn format_fallback(fallback: &Option<Vec<TransportPreference>>) -> String {
242  match fallback {
243    Some(v) if !v.is_empty() => {
244      let items: Vec<String> = v.iter().map(|p| format!("\"{p}\"")).collect();
245      format!("[{}] as const", items.join(", "))
246    }
247    _ => "[] as const".to_string(),
248  }
249}
250
251/// Resolve effective channel transport: channel-level > transportDefaults["channel"] > Ws.
252fn resolve_channel_transport(
253  ch: &crate::manifest::ChannelSchema,
254  defaults: &std::collections::BTreeMap<String, TransportConfig>,
255) -> &'static str {
256  if let Some(ref t) = ch.transport {
257    return match t.prefer {
258      TransportPreference::Http => "http",
259      TransportPreference::Sse => "sse",
260      TransportPreference::Ws => "ws",
261      TransportPreference::Ipc => "ipc",
262    };
263  }
264  if let Some(t) = defaults.get("channel") {
265    return match t.prefer {
266      TransportPreference::Http => "http",
267      TransportPreference::Sse => "sse",
268      TransportPreference::Ws => "ws",
269      TransportPreference::Ipc => "ipc",
270    };
271  }
272  "ws"
273}
274
275/// Resolve effective channel fallback: channel-level > transportDefaults["channel"] > ["http"].
276fn resolve_channel_fallback(
277  ch: &crate::manifest::ChannelSchema,
278  defaults: &std::collections::BTreeMap<String, TransportConfig>,
279) -> Option<Vec<TransportPreference>> {
280  if let Some(ref t) = ch.transport {
281    return t.fallback.clone();
282  }
283  if let Some(t) = defaults.get("channel") {
284    return t.fallback.clone();
285  }
286  Some(vec![TransportPreference::Http])
287}
288
289/// Generate transport hint with defaults, procedure overrides, and channel metadata.
290fn generate_transport_hint(manifest: &Manifest, rpc_hashes: Option<&RpcHashMap>) -> String {
291  let mut out = String::from("export const seamTransportHint = {\n");
292
293  // defaults section: always emitted from manifest.transport_defaults
294  out.push_str("  defaults: {\n");
295  for (kind, tc) in &manifest.transport_defaults {
296    out.push_str(&format!(
297      "    {kind}: {{ prefer: \"{prefer}\" as const, fallback: {fallback} }},\n",
298      prefer = tc.prefer,
299      fallback = format_fallback(&tc.fallback),
300    ));
301  }
302  out.push_str("  },\n");
303
304  // procedures section: only those with explicit transport override
305  let proc_overrides: Vec<(&String, &TransportConfig)> = manifest
306    .procedures
307    .iter()
308    .filter_map(|(name, schema)| schema.transport.as_ref().map(|t| (name, t)))
309    .collect();
310  if !proc_overrides.is_empty() {
311    out.push_str("  procedures: {\n");
312    for (name, tc) in &proc_overrides {
313      out.push_str(&format!(
314        "    {}: {{ prefer: \"{prefer}\" as const, fallback: {fallback} }},\n",
315        quote_key(name),
316        prefer = tc.prefer,
317        fallback = format_fallback(&tc.fallback),
318      ));
319    }
320    out.push_str("  },\n");
321  }
322
323  // channels section
324  if !manifest.channels.is_empty() {
325    out.push_str("  channels: {\n");
326    for (ch_name, ch) in &manifest.channels {
327      let transport = resolve_channel_transport(ch, &manifest.transport_defaults);
328      let fallback = resolve_channel_fallback(ch, &manifest.transport_defaults);
329
330      out.push_str(&format!("    {}: {{\n", quote_key(ch_name)));
331      out.push_str(&format!("      transport: \"{transport}\" as const,\n"));
332      out.push_str(&format!("      fallback: {},\n", format_fallback(&fallback)));
333
334      let incoming: Vec<String> = ch
335        .incoming
336        .keys()
337        .map(|msg_name| {
338          let full_name = format!("{ch_name}.{msg_name}");
339          let wire = rpc_hashes
340            .and_then(|m| m.procedures.get(&full_name))
341            .map(String::as_str)
342            .unwrap_or(full_name.as_str());
343          format!("\"{wire}\"")
344        })
345        .collect();
346      out.push_str(&format!("      incoming: [{}],\n", incoming.join(", ")));
347
348      let events_name = format!("{ch_name}.events");
349      let events_wire = rpc_hashes
350        .and_then(|m| m.procedures.get(&events_name))
351        .map(String::as_str)
352        .unwrap_or(events_name.as_str());
353      out.push_str(&format!("      outgoing: \"{events_wire}\",\n"));
354
355      out.push_str("    },\n");
356    }
357    out.push_str("  },\n");
358  }
359
360  out.push_str("} as const;\n\n");
361  out.push_str("export type SeamTransportHint = typeof seamTransportHint;\n\n");
362  out
363}
364
365/// Generate a dependency-free `meta.ts` exporting only DATA_ID.
366pub fn generate_typescript_meta(data_id: &str) -> String {
367  format!("// Auto-generated by seam. Do not edit.\nexport const DATA_ID = \"{data_id}\";\n")
368}
369
370/// Resolve the wire name for a procedure (hashed name if available, original otherwise).
371fn resolve_wire_name<'a>(name: &'a str, rpc_hashes: Option<&'a RpcHashMap>) -> &'a str {
372  rpc_hashes.and_then(|m| m.procedures.get(name)).map(String::as_str).unwrap_or(name)
373}
374
375/// Emit import declarations based on procedure kinds present in the manifest.
376fn generate_imports(has_stream: bool) -> String {
377  let mut out = String::from("import { createClient } from \"@canmi/seam-client\";\n");
378  if has_stream {
379    out.push_str(
380      "import type { SeamClient, SeamClientError, ProcedureKind, Unsubscribe, StreamHandle } from \"@canmi/seam-client\";\n\n",
381    );
382  } else {
383    out.push_str(
384      "import type { SeamClient, SeamClientError, ProcedureKind, Unsubscribe } from \"@canmi/seam-client\";\n\n",
385    );
386  }
387  out
388}
389
390/// Emit type declarations for each non-channel procedure and collect interface/factory lines.
391fn generate_procedure_declarations(
392  manifest: &Manifest,
393  rpc_hashes: Option<&RpcHashMap>,
394  channel_owned: &BTreeSet<String>,
395) -> Result<(String, Vec<String>, Vec<String>)> {
396  let mut out = String::new();
397  let mut iface_lines: Vec<String> = Vec::new();
398  let mut factory_lines: Vec<String> = Vec::new();
399
400  for (name, schema) in &manifest.procedures {
401    if channel_owned.contains(name) {
402      continue;
403    }
404
405    let pascal = to_pascal_case(name);
406    let key = quote_key(name);
407
408    let input_name = format!("{pascal}Input");
409    // Stream uses "Chunk" suffix to clarify it's the chunk type, not a single output
410    let output_name = if schema.proc_type == ProcedureType::Stream {
411      format!("{pascal}Chunk")
412    } else {
413      format!("{pascal}Output")
414    };
415
416    out.push_str(&render_top_level(&input_name, &schema.input)?);
417    out.push('\n');
418
419    if let Some(output_schema) = schema.effective_output() {
420      out.push_str(&render_top_level(&output_name, output_schema)?);
421      out.push('\n');
422    }
423
424    if let Some(ref error_schema) = schema.error {
425      let error_name = format!("{pascal}Error");
426      out.push_str(&render_top_level(&error_name, error_schema)?);
427      out.push('\n');
428    }
429
430    let wire_name = resolve_wire_name(name, rpc_hashes);
431    let (iface, factory) =
432      procedure_client_lines(&key, &input_name, &output_name, wire_name, schema);
433    iface_lines.push(iface);
434    factory_lines.push(factory);
435  }
436
437  Ok((out, iface_lines, factory_lines))
438}
439
440/// Produce one interface line and one factory line for a procedure.
441fn procedure_client_lines(
442  key: &str,
443  input: &str,
444  output: &str,
445  wire: &str,
446  schema: &ProcedureSchema,
447) -> (String, String) {
448  match schema.proc_type {
449    ProcedureType::Stream => (
450      format!("  {key}(input: {input}): StreamHandle<{output}>;"),
451      format!("    {key}: (input) => client.stream(\"{wire}\", input) as StreamHandle<{output}>,"),
452    ),
453    ProcedureType::Subscription => (
454      format!(
455        "  {key}(input: {input}, onData: (data: {output}) => void, onError?: (err: SeamClientError) => void): Unsubscribe;"
456      ),
457      format!(
458        "    {key}: (input, onData, onError) => client.subscribe(\"{wire}\", input, onData as (data: unknown) => void, onError),"
459      ),
460    ),
461    ProcedureType::Upload => (
462      format!("  {key}(input: {input}, file: File | Blob): Promise<{output}>;"),
463      format!(
464        "    {key}: (input, file) => client.upload(\"{wire}\", input, file) as Promise<{output}>,"
465      ),
466    ),
467    _ => {
468      let method = if schema.proc_type == ProcedureType::Command { "command" } else { "query" };
469      (
470        format!("  {key}(input: {input}): Promise<{output}>;"),
471        format!("    {key}: (input) => client.{method}(\"{wire}\", input) as Promise<{output}>,"),
472      )
473    }
474  }
475}
476
477/// Emit the `createSeamClient` factory function.
478fn generate_client_factory(
479  manifest: &Manifest,
480  rpc_hashes: Option<&RpcHashMap>,
481  factory_lines: &[String],
482  has_channels: bool,
483) -> String {
484  let return_type = if has_channels {
485    "SeamProcedures & {\n  channel<K extends keyof SeamChannels>(\n    name: K,\n    input: SeamChannels[K][\"input\"],\n  ): SeamChannels[K][\"handle\"];\n}"
486  } else {
487    "SeamProcedures"
488  };
489
490  let mut out = format!("export function createSeamClient(baseUrl: string): {return_type} {{\n");
491
492  // Build createClient options
493  let mut opts_parts = vec![String::from("baseUrl")];
494  if let Some(map) = rpc_hashes {
495    opts_parts.push(format!("batchEndpoint: \"{}\"", map.batch));
496  }
497  if has_channels {
498    let entries: Vec<String> = manifest
499      .channels
500      .iter()
501      .map(|(name, ch)| {
502        let transport = resolve_channel_transport(ch, &manifest.transport_defaults);
503        format!("{}: \"{}\"", quote_key(name), transport)
504      })
505      .collect();
506    opts_parts.push(format!("channelTransports: {{ {} }}", entries.join(", ")));
507  }
508  out.push_str(&format!(
509    "  const client: SeamClient = createClient({{ {} }});\n",
510    opts_parts.join(", ")
511  ));
512
513  if has_channels {
514    out.push_str("  function channel<K extends keyof SeamChannels>(name: K, input: SeamChannels[K][\"input\"]): SeamChannels[K][\"handle\"] {\n");
515    out.push_str(&generate_channel_factory(manifest));
516    out.push_str("    throw new Error(`Unknown channel: ${name as string}`);\n");
517    out.push_str("  }\n");
518  }
519
520  out.push_str("  return {\n");
521  for line in factory_lines {
522    out.push_str(line);
523    out.push('\n');
524  }
525  if has_channels {
526    out.push_str("    channel,\n");
527  }
528  out.push_str("  };\n");
529  out.push_str("}\n");
530
531  out
532}
533
534/// Generate a typed TypeScript client from a manifest.
535pub fn generate_typescript(
536  manifest: &Manifest,
537  rpc_hashes: Option<&RpcHashMap>,
538  _data_id: &str,
539) -> Result<String> {
540  let mut out = String::from("// Auto-generated by seam. Do not edit.\n");
541
542  let has_channels = !manifest.channels.is_empty();
543  let has_stream = manifest.procedures.values().any(|s| s.proc_type == ProcedureType::Stream);
544
545  out.push_str(&generate_imports(has_stream));
546  out.push_str("export { DATA_ID } from \"./meta.js\";\n\n");
547
548  let channel_owned = channel_owned_procedures(manifest);
549  let (type_decls, iface_lines, factory_lines) =
550    generate_procedure_declarations(manifest, rpc_hashes, &channel_owned)?;
551  out.push_str(&type_decls);
552
553  out.push_str("export interface SeamProcedures {\n");
554  for line in &iface_lines {
555    out.push_str(line);
556    out.push('\n');
557  }
558  out.push_str("}\n\n");
559
560  out.push_str(&generate_procedure_meta(manifest));
561  out.push_str(&generate_procedure_config(manifest));
562
563  if has_channels {
564    out.push_str(&generate_channel_types(manifest)?);
565  }
566
567  // Always emit transport hint (contains defaults section)
568  out.push_str(&generate_transport_hint(manifest, rpc_hashes));
569
570  out.push_str(&generate_client_factory(manifest, rpc_hashes, &factory_lines, has_channels));
571
572  Ok(out)
573}
574
575/// Generate the channel factory body (if-branches for each channel).
576fn generate_channel_factory(manifest: &Manifest) -> String {
577  let mut out = String::new();
578
579  for ch_name in manifest.channels.keys() {
580    out.push_str(&format!("    if (name === \"{ch_name}\") {{\n"));
581    out.push_str(
582      "      return client.channel(name, input) as unknown as SeamChannels[typeof name][\"handle\"];\n",
583    );
584    out.push_str("    }\n");
585  }
586
587  out
588}