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 schema;
14pub mod server;
15pub mod types;
16pub mod wire;
17
18use crate::code_writer::CodeWriter;
19use vox_types::{MethodDescriptor, ServiceDescriptor};
20
21pub use client::generate_client;
22pub use http_client::generate_http_client;
23pub use schema::generate_descriptor;
24pub use schema::generate_send_schema_table;
25pub use server::generate_server;
26pub use types::{collect_named_types, generate_named_types};
27
28/// Generate method IDs as a TypeScript constant record.
29pub fn generate_method_ids(methods: &[&MethodDescriptor]) -> String {
30    use crate::render::{fq_name, hex_u64};
31
32    let mut items = methods
33        .iter()
34        .map(|m| (fq_name(m), m.id.0))
35        .collect::<Vec<_>>();
36    items.sort_by(|a, b| a.0.cmp(&b.0));
37
38    let mut out = String::new();
39    out.push_str("// @generated by vox-codegen\n");
40    out.push_str("// This file defines canonical vox method IDs.\n\n");
41    out.push_str("export const METHOD_ID: Record<string, bigint> = {\n");
42    for (name, id) in items {
43        out.push_str(&format!("  \"{name}\": {}n,\n", hex_u64(id)));
44    }
45    out.push_str("} as const;\n");
46    out
47}
48
49/// Generate a complete TypeScript module for a service.
50///
51/// This is the main entry point for TypeScript code generation.
52pub fn generate_service(service: &ServiceDescriptor) -> String {
53    use crate::code_writer::CodeWriter;
54    use crate::cw_writeln;
55
56    let mut output = String::new();
57    let mut w = CodeWriter::with_indent_spaces(&mut output, 2);
58
59    // Header
60    cw_writeln!(w, "// @generated by vox-codegen").unwrap();
61    cw_writeln!(
62        w,
63        "// DO NOT EDIT - regenerate with `cargo xtask codegen --typescript`"
64    )
65    .unwrap();
66    w.blank_line().unwrap();
67
68    generate_imports(service, &mut w);
69    w.blank_line().unwrap();
70
71    // Named types (structs and enums)
72    let named_types = collect_named_types(service);
73    output.push_str(&generate_named_types(&named_types));
74
75    // Request/Response type aliases
76    output.push_str(&generate_request_response_types(service, &named_types));
77
78    // Client
79    output.push_str(&generate_client(service));
80
81    // Server (handler interface + dispatcher)
82    output.push_str(&generate_server(service));
83
84    // Pre-computed CBOR send schema table (must come before descriptor)
85    output.push_str(&generate_send_schema_table(service));
86
87    // Service descriptor
88    output.push_str(&generate_descriptor(service));
89
90    output
91}
92
93/// Generate imports for the service module.
94fn generate_imports(service: &ServiceDescriptor, w: &mut CodeWriter<&mut String>) {
95    use crate::cw_writeln;
96    use vox_types::{ShapeKind, classify_shape, is_rx, is_tx};
97
98    // Check if any method uses channels in args.
99    let has_streaming = service
100        .methods
101        .iter()
102        .any(|m| m.args.iter().any(|a| is_tx(a.shape) || is_rx(a.shape)));
103
104    // Check if any method returns Result<T, E> (fallible methods)
105    let has_fallible = service
106        .methods
107        .iter()
108        .any(|m| matches!(classify_shape(m.return_shape), ShapeKind::Result { .. }));
109
110    // Core runtime: descriptor types + Caller + session/conduit helpers
111    cw_writeln!(
112        w,
113        "import type {{ Caller, MethodDescriptor, ServiceDescriptor, VoxCall, Dispatcher, RequestContext, SessionTransportOptions }} from \"@bearcove/vox-core\";"
114    )
115    .unwrap();
116    cw_writeln!(
117        w,
118        "import {{ session, voxServiceMetadata }} from \"@bearcove/vox-core\";"
119    )
120    .unwrap();
121
122    // WebSocket transport for connect helper
123    cw_writeln!(w, "import {{ wsConnector }} from \"@bearcove/vox-ws\";").unwrap();
124
125    // RpcError for fallible client methods
126    if has_fallible {
127        cw_writeln!(w, "import {{ RpcError }} from \"@bearcove/vox-core\";").unwrap();
128    }
129
130    // Tx/Rx and bindChannels for streaming handler args and type aliases
131    if has_streaming {
132        cw_writeln!(
133            w,
134            "import {{ Tx, Rx, argElementRefsForMethod, bindChannelsForTypeRefs, finalizeBoundChannelsForTypeRefs }} from \"@bearcove/vox-core\";"
135        )
136        .unwrap();
137    }
138}
139
140/// Generate request/response type aliases, skipping any that conflict with named types
141fn generate_request_response_types(
142    service: &ServiceDescriptor,
143    named_types: &[(String, &'static facet_core::Shape)],
144) -> String {
145    use heck::ToUpperCamelCase;
146    use std::collections::HashSet;
147    use types::ts_type;
148
149    // Collect just the type names for conflict checking
150    let type_names: HashSet<&str> = named_types.iter().map(|(name, _)| name.as_str()).collect();
151
152    let mut out = String::new();
153    out.push_str("// Request/Response type aliases\n");
154
155    for method in service.methods {
156        let method_name = method.method_name.to_upper_camel_case();
157        let request_name = format!("{method_name}Request");
158        let response_name = format!("{method_name}Response");
159
160        // Only generate request type alias if it doesn't conflict with a named type
161        if !type_names.contains(request_name.as_str()) {
162            if method.args.is_empty() {
163                out.push_str(&format!("export type {request_name} = [];\n"));
164            } else if method.args.len() == 1 {
165                let ty = ts_type(method.args[0].shape);
166                out.push_str(&format!("export type {request_name} = [{ty}];\n"));
167            } else {
168                out.push_str(&format!("export type {request_name} = [\n"));
169                for arg in method.args {
170                    let ty = ts_type(arg.shape);
171                    out.push_str(&format!("  {ty}, // {}\n", arg.name));
172                }
173                out.push_str("];\n");
174            }
175        }
176
177        // Only generate response type alias if it doesn't conflict with a named type
178        if !type_names.contains(response_name.as_str()) {
179            let ret_ty = ts_type(method.return_shape);
180            out.push_str(&format!("export type {response_name} = {ret_ty};\n"));
181        }
182
183        out.push('\n');
184    }
185
186    out
187}
188
189#[cfg(test)]
190mod tests {
191    #![allow(dead_code)]
192
193    use super::generate_service;
194    use facet::Facet;
195    use vox_types::{
196        RetryPolicy, Rx, ServiceDescriptor, Tx, method_descriptor, method_descriptor_with_retry,
197    };
198
199    #[derive(Facet)]
200    struct RecursiveNode {
201        next: Option<Box<RecursiveNode>>,
202    }
203
204    #[derive(Facet)]
205    #[repr(transparent)]
206    #[facet(transparent)]
207    struct SessionId(pub String);
208
209    #[derive(Facet)]
210    struct SessionSummary {
211        id: SessionId,
212    }
213
214    #[derive(Facet)]
215    #[repr(u8)]
216    enum ToolCallKind {
217        Read,
218        Execute,
219    }
220
221    #[derive(Facet)]
222    #[repr(u8)]
223    enum ToolCallStatus {
224        Running,
225        Success,
226        Failure,
227    }
228
229    #[derive(Facet)]
230    #[repr(u8)]
231    enum PermissionResolution {
232        Approved,
233        Denied,
234    }
235
236    #[derive(Facet)]
237    #[repr(u8)]
238    enum ContentBlock {
239        Text {
240            text: String,
241        },
242        ToolCall {
243            id: String,
244            title: String,
245            kind: Option<ToolCallKind>,
246            status: ToolCallStatus,
247        },
248        Permission {
249            id: String,
250            title: String,
251            kind: Option<ToolCallKind>,
252            resolution: Option<PermissionResolution>,
253        },
254    }
255
256    #[derive(Facet)]
257    #[repr(u8)]
258    enum BlockPatch {
259        TextAppend {
260            text: String,
261        },
262        ToolCallUpdate {
263            id: String,
264            kind: Option<ToolCallKind>,
265            status: ToolCallStatus,
266        },
267    }
268
269    #[derive(Facet)]
270    #[repr(u8)]
271    enum SessionEvent {
272        BlockAppend {
273            block_id: String,
274            role: String,
275            block: ContentBlock,
276        },
277        BlockPatch {
278            block_id: String,
279            role: String,
280            patch: BlockPatch,
281        },
282    }
283
284    #[derive(Facet)]
285    struct SessionEventEnvelope {
286        seq: u64,
287        event: SessionEvent,
288    }
289
290    #[derive(Facet)]
291    #[repr(u8)]
292    enum SubscribeMessage {
293        Event(SessionEventEnvelope),
294        ReplayComplete,
295    }
296
297    #[test]
298    fn generated_typescript_contains_no_postcard_primitive_usage() {
299        let echo = method_descriptor::<(String,), String>("TestSvc", "echo", &["message"], None);
300        let divide = method_descriptor::<(u64, u64), Result<u64, String>>(
301            "TestSvc",
302            "divide",
303            &["lhs", "rhs"],
304            None,
305        );
306        let methods = Box::leak(vec![echo, divide].into_boxed_slice());
307        let service = ServiceDescriptor {
308            service_name: "TestSvc",
309            methods,
310            doc: None,
311        };
312
313        let generated = generate_service(&service);
314        assert!(
315            !generated.contains("import * as pc from \"@bearcove/vox-postcard\""),
316            "generated TypeScript must not import postcard primitive namespace:\n{generated}"
317        );
318        assert!(
319            !generated.contains("pc."),
320            "generated TypeScript must not call postcard primitives directly:\n{generated}"
321        );
322    }
323
324    #[test]
325    fn generated_typescript_uses_canonical_service_schemas() {
326        let recurse = method_descriptor::<(RecursiveNode,), RecursiveNode>(
327            "RecursiveSvc",
328            "recurse",
329            &["node"],
330            None,
331        );
332        let methods = Box::leak(vec![recurse].into_boxed_slice());
333        let service = ServiceDescriptor {
334            service_name: "RecursiveSvc",
335            methods,
336            doc: None,
337        };
338
339        let generated = generate_service(&service);
340        assert!(
341            generated.contains("send_schemas"),
342            "generated TypeScript must include canonical service schemas:\n{generated}"
343        );
344        assert!(
345            !generated.contains("schema_registry"),
346            "generated TypeScript must not include the legacy schema registry:\n{generated}"
347        );
348    }
349
350    #[test]
351    fn generated_typescript_emits_alias_for_transparent_newtype() {
352        let summarize = method_descriptor::<(SessionId,), SessionSummary>(
353            "SessionSvc",
354            "summarize",
355            &["id"],
356            None,
357        );
358        let methods = Box::leak(vec![summarize].into_boxed_slice());
359        let service = ServiceDescriptor {
360            service_name: "SessionSvc",
361            methods,
362            doc: None,
363        };
364
365        let generated = generate_service(&service);
366        assert!(
367            generated.contains("export type SessionId = string;"),
368            "transparent named newtypes must emit a type alias:\n{generated}"
369        );
370        assert!(
371            generated.contains("id: SessionId;"),
372            "uses of transparent named newtypes must keep alias name:\n{generated}"
373        );
374    }
375
376    #[test]
377    fn generated_typescript_emits_channel_schemas() {
378        let subscribe = method_descriptor::<(Tx<u32>, Rx<u32>), ()>(
379            "StreamSvc",
380            "subscribe",
381            &["output", "input"],
382            None,
383        );
384        let methods = Box::leak(vec![subscribe].into_boxed_slice());
385        let service = ServiceDescriptor {
386            service_name: "StreamSvc",
387            methods,
388            doc: None,
389        };
390
391        let generated = generate_service(&service);
392        assert!(
393            generated.contains("kind: { tag: 'channel', direction: 'tx'"),
394            "Tx<T> must be emitted into canonical service schemas:\n{generated}"
395        );
396        assert!(
397            generated.contains("kind: { tag: 'channel', direction: 'rx'"),
398            "Rx<T> must be emitted into canonical service schemas:\n{generated}"
399        );
400    }
401
402    #[test]
403    fn generated_typescript_emits_retry_policy_on_method_descriptors() {
404        let fetch = method_descriptor_with_retry::<(), u64>(
405            "RetrySvc",
406            "fetch",
407            &[],
408            None,
409            RetryPolicy::IDEM,
410        );
411        let effect = method_descriptor_with_retry::<(), Result<u64, String>>(
412            "RetrySvc",
413            "effect",
414            &[],
415            None,
416            RetryPolicy::PERSIST,
417        );
418        let methods = Box::leak(vec![fetch, effect].into_boxed_slice());
419        let service = ServiceDescriptor {
420            service_name: "RetrySvc",
421            methods,
422            doc: None,
423        };
424
425        let generated = generate_service(&service);
426        assert!(
427            generated.contains("retry: { persist: false, idem: true }"),
428            "generated TypeScript must include retry policy for idem methods:\n{generated}"
429        );
430        assert!(
431            generated.contains("retry: { persist: true, idem: false }"),
432            "generated TypeScript must include retry policy for persist methods:\n{generated}"
433        );
434    }
435
436    #[test]
437    fn generated_typescript_keeps_struct_variants_with_kind_fields_named() {
438        let subscribe =
439            method_descriptor::<(), SubscribeMessage>("ShipSvc", "subscribe", &[], None);
440        let methods = Box::leak(vec![subscribe].into_boxed_slice());
441        let service = ServiceDescriptor {
442            service_name: "ShipSvc",
443            methods,
444            doc: None,
445        };
446
447        let generated = generate_service(&service);
448        assert!(
449            generated.contains(
450                "name: 'ToolCall', index: 1, payload: { tag: 'struct', fields: [{ name: 'id'"
451            ),
452            "struct variants with a field named `kind` must stay struct variants in canonical schemas:\n{generated}"
453        );
454        assert!(
455            generated.contains(
456                "name: 'Permission', index: 2, payload: { tag: 'struct', fields: [{ name: 'id'"
457            ),
458            "similar struct variants must keep their named `kind` field in canonical schemas:\n{generated}"
459        );
460        assert!(
461            generated.contains(
462                "name: 'ToolCallUpdate', index: 1, payload: { tag: 'struct', fields: [{ name: 'id'"
463            ),
464            "patch variants with a field named `kind` must also stay struct variants in canonical schemas:\n{generated}"
465        );
466        assert!(
467            generated.contains("{ name: 'kind', type_ref:"),
468            "canonical struct variants must preserve the literal field name `kind`:\n{generated}"
469        );
470    }
471
472    #[test]
473    fn generated_typescript_avoids_parameter_properties_and_types_catch_error() {
474        let divide = method_descriptor::<(u64, u64), Result<u64, String>>(
475            "StrictSvc",
476            "divide",
477            &["lhs", "rhs"],
478            None,
479        );
480        let methods = Box::leak(vec![divide].into_boxed_slice());
481        let service = ServiceDescriptor {
482            service_name: "StrictSvc",
483            methods,
484            doc: None,
485        };
486
487        let generated = generate_service(&service);
488        assert!(
489            !generated.contains("constructor(private readonly handler"),
490            "generated TypeScript must avoid constructor parameter properties:\n{generated}"
491        );
492        assert!(
493            generated.contains("private readonly handler: StrictSvcHandler;"),
494            "dispatcher must emit an explicit handler field:\n{generated}"
495        );
496        assert!(
497            generated.contains("constructor(handler: StrictSvcHandler)"),
498            "dispatcher constructor must use explicit assignment parameter:\n{generated}"
499        );
500        assert!(
501            generated.contains("catch (e: any)"),
502            "fallible client methods must type catch binding for strict TypeScript:\n{generated}"
503        );
504    }
505}