Skip to main content

vercel_rpc_cli/codegen/
client.rs

1use super::typescript::{emit_jsdoc, rust_type_to_ts};
2use crate::model::{Manifest, Procedure, ProcedureKind};
3
4// Header comment included at the top of every generated client file.
5const 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
10/// Standard RPC error class with status code and structured error data.
11const 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
23/// Context passed to the `onRequest` lifecycle hook.
24const 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
32/// Context passed to the `onResponse` lifecycle hook.
33const 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
42/// Context passed to the `onError` lifecycle hook.
43const 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
52/// Retry policy configuration.
53const RETRY_POLICY_INTERFACE: &str = r#"export interface RetryPolicy {
54  attempts: number;
55  delay: number | ((attempt: number) => number);
56  retryOn?: number[];
57}"#;
58
59/// Configuration interface for the RPC client.
60const 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
78/// Per-call options that override client-level defaults for a single request.
79const CALL_OPTIONS_INTERFACE: &str = r#"export interface CallOptions {
80  headers?: Record<string, string>;
81  timeout?: number;
82  signal?: AbortSignal;
83  dedupe?: boolean;
84}"#;
85
86/// Computes a dedup map key from procedure name and serialized input.
87const 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
96/// Wraps a shared promise so that a per-caller AbortSignal can reject independently.
97const 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
110/// Internal fetch helper shared by query and mutate methods.
111const 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
203/// Generates the complete `rpc-client.ts` file content from a manifest.
204///
205/// The output includes:
206/// 1. Auto-generation header
207/// 2. Re-export of `Procedures` type from the types file
208/// 3. `RpcError` class for structured error handling
209/// 4. Internal `rpcFetch` helper
210/// 5. `createRpcClient` factory function with fully typed `query` / `mutate` methods
211pub 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    // Header
219    out.push_str(GENERATED_HEADER);
220    out.push('\n');
221
222    // Collect all user-defined type names (structs + enums) for import
223    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    // Import Procedures type (and any referenced types) from the types file
231    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    // Error class
247    emit!(out, "{ERROR_CLASS}\n");
248
249    // Lifecycle hook context interfaces
250    emit!(out, "{REQUEST_CONTEXT_INTERFACE}\n");
251    emit!(out, "{RESPONSE_CONTEXT_INTERFACE}\n");
252    emit!(out, "{ERROR_CONTEXT_INTERFACE}\n");
253
254    // Retry policy interface
255    emit!(out, "{RETRY_POLICY_INTERFACE}\n");
256
257    // Client config interface
258    emit!(out, "{CONFIG_INTERFACE}\n");
259
260    // Per-call options interface
261    emit!(out, "{CALL_OPTIONS_INTERFACE}\n");
262
263    // Internal fetch helper
264    emit!(out, "{FETCH_HELPER}\n");
265
266    // Dedup helpers (only when the manifest has queries)
267    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    // Type helpers for ergonomic API
277    generate_type_helpers(&mut out);
278    out.push('\n');
279
280    // Client factory
281    generate_client_factory(manifest, preserve_docs, &mut out);
282
283    out
284}
285
286/// Emits utility types that power the typed client API.
287fn 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
308/// Generates the `createRpcClient` factory using an interface for typed overloads.
309fn 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    // Partition queries and mutations by void/non-void input
324    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    // Emit VOID_QUERIES/VOID_MUTATIONS sets when mixed void/non-void exists
333    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 the RpcClient interface with overloaded method signatures
359    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 the factory function
376    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        // Extract input and callOptions into locals based on void/non-void branching
397        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        // Dedup logic
428        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            // Mixed: use VOID_MUTATIONS set to branch at runtime
464            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            // All void: args[0] is always CallOptions
476            emit!(
477                out,
478                "      return rpcFetch(config, \"POST\", key, undefined, args[0] as CallOptions | undefined);"
479            );
480        } else {
481            // All non-void: args[0] is input, args[1] is CallOptions
482            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
494/// Generates query overload signatures for the RpcClient interface.
495fn 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    // Overload signatures for void-input queries (no input argument required)
503    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    // Overload signatures for non-void-input queries
525    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
552/// Generates mutation overload signatures for the RpcClient interface.
553fn 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    // Overload signatures for void-input mutations
561    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    // Overload signatures for non-void-input mutations
583    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
610/// Returns `true` if the procedure takes no input (void).
611fn is_void_input(proc: &Procedure) -> bool {
612    proc.input.as_ref().is_none_or(|ty| ty.name == "()")
613}