1use super::typescript::{emit_jsdoc, rust_type_to_ts};
2use crate::model::{Manifest, Procedure, ProcedureKind};
3
4const GENERATED_HEADER: &str = "\
6// This file is auto-generated by vercel-rpc-cli. Do not edit manually.
7// Re-run `rpc generate` or use `rpc watch` to regenerate.
8";
9
10const ERROR_CLASS: &str = r#"export class RpcError extends Error {
12 readonly status: number;
13 readonly data: unknown;
14
15 constructor(status: number, message: string, data?: unknown) {
16 super(message);
17 this.name = "RpcError";
18 this.status = status;
19 this.data = data;
20 }
21}"#;
22
23const REQUEST_CONTEXT_INTERFACE: &str = r#"export interface RequestContext {
25 procedure: string;
26 method: "GET" | "POST";
27 url: string;
28 headers: Record<string, string>;
29 input?: unknown;
30}"#;
31
32const RESPONSE_CONTEXT_INTERFACE: &str = r#"export interface ResponseContext {
34 procedure: string;
35 method: "GET" | "POST";
36 url: string;
37 response: Response;
38 data: unknown;
39 duration: number;
40}"#;
41
42const ERROR_CONTEXT_INTERFACE: &str = r#"export interface ErrorContext {
44 procedure: string;
45 method: "GET" | "POST";
46 url: string;
47 error: unknown;
48 attempt: number;
49 willRetry: boolean;
50}"#;
51
52const RETRY_POLICY_INTERFACE: &str = r#"export interface RetryPolicy {
54 attempts: number;
55 delay: number | ((attempt: number) => number);
56 retryOn?: number[];
57}"#;
58
59const CONFIG_INTERFACE: &str = r#"export interface RpcClientConfig {
61 baseUrl: string;
62 fetch?: typeof globalThis.fetch;
63 headers?:
64 | Record<string, string>
65 | (() => Record<string, string> | Promise<Record<string, string>>);
66 onRequest?: (ctx: RequestContext) => void | Promise<void>;
67 onResponse?: (ctx: ResponseContext) => void | Promise<void>;
68 onError?: (ctx: ErrorContext) => void | Promise<void>;
69 retry?: RetryPolicy;
70 timeout?: number;
71 serialize?: (input: unknown) => string;
72 deserialize?: (text: string) => unknown;
73 // AbortSignal for cancelling all requests made by this client.
74 signal?: AbortSignal;
75 dedupe?: boolean;
76}"#;
77
78const CALL_OPTIONS_INTERFACE: &str = r#"export interface CallOptions {
80 headers?: Record<string, string>;
81 timeout?: number;
82 signal?: AbortSignal;
83 dedupe?: boolean;
84}"#;
85
86const DEDUP_KEY_FN: &str = r#"function dedupKey(procedure: string, input: unknown, config: RpcClientConfig): string {
88 const serialized = input === undefined
89 ? ""
90 : config.serialize
91 ? config.serialize(input)
92 : JSON.stringify(input);
93 return procedure + ":" + serialized;
94}"#;
95
96const WRAP_WITH_SIGNAL_FN: &str = r#"function wrapWithSignal<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
98 if (!signal) return promise;
99 if (signal.aborted) return Promise.reject(signal.reason);
100 return new Promise<T>((resolve, reject) => {
101 const onAbort = () => reject(signal.reason);
102 signal.addEventListener("abort", onAbort, { once: true });
103 promise.then(
104 (value) => { signal.removeEventListener("abort", onAbort); resolve(value); },
105 (error) => { signal.removeEventListener("abort", onAbort); reject(error); },
106 );
107 });
108}"#;
109
110const FETCH_HELPER: &str = r#"const DEFAULT_RETRY_ON = [408, 429, 500, 502, 503, 504];
112
113async function rpcFetch(
114 config: RpcClientConfig,
115 method: "GET" | "POST",
116 procedure: string,
117 input?: unknown,
118 callOptions?: CallOptions,
119): Promise<unknown> {
120 let url = `${config.baseUrl}/${procedure}`;
121 const customHeaders = typeof config.headers === "function"
122 ? await config.headers()
123 : config.headers;
124 const baseHeaders: Record<string, string> = { ...customHeaders, ...callOptions?.headers };
125
126 if (method === "GET" && input !== undefined) {
127 const serialized = config.serialize ? config.serialize(input) : JSON.stringify(input);
128 url += `?input=${encodeURIComponent(serialized)}`;
129 } else if (method === "POST" && input !== undefined) {
130 baseHeaders["Content-Type"] = "application/json";
131 }
132
133 const fetchFn = config.fetch ?? globalThis.fetch;
134 const maxAttempts = 1 + (config.retry?.attempts ?? 0);
135 const retryOn = config.retry?.retryOn ?? DEFAULT_RETRY_ON;
136 const effectiveTimeout = callOptions?.timeout ?? config.timeout;
137 const start = Date.now();
138
139 for (let attempt = 1; attempt <= maxAttempts; attempt++) {
140 const reqCtx: RequestContext = { procedure, method, url, headers: { ...baseHeaders }, input };
141 await config.onRequest?.(reqCtx);
142
143 const init: RequestInit = { method, headers: reqCtx.headers };
144 if (method === "POST" && input !== undefined) {
145 init.body = config.serialize ? config.serialize(input) : JSON.stringify(input);
146 }
147
148 let timeoutId: ReturnType<typeof setTimeout> | undefined;
149 const signals: AbortSignal[] = [];
150 if (config.signal) signals.push(config.signal);
151 if (callOptions?.signal) signals.push(callOptions.signal);
152 if (effectiveTimeout) {
153 const controller = new AbortController();
154 timeoutId = setTimeout(() => controller.abort(), effectiveTimeout);
155 signals.push(controller.signal);
156 }
157 if (signals.length > 0) {
158 init.signal = signals.length === 1 ? signals[0] : AbortSignal.any(signals);
159 }
160
161 try {
162 const res = await fetchFn(url, init);
163
164 if (!res.ok) {
165 let data: unknown;
166 try {
167 data = await res.json();
168 } catch {
169 data = await res.text().catch(() => null);
170 }
171 const rpcError = new RpcError(
172 res.status,
173 `RPC error on "${procedure}": ${res.status} ${res.statusText}`,
174 data,
175 );
176 const canRetry = retryOn.includes(res.status) && attempt < maxAttempts;
177 await config.onError?.({ procedure, method, url, error: rpcError, attempt, willRetry: canRetry });
178 if (!canRetry) throw rpcError;
179 } else {
180 const json = config.deserialize ? config.deserialize(await res.text()) : await res.json();
181 const result = json?.result?.data ?? json;
182 const duration = Date.now() - start;
183 await config.onResponse?.({ procedure, method, url, response: res, data: result, duration });
184 return result;
185 }
186 } catch (err) {
187 if (err instanceof RpcError) throw err;
188 const willRetry = attempt < maxAttempts;
189 await config.onError?.({ procedure, method, url, error: err, attempt, willRetry });
190 if (!willRetry) throw err;
191 } finally {
192 if (timeoutId !== undefined) clearTimeout(timeoutId);
193 }
194
195 if (config.retry) {
196 const d = typeof config.retry.delay === "function"
197 ? config.retry.delay(attempt) : config.retry.delay;
198 await new Promise(r => setTimeout(r, d));
199 }
200 }
201}"#;
202
203pub fn generate_client_file(
212 manifest: &Manifest,
213 types_import_path: &str,
214 preserve_docs: bool,
215) -> String {
216 let mut out = String::with_capacity(2048);
217
218 out.push_str(GENERATED_HEADER);
220 out.push('\n');
221
222 let type_names: Vec<&str> = manifest
224 .structs
225 .iter()
226 .map(|s| s.name.as_str())
227 .chain(manifest.enums.iter().map(|e| e.name.as_str()))
228 .collect();
229
230 if type_names.is_empty() {
232 emit!(
233 out,
234 "import type {{ Procedures }} from \"{types_import_path}\";\n"
235 );
236 emit!(out, "export type {{ Procedures }};\n");
237 } else {
238 let types_csv = type_names.join(", ");
239 emit!(
240 out,
241 "import type {{ Procedures, {types_csv} }} from \"{types_import_path}\";\n"
242 );
243 emit!(out, "export type {{ Procedures, {types_csv} }};\n");
244 }
245
246 emit!(out, "{ERROR_CLASS}\n");
248
249 emit!(out, "{REQUEST_CONTEXT_INTERFACE}\n");
251 emit!(out, "{RESPONSE_CONTEXT_INTERFACE}\n");
252 emit!(out, "{ERROR_CONTEXT_INTERFACE}\n");
253
254 emit!(out, "{RETRY_POLICY_INTERFACE}\n");
256
257 emit!(out, "{CONFIG_INTERFACE}\n");
259
260 emit!(out, "{CALL_OPTIONS_INTERFACE}\n");
262
263 emit!(out, "{FETCH_HELPER}\n");
265
266 let has_queries = manifest
268 .procedures
269 .iter()
270 .any(|p| p.kind == ProcedureKind::Query);
271 if has_queries {
272 emit!(out, "{DEDUP_KEY_FN}\n");
273 emit!(out, "{WRAP_WITH_SIGNAL_FN}\n");
274 }
275
276 generate_type_helpers(&mut out);
278 out.push('\n');
279
280 generate_client_factory(manifest, preserve_docs, &mut out);
282
283 out
284}
285
286fn generate_type_helpers(out: &mut String) {
288 emit!(out, "type QueryKey = keyof Procedures[\"queries\"];");
289 emit!(out, "type MutationKey = keyof Procedures[\"mutations\"];");
290 emit!(
291 out,
292 "type QueryInput<K extends QueryKey> = Procedures[\"queries\"][K][\"input\"];"
293 );
294 emit!(
295 out,
296 "type QueryOutput<K extends QueryKey> = Procedures[\"queries\"][K][\"output\"];"
297 );
298 emit!(
299 out,
300 "type MutationInput<K extends MutationKey> = Procedures[\"mutations\"][K][\"input\"];"
301 );
302 emit!(
303 out,
304 "type MutationOutput<K extends MutationKey> = Procedures[\"mutations\"][K][\"output\"];"
305 );
306}
307
308fn generate_client_factory(manifest: &Manifest, preserve_docs: bool, out: &mut String) {
310 let queries: Vec<_> = manifest
311 .procedures
312 .iter()
313 .filter(|p| p.kind == ProcedureKind::Query)
314 .collect();
315 let mutations: Vec<_> = manifest
316 .procedures
317 .iter()
318 .filter(|p| p.kind == ProcedureKind::Mutation)
319 .collect();
320 let has_queries = !queries.is_empty();
321 let has_mutations = !mutations.is_empty();
322
323 let void_queries: Vec<_> = queries.iter().filter(|p| is_void_input(p)).collect();
325 let non_void_queries: Vec<_> = queries.iter().filter(|p| !is_void_input(p)).collect();
326 let void_mutations: Vec<_> = mutations.iter().filter(|p| is_void_input(p)).collect();
327 let non_void_mutations: Vec<_> = mutations.iter().filter(|p| !is_void_input(p)).collect();
328
329 let query_mixed = !void_queries.is_empty() && !non_void_queries.is_empty();
330 let mutation_mixed = !void_mutations.is_empty() && !non_void_mutations.is_empty();
331
332 if query_mixed {
334 let names: Vec<_> = void_queries
335 .iter()
336 .map(|p| format!("\"{}\"", p.name))
337 .collect();
338 emit!(
339 out,
340 "const VOID_QUERIES: Set<string> = new Set([{}]);",
341 names.join(", ")
342 );
343 out.push('\n');
344 }
345 if mutation_mixed {
346 let names: Vec<_> = void_mutations
347 .iter()
348 .map(|p| format!("\"{}\"", p.name))
349 .collect();
350 emit!(
351 out,
352 "const VOID_MUTATIONS: Set<string> = new Set([{}]);",
353 names.join(", ")
354 );
355 out.push('\n');
356 }
357
358 emit!(out, "export interface RpcClient {{");
360
361 if has_queries {
362 generate_query_overloads(manifest, preserve_docs, out);
363 }
364
365 if has_mutations {
366 if has_queries {
367 out.push('\n');
368 }
369 generate_mutation_overloads(manifest, preserve_docs, out);
370 }
371
372 emit!(out, "}}");
373 out.push('\n');
374
375 emit!(
377 out,
378 "export function createRpcClient(config: RpcClientConfig): RpcClient {{"
379 );
380
381 if has_queries {
382 emit!(
383 out,
384 " const inflight = new Map<string, Promise<unknown>>();\n"
385 );
386 }
387
388 emit!(out, " return {{");
389
390 if has_queries {
391 emit!(
392 out,
393 " query(key: QueryKey, ...args: unknown[]): Promise<unknown> {{"
394 );
395
396 if query_mixed {
398 emit!(out, " let input: unknown;");
399 emit!(out, " let callOptions: CallOptions | undefined;");
400 emit!(out, " if (VOID_QUERIES.has(key)) {{");
401 emit!(out, " input = undefined;");
402 emit!(
403 out,
404 " callOptions = args[0] as CallOptions | undefined;"
405 );
406 emit!(out, " }} else {{");
407 emit!(out, " input = args[0];");
408 emit!(
409 out,
410 " callOptions = args[1] as CallOptions | undefined;"
411 );
412 emit!(out, " }}");
413 } else if !void_queries.is_empty() {
414 emit!(out, " const input = undefined;");
415 emit!(
416 out,
417 " const callOptions = args[0] as CallOptions | undefined;"
418 );
419 } else {
420 emit!(out, " const input = args[0];");
421 emit!(
422 out,
423 " const callOptions = args[1] as CallOptions | undefined;"
424 );
425 }
426
427 emit!(
429 out,
430 " const shouldDedupe = callOptions?.dedupe ?? config.dedupe ?? true;"
431 );
432 emit!(out, " if (shouldDedupe) {{");
433 emit!(out, " const k = dedupKey(key, input, config);");
434 emit!(out, " const existing = inflight.get(k);");
435 emit!(
436 out,
437 " if (existing) return wrapWithSignal(existing, callOptions?.signal);"
438 );
439 emit!(
440 out,
441 " const promise = rpcFetch(config, \"GET\", key, input, callOptions)"
442 );
443 emit!(out, " .finally(() => inflight.delete(k));");
444 emit!(out, " inflight.set(k, promise);");
445 emit!(
446 out,
447 " return wrapWithSignal(promise, callOptions?.signal);"
448 );
449 emit!(out, " }}");
450 emit!(
451 out,
452 " return rpcFetch(config, \"GET\", key, input, callOptions);"
453 );
454 emit!(out, " }},");
455 }
456
457 if has_mutations {
458 emit!(
459 out,
460 " mutate(key: MutationKey, ...args: unknown[]): Promise<unknown> {{"
461 );
462 if mutation_mixed {
463 emit!(out, " if (VOID_MUTATIONS.has(key)) {{");
465 emit!(
466 out,
467 " return rpcFetch(config, \"POST\", key, undefined, args[0] as CallOptions | undefined);"
468 );
469 emit!(out, " }}");
470 emit!(
471 out,
472 " return rpcFetch(config, \"POST\", key, args[0], args[1] as CallOptions | undefined);"
473 );
474 } else if !void_mutations.is_empty() {
475 emit!(
477 out,
478 " return rpcFetch(config, \"POST\", key, undefined, args[0] as CallOptions | undefined);"
479 );
480 } else {
481 emit!(
483 out,
484 " return rpcFetch(config, \"POST\", key, args[0], args[1] as CallOptions | undefined);"
485 );
486 }
487 emit!(out, " }},");
488 }
489
490 emit!(out, " }} as RpcClient;");
491 emit!(out, "}}");
492}
493
494fn generate_query_overloads(manifest: &Manifest, preserve_docs: bool, out: &mut String) {
496 let (void_queries, non_void_queries): (Vec<_>, Vec<_>) = manifest
497 .procedures
498 .iter()
499 .filter(|p| p.kind == ProcedureKind::Query)
500 .partition(|p| is_void_input(p));
501
502 for proc in &void_queries {
504 if preserve_docs && let Some(doc) = &proc.docs {
505 emit_jsdoc(doc, " ", out);
506 }
507 let output_ts = proc
508 .output
509 .as_ref()
510 .map(rust_type_to_ts)
511 .unwrap_or_else(|| "void".to_string());
512 emit!(
513 out,
514 " query(key: \"{}\"): Promise<{}>;",
515 proc.name, output_ts,
516 );
517 emit!(
518 out,
519 " query(key: \"{}\", options: CallOptions): Promise<{}>;",
520 proc.name, output_ts,
521 );
522 }
523
524 for proc in &non_void_queries {
526 if preserve_docs && let Some(doc) = &proc.docs {
527 emit_jsdoc(doc, " ", out);
528 }
529 let input_ts = proc
530 .input
531 .as_ref()
532 .map(rust_type_to_ts)
533 .unwrap_or_else(|| "void".to_string());
534 let output_ts = proc
535 .output
536 .as_ref()
537 .map(rust_type_to_ts)
538 .unwrap_or_else(|| "void".to_string());
539 emit!(
540 out,
541 " query(key: \"{}\", input: {}): Promise<{}>;",
542 proc.name, input_ts, output_ts,
543 );
544 emit!(
545 out,
546 " query(key: \"{}\", input: {}, options: CallOptions): Promise<{}>;",
547 proc.name, input_ts, output_ts,
548 );
549 }
550}
551
552fn generate_mutation_overloads(manifest: &Manifest, preserve_docs: bool, out: &mut String) {
554 let (void_mutations, non_void_mutations): (Vec<_>, Vec<_>) = manifest
555 .procedures
556 .iter()
557 .filter(|p| p.kind == ProcedureKind::Mutation)
558 .partition(|p| is_void_input(p));
559
560 for proc in &void_mutations {
562 if preserve_docs && let Some(doc) = &proc.docs {
563 emit_jsdoc(doc, " ", out);
564 }
565 let output_ts = proc
566 .output
567 .as_ref()
568 .map(rust_type_to_ts)
569 .unwrap_or_else(|| "void".to_string());
570 emit!(
571 out,
572 " mutate(key: \"{}\"): Promise<{}>;",
573 proc.name, output_ts,
574 );
575 emit!(
576 out,
577 " mutate(key: \"{}\", options: CallOptions): Promise<{}>;",
578 proc.name, output_ts,
579 );
580 }
581
582 for proc in &non_void_mutations {
584 if preserve_docs && let Some(doc) = &proc.docs {
585 emit_jsdoc(doc, " ", out);
586 }
587 let input_ts = proc
588 .input
589 .as_ref()
590 .map(rust_type_to_ts)
591 .unwrap_or_else(|| "void".to_string());
592 let output_ts = proc
593 .output
594 .as_ref()
595 .map(rust_type_to_ts)
596 .unwrap_or_else(|| "void".to_string());
597 emit!(
598 out,
599 " mutate(key: \"{}\", input: {}): Promise<{}>;",
600 proc.name, input_ts, output_ts,
601 );
602 emit!(
603 out,
604 " mutate(key: \"{}\", input: {}, options: CallOptions): Promise<{}>;",
605 proc.name, input_ts, output_ts,
606 );
607 }
608}
609
610fn is_void_input(proc: &Procedure) -> bool {
612 proc.input.as_ref().is_none_or(|ty| ty.name == "()")
613}