1pub 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 #![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}