⚡ vercel-rpc
End-to-end typesafe RPC between Rust lambdas on Vercel and SvelteKit
Live Demo → svelte-rust-beta.vercel.app
Write Rust functions → get a fully typed TypeScript client. Zero config, zero boilerplate.
Why?
Building serverless APIs with Rust on Vercel is fast — but keeping TypeScript types in sync is painful. vercel-rpc solves this:
- 🦀 Write plain Rust functions with
#[rpc_query]/#[rpc_mutation] - 🔄 Auto-generate TypeScript types & client from Rust source code
- 👀 Watch mode — types regenerate on every save
- 🚀 Deploy to Vercel — each function becomes a serverless lambda
- 🛡️ End-to-end type safety — Rust types → TypeScript types, no manual sync
How It Works
┌──────────────┐ scan ┌─────────────┐ codegen ┌──────────────────┐
│ api/*.rs │ ──────────▶ │ Manifest │ ──────────▶ │ rpc-types.ts │
│ #[rpc_query]│ (syn) │ procedures │ (rust→ts) │ rpc-client.ts │
│ #[rpc_mut.] │ │ structs │ │ Typed RpcClient │
└──────────────┘ └─────────────┘ └──────────────────┘
│ │
│ deploy (vercel) import (svelte) │
▼ ▼
┌──────────────┐ HTTP (GET/POST) ┌──────────────────┐
│ Vercel Lambda│ ◀─────────────────────────────────────── │ SvelteKit App │
│ /api/hello │ │ rpc.query(...) │
│ /api/time │ ───────────────────────────────────────▶ │ fully typed! ✨ │
└──────────────┘ JSON response └──────────────────┘
Quick Start
1. Define a Rust lambda
// api/hello.rs
use rpc_query;
async
That's it. The macro generates a full Vercel-compatible handler with:
- Input parsing (query params for queries, JSON body for mutations)
- JSON serialization of the response
- CORS headers & OPTIONS preflight
- HTTP method validation (GET for queries, POST for mutations)
- Structured error responses for
Result<T, E>return types
2. Generate TypeScript bindings
# One-time generation (from demo/)
# Or directly with cargo (from project root)
This produces two files:
src/lib/rpc-types.ts — type definitions:
export interface TimeResponse {
timestamp: number;
message: string;
}
export type Procedures = {
queries: {
hello: { input: string; output: string };
time: { input: void; output: TimeResponse };
};
mutations: {
};
};
src/lib/rpc-client.ts — typed client with overloads:
export interface RpcClient {
query(key: "time"): Promise<TimeResponse>;
query(key: "hello", input: string): Promise<string>;
}
export function createRpcClient(baseUrl: string): RpcClient { /* ... */ }
3. Use in SvelteKit
// demo/src/lib/client.ts
import { createRpcClient } from "./rpc-client";
export const rpc = createRpcClient("/api");
<!-- demo/src/routes/+page.svelte -->
<script lang="ts">
import { rpc } from "$lib/client";
let greeting = $state("");
async function sayHello() {
greeting = await rpc.query("hello", "World");
// ^ autocomplete ✨
// ^ typed as string ✨
}
</script>
<button onclick={sayHello}>Say Hello</button>
<p>{greeting}</p>
4. Watch mode (development)
This runs the RPC watcher and Vite dev server in parallel. Every time you save a .rs file in api/, the TypeScript types and client are regenerated automatically:
vercel-rpc watch mode
api dir: api
types: src/lib/rpc-types.ts
client: src/lib/rpc-client.ts
✓ Generated 2 procedure(s), 1 struct(s), 0 enum(s) in 3ms
→ src/lib/rpc-types.ts
→ src/lib/rpc-client.ts
Watching for changes in api
[12:34:56] Changed: api/hello.rs
✓ Regenerated in 2ms
Project Structure
vercel-rpc/
├── crates/
│ ├── rpc-macro/ # Proc-macro crate
│ │ └── src/lib.rs # #[rpc_query] / #[rpc_mutation]
│ └── rpc-cli/ # CLI crate (binary: `rpc`)
│ └── src/
│ ├── main.rs # CLI entry (scan / generate / watch)
│ ├── model.rs # Manifest, Procedure, RustType, StructDef, EnumDef
│ ├── parser/ # Rust source → Manifest (via syn)
│ │ ├── extract.rs # File scanning & procedure extraction
│ │ └── types.rs # syn::Type → RustType conversion
│ ├── codegen/ # Manifest → TypeScript
│ │ ├── typescript.rs # RustType → TS type mapping + rpc-types.ts
│ │ └── client.rs # RpcClient interface + rpc-client.ts
│ └── watch.rs # File watcher with debounce
├── demo/ # SvelteKit demo application + Rust lambdas
│ ├── api/ # Rust lambdas (each file = one endpoint)
│ │ ├── hello.rs # GET /api/hello?input="name"
│ │ └── time.rs # GET /api/time
│ ├── Cargo.toml # Rust package for demo lambdas
│ ├── src/
│ │ ├── lib/
│ │ │ ├── rpc-types.ts # ← auto-generated types
│ │ │ ├── rpc-client.ts # ← auto-generated client
│ │ │ └── client.ts # RPC client instance (manual)
│ │ └── routes/ # SvelteKit pages
│ ├── tests/
│ │ ├── integration/ # Vitest: codegen pipeline tests
│ │ └── e2e/ # Playwright: UI + API tests
│ ├── package.json # Node scripts
│ ├── svelte.config.js # SvelteKit config
│ ├── vite.config.ts # Vite config + API mock plugin
│ └── tsconfig.json # TypeScript config
├── Cargo.toml # Rust workspace (crates + demo)
├── vercel.json # Vercel config
└── README.md
CLI Reference
rpc scan
Scan Rust source files and print discovered procedures:
Discovered 2 procedure(s), 1 struct(s), 0 enum(s):
Query hello (String) -> String [api/hello.rs]
Query time (()) -> TimeResponse [api/time.rs]
struct TimeResponse {
timestamp: u64,
message: String,
}
rpc generate
Generate TypeScript types and client:
| Flag | Default | Description |
|---|---|---|
--dir, -d |
api |
Rust source directory |
--output, -o |
src/lib/rpc-types.ts |
Types output path |
--client-output, -c |
src/lib/rpc-client.ts |
Client output path |
--types-import |
./rpc-types |
Import path for types in client |
rpc watch
Watch for changes and regenerate on save (same flags as generate):
Rust Macros
#[rpc_query] — GET endpoint
use rpc_query;
// No input
async
// With input (parsed from ?input= query param)
async
// With custom struct output
async
// With Result return type (Err → 400 JSON error)
async
#[rpc_mutation] — POST endpoint
use rpc_mutation;
async
Enum & Struct support
Structs and enums with #[derive(Serialize)] are automatically picked up and converted to TypeScript:
Generated TypeScript:
export interface UserProfile {
name: string;
age: number;
}
export type Status = "Active" | "Inactive" | "Banned";
export type ApiResult = { Ok: string } | "NotFound" | { Error: { code: number; message: string } };
Generated handler features
Every macro-annotated function automatically gets:
| Feature | Description |
|---|---|
| CORS | Access-Control-Allow-Origin: * on all responses |
| Preflight | OPTIONS → 204 No Content with CORS headers |
| Method check | 405 Method Not Allowed for wrong HTTP method |
| Input parsing | Query param (GET) or JSON body (POST) |
| Error handling | Result<T, E> → Ok = 200, Err = 400 with JSON error |
| Response format | { "result": { "type": "response", "data": ... } } |
Type Mapping
| Rust | TypeScript |
|---|---|
String, &str, char |
string |
i8..i128, u8..u128, f32, f64 |
number |
bool |
boolean |
() |
void |
Vec<T> |
T[] |
Option<T> |
T | null |
HashMap<K, V>, BTreeMap<K, V> |
Record<K, V> |
(A, B, C) |
[A, B, C] |
Result<T, E> |
T (error handled at runtime) |
| Custom structs | interface with same fields |
| Enums (unit variants) | "A" | "B" | "C" (string union) |
| Enums (tuple variants) | { A: string } | { B: number } (tagged union) |
| Enums (struct variants) | { A: { x: number; y: number } } (tagged union) |
| Enums (mixed) | Combination of all above |
npm Scripts
See CONTRIBUTING.md for development scripts and setup instructions.
Testing
Detailed testing strategy and commands are described in CONTRIBUTING.md.
Deploy to Vercel
Since the SvelteKit demo lives in demo/, you need to configure Vercel's Root Directory:
- Go to your Vercel project → Settings → General
- Set Root Directory to
demo - Vercel will auto-detect SvelteKit and run
npm run buildfromdemo/ - Rust lambdas in
demo/api/are compiled as serverless functions automatically
# Install Vercel CLI
# Deploy (set root directory on first deploy)
Note: With Root Directory set to
demo, Vercel detectsdemo/api/as the serverless functions' directory. Sodemo/api/hello.rs→/api/hello.
Each .rs file in api/ becomes a serverless function at /api/<name>.
Sponsors
If you find this project useful, consider sponsoring to support its development.
License
MIT OR Apache-2.0
This project is not affiliated with or endorsed by Vercel Inc. "Vercel" is a trademark of Vercel Inc.