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