1use std::fmt::Write;
2
3use super::typescript::{emit_jsdoc, rust_type_to_ts};
4use crate::model::{Manifest, Procedure, ProcedureKind};
5
6const GENERATED_HEADER: &str = "\
8// This file is auto-generated by vercel-rpc-cli. Do not edit manually.
9// Re-run `rpc generate` or use `rpc watch` to regenerate.
10";
11
12const ERROR_CLASS: &str = r#"export class RpcError extends Error {
14 readonly status: number;
15 readonly data: unknown;
16
17 constructor(status: number, message: string, data?: unknown) {
18 super(message);
19 this.name = "RpcError";
20 this.status = status;
21 this.data = data;
22 }
23}"#;
24
25const REQUEST_CONTEXT_INTERFACE: &str = r#"export interface RequestContext {
27 procedure: string;
28 method: "GET" | "POST";
29 url: string;
30 headers: Record<string, string>;
31 input?: unknown;
32}"#;
33
34const RESPONSE_CONTEXT_INTERFACE: &str = r#"export interface ResponseContext {
36 procedure: string;
37 method: "GET" | "POST";
38 url: string;
39 response: Response;
40 data: unknown;
41 duration: number;
42}"#;
43
44const ERROR_CONTEXT_INTERFACE: &str = r#"export interface ErrorContext {
46 procedure: string;
47 method: "GET" | "POST";
48 url: string;
49 error: unknown;
50 attempt: number;
51 willRetry: boolean;
52}"#;
53
54const RETRY_POLICY_INTERFACE: &str = r#"export interface RetryPolicy {
56 attempts: number;
57 delay: number | ((attempt: number) => number);
58 retryOn?: number[];
59}"#;
60
61const CONFIG_INTERFACE: &str = r#"export interface RpcClientConfig {
63 baseUrl: string;
64 fetch?: typeof globalThis.fetch;
65 headers?:
66 | Record<string, string>
67 | (() => Record<string, string> | Promise<Record<string, string>>);
68 onRequest?: (ctx: RequestContext) => void | Promise<void>;
69 onResponse?: (ctx: ResponseContext) => void | Promise<void>;
70 onError?: (ctx: ErrorContext) => void | Promise<void>;
71 retry?: RetryPolicy;
72 timeout?: number;
73 serialize?: (input: unknown) => string;
74 deserialize?: (text: string) => unknown;
75 // AbortSignal for cancelling all requests made by this client.
76 signal?: AbortSignal;
77}"#;
78
79const FETCH_HELPER: &str = r#"const DEFAULT_RETRY_ON = [408, 429, 500, 502, 503, 504];
81
82async function rpcFetch(
83 config: RpcClientConfig,
84 method: "GET" | "POST",
85 procedure: string,
86 input?: unknown,
87): Promise<unknown> {
88 let url = `${config.baseUrl}/${procedure}`;
89 const customHeaders = typeof config.headers === "function"
90 ? await config.headers()
91 : config.headers;
92 const baseHeaders: Record<string, string> = { ...customHeaders };
93
94 if (method === "GET" && input !== undefined) {
95 const serialized = config.serialize ? config.serialize(input) : JSON.stringify(input);
96 url += `?input=${encodeURIComponent(serialized)}`;
97 } else if (method === "POST" && input !== undefined) {
98 baseHeaders["Content-Type"] = "application/json";
99 }
100
101 const fetchFn = config.fetch ?? globalThis.fetch;
102 const maxAttempts = 1 + (config.retry?.attempts ?? 0);
103 const retryOn = config.retry?.retryOn ?? DEFAULT_RETRY_ON;
104 const start = Date.now();
105
106 for (let attempt = 1; attempt <= maxAttempts; attempt++) {
107 const reqCtx: RequestContext = { procedure, method, url, headers: { ...baseHeaders }, input };
108 await config.onRequest?.(reqCtx);
109
110 const init: RequestInit = { method, headers: reqCtx.headers };
111 if (method === "POST" && input !== undefined) {
112 init.body = config.serialize ? config.serialize(input) : JSON.stringify(input);
113 }
114
115 let timeoutId: ReturnType<typeof setTimeout> | undefined;
116 if (config.timeout) {
117 const controller = new AbortController();
118 timeoutId = setTimeout(() => controller.abort(), config.timeout);
119 init.signal = config.signal
120 ? AbortSignal.any([config.signal, controller.signal])
121 : controller.signal;
122 } else if (config.signal) {
123 init.signal = config.signal;
124 }
125
126 try {
127 const res = await fetchFn(url, init);
128
129 if (!res.ok) {
130 let data: unknown;
131 try {
132 data = await res.json();
133 } catch {
134 data = await res.text().catch(() => null);
135 }
136 const rpcError = new RpcError(
137 res.status,
138 `RPC error on "${procedure}": ${res.status} ${res.statusText}`,
139 data,
140 );
141 const canRetry = retryOn.includes(res.status) && attempt < maxAttempts;
142 await config.onError?.({ procedure, method, url, error: rpcError, attempt, willRetry: canRetry });
143 if (!canRetry) throw rpcError;
144 } else {
145 const json = config.deserialize ? config.deserialize(await res.text()) : await res.json();
146 const result = json?.result?.data ?? json;
147 const duration = Date.now() - start;
148 await config.onResponse?.({ procedure, method, url, response: res, data: result, duration });
149 return result;
150 }
151 } catch (err) {
152 if (err instanceof RpcError) throw err;
153 const willRetry = attempt < maxAttempts;
154 await config.onError?.({ procedure, method, url, error: err, attempt, willRetry });
155 if (!willRetry) throw err;
156 } finally {
157 if (timeoutId !== undefined) clearTimeout(timeoutId);
158 }
159
160 if (config.retry) {
161 const d = typeof config.retry.delay === "function"
162 ? config.retry.delay(attempt) : config.retry.delay;
163 await new Promise(r => setTimeout(r, d));
164 }
165 }
166}"#;
167
168pub fn generate_client_file(
177 manifest: &Manifest,
178 types_import_path: &str,
179 preserve_docs: bool,
180) -> String {
181 let mut out = String::with_capacity(2048);
182
183 out.push_str(GENERATED_HEADER);
185 out.push('\n');
186
187 let type_names: Vec<&str> = manifest
189 .structs
190 .iter()
191 .map(|s| s.name.as_str())
192 .chain(manifest.enums.iter().map(|e| e.name.as_str()))
193 .collect();
194
195 if type_names.is_empty() {
197 let _ = writeln!(
198 out,
199 "import type {{ Procedures }} from \"{types_import_path}\";\n"
200 );
201 let _ = writeln!(out, "export type {{ Procedures }};\n");
202 } else {
203 let types_csv = type_names.join(", ");
204 let _ = writeln!(
205 out,
206 "import type {{ Procedures, {types_csv} }} from \"{types_import_path}\";\n"
207 );
208 let _ = writeln!(out, "export type {{ Procedures, {types_csv} }};\n");
209 }
210
211 let _ = writeln!(out, "{ERROR_CLASS}\n");
213
214 let _ = writeln!(out, "{REQUEST_CONTEXT_INTERFACE}\n");
216 let _ = writeln!(out, "{RESPONSE_CONTEXT_INTERFACE}\n");
217 let _ = writeln!(out, "{ERROR_CONTEXT_INTERFACE}\n");
218
219 let _ = writeln!(out, "{RETRY_POLICY_INTERFACE}\n");
221
222 let _ = writeln!(out, "{CONFIG_INTERFACE}\n");
224
225 let _ = writeln!(out, "{FETCH_HELPER}\n");
227
228 generate_type_helpers(&mut out);
230 out.push('\n');
231
232 generate_client_factory(manifest, preserve_docs, &mut out);
234
235 out
236}
237
238fn generate_type_helpers(out: &mut String) {
240 let _ = writeln!(out, "type QueryKey = keyof Procedures[\"queries\"];");
241 let _ = writeln!(out, "type MutationKey = keyof Procedures[\"mutations\"];");
242 let _ = writeln!(
243 out,
244 "type QueryInput<K extends QueryKey> = Procedures[\"queries\"][K][\"input\"];"
245 );
246 let _ = writeln!(
247 out,
248 "type QueryOutput<K extends QueryKey> = Procedures[\"queries\"][K][\"output\"];"
249 );
250 let _ = writeln!(
251 out,
252 "type MutationInput<K extends MutationKey> = Procedures[\"mutations\"][K][\"input\"];"
253 );
254 let _ = writeln!(
255 out,
256 "type MutationOutput<K extends MutationKey> = Procedures[\"mutations\"][K][\"output\"];"
257 );
258}
259
260fn generate_client_factory(manifest: &Manifest, preserve_docs: bool, out: &mut String) {
262 let has_queries = manifest
263 .procedures
264 .iter()
265 .any(|p| p.kind == ProcedureKind::Query);
266 let has_mutations = manifest
267 .procedures
268 .iter()
269 .any(|p| p.kind == ProcedureKind::Mutation);
270
271 let _ = writeln!(out, "export interface RpcClient {{");
273
274 if has_queries {
275 generate_query_overloads(manifest, preserve_docs, out);
276 }
277
278 if has_mutations {
279 if has_queries {
280 out.push('\n');
281 }
282 generate_mutation_overloads(manifest, preserve_docs, out);
283 }
284
285 let _ = writeln!(out, "}}");
286 out.push('\n');
287
288 let _ = writeln!(
290 out,
291 "export function createRpcClient(config: RpcClientConfig): RpcClient {{"
292 );
293 let _ = writeln!(out, " return {{");
294
295 if has_queries {
296 let _ = writeln!(
297 out,
298 " query(key: QueryKey, ...args: unknown[]): Promise<unknown> {{"
299 );
300 let _ = writeln!(out, " return rpcFetch(config, \"GET\", key, args[0]);");
301 let _ = writeln!(out, " }},");
302 }
303
304 if has_mutations {
305 let _ = writeln!(
306 out,
307 " mutate(key: MutationKey, ...args: unknown[]): Promise<unknown> {{"
308 );
309 let _ = writeln!(
310 out,
311 " return rpcFetch(config, \"POST\", key, args[0]);"
312 );
313 let _ = writeln!(out, " }},");
314 }
315
316 let _ = writeln!(out, " }} as RpcClient;");
317 let _ = writeln!(out, "}}");
318}
319
320fn generate_query_overloads(manifest: &Manifest, preserve_docs: bool, out: &mut String) {
322 let (void_queries, non_void_queries): (Vec<_>, Vec<_>) = manifest
323 .procedures
324 .iter()
325 .filter(|p| p.kind == ProcedureKind::Query)
326 .partition(|p| is_void_input(p));
327
328 for proc in &void_queries {
330 if preserve_docs && let Some(doc) = &proc.docs {
331 emit_jsdoc(doc, " ", out);
332 }
333 let output_ts = proc
334 .output
335 .as_ref()
336 .map(rust_type_to_ts)
337 .unwrap_or_else(|| "void".to_string());
338 let _ = writeln!(
339 out,
340 " query(key: \"{}\"): Promise<{}>;",
341 proc.name, output_ts,
342 );
343 }
344
345 for proc in &non_void_queries {
347 if preserve_docs && let Some(doc) = &proc.docs {
348 emit_jsdoc(doc, " ", out);
349 }
350 let input_ts = proc
351 .input
352 .as_ref()
353 .map(rust_type_to_ts)
354 .unwrap_or_else(|| "void".to_string());
355 let output_ts = proc
356 .output
357 .as_ref()
358 .map(rust_type_to_ts)
359 .unwrap_or_else(|| "void".to_string());
360 let _ = writeln!(
361 out,
362 " query(key: \"{}\", input: {}): Promise<{}>;",
363 proc.name, input_ts, output_ts,
364 );
365 }
366}
367
368fn generate_mutation_overloads(manifest: &Manifest, preserve_docs: bool, out: &mut String) {
370 let (void_mutations, non_void_mutations): (Vec<_>, Vec<_>) = manifest
371 .procedures
372 .iter()
373 .filter(|p| p.kind == ProcedureKind::Mutation)
374 .partition(|p| is_void_input(p));
375
376 for proc in &void_mutations {
378 if preserve_docs && let Some(doc) = &proc.docs {
379 emit_jsdoc(doc, " ", out);
380 }
381 let output_ts = proc
382 .output
383 .as_ref()
384 .map(rust_type_to_ts)
385 .unwrap_or_else(|| "void".to_string());
386 let _ = writeln!(
387 out,
388 " mutate(key: \"{}\"): Promise<{}>;",
389 proc.name, output_ts,
390 );
391 }
392
393 for proc in &non_void_mutations {
395 if preserve_docs && let Some(doc) = &proc.docs {
396 emit_jsdoc(doc, " ", out);
397 }
398 let input_ts = proc
399 .input
400 .as_ref()
401 .map(rust_type_to_ts)
402 .unwrap_or_else(|| "void".to_string());
403 let output_ts = proc
404 .output
405 .as_ref()
406 .map(rust_type_to_ts)
407 .unwrap_or_else(|| "void".to_string());
408 let _ = writeln!(
409 out,
410 " mutate(key: \"{}\", input: {}): Promise<{}>;",
411 proc.name, input_ts, output_ts,
412 );
413 }
414}
415
416fn is_void_input(proc: &Procedure) -> bool {
418 proc.input.as_ref().is_none_or(|ty| ty.name == "()")
419}