Skip to main content

vox_codegen/targets/typescript/
mod.rs

1//! TypeScript code generation for vox services.
2//!
3//! This module generates TypeScript client and server code from service definitions.
4//! The generated code includes:
5//! - Type definitions for all named types (structs, enums)
6//! - Client interface and implementation for making RPC calls
7//! - Handler interface for implementing the service
8//! - A Dispatcher class that routes calls to handler methods
9//! - A service descriptor for runtime schema-driven encode/decode
10
11pub 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
31/// Generate the phon `HandshakeMessage` module for vox-core: the handshake message
32/// types + registry + `schemaId`, plus `handshakeSchemaClosure` (the self-describing
33/// schema-closure bytes to prepend when sending a handshake message). The handshake
34/// is phon self-describing — each message carries its own schema closure.
35pub 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
54/// Generate the phon `Message` wire module for `@bearcove/vox-wire`: the envelope
55/// type declarations, the self-describing schema-bytes, a ready `registry`, and the
56/// `schemaId` constants — produced by delegating to `phon-codegen`. vox-wire's codec
57/// decodes/encodes the envelope against this registry via `@bearcove/phon-engine`.
58pub 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    // The Message schema closure (hex) — peers exchange this in the handshake's
66    // `message_payload_schema` so each can build an envelope compat decoder.
67    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
79/// Generate method IDs as a TypeScript constant record.
80// r[impl schema.method-id]
81// r[impl rpc.method-id.algorithm]
82// r[impl rpc.method-id.no-collisions]
83pub 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
116/// Generate a complete TypeScript module for a service.
117///
118/// This is the main entry point for TypeScript code generation.
119pub 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    // r[impl transport.fd.capability]
132    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    // Header
138    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    // Named types (structs and enums)
150    let named_types = collect_named_types(service);
151    output.push_str(&generate_named_types(&named_types));
152
153    // Request/Response type aliases
154    output.push_str(&generate_request_response_types(service, &named_types));
155
156    // phon registry + per-method schema table (registry + roots + channel metadata)
157    output.push_str(&generate_phon_service(service));
158
159    // Client
160    output.push_str(&client::generate_client_with_connect_helper(
161        service,
162        options.connect_helper,
163    ));
164
165    // Server (handler interface + dispatcher)
166    output.push_str(&generate_server(service));
167
168    // Service descriptor (references {service}Methods above)
169    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        // r[impl rpc.channel.placement]
181        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        // r[impl rpc.channel.no-collections]
188        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        // r[impl rpc.channel.direct-args]
195        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
204/// Generate imports for the service module.
205fn 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    // Check if any method uses channels in args.
214    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    // Check if any method returns Result<T, E> (fallible methods)
220    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    // RpcError for fallible client methods
253    if has_fallible {
254        cw_writeln!(w, "import {{ RpcError }} from \"@bearcove/vox-core\";").unwrap();
255    }
256
257    // Tx/Rx types for streaming handler args/returns.
258    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
328/// Generate request/response type aliases, skipping any that conflict with named types
329fn 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    // Collect just the type names for conflict checking
338    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        // Only generate request type alias if it doesn't conflict with a named type
349        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        // Only generate response type alias if it doesn't conflict with a named type
366        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    // A non-recursive composite (struct + scalar + string + list). NOTE: phon's
417    // `from_shapes` has no cycle detection, so a self-referential type (via `Box` or
418    // `Vec<Self>`) is unsupported — `Box<T>` errors and `Vec<Self>` stack-overflows.
419    // This test only needs a non-trivial type to verify canonical-schema emission.
420    #[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    // r[verify schema.type-id]
715    // r[verify schema.principles.self-describing]
716    // r[verify schema.principles.once-per-type]
717    // r[verify schema.format.self-contained]
718    // r[verify schema.tracking.transitive]
719    // r[verify schema.method-id]
720    // r[verify rpc.method-id]
721    // r[verify rpc.method-id.algorithm]
722    // r[verify service-macro.is-source-of-truth]
723    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        // The phon service schemas: a per-method `{service}Methods` table of
764        // `PhonMethodSchemas` over a phon `Registry` built from self-describing closure
765        // bytes — not the legacy postcard `schema_registry`.
766        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    // r[verify rpc.caller]
809    // r[verify rpc.service]
810    // r[verify rpc.service.methods]
811    // r[verify rpc.handler]
812    // r[verify lane.service]
813    // r[verify rpc.connection-setup]
814    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    // r[verify rpc.method-id.no-collisions]
923    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    // r[verify schema.exchange.channels]
1011    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        // Channels ride out-of-band now: a `Tx`/`Rx` arg is not a `channel` node inside the
1034        // canonical schema — it is per-method channel metadata (arg index + direction +
1035        // element root) in the `{service}Methods` table, with the arg encoded as a u32 wire
1036        // index. The element schema itself is a normal type in the registry.
1037        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    // r[verify rpc.channel.placement]
1061    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    // r[verify rpc.channel.direct-args]
1076    #[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    // r[verify rpc.channel.no-collections]
1093    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    // r[verify transport.fd.capability]
1111    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        // Canonical schemas are now opaque phon closure bytes, so struct-variant shape is
1150        // verified at the generated-type level: a struct enum variant that has a field
1151        // literally named `kind` must stay a struct variant (named fields, including
1152        // `kind`) — not collapse to a unit/newtype variant.
1153        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    // r[verify rpc.fallible]
1204    // r[verify rpc.fallible.caller-signature]
1205    // r[verify rpc.fallible.vox-error]
1206    // r[verify rpc.fallible.vox-error.outcome]
1207    #[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}