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    use super::generate_service;
187    use facet::Facet;
188    use roam_hash::method_descriptor;
189    use roam_types::ServiceDescriptor;
190
191    #[derive(Facet)]
192    struct RecursiveNode {
193        next: Option<Box<RecursiveNode>>,
194    }
195
196    #[derive(Facet)]
197    #[repr(transparent)]
198    #[facet(transparent)]
199    struct SessionId(pub String);
200
201    #[derive(Facet)]
202    struct SessionSummary {
203        id: SessionId,
204    }
205
206    #[test]
207    fn generated_typescript_contains_no_postcard_primitive_usage() {
208        let echo = method_descriptor::<(String,), String>("TestSvc", "echo", &["message"], None);
209        let divide = method_descriptor::<(u64, u64), Result<u64, String>>(
210            "TestSvc",
211            "divide",
212            &["lhs", "rhs"],
213            None,
214        );
215        let methods = Box::leak(vec![echo, divide].into_boxed_slice());
216        let service = ServiceDescriptor {
217            service_name: "TestSvc",
218            methods,
219            doc: None,
220        };
221
222        let generated = generate_service(&service);
223        assert!(
224            !generated.contains("import * as pc from \"@bearcove/roam-postcard\""),
225            "generated TypeScript must not import postcard primitive namespace:\n{generated}"
226        );
227        assert!(
228            !generated.contains("pc."),
229            "generated TypeScript must not call postcard primitives directly:\n{generated}"
230        );
231    }
232
233    #[test]
234    fn generated_typescript_uses_refs_for_recursive_named_types() {
235        let recurse = method_descriptor::<(RecursiveNode,), RecursiveNode>(
236            "RecursiveSvc",
237            "recurse",
238            &["node"],
239            None,
240        );
241        let methods = Box::leak(vec![recurse].into_boxed_slice());
242        let service = ServiceDescriptor {
243            service_name: "RecursiveSvc",
244            methods,
245            doc: None,
246        };
247
248        let generated = generate_service(&service);
249        assert!(
250            generated.contains("schema_registry"),
251            "generated TypeScript must include a schema registry:\n{generated}"
252        );
253        assert!(
254            generated.contains("{ kind: 'ref', name: 'RecursiveNode' }"),
255            "recursive references must emit ref schemas:\n{generated}"
256        );
257    }
258
259    #[test]
260    fn generated_typescript_emits_alias_for_transparent_newtype() {
261        let summarize = method_descriptor::<(SessionId,), SessionSummary>(
262            "SessionSvc",
263            "summarize",
264            &["id"],
265            None,
266        );
267        let methods = Box::leak(vec![summarize].into_boxed_slice());
268        let service = ServiceDescriptor {
269            service_name: "SessionSvc",
270            methods,
271            doc: None,
272        };
273
274        let generated = generate_service(&service);
275        assert!(
276            generated.contains("export type SessionId = string;"),
277            "transparent named newtypes must emit a type alias:\n{generated}"
278        );
279        assert!(
280            generated.contains("id: SessionId;"),
281            "uses of transparent named newtypes must keep alias name:\n{generated}"
282        );
283    }
284}