Call server-side functions and classes as if they ran client-side

Dates come back as Dates Generators stream to for await Uploads report their progress No framework needed on Bun

defuss-rpc exposes server-side functions and classes as a fully typed, async client API, built from the server's schema at runtime. defuss-dson encodes every argument and result, so Date, Map, Set, BigInt and typed arrays arrive intact. Calls, sequential or parallel, are plain HTTP and JSON; event streams are NDJSON you read with for await; files travel as raw bytes, uploads with progress. On Bun it needs nothing else: Bun.serve is the default and answered the most calls with the least memory. Express, bunway, Vite and Astro are optional.

bun add defuss-rpc

Part of the defuss (opens in a new tab) ecosystem by Aron Homberg (kyr0) (opens in a new tab) · Open Source (MIT (opens in a new tab))

Typed from your server code

getRpcClient<typeof RpcApi>() types every call from the server's own export. No schema language, no generated stubs that go stale.

Generators stream

An async * method sends each yield as an NDJSON frame the moment it happens; break on the client stops the generator on the server.

Types JSON loses

DSON carries Date, Map, Set, BigInt and every typed array, plus undefined, NaN, ±Infinity and -0, as arguments and as results.

Files of any size

Uploads stream to disk with SHA-256, gzip and live progress, and resume after a dropped connection; streaming handlers read them in bounded chunks.

Hooks at both ends

Server guards reject calls with 403 before they run; client hooks add auth headers, metrics and logging around every call.

Your server framework

rpcRoute maps a Request to a Response for Bun.serve and Astro routes; ExpressRpcServer runs it on Bun.serve by default, or on Express (Node.js and Bun) or bunway (Bun) when you import their provider.

Get started

Define, serve, call

Three files. Each one below is a file of examples/, and the end-to-end test runs every one of them against the published package, on Node.js and on Bun.

  1. Define the API as classes and modules

    Export one object of namespaces and its type. A class gets a fresh instance per call; a module is shared.

    api.ts
    /** A module: plain functions on one shared object. */export const MathApi = {  async add(a: number, b: number) {    return a + b;  },  async multiply(a: number, b: number) {    return a * b;  },};/** * A class: the server creates a fresh instance for every call, so * fields never leak between requests. */export class GreeterApi {  async greet(name: string) {    return `Hello, ${name}!`;  }}/** * An async generator streams: the client receives every `yield` * as it happens. */export const ChatApi = {  async *reply(prompt: string) {    for (const word of `You said: ${prompt}`.split(" ")) {      await new Promise((resolve) => setTimeout(resolve, 40));      yield word;    }    return "done";  },};/** * DSON keeps the types JSON loses: Date, Set, Map, BigInt and * typed arrays. */export const TypesApi = {  async sample() {    return {      when: new Date("2026-11-01T12:00:00.000Z"),      tags: new Set(["rpc", "dson"]),      scores: new Map([["ada", 1815]]),      big: 2n ** 64n,      bytes: new Uint8Array([222, 173, 190, 239]),    };  },};/** Which runtime answers: the same API runs on Node.js and Bun. */export const ServerInfo = {  async runtime() {    const { bun, node } = process.versions;    return bun ? `Bun ${bun}` : `Node.js ${node}`;  },};const RpcApi = { MathApi, GreeterApi, ChatApi, TypesApi, ServerInfo };export default RpcApi;export type RpcApi = typeof RpcApi;
  2. Serve it

    ExpressRpcServer adds CORS, gzip, body limits and a health route. Without a provider it runs on Bun.serve at port 63920: the default, with the most calls per second and the least memory (Performance). Import a provider only on Node.js or for a framework's routes and middleware. A Bun.serve you already run can mount rpcRoute itself.

    server-bun.ts
    import { createRpcServer } from "defuss-rpc/server";import { ExpressRpcServer } from "defuss-rpc/express-server";import RpcApi from "./api.ts";import "./files.ts";import "./hooks.ts";createRpcServer(RpcApi);// No provider: Bun.serve, without a framework. On Node.js, select// one (server-express.ts).const server = new ExpressRpcServer({  host: "127.0.0.1",  // undefined: the default port, 63920  port: process.env.PORT ? Number(process.env.PORT) : undefined,});// Requests outside the RPC routes go to one fetch handler (default:// 404); add your own before start().server.getApp().fetch = (request) =>  new URL(request.url).pathname === "/version"    ? Response.json({ version: 1 })    : new Response("Not Found", { status: 404 });await server.start(); // listening at http://127.0.0.1:63920
  3. Call it, typed

    The client fetches the schema once per endpoint and builds callers for every method. The comments show what the e2e test sees printed.

    client.ts
    import {  addHook,  download,  getRpcClient,  setHeaders,  uploadComplete,} from "defuss-rpc/client";import type { RpcApi } from "./api.ts";const baseUrl = process.env.RPC_URL ?? "http://127.0.0.1:63920";const rpc = await getRpcClient<RpcApi>({ baseUrl });console.log(await rpc.MathApi.add(2, 3)); // 5console.log(await new rpc.GreeterApi().greet("Ada")); // Hello, Ada!
Performance

Stay on the default

Of every ExpressRpcServer setup, the default on Bun.serve answered the most small calls and needed the least memory, idle and under load, in two runs of make bench. Choose a provider for Node.js or for a framework's middleware, not for speed.

  • 41,930calls per second on the default (35,798 in run 1). bunway: 28,491. Express on Node.js: 16,776.
  • 1 / 3 mscall latency, p50 / p99. bunway: 2 / 4. Express on Node.js: 3 / 9.
  • 29 MiBmemory (RSS) of the idle server. bunway: 36. Express on Node.js: 99.
  • 59 MiBmemory under small calls. bunway: 65. Express on Node.js: 213.

Every server, side by side

Small calls and streams per second, latency, and the server process's memory: idle, under small calls, and at its peak while moving files.

Small calls, streams and the server process's memory (RSS), per server
ServerRuntimecalls/scalls/s, run 1p50 / p99 msstreams/sRSS idleRSS under callsRSS peak (files)
No provider: Bun.serve defaultBun41,93035,7981 / 36,74829 MiB59 MiB384 MiB
bunwayProvider()Bun28,49128,2962 / 47,30436 MiB65 MiB394 MiB
expressProvider()Bun20,02720,9812 / 75,13445 MiB129 MiB308 MiB
expressProvider()Node.js16,77615,5513 / 93,99199 MiB213 MiB335 MiB
rpcRoute in a bare Bun.serveBun62,62156,0560 / 29,24429 MiB66 MiB389 MiB

Reading the numbers

What the benchmark measured, and where the default does not lead.

How was it measured?

On 2026-11-01, on an Apple M5 Max with Node.js 26.11 and Bun 1.4.3: 5 s per scenario, 64 connections, autocannon on the same machine at a load average of 17 to 19. Every column except run 1 comes from run 2.

Where does the default not lead?

Streams and files: the default and bunway were within each other's noise. The memory peak while moving files, 300 to 400 MiB for every server, did not rank the servers the same way in both runs.

Why is rpcRoute faster than the default?

It is the baseline: rpcRoute in a bare Bun.serve. The default adds CORS, routing, body reading and the size limit, and that work is the gap.

Can I measure it myself?

make bench runs every scenario on your machine. The README has the full table, with uploads and downloads.

Protocol

How a call travels

Plain HTTP with four body formats: DSON for calls, NDJSON for generators and upload answers, raw bytes for files, Server-Sent Events for upload progress. Without ExpressRpcServer (rpcRoute in your own Bun.serve or an Astro route) your server takes the place of the HTTP layer.

Calls · application/json A call: the schema once, then one POST per call Time runs down; calls are solid, answers dashed. Every request and answer passes the HTTP layer; an arrow points at the layer that reads its format.
Your code App
defuss-rpc/client Client
ExpressRpcServer HTTP layer
defuss-rpc/server rpcRoute
Your API Class or module
  1. getRpcClient<RpcApi>({ baseUrl })
  2. POST /rpc/schema · once per baseUrl
  3. 200 · application/json · the schema
  4. rpc.MathApi.add(2, 3)
  5. DSON.stringify({ className, methodName, args })
  6. POST /rpc · Content-Type: application/json
  7. CORS · inflate gzip/deflate · jsonSizeLimit (413)
  8. Fetch Request
  9. DSON.parse · guard hooks (403)
  10. add(2, 3) · a class gets a new instance
  11. 5
  12. 200 · DSON text · application/json
  13. gzip above 1 KiB when accepted
  14. 5 · Date, Map, BigInt arrive as such
Generators · application/x-ndjson A generator: one NDJSON frame per yield The server pulls the next value only when the client reads. A break instead of reading to the end cancels the response, and the generator's finally runs.
Your code App
defuss-rpc/client Client
ExpressRpcServer HTTP layer
defuss-rpc/server rpcRoute
Your API Class or module
  1. for await (... of rpc.ChatApi.reply("hi"))
  2. POST /rpc · the same call body
  3. Fetch Request
  4. reply("hi") · an async generator
  5. yield "hi"
  6. application/x-ndjson · DSON frame { type: "yield" }
  7. gzip flushed after every frame
  8. "hi" · one value per frame
  9. return · frame { type: "return" }
  10. or: break
  11. cancel the response body
  12. generator.return() · finally runs
Uploads · application/octet-stream An upload: bytes to disk, progress as events, the hash in the answer The body is raw bytes and has no size limit; the handler runs after the last byte. HEAD happens only when an uploadId resumes an upload.
Your code App
defuss-rpc/client Client
ExpressRpcServer HTTP layer
defuss-rpc/server rpcRoute
Your handler Upload handler
  1. upload("file", data, { uploadId })
  2. HEAD /rpc/upload/:id · resume only
  3. X-Upload-Offset: bytes on disk
  4. GET /rpc/upload/progress/:id · EventSource
  5. POST /rpc/upload · octet-stream, gzip
  6. the body as a stream
  7. temp file + SHA-256, chunk by chunk
  8. text/event-stream · data: { progress } JSON
  9. handler(bytes or stream, meta)
  10. result
  11. 201 · NDJSON: received { sha256 }, result
  12. events: sending, receiving, complete
Downloads · raw bytes A download: raw bytes with name and hash in the headers Downloads are never compressed by the server. download() returns the bytes, downloadAsBlob() a Blob.
Your code App
defuss-rpc/client Client
ExpressRpcServer HTTP layer
defuss-rpc/server rpcRoute
Your handler Download handler
  1. download("file", baseUrl, { downloadId })
  2. GET /rpc/download?handler=...&downloadId=...
  3. Fetch Request
  4. handler({ downloadId, rangeStart, rangeEnd })
  5. bytes, or { data, fileMeta }
  6. 200 · raw bytes · Content-Disposition, X-File-Sha256
  7. passed through as it is
  8. { data, filename, sha256 }

The encoding on each layer

What each layer reads and writes, per kind of request, from your code down to your API.

  • Calls

    application/json Sequence

    Your code and the client
    Typed values: Date, Map, Set, BigInt, typed arrays.
    Wire (HTTP)
    POST /rpc with DSON text as application/json.
    HTTP layer
    CORS; inflates gzip and deflate bodies; 413 above jsonSizeLimit; gzip above 1 KiB.
    rpcRoute and your API
    DSON.parse gives the arguments, DSON.stringify the answer.
  • Generators

    application/x-ndjson Sequence

    Your code and the client
    for await over the values.
    Wire (HTTP)
    One DSON frame per line: each yield, then return or error.
    HTTP layer
    gzip, flushed after every frame.
    rpcRoute and your API
    The generator is pulled once per read.
  • Uploads

    application/octet-stream Sequence

    Your code and the client
    An async iterator of sending, receiving and complete.
    Wire (HTTP)
    POST /rpc/upload: raw bytes, gzip, with the headers
    • X-Upload-Handler
    • X-Upload-Id
    • X-Original-Size
    • X-Upload-Offset
    • X-Original-Filename
    Progress as Server-Sent Events with JSON data; the answer as NDJSON.
    HTTP layer
    No size limit; progress events are never compressed.
    rpcRoute and your API
    The handler runs after the last byte, with the bytes or a chunked stream.
  • Downloads

    raw bytes Sequence

    Your code and the client
    { data, filename, sha256 }, or a Blob.
    Wire (HTTP)
    GET /rpc/download: raw bytes with Content-Disposition (RFC 6266) and X-File-Sha256.
    HTTP layer
    Passed through, never compressed.
    rpcRoute and your API
    The handler returns bytes or a stream, plus fileMeta.
Vite & AstroOptional

The dev server starts the RPC server

You need neither. defuss-rpc runs on its own, on Bun.serve or a provider, as shown above, and depends on neither. Add one of these packages only when your app already runs on Vite or Astro and vite dev or astro dev should start the RPC server for you.

defuss-rpc-vite runs an ExpressRpcServer beside the dev server (on Bun without a provider, port 63920) and tells the client where it is through virtual:defuss-rpc, so getRpcClient() needs no options.

bun add defuss-rpc-vite
main.ts
// registers the dev server's RPC endpoint as the defaultimport "virtual:defuss-rpc";import { getRpcClient } from "defuss-rpc/client";import type { RpcApi } from "./api.ts";// no baseUrl: the virtual module set itconst rpc = await getRpcClient<RpcApi>();const sum = await rpc.MathApi.add(2, 3);document.querySelector("#sum")!.textContent = String(sum);
Client
vite.config.ts
import { defineConfig } from "vite";import { defussRpc } from "defuss-rpc-vite";import RpcApi from "./api.ts";// No provider: the RPC server runs on Bun.serve, port 63920.// On Node.js add `provider: expressProvider()`.export default defineConfig({  plugins: [    defussRpc({      api: RpcApi,      host: "127.0.0.1",      watch: ["api.ts"],    }),  ],});
Server

To import rpcEndpoint in TypeScript, declare the virtual module once:

env.d.ts
declare module "virtual:defuss-rpc" {  /** Endpoint the client uses, e.g. "http://127.0.0.1:63920" in vite dev. */  export const rpcEndpoint: string;}
Streaming and types

Generators become for await

A generator method answers with application/x-ndjson: one DSON frame per yield, then a return frame, or an error frame the client throws. The server pulls the next value only when the client reads, and a break runs the generator's finally.

client.ts
const words: string[] = [];for await (const word of rpc.ChatApi.reply("hi there")) {  words.push(word);}console.log(words.join(" ")); // You said: hi there
Client
api.ts (ChatApi)
/** * An async generator streams: the client receives every `yield` * as it happens. */export const ChatApi = {  async *reply(prompt: string) {    for (const word of `You said: ${prompt}`.split(" ")) {      await new Promise((resolve) => setTimeout(resolve, 40));      yield word;    }    return "done";  },};
Server

On the wire

Each NDJSON line is DSON's compact encoding of { type, value }, which the client decodes with DSON.parse. A method that is not a generator answers with one DSON body.

response body of ChatApi.reply("hi there")
[[2,[[1,2],[3,4]]],[0,"type"],[0,"yield"],[0,"value"],[0,"You"]][[2,[[1,2],[3,4]]],[0,"type"],[0,"yield"],[0,"value"],[0,"said:"]][[2,[[1,2],[3,4]]],[0,"type"],[0,"yield"],[0,"value"],[0,"hi"]][[2,[[1,2],[3,4]]],[0,"type"],[0,"yield"],[0,"value"],[0,"there"]][[2,[[1,2],[3,4]]],[0,"type"],[0,"return"],[0,"value"],[0,"done"]]

Types JSON loses

DSON encodes every argument and result, so a Date stays a Date, a Map a Map and a BigInt a BigInt, and a typed array keeps its bytes.

client.ts
const { when, tags, scores, big, bytes } =  await rpc.TypesApi.sample();console.log(when.toISOString()); // 2026-11-01T12:00:00.000Zconsole.log(tags.has("dson"), scores.get("ada")); // true 1815console.log(big); // 18446744073709551616nconsole.log(bytes instanceof Uint8Array); // true
Client
api.ts (TypesApi)
/** * DSON keeps the types JSON loses: Date, Set, Map, BigInt and * typed arrays. */export const TypesApi = {  async sample() {    return {      when: new Date("2026-11-01T12:00:00.000Z"),      tags: new Set(["rpc", "dson"]),      scores: new Map([["ada", 1815]]),      big: 2n ** 64n,      bytes: new Uint8Array([222, 173, 190, 239]),    };  },};
Server
Files

Uploads and downloads with a hash on every byte

An upload is written chunk by chunk to a temp file while the server computes SHA-256; the handler runs when the last byte arrived. Pass a known uploadId and the client asks the server how far it got and sends only the rest.

client.ts (Node.js, Bun)
type StoredFile = { id: string; size: number };const data = new TextEncoder().encode("hello, file");const { result, sha256 } = await uploadComplete<StoredFile>(  "file",  data,  { baseUrl, originalFilename: "hello.txt" },);console.log(result.size, sha256.length); // 11 64const file = await download("file", baseUrl, {  downloadId: result.id,});const text = new TextDecoder().decode(file.data);console.log(text, file.filename); // hello, file hello.txt
Client
files.ts
import {  addDownloadHandler,  addUploadHandler,} from "defuss-rpc/server";interface StoredFile {  data: Uint8Array;  name: string;}/** The last 20 files: a demo must not grow without bound. */const files = new Map<string, StoredFile>();addUploadHandler("file", (data, meta) => {  const { uploadId: id, originalFilename, sha256 } = meta;  const name = originalFilename || `${id}.bin`;  files.set(id, { data, name });  if (files.size > 20) files.delete(files.keys().next().value!);  return { id, size: data.byteLength, sha256 };});addDownloadHandler("file", ({ downloadId }) => {  const file = files.get(downloadId);  if (!file) throw new Error(`Unknown file ${downloadId}`);  return {    data: file.data,    fileMeta: {      size: file.data.byteLength,      contentType: "application/octet-stream",      filename: file.name,    },  };});
Server

In a browser page

The same calls from a page: progress for a progress bar, and the file back as a Blob for an object URL or a save link.

browser.ts (browser)
import { downloadAsBlob, upload } from "defuss-rpc/client";export interface StoredFile {  id: string;  size: number;  sha256: string;}/** * Uploads a File and reports progress: "sending" from the browser, * "receiving" from the server's SSE stream. */export async function sendFile(  file: File,  baseUrl: string,  onProgress: (phase: string, percent: number) => void,) {  const options = { baseUrl, originalFilename: file.name };  for await (const event of upload<StoredFile>("file", file, options)) {    if (event.type === "complete") return event;    onProgress(event.type, event.percent);  }  throw new Error("upload ended without a result");}/** Fetches the file back as a Blob, for an object URL or a save link. */export async function fetchFile(id: string, baseUrl: string) {  const { blob, result } = await downloadAsBlob("file", baseUrl, {    downloadId: id,  });  return { blob, filename: result.filename ?? `${id}.bin` };}

The events of upload()

upload() yields these events to for await. uploadComplete() skips the progress stream and returns the fields of complete.

  • sending

    Node.js and Bun: per chunk of the streamed body. Browsers: once, after the buffered body was sent.

    • bytesSent
    • totalBytes
    • percent
  • receiving

    Where EventSource exists (browsers): the bytes the server has stored, from its progress stream.

    • bytesReceived
    • totalBytes
    • percent
  • complete

    Once, at the end, with the handler's return value.

    • result
    • uploadId
    • bytesReceived
    • sha256
    • durationMs
Hooks

Guards before, hooks after

Server guards see the namespace, method, arguments and the raw Request; an explicit false answers 403 before the method runs. Client hooks wrap every call: a guard can stop it, response and result hooks observe it.

client.ts
// sent with every later call and uploadsetHeaders({ Authorization: "Bearer demo-token" });let answered = 0;// runs after each decoded resultaddHook({ phase: "result", fn: () => void answered++ });await rpc.MathApi.multiply(6, 7);console.log(answered); // 1
Client
hooks.ts
import { addHook } from "defuss-rpc/server";const MAX_UPLOAD_BYTES = 50 * 1024 * 1024;// Guards run before every call, upload and download; returning// false answers HTTP 403. For uploads the namespace is// "__upload__" and args[0] is the size the client announced.addHook({  phase: "guard",  fn: (namespace, _method, args) => {    if (namespace !== "__upload__") return true;    return Number(args[0]) <= MAX_UPLOAD_BYTES;  },});
Server
Runtimes

Tested where it runs

Every cell below is a test that runs on each change: Vitest in Node.js, Bun, Chromium and WebKit, and an end-to-end run of the packed package on Node.js and Bun.

Where each part runs; every mark is a test that runs on each change
PartBrowsersNode.js 22, 24, 26Bun default
Client: calls, streams, uploads, downloadsChromium, WebKit
rpcRoute (Fetch handler)n/awith Bun.serve
ExpressRpcServer, no providern/athrows and names expressProvider()on Bun.serve
ExpressRpcServer on expressProvider()n/a
ExpressRpcServer on bunwayProvider()n/abunway runs on Bun.serve
defuss-rpc-vite, defuss-rpc-astron/aon Expresswithout a provider; the Vite plugin on every provider

make bench compares the providers on calls, streams, uploads, downloads and memory; Performance shows the latest run.

Functions and classes are the API now

One runtime dependency (defuss-dson), ESM and CommonJS with TypeScript types. The source, the tests and the design notes are on GitHub.

MIT licensed