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::{Manifest, ProcedureType};
8use crate::rpc_hash::RpcHashMap;
9
10use super::render::{render_top_level, to_pascal_case};
11
12/// Wrap name in quotes if it contains characters that make it an invalid JS identifier.
13fn quote_key(name: &str) -> String {
14  if name.contains('.') { format!("\"{name}\"") } else { name.to_string() }
15}
16
17/// Build set of procedure names owned by channels (excluded from SeamProcedures).
18fn channel_owned_procedures(manifest: &Manifest) -> BTreeSet<String> {
19  let mut owned = BTreeSet::new();
20  for (ch_name, ch) in &manifest.channels {
21    for msg_name in ch.incoming.keys() {
22      owned.insert(format!("{ch_name}.{msg_name}"));
23    }
24    owned.insert(format!("{ch_name}.events"));
25  }
26  owned
27}
28
29/// Generate channel type declarations, SeamChannels, and channel factory helper.
30fn generate_channel_types(manifest: &Manifest) -> Result<String> {
31  if manifest.channels.is_empty() {
32    return Ok(String::new());
33  }
34
35  let mut out = String::new();
36  let mut channel_entries: Vec<String> = Vec::new();
37
38  for (ch_name, ch) in &manifest.channels {
39    let ch_pascal = to_pascal_case(ch_name);
40
41    // Channel input type
42    let input_type = format!("{ch_pascal}ChannelInput");
43    out.push_str(&render_top_level(&input_type, &ch.input)?);
44    out.push('\n');
45
46    // Incoming message types
47    let mut handle_methods: Vec<String> = Vec::new();
48    for (msg_name, msg) in &ch.incoming {
49      let msg_pascal = to_pascal_case(msg_name);
50      let msg_input_type = format!("{ch_pascal}{msg_pascal}Input");
51      let msg_output_type = format!("{ch_pascal}{msg_pascal}Output");
52
53      out.push_str(&render_top_level(&msg_input_type, &msg.input)?);
54      out.push('\n');
55      out.push_str(&render_top_level(&msg_output_type, &msg.output)?);
56      out.push('\n');
57
58      if let Some(ref error_schema) = msg.error {
59        let msg_error_type = format!("{ch_pascal}{msg_pascal}Error");
60        out.push_str(&render_top_level(&msg_error_type, error_schema)?);
61        out.push('\n');
62      }
63
64      handle_methods
65        .push(format!("  {msg_name}(input: {msg_input_type}): Promise<{msg_output_type}>;"));
66    }
67
68    // Outgoing event payload types + union
69    out.push_str(&generate_channel_outgoing(ch, &ch_pascal)?);
70
71    // Channel handle interface
72    let event_type = format!("{ch_pascal}Event");
73    let handle_type = format!("{ch_pascal}Channel");
74    out.push_str(&format!("export interface {handle_type} {{\n"));
75    for method in &handle_methods {
76      out.push_str(method);
77      out.push('\n');
78    }
79    out.push_str(&format!(
80      "  on<E extends {event_type}[\"type\"]>(\n    event: E,\n    callback: (data: Extract<{event_type}, {{ type: E }}>[\"payload\"]) => void,\n  ): void;\n"
81    ));
82    out.push_str("  close(): void;\n");
83    out.push_str("}\n\n");
84
85    // SeamChannels entry
86    channel_entries.push(format!("  {ch_name}: {{ input: {input_type}; handle: {handle_type} }};"));
87  }
88
89  // SeamChannels interface
90  out.push_str("export interface SeamChannels {\n");
91  for entry in &channel_entries {
92    out.push_str(entry);
93    out.push('\n');
94  }
95  out.push_str("}\n\n");
96
97  Ok(out)
98}
99
100/// Generate outgoing event payload types and the discriminated union for a channel.
101fn generate_channel_outgoing(
102  ch: &crate::manifest::ChannelSchema,
103  ch_pascal: &str,
104) -> Result<String> {
105  let mut out = String::new();
106  let mut union_parts: Vec<String> = Vec::new();
107
108  for (evt_name, evt_schema) in &ch.outgoing {
109    let evt_pascal = to_pascal_case(evt_name);
110    let payload_type = format!("{ch_pascal}{evt_pascal}Payload");
111    out.push_str(&render_top_level(&payload_type, evt_schema)?);
112    out.push('\n');
113    union_parts.push(format!("  | {{ type: \"{evt_name}\"; payload: {payload_type} }}"));
114  }
115
116  let event_type = format!("{ch_pascal}Event");
117  out.push_str(&format!("export type {event_type} =\n"));
118  for part in &union_parts {
119    out.push_str(part);
120    out.push('\n');
121  }
122  out.push_str(";\n\n");
123  Ok(out)
124}
125
126/// Generate SeamProcedureMeta type map (includes all procedures, even channel-owned).
127fn generate_procedure_meta(manifest: &Manifest) -> String {
128  // Build lookup for channel event procedures ({ch}.events) whose types
129  // are named differently: input = {Ch}ChannelInput, output = {Ch}Event.
130  let channel_event_names: BTreeSet<String> =
131    manifest.channels.keys().map(|ch| format!("{ch}.events")).collect();
132
133  let mut out = String::from("export interface SeamProcedureMeta {\n");
134  for (name, schema) in &manifest.procedures {
135    let pascal = to_pascal_case(name);
136    let key = quote_key(name);
137    let kind = match schema.proc_type {
138      ProcedureType::Query => "query",
139      ProcedureType::Command => "command",
140      ProcedureType::Subscription => "subscription",
141    };
142    let (input_name, output_name) = if channel_event_names.contains(name) {
143      // Channel event subscription: types follow channel naming convention
144      let ch_name = name.strip_suffix(".events").unwrap();
145      let ch_pascal = to_pascal_case(ch_name);
146      (format!("{ch_pascal}ChannelInput"), format!("{ch_pascal}Event"))
147    } else {
148      (format!("{pascal}Input"), format!("{pascal}Output"))
149    };
150    if schema.error.is_some() {
151      let error_name = format!("{pascal}Error");
152      out.push_str(&format!(
153        "  {key}: {{ kind: \"{kind}\"; input: {input_name}; output: {output_name}; error: {error_name} }};\n"
154      ));
155    } else {
156      out.push_str(&format!(
157        "  {key}: {{ kind: \"{kind}\"; input: {input_name}; output: {output_name} }};\n"
158      ));
159    }
160  }
161  out.push_str("}\n\n");
162  out
163}
164
165/// Generate transport hint for channels (WS metadata for auto-selection).
166fn generate_transport_hint(manifest: &Manifest, rpc_hashes: Option<&RpcHashMap>) -> String {
167  if manifest.channels.is_empty() {
168    return String::new();
169  }
170
171  let mut out = String::from("export const seamTransportHint = {\n  channels: {\n");
172
173  for (ch_name, ch) in &manifest.channels {
174    out.push_str(&format!("    {}: {{\n", quote_key(ch_name)));
175    out.push_str("      transport: \"ws\" as const,\n");
176
177    let incoming: Vec<String> = ch
178      .incoming
179      .keys()
180      .map(|msg_name| {
181        let full_name = format!("{ch_name}.{msg_name}");
182        let wire = rpc_hashes
183          .and_then(|m| m.procedures.get(&full_name))
184          .map(|h| h.as_str())
185          .unwrap_or(full_name.as_str());
186        format!("\"{wire}\"")
187      })
188      .collect();
189    out.push_str(&format!("      incoming: [{}],\n", incoming.join(", ")));
190
191    let events_name = format!("{ch_name}.events");
192    let events_wire = rpc_hashes
193      .and_then(|m| m.procedures.get(&events_name))
194      .map(|h| h.as_str())
195      .unwrap_or(events_name.as_str());
196    out.push_str(&format!("      outgoing: \"{events_wire}\",\n"));
197
198    out.push_str("    },\n");
199  }
200
201  out.push_str("  },\n} as const;\n\n");
202  out.push_str("export type SeamTransportHint = typeof seamTransportHint;\n\n");
203  out
204}
205
206/// Generate a dependency-free `meta.ts` exporting only DATA_ID.
207pub fn generate_typescript_meta(data_id: &str) -> String {
208  format!("// Auto-generated by seam. Do not edit.\nexport const DATA_ID = \"{data_id}\";\n")
209}
210
211/// Generate a typed TypeScript client from a manifest.
212pub fn generate_typescript(
213  manifest: &Manifest,
214  rpc_hashes: Option<&RpcHashMap>,
215  _data_id: &str,
216) -> Result<String> {
217  let mut out = String::new();
218  out.push_str("// Auto-generated by seam. Do not edit.\n");
219
220  let has_channels = !manifest.channels.is_empty();
221
222  out.push_str("import { createClient } from \"@canmi/seam-client\";\n");
223  out.push_str(
224    "import type { SeamClient, SeamClientError, ProcedureKind, Unsubscribe } from \"@canmi/seam-client\";\n\n",
225  );
226
227  out.push_str("export { DATA_ID } from \"./meta.js\";\n\n");
228
229  let channel_owned = channel_owned_procedures(manifest);
230
231  let mut iface_lines: Vec<String> = Vec::new();
232  let mut factory_lines: Vec<String> = Vec::new();
233
234  for (name, schema) in &manifest.procedures {
235    // Skip channel-owned procedures from standalone generation
236    if channel_owned.contains(name) {
237      continue;
238    }
239
240    let pascal = to_pascal_case(name);
241    let key = quote_key(name);
242    let is_subscription = schema.proc_type == ProcedureType::Subscription;
243
244    let input_name = format!("{pascal}Input");
245    let output_name = format!("{pascal}Output");
246
247    let input_decl = render_top_level(&input_name, &schema.input)?;
248    let output_decl = render_top_level(&output_name, &schema.output)?;
249
250    out.push_str(&input_decl);
251    out.push('\n');
252    out.push_str(&output_decl);
253    out.push('\n');
254
255    if let Some(ref error_schema) = schema.error {
256      let error_name = format!("{pascal}Error");
257      let error_decl = render_top_level(&error_name, error_schema)?;
258      out.push_str(&error_decl);
259      out.push('\n');
260    }
261
262    let wire_name =
263      rpc_hashes.and_then(|m| m.procedures.get(name)).map(|h| h.as_str()).unwrap_or(name.as_str());
264
265    if is_subscription {
266      iface_lines.push(format!(
267        "  {key}(input: {input_name}, onData: (data: {output_name}) => void, onError?: (err: SeamClientError) => void): Unsubscribe;"
268      ));
269      factory_lines.push(format!(
270        "    {key}: (input, onData, onError) => client.subscribe(\"{wire_name}\", input, onData as (data: unknown) => void, onError),"
271      ));
272    } else {
273      let method = match schema.proc_type {
274        ProcedureType::Command => "command",
275        _ => "query",
276      };
277      iface_lines.push(format!("  {key}(input: {input_name}): Promise<{output_name}>;"));
278      factory_lines.push(format!(
279        "    {key}: (input) => client.{method}(\"{wire_name}\", input) as Promise<{output_name}>,"
280      ));
281    }
282  }
283
284  out.push_str("export interface SeamProcedures {\n");
285  for line in &iface_lines {
286    out.push_str(line);
287    out.push('\n');
288  }
289  out.push_str("}\n\n");
290
291  out.push_str(&generate_procedure_meta(manifest));
292
293  // Channel types + transport hint
294  if has_channels {
295    out.push_str(&generate_channel_types(manifest)?);
296    out.push_str(&generate_transport_hint(manifest, rpc_hashes));
297  }
298
299  // createSeamClient factory
300  let return_type = if has_channels {
301    "SeamProcedures & {\n  channel<K extends keyof SeamChannels>(\n    name: K,\n    input: SeamChannels[K][\"input\"],\n  ): SeamChannels[K][\"handle\"];\n}"
302  } else {
303    "SeamProcedures"
304  };
305
306  out.push_str(&format!("export function createSeamClient(baseUrl: string): {return_type} {{\n"));
307
308  // Build createClient options
309  let mut opts_parts = vec![String::from("baseUrl")];
310  if let Some(map) = rpc_hashes {
311    opts_parts.push(format!("batchEndpoint: \"{}\"", map.batch));
312  }
313  if has_channels {
314    let entries: Vec<String> =
315      manifest.channels.keys().map(|name| format!("{}: \"ws\"", quote_key(name))).collect();
316    opts_parts.push(format!("channelTransports: {{ {} }}", entries.join(", ")));
317  }
318  out.push_str(&format!(
319    "  const client: SeamClient = createClient({{ {} }});\n",
320    opts_parts.join(", ")
321  ));
322
323  if has_channels {
324    // Build channel factory function
325    out.push_str("  function channel<K extends keyof SeamChannels>(name: K, input: SeamChannels[K][\"input\"]): SeamChannels[K][\"handle\"] {\n");
326
327    let channel_factory = generate_channel_factory(manifest)?;
328    out.push_str(&channel_factory);
329
330    out.push_str("    throw new Error(`Unknown channel: ${name as string}`);\n");
331    out.push_str("  }\n");
332  }
333
334  out.push_str("  return {\n");
335  for line in &factory_lines {
336    out.push_str(line);
337    out.push('\n');
338  }
339  if has_channels {
340    out.push_str("    channel,\n");
341  }
342  out.push_str("  };\n");
343  out.push_str("}\n");
344
345  Ok(out)
346}
347
348/// Generate the channel factory body (if-branches for each channel).
349fn generate_channel_factory(manifest: &Manifest) -> Result<String> {
350  let mut out = String::new();
351
352  for ch_name in manifest.channels.keys() {
353    out.push_str(&format!("    if (name === \"{ch_name}\") {{\n"));
354    out.push_str(
355      "      return client.channel(name, input) as unknown as SeamChannels[typeof name][\"handle\"];\n",
356    );
357    out.push_str("    }\n");
358  }
359
360  Ok(out)
361}