Skip to main content

vox_codegen/targets/typescript/
client.rs

1//! TypeScript client generation.
2//!
3//! Generates client interface and implementation for making caller-visible RPC
4//! calls. Each generated method issues one request attempt.
5//! The client uses the canonical service schema table for request/response
6//! encode/decode. No method-specific serialization code is generated here.
7
8use heck::{ToLowerCamelCase, ToUpperCamelCase};
9use vox_types::{ServiceDescriptor, ShapeKind, classify_shape, is_rx, is_tx};
10
11use super::types::{ts_type_client_arg, ts_type_client_return};
12
13/// Format a doc comment for TypeScript/JSDoc.
14fn format_doc_comment(doc: &str, indent: &str) -> String {
15    let lines: Vec<&str> = doc.lines().collect();
16
17    if lines.is_empty() {
18        return String::new();
19    }
20
21    if lines.len() == 1 {
22        format!("{}/** {} */\n", indent, lines[0].trim())
23    } else {
24        let mut out = format!("{}/**\n", indent);
25        for line in lines {
26            let trimmed = line.trim();
27            if trimmed.is_empty() {
28                out.push_str(&format!("{} *\n", indent));
29            } else {
30                out.push_str(&format!("{} * {}\n", indent, trimmed));
31            }
32        }
33        out.push_str(&format!("{} */\n", indent));
34        out
35    }
36}
37
38/// Generate caller interface for making caller-visible RPC calls to the service.
39///
40/// r[impl rpc.channel.binding] - Caller binds channels in args.
41pub fn generate_caller_interface(service: &ServiceDescriptor) -> String {
42    let mut out = String::new();
43    let service_name = service.service_name.to_upper_camel_case();
44
45    out.push_str(&format!("// Caller interface for {service_name}\n"));
46    out.push_str(&format!("export interface {service_name}Caller {{\n"));
47
48    for method in service.methods {
49        let method_name = method.method_name.to_lower_camel_case();
50        let args = method
51            .args
52            .iter()
53            .map(|a| {
54                format!(
55                    "{}: {}",
56                    a.name.to_lower_camel_case(),
57                    ts_type_client_arg(a.shape)
58                )
59            })
60            .collect::<Vec<_>>()
61            .join(", ");
62        let ret_ty = ts_type_client_return(method.return_shape);
63
64        if let Some(doc) = &method.doc {
65            out.push_str(&format_doc_comment(doc, "  "));
66        }
67        out.push_str(&format!("  {method_name}({args}): Promise<{ret_ty}>;\n"));
68    }
69
70    out.push_str("}\n\n");
71    out
72}
73
74/// Generate client implementation.
75///
76/// Each generated client method represents one RPC call:
77/// 1. Binds to its generated `MethodDescriptor` constant
78/// 2. Binds any channel args (via canonical arg refs if streaming)
79/// 3. Calls `caller.call({ method, args, descriptor, ... })` to start a
80///    request attempt
81/// 4. The runtime encodes/decodes using the canonical service schema table
82pub fn generate_client_impl(service: &ServiceDescriptor) -> String {
83    let mut out = String::new();
84    let service_name = service.service_name.to_upper_camel_case();
85    let service_name_lower = service.service_name.to_lower_camel_case();
86
87    out.push_str(&format!("// Client implementation for {service_name}\n"));
88    out.push_str(&format!(
89        "export class {service_name}Client implements {service_name}Caller {{\n"
90    ));
91    out.push_str("  static get descriptor(): ServiceDescriptor {\n");
92    out.push_str(&format!("    return {service_name_lower}_descriptor;\n"));
93    out.push_str("  }\n\n");
94    out.push_str("  private caller: Caller;\n\n");
95    out.push_str("  constructor(caller: Caller) {\n");
96    out.push_str("    this.caller = caller;\n");
97    out.push_str("  }\n\n");
98
99    for method in service.methods {
100        let method_name = method.method_name.to_lower_camel_case();
101        let method_descriptor_name = format!("{service_name_lower}_{method_name}_method");
102
103        let has_streaming_args = method.args.iter().any(|a| is_tx(a.shape) || is_rx(a.shape));
104
105        let args = method
106            .args
107            .iter()
108            .map(|a| {
109                format!(
110                    "{}: {}",
111                    a.name.to_lower_camel_case(),
112                    ts_type_client_arg(a.shape)
113                )
114            })
115            .collect::<Vec<_>>()
116            .join(", ");
117
118        let ret_ty = ts_type_client_return(method.return_shape);
119
120        let args_record = if method.args.is_empty() {
121            "{}".to_string()
122        } else {
123            let fields: Vec<_> = method
124                .args
125                .iter()
126                .map(|a| a.name.to_lower_camel_case())
127                .collect();
128            format!("{{ {} }}", fields.join(", "))
129        };
130
131        if let Some(doc) = &method.doc {
132            out.push_str(&format_doc_comment(doc, "  "));
133        }
134        out.push_str(&format!(
135            "  async {method_name}({args}): Promise<{ret_ty}> {{\n"
136        ));
137
138        let _ = has_streaming_args;
139        // r[impl rpc.method-id]
140        let method_id_hex = crate::render::hex_u64(crate::method_id(method));
141        // The per-call phon schema data: the method's args/ok roots + args schema
142        // closure (from `{service}Methods`) and the service phon `Registry`.
143        let call_fields = format!(
144            "      method: \"{service_name}.{method_name}\",\n      args: {args_record},\n      descriptor: {method_descriptor_name},\n      methodSchemas: {service_name_lower}Methods[\"{method_id_hex}\"],\n      registry: {service_name_lower}Registry,\n",
145        );
146
147        let is_fallible = matches!(
148            classify_shape(method.return_shape),
149            ShapeKind::Result { .. }
150        );
151
152        if is_fallible {
153            out.push_str("    try {\n");
154            out.push_str("      const __voxResult = await this.caller.call({\n");
155            out.push_str(&call_fields);
156            out.push_str("      });\n");
157            out.push_str(&format!(
158                "      return {{ ok: true, value: __voxResult }} as {ret_ty};\n"
159            ));
160            out.push_str("    } catch (e: any) {\n");
161            out.push_str("      if (e instanceof RpcError && e.isUserError()) {\n");
162            out.push_str(&format!(
163                "        return {{ ok: false, error: e.userError }} as {ret_ty};\n"
164            ));
165            out.push_str("      }\n");
166            out.push_str("      throw e;\n");
167            out.push_str("    }\n");
168        } else {
169            out.push_str("    const __voxResult = await this.caller.call({\n");
170            out.push_str(&call_fields);
171            out.push_str("    });\n");
172            out.push_str(&format!("    return __voxResult as {ret_ty};\n"));
173        }
174
175        out.push_str("  }\n\n");
176    }
177
178    out.push_str("}\n\n");
179    out
180}
181
182/// Generate a connect() helper function for WebSocket connections.
183pub fn generate_connect_function(service: &ServiceDescriptor) -> String {
184    let service_name = service.service_name.to_upper_camel_case();
185
186    let mut out = String::new();
187    out.push_str(&format!(
188        "/**\n * Connect to a {service_name} server over WebSocket.\n"
189    ));
190    out.push_str(" * @param url - WebSocket URL (e.g., \"ws://localhost:9000\")\n");
191    out.push_str(&format!(
192        " * @returns A connected {service_name}Client instance\n"
193    ));
194    out.push_str(" */\n");
195    out.push_str(&format!(
196        "export async function connect{service_name}(\n  url: string,\n  options: ConnectLaneOptions = {{}},\n): Promise<{service_name}Client> {{\n"
197    ));
198    out.push_str(&format!(
199        "  return connectLane(wsConnector(url), {service_name}Client, {{\n    ...options,\n    laneMetadata: options.laneMetadata ?? voxServiceMetadata(\"{}\"),\n  }});\n",
200        service.service_name
201    ));
202    out.push_str("}\n\n");
203    out
204}
205
206pub fn generate_client_with_connect_helper(
207    service: &ServiceDescriptor,
208    connect_helper: bool,
209) -> String {
210    let mut out = String::new();
211    out.push_str(&generate_caller_interface(service));
212    out.push_str(&generate_client_impl(service));
213    if connect_helper {
214        out.push_str(&generate_connect_function(service));
215    }
216    out
217}
218
219/// Generate complete client code (interface + implementation + connect helper).
220pub fn generate_client(service: &ServiceDescriptor) -> String {
221    generate_client_with_connect_helper(service, true)
222}