Skip to main content

vercel_rpc_cli/codegen/
client.rs

1use std::fmt::Write;
2
3use super::typescript::{emit_jsdoc, rust_type_to_ts};
4use crate::model::{Manifest, Procedure, ProcedureKind};
5
6// Header comment included at the top of every generated client file.
7const 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
12/// Standard RPC error class with status code and structured error data.
13const 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
25/// Context passed to the `onRequest` lifecycle hook.
26const 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
34/// Context passed to the `onResponse` lifecycle hook.
35const 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
44/// Context passed to the `onError` lifecycle hook.
45const 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
54/// Retry policy configuration.
55const RETRY_POLICY_INTERFACE: &str = r#"export interface RetryPolicy {
56  attempts: number;
57  delay: number | ((attempt: number) => number);
58  retryOn?: number[];
59}"#;
60
61/// Configuration interface for the RPC client.
62const 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
79/// Internal fetch helper shared by query and mutate methods.
80const 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
168/// Generates the complete `rpc-client.ts` file content from a manifest.
169///
170/// The output includes:
171/// 1. Auto-generation header
172/// 2. Re-export of `Procedures` type from the types file
173/// 3. `RpcError` class for structured error handling
174/// 4. Internal `rpcFetch` helper
175/// 5. `createRpcClient` factory function with fully typed `query` / `mutate` methods
176pub 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    // Header
184    out.push_str(GENERATED_HEADER);
185    out.push('\n');
186
187    // Collect all user-defined type names (structs + enums) for import
188    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    // Import Procedures type (and any referenced types) from the types file
196    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    // Error class
212    let _ = writeln!(out, "{ERROR_CLASS}\n");
213
214    // Lifecycle hook context interfaces
215    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    // Retry policy interface
220    let _ = writeln!(out, "{RETRY_POLICY_INTERFACE}\n");
221
222    // Client config interface
223    let _ = writeln!(out, "{CONFIG_INTERFACE}\n");
224
225    // Internal fetch helper
226    let _ = writeln!(out, "{FETCH_HELPER}\n");
227
228    // Type helpers for ergonomic API
229    generate_type_helpers(&mut out);
230    out.push('\n');
231
232    // Client factory
233    generate_client_factory(manifest, preserve_docs, &mut out);
234
235    out
236}
237
238/// Emits utility types that power the typed client API.
239fn 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
260/// Generates the `createRpcClient` factory using an interface for typed overloads.
261fn 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    // Emit the RpcClient interface with overloaded method signatures
272    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    // Emit the factory function
289    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
320/// Generates query overload signatures for the RpcClient interface.
321fn 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    // Overload signatures for void-input queries (no input argument required)
329    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    // Overload signatures for non-void-input queries
346    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
368/// Generates mutation overload signatures for the RpcClient interface.
369fn 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    // Overload signatures for void-input mutations
377    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    // Overload signatures for non-void-input mutations
394    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
416/// Returns `true` if the procedure takes no input (void).
417fn is_void_input(proc: &Procedure) -> bool {
418    proc.input.as_ref().is_none_or(|ty| ty.name == "()")
419}