pub mod client;
pub mod http_client;
pub mod schema;
pub mod server;
pub mod types;
pub mod wire;
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_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
}
pub fn generate_service(service: &ServiceDescriptor) -> String {
use crate::code_writer::CodeWriter;
use crate::cw_writeln;
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);
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_client(service));
output.push_str(&generate_server(service));
output.push_str(&generate_send_schema_table(service));
output.push_str(&generate_descriptor(service));
output
}
fn generate_imports(service: &ServiceDescriptor, w: &mut CodeWriter<&mut String>) {
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 { .. }));
cw_writeln!(
w,
"import type {{ Caller, MethodDescriptor, ServiceDescriptor, VoxCall, Dispatcher, RequestContext, SessionTransportOptions }} from \"@bearcove/vox-core\";"
)
.unwrap();
cw_writeln!(
w,
"import {{ session, voxServiceMetadata }} from \"@bearcove/vox-core\";"
)
.unwrap();
cw_writeln!(w, "import {{ wsConnector }} from \"@bearcove/vox-ws\";").unwrap();
if has_fallible {
cw_writeln!(w, "import {{ RpcError }} from \"@bearcove/vox-core\";").unwrap();
}
if has_streaming {
cw_writeln!(
w,
"import {{ Tx, Rx, argElementRefsForMethod, bindChannelsForTypeRefs, finalizeBoundChannelsForTypeRefs }} from \"@bearcove/vox-core\";"
)
.unwrap();
}
}
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 super::generate_service;
use facet::Facet;
use vox_types::{
RetryPolicy, Rx, ServiceDescriptor, Tx, method_descriptor, method_descriptor_with_retry,
};
#[derive(Facet)]
struct RecursiveNode {
next: Option<Box<RecursiveNode>>,
}
#[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 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,
}
#[test]
fn generated_typescript_contains_no_postcard_primitive_usage() {
let echo = method_descriptor::<(String,), String>("TestSvc", "echo", &["message"], None);
let divide = method_descriptor::<(u64, u64), Result<u64, String>>(
"TestSvc",
"divide",
&["lhs", "rhs"],
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_uses_canonical_service_schemas() {
let recurse = method_descriptor::<(RecursiveNode,), RecursiveNode>(
"RecursiveSvc",
"recurse",
&["node"],
None,
);
let methods = Box::leak(vec![recurse].into_boxed_slice());
let service = ServiceDescriptor {
service_name: "RecursiveSvc",
methods,
doc: None,
};
let generated = generate_service(&service);
assert!(
generated.contains("send_schemas"),
"generated TypeScript must include canonical service schemas:\n{generated}"
);
assert!(
!generated.contains("schema_registry"),
"generated TypeScript must not include the legacy schema registry:\n{generated}"
);
}
#[test]
fn generated_typescript_emits_alias_for_transparent_newtype() {
let summarize = method_descriptor::<(SessionId,), SessionSummary>(
"SessionSvc",
"summarize",
&["id"],
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<u32>, Rx<u32>), ()>(
"StreamSvc",
"subscribe",
&["output", "input"],
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("kind: { tag: 'channel', direction: 'tx'"),
"Tx<T> must be emitted into canonical service schemas:\n{generated}"
);
assert!(
generated.contains("kind: { tag: 'channel', direction: 'rx'"),
"Rx<T> must be emitted into canonical service schemas:\n{generated}"
);
}
#[test]
fn generated_typescript_emits_retry_policy_on_method_descriptors() {
let fetch = method_descriptor_with_retry::<(), u64>(
"RetrySvc",
"fetch",
&[],
None,
RetryPolicy::IDEM,
);
let effect = method_descriptor_with_retry::<(), Result<u64, String>>(
"RetrySvc",
"effect",
&[],
None,
RetryPolicy::PERSIST,
);
let methods = Box::leak(vec![fetch, effect].into_boxed_slice());
let service = ServiceDescriptor {
service_name: "RetrySvc",
methods,
doc: None,
};
let generated = generate_service(&service);
assert!(
generated.contains("retry: { persist: false, idem: true }"),
"generated TypeScript must include retry policy for idem methods:\n{generated}"
);
assert!(
generated.contains("retry: { persist: true, idem: false }"),
"generated TypeScript must include retry policy for persist methods:\n{generated}"
);
}
#[test]
fn generated_typescript_keeps_struct_variants_with_kind_fields_named() {
let subscribe =
method_descriptor::<(), SubscribeMessage>("ShipSvc", "subscribe", &[], 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(
"name: 'ToolCall', index: 1, payload: { tag: 'struct', fields: [{ name: 'id'"
),
"struct variants with a field named `kind` must stay struct variants in canonical schemas:\n{generated}"
);
assert!(
generated.contains(
"name: 'Permission', index: 2, payload: { tag: 'struct', fields: [{ name: 'id'"
),
"similar struct variants must keep their named `kind` field in canonical schemas:\n{generated}"
);
assert!(
generated.contains(
"name: 'ToolCallUpdate', index: 1, payload: { tag: 'struct', fields: [{ name: 'id'"
),
"patch variants with a field named `kind` must also stay struct variants in canonical schemas:\n{generated}"
);
assert!(
generated.contains("{ name: 'kind', type_ref:"),
"canonical struct variants must preserve the literal field name `kind`:\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,
);
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}"
);
}
}