Skip to main content

roam_codegen/targets/typescript/
mod.rs

1//! TypeScript code generation for roam services.
2//!
3//! This module generates TypeScript client and server code from service definitions.
4//! The generated code includes:
5//! - Type definitions for all named types (structs, enums)
6//! - Client interface and implementation for making RPC calls
7//! - Handler interface for implementing the service
8//! - A Dispatcher class that routes calls to handler methods
9//! - A service descriptor for runtime schema-driven encode/decode
10
11pub mod client;
12pub mod http_client;
13pub mod schema;
14pub mod server;
15pub mod types;
16
17use crate::code_writer::CodeWriter;
18use roam_types::{MethodDescriptor, ServiceDescriptor};
19
20pub use client::generate_client;
21pub use http_client::generate_http_client;
22pub use schema::generate_descriptor;
23pub use server::generate_server;
24pub use types::{collect_named_types, generate_named_types};
25
26/// Generate method IDs as a TypeScript constant record.
27pub fn generate_method_ids(methods: &[&MethodDescriptor]) -> String {
28    use crate::render::{fq_name, hex_u64};
29
30    let mut items = methods
31        .iter()
32        .map(|m| (fq_name(m), m.id.0))
33        .collect::<Vec<_>>();
34    items.sort_by(|a, b| a.0.cmp(&b.0));
35
36    let mut out = String::new();
37    out.push_str("// @generated by roam-codegen\n");
38    out.push_str("// This file defines canonical roam method IDs.\n\n");
39    out.push_str("export const METHOD_ID: Record<string, bigint> = {\n");
40    for (name, id) in items {
41        out.push_str(&format!("  \"{name}\": {}n,\n", hex_u64(id)));
42    }
43    out.push_str("} as const;\n");
44    out
45}
46
47/// Generate a complete TypeScript module for a service.
48///
49/// This is the main entry point for TypeScript code generation.
50pub fn generate_service(service: &ServiceDescriptor) -> String {
51    use crate::code_writer::CodeWriter;
52    use crate::cw_writeln;
53
54    let mut output = String::new();
55    let mut w = CodeWriter::with_indent_spaces(&mut output, 2);
56
57    // Header
58    cw_writeln!(w, "// @generated by roam-codegen").unwrap();
59    cw_writeln!(
60        w,
61        "// DO NOT EDIT - regenerate with `cargo xtask codegen --typescript`"
62    )
63    .unwrap();
64    w.blank_line().unwrap();
65
66    generate_imports(service, &mut w);
67    w.blank_line().unwrap();
68
69    // Named types (structs and enums)
70    let named_types = collect_named_types(service);
71    output.push_str(&generate_named_types(&named_types));
72
73    // Request/Response type aliases
74    output.push_str(&generate_request_response_types(service, &named_types));
75
76    // Client
77    output.push_str(&generate_client(service));
78
79    // Server (handler interface + dispatcher)
80    output.push_str(&generate_server(service));
81
82    // Service descriptor
83    output.push_str(&generate_descriptor(service));
84
85    output
86}
87
88/// Generate imports for the service module.
89fn generate_imports(service: &ServiceDescriptor, w: &mut CodeWriter<&mut String>) {
90    use crate::cw_writeln;
91    use roam_types::{ShapeKind, classify_shape, is_rx, is_tx};
92
93    // Check if any method uses channels in args.
94    let has_streaming = service
95        .methods
96        .iter()
97        .any(|m| m.args.iter().any(|a| is_tx(a.shape) || is_rx(a.shape)));
98
99    // Check if any method returns Result<T, E> (fallible methods)
100    let has_fallible = service
101        .methods
102        .iter()
103        .any(|m| matches!(classify_shape(m.return_shape), ShapeKind::Result { .. }));
104
105    // Core runtime: descriptor types + Caller + CallBuilder + connection helpers
106    cw_writeln!(
107        w,
108        "import type {{ Caller, MethodDescriptor, ServiceDescriptor, RoamCall, ChannelingDispatcher, Schema, SchemaRegistry }} from \"@bearcove/roam-core\";"
109    )
110    .unwrap();
111    cw_writeln!(
112        w,
113        "import {{ CallBuilder, helloExchangeInitiator, defaultHello }} from \"@bearcove/roam-core\";"
114    )
115    .unwrap();
116
117    // WebSocket transport for connect helper
118    cw_writeln!(w, "import {{ connectWs }} from \"@bearcove/roam-ws\";").unwrap();
119
120    // RpcError for fallible client methods
121    if has_fallible {
122        cw_writeln!(w, "import {{ RpcError }} from \"@bearcove/roam-core\";").unwrap();
123    }
124
125    // Tx/Rx and bindChannels for streaming handler args and type aliases
126    if has_streaming {
127        cw_writeln!(
128            w,
129            "import {{ Tx, Rx, bindChannels }} from \"@bearcove/roam-core\";"
130        )
131        .unwrap();
132    }
133}
134
135/// Generate request/response type aliases, skipping any that conflict with named types
136fn generate_request_response_types(
137    service: &ServiceDescriptor,
138    named_types: &[(String, &'static facet_core::Shape)],
139) -> String {
140    use heck::ToUpperCamelCase;
141    use std::collections::HashSet;
142    use types::ts_type;
143
144    // Collect just the type names for conflict checking
145    let type_names: HashSet<&str> = named_types.iter().map(|(name, _)| name.as_str()).collect();
146
147    let mut out = String::new();
148    out.push_str("// Request/Response type aliases\n");
149
150    for method in service.methods {
151        let method_name = method.method_name.to_upper_camel_case();
152        let request_name = format!("{method_name}Request");
153        let response_name = format!("{method_name}Response");
154
155        // Only generate request type alias if it doesn't conflict with a named type
156        if !type_names.contains(request_name.as_str()) {
157            if method.args.is_empty() {
158                out.push_str(&format!("export type {request_name} = [];\n"));
159            } else if method.args.len() == 1 {
160                let ty = ts_type(method.args[0].shape);
161                out.push_str(&format!("export type {request_name} = [{ty}];\n"));
162            } else {
163                out.push_str(&format!("export type {request_name} = [\n"));
164                for arg in method.args {
165                    let ty = ts_type(arg.shape);
166                    out.push_str(&format!("  {ty}, // {}\n", arg.name));
167                }
168                out.push_str("];\n");
169            }
170        }
171
172        // Only generate response type alias if it doesn't conflict with a named type
173        if !type_names.contains(response_name.as_str()) {
174            let ret_ty = ts_type(method.return_shape);
175            out.push_str(&format!("export type {response_name} = {ret_ty};\n"));
176        }
177
178        out.push('\n');
179    }
180
181    out
182}
183
184#[cfg(test)]
185mod tests {
186    #![allow(dead_code)]
187
188    use super::generate_service;
189    use facet::Facet;
190    use roam_hash::method_descriptor;
191    use roam_types::{Rx, ServiceDescriptor, Tx};
192
193    #[derive(Facet)]
194    struct RecursiveNode {
195        next: Option<Box<RecursiveNode>>,
196    }
197
198    #[derive(Facet)]
199    #[repr(transparent)]
200    #[facet(transparent)]
201    struct SessionId(pub String);
202
203    #[derive(Facet)]
204    struct SessionSummary {
205        id: SessionId,
206    }
207
208    #[derive(Facet)]
209    #[repr(u8)]
210    enum ToolCallKind {
211        Read,
212        Execute,
213    }
214
215    #[derive(Facet)]
216    #[repr(u8)]
217    enum ToolCallStatus {
218        Running,
219        Success,
220        Failure,
221    }
222
223    #[derive(Facet)]
224    #[repr(u8)]
225    enum PermissionResolution {
226        Approved,
227        Denied,
228    }
229
230    #[derive(Facet)]
231    #[repr(u8)]
232    enum ContentBlock {
233        Text {
234            text: String,
235        },
236        ToolCall {
237            id: String,
238            title: String,
239            kind: Option<ToolCallKind>,
240            status: ToolCallStatus,
241        },
242        Permission {
243            id: String,
244            title: String,
245            kind: Option<ToolCallKind>,
246            resolution: Option<PermissionResolution>,
247        },
248    }
249
250    #[derive(Facet)]
251    #[repr(u8)]
252    enum BlockPatch {
253        TextAppend {
254            text: String,
255        },
256        ToolCallUpdate {
257            id: String,
258            kind: Option<ToolCallKind>,
259            status: ToolCallStatus,
260        },
261    }
262
263    #[derive(Facet)]
264    #[repr(u8)]
265    enum SessionEvent {
266        BlockAppend {
267            block_id: String,
268            role: String,
269            block: ContentBlock,
270        },
271        BlockPatch {
272            block_id: String,
273            role: String,
274            patch: BlockPatch,
275        },
276    }
277
278    #[derive(Facet)]
279    struct SessionEventEnvelope {
280        seq: u64,
281        event: SessionEvent,
282    }
283
284    #[derive(Facet)]
285    #[repr(u8)]
286    enum SubscribeMessage {
287        Event(SessionEventEnvelope),
288        ReplayComplete,
289    }
290
291    #[test]
292    fn generated_typescript_contains_no_postcard_primitive_usage() {
293        let echo = method_descriptor::<(String,), String>("TestSvc", "echo", &["message"], None);
294        let divide = method_descriptor::<(u64, u64), Result<u64, String>>(
295            "TestSvc",
296            "divide",
297            &["lhs", "rhs"],
298            None,
299        );
300        let methods = Box::leak(vec![echo, divide].into_boxed_slice());
301        let service = ServiceDescriptor {
302            service_name: "TestSvc",
303            methods,
304            doc: None,
305        };
306
307        let generated = generate_service(&service);
308        assert!(
309            !generated.contains("import * as pc from \"@bearcove/roam-postcard\""),
310            "generated TypeScript must not import postcard primitive namespace:\n{generated}"
311        );
312        assert!(
313            !generated.contains("pc."),
314            "generated TypeScript must not call postcard primitives directly:\n{generated}"
315        );
316    }
317
318    #[test]
319    fn generated_typescript_uses_refs_for_recursive_named_types() {
320        let recurse = method_descriptor::<(RecursiveNode,), RecursiveNode>(
321            "RecursiveSvc",
322            "recurse",
323            &["node"],
324            None,
325        );
326        let methods = Box::leak(vec![recurse].into_boxed_slice());
327        let service = ServiceDescriptor {
328            service_name: "RecursiveSvc",
329            methods,
330            doc: None,
331        };
332
333        let generated = generate_service(&service);
334        assert!(
335            generated.contains("schema_registry"),
336            "generated TypeScript must include a schema registry:\n{generated}"
337        );
338        assert!(
339            generated.contains("{ kind: 'ref', name: 'RecursiveNode' }"),
340            "recursive references must emit ref schemas:\n{generated}"
341        );
342    }
343
344    #[test]
345    fn generated_typescript_emits_alias_for_transparent_newtype() {
346        let summarize = method_descriptor::<(SessionId,), SessionSummary>(
347            "SessionSvc",
348            "summarize",
349            &["id"],
350            None,
351        );
352        let methods = Box::leak(vec![summarize].into_boxed_slice());
353        let service = ServiceDescriptor {
354            service_name: "SessionSvc",
355            methods,
356            doc: None,
357        };
358
359        let generated = generate_service(&service);
360        assert!(
361            generated.contains("export type SessionId = string;"),
362            "transparent named newtypes must emit a type alias:\n{generated}"
363        );
364        assert!(
365            generated.contains("id: SessionId;"),
366            "uses of transparent named newtypes must keep alias name:\n{generated}"
367        );
368    }
369
370    #[test]
371    fn generated_typescript_preserves_channel_initial_credit() {
372        let subscribe = method_descriptor::<(Tx<u32>, Rx<u32, 32>), ()>(
373            "StreamSvc",
374            "subscribe",
375            &["output", "input"],
376            None,
377        );
378        let methods = Box::leak(vec![subscribe].into_boxed_slice());
379        let service = ServiceDescriptor {
380            service_name: "StreamSvc",
381            methods,
382            doc: None,
383        };
384
385        let generated = generate_service(&service);
386        assert!(
387            generated.contains("{ kind: 'tx', initial_credit: 16, element: { kind: 'u32' } }"),
388            "default Tx<T> credit must be emitted into the descriptor:\n{generated}"
389        );
390        assert!(
391            generated.contains("{ kind: 'rx', initial_credit: 32, element: { kind: 'u32' } }"),
392            "explicit Rx<T, N> credit must be emitted into the descriptor:\n{generated}"
393        );
394    }
395
396    #[test]
397    fn generated_typescript_keeps_struct_variants_with_kind_fields_named() {
398        let subscribe =
399            method_descriptor::<(), SubscribeMessage>("ShipSvc", "subscribe", &[], None);
400        let methods = Box::leak(vec![subscribe].into_boxed_slice());
401        let service = ServiceDescriptor {
402            service_name: "ShipSvc",
403            methods,
404            doc: None,
405        };
406
407        let generated = generate_service(&service);
408        assert!(
409            generated.contains(
410                "name: 'ToolCall', fields: { 'id': { kind: 'string' }, 'title': { kind: 'string' }, 'kind': { kind: 'option', inner: { kind: 'ref', name: 'ToolCallKind' } }, 'status': { kind: 'ref', name: 'ToolCallStatus' } }"
411            ),
412            "struct variants with a field named `kind` must stay named-field variants:\n{generated}"
413        );
414        assert!(
415            generated.contains(
416                "name: 'Permission', fields: { 'id': { kind: 'string' }, 'title': { kind: 'string' }, 'kind': { kind: 'option', inner: { kind: 'ref', name: 'ToolCallKind' } }, 'resolution': { kind: 'option', inner: { kind: 'ref', name: 'PermissionResolution' } } }"
417            ),
418            "similar struct variants must keep their named `kind` field:\n{generated}"
419        );
420        assert!(
421            generated.contains(
422                "name: 'ToolCallUpdate', fields: { 'id': { kind: 'string' }, 'kind': { kind: 'option', inner: { kind: 'ref', name: 'ToolCallKind' } }, 'status': { kind: 'ref', name: 'ToolCallStatus' } }"
423            ),
424            "patch variants with a field named `kind` must also stay named-field variants:\n{generated}"
425        );
426    }
427}