seam_codegen/typescript/
generator.rs1use 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
12fn quote_key(name: &str) -> String {
14 if name.contains('.') { format!("\"{name}\"") } else { name.to_string() }
15}
16
17fn 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
29fn 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 let input_type = format!("{ch_pascal}ChannelInput");
43 out.push_str(&render_top_level(&input_type, &ch.input)?);
44 out.push('\n');
45
46 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 out.push_str(&generate_channel_outgoing(ch, &ch_pascal)?);
70
71 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 channel_entries.push(format!(" {ch_name}: {{ input: {input_type}; handle: {handle_type} }};"));
87 }
88
89 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
100fn 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
126fn generate_procedure_meta(manifest: &Manifest) -> String {
128 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 let ch_name = name.strip_suffix(".events").expect("channel event name has .events suffix");
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
165fn 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(String::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(String::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
206pub 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
211pub 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 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(String::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 if has_channels {
295 out.push_str(&generate_channel_types(manifest)?);
296 out.push_str(&generate_transport_hint(manifest, rpc_hashes));
297 }
298
299 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 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 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
348fn generate_channel_factory(manifest: &Manifest) -> 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 out
361}