1pub mod client;
12pub mod http_client;
13pub mod phon;
14pub mod schema;
15pub mod server;
16pub mod types;
17pub mod wire;
18
19pub use phon::generate_phon_service;
20
21use crate::code_writer::CodeWriter;
22use vox_types::{MethodDescriptor, ServiceDescriptor};
23
24pub use client::generate_client;
25pub use http_client::generate_http_client;
26pub use schema::generate_descriptor;
27pub use schema::generate_send_schema_table;
28pub use server::generate_server;
29pub use types::{collect_named_types, generate_named_types};
30
31pub fn generate_phon_handshake() -> String {
36 let module =
37 phon_codegen::Module::from_shapes(&[<vox_types::HandshakeMessage as facet_core::Facet<
38 'static,
39 >>::SHAPE])
40 .expect("derive phon schema for HandshakeMessage");
41 let mut out = phon_codegen::typescript::render(&module);
42 let closure =
43 vox_phon::schema_bytes::<vox_types::HandshakeMessage>().expect("handshake schema bytes");
44 let mut hex = String::with_capacity(closure.len() * 2);
45 for b in closure {
46 hex.push_str(&format!("{b:02x}"));
47 }
48 out.push_str(&format!(
49 "\n/// The local HandshakeMessage schema closure (hex), prepended when sending\n/// a self-describing handshake message.\nexport const handshakeSchemaClosure = \"{hex}\";\n"
50 ));
51 out
52}
53
54pub fn generate_phon_wire() -> String {
59 let module =
60 phon_codegen::Module::from_shapes(&[<vox_types::Message<'static> as facet_core::Facet<
61 'static,
62 >>::SHAPE])
63 .expect("derive phon schema for the Message envelope");
64 let mut out = phon_codegen::typescript::render(&module);
65 let closure =
68 vox_phon::schema_bytes::<vox_types::Message<'static>>().expect("Message schema bytes");
69 let mut hex = String::with_capacity(closure.len() * 2);
70 for b in closure {
71 hex.push_str(&format!("{b:02x}"));
72 }
73 out.push_str(&format!(
74 "\n/// The local Message envelope schema closure (hex), advertised in the\n/// handshake's `message_payload_schema`.\nexport const messageSchemaClosure = \"{hex}\";\n"
75 ));
76 out
77}
78
79pub fn generate_method_ids(methods: &[&MethodDescriptor]) -> String {
84 use crate::render::{fq_name, hex_u64};
85
86 let mut items = methods
87 .iter()
88 .map(|m| (fq_name(m), m.id.0))
89 .collect::<Vec<_>>();
90 items.sort_by(|a, b| a.0.cmp(&b.0));
91
92 let mut out = String::new();
93 out.push_str("// @generated by vox-codegen\n");
94 out.push_str("// This file defines canonical vox method IDs.\n\n");
95 out.push_str("export const METHOD_ID: Record<string, bigint> = {\n");
96 for (name, id) in items {
97 out.push_str(&format!(" \"{name}\": {}n,\n", hex_u64(id)));
98 }
99 out.push_str("} as const;\n");
100 out
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub struct ServiceGenerationOptions {
105 pub connect_helper: bool,
106}
107
108impl Default for ServiceGenerationOptions {
109 fn default() -> Self {
110 Self {
111 connect_helper: true,
112 }
113 }
114}
115
116pub fn generate_service(service: &ServiceDescriptor) -> String {
120 generate_service_with_options(service, ServiceGenerationOptions::default())
121}
122
123pub fn generate_service_with_options(
124 service: &ServiceDescriptor,
125 options: ServiceGenerationOptions,
126) -> String {
127 use crate::code_writer::CodeWriter;
128 use crate::cw_writeln;
129
130 validate_channel_rules(service);
131 super::validate_no_fd_shapes(service, "TypeScript");
133
134 let mut output = String::new();
135 let mut w = CodeWriter::with_indent_spaces(&mut output, 2);
136
137 cw_writeln!(w, "// @generated by vox-codegen").unwrap();
139 cw_writeln!(
140 w,
141 "// DO NOT EDIT - regenerate with `cargo xtask codegen --typescript`"
142 )
143 .unwrap();
144 w.blank_line().unwrap();
145
146 generate_imports(service, &mut w, options);
147 w.blank_line().unwrap();
148
149 let named_types = collect_named_types(service);
151 output.push_str(&generate_named_types(&named_types));
152
153 output.push_str(&generate_request_response_types(service, &named_types));
155
156 output.push_str(&generate_phon_service(service));
158
159 output.push_str(&client::generate_client_with_connect_helper(
161 service,
162 options.connect_helper,
163 ));
164
165 output.push_str(&generate_server(service));
167
168 output.push_str(&generate_descriptor(service));
170
171 let len = output.trim_end_matches('\n').len();
172 output.truncate(len);
173 output.push('\n');
174
175 output
176}
177
178fn validate_channel_rules(service: &ServiceDescriptor) {
179 for method in service.methods {
180 assert!(
182 !vox_types::shape_contains_channel(method.return_shape),
183 "channels are not allowed in return types: {}.{}",
184 method.service_name,
185 method.method_name
186 );
187 assert!(
189 !vox_types::shape_contains_channel_in_collection(method.args_shape),
190 "channels are not allowed inside collections: {}.{}",
191 method.service_name,
192 method.method_name
193 );
194 assert!(
196 !vox_types::shape_contains_indirect_channel_arg(method.args_shape),
197 "channels are only allowed as direct method arguments: {}.{}",
198 method.service_name,
199 method.method_name
200 );
201 }
202}
203
204fn generate_imports(
206 service: &ServiceDescriptor,
207 w: &mut CodeWriter<&mut String>,
208 options: ServiceGenerationOptions,
209) {
210 use crate::cw_writeln;
211 use vox_types::{ShapeKind, classify_shape, is_rx, is_tx};
212
213 let has_streaming = service
215 .methods
216 .iter()
217 .any(|m| m.args.iter().any(|a| is_tx(a.shape) || is_rx(a.shape)));
218
219 let has_fallible = service
221 .methods
222 .iter()
223 .any(|m| matches!(classify_shape(m.return_shape), ShapeKind::Result { .. }));
224
225 let has_dynamic = service.methods.iter().any(|m| {
226 m.args
227 .iter()
228 .any(|arg| shape_contains_dynamic_value(arg.shape))
229 || shape_contains_dynamic_value(m.return_shape)
230 });
231
232 if options.connect_helper {
233 cw_writeln!(
234 w,
235 "import type {{ Caller, MethodDescriptor, ServiceDescriptor, VoxCall, Dispatcher, RequestContext, ConnectLaneOptions }} from \"@bearcove/vox-core\";"
236 )
237 .unwrap();
238 cw_writeln!(
239 w,
240 "import {{ connectLane, voxServiceMetadata }} from \"@bearcove/vox-core\";"
241 )
242 .unwrap();
243 cw_writeln!(w, "import {{ wsConnector }} from \"@bearcove/vox-ws\";").unwrap();
244 } else {
245 cw_writeln!(
246 w,
247 "import type {{ Caller, MethodDescriptor, ServiceDescriptor, VoxCall, Dispatcher, RequestContext }} from \"@bearcove/vox-core\";"
248 )
249 .unwrap();
250 }
251
252 if has_fallible {
254 cw_writeln!(w, "import {{ RpcError }} from \"@bearcove/vox-core\";").unwrap();
255 }
256
257 if has_streaming {
259 cw_writeln!(w, "import {{ Tx, Rx }} from \"@bearcove/vox-core\";").unwrap();
260 }
261
262 if has_dynamic {
263 cw_writeln!(w, "import type {{ Value }} from \"@bearcove/phon-schema\";").unwrap();
264 }
265}
266
267fn shape_contains_dynamic_value(shape: &'static facet_core::Shape) -> bool {
268 let mut seen = std::collections::HashSet::new();
269 shape_contains_dynamic_value_inner(shape, &mut seen)
270}
271
272fn shape_contains_dynamic_value_inner(
273 shape: &'static facet_core::Shape,
274 seen: &mut std::collections::HashSet<&'static facet_core::Shape>,
275) -> bool {
276 if types::is_dynamic_value(shape) {
277 return true;
278 }
279
280 if !seen.insert(shape) {
281 return false;
282 }
283
284 match vox_types::classify_shape(shape) {
285 vox_types::ShapeKind::List { element }
286 | vox_types::ShapeKind::Slice { element }
287 | vox_types::ShapeKind::Option { inner: element }
288 | vox_types::ShapeKind::Array { element, .. }
289 | vox_types::ShapeKind::Set { element }
290 | vox_types::ShapeKind::Tx { inner: element }
291 | vox_types::ShapeKind::Rx { inner: element }
292 | vox_types::ShapeKind::Pointer { pointee: element } => {
293 shape_contains_dynamic_value_inner(element, seen)
294 }
295 vox_types::ShapeKind::Map { key, value } => {
296 shape_contains_dynamic_value_inner(key, seen)
297 || shape_contains_dynamic_value_inner(value, seen)
298 }
299 vox_types::ShapeKind::Tuple { elements } => elements
300 .iter()
301 .any(|param| shape_contains_dynamic_value_inner(param.shape, seen)),
302 vox_types::ShapeKind::TupleStruct { fields }
303 | vox_types::ShapeKind::Struct(vox_types::StructInfo { fields, .. }) => fields
304 .iter()
305 .any(|field| shape_contains_dynamic_value_inner(field.shape(), seen)),
306 vox_types::ShapeKind::Enum(vox_types::EnumInfo { variants, .. }) => {
307 variants
308 .iter()
309 .any(|variant| match vox_types::classify_variant(variant) {
310 vox_types::VariantKind::Unit => false,
311 vox_types::VariantKind::Newtype { inner } => {
312 shape_contains_dynamic_value_inner(inner, seen)
313 }
314 vox_types::VariantKind::Tuple { fields }
315 | vox_types::VariantKind::Struct { fields } => fields
316 .iter()
317 .any(|field| shape_contains_dynamic_value_inner(field.shape(), seen)),
318 })
319 }
320 vox_types::ShapeKind::Result { ok, err } => {
321 shape_contains_dynamic_value_inner(ok, seen)
322 || shape_contains_dynamic_value_inner(err, seen)
323 }
324 vox_types::ShapeKind::Scalar(_) | vox_types::ShapeKind::Opaque => false,
325 }
326}
327
328fn generate_request_response_types(
330 service: &ServiceDescriptor,
331 named_types: &[(String, &'static facet_core::Shape)],
332) -> String {
333 use heck::ToUpperCamelCase;
334 use std::collections::HashSet;
335 use types::ts_type;
336
337 let type_names: HashSet<&str> = named_types.iter().map(|(name, _)| name.as_str()).collect();
339
340 let mut out = String::new();
341 out.push_str("// Request/Response type aliases\n");
342
343 for method in service.methods {
344 let method_name = method.method_name.to_upper_camel_case();
345 let request_name = format!("{method_name}Request");
346 let response_name = format!("{method_name}Response");
347
348 if !type_names.contains(request_name.as_str()) {
350 if method.args.is_empty() {
351 out.push_str(&format!("export type {request_name} = [];\n"));
352 } else if method.args.len() == 1 {
353 let ty = ts_type(method.args[0].shape);
354 out.push_str(&format!("export type {request_name} = [{ty}];\n"));
355 } else {
356 out.push_str(&format!("export type {request_name} = [\n"));
357 for arg in method.args {
358 let ty = ts_type(arg.shape);
359 out.push_str(&format!(" {ty}, // {}\n", arg.name));
360 }
361 out.push_str("];\n");
362 }
363 }
364
365 if !type_names.contains(response_name.as_str()) {
367 let ret_ty = ts_type(method.return_shape);
368 out.push_str(&format!("export type {response_name} = {ret_ty};\n"));
369 }
370
371 out.push('\n');
372 }
373
374 out
375}
376
377#[cfg(test)]
378mod tests {
379 #![allow(dead_code)]
380
381 use std::collections::BTreeSet;
382
383 use super::{ServiceGenerationOptions, generate_service, generate_service_with_options};
384 use crate::render::hex_u64;
385 use facet::{Facet, Shape};
386 use vox_types::{
387 MethodDescriptor, MethodDescriptorOptions, MethodId, Rx, ServiceDescriptor, Tx,
388 method_descriptor,
389 };
390
391 fn hex_bytes(bytes: &[u8]) -> String {
392 let mut out = String::with_capacity(bytes.len() * 2);
393 for byte in bytes {
394 out.push_str(&format!("{byte:02x}"));
395 }
396 out
397 }
398
399 fn parse_self_describing_unique_bundle(
400 bytes: &[u8],
401 expected_root: u64,
402 ) -> vox_phon::SchemaBundle {
403 let bundle = vox_phon::parse_schema_bytes(bytes).expect("schema closure parses");
404 assert_eq!(bundle.root.as_u64(), expected_root);
405 let mut seen = BTreeSet::new();
406 for schema in &bundle.schemas {
407 assert!(
408 seen.insert(schema.id.as_u64()),
409 "schema closure must not repeat type ID {:#x}",
410 schema.id.as_u64()
411 );
412 }
413 bundle
414 }
415
416 #[derive(Facet)]
421 struct CompositeNode {
422 value: u64,
423 label: String,
424 tags: Vec<String>,
425 }
426
427 #[derive(Facet)]
428 struct DynamicTemplateCall {
429 args: Vec<facet_value::Value>,
430 kwargs: Vec<(String, facet_value::Value)>,
431 }
432
433 #[derive(Facet)]
434 struct NestedInner {
435 value: u32,
436 }
437
438 #[derive(Facet)]
439 struct NestedOuter {
440 inner: NestedInner,
441 }
442
443 #[derive(Facet)]
444 struct NestedChannels {
445 stream: Tx<u32>,
446 }
447
448 #[derive(Facet)]
449 struct OuterChannels {
450 inner: NestedChannels,
451 }
452
453 #[cfg(unix)]
454 #[derive(Facet)]
455 struct FdBundle {
456 descriptors: Vec<vox_types::Fd>,
457 }
458
459 #[derive(Facet)]
460 #[repr(transparent)]
461 #[facet(transparent)]
462 struct SessionId(pub String);
463
464 #[derive(Facet)]
465 struct SessionSummary {
466 id: SessionId,
467 }
468
469 #[derive(Facet)]
470 #[repr(u8)]
471 enum ToolCallKind {
472 Read,
473 Execute,
474 }
475
476 #[derive(Facet)]
477 #[repr(u8)]
478 enum ToolCallStatus {
479 Running,
480 Success,
481 Failure,
482 }
483
484 #[derive(Facet)]
485 #[repr(u8)]
486 enum PermissionResolution {
487 Approved,
488 Denied,
489 }
490
491 #[derive(Facet)]
492 #[repr(u8)]
493 enum ContentBlock {
494 Text {
495 text: String,
496 },
497 ToolCall {
498 id: String,
499 title: String,
500 kind: Option<ToolCallKind>,
501 status: ToolCallStatus,
502 },
503 Permission {
504 id: String,
505 title: String,
506 kind: Option<ToolCallKind>,
507 resolution: Option<PermissionResolution>,
508 },
509 }
510
511 #[derive(Facet)]
512 #[repr(u8)]
513 enum DomNode {
514 Element { tag: String, children: Vec<String> },
515 Text(String),
516 }
517
518 #[derive(Facet)]
519 #[repr(u8)]
520 enum BlockPatch {
521 TextAppend {
522 text: String,
523 },
524 ToolCallUpdate {
525 id: String,
526 kind: Option<ToolCallKind>,
527 status: ToolCallStatus,
528 },
529 }
530
531 #[derive(Facet)]
532 #[repr(u8)]
533 enum SessionEvent {
534 BlockAppend {
535 block_id: String,
536 role: String,
537 block: ContentBlock,
538 },
539 BlockPatch {
540 block_id: String,
541 role: String,
542 patch: BlockPatch,
543 },
544 }
545
546 #[derive(Facet)]
547 struct SessionEventEnvelope {
548 seq: u64,
549 event: SessionEvent,
550 }
551
552 #[derive(Facet)]
553 #[repr(u8)]
554 enum SubscribeMessage {
555 Event(SessionEventEnvelope),
556 ReplayComplete,
557 }
558
559 fn service_with_shapes(
560 args_shape: &'static Shape,
561 return_shape: &'static Shape,
562 ) -> ServiceDescriptor {
563 let method = Box::leak(Box::new(MethodDescriptor {
564 id: MethodId(1),
565 service_name: "BadSvc",
566 method_name: "bad",
567 args_shape,
568 args: &[],
569 return_shape,
570 response_wire_shape: <Result<(), vox_types::VoxError> as Facet>::SHAPE,
571 args_have_channels: vox_types::shape_contains_channel(args_shape),
572 doc: None,
573 }));
574 let methods = Box::leak(vec![method as &'static MethodDescriptor].into_boxed_slice());
575 ServiceDescriptor {
576 service_name: "BadSvc",
577 methods,
578 doc: None,
579 }
580 }
581
582 fn dynamic_value_service() -> ServiceDescriptor {
583 let method = method_descriptor::<(DynamicTemplateCall,), DynamicTemplateCall>(
584 "DynamicSvc",
585 "echoDynamic",
586 &["call"],
587 &[None],
588 MethodDescriptorOptions {
589 response_wire_shape:
590 <Result<DynamicTemplateCall, vox_types::VoxError> as Facet>::SHAPE,
591 doc: None,
592 },
593 );
594 let methods = Box::leak(vec![method].into_boxed_slice());
595 ServiceDescriptor {
596 service_name: "DynamicSvc",
597 methods,
598 doc: None,
599 }
600 }
601
602 fn panic_message(panic: Box<dyn std::any::Any + Send>) -> String {
603 panic
604 .downcast_ref::<String>()
605 .cloned()
606 .or_else(|| panic.downcast_ref::<&'static str>().map(|s| s.to_string()))
607 .expect("panic payload should be a string")
608 }
609
610 #[test]
611 fn generated_typescript_contains_no_postcard_primitive_usage() {
612 let echo = method_descriptor::<(String,), String>(
613 "TestSvc",
614 "echo",
615 &["message"],
616 &[None],
617 MethodDescriptorOptions {
618 response_wire_shape: <Result<String, vox_types::VoxError> as Facet>::SHAPE,
619 doc: None,
620 },
621 );
622 let divide = method_descriptor::<(u64, u64), Result<u64, String>>(
623 "TestSvc",
624 "divide",
625 &["lhs", "rhs"],
626 &[None, None],
627 MethodDescriptorOptions {
628 response_wire_shape: <Result<u64, vox_types::VoxError<String>> as Facet>::SHAPE,
629 doc: None,
630 },
631 );
632 let methods = Box::leak(vec![echo, divide].into_boxed_slice());
633 let service = ServiceDescriptor {
634 service_name: "TestSvc",
635 methods,
636 doc: None,
637 };
638
639 let generated = generate_service(&service);
640 assert!(
641 !generated.contains("import * as pc from \"@bearcove/vox-postcard\""),
642 "generated TypeScript must not import postcard primitive namespace:\n{generated}"
643 );
644 assert!(
645 !generated.contains("pc."),
646 "generated TypeScript must not call postcard primitives directly:\n{generated}"
647 );
648 }
649
650 #[test]
651 fn generated_typescript_phon_service_handles_dynamic_value_payloads() {
652 let service = dynamic_value_service();
653 let generated = super::phon::generate_phon_service(&service);
654 assert!(
655 generated.contains("argsSchemaClosure"),
656 "dynamic service must still emit args schema closure:\n{generated}"
657 );
658 assert!(
659 generated.contains("responseSchemaClosure"),
660 "dynamic service must still emit response schema closure:\n{generated}"
661 );
662 }
663
664 #[test]
665 fn generated_typescript_uses_value_type_for_dynamic_payloads() {
666 let service = dynamic_value_service();
667 let generated = generate_service(&service);
668 assert!(
669 generated.contains("import type { Value } from \"@bearcove/phon-schema\";"),
670 "dynamic payloads must import phon Value:\n{generated}"
671 );
672 assert!(
673 generated.contains("args: Value[];"),
674 "dynamic Vec<Value> fields must render as Value[]:\n{generated}"
675 );
676 assert!(
677 generated.contains("kwargs: [string, Value][];"),
678 "dynamic tuple-vector kwargs must render as [string, Value][]:\n{generated}"
679 );
680 }
681
682 #[test]
683 fn generated_typescript_testbed_service_handles_dodeca_dynamic_root() {
684 let service = spec_proto::all_services()
685 .into_iter()
686 .find(|service| service.service_name == "Testbed")
687 .expect("testbed service exists");
688 let generated = generate_service(service);
689 assert!(
690 generated.contains("export interface DodecaTemplateCall"),
691 "testbed generated service must include the dynamic Dodeca root:\n{generated}"
692 );
693 assert!(
694 generated.contains("import type { Value } from \"@bearcove/phon-schema\";"),
695 "testbed generated service must import phon Value:\n{generated}"
696 );
697 }
698
699 #[test]
700 fn generated_typescript_wire_and_handshake_modules_render() {
701 let wire = super::generate_phon_wire();
702 assert!(
703 wire.contains("messageSchemaClosure"),
704 "wire module must render message schema closure:\n{wire}"
705 );
706 let handshake = super::generate_phon_handshake();
707 assert!(
708 handshake.contains("handshakeSchemaClosure"),
709 "handshake module must render handshake schema closure:\n{handshake}"
710 );
711 }
712
713 #[test]
714 fn generated_typescript_uses_canonical_service_schemas() {
724 let recurse = method_descriptor::<(NestedOuter,), NestedOuter>(
725 "RecursiveSvc",
726 "recurse",
727 &["node"],
728 &[None],
729 MethodDescriptorOptions {
730 response_wire_shape: <Result<NestedOuter, vox_types::VoxError> as Facet>::SHAPE,
731 doc: None,
732 },
733 );
734 let methods = Box::leak(vec![recurse].into_boxed_slice());
735 let service = ServiceDescriptor {
736 service_name: "RecursiveSvc",
737 methods,
738 doc: None,
739 };
740
741 let generated = generate_service(&service);
742 let args_root = vox_phon::schema_id_for_shape(recurse.args_shape).expect("args schema id");
743 let response_root =
744 vox_phon::schema_id_for_shape(recurse.response_wire_shape).expect("response schema id");
745 let args_closure =
746 vox_phon::schema_bytes_for_shape(recurse.args_shape).expect("args schema closure");
747 let response_closure = vox_phon::schema_bytes_for_shape(recurse.response_wire_shape)
748 .expect("response schema closure");
749 let args_bundle = parse_self_describing_unique_bundle(&args_closure, args_root.as_u64());
750 let _response_bundle =
751 parse_self_describing_unique_bundle(&response_closure, response_root.as_u64());
752 let nested_inner_id =
753 vox_phon::schema_id_for_shape(<NestedInner as Facet>::SHAPE).expect("inner schema id");
754
755 assert_eq!(args_bundle.root, args_root);
756 assert!(
757 args_bundle
758 .schemas
759 .iter()
760 .any(|schema| schema.id == nested_inner_id),
761 "args closure must carry transitively referenced NestedInner schema"
762 );
763 assert!(
767 generated.contains("recursiveSvcMethods")
768 && generated.contains("PhonMethodSchemas")
769 && generated.contains("recursiveSvcRegistry = new Registry("),
770 "generated TypeScript must include the phon service schemas:\n{generated}"
771 );
772 assert!(
773 !generated.contains("schema_registry"),
774 "generated TypeScript must not include the legacy schema registry:\n{generated}"
775 );
776 assert!(
777 generated.contains(&format!(" \"{}\": {{", hex_u64(crate::method_id(recurse)))),
778 "generated TypeScript method table must use the canonical Vox method ID:\n{generated}"
779 );
780 assert!(
781 generated.contains(&format!("argsRoot: {}n", hex_u64(args_root.as_u64()))),
782 "generated TypeScript must use the phon args schema ID:\n{generated}"
783 );
784 assert!(
785 generated.contains(&format!(
786 "responseRoot: {}n",
787 hex_u64(response_root.as_u64())
788 )),
789 "generated TypeScript must use the phon response schema ID:\n{generated}"
790 );
791 assert!(
792 generated.contains(&format!(
793 "argsSchemaClosure: \"{}\"",
794 hex_bytes(&args_closure)
795 )),
796 "generated TypeScript must embed the self-contained phon args closure:\n{generated}"
797 );
798 assert!(
799 generated.contains(&format!(
800 "responseSchemaClosure: \"{}\"",
801 hex_bytes(&response_closure)
802 )),
803 "generated TypeScript must embed the self-contained phon response closure:\n{generated}"
804 );
805 }
806
807 #[test]
808 fn generated_typescript_emits_rpc_caller_handler_and_lane_shapes() {
815 let echo = method_descriptor::<(String,), String>(
816 "TestSvc",
817 "echo",
818 &["message"],
819 &[None],
820 MethodDescriptorOptions {
821 response_wire_shape: <Result<String, vox_types::VoxError> as Facet>::SHAPE,
822 doc: None,
823 },
824 );
825 let divide = method_descriptor::<(u64, u64), Result<u64, String>>(
826 "TestSvc",
827 "divide",
828 &["lhs", "rhs"],
829 &[None, None],
830 MethodDescriptorOptions {
831 response_wire_shape: <Result<u64, vox_types::VoxError<String>> as Facet>::SHAPE,
832 doc: None,
833 },
834 );
835 let methods = Box::leak(vec![echo, divide].into_boxed_slice());
836 let service = ServiceDescriptor {
837 service_name: "TestSvc",
838 methods,
839 doc: None,
840 };
841
842 let generated = generate_service(&service);
843
844 assert!(
845 generated.contains("export interface TestSvcCaller {")
846 && generated.contains(" async echo(message: string): Promise<string> {")
847 && generated.contains(" async divide(lhs: bigint, rhs: bigint):"),
848 "generated TypeScript must expose async caller methods matching the source service:\n{generated}"
849 );
850 assert!(
851 generated.contains("export class TestSvcClient implements TestSvcCaller")
852 && generated.contains("static get descriptor(): ServiceDescriptor {")
853 && generated.contains("return testSvc_descriptor;")
854 && generated.contains("private caller: Caller;")
855 && generated.contains("const __voxResult = await this.caller.call({")
856 && generated.contains("method: \"TestSvc.echo\",")
857 && generated.contains("descriptor: testSvc_echo_method,")
858 && generated.contains("methodSchemas: testSvcMethods["),
859 "generated TypeScript client must delegate request serialization/response handling to Caller:\n{generated}"
860 );
861 assert!(
862 generated.contains("return connectLane(wsConnector(url), TestSvcClient, {")
863 && generated.contains(
864 "laneMetadata: options.laneMetadata ?? voxServiceMetadata(\"TestSvc\"),"
865 ),
866 "generated TypeScript connect helper must open a service lane:\n{generated}"
867 );
868 assert!(
869 generated.contains("export interface TestSvcHandler {")
870 && generated.contains(" echo(message: string): Promise<string> | string;")
871 && generated.contains(" divide(lhs: bigint, rhs: bigint):"),
872 "generated TypeScript handler must expose service methods with server-side argument types:\n{generated}"
873 );
874 assert!(
875 generated.contains("export class TestSvcDispatcher implements Dispatcher")
876 && generated.contains("private readonly handler: TestSvcHandler;")
877 && generated.contains("getDescriptor(): ServiceDescriptor")
878 && generated.contains("return testSvc_descriptor;")
879 && generated.contains("const result = await this.handler.echo(args[0] as string);")
880 && generated.contains(
881 "if (result.ok) call.reply(result.value); else call.replyErr(result.error);"
882 ),
883 "generated TypeScript dispatcher must route decoded args to the handler and reply through VoxCall:\n{generated}"
884 );
885 }
886
887 #[test]
888 fn generated_typescript_can_omit_websocket_connect_helper() {
889 let ping = method_descriptor::<(), u64>(
890 "TestSvc",
891 "ping",
892 &[],
893 &[],
894 MethodDescriptorOptions {
895 response_wire_shape: <Result<u64, vox_types::VoxError> as Facet>::SHAPE,
896 doc: None,
897 },
898 );
899 let methods = Box::leak(vec![ping].into_boxed_slice());
900 let service = ServiceDescriptor {
901 service_name: "TestSvc",
902 methods,
903 doc: None,
904 };
905
906 let generated = generate_service_with_options(
907 &service,
908 ServiceGenerationOptions {
909 connect_helper: false,
910 },
911 );
912
913 assert!(generated.contains("export class TestSvcClient implements TestSvcCaller"));
914 assert!(!generated.contains("ConnectLaneOptions"));
915 assert!(!generated.contains("connectLane"));
916 assert!(!generated.contains("voxServiceMetadata"));
917 assert!(!generated.contains("wsConnector"));
918 assert!(!generated.contains("export async function connectTestSvc"));
919 }
920
921 #[test]
922 fn generated_typescript_method_ids_include_service_name() {
924 let alpha = method_descriptor::<(), ()>(
925 "AlphaSvc",
926 "echo",
927 &[],
928 &[],
929 MethodDescriptorOptions {
930 response_wire_shape: <Result<(), vox_types::VoxError> as Facet>::SHAPE,
931 doc: None,
932 },
933 );
934 let beta = method_descriptor::<(), ()>(
935 "BetaSvc",
936 "echo",
937 &[],
938 &[],
939 MethodDescriptorOptions {
940 response_wire_shape: <Result<(), vox_types::VoxError> as Facet>::SHAPE,
941 doc: None,
942 },
943 );
944
945 assert_ne!(crate::method_id(alpha), crate::method_id(beta));
946
947 let alpha_methods = Box::leak(vec![alpha].into_boxed_slice());
948 let beta_methods = Box::leak(vec![beta].into_boxed_slice());
949 let alpha_service = ServiceDescriptor {
950 service_name: "AlphaSvc",
951 methods: alpha_methods,
952 doc: None,
953 };
954 let beta_service = ServiceDescriptor {
955 service_name: "BetaSvc",
956 methods: beta_methods,
957 doc: None,
958 };
959
960 let alpha_generated = generate_service(&alpha_service);
961 let beta_generated = generate_service(&beta_service);
962 let alpha_entry = format!(" \"{}\": {{", hex_u64(crate::method_id(alpha)));
963 let beta_entry = format!(" \"{}\": {{", hex_u64(crate::method_id(beta)));
964
965 assert!(
966 alpha_generated.contains(&alpha_entry),
967 "Alpha service must emit its service-qualified method ID:\n{alpha_generated}"
968 );
969 assert!(
970 !alpha_generated.contains(&beta_entry),
971 "Alpha service must not reuse Beta service's same-method-name ID:\n{alpha_generated}"
972 );
973 assert!(
974 beta_generated.contains(&beta_entry),
975 "Beta service must emit its service-qualified method ID:\n{beta_generated}"
976 );
977 }
978
979 #[test]
980 fn generated_typescript_emits_alias_for_transparent_newtype() {
981 let summarize = method_descriptor::<(SessionId,), SessionSummary>(
982 "SessionSvc",
983 "summarize",
984 &["id"],
985 &[None],
986 MethodDescriptorOptions {
987 response_wire_shape: <Result<SessionSummary, vox_types::VoxError> as Facet>::SHAPE,
988 doc: None,
989 },
990 );
991 let methods = Box::leak(vec![summarize].into_boxed_slice());
992 let service = ServiceDescriptor {
993 service_name: "SessionSvc",
994 methods,
995 doc: None,
996 };
997
998 let generated = generate_service(&service);
999 assert!(
1000 generated.contains("export type SessionId = string;"),
1001 "transparent named newtypes must emit a type alias:\n{generated}"
1002 );
1003 assert!(
1004 generated.contains("id: SessionId;"),
1005 "uses of transparent named newtypes must keep alias name:\n{generated}"
1006 );
1007 }
1008
1009 #[test]
1010 fn generated_typescript_emits_channel_schemas() {
1012 let subscribe = method_descriptor::<(Tx<NestedInner>, Rx<NestedInner>), ()>(
1013 "StreamSvc",
1014 "subscribe",
1015 &["output", "input"],
1016 &[
1017 Some(<NestedInner as facet::Facet>::SHAPE),
1018 Some(<NestedInner as facet::Facet>::SHAPE),
1019 ],
1020 MethodDescriptorOptions {
1021 response_wire_shape: <Result<(), vox_types::VoxError> as Facet>::SHAPE,
1022 doc: None,
1023 },
1024 );
1025 let methods = Box::leak(vec![subscribe].into_boxed_slice());
1026 let service = ServiceDescriptor {
1027 service_name: "StreamSvc",
1028 methods,
1029 doc: None,
1030 };
1031
1032 let generated = generate_service(&service);
1033 assert!(
1038 generated.contains("channels: [{ index: 0, direction: \"tx\""),
1039 "Tx<T> arg must be emitted as out-of-band channel metadata:\n{generated}"
1040 );
1041 assert!(
1042 generated.contains("direction: \"rx\", elementRoot:"),
1043 "Rx<T> arg must be emitted as out-of-band channel metadata:\n{generated}"
1044 );
1045 assert!(
1046 generated.contains("6368616e6e656c2e6172672e302e74782e656c656d656e74"),
1047 "Tx<T> element root must be carried as an auxiliary schema root:\n{generated}"
1048 );
1049 assert!(
1050 generated.contains("6368616e6e656c2e6172672e312e72782e656c656d656e74"),
1051 "Rx<T> element root must be carried as an auxiliary schema root:\n{generated}"
1052 );
1053 assert!(
1054 generated.contains("export interface NestedInner"),
1055 "named channel element types must be emitted as normal generated types:\n{generated}"
1056 );
1057 }
1058
1059 #[test]
1060 fn generated_typescript_rejects_channels_in_return_shapes() {
1062 let service = service_with_shapes(<() as Facet>::SHAPE, <Tx<u32> as Facet>::SHAPE);
1063
1064 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1065 generate_service(&service);
1066 }));
1067 let message = panic_message(result.expect_err("generator should reject channel returns"));
1068
1069 assert!(
1070 message.contains("channels are not allowed in return types: BadSvc.bad"),
1071 "unexpected panic message: {message}"
1072 );
1073 }
1074
1075 #[test]
1077 fn generated_typescript_rejects_nested_channel_args() {
1078 let service = service_with_shapes(<(OuterChannels,) as Facet>::SHAPE, <() as Facet>::SHAPE);
1079
1080 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1081 generate_service(&service);
1082 }));
1083 let message = panic_message(result.expect_err("generator should reject nested channels"));
1084
1085 assert!(
1086 message.contains("channels are only allowed as direct method arguments: BadSvc.bad"),
1087 "unexpected panic message: {message}"
1088 );
1089 }
1090
1091 #[test]
1092 fn generated_typescript_rejects_channels_inside_collections() {
1094 let service = service_with_shapes(<(Vec<Tx<u32>>,) as Facet>::SHAPE, <() as Facet>::SHAPE);
1095
1096 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1097 generate_service(&service);
1098 }));
1099 let message =
1100 panic_message(result.expect_err("generator should reject collection channels"));
1101
1102 assert!(
1103 message.contains("channels are not allowed inside collections: BadSvc.bad"),
1104 "unexpected panic message: {message}"
1105 );
1106 }
1107
1108 #[cfg(unix)]
1109 #[test]
1110 fn generated_typescript_rejects_fd_capability_surfaces() {
1112 let service = service_with_shapes(<() as Facet>::SHAPE, <FdBundle as Facet>::SHAPE);
1113
1114 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1115 generate_service(&service);
1116 }));
1117 let message =
1118 panic_message(result.expect_err("generator should reject fd capability surfaces"));
1119
1120 assert!(
1121 message.contains(
1122 "TypeScript codegen does not support vox::Fd capabilities: BadSvc.bad return type"
1123 ),
1124 "unexpected panic message: {message}"
1125 );
1126 }
1127
1128 #[test]
1129 fn generated_typescript_keeps_struct_variants_with_kind_fields_named() {
1130 let subscribe = method_descriptor::<(), SubscribeMessage>(
1131 "ShipSvc",
1132 "subscribe",
1133 &[],
1134 &[],
1135 MethodDescriptorOptions {
1136 response_wire_shape:
1137 <Result<SubscribeMessage, vox_types::VoxError> as Facet>::SHAPE,
1138 doc: None,
1139 },
1140 );
1141 let methods = Box::leak(vec![subscribe].into_boxed_slice());
1142 let service = ServiceDescriptor {
1143 service_name: "ShipSvc",
1144 methods,
1145 doc: None,
1146 };
1147
1148 let generated = generate_service(&service);
1149 assert!(
1154 generated.contains(
1155 "| { tag: 'ToolCall'; id: string; title: string; kind: ToolCallKind | null; status: ToolCallStatus }"
1156 ),
1157 "struct variants with a field named `kind` must stay struct variants in the generated type:\n{generated}"
1158 );
1159 assert!(
1160 generated.contains(
1161 "| { tag: 'Permission'; id: string; title: string; kind: ToolCallKind | null; resolution: PermissionResolution | null }"
1162 ),
1163 "similar struct variants must keep their named `kind` field in the generated type:\n{generated}"
1164 );
1165 assert!(
1166 generated.contains(
1167 "| { tag: 'ToolCallUpdate'; id: string; kind: ToolCallKind | null; status: ToolCallStatus }"
1168 ),
1169 "patch variants with a field named `kind` must also stay struct variants in the generated type:\n{generated}"
1170 );
1171 }
1172
1173 #[test]
1174 fn generated_typescript_uses_alternate_discriminator_for_tag_payload_fields() {
1175 let parse = method_descriptor::<(), DomNode>(
1176 "DomSvc",
1177 "parse",
1178 &[],
1179 &[],
1180 MethodDescriptorOptions {
1181 response_wire_shape: <Result<DomNode, vox_types::VoxError> as Facet>::SHAPE,
1182 doc: None,
1183 },
1184 );
1185 let methods = Box::leak(vec![parse].into_boxed_slice());
1186 let service = ServiceDescriptor {
1187 service_name: "DomSvc",
1188 methods,
1189 doc: None,
1190 };
1191
1192 let generated = generate_service(&service);
1193 assert!(
1194 generated.contains("{ $tag: 'Element'; tag: string; children: string[] }"),
1195 "struct variants with a field named `tag` must use `$tag` as the discriminant:\n{generated}"
1196 );
1197 assert!(
1198 generated.contains("| { $tag: 'Text'; value: string }"),
1199 "all variants in that enum must share the `$tag` discriminant:\n{generated}"
1200 );
1201 }
1202
1203 #[test]
1208 fn generated_typescript_avoids_parameter_properties_and_types_catch_error() {
1209 let divide = method_descriptor::<(u64, u64), Result<u64, String>>(
1210 "StrictSvc",
1211 "divide",
1212 &["lhs", "rhs"],
1213 &[None, None],
1214 MethodDescriptorOptions {
1215 response_wire_shape: <Result<u64, vox_types::VoxError<String>> as Facet>::SHAPE,
1216 doc: None,
1217 },
1218 );
1219 let methods = Box::leak(vec![divide].into_boxed_slice());
1220 let service = ServiceDescriptor {
1221 service_name: "StrictSvc",
1222 methods,
1223 doc: None,
1224 };
1225
1226 let generated = generate_service(&service);
1227 assert!(
1228 !generated.contains("constructor(private readonly handler"),
1229 "generated TypeScript must avoid constructor parameter properties:\n{generated}"
1230 );
1231 assert!(
1232 generated.contains("private readonly handler: StrictSvcHandler;"),
1233 "dispatcher must emit an explicit handler field:\n{generated}"
1234 );
1235 assert!(
1236 generated.contains("constructor(handler: StrictSvcHandler)"),
1237 "dispatcher constructor must use explicit assignment parameter:\n{generated}"
1238 );
1239 assert!(
1240 generated.contains("catch (e: any)"),
1241 "fallible client methods must type catch binding for strict TypeScript:\n{generated}"
1242 );
1243 assert!(
1244 generated.contains("return { ok: true, value: __voxResult }"),
1245 "fallible client methods must wrap successful responses:\n{generated}"
1246 );
1247 assert!(
1248 generated.contains("if (e instanceof RpcError && e.isUserError())"),
1249 "fallible client methods must distinguish user errors from protocol/runtime errors:\n{generated}"
1250 );
1251 assert!(
1252 generated.contains("return { ok: false, error: e.userError }"),
1253 "fallible client methods must return user errors in the public Result shape:\n{generated}"
1254 );
1255 assert!(
1256 generated.contains("throw e;"),
1257 "fallible client methods must rethrow non-user errors:\n{generated}"
1258 );
1259 }
1260}