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 }} 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 roam_hash::method_descriptor;
188    use roam_types::ServiceDescriptor;
189
190    #[test]
191    fn generated_typescript_contains_no_postcard_primitive_usage() {
192        let echo = method_descriptor::<(String,), String>("TestSvc", "echo", &["message"], None);
193        let divide = method_descriptor::<(u64, u64), Result<u64, String>>(
194            "TestSvc",
195            "divide",
196            &["lhs", "rhs"],
197            None,
198        );
199        let methods = Box::leak(vec![echo, divide].into_boxed_slice());
200        let service = ServiceDescriptor {
201            service_name: "TestSvc",
202            methods,
203            doc: None,
204        };
205
206        let generated = generate_service(&service);
207        assert!(
208            !generated.contains("import * as pc from \"@bearcove/roam-postcard\""),
209            "generated TypeScript must not import postcard primitive namespace:\n{generated}"
210        );
211        assert!(
212            !generated.contains("pc."),
213            "generated TypeScript must not call postcard primitives directly:\n{generated}"
214        );
215    }
216}