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!(w, "import {{ session }} from \"@bearcove/vox-core\";").unwrap();
117
118    // WebSocket transport for connect helper
119    cw_writeln!(w, "import {{ wsConnector }} from \"@bearcove/vox-ws\";").unwrap();
120
121    // RpcError for fallible client methods
122    if has_fallible {
123        cw_writeln!(w, "import {{ RpcError }} from \"@bearcove/vox-core\";").unwrap();
124    }
125
126    // Tx/Rx and bindChannels for streaming handler args and type aliases
127    if has_streaming {
128        cw_writeln!(
129            w,
130            "import {{ Tx, Rx, argElementRefsForMethod, bindChannelsForTypeRefs, finalizeBoundChannelsForTypeRefs }} from \"@bearcove/vox-core\";"
131        )
132        .unwrap();
133    }
134}
135
136/// Generate request/response type aliases, skipping any that conflict with named types
137fn generate_request_response_types(
138    service: &ServiceDescriptor,
139    named_types: &[(String, &'static facet_core::Shape)],
140) -> String {
141    use heck::ToUpperCamelCase;
142    use std::collections::HashSet;
143    use types::ts_type;
144
145    // Collect just the type names for conflict checking
146    let type_names: HashSet<&str> = named_types.iter().map(|(name, _)| name.as_str()).collect();
147
148    let mut out = String::new();
149    out.push_str("// Request/Response type aliases\n");
150
151    for method in service.methods {
152        let method_name = method.method_name.to_upper_camel_case();
153        let request_name = format!("{method_name}Request");
154        let response_name = format!("{method_name}Response");
155
156        // Only generate request type alias if it doesn't conflict with a named type
157        if !type_names.contains(request_name.as_str()) {
158            if method.args.is_empty() {
159                out.push_str(&format!("export type {request_name} = [];\n"));
160            } else if method.args.len() == 1 {
161                let ty = ts_type(method.args[0].shape);
162                out.push_str(&format!("export type {request_name} = [{ty}];\n"));
163            } else {
164                out.push_str(&format!("export type {request_name} = [\n"));
165                for arg in method.args {
166                    let ty = ts_type(arg.shape);
167                    out.push_str(&format!("  {ty}, // {}\n", arg.name));
168                }
169                out.push_str("];\n");
170            }
171        }
172
173        // Only generate response type alias if it doesn't conflict with a named type
174        if !type_names.contains(response_name.as_str()) {
175            let ret_ty = ts_type(method.return_shape);
176            out.push_str(&format!("export type {response_name} = {ret_ty};\n"));
177        }
178
179        out.push('\n');
180    }
181
182    out
183}
184
185#[cfg(test)]
186mod tests {
187    #![allow(dead_code)]
188
189    use super::generate_service;
190    use facet::Facet;
191    use vox_types::{
192        RetryPolicy, Rx, ServiceDescriptor, Tx, method_descriptor, method_descriptor_with_retry,
193    };
194
195    #[derive(Facet)]
196    struct RecursiveNode {
197        next: Option<Box<RecursiveNode>>,
198    }
199
200    #[derive(Facet)]
201    #[repr(transparent)]
202    #[facet(transparent)]
203    struct SessionId(pub String);
204
205    #[derive(Facet)]
206    struct SessionSummary {
207        id: SessionId,
208    }
209
210    #[derive(Facet)]
211    #[repr(u8)]
212    enum ToolCallKind {
213        Read,
214        Execute,
215    }
216
217    #[derive(Facet)]
218    #[repr(u8)]
219    enum ToolCallStatus {
220        Running,
221        Success,
222        Failure,
223    }
224
225    #[derive(Facet)]
226    #[repr(u8)]
227    enum PermissionResolution {
228        Approved,
229        Denied,
230    }
231
232    #[derive(Facet)]
233    #[repr(u8)]
234    enum ContentBlock {
235        Text {
236            text: String,
237        },
238        ToolCall {
239            id: String,
240            title: String,
241            kind: Option<ToolCallKind>,
242            status: ToolCallStatus,
243        },
244        Permission {
245            id: String,
246            title: String,
247            kind: Option<ToolCallKind>,
248            resolution: Option<PermissionResolution>,
249        },
250    }
251
252    #[derive(Facet)]
253    #[repr(u8)]
254    enum BlockPatch {
255        TextAppend {
256            text: String,
257        },
258        ToolCallUpdate {
259            id: String,
260            kind: Option<ToolCallKind>,
261            status: ToolCallStatus,
262        },
263    }
264
265    #[derive(Facet)]
266    #[repr(u8)]
267    enum SessionEvent {
268        BlockAppend {
269            block_id: String,
270            role: String,
271            block: ContentBlock,
272        },
273        BlockPatch {
274            block_id: String,
275            role: String,
276            patch: BlockPatch,
277        },
278    }
279
280    #[derive(Facet)]
281    struct SessionEventEnvelope {
282        seq: u64,
283        event: SessionEvent,
284    }
285
286    #[derive(Facet)]
287    #[repr(u8)]
288    enum SubscribeMessage {
289        Event(SessionEventEnvelope),
290        ReplayComplete,
291    }
292
293    #[test]
294    fn generated_typescript_contains_no_postcard_primitive_usage() {
295        let echo = method_descriptor::<(String,), String>("TestSvc", "echo", &["message"], None);
296        let divide = method_descriptor::<(u64, u64), Result<u64, String>>(
297            "TestSvc",
298            "divide",
299            &["lhs", "rhs"],
300            None,
301        );
302        let methods = Box::leak(vec![echo, divide].into_boxed_slice());
303        let service = ServiceDescriptor {
304            service_name: "TestSvc",
305            methods,
306            doc: None,
307        };
308
309        let generated = generate_service(&service);
310        assert!(
311            !generated.contains("import * as pc from \"@bearcove/vox-postcard\""),
312            "generated TypeScript must not import postcard primitive namespace:\n{generated}"
313        );
314        assert!(
315            !generated.contains("pc."),
316            "generated TypeScript must not call postcard primitives directly:\n{generated}"
317        );
318    }
319
320    #[test]
321    fn generated_typescript_uses_canonical_service_schemas() {
322        let recurse = method_descriptor::<(RecursiveNode,), RecursiveNode>(
323            "RecursiveSvc",
324            "recurse",
325            &["node"],
326            None,
327        );
328        let methods = Box::leak(vec![recurse].into_boxed_slice());
329        let service = ServiceDescriptor {
330            service_name: "RecursiveSvc",
331            methods,
332            doc: None,
333        };
334
335        let generated = generate_service(&service);
336        assert!(
337            generated.contains("send_schemas"),
338            "generated TypeScript must include canonical service schemas:\n{generated}"
339        );
340        assert!(
341            !generated.contains("schema_registry"),
342            "generated TypeScript must not include the legacy schema registry:\n{generated}"
343        );
344    }
345
346    #[test]
347    fn generated_typescript_emits_alias_for_transparent_newtype() {
348        let summarize = method_descriptor::<(SessionId,), SessionSummary>(
349            "SessionSvc",
350            "summarize",
351            &["id"],
352            None,
353        );
354        let methods = Box::leak(vec![summarize].into_boxed_slice());
355        let service = ServiceDescriptor {
356            service_name: "SessionSvc",
357            methods,
358            doc: None,
359        };
360
361        let generated = generate_service(&service);
362        assert!(
363            generated.contains("export type SessionId = string;"),
364            "transparent named newtypes must emit a type alias:\n{generated}"
365        );
366        assert!(
367            generated.contains("id: SessionId;"),
368            "uses of transparent named newtypes must keep alias name:\n{generated}"
369        );
370    }
371
372    #[test]
373    fn generated_typescript_emits_channel_schemas() {
374        let subscribe = method_descriptor::<(Tx<u32>, Rx<u32>), ()>(
375            "StreamSvc",
376            "subscribe",
377            &["output", "input"],
378            None,
379        );
380        let methods = Box::leak(vec![subscribe].into_boxed_slice());
381        let service = ServiceDescriptor {
382            service_name: "StreamSvc",
383            methods,
384            doc: None,
385        };
386
387        let generated = generate_service(&service);
388        assert!(
389            generated.contains("kind: { tag: 'channel', direction: 'tx'"),
390            "Tx<T> must be emitted into canonical service schemas:\n{generated}"
391        );
392        assert!(
393            generated.contains("kind: { tag: 'channel', direction: 'rx'"),
394            "Rx<T> must be emitted into canonical service schemas:\n{generated}"
395        );
396    }
397
398    #[test]
399    fn generated_typescript_emits_retry_policy_on_method_descriptors() {
400        let fetch = method_descriptor_with_retry::<(), u64>(
401            "RetrySvc",
402            "fetch",
403            &[],
404            None,
405            RetryPolicy::IDEM,
406        );
407        let effect = method_descriptor_with_retry::<(), Result<u64, String>>(
408            "RetrySvc",
409            "effect",
410            &[],
411            None,
412            RetryPolicy::PERSIST,
413        );
414        let methods = Box::leak(vec![fetch, effect].into_boxed_slice());
415        let service = ServiceDescriptor {
416            service_name: "RetrySvc",
417            methods,
418            doc: None,
419        };
420
421        let generated = generate_service(&service);
422        assert!(
423            generated.contains("retry: { persist: false, idem: true }"),
424            "generated TypeScript must include retry policy for idem methods:\n{generated}"
425        );
426        assert!(
427            generated.contains("retry: { persist: true, idem: false }"),
428            "generated TypeScript must include retry policy for persist methods:\n{generated}"
429        );
430    }
431
432    #[test]
433    fn generated_typescript_keeps_struct_variants_with_kind_fields_named() {
434        let subscribe =
435            method_descriptor::<(), SubscribeMessage>("ShipSvc", "subscribe", &[], None);
436        let methods = Box::leak(vec![subscribe].into_boxed_slice());
437        let service = ServiceDescriptor {
438            service_name: "ShipSvc",
439            methods,
440            doc: None,
441        };
442
443        let generated = generate_service(&service);
444        assert!(
445            generated.contains(
446                "name: 'ToolCall', index: 1, payload: { tag: 'struct', fields: [{ name: 'id'"
447            ),
448            "struct variants with a field named `kind` must stay struct variants in canonical schemas:\n{generated}"
449        );
450        assert!(
451            generated.contains(
452                "name: 'Permission', index: 2, payload: { tag: 'struct', fields: [{ name: 'id'"
453            ),
454            "similar struct variants must keep their named `kind` field in canonical schemas:\n{generated}"
455        );
456        assert!(
457            generated.contains(
458                "name: 'ToolCallUpdate', index: 1, payload: { tag: 'struct', fields: [{ name: 'id'"
459            ),
460            "patch variants with a field named `kind` must also stay struct variants in canonical schemas:\n{generated}"
461        );
462        assert!(
463            generated.contains("{ name: 'kind', type_ref:"),
464            "canonical struct variants must preserve the literal field name `kind`:\n{generated}"
465        );
466    }
467}