vox_codegen/targets/typescript/
client.rs1use 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
14fn 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
39pub 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
75pub 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 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
230pub 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
254pub 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}