1pub mod client;
12pub mod http_client;
13pub mod schema;
14pub mod server;
15pub mod types;
16pub mod wire;
17
18use crate::code_writer::CodeWriter;
19use vox_types::{MethodDescriptor, ServiceDescriptor};
20
21pub use client::generate_client;
22pub use http_client::generate_http_client;
23pub use schema::generate_descriptor;
24pub use schema::generate_send_schema_table;
25pub use server::generate_server;
26pub use types::{collect_named_types, generate_named_types};
27
28pub fn generate_method_ids(methods: &[&MethodDescriptor]) -> String {
30 use crate::render::{fq_name, hex_u64};
31
32 let mut items = methods
33 .iter()
34 .map(|m| (fq_name(m), m.id.0))
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 vox-codegen\n");
40 out.push_str("// This file defines canonical vox 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
49pub fn generate_service(service: &ServiceDescriptor) -> String {
53 use crate::code_writer::CodeWriter;
54 use crate::cw_writeln;
55
56 let mut output = String::new();
57 let mut w = CodeWriter::with_indent_spaces(&mut output, 2);
58
59 cw_writeln!(w, "// @generated by vox-codegen").unwrap();
61 cw_writeln!(
62 w,
63 "// DO NOT EDIT - regenerate with `cargo xtask codegen --typescript`"
64 )
65 .unwrap();
66 w.blank_line().unwrap();
67
68 generate_imports(service, &mut w);
69 w.blank_line().unwrap();
70
71 let named_types = collect_named_types(service);
73 output.push_str(&generate_named_types(&named_types));
74
75 output.push_str(&generate_request_response_types(service, &named_types));
77
78 output.push_str(&generate_client(service));
80
81 output.push_str(&generate_server(service));
83
84 output.push_str(&generate_send_schema_table(service));
86
87 output.push_str(&generate_descriptor(service));
89
90 output
91}
92
93fn generate_imports(service: &ServiceDescriptor, w: &mut CodeWriter<&mut String>) {
95 use crate::cw_writeln;
96 use vox_types::{ShapeKind, classify_shape, is_rx, is_tx};
97
98 let has_streaming = service
100 .methods
101 .iter()
102 .any(|m| m.args.iter().any(|a| is_tx(a.shape) || is_rx(a.shape)));
103
104 let has_fallible = service
106 .methods
107 .iter()
108 .any(|m| matches!(classify_shape(m.return_shape), ShapeKind::Result { .. }));
109
110 cw_writeln!(
112 w,
113 "import type {{ Caller, MethodDescriptor, ServiceDescriptor, VoxCall, Dispatcher, RequestContext, SessionTransportOptions }} from \"@bearcove/vox-core\";"
114 )
115 .unwrap();
116 cw_writeln!(
117 w,
118 "import {{ session, voxServiceMetadata }} from \"@bearcove/vox-core\";"
119 )
120 .unwrap();
121
122 cw_writeln!(w, "import {{ wsConnector }} from \"@bearcove/vox-ws\";").unwrap();
124
125 if has_fallible {
127 cw_writeln!(w, "import {{ RpcError }} from \"@bearcove/vox-core\";").unwrap();
128 }
129
130 if has_streaming {
132 cw_writeln!(
133 w,
134 "import {{ Tx, Rx, argElementRefsForMethod, bindChannelsForTypeRefs, finalizeBoundChannelsForTypeRefs }} from \"@bearcove/vox-core\";"
135 )
136 .unwrap();
137 }
138}
139
140fn generate_request_response_types(
142 service: &ServiceDescriptor,
143 named_types: &[(String, &'static facet_core::Shape)],
144) -> String {
145 use heck::ToUpperCamelCase;
146 use std::collections::HashSet;
147 use types::ts_type;
148
149 let type_names: HashSet<&str> = named_types.iter().map(|(name, _)| name.as_str()).collect();
151
152 let mut out = String::new();
153 out.push_str("// Request/Response type aliases\n");
154
155 for method in service.methods {
156 let method_name = method.method_name.to_upper_camel_case();
157 let request_name = format!("{method_name}Request");
158 let response_name = format!("{method_name}Response");
159
160 if !type_names.contains(request_name.as_str()) {
162 if method.args.is_empty() {
163 out.push_str(&format!("export type {request_name} = [];\n"));
164 } else if method.args.len() == 1 {
165 let ty = ts_type(method.args[0].shape);
166 out.push_str(&format!("export type {request_name} = [{ty}];\n"));
167 } else {
168 out.push_str(&format!("export type {request_name} = [\n"));
169 for arg in method.args {
170 let ty = ts_type(arg.shape);
171 out.push_str(&format!(" {ty}, // {}\n", arg.name));
172 }
173 out.push_str("];\n");
174 }
175 }
176
177 if !type_names.contains(response_name.as_str()) {
179 let ret_ty = ts_type(method.return_shape);
180 out.push_str(&format!("export type {response_name} = {ret_ty};\n"));
181 }
182
183 out.push('\n');
184 }
185
186 out
187}
188
189#[cfg(test)]
190mod tests {
191 #![allow(dead_code)]
192
193 use super::generate_service;
194 use facet::Facet;
195 use vox_types::{
196 RetryPolicy, Rx, ServiceDescriptor, Tx, method_descriptor, method_descriptor_with_retry,
197 };
198
199 #[derive(Facet)]
200 struct RecursiveNode {
201 next: Option<Box<RecursiveNode>>,
202 }
203
204 #[derive(Facet)]
205 #[repr(transparent)]
206 #[facet(transparent)]
207 struct SessionId(pub String);
208
209 #[derive(Facet)]
210 struct SessionSummary {
211 id: SessionId,
212 }
213
214 #[derive(Facet)]
215 #[repr(u8)]
216 enum ToolCallKind {
217 Read,
218 Execute,
219 }
220
221 #[derive(Facet)]
222 #[repr(u8)]
223 enum ToolCallStatus {
224 Running,
225 Success,
226 Failure,
227 }
228
229 #[derive(Facet)]
230 #[repr(u8)]
231 enum PermissionResolution {
232 Approved,
233 Denied,
234 }
235
236 #[derive(Facet)]
237 #[repr(u8)]
238 enum ContentBlock {
239 Text {
240 text: String,
241 },
242 ToolCall {
243 id: String,
244 title: String,
245 kind: Option<ToolCallKind>,
246 status: ToolCallStatus,
247 },
248 Permission {
249 id: String,
250 title: String,
251 kind: Option<ToolCallKind>,
252 resolution: Option<PermissionResolution>,
253 },
254 }
255
256 #[derive(Facet)]
257 #[repr(u8)]
258 enum BlockPatch {
259 TextAppend {
260 text: String,
261 },
262 ToolCallUpdate {
263 id: String,
264 kind: Option<ToolCallKind>,
265 status: ToolCallStatus,
266 },
267 }
268
269 #[derive(Facet)]
270 #[repr(u8)]
271 enum SessionEvent {
272 BlockAppend {
273 block_id: String,
274 role: String,
275 block: ContentBlock,
276 },
277 BlockPatch {
278 block_id: String,
279 role: String,
280 patch: BlockPatch,
281 },
282 }
283
284 #[derive(Facet)]
285 struct SessionEventEnvelope {
286 seq: u64,
287 event: SessionEvent,
288 }
289
290 #[derive(Facet)]
291 #[repr(u8)]
292 enum SubscribeMessage {
293 Event(SessionEventEnvelope),
294 ReplayComplete,
295 }
296
297 #[test]
298 fn generated_typescript_contains_no_postcard_primitive_usage() {
299 let echo = method_descriptor::<(String,), String>("TestSvc", "echo", &["message"], None);
300 let divide = method_descriptor::<(u64, u64), Result<u64, String>>(
301 "TestSvc",
302 "divide",
303 &["lhs", "rhs"],
304 None,
305 );
306 let methods = Box::leak(vec![echo, divide].into_boxed_slice());
307 let service = ServiceDescriptor {
308 service_name: "TestSvc",
309 methods,
310 doc: None,
311 };
312
313 let generated = generate_service(&service);
314 assert!(
315 !generated.contains("import * as pc from \"@bearcove/vox-postcard\""),
316 "generated TypeScript must not import postcard primitive namespace:\n{generated}"
317 );
318 assert!(
319 !generated.contains("pc."),
320 "generated TypeScript must not call postcard primitives directly:\n{generated}"
321 );
322 }
323
324 #[test]
325 fn generated_typescript_uses_canonical_service_schemas() {
326 let recurse = method_descriptor::<(RecursiveNode,), RecursiveNode>(
327 "RecursiveSvc",
328 "recurse",
329 &["node"],
330 None,
331 );
332 let methods = Box::leak(vec![recurse].into_boxed_slice());
333 let service = ServiceDescriptor {
334 service_name: "RecursiveSvc",
335 methods,
336 doc: None,
337 };
338
339 let generated = generate_service(&service);
340 assert!(
341 generated.contains("send_schemas"),
342 "generated TypeScript must include canonical service schemas:\n{generated}"
343 );
344 assert!(
345 !generated.contains("schema_registry"),
346 "generated TypeScript must not include the legacy schema registry:\n{generated}"
347 );
348 }
349
350 #[test]
351 fn generated_typescript_emits_alias_for_transparent_newtype() {
352 let summarize = method_descriptor::<(SessionId,), SessionSummary>(
353 "SessionSvc",
354 "summarize",
355 &["id"],
356 None,
357 );
358 let methods = Box::leak(vec![summarize].into_boxed_slice());
359 let service = ServiceDescriptor {
360 service_name: "SessionSvc",
361 methods,
362 doc: None,
363 };
364
365 let generated = generate_service(&service);
366 assert!(
367 generated.contains("export type SessionId = string;"),
368 "transparent named newtypes must emit a type alias:\n{generated}"
369 );
370 assert!(
371 generated.contains("id: SessionId;"),
372 "uses of transparent named newtypes must keep alias name:\n{generated}"
373 );
374 }
375
376 #[test]
377 fn generated_typescript_emits_channel_schemas() {
378 let subscribe = method_descriptor::<(Tx<u32>, Rx<u32>), ()>(
379 "StreamSvc",
380 "subscribe",
381 &["output", "input"],
382 None,
383 );
384 let methods = Box::leak(vec![subscribe].into_boxed_slice());
385 let service = ServiceDescriptor {
386 service_name: "StreamSvc",
387 methods,
388 doc: None,
389 };
390
391 let generated = generate_service(&service);
392 assert!(
393 generated.contains("kind: { tag: 'channel', direction: 'tx'"),
394 "Tx<T> must be emitted into canonical service schemas:\n{generated}"
395 );
396 assert!(
397 generated.contains("kind: { tag: 'channel', direction: 'rx'"),
398 "Rx<T> must be emitted into canonical service schemas:\n{generated}"
399 );
400 }
401
402 #[test]
403 fn generated_typescript_emits_retry_policy_on_method_descriptors() {
404 let fetch = method_descriptor_with_retry::<(), u64>(
405 "RetrySvc",
406 "fetch",
407 &[],
408 None,
409 RetryPolicy::IDEM,
410 );
411 let effect = method_descriptor_with_retry::<(), Result<u64, String>>(
412 "RetrySvc",
413 "effect",
414 &[],
415 None,
416 RetryPolicy::PERSIST,
417 );
418 let methods = Box::leak(vec![fetch, effect].into_boxed_slice());
419 let service = ServiceDescriptor {
420 service_name: "RetrySvc",
421 methods,
422 doc: None,
423 };
424
425 let generated = generate_service(&service);
426 assert!(
427 generated.contains("retry: { persist: false, idem: true }"),
428 "generated TypeScript must include retry policy for idem methods:\n{generated}"
429 );
430 assert!(
431 generated.contains("retry: { persist: true, idem: false }"),
432 "generated TypeScript must include retry policy for persist methods:\n{generated}"
433 );
434 }
435
436 #[test]
437 fn generated_typescript_keeps_struct_variants_with_kind_fields_named() {
438 let subscribe =
439 method_descriptor::<(), SubscribeMessage>("ShipSvc", "subscribe", &[], None);
440 let methods = Box::leak(vec![subscribe].into_boxed_slice());
441 let service = ServiceDescriptor {
442 service_name: "ShipSvc",
443 methods,
444 doc: None,
445 };
446
447 let generated = generate_service(&service);
448 assert!(
449 generated.contains(
450 "name: 'ToolCall', index: 1, payload: { tag: 'struct', fields: [{ name: 'id'"
451 ),
452 "struct variants with a field named `kind` must stay struct variants in canonical schemas:\n{generated}"
453 );
454 assert!(
455 generated.contains(
456 "name: 'Permission', index: 2, payload: { tag: 'struct', fields: [{ name: 'id'"
457 ),
458 "similar struct variants must keep their named `kind` field in canonical schemas:\n{generated}"
459 );
460 assert!(
461 generated.contains(
462 "name: 'ToolCallUpdate', index: 1, payload: { tag: 'struct', fields: [{ name: 'id'"
463 ),
464 "patch variants with a field named `kind` must also stay struct variants in canonical schemas:\n{generated}"
465 );
466 assert!(
467 generated.contains("{ name: 'kind', type_ref:"),
468 "canonical struct variants must preserve the literal field name `kind`:\n{generated}"
469 );
470 }
471
472 #[test]
473 fn generated_typescript_avoids_parameter_properties_and_types_catch_error() {
474 let divide = method_descriptor::<(u64, u64), Result<u64, String>>(
475 "StrictSvc",
476 "divide",
477 &["lhs", "rhs"],
478 None,
479 );
480 let methods = Box::leak(vec![divide].into_boxed_slice());
481 let service = ServiceDescriptor {
482 service_name: "StrictSvc",
483 methods,
484 doc: None,
485 };
486
487 let generated = generate_service(&service);
488 assert!(
489 !generated.contains("constructor(private readonly handler"),
490 "generated TypeScript must avoid constructor parameter properties:\n{generated}"
491 );
492 assert!(
493 generated.contains("private readonly handler: StrictSvcHandler;"),
494 "dispatcher must emit an explicit handler field:\n{generated}"
495 );
496 assert!(
497 generated.contains("constructor(handler: StrictSvcHandler)"),
498 "dispatcher constructor must use explicit assignment parameter:\n{generated}"
499 );
500 assert!(
501 generated.contains("catch (e: any)"),
502 "fallible client methods must type catch binding for strict TypeScript:\n{generated}"
503 );
504 }
505}