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//! - Server handler interface for implementing services
8//! - Encoding/decoding logic for all types
9//! - Runtime schema information for streaming channel binding
10
11pub mod client;
12pub mod decode;
13pub mod encode;
14pub mod http_client;
15pub mod schema;
16pub mod server;
17pub mod types;
18
19use crate::code_writer::CodeWriter;
20use roam_schema::{MethodDetail, ServiceDetail};
21
22pub use client::generate_client;
23pub use http_client::generate_http_client;
24pub use schema::generate_schemas;
25pub use server::generate_server;
26pub use types::{collect_named_types, generate_named_types};
27
28/// Generate method IDs as a TypeScript constant record.
29pub fn generate_method_ids(methods: &[MethodDetail]) -> String {
30    use crate::render::{fq_name, hex_u64};
31
32    let mut items = methods
33        .iter()
34        .map(|m| (fq_name(m), crate::method_id(m)))
35        .collect::<Vec<_>>();
36    items.sort_by(|a, b| a.0.cmp(&b.0));
37
38    let mut out = String::new();
39    out.push_str("// @generated by roam-codegen\n");
40    out.push_str("// This file defines canonical roam method IDs.\n\n");
41    out.push_str("export const METHOD_ID: Record<string, bigint> = {\n");
42    for (name, id) in items {
43        out.push_str(&format!("  \"{name}\": {}n,\n", hex_u64(id)));
44    }
45    out.push_str("} as const;\n");
46    out
47}
48
49/// Generate a complete TypeScript module for a service.
50///
51/// This is the main entry point for TypeScript code generation.
52pub fn generate_service(service: &ServiceDetail) -> String {
53    use crate::code_writer::CodeWriter;
54    use crate::{cw_writeln, render::hex_u64};
55    use heck::ToLowerCamelCase;
56
57    let mut output = String::new();
58    let mut w = CodeWriter::with_indent_spaces(&mut output, 2);
59
60    // Header
61    cw_writeln!(w, "// @generated by roam-codegen").unwrap();
62    cw_writeln!(
63        w,
64        "// DO NOT EDIT - regenerate with `cargo xtask codegen --typescript`"
65    )
66    .unwrap();
67    w.blank_line().unwrap();
68
69    // TODO: This import list should probably be in roam-core or generated more intelligently
70    generate_imports(service, &mut w);
71    w.blank_line().unwrap();
72
73    // Method IDs
74    cw_writeln!(w, "export const METHOD_ID = {{").unwrap();
75    {
76        let _indent = w.indent();
77        for method in &service.methods {
78            let id = crate::method_id(method);
79            let method_name = method.method_name.to_lower_camel_case();
80            cw_writeln!(w, "{method_name}: {}n,", hex_u64(id)).unwrap();
81        }
82    }
83    cw_writeln!(w, "}} as const;").unwrap();
84    w.blank_line().unwrap();
85
86    // Named types (structs and enums)
87    let named_types = collect_named_types(service);
88    output.push_str(&generate_named_types(&named_types));
89
90    // Type aliases for request/response (only if they don't conflict with named types)
91    output.push_str(&generate_request_response_types(service, &named_types));
92
93    // Client
94    output.push_str(&generate_client(service));
95
96    // Server
97    output.push_str(&generate_server(service));
98
99    // Schemas
100    output.push_str(&generate_schemas(service));
101
102    output
103}
104
105/// Generate imports from @bearcove/roam-core
106fn generate_imports(service: &ServiceDetail, w: &mut CodeWriter<&mut String>) {
107    use crate::cw_writeln;
108    use roam_schema::{ShapeKind, classify_shape, is_rx, is_tx};
109
110    // Check if any method uses streaming
111    let has_streaming = service.methods.iter().any(|m| {
112        m.args.iter().any(|a| is_tx(a.ty) || is_rx(a.ty))
113            || is_tx(m.return_type)
114            || is_rx(m.return_type)
115    });
116
117    // Check if any method returns Result<T, E> (fallible methods)
118    let has_fallible = service
119        .methods
120        .iter()
121        .any(|m| matches!(classify_shape(m.return_type), ShapeKind::Result { .. }));
122
123    // Type imports
124    cw_writeln!(w, "import type {{ MethodHandler, Connection, MessageTransport, DecodeResult, MethodSchema }} from \"@bearcove/roam-core\";").unwrap();
125
126    // Runtime function imports
127    cw_writeln!(w, "import {{").unwrap();
128    {
129        let _indent = w.indent();
130        cw_writeln!(w, "encodeResultOk, encodeResultErr, encodeInvalidPayload,").unwrap();
131        cw_writeln!(
132            w,
133            "concat, encodeVarint, decodeVarintNumber, decodeRpcResult,"
134        )
135        .unwrap();
136        cw_writeln!(w, "encodeWithSchema, decodeWithSchema,").unwrap();
137        cw_writeln!(w, "encodeBool, decodeBool,").unwrap();
138        cw_writeln!(w, "encodeU8, decodeU8, encodeI8, decodeI8,").unwrap();
139        cw_writeln!(w, "encodeU16, decodeU16, encodeI16, decodeI16,").unwrap();
140        cw_writeln!(w, "encodeU32, decodeU32, encodeI32, decodeI32,").unwrap();
141        cw_writeln!(w, "encodeU64, decodeU64, encodeI64, decodeI64,").unwrap();
142        cw_writeln!(w, "encodeF32, decodeF32, encodeF64, decodeF64,").unwrap();
143        cw_writeln!(w, "encodeString, decodeString,").unwrap();
144        cw_writeln!(w, "encodeBytes, decodeBytes,").unwrap();
145        cw_writeln!(w, "encodeOption, decodeOption,").unwrap();
146        cw_writeln!(w, "encodeVec, decodeVec,").unwrap();
147        cw_writeln!(w, "encodeTuple2, decodeTuple2, encodeTuple3, decodeTuple3,").unwrap();
148        cw_writeln!(w, "encodeEnumVariant, decodeEnumVariant,").unwrap();
149    }
150    cw_writeln!(w, "  helloExchangeInitiator, defaultHello,").unwrap();
151    cw_writeln!(w, "}} from \"@bearcove/roam-core\";").unwrap();
152
153    // WebSocket transport for connect helper
154    cw_writeln!(
155        w,
156        "import {{ connectWs, type WsTransport }} from \"@bearcove/roam-ws\";"
157    )
158    .unwrap();
159
160    // RpcError for fallible methods (methods returning Result<T, E>)
161    if has_fallible {
162        cw_writeln!(w, "import {{ RpcError }} from \"@bearcove/roam-core\";").unwrap();
163    }
164
165    if has_streaming {
166        cw_writeln!(
167            w,
168            "import {{ Tx, Rx, createServerTx, createServerRx, bindChannels }} from \"@bearcove/roam-core\";"
169        )
170        .unwrap();
171        cw_writeln!(
172            w,
173            "import type {{ ChannelId, ChannelRegistry, TaskSender, BindingSerializers, Schema }} from \"@bearcove/roam-core\";"
174        )
175        .unwrap();
176    }
177}
178
179/// Generate request/response type aliases, skipping any that conflict with named types
180fn generate_request_response_types(
181    service: &ServiceDetail,
182    named_types: &[(String, &'static facet_core::Shape)],
183) -> String {
184    use heck::ToUpperCamelCase;
185    use std::collections::HashSet;
186    use types::ts_type;
187
188    // Collect just the type names for conflict checking
189    let type_names: HashSet<&str> = named_types.iter().map(|(name, _)| name.as_str()).collect();
190
191    let mut out = String::new();
192    out.push_str("// Request/Response type aliases\n");
193
194    for method in &service.methods {
195        let method_name = method.method_name.to_upper_camel_case();
196        let request_name = format!("{method_name}Request");
197        let response_name = format!("{method_name}Response");
198
199        // Only generate request type alias if it doesn't conflict with a named type
200        if !type_names.contains(request_name.as_str()) {
201            if method.args.is_empty() {
202                out.push_str(&format!("export type {request_name} = [];\n"));
203            } else if method.args.len() == 1 {
204                let ty = ts_type(method.args[0].ty);
205                out.push_str(&format!("export type {request_name} = [{ty}];\n"));
206            } else {
207                out.push_str(&format!("export type {request_name} = [\n"));
208                for arg in &method.args {
209                    let ty = ts_type(arg.ty);
210                    out.push_str(&format!("  {ty}, // {}\n", arg.name));
211                }
212                out.push_str("];\n");
213            }
214        }
215
216        // Only generate response type alias if it doesn't conflict with a named type
217        if !type_names.contains(response_name.as_str()) {
218            let ret_ty = ts_type(method.return_type);
219            out.push_str(&format!("export type {response_name} = {ret_ty};\n"));
220        }
221
222        out.push('\n');
223    }
224
225    out
226}