defuss-rpc
v1.6.2
Published
Remote Procedure Call (RPC) for defuss. Requires Node.js 20, 22, 24, or 25 (uses uWebSockets.js native addon). Linux requires glibc >= 2.38.
Maintainers
Readme
Remote Procedure Call (RPC)
⚠️ Runtime Requirement: Node.js only. The RPC server uses
uWebSockets.js(viaultimate-express), a native addon that requires Node.js. It does not run under Bun or Deno.| | Supported Versions | |---|---| | Node.js | 20, 22, 24, 25 | | Platforms | macOS (x64, arm64), Linux (x64, arm64), Windows (x64) | | Linux glibc | >= 2.38 (Ubuntu 24.04+, Debian 13+, RHEL 9.4+) |
Tests must be run with
bun run test(which invokesvitestunder Node.js). Do not usebun test- that triggers Bun's built-in test runner which cannot load the uWebSockets.js native addon.
defuss-rpc is a tiny but powerful RPC library for building type-safe APIs in JavaScript and TypeScript. It enables seamless client-server communication with automatic type safety, bi-directional, seamless binary data format support (via DSON - just pass Uint8Array around; uploads and downloads of TB of data are possible, including streaming and chunked transfers, progress tracking, resend, hash integrity checks, etc.), generator streaming, and minimal setup.
✨ Features
- Type-safe - Full TypeScript support with automatic client type generation
- Classes & Modules - Define APIs as classes (stateful) or plain objects (functional)
- Generator Streaming -
async *generators stream to the client as NDJSON, consumed viafor await...of - DSON Serialization -
Date,Map,Set,Uint8Array,BigInt, and more survive the wire - Vite Plugin - Auto-starts an RPC server alongside Vite dev, with file watching and HMR
- Astro Integration - First-class Astro support via
defussRpc(), withAstro.locals.rpcEndpoint - ExpressRpcServer - Managed Express.js adapter with CORS, health check, and streaming support
- File Uploads - First-class binary upload with
upload()/uploadComplete(), server handlers, SSE progress, gzip compression, and resumable transfers - File Downloads - First-class binary download with
download()/downloadAsBlob(), server handlers, auth headers, hash integrity, and streaming support - Hook System - Guard and result hooks on both server and client for auth, logging, and auditing
- Schema Introspection - Automatic API schema generation and discovery at
/rpc/schema - Framework Agnostic - Works with Astro, Vite, Express.js, or any framework that supports
Request/Response - Fast Upload - Optimized for large binary transfers with (NDJSON) streaming / half-duplex support, gzip compression, and hash verification (SHA-256 and MD5)
Getting Started
1. Install
bun install defuss-rpc2. Define your API
APIs can be classes (instantiated fresh per call) or plain objects (module-style):
// src/api/foo-api.ts - Class-based API
export class FooApi {
async getFoo(id: string) {
return { id, name: "Foo Item" };
}
async createFoo(item: { name: string }) {
return { id: "new-id", ...item };
}
}// src/api/math-utils.ts - Module-based API
export const MathUtils = {
async add(a: number, b: number) {
return a + b;
},
async multiply(a: number, b: number) {
return a * b;
},
};3. Create the RPC registry
// src/rpc.ts
import { FooApi } from "./api/foo-api.js";
import { MathUtils } from "./api/math-utils.js";
const RpcApi = { FooApi, MathUtils };
export default RpcApi;
export type RpcApi = typeof RpcApi;4. Wire it up
Choose one of the integrations below - Astro, Vite, or Express.
5. Use on the client
When using the Vite or Astro plugin, the RPC endpoint is auto-registered - just import the virtual module anywhere in your app and call getRpcClient() without options:
import "virtual:defuss-rpc"; // auto-registers the endpoint (import once in your entry point)
import { getRpcClient } from "defuss-rpc/client";
import type { RpcApi } from "../rpc.js";
const rpc = await getRpcClient<RpcApi>(); // endpoint is resolved automatically
// Class-based: instantiate, then call methods
const fooApi = new rpc.FooApi();
const foo = await fooApi.getFoo("123"); // fully typed
// Module-based: call functions directly
const sum = await rpc.MathUtils.add(2, 3); // 5You can also read the endpoint value directly if needed:
import { rpcEndpoint } from "virtual:defuss-rpc";
console.log(rpcEndpoint); // e.g. "http://localhost:3210"Or override the endpoint per-client:
const rpc = await getRpcClient<RpcApi>({ baseUrl: "http://other-host:4000" });Resolution order: explicit baseUrl option => auto-registered endpoint from virtual module => "" (current page origin).
Astro Integration
The defussRpc() Astro integration wraps the Vite plugin and injects middleware to populate Astro.locals.rpcEndpoint.
// astro.config.ts
import { defineConfig } from "astro/config";
import defuss from "defuss-astro";
import node from "@astrojs/node";
import { defussRpc } from "defuss-rpc/astro.js";
import RpcApi from "./src/rpc.js";
export default defineConfig({
integrations: [
defuss({ include: ["src/**/*.tsx"] }),
defussRpc({
api: RpcApi,
port: 0, // 0 = random available port
watch: ["src/api/**/*.ts"], // hot-reload API files
}),
],
adapter: node({ mode: "standalone" }),
});Add the type to your env.d.ts:
declare namespace App {
interface Locals {
rpcEndpoint: string;
}
}No manual route handler needed - the integration handles everything.
Vite Plugin
Use the Vite plugin directly in non-Astro projects:
// vite.config.ts
import { defineConfig } from "vite";
import defuss from "defuss-vite";
import { defussRpc } from "defuss-rpc/vite-plugin.js";
import RpcApi from "./src/rpc.js";
export default defineConfig({
plugins: [
defuss(),
defussRpc({
api: RpcApi,
port: 0,
watch: ["src/api/**/*.ts"],
}),
],
});The plugin:
- Starts an
ExpressRpcServeralongside Vite's dev server - Provides a
virtual:defuss-rpcmodule that auto-registers the RPC endpoint with the client - Watches API files and hot-reloads the RPC namespace on change
// Client code - just import the virtual module and go
import "virtual:defuss-rpc";
import { getRpcClient } from "defuss-rpc/client";
const rpc = await getRpcClient<RpcApi>(); // endpoint auto-resolvedPlugin Options
| Option | Type | Default | Description |
| :-------------- | :--------------------- | :------------------- | :---------------------------------------------------- |
| api | ApiNamespace | (required) | Map of namespace name => class or module |
| port | number | 0 | Port for the RPC server (0 = OS-assigned) |
| protocol | "http" \| "https" | "http" | Protocol for the endpoint URL |
| host | string | "localhost" | Host/IP to bind ("0.0.0.0" for all interfaces) |
| basePath | string | "" | URL prefix for all RPC endpoints |
| jsonSizeLimit | string | "1mb" | Max request body size (use upload() for large files) |
| corsOrigin | string \| string[] | "*" | Access-Control-Allow-Origin value |
| watch | string \| string[] | ["src/**/*.ts"] | Glob patterns for API file watching |
| endpoint | string | (auto-constructed) | Full RPC endpoint URL for the client to connect to |
ExpressRpcServer
A managed Express adapter that bridges rpcRoute to an HTTP server with CORS, health checks, and NDJSON streaming support. Used internally by the Vite plugin, but also available standalone:
import { createRpcServer } from "defuss-rpc/server";
import { ExpressRpcServer } from "defuss-rpc/express-server";
import RpcApi from "./rpc.js";
createRpcServer(RpcApi);
const server = new ExpressRpcServer({
port: 3210,
corsOrigin: "https://app.example.com",
});
const { port, url } = await server.start();
console.log(`RPC server running on ${url}`);
// Later:
await server.stop();Endpoints exposed:
| Endpoint | Description |
| :-------------------- | :---------------------------------------- |
| GET/POST /health | Liveness check { status: "ok" } |
| POST /rpc | Dispatch an RPC call |
| POST /rpc/schema | Return the registered namespace schema |
| POST /rpc/upload | Binary file upload (NDJSON response) |
| HEAD /rpc/upload/:id| Resume check - returns X-Upload-Offset |
| GET /rpc/upload/progress/:id | SSE stream of server-side progress |
| GET /rpc/download | Binary file download |
CORS Headers
ExpressRpcServer sets CORS headers automatically. By default, Access-Control-Expose-Headers includes upload-related headers (X-Upload-Offset, X-Upload-Handler, X-Original-Size, X-Upload-Status).
For file downloads, the browser's fetch() API can only read response headers explicitly exposed via Access-Control-Expose-Headers. If your download handler sets Content-Disposition (for filename preservation) or custom headers like X-File-Sha256, they must be exposed:
const server = new ExpressRpcServer({
port: 3210,
corsOrigin: "*",
});
// Expose download headers for browser fetch() access
const app = server.getApp();
app.use((req, res, next) => {
res.header(
"Access-Control-Expose-Headers",
"Content-Disposition, Content-Length, X-Download-Id, X-File-Size, X-File-Sha256",
);
next();
});
await server.start();Without this, response.headers.get("Content-Disposition") returns null in the browser, and the downloaded filename falls back to the download ID.
Generator Streaming
Any async * generator function in your API automatically streams to the client using the NDJSON protocol. The client receives an async generator that you consume with for await...of.
Server - define a generator
// src/api/chat.ts
export const ChatApi = {
async *streamMessage(prompt: string) {
const words = "Hello from the streaming RPC server!".split(" ");
for (const word of words) {
await new Promise((r) => setTimeout(r, 100));
yield word;
}
return words.join(" "); // return value is the final frame
},
async ping() {
return "pong";
},
};Client - consume the stream
import { getRpcClient } from "defuss-rpc/client";
const rpc = await getRpcClient<RpcApi>();
for await (const chunk of rpc.ChatApi.streamMessage("hi")) {
console.log(chunk); // "Hello", "from", "the", ...
}Wire protocol
Generator responses use Content-Type: application/x-ndjson. Each line is a DSON-serialized frame:
{"type":"yield","value":"Hello"}
{"type":"yield","value":"from"}
{"type":"yield","value":"the"}
{"type":"return","value":"Hello from the streaming RPC server!"}If the generator throws, an error frame is sent:
{"type":"error","error":{"message":"Something went wrong","stack":"..."}}Non-generator methods continue to use standard single-response JSON as before.
File Uploads
defuss-rpc provides first-class binary upload support with progress tracking, gzip compression, hash verification, and resumable transfers - no chunking or manual MD5 required.
Server - register an upload handler
Use addUploadHandler() for buffered uploads (entire payload in memory) or addStreamingUploadHandler() for large files processed as a stream:
import { addUploadHandler, addStreamingUploadHandler } from "defuss-rpc/server";
// Buffered: receives the full Uint8Array after upload completes
addUploadHandler<{ size: number; name: string }>("file-upload", async (data, meta) => {
// data is a Uint8Array, meta contains uploadId, sha256, originalSize, etc.
await fs.writeFile(`uploads/${meta.uploadId}.bin`, data);
return { size: data.byteLength, name: meta.uploadId };
});
// Streaming: receives a ReadableStream<Uint8Array> for real-time processing
addStreamingUploadHandler<{ totalBytes: number }>("video-ingest", async (stream, meta) => {
const reader = stream.getReader();
let totalBytes = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
totalBytes += value.byteLength;
}
return { totalBytes };
});The meta object (UploadMeta) contains:
| Field | Type | Description |
| :--------------- | :------- | :----------------------------------------------- |
| uploadId | string | Unique upload identifier |
| handlerName | string | Registered handler name |
| originalSize | number | Expected total bytes (from client header) |
| bytesReceived | number | Actual bytes received (after decompression) |
| sha256 | string | SHA-256 hex digest of received content |
| durationMs | number | Server-side processing duration in ms |
| contentEncoding| string | Transfer encoding ("identity", "gzip", etc.) |
| offset | number | Byte offset (0 for fresh, >0 for resumed) |
Client - upload with progress
Use upload() for progress tracking or uploadComplete() for fire-and-forget:
import { upload, uploadComplete } from "defuss-rpc/client";
// With progress events (async generator)
for await (const event of upload<MyResult>("file-upload", file)) {
if (event.type === "sending") console.log(`Sending: ${event.percent}%`);
if (event.type === "receiving") console.log(`Server: ${event.percent}%`);
if (event.type === "complete") console.log("Done!", event.result, event.sha256);
}
// Fire-and-forget (returns UploadResult<T>)
const result = await uploadComplete<MyResult>("file-upload", buffer);
console.log(result.sha256, result.result);Upload events
The upload() generator yields three event types:
| Event | Fields | Description |
| :---------- | :-------------------------------------------- | :------------------------------------ |
| sending | bytesSent, totalBytes, percent | Client-side bytes fed to the network |
| receiving | bytesReceived, totalBytes, percent | Server-confirmed progress (via SSE) |
| complete | result, uploadId, sha256, durationMs, bytesReceived | Final result with hash |
Options
await upload("handler", data, {
baseUrl: "http://localhost:3210", // Override RPC endpoint
compression: "auto", // "auto" | "gzip" | "none"
chunkSize: 256 * 1024, // Streaming chunk size (256 KiB default)
uploadId: "previous-id", // Resume a previous upload
signal: abortController.signal, // AbortSignal for cancellation
headers: { Authorization: "..." }, // Extra headers
progress: true, // Enable SSE sideband (default: true)
});Resumable uploads
Pass a previously used uploadId to resume an interrupted upload. The client sends a HEAD request to check the server offset and skips already-transferred bytes:
for await (const event of upload("file-upload", largeFile, {
uploadId: "my-upload-id",
})) {
// Automatically resumes from where it left off
}File Downloads
Download files from the server using addDownloadHandler() (buffered) or addStreamingDownloadHandler() (streaming). Downloads support authentication via request headers, hash integrity verification, and Content-Disposition for browser file saving.
Server - register a download handler
import { addDownloadHandler, addStreamingDownloadHandler } from "defuss-rpc/server";
import { readFile } from "node:fs/promises";
import { createReadStream } from "node:fs";
// Buffered: loads entire file into memory (small/medium files)
addDownloadHandler("static-file", async (meta) => {
const data = await readFile("/path/to/file.zip");
return {
data: new Uint8Array(data),
fileMeta: {
size: data.byteLength,
contentType: "application/zip",
filename: "file.zip",
},
};
});
// Streaming: streams from disk (large files, no memory pressure)
addStreamingDownloadHandler("large-file", async (meta) => {
const { createReadStream } = await import("node:fs");
const { stat } = await import("node:fs/promises");
const filePath = "/path/to/large-video.mp4";
const statResult = await stat(filePath);
return {
stream: createReadStream(filePath).readableWebStream(),
fileMeta: {
size: statResult.size,
contentType: "video/mp4",
filename: "large-video.mp4",
lastModified: statResult.mtimeMs,
},
};
});Client - download with auth
import { download, downloadAsBlob } from "defuss-rpc/client";
// Download as raw binary (returns DownloadResult)
const result = await download("static-file", rpcBaseUrl, {
headers: { Authorization: "Bearer my-token" },
});
console.log(result.bytesDownloaded, result.sha256, result.filename);
// Download as Blob (for browser usage)
const { blob, result } = await downloadAsBlob("static-file", rpcBaseUrl, {
headers: { Authorization: "Bearer my-token" },
});
console.log(blob.size, result.filename);Download result
| Field | Type | Description |
| :---------------- | :------- | :--------------------------------------------- |
| downloadId | string | Unique download identifier |
| bytesDownloaded | number | Total bytes received |
| sha256 | string | SHA-256 hex digest (from X-File-Sha256) |
| filename | string | Suggested filename (from Content-Disposition) |
Hook System
Both server and client support a hook system for cross-cutting concerns like auth, logging, and auditing.
Server hooks
import { addHook } from "defuss-rpc/server";
// Guard hook - runs before method invocation. Return false to reject (HTTP 403).
addHook({
phase: "guard",
fn: async (className, methodName, args, request) => {
const authHeader = request.headers.get("authorization");
if (!authHeader) return false; // blocks the call
return true;
},
});
// Result hook - runs after successful return, before response is sent.
addHook({
phase: "result",
fn: async (className, methodName, args, request, result) => {
console.log(`${className}.${methodName} returned`, result);
},
});Client hooks
import { addHook, setHeaders } from "defuss-rpc/client";
// Set custom headers on every RPC request
setHeaders({ Authorization: "Bearer my-token" });
// Guard hook - runs before the fetch is dispatched
addHook({
phase: "guard",
fn: async (className, methodName, args, request) => {
console.log(`Calling ${className}.${methodName}`);
return true; // return false to abort the call
},
});
// Response hook - runs after the HTTP response arrives, before body is read
addHook({
phase: "response",
fn: async (className, methodName, args, request, response) => {
console.log(`Response status: ${response.status}`);
},
});
// Result hook - runs after DSON deserialization
addHook({
phase: "result",
fn: async (className, methodName, args, request, response, data) => {
console.log(`Got result:`, data);
},
});Schema Introspection
The RPC server automatically generates a schema describing all registered namespaces:
import { getSchema } from "defuss-rpc/client";
const schema = await getSchema(); // cached for the page lifetimeExample response:
[
{
"kind": "class",
"className": "FooApi",
"methods": {
"getFoo": { "async": true, "generator": false },
"createFoo": { "async": true, "generator": false }
},
"properties": {}
},
{
"kind": "module",
"moduleName": "ChatApi",
"methods": {
"streamMessage": { "async": true, "generator": true },
"ping": { "async": true, "generator": false }
}
}
]DSON Transport
All RPC payloads are serialized with defuss-dson, which extends JSON to preserve types that JSON.stringify drops:
Date,Map,SetUint8Array,Int32Array,ArrayBuffer, and all typed arraysBigIntundefined(inside objects)
This means you can pass and return binary data (Uint8Array), dates, maps, and sets transparently - no manual encoding needed.
Architecture
/
├-- src/
| ├-- client.ts # Proxy-based RPC client, generator consumer
| ├-- server.ts # rpcRoute handler, schema generation, streaming
| ├-- express-server.ts # ExpressRpcServer adapter with CORS & streaming
| ├-- vite-plugin.ts # Vite plugin: dev server, virtual module, HMR
| ├-- astro-integration.ts # Astro integration wrapping the Vite plugin
| ├-- astro-middleware.ts # Injects Astro.locals.rpcEndpoint
| ├-- rpc-state.ts # Shared state: config, base URL, server reference
| ├-- upload-state.ts # Upload handler registry and temp-file state
| └-- types.d.ts # TypeScript type definitions
├-- tsconfig.json
├-- LICENSE
└-- package.jsonServer -
createRpcServer()registers namespace entries.rpcRoutehandles dispatch: schema requests return introspection data, RPC calls route to the class instance or module function. Generator results are streamed as NDJSON viaReadableStream.Client -
getRpcClient()fetches the schema, then builds aProxy-based client. Regular methods usefetch+ DSON. Generator methods returnasync function*that reads the NDJSON stream viagetReader()and reconstructs the yields/returns/errors.Express Adapter -
ExpressRpcServerconverts Express requests to Fetch APIRequestobjects, delegates torpcRoute, and maps the response back. NDJSON responses are piped chunk-by-chunk viares.write().Vite/Astro - The Vite plugin starts an
ExpressRpcServerin dev, exposesrpcEndpointvia a virtual module, and watches API files for hot-reload. The Astro integration wraps this and adds middleware forAstro.locals.rpcEndpoint.
Examples
examples/with-rpc-upload- Binary file upload with progress bar, SSE tracking, and hash verificationexamples/with-rpc-chat-streaming- AI-style chat streaming using async generators
🧞 Commands
All commands are run from the root of the project, from a terminal:
| Command | Action |
| :------------ | :----------------------------------------------- |
| bun run build | Build the RPC package. |
| bun run test | Run the Node.js test suite (via Vitest). |
| bun run test:browser | Run Playwright browser integration tests. |
| bun run publish | Publish a new version of defuss-rpc. |
Note:
bun run testinvokesvitest runwhich executes under Node.js. Do not usebun test(Bun's built-in test runner) - the uWebSockets.js native addon is incompatible with Bun's module loader.
