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!(w, "import {{ session }} from \"@bearcove/vox-core\";").unwrap();
117
118 cw_writeln!(w, "import {{ wsConnector }} from \"@bearcove/vox-ws\";").unwrap();
120
121 if has_fallible {
123 cw_writeln!(w, "import {{ RpcError }} from \"@bearcove/vox-core\";").unwrap();
124 }
125
126 if has_streaming {
128 cw_writeln!(
129 w,
130 "import {{ Tx, Rx, argElementRefsForMethod, bindChannelsForTypeRefs, finalizeBoundChannelsForTypeRefs }} from \"@bearcove/vox-core\";"
131 )
132 .unwrap();
133 }
134}
135
136fn generate_request_response_types(
138 service: &ServiceDescriptor,
139 named_types: &[(String, &'static facet_core::Shape)],
140) -> String {
141 use heck::ToUpperCamelCase;
142 use std::collections::HashSet;
143 use types::ts_type;
144
145 let type_names: HashSet<&str> = named_types.iter().map(|(name, _)| name.as_str()).collect();
147
148 let mut out = String::new();
149 out.push_str("// Request/Response type aliases\n");
150
151 for method in service.methods {
152 let method_name = method.method_name.to_upper_camel_case();
153 let request_name = format!("{method_name}Request");
154 let response_name = format!("{method_name}Response");
155
156 if !type_names.contains(request_name.as_str()) {
158 if method.args.is_empty() {
159 out.push_str(&format!("export type {request_name} = [];\n"));
160 } else if method.args.len() == 1 {
161 let ty = ts_type(method.args[0].shape);
162 out.push_str(&format!("export type {request_name} = [{ty}];\n"));
163 } else {
164 out.push_str(&format!("export type {request_name} = [\n"));
165 for arg in method.args {
166 let ty = ts_type(arg.shape);
167 out.push_str(&format!(" {ty}, // {}\n", arg.name));
168 }
169 out.push_str("];\n");
170 }
171 }
172
173 if !type_names.contains(response_name.as_str()) {
175 let ret_ty = ts_type(method.return_shape);
176 out.push_str(&format!("export type {response_name} = {ret_ty};\n"));
177 }
178
179 out.push('\n');
180 }
181
182 out
183}
184
185#[cfg(test)]
186mod tests {
187 #![allow(dead_code)]
188
189 use super::generate_service;
190 use facet::Facet;
191 use vox_types::{
192 RetryPolicy, Rx, ServiceDescriptor, Tx, method_descriptor, method_descriptor_with_retry,
193 };
194
195 #[derive(Facet)]
196 struct RecursiveNode {
197 next: Option<Box<RecursiveNode>>,
198 }
199
200 #[derive(Facet)]
201 #[repr(transparent)]
202 #[facet(transparent)]
203 struct SessionId(pub String);
204
205 #[derive(Facet)]
206 struct SessionSummary {
207 id: SessionId,
208 }
209
210 #[derive(Facet)]
211 #[repr(u8)]
212 enum ToolCallKind {
213 Read,
214 Execute,
215 }
216
217 #[derive(Facet)]
218 #[repr(u8)]
219 enum ToolCallStatus {
220 Running,
221 Success,
222 Failure,
223 }
224
225 #[derive(Facet)]
226 #[repr(u8)]
227 enum PermissionResolution {
228 Approved,
229 Denied,
230 }
231
232 #[derive(Facet)]
233 #[repr(u8)]
234 enum ContentBlock {
235 Text {
236 text: String,
237 },
238 ToolCall {
239 id: String,
240 title: String,
241 kind: Option<ToolCallKind>,
242 status: ToolCallStatus,
243 },
244 Permission {
245 id: String,
246 title: String,
247 kind: Option<ToolCallKind>,
248 resolution: Option<PermissionResolution>,
249 },
250 }
251
252 #[derive(Facet)]
253 #[repr(u8)]
254 enum BlockPatch {
255 TextAppend {
256 text: String,
257 },
258 ToolCallUpdate {
259 id: String,
260 kind: Option<ToolCallKind>,
261 status: ToolCallStatus,
262 },
263 }
264
265 #[derive(Facet)]
266 #[repr(u8)]
267 enum SessionEvent {
268 BlockAppend {
269 block_id: String,
270 role: String,
271 block: ContentBlock,
272 },
273 BlockPatch {
274 block_id: String,
275 role: String,
276 patch: BlockPatch,
277 },
278 }
279
280 #[derive(Facet)]
281 struct SessionEventEnvelope {
282 seq: u64,
283 event: SessionEvent,
284 }
285
286 #[derive(Facet)]
287 #[repr(u8)]
288 enum SubscribeMessage {
289 Event(SessionEventEnvelope),
290 ReplayComplete,
291 }
292
293 #[test]
294 fn generated_typescript_contains_no_postcard_primitive_usage() {
295 let echo = method_descriptor::<(String,), String>("TestSvc", "echo", &["message"], None);
296 let divide = method_descriptor::<(u64, u64), Result<u64, String>>(
297 "TestSvc",
298 "divide",
299 &["lhs", "rhs"],
300 None,
301 );
302 let methods = Box::leak(vec![echo, divide].into_boxed_slice());
303 let service = ServiceDescriptor {
304 service_name: "TestSvc",
305 methods,
306 doc: None,
307 };
308
309 let generated = generate_service(&service);
310 assert!(
311 !generated.contains("import * as pc from \"@bearcove/vox-postcard\""),
312 "generated TypeScript must not import postcard primitive namespace:\n{generated}"
313 );
314 assert!(
315 !generated.contains("pc."),
316 "generated TypeScript must not call postcard primitives directly:\n{generated}"
317 );
318 }
319
320 #[test]
321 fn generated_typescript_uses_canonical_service_schemas() {
322 let recurse = method_descriptor::<(RecursiveNode,), RecursiveNode>(
323 "RecursiveSvc",
324 "recurse",
325 &["node"],
326 None,
327 );
328 let methods = Box::leak(vec![recurse].into_boxed_slice());
329 let service = ServiceDescriptor {
330 service_name: "RecursiveSvc",
331 methods,
332 doc: None,
333 };
334
335 let generated = generate_service(&service);
336 assert!(
337 generated.contains("send_schemas"),
338 "generated TypeScript must include canonical service schemas:\n{generated}"
339 );
340 assert!(
341 !generated.contains("schema_registry"),
342 "generated TypeScript must not include the legacy schema registry:\n{generated}"
343 );
344 }
345
346 #[test]
347 fn generated_typescript_emits_alias_for_transparent_newtype() {
348 let summarize = method_descriptor::<(SessionId,), SessionSummary>(
349 "SessionSvc",
350 "summarize",
351 &["id"],
352 None,
353 );
354 let methods = Box::leak(vec![summarize].into_boxed_slice());
355 let service = ServiceDescriptor {
356 service_name: "SessionSvc",
357 methods,
358 doc: None,
359 };
360
361 let generated = generate_service(&service);
362 assert!(
363 generated.contains("export type SessionId = string;"),
364 "transparent named newtypes must emit a type alias:\n{generated}"
365 );
366 assert!(
367 generated.contains("id: SessionId;"),
368 "uses of transparent named newtypes must keep alias name:\n{generated}"
369 );
370 }
371
372 #[test]
373 fn generated_typescript_emits_channel_schemas() {
374 let subscribe = method_descriptor::<(Tx<u32>, Rx<u32>), ()>(
375 "StreamSvc",
376 "subscribe",
377 &["output", "input"],
378 None,
379 );
380 let methods = Box::leak(vec![subscribe].into_boxed_slice());
381 let service = ServiceDescriptor {
382 service_name: "StreamSvc",
383 methods,
384 doc: None,
385 };
386
387 let generated = generate_service(&service);
388 assert!(
389 generated.contains("kind: { tag: 'channel', direction: 'tx'"),
390 "Tx<T> must be emitted into canonical service schemas:\n{generated}"
391 );
392 assert!(
393 generated.contains("kind: { tag: 'channel', direction: 'rx'"),
394 "Rx<T> must be emitted into canonical service schemas:\n{generated}"
395 );
396 }
397
398 #[test]
399 fn generated_typescript_emits_retry_policy_on_method_descriptors() {
400 let fetch = method_descriptor_with_retry::<(), u64>(
401 "RetrySvc",
402 "fetch",
403 &[],
404 None,
405 RetryPolicy::IDEM,
406 );
407 let effect = method_descriptor_with_retry::<(), Result<u64, String>>(
408 "RetrySvc",
409 "effect",
410 &[],
411 None,
412 RetryPolicy::PERSIST,
413 );
414 let methods = Box::leak(vec![fetch, effect].into_boxed_slice());
415 let service = ServiceDescriptor {
416 service_name: "RetrySvc",
417 methods,
418 doc: None,
419 };
420
421 let generated = generate_service(&service);
422 assert!(
423 generated.contains("retry: { persist: false, idem: true }"),
424 "generated TypeScript must include retry policy for idem methods:\n{generated}"
425 );
426 assert!(
427 generated.contains("retry: { persist: true, idem: false }"),
428 "generated TypeScript must include retry policy for persist methods:\n{generated}"
429 );
430 }
431
432 #[test]
433 fn generated_typescript_keeps_struct_variants_with_kind_fields_named() {
434 let subscribe =
435 method_descriptor::<(), SubscribeMessage>("ShipSvc", "subscribe", &[], None);
436 let methods = Box::leak(vec![subscribe].into_boxed_slice());
437 let service = ServiceDescriptor {
438 service_name: "ShipSvc",
439 methods,
440 doc: None,
441 };
442
443 let generated = generate_service(&service);
444 assert!(
445 generated.contains(
446 "name: 'ToolCall', index: 1, payload: { tag: 'struct', fields: [{ name: 'id'"
447 ),
448 "struct variants with a field named `kind` must stay struct variants in canonical schemas:\n{generated}"
449 );
450 assert!(
451 generated.contains(
452 "name: 'Permission', index: 2, payload: { tag: 'struct', fields: [{ name: 'id'"
453 ),
454 "similar struct variants must keep their named `kind` field in canonical schemas:\n{generated}"
455 );
456 assert!(
457 generated.contains(
458 "name: 'ToolCallUpdate', index: 1, payload: { tag: 'struct', fields: [{ name: 'id'"
459 ),
460 "patch variants with a field named `kind` must also stay struct variants in canonical schemas:\n{generated}"
461 );
462 assert!(
463 generated.contains("{ name: 'kind', type_ref:"),
464 "canonical struct variants must preserve the literal field name `kind`:\n{generated}"
465 );
466 }
467}