Lightweight, type-safe RPC over HTTP and WebSockets.
The tRPC and Next.js Server Actions, but for everyone.
Lixnet is a tiny yet powerful TypeScript-first RPC library for teams who want a small, explicit RPC layer with excellent typing, without codegen or framework lock-in.
If you like the ergonomics of tRPC or Next.js Server Actions, but want something that:
- stays close to the Fetch platform (
Request/Response) - works in Bun / Next.js / Workers
- keeps the protocol simple and inspectable
Lixnet is a good fit.
It gives you:
- HTTP RPC via
fetch(LixnetServer+LixnetClient) - WebSocket events + callbacks (
LixnetPeer) - Zero dependencies, with optional validation through any Standard Schema library (zod, valibot, arktype, ...)
- Next.js-like
request.cookies()/request.headers()ergonomics inside handlers (without pulling in Next)
It’s designed to be minimal: no codegen, no schema registry, no client bundler magic.
- Quickstart (HTTP RPC)
- Framework adapters (server wiring)
- Inferred contract (
routes/server.route) - Server:
LixnetServer - Errors
- CORS
- Client:
LixnetClient - Handlers:
requestandresponse - WebSockets:
LixnetPeer - API reference
- Contributor docs
- License
Install:
bun add lixnet zod # zod is optional: any Standard Schema library (zod, valibot, arktype) worksOther package managers:
pnpm add lixnet zod
npm i lixnet zodThis is the contract shared by server and client.
type Events = {
greet: (input: { name: string }) => Promise<string>;
};Register handlers with optional schemas (any Standard Schema library), then expose an HTTP endpoint that forwards the incoming Request to server.handle(...).
import { z } from "zod";
import { LixnetServer } from "lixnet";
type Events = {
greet: (input: { name: string }) => Promise<string>;
};
export const server = new LixnetServer<Events>({
debugLog: false,
});
server.on({
event: "greet",
schema: z.object({ name: z.string() }),
handler: async ({ name, request, response }) => {
// Optional: read request cookies/headers (Next-like API)
const userAgent = request.headers().get("user-agent");
if (userAgent) response.header("x-user-agent", userAgent);
return `Hello, ${name}!`;
},
});Lixnet uses the Fetch standard Request/Response, so wiring is always “forward Request into server.handle(...)”.
import { server } from "./rpc/server";
Bun.serve({
port: 3000,
async fetch(req) {
const url = new URL(req.url);
if (req.method === "POST" && url.pathname === "/rpc") {
return server.handle(req);
}
return new Response("Not found", { status: 404 });
},
});export async function POST(req: Request) {
return server.handle(req);
}export default {
async fetch(req: Request) {
const url = new URL(req.url);
if (req.method === "POST" && url.pathname === "/rpc") {
return server.handle(req);
}
return new Response("Not found", { status: 404 });
},
};import { LixnetClient } from "lixnet";
type Events = {
greet: (input: { name: string }) => Promise<string>;
};
const client = new LixnetClient<Events>({ rpcUrl: "/api/rpc" });
const message = await client.call("greet", { name: "World" });If you need auth/cookies, pass fetch options:
await client.call(
"greet",
{ name: "World" },
{ credentials: "include", headers: { Authorization: "Bearer ..." } },
);Instead of writing an Events type by hand, pass your events as routes (or chain .route(...)) and let the client infer everything from typeof server. Experimental: Events + on() keeps working and both can be used side by side.
// cloudflare/ioServer.ts (worker only)
import { z } from "zod";
import { LixnetServer } from "lixnet";
import live from "./(functions)/live";
export default function createRpcServer(options: { defaultHeaders?: Record<string, string> } = {}) {
return new LixnetServer({
...options,
routes: {
helloThere: {
schema: z.object({ name: z.string() }),
handler: ({ name }) => ({ message: `Hello ${name}` }), // name: string, from the schema
},
// no schema: input is the handler's parameter type minus request/response
live: { handler: live },
},
});
}
export type RpcServer = ReturnType<typeof createRpcServer>;events is an alias of routes (same types and behaviour) for codebases that still use the events naming; pass one or the other.
LixnetStreamServer takes the same routes option and .route(...); see "HTTP streaming" in FOR_AGENTS.md. LixnetPeerEvents does the same for WebSockets (see below).
.route(name, { schema?, handler }) does the same one route at a time and can be chained after routes, e.g. to add events conditionally:
new LixnetServer({ routes: { helloThere: { ... } } })
.route("live", { handler: live });// cloudflare/io.ts (browser)
import type { RpcServer } from "./ioServer"; // type-only: erased at build time
import { LixnetClient } from "lixnet";
export const rpcClient = new LixnetClient<RpcServer>({ rpcUrl: "/rpc" });
await rpcClient.call("helloThere", { name: "World" }); // { message: string }
await rpcClient.call("live"); // input omitted: live takes no client input- With a schema, the handler receives the schema's output and the client sends the schema's input (so
z.string().transform(Number)means the client sends a string). - Without a schema, annotate the handler's parameter;
request/responseare removed from the client input. Anything else in that parameter (e.g. an optionaldb) becomes client input, so keep server-only values out of handler params.
Keeping server code out of the client:
- Always
import typethe server type. EnableverbatimModuleSyntax(or theconsistent-type-importslint rule) so an accidental value import fails instead of bundling the worker (sharp, wasm, ...) into the browser. - The bundle is safe either way, but type-checking still follows the import into the server files. Keep
ioServer.tsthin (it imports handlers only) and give handlers explicit return types, so the client's tsconfig doesn't have to resolve worker-only types. - If the client tsconfig still can't resolve worker types, emit declarations from the worker project (
tsc --emitDeclarationOnly) andimport typefrom the generated.d.ts.
server.on(...) accepts either one registration object or an array.
event: string key matching yourEventstypeschema(optional): a Standard Schema (zod, valibot, arktype, ...) to validateinputhandler: receives validated input plus{ request, response }injections
Example registering multiple events:
server.on([
{
event: "greet",
schema: z.object({ name: z.string() }),
handler: async ({ name }) => `Hello, ${name}!`,
},
{
event: "health",
handler: async () => ({ ok: true as const }),
},
]);server.handle(request) expects the request body to be JSON:
{ "event": "someEvent", "input": { "...": "..." } }Responses are JSON and shaped as either:
{ "data": "..." }or
{ "error": "...", "code": "UNAUTHORIZED", "data": {} }new LixnetServer({ ... }) supports:
defaultHeaders: headers included on every response (unless deleted later)formatter: customize howLixnetResponsebecomes aResponselogger+debugLog: currently used for invalid JSON logging
Example:
const server = new LixnetServer<Events>({
debugLog: true,
defaultHeaders: {
"access-control-allow-origin": "*",
},
});Throw a LixnetError from a handler to send a specific error; the client throws it back with the same code, status and data:
import { LixnetError } from "lixnet";
// server
throw new LixnetError("UNAUTHORIZED", "Session expired");
throw new LixnetError("TOO_MANY_REQUESTS", "Slow down", { data: { retryAfter: 30 } });
throw new LixnetError("PLAN_LIMIT", "Upgrade your plan", { status: 402 }); // custom code
// client
try {
await client.call("me");
} catch (e) {
if (LixnetError.is(e) && e.code === "UNAUTHORIZED") redirect("/login");
}- Built-in codes and their default statuses (
LIXNET_ERROR_STATUS):BAD_REQUEST400,INVALID_INPUT400,UNAUTHORIZED401,FORBIDDEN403,NOT_FOUND404,CONFLICT409,TOO_MANY_REQUESTS429,INTERNAL500. Any other string works as a code (default status 400). LixnetError.is(e)is preferred overinstanceof: it also works when two copies of Lixnet end up in one bundle.- Every client error is a
LixnetError(it extendsError, soe.messageis unchanged), including non-Lixnet responses such as a proxy's HTML 502. - For errors the caller is expected to handle as part of the result, returning a typed union (
{ ok: false, ... }) is still the better fit;LixnetErroris for cross-cutting cases (auth, permissions, rate limits, validation). response.code(403); response.error("Forbidden")still works; the code is derived from the status (FORBIDDEN), or pass one:response.error("Forbidden", { code: "NO_ACCESS" }).
Built-in errors (both servers, same messages as before plus a code):
error |
code |
Status |
|---|---|---|
"Invalid JSON" |
BAD_REQUEST |
400 |
"Invalid request body" |
BAD_REQUEST |
400 |
"Event not found" (missing event) |
BAD_REQUEST |
400 |
"Input not found" |
BAD_REQUEST |
400 |
"Event not found" (unknown event) |
NOT_FOUND |
404 |
"Invalid input" (schema failure) |
INVALID_INPUT |
400 |
"Origin not allowed" (with cors) |
FORBIDDEN |
403 |
"Handler error" (handler threw a non-LixnetError) |
INTERNAL |
500 |
INVALID_INPUT always includes the validation issues, so forms can show errors per field:
{
"error": "Invalid input",
"code": "INVALID_INPUT",
"data": { "issues": [{ "path": ["email"], "message": "Invalid email address" }] }
}Error responses always go through the formatter, so defaultHeaders and CORS headers are included. Keep the error strings stable; match on code in new code.
When the RPC server is on another origin than the app (e.g. a Worker on api.example.com), pass cors instead of hand-writing CORS headers and an OPTIONS branch. Works on LixnetServer and LixnetStreamServer.
const server = new LixnetServer({
cors: {
origin: ["https://example.com", "http://localhost:3000"], // or "*", or (origin) => boolean
credentials: true, // cookies; not allowed with origin "*"
// maxAge: 7200, // preflight cache in seconds (default 7200, Chrome's cap)
// allowHeaders: ["Content-Type", "Authorization"], // default: whatever the browser asks for
// exposeHeaders: ["X-Request-Id"],
},
});
export default {
async fetch(request: Request) {
const url = new URL(request.url);
if (url.pathname === "/rpc" && (request.method === "POST" || request.method === "OPTIONS")) {
return server.handle(request);
}
return new Response("Not found", { status: 404 });
},
};OPTIONSpreflights get204withAccess-Control-Max-Age(default 7200), so the browser preflights at most once per 2 hours per origin instead of before every call.- Every response, errors included, gets
Access-Control-Allow-Origin(the caller's origin, plusVary: Origin) andAccess-Control-Allow-Credentialswhen enabled. - Requests whose
Originisn't allowed get403 { "error": "Origin not allowed" }without running the handler. Requests without anOriginheader (server-to-server, SSR) are not CORS and pass through.
client.call(event, input, options?):
- sends a
POSTwith{ event, input } - throws a
LixnetError(withcode,status,data) when the response contains{ error }; see "Errors" below - otherwise returns
{ data }’s value
const client = new LixnetClient<Events>({
rpcUrl: "https://api.example.com/rpc",
// optional: any fetch-compatible function (defaults to the global fetch)
fetch: (url, init) => fetch(url, { ...init, credentials: "include" }),
});fetch is also how you test or SSR without a network or patching globalThis.fetch:
const client = new LixnetClient<typeof server>({
rpcUrl: "http://local/rpc",
fetch: (url, init) => server.handle(new Request(url, init)),
});LixnetStreamClient takes the same option.
Every event as a typed function: names autocomplete, input is checked and the result is inferred. Same arguments as call (input, then RequestInit options).
const { caller } = rpcClient;
const data = await caller.helloThere({ name: "World" }); // { message: string }
await caller.live(); // input omitted: live takes no client input
const { helloThere } = caller; // destructuring worksLixnetStreamClient has one too: await streamClient.caller.generate({ prompt }, { onStreamChunk }). An event named then can only be called with call(...), so the caller is never mistaken for a Promise.
Handler signature is:
- your validated input fields
- plus:
request: aRequestenhanced withrequest.cookies()andrequest.headers()response: aLixnetResponseyou can use to stage headers/cookies/status
Common operations:
- Set status:
response.code(201) - Set header:
response.header("X-Foo", "bar") - Delete header:
response.deleteHeader("x-foo") - Set cookie:
response.cookie("session", "abc", { httpOnly: true, path: "/" }) - Delete cookie:
response.deleteCookie("session", { path: "/" })
request.cookies() / request.headers() also support “read your writes” inside the handler because they merge staged response mutations.
LixnetPeer is a typed event dispatcher for WebSockets with:
- fire-and-forget events
- optional callbacks (request/response style over WS)
- optional chunking for large payloads (see contributor docs for protocol details)
Minimal sketch:
import { LixnetPeer } from "lixnet";
type ClientToServer = {
ping: (input: { t: number }) => Promise<{ ok: true }>;
};
type ServerToClient = {
notify: (input: { message: string }) => void;
};
const peer = new LixnetPeer<ServerToClient, ClientToServer>();
peer.on("ping", async ({ t, socket }) => {
return { ok: true };
});
// In your WS message handler:
// peer.handle({ data: event.data, socket: ws });Like routes on LixnetServer, each side can define the events it handles as a LixnetPeerEvents object and let the other side infer everything with import type. Each events object only describes its own side, so both sides can import each other's types without a circular type (two peers typed directly from typeof each other would be).
// server/events.ts
import { z } from "zod";
import { LixnetPeerEvents } from "lixnet";
export const serverEvents = new LixnetPeerEvents({
routes: {
hello: {
schema: z.object({ name: z.string() }),
handler: ({ name, socket }) => `Hello ${name}`,
},
},
}).route("add", { handler: ({ a, b }: { a: number; b: number }) => a + b });
// client/events.ts
export const clientEvents = new LixnetPeerEvents().route("notify", {
handler: ({ message }: { message: string }) => console.log(message),
});
// server/socket.ts
import type { clientEvents } from "../client/events";
const peer = new LixnetPeer<typeof clientEvents>({ events: serverEvents });
peer.call("notify", { message: "hi" });
// client/socket.ts
import type { serverEvents } from "../server/events";
const peer = new LixnetPeer<typeof serverEvents>({ events: clientEvents });
peer.call("hello", { name: "World" }, { callback: (greeting) => greeting /* string */ });eventsis an alias ofroutes, and.route(...)chains, exactly likeLixnetServer. Handlers getsocketnext to their input.- With a schema, invalid input never reaches the handler; if the caller passed a
callback, itsonErrorgets aLixnetErrorwith codeINVALID_INPUT. - A handler can't use the other side's types through its events object (that would bring the cycle back). Return a value for callbacks, or call the other side from the file that creates the peer.
LixnetPeer<ThisToThere, ThereToThis>withon(...)keeps working.
const peer = new LixnetPeer<ServerToClient, ClientToServer>();
peer.setSocket(ws);
ws.addEventListener("message", (event) => {
peer.handle({ data: event.data, socket: ws });
});await peer.call(
"ping",
{ t: Date.now() },
{
callback: (result) => {
// result is typed as Awaited<ReturnType<ClientToServer["ping"]>>
console.log(result.ok);
},
},
);If messages can exceed your runtime’s WS frame limits, enable chunking:
peer.setTransmissionLimit(64_000); // bytes-ish (string length)
peer.setTransmissionChunksLimit(20); // currently stored, not enforced by senderFrom src/exports.ts:
LixnetServerLixnetClientLixnetPeer,LixnetPeerEventsLixnetStreamServer,LixnetStreamClientLixnetResponse
Types:
LXN_ServerClient_EventTypeLXNServerHandlerFunctionInputLXN_ServerClient_Request(alias ofLixnetRequest)LixnetRequest,LixnetCookies,LixnetHeaders
- Implementation + extension guide:
FOR_AGENTS.md
MIT