@thednp/rpc
v0.3.7
Published
⚡ A Vite plugin for creating server functions with automatic Remote Procedure Calls (RPC)
Maintainers
Readme
@thednp/rpc
A Vite plugin for automatic RPC generation — write server functions, re-export them, call them. Simple and framework agnostic.
Isomorphic Design
Server functions defined in src/api/server.ts run exclusively on the server. The plugin transforms their imports into client-side fetch stubs, so calling a server function from the client looks and feels like a local call — but the actual execution stays on the server.
The server functions run isomorphically within any Vite powered runtime.
Why this exists
Many RPC solutions like to overcomplicate things to the point where you no longer ship features, you're maintaining a framework. RPC should be a bridge, not a metropolis.
@thednp/rpc allows you to supercharge any vite powered SPA/SSR starter template in minutes. To prove it, we made a quick guide to recreate our Express SSR example.
Simplicity is best
@thednp/rpc takes simplicity very seriously:
You define them in a file, import and call them where you need them. The plugin handles everything in between — system wide configuration, scanning, type inference, client fetch modules generation, middleware registration, request cancellation — without asking you to restructure your codebase.
createFunction.ts— server-side definition (wrapped handler withAbortController)getClientModules.ts— build-time code generation (string template with validation)client-helpers.ts— client-side runtime (thinfetchbased modules)server-helpers.ts— server-only utilities (RPCError, error formatting,redirect, glob file walking)scanForServerFiles.ts— file discovery- Adapters — thin middleware wrappers
Sound mental model
- Query Engine — The Brain (something like
@tanstack/react-querythat handles caching, lifecycles, deduplication). - @thednp/rpc — The Nervous System (isomorphic transport, serialization, client/server bridge, request cancellation).
- UI Framework — The Muscle (Reactive DOM updates).
What you get
Your server code lives in src/api/server.ts. The plugin knows it's server code because of where it lives, not because you annotated it. There's no 'use server' string to forget, no build error when you accidentally leave it out. The boundary is the file. That's it.
The plugin options live in rpc.config.ts at your project root. Adapter choice, URL prefix, middleware hooks — it's all in one place. You set it up once and then you don't think about it again.
You can access config system wide by calling loadRPCConfig() within your project server-side code.
When you import a server function on the client, the plugin generates a stub that matches your function's exact signature. Change an argument type on the server, and the client types update on the next build. There's no separate codegen command to run, no generated files to commit, no drift between your server and client types.
Every server function call returns a handle with a cancel() helper. Under the hood, it's an AbortController wired into the fetch request. You don't have to create the controller, pass the signal, or clean up listeners. You just call cancel() and the request dies. The server function receives the AbortSignal as its first argument, so you can bail out of expensive work early if the client has already moved on.
The core plugin doesn't care whether you're running Express, Fastify, Hono, Koa, or h3. Adapters for all five are bundled with the package — you import the one you need, register it as middleware, and you're done. If you're building a plain SPA with no server framework at all, the Vite dev server handles RPC requests directly in development. No adapter needed.
Scan src/api/ for classic server.ts|js|mjs|mts files, or switch to glob mode (serverFiles: 'glob') to recursively pick up *.server.{ts,js,mjs,mts} files — handy for feature-based layouts. A scanRoot option points scanning at a shared package directory in monorepos. Duplicate function names throw in development so the conflict is fixed immediately (warning in production).
Server errors return a generic Internal Server Error — no messages, codes, or stacks leak to clients, in any environment. Only RPCError payloads (developer-authored message/code/data) reach the client, and only in development, so you can debug instantly. multipart/form-data content type is supported for file uploads via your framework's multipart parser, and json/text/urlencoded requests are validated against the function's declared content type (415 Unsupported Media Type on mismatch; form encodings are interchangeable for nojs form fallbacks).
Generic type inference flows from your server function's arguments and return type all the way to the client stub. You get autocomplete for function names, argument types, and return types without writing a single type annotation on the client side.
Run multiple RPC instances in parallel. Pass { rpcPrefix: "v1:rpc" } to createServerFunction to register a function under a custom prefix — versioned APIs, namespaced endpoints, and API segregation without function-name collisions. The same name can coexist under different prefixes (v1:rpc/login + v2:rpc/login), middleware dispatches to the prefix-scoped map, and the plugin generates client stubs per prefix. Functions default to "__rpc" for full backward compatibility. See the Multi-Prefix Guide.
Write one middleware function that runs unchanged on every adapter (Express, Fastify, Hono, Koa, h3). Because every dispatch runs inside a per-request context, middleware written against getRequestContext() — reading normalized request data via getRequestMeta(), short-circuiting with sendResponse(status, body, headers) — behaves identically regardless of the host framework. No per-framework rewrites for cross-cutting RPC rules like per-function rate limiting, audit logging, or feature flags. See the Middleware Guide.
Examples
| Source | Demo | Clone |
| ----------------------------------------------------------------------------------------| ----------------------------------------------------------------------------------------------| ---------------------------------------------------------|
| examples/spa | StackBlitz | pnpm dlx degit thednp/rpc/examples/spa my-app |
| examples/ssr | StackBlitz | pnpm dlx degit thednp/rpc/examples/ssr my-app |
| examples/express | StackBlitz | pnpm dlx degit thednp/rpc/examples/express my-app |
| examples/fastify | StackBlitz | pnpm dlx degit thednp/rpc/examples/fastify my-app |
| examples/h3 | StackBlitz | pnpm dlx degit thednp/rpc/examples/h3 my-app |
| examples/hono | StackBlitz | pnpm dlx degit thednp/rpc/examples/hono my-app |
| examples/koa | StackBlitz | pnpm dlx degit thednp/rpc/examples/koa my-app |
| examples/react-query | StackBlitz | pnpm dlx degit thednp/rpc/examples/react-query my-app |
| examples/solid-query | StackBlitz | pnpm dlx degit thednp/rpc/examples/solid-query my-app |
| examples/advanced | StackBlitz | pnpm dlx degit thednp/rpc/examples/advanced my-app |
Clone an example:
degitscaffolds a fresh copy straight from the repo — no git history, ready to run:
# Scaffold the Express example
pnpm dlx degit thednp/rpc/examples/express my-rpc-app
cd my-rpc-app
pnpm install
pnpm devSSR examples demonstrate isomorphic usage: server functions are imported directly during server-side rendering (entry-server.ts) and also called from the client via auto-generated fetch stubs. The SPA example uses only the client-side stubs.
Quick Start
1. Installation
// npm/pnpm and jsr
pnpm add jsr:@thednp/rpc
// OR
npx jsr add @thednp/rpc// deno and jsr
deno add jsr:@thednp/rpc// pnpm/bun from the npm registry
pnpm add @thednp/rpc// npm
npm i @thednp/rpc2. Configuration
Create rpc.config.ts at your project root:
import { defineConfig } from "@thednp/rpc/config";
export default defineConfig({
rpcPrefix: "__rpc",
});Update vite.config.ts at your project root:
import { defineConfig } from 'vite';
import rpc from '@thednp/rpc';
export default defineConfig({
plugins: [rpc(/* development options */)]
});
Check Configuration Guide for details.
3. Define a server function
Create src/api/server.ts:
import { createServerFunction, RPCError } from "@thednp/rpc/server";
export const greet = createServerFunction("greet", (signal, name: string) => {
// access AbortSignal
signal.throwIfAborted();
// add validation and other server ONLY functionality
// throw typed errors for server-side failures
if (!name) throw new RPCError("Name is required", "EMPTY_NAME");
// return the result of processing
return `Hello, ${name}!`;
});RPCError is the typed error helper — in development its message (and code/data) reach the client for instant debugging; in production the response is always a generic Internal Server Error.
Create src/api/index.ts:
export * from "./server";Check Server Functions Guide for details.
4. Call it in your code
Import the function in any client-side or server-side file:
// src/app.ts
import { greet } from "./api";
const { data, cancel } = greet("World");
const result = await data; // "Hello, World!"
cancel("Client aborted"); // AbortController-based cancellation5. Register the RPC middleware on the server
Import and use the middleware from your chosen adapter package.
// Express
import express from "express";
import { createRPCMiddleware } from "@thednp/rpc/express";
const app = express();
app.use(createRPCMiddleware());
app.listen(3000);See the Adapters guide for full snippets for each framework.
Testing
Unit Testing
pnpm test # Run tests once with coverage (vitest run --coverage)
pnpm test:watch # Run tests in watch mode with coverage
pnpm test:ui # Run tests with UITests use Vitest with Istanbul coverage — 11 test files covering the plugin, scanning, server/client helpers, request context, the adapter type-export surface, and all five adapters, at 100% coverage.
Live Testing
pnpm test:dev # Runs all examples/<example> in DEV mode and reports their status in a table
pnpm test:prod # Runs all examples/<example> in PRODUCTION mode and reports their status in a tableThese tests check the following:
- check if the server runs and doesn't crash
- check if there is any issue generating the HTML
- check if server functions work properly
Contributing
Contributions are welcome. This project uses:
- pnpm for package management
- deno for linting and formatting
- tsdown for bundling
- vitest with istanbul for testing
- TypeScript for type checking
Development
pnpm lint # deno lint + tsc -noEmit
pnpm format # deno fmt src tests examples/**/src
pnpm test # Run tests once with coverage (vitest run --coverage)
pnpm test:ui # Run tests with UI
pnpm build # Bundle with tsdown (tsdown)All changes should pass pnpm lint && pnpm format && pnpm test before submitting. See AGENTS.md for the full command reference and project conventions.
Security
RPC endpoints are, by definition, public surface area. Anything reachable over HTTP can be prodded, poked, and abused. We've tried to close the obvious doors:
The URL prefix is validated with an anchored regex, not a simple startsWith check. This means a request to /__rpc-evil/foo won't accidentally match the /__rpc prefix and slip through to your server functions. It sounds like a small thing, but prefix bypass bugs are one of the most common mistakes in middleware-based routing, and they're the kind of thing that only shows up in a security audit at 2am.
When the plugin generates client modules, it interpolates your function names and type signatures into the generated code. Every identifier is validated before it's written into the output. A server function named greet; drop table users won't make it through the generator — it'll fail at build time with a clear error, rather than producing a client module with arbitrary code in it.
When a server function throws, the client receives a clean, generic error message. Stack traces, file paths, database connection strings, and other internal details stay on the server, where they belong. Your server logs get the full error. The client gets "Internal Server Error" and nothing more.
The readBody utility of each adapter doesn't cap raw request bodies by default. You need to use the middleware provided by your server framework of choice.
Server functions only support GET and POST (default POST). RPC dispatch is not REST — PUT/PATCH/DELETE carry resource semantics that don't apply to function calls, and OPTIONS must stay reserved for CORS preflight. Every accepted method is another dispatch path to validate; keeping the surface minimal (and defaulting to POST) reduces CSRF and parsing attack surface. See Server Functions Guide for details.
Request bodies are validated against the function's declared contentType before parsing — mismatches get a 415 Unsupported Media Type. JSON and text functions require an exact match (after stripping charset/boundary parameters); the two form encodings are interchangeable so native urlencoded <form> submissions keep working on multipart-declared endpoints (nojs progressive enhancement). Requests without a Content-Type header are exempt, so curl and GET keep working unchanged. See Wire Protocol for details.
The full threat model, including edge cases and configuration options for tightening things further, is documented in Security.
Documentation
- Quick Start — Rebuild the Express SSR example from
create-vitein under a minute - Getting Started — Installation, project structure, and your first function
- Configuration — Full configuration reference
- Server Functions — Creating server functions
- Middleware — Universal middleware via the request context
- Native Form Fallback — Making RPC endpoints work as a no-JS
<form>action - Client Usage — Client-side usage
- Wire Protocol — The HTTP contract behind the generated clients (curl debugging)
- Adapters — Framework adapters
- Comparison — How the cross-origin/CSRF boundary compares to Next.js Server Actions, TanStack Start, SvelteKit, and tRPC
- Best Practices — Tips and best practices
- Security — Security hardening
License
Released under MIT.
