roam_codegen/targets/typescript/
mod.rs1pub 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
26pub 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
47pub 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 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 let named_types = collect_named_types(service);
71 output.push_str(&generate_named_types(&named_types));
72
73 output.push_str(&generate_request_response_types(service, &named_types));
75
76 output.push_str(&generate_client(service));
78
79 output.push_str(&generate_server(service));
81
82 output.push_str(&generate_descriptor(service));
84
85 output
86}
87
88fn 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 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 let has_fallible = service
101 .methods
102 .iter()
103 .any(|m| matches!(classify_shape(m.return_shape), ShapeKind::Result { .. }));
104
105 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 cw_writeln!(w, "import {{ connectWs }} from \"@bearcove/roam-ws\";").unwrap();
119
120 if has_fallible {
122 cw_writeln!(w, "import {{ RpcError }} from \"@bearcove/roam-core\";").unwrap();
123 }
124
125 if has_streaming {
127 cw_writeln!(
128 w,
129 "import {{ Tx, Rx, bindChannels }} from \"@bearcove/roam-core\";"
130 )
131 .unwrap();
132 }
133}
134
135fn 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 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 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 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}