pub mod client;
pub mod http_client;
pub mod phon;
pub mod schema;
pub mod server;
pub mod types;
pub mod wire;
pub use phon::generate_phon_service;
use crate::code_writer::CodeWriter;
use vox_types::{MethodDescriptor, ServiceDescriptor};
pub use client::generate_client;
pub use http_client::generate_http_client;
pub use schema::generate_descriptor;
pub use schema::generate_send_schema_table;
pub use server::generate_server;
pub use types::{collect_named_types, generate_named_types};
pub fn generate_phon_handshake() -> String {
let module =
phon_codegen::Module::from_shapes(&[<vox_types::HandshakeMessage as facet_core::Facet<
'static,
>>::SHAPE])
.expect("derive phon schema for HandshakeMessage");
let mut out = phon_codegen::typescript::render(&module);
let closure =
vox_phon::schema_bytes::<vox_types::HandshakeMessage>().expect("handshake schema bytes");
let mut hex = String::with_capacity(closure.len() * 2);
for b in closure {
hex.push_str(&format!("{b:02x}"));
}
out.push_str(&format!(
"\n/// The local HandshakeMessage schema closure (hex), prepended when sending\n/// a self-describing handshake message.\nexport const handshakeSchemaClosure = \"{hex}\";\n"
));
out
}
pub fn generate_phon_wire() -> String {
let module =
phon_codegen::Module::from_shapes(&[<vox_types::Message<'static> as facet_core::Facet<
'static,
>>::SHAPE])
.expect("derive phon schema for the Message envelope");
let mut out = phon_codegen::typescript::render(&module);
let closure =
vox_phon::schema_bytes::<vox_types::Message<'static>>().expect("Message schema bytes");
let mut hex = String::with_capacity(closure.len() * 2);
for b in closure {
hex.push_str(&format!("{b:02x}"));
}
out.push_str(&format!(
"\n/// The local Message envelope schema closure (hex), advertised in the\n/// handshake's `message_payload_schema`.\nexport const messageSchemaClosure = \"{hex}\";\n"
));
out
}
pub fn generate_method_ids(methods: &[&MethodDescriptor]) -> String {
use crate::render::{fq_name, hex_u64};
let mut items = methods
.iter()
.map(|m| (fq_name(m), m.id.0))
.collect::<Vec<_>>();
items.sort_by(|a, b| a.0.cmp(&b.0));
let mut out = String::new();
out.push_str("// @generated by vox-codegen\n");
out.push_str("// This file defines canonical vox method IDs.\n\n");
out.push_str("export const METHOD_ID: Record<string, bigint> = {\n");
for (name, id) in items {
out.push_str(&format!(" \"{name}\": {}n,\n", hex_u64(id)));
}
out.push_str("} as const;\n");
out
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ServiceGenerationOptions {
pub connect_helper: bool,
}
impl Default for ServiceGenerationOptions {
fn default() -> Self {
Self {
connect_helper: true,
}
}
}
pub fn generate_service(service: &ServiceDescriptor) -> String {
generate_service_with_options(service, ServiceGenerationOptions::default())
}
pub fn generate_service_with_options(
service: &ServiceDescriptor,
options: ServiceGenerationOptions,
) -> String {
use crate::code_writer::CodeWriter;
use crate::cw_writeln;
validate_channel_rules(service);
super::validate_no_fd_shapes(service, "TypeScript");
let mut output = String::new();
let mut w = CodeWriter::with_indent_spaces(&mut output, 2);
cw_writeln!(w, "// @generated by vox-codegen").unwrap();
cw_writeln!(
w,
"// DO NOT EDIT - regenerate with `cargo xtask codegen --typescript`"
)
.unwrap();
w.blank_line().unwrap();
generate_imports(service, &mut w, options);
w.blank_line().unwrap();
let named_types = collect_named_types(service);
output.push_str(&generate_named_types(&named_types));
output.push_str(&generate_request_response_types(service, &named_types));
output.push_str(&generate_phon_service(service));
output.push_str(&client::generate_client_with_connect_helper(
service,
options.connect_helper,
));
output.push_str(&generate_server(service));
output.push_str(&generate_descriptor(service));
let len = output.trim_end_matches('\n').len();
output.truncate(len);
output.push('\n');
output
}
fn validate_channel_rules(service: &ServiceDescriptor) {
for method in service.methods {
assert!(
!vox_types::shape_contains_channel(method.return_shape),
"channels are not allowed in return types: {}.{}",
method.service_name,
method.method_name
);
assert!(
!vox_types::shape_contains_channel_in_collection(method.args_shape),
"channels are not allowed inside collections: {}.{}",
method.service_name,
method.method_name
);
assert!(
!vox_types::shape_contains_indirect_channel_arg(method.args_shape),
"channels are only allowed as direct method arguments: {}.{}",
method.service_name,
method.method_name
);
}
}
fn generate_imports(
service: &ServiceDescriptor,
w: &mut CodeWriter<&mut String>,
options: ServiceGenerationOptions,
) {
use crate::cw_writeln;
use vox_types::{ShapeKind, classify_shape, is_rx, is_tx};
let has_streaming = service
.methods
.iter()
.any(|m| m.args.iter().any(|a| is_tx(a.shape) || is_rx(a.shape)));
let has_fallible = service
.methods
.iter()
.any(|m| matches!(classify_shape(m.return_shape), ShapeKind::Result { .. }));
let has_dynamic = service.methods.iter().any(|m| {
m.args
.iter()
.any(|arg| shape_contains_dynamic_value(arg.shape))
|| shape_contains_dynamic_value(m.return_shape)
});
if options.connect_helper {
cw_writeln!(
w,
"import type {{ Caller, MethodDescriptor, ServiceDescriptor, VoxCall, Dispatcher, RequestContext, ConnectLaneOptions }} from \"@bearcove/vox-core\";"
)
.unwrap();
cw_writeln!(
w,
"import {{ connectLane, voxServiceMetadata }} from \"@bearcove/vox-core\";"
)
.unwrap();
cw_writeln!(w, "import {{ wsConnector }} from \"@bearcove/vox-ws\";").unwrap();
} else {
cw_writeln!(
w,
"import type {{ Caller, MethodDescriptor, ServiceDescriptor, VoxCall, Dispatcher, RequestContext }} from \"@bearcove/vox-core\";"
)
.unwrap();
}
if has_fallible {
cw_writeln!(w, "import {{ RpcError }} from \"@bearcove/vox-core\";").unwrap();
}
if has_streaming {
cw_writeln!(w, "import {{ Tx, Rx }} from \"@bearcove/vox-core\";").unwrap();
}
if has_dynamic {
cw_writeln!(w, "import type {{ Value }} from \"@bearcove/phon-schema\";").unwrap();
}
}
fn shape_contains_dynamic_value(shape: &'static facet_core::Shape) -> bool {
let mut seen = std::collections::HashSet::new();
shape_contains_dynamic_value_inner(shape, &mut seen)
}
fn shape_contains_dynamic_value_inner(
shape: &'static facet_core::Shape,
seen: &mut std::collections::HashSet<&'static facet_core::Shape>,
) -> bool {
if types::is_dynamic_value(shape) {
return true;
}
if !seen.insert(shape) {
return false;
}
match vox_types::classify_shape(shape) {
vox_types::ShapeKind::List { element }
| vox_types::ShapeKind::Slice { element }
| vox_types::ShapeKind::Option { inner: element }
| vox_types::ShapeKind::Array { element, .. }
| vox_types::ShapeKind::Set { element }
| vox_types::ShapeKind::Tx { inner: element }
| vox_types::ShapeKind::Rx { inner: element }
| vox_types::ShapeKind::Pointer { pointee: element } => {
shape_contains_dynamic_value_inner(element, seen)
}
vox_types::ShapeKind::Map { key, value } => {
shape_contains_dynamic_value_inner(key, seen)
|| shape_contains_dynamic_value_inner(value, seen)
}
vox_types::ShapeKind::Tuple { elements } => elements
.iter()
.any(|param| shape_contains_dynamic_value_inner(param.shape, seen)),
vox_types::ShapeKind::TupleStruct { fields }
| vox_types::ShapeKind::Struct(vox_types::StructInfo { fields, .. }) => fields
.iter()
.any(|field| shape_contains_dynamic_value_inner(field.shape(), seen)),
vox_types::ShapeKind::Enum(vox_types::EnumInfo { variants, .. }) => {
variants
.iter()
.any(|variant| match vox_types::classify_variant(variant) {
vox_types::VariantKind::Unit => false,
vox_types::VariantKind::Newtype { inner } => {
shape_contains_dynamic_value_inner(inner, seen)
}
vox_types::VariantKind::Tuple { fields }
| vox_types::VariantKind::Struct { fields } => fields
.iter()
.any(|field| shape_contains_dynamic_value_inner(field.shape(), seen)),
})
}
vox_types::ShapeKind::Result { ok, err } => {
shape_contains_dynamic_value_inner(ok, seen)
|| shape_contains_dynamic_value_inner(err, seen)
}
vox_types::ShapeKind::Scalar(_) | vox_types::ShapeKind::Opaque => false,
}
}
fn generate_request_response_types(
service: &ServiceDescriptor,
named_types: &[(String, &'static facet_core::Shape)],
) -> String {
use heck::ToUpperCamelCase;
use std::collections::HashSet;
use types::ts_type;
let type_names: HashSet<&str> = named_types.iter().map(|(name, _)| name.as_str()).collect();
let mut out = String::new();
out.push_str("// Request/Response type aliases\n");
for method in service.methods {
let method_name = method.method_name.to_upper_camel_case();
let request_name = format!("{method_name}Request");
let response_name = format!("{method_name}Response");
if !type_names.contains(request_name.as_str()) {
if method.args.is_empty() {
out.push_str(&format!("export type {request_name} = [];\n"));
} else if method.args.len() == 1 {
let ty = ts_type(method.args[0].shape);
out.push_str(&format!("export type {request_name} = [{ty}];\n"));
} else {
out.push_str(&format!("export type {request_name} = [\n"));
for arg in method.args {
let ty = ts_type(arg.shape);
out.push_str(&format!(" {ty}, // {}\n", arg.name));
}
out.push_str("];\n");
}
}
if !type_names.contains(response_name.as_str()) {
let ret_ty = ts_type(method.return_shape);
out.push_str(&format!("export type {response_name} = {ret_ty};\n"));
}
out.push('\n');
}
out
}
#[cfg(test)]
mod tests {
#![allow(dead_code)]
use std::collections::BTreeSet;
use super::{ServiceGenerationOptions, generate_service, generate_service_with_options};
use crate::render::hex_u64;
use facet::{Facet, Shape};
use vox_types::{
MethodDescriptor, MethodDescriptorOptions, MethodId, Rx, ServiceDescriptor, Tx,
method_descriptor,
};
fn hex_bytes(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
out.push_str(&format!("{byte:02x}"));
}
out
}
fn parse_self_describing_unique_bundle(
bytes: &[u8],
expected_root: u64,
) -> vox_phon::SchemaBundle {
let bundle = vox_phon::parse_schema_bytes(bytes).expect("schema closure parses");
assert_eq!(bundle.root.as_u64(), expected_root);
let mut seen = BTreeSet::new();
for schema in &bundle.schemas {
assert!(
seen.insert(schema.id.as_u64()),
"schema closure must not repeat type ID {:#x}",
schema.id.as_u64()
);
}
bundle
}
#[derive(Facet)]
struct CompositeNode {
value: u64,
label: String,
tags: Vec<String>,
}
#[derive(Facet)]
struct DynamicTemplateCall {
args: Vec<facet_value::Value>,
kwargs: Vec<(String, facet_value::Value)>,
}
#[derive(Facet)]
struct NestedInner {
value: u32,
}
#[derive(Facet)]
struct NestedOuter {
inner: NestedInner,
}
#[derive(Facet)]
struct NestedChannels {
stream: Tx<u32>,
}
#[derive(Facet)]
struct OuterChannels {
inner: NestedChannels,
}
#[cfg(unix)]
#[derive(Facet)]
struct FdBundle {
descriptors: Vec<vox_types::Fd>,
}
#[derive(Facet)]
#[repr(transparent)]
#[facet(transparent)]
struct SessionId(pub String);
#[derive(Facet)]
struct SessionSummary {
id: SessionId,
}
#[derive(Facet)]
#[repr(u8)]
enum ToolCallKind {
Read,
Execute,
}
#[derive(Facet)]
#[repr(u8)]
enum ToolCallStatus {
Running,
Success,
Failure,
}
#[derive(Facet)]
#[repr(u8)]
enum PermissionResolution {
Approved,
Denied,
}
#[derive(Facet)]
#[repr(u8)]
enum ContentBlock {
Text {
text: String,
},
ToolCall {
id: String,
title: String,
kind: Option<ToolCallKind>,
status: ToolCallStatus,
},
Permission {
id: String,
title: String,
kind: Option<ToolCallKind>,
resolution: Option<PermissionResolution>,
},
}
#[derive(Facet)]
#[repr(u8)]
enum DomNode {
Element { tag: String, children: Vec<String> },
Text(String),
}
#[derive(Facet)]
#[repr(u8)]
enum BlockPatch {
TextAppend {
text: String,
},
ToolCallUpdate {
id: String,
kind: Option<ToolCallKind>,
status: ToolCallStatus,
},
}
#[derive(Facet)]
#[repr(u8)]
enum SessionEvent {
BlockAppend {
block_id: String,
role: String,
block: ContentBlock,
},
BlockPatch {
block_id: String,
role: String,
patch: BlockPatch,
},
}
#[derive(Facet)]
struct SessionEventEnvelope {
seq: u64,
event: SessionEvent,
}
#[derive(Facet)]
#[repr(u8)]
enum SubscribeMessage {
Event(SessionEventEnvelope),
ReplayComplete,
}
fn service_with_shapes(
args_shape: &'static Shape,
return_shape: &'static Shape,
) -> ServiceDescriptor {
let method = Box::leak(Box::new(MethodDescriptor {
id: MethodId(1),
service_name: "BadSvc",
method_name: "bad",
args_shape,
args: &[],
return_shape,
response_wire_shape: <Result<(), vox_types::VoxError> as Facet>::SHAPE,
args_have_channels: vox_types::shape_contains_channel(args_shape),
doc: None,
}));
let methods = Box::leak(vec![method as &'static MethodDescriptor].into_boxed_slice());
ServiceDescriptor {
service_name: "BadSvc",
methods,
doc: None,
}
}
fn dynamic_value_service() -> ServiceDescriptor {
let method = method_descriptor::<(DynamicTemplateCall,), DynamicTemplateCall>(
"DynamicSvc",
"echoDynamic",
&["call"],
&[None],
MethodDescriptorOptions {
response_wire_shape:
<Result<DynamicTemplateCall, vox_types::VoxError> as Facet>::SHAPE,
doc: None,
},
);
let methods = Box::leak(vec![method].into_boxed_slice());
ServiceDescriptor {
service_name: "DynamicSvc",
methods,
doc: None,
}
}
fn panic_message(panic: Box<dyn std::any::Any + Send>) -> String {
panic
.downcast_ref::<String>()
.cloned()
.or_else(|| panic.downcast_ref::<&'static str>().map(|s| s.to_string()))
.expect("panic payload should be a string")
}
#[test]
fn generated_typescript_contains_no_postcard_primitive_usage() {
let echo = method_descriptor::<(String,), String>(
"TestSvc",
"echo",
&["message"],
&[None],
MethodDescriptorOptions {
response_wire_shape: <Result<String, vox_types::VoxError> as Facet>::SHAPE,
doc: None,
},
);
let divide = method_descriptor::<(u64, u64), Result<u64, String>>(
"TestSvc",
"divide",
&["lhs", "rhs"],
&[None, None],
MethodDescriptorOptions {
response_wire_shape: <Result<u64, vox_types::VoxError<String>> as Facet>::SHAPE,
doc: None,
},
);
let methods = Box::leak(vec![echo, divide].into_boxed_slice());
let service = ServiceDescriptor {
service_name: "TestSvc",
methods,
doc: None,
};
let generated = generate_service(&service);
assert!(
!generated.contains("import * as pc from \"@bearcove/vox-postcard\""),
"generated TypeScript must not import postcard primitive namespace:\n{generated}"
);
assert!(
!generated.contains("pc."),
"generated TypeScript must not call postcard primitives directly:\n{generated}"
);
}
#[test]
fn generated_typescript_phon_service_handles_dynamic_value_payloads() {
let service = dynamic_value_service();
let generated = super::phon::generate_phon_service(&service);
assert!(
generated.contains("argsSchemaClosure"),
"dynamic service must still emit args schema closure:\n{generated}"
);
assert!(
generated.contains("responseSchemaClosure"),
"dynamic service must still emit response schema closure:\n{generated}"
);
}
#[test]
fn generated_typescript_uses_value_type_for_dynamic_payloads() {
let service = dynamic_value_service();
let generated = generate_service(&service);
assert!(
generated.contains("import type { Value } from \"@bearcove/phon-schema\";"),
"dynamic payloads must import phon Value:\n{generated}"
);
assert!(
generated.contains("args: Value[];"),
"dynamic Vec<Value> fields must render as Value[]:\n{generated}"
);
assert!(
generated.contains("kwargs: [string, Value][];"),
"dynamic tuple-vector kwargs must render as [string, Value][]:\n{generated}"
);
}
#[test]
fn generated_typescript_testbed_service_handles_dodeca_dynamic_root() {
let service = spec_proto::all_services()
.into_iter()
.find(|service| service.service_name == "Testbed")
.expect("testbed service exists");
let generated = generate_service(service);
assert!(
generated.contains("export interface DodecaTemplateCall"),
"testbed generated service must include the dynamic Dodeca root:\n{generated}"
);
assert!(
generated.contains("import type { Value } from \"@bearcove/phon-schema\";"),
"testbed generated service must import phon Value:\n{generated}"
);
}
#[test]
fn generated_typescript_wire_and_handshake_modules_render() {
let wire = super::generate_phon_wire();
assert!(
wire.contains("messageSchemaClosure"),
"wire module must render message schema closure:\n{wire}"
);
let handshake = super::generate_phon_handshake();
assert!(
handshake.contains("handshakeSchemaClosure"),
"handshake module must render handshake schema closure:\n{handshake}"
);
}
#[test]
fn generated_typescript_uses_canonical_service_schemas() {
let recurse = method_descriptor::<(NestedOuter,), NestedOuter>(
"RecursiveSvc",
"recurse",
&["node"],
&[None],
MethodDescriptorOptions {
response_wire_shape: <Result<NestedOuter, vox_types::VoxError> as Facet>::SHAPE,
doc: None,
},
);
let methods = Box::leak(vec![recurse].into_boxed_slice());
let service = ServiceDescriptor {
service_name: "RecursiveSvc",
methods,
doc: None,
};
let generated = generate_service(&service);
let args_root = vox_phon::schema_id_for_shape(recurse.args_shape).expect("args schema id");
let response_root =
vox_phon::schema_id_for_shape(recurse.response_wire_shape).expect("response schema id");
let args_closure =
vox_phon::schema_bytes_for_shape(recurse.args_shape).expect("args schema closure");
let response_closure = vox_phon::schema_bytes_for_shape(recurse.response_wire_shape)
.expect("response schema closure");
let args_bundle = parse_self_describing_unique_bundle(&args_closure, args_root.as_u64());
let _response_bundle =
parse_self_describing_unique_bundle(&response_closure, response_root.as_u64());
let nested_inner_id =
vox_phon::schema_id_for_shape(<NestedInner as Facet>::SHAPE).expect("inner schema id");
assert_eq!(args_bundle.root, args_root);
assert!(
args_bundle
.schemas
.iter()
.any(|schema| schema.id == nested_inner_id),
"args closure must carry transitively referenced NestedInner schema"
);
assert!(
generated.contains("recursiveSvcMethods")
&& generated.contains("PhonMethodSchemas")
&& generated.contains("recursiveSvcRegistry = new Registry("),
"generated TypeScript must include the phon service schemas:\n{generated}"
);
assert!(
!generated.contains("schema_registry"),
"generated TypeScript must not include the legacy schema registry:\n{generated}"
);
assert!(
generated.contains(&format!(" \"{}\": {{", hex_u64(crate::method_id(recurse)))),
"generated TypeScript method table must use the canonical Vox method ID:\n{generated}"
);
assert!(
generated.contains(&format!("argsRoot: {}n", hex_u64(args_root.as_u64()))),
"generated TypeScript must use the phon args schema ID:\n{generated}"
);
assert!(
generated.contains(&format!(
"responseRoot: {}n",
hex_u64(response_root.as_u64())
)),
"generated TypeScript must use the phon response schema ID:\n{generated}"
);
assert!(
generated.contains(&format!(
"argsSchemaClosure: \"{}\"",
hex_bytes(&args_closure)
)),
"generated TypeScript must embed the self-contained phon args closure:\n{generated}"
);
assert!(
generated.contains(&format!(
"responseSchemaClosure: \"{}\"",
hex_bytes(&response_closure)
)),
"generated TypeScript must embed the self-contained phon response closure:\n{generated}"
);
}
#[test]
fn generated_typescript_emits_rpc_caller_handler_and_lane_shapes() {
let echo = method_descriptor::<(String,), String>(
"TestSvc",
"echo",
&["message"],
&[None],
MethodDescriptorOptions {
response_wire_shape: <Result<String, vox_types::VoxError> as Facet>::SHAPE,
doc: None,
},
);
let divide = method_descriptor::<(u64, u64), Result<u64, String>>(
"TestSvc",
"divide",
&["lhs", "rhs"],
&[None, None],
MethodDescriptorOptions {
response_wire_shape: <Result<u64, vox_types::VoxError<String>> as Facet>::SHAPE,
doc: None,
},
);
let methods = Box::leak(vec![echo, divide].into_boxed_slice());
let service = ServiceDescriptor {
service_name: "TestSvc",
methods,
doc: None,
};
let generated = generate_service(&service);
assert!(
generated.contains("export interface TestSvcCaller {")
&& generated.contains(" async echo(message: string): Promise<string> {")
&& generated.contains(" async divide(lhs: bigint, rhs: bigint):"),
"generated TypeScript must expose async caller methods matching the source service:\n{generated}"
);
assert!(
generated.contains("export class TestSvcClient implements TestSvcCaller")
&& generated.contains("static get descriptor(): ServiceDescriptor {")
&& generated.contains("return testSvc_descriptor;")
&& generated.contains("private caller: Caller;")
&& generated.contains("const __voxResult = await this.caller.call({")
&& generated.contains("method: \"TestSvc.echo\",")
&& generated.contains("descriptor: testSvc_echo_method,")
&& generated.contains("methodSchemas: testSvcMethods["),
"generated TypeScript client must delegate request serialization/response handling to Caller:\n{generated}"
);
assert!(
generated.contains("return connectLane(wsConnector(url), TestSvcClient, {")
&& generated.contains(
"laneMetadata: options.laneMetadata ?? voxServiceMetadata(\"TestSvc\"),"
),
"generated TypeScript connect helper must open a service lane:\n{generated}"
);
assert!(
generated.contains("export interface TestSvcHandler {")
&& generated.contains(" echo(message: string): Promise<string> | string;")
&& generated.contains(" divide(lhs: bigint, rhs: bigint):"),
"generated TypeScript handler must expose service methods with server-side argument types:\n{generated}"
);
assert!(
generated.contains("export class TestSvcDispatcher implements Dispatcher")
&& generated.contains("private readonly handler: TestSvcHandler;")
&& generated.contains("getDescriptor(): ServiceDescriptor")
&& generated.contains("return testSvc_descriptor;")
&& generated.contains("const result = await this.handler.echo(args[0] as string);")
&& generated.contains(
"if (result.ok) call.reply(result.value); else call.replyErr(result.error);"
),
"generated TypeScript dispatcher must route decoded args to the handler and reply through VoxCall:\n{generated}"
);
}
#[test]
fn generated_typescript_can_omit_websocket_connect_helper() {
let ping = method_descriptor::<(), u64>(
"TestSvc",
"ping",
&[],
&[],
MethodDescriptorOptions {
response_wire_shape: <Result<u64, vox_types::VoxError> as Facet>::SHAPE,
doc: None,
},
);
let methods = Box::leak(vec![ping].into_boxed_slice());
let service = ServiceDescriptor {
service_name: "TestSvc",
methods,
doc: None,
};
let generated = generate_service_with_options(
&service,
ServiceGenerationOptions {
connect_helper: false,
},
);
assert!(generated.contains("export class TestSvcClient implements TestSvcCaller"));
assert!(!generated.contains("ConnectLaneOptions"));
assert!(!generated.contains("connectLane"));
assert!(!generated.contains("voxServiceMetadata"));
assert!(!generated.contains("wsConnector"));
assert!(!generated.contains("export async function connectTestSvc"));
}
#[test]
fn generated_typescript_method_ids_include_service_name() {
let alpha = method_descriptor::<(), ()>(
"AlphaSvc",
"echo",
&[],
&[],
MethodDescriptorOptions {
response_wire_shape: <Result<(), vox_types::VoxError> as Facet>::SHAPE,
doc: None,
},
);
let beta = method_descriptor::<(), ()>(
"BetaSvc",
"echo",
&[],
&[],
MethodDescriptorOptions {
response_wire_shape: <Result<(), vox_types::VoxError> as Facet>::SHAPE,
doc: None,
},
);
assert_ne!(crate::method_id(alpha), crate::method_id(beta));
let alpha_methods = Box::leak(vec![alpha].into_boxed_slice());
let beta_methods = Box::leak(vec![beta].into_boxed_slice());
let alpha_service = ServiceDescriptor {
service_name: "AlphaSvc",
methods: alpha_methods,
doc: None,
};
let beta_service = ServiceDescriptor {
service_name: "BetaSvc",
methods: beta_methods,
doc: None,
};
let alpha_generated = generate_service(&alpha_service);
let beta_generated = generate_service(&beta_service);
let alpha_entry = format!(" \"{}\": {{", hex_u64(crate::method_id(alpha)));
let beta_entry = format!(" \"{}\": {{", hex_u64(crate::method_id(beta)));
assert!(
alpha_generated.contains(&alpha_entry),
"Alpha service must emit its service-qualified method ID:\n{alpha_generated}"
);
assert!(
!alpha_generated.contains(&beta_entry),
"Alpha service must not reuse Beta service's same-method-name ID:\n{alpha_generated}"
);
assert!(
beta_generated.contains(&beta_entry),
"Beta service must emit its service-qualified method ID:\n{beta_generated}"
);
}
#[test]
fn generated_typescript_emits_alias_for_transparent_newtype() {
let summarize = method_descriptor::<(SessionId,), SessionSummary>(
"SessionSvc",
"summarize",
&["id"],
&[None],
MethodDescriptorOptions {
response_wire_shape: <Result<SessionSummary, vox_types::VoxError> as Facet>::SHAPE,
doc: None,
},
);
let methods = Box::leak(vec![summarize].into_boxed_slice());
let service = ServiceDescriptor {
service_name: "SessionSvc",
methods,
doc: None,
};
let generated = generate_service(&service);
assert!(
generated.contains("export type SessionId = string;"),
"transparent named newtypes must emit a type alias:\n{generated}"
);
assert!(
generated.contains("id: SessionId;"),
"uses of transparent named newtypes must keep alias name:\n{generated}"
);
}
#[test]
fn generated_typescript_emits_channel_schemas() {
let subscribe = method_descriptor::<(Tx<NestedInner>, Rx<NestedInner>), ()>(
"StreamSvc",
"subscribe",
&["output", "input"],
&[
Some(<NestedInner as facet::Facet>::SHAPE),
Some(<NestedInner as facet::Facet>::SHAPE),
],
MethodDescriptorOptions {
response_wire_shape: <Result<(), vox_types::VoxError> as Facet>::SHAPE,
doc: None,
},
);
let methods = Box::leak(vec![subscribe].into_boxed_slice());
let service = ServiceDescriptor {
service_name: "StreamSvc",
methods,
doc: None,
};
let generated = generate_service(&service);
assert!(
generated.contains("channels: [{ index: 0, direction: \"tx\""),
"Tx<T> arg must be emitted as out-of-band channel metadata:\n{generated}"
);
assert!(
generated.contains("direction: \"rx\", elementRoot:"),
"Rx<T> arg must be emitted as out-of-band channel metadata:\n{generated}"
);
assert!(
generated.contains("6368616e6e656c2e6172672e302e74782e656c656d656e74"),
"Tx<T> element root must be carried as an auxiliary schema root:\n{generated}"
);
assert!(
generated.contains("6368616e6e656c2e6172672e312e72782e656c656d656e74"),
"Rx<T> element root must be carried as an auxiliary schema root:\n{generated}"
);
assert!(
generated.contains("export interface NestedInner"),
"named channel element types must be emitted as normal generated types:\n{generated}"
);
}
#[test]
fn generated_typescript_rejects_channels_in_return_shapes() {
let service = service_with_shapes(<() as Facet>::SHAPE, <Tx<u32> as Facet>::SHAPE);
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
generate_service(&service);
}));
let message = panic_message(result.expect_err("generator should reject channel returns"));
assert!(
message.contains("channels are not allowed in return types: BadSvc.bad"),
"unexpected panic message: {message}"
);
}
#[test]
fn generated_typescript_rejects_nested_channel_args() {
let service = service_with_shapes(<(OuterChannels,) as Facet>::SHAPE, <() as Facet>::SHAPE);
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
generate_service(&service);
}));
let message = panic_message(result.expect_err("generator should reject nested channels"));
assert!(
message.contains("channels are only allowed as direct method arguments: BadSvc.bad"),
"unexpected panic message: {message}"
);
}
#[test]
fn generated_typescript_rejects_channels_inside_collections() {
let service = service_with_shapes(<(Vec<Tx<u32>>,) as Facet>::SHAPE, <() as Facet>::SHAPE);
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
generate_service(&service);
}));
let message =
panic_message(result.expect_err("generator should reject collection channels"));
assert!(
message.contains("channels are not allowed inside collections: BadSvc.bad"),
"unexpected panic message: {message}"
);
}
#[cfg(unix)]
#[test]
fn generated_typescript_rejects_fd_capability_surfaces() {
let service = service_with_shapes(<() as Facet>::SHAPE, <FdBundle as Facet>::SHAPE);
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
generate_service(&service);
}));
let message =
panic_message(result.expect_err("generator should reject fd capability surfaces"));
assert!(
message.contains(
"TypeScript codegen does not support vox::Fd capabilities: BadSvc.bad return type"
),
"unexpected panic message: {message}"
);
}
#[test]
fn generated_typescript_keeps_struct_variants_with_kind_fields_named() {
let subscribe = method_descriptor::<(), SubscribeMessage>(
"ShipSvc",
"subscribe",
&[],
&[],
MethodDescriptorOptions {
response_wire_shape:
<Result<SubscribeMessage, vox_types::VoxError> as Facet>::SHAPE,
doc: None,
},
);
let methods = Box::leak(vec![subscribe].into_boxed_slice());
let service = ServiceDescriptor {
service_name: "ShipSvc",
methods,
doc: None,
};
let generated = generate_service(&service);
assert!(
generated.contains(
"| { tag: 'ToolCall'; id: string; title: string; kind: ToolCallKind | null; status: ToolCallStatus }"
),
"struct variants with a field named `kind` must stay struct variants in the generated type:\n{generated}"
);
assert!(
generated.contains(
"| { tag: 'Permission'; id: string; title: string; kind: ToolCallKind | null; resolution: PermissionResolution | null }"
),
"similar struct variants must keep their named `kind` field in the generated type:\n{generated}"
);
assert!(
generated.contains(
"| { tag: 'ToolCallUpdate'; id: string; kind: ToolCallKind | null; status: ToolCallStatus }"
),
"patch variants with a field named `kind` must also stay struct variants in the generated type:\n{generated}"
);
}
#[test]
fn generated_typescript_uses_alternate_discriminator_for_tag_payload_fields() {
let parse = method_descriptor::<(), DomNode>(
"DomSvc",
"parse",
&[],
&[],
MethodDescriptorOptions {
response_wire_shape: <Result<DomNode, vox_types::VoxError> as Facet>::SHAPE,
doc: None,
},
);
let methods = Box::leak(vec![parse].into_boxed_slice());
let service = ServiceDescriptor {
service_name: "DomSvc",
methods,
doc: None,
};
let generated = generate_service(&service);
assert!(
generated.contains("{ $tag: 'Element'; tag: string; children: string[] }"),
"struct variants with a field named `tag` must use `$tag` as the discriminant:\n{generated}"
);
assert!(
generated.contains("| { $tag: 'Text'; value: string }"),
"all variants in that enum must share the `$tag` discriminant:\n{generated}"
);
}
#[test]
fn generated_typescript_avoids_parameter_properties_and_types_catch_error() {
let divide = method_descriptor::<(u64, u64), Result<u64, String>>(
"StrictSvc",
"divide",
&["lhs", "rhs"],
&[None, None],
MethodDescriptorOptions {
response_wire_shape: <Result<u64, vox_types::VoxError<String>> as Facet>::SHAPE,
doc: None,
},
);
let methods = Box::leak(vec![divide].into_boxed_slice());
let service = ServiceDescriptor {
service_name: "StrictSvc",
methods,
doc: None,
};
let generated = generate_service(&service);
assert!(
!generated.contains("constructor(private readonly handler"),
"generated TypeScript must avoid constructor parameter properties:\n{generated}"
);
assert!(
generated.contains("private readonly handler: StrictSvcHandler;"),
"dispatcher must emit an explicit handler field:\n{generated}"
);
assert!(
generated.contains("constructor(handler: StrictSvcHandler)"),
"dispatcher constructor must use explicit assignment parameter:\n{generated}"
);
assert!(
generated.contains("catch (e: any)"),
"fallible client methods must type catch binding for strict TypeScript:\n{generated}"
);
assert!(
generated.contains("return { ok: true, value: __voxResult }"),
"fallible client methods must wrap successful responses:\n{generated}"
);
assert!(
generated.contains("if (e instanceof RpcError && e.isUserError())"),
"fallible client methods must distinguish user errors from protocol/runtime errors:\n{generated}"
);
assert!(
generated.contains("return { ok: false, error: e.userError }"),
"fallible client methods must return user errors in the public Result shape:\n{generated}"
);
assert!(
generated.contains("throw e;"),
"fallible client methods must rethrow non-user errors:\n{generated}"
);
}
}