Universal Type-Safe RPC with Reactive Streams
A groundbreaking RPC library that provides perfect type inference, universal transport support, and reactive stream-based response handling. Built for any boundary: HTTP, WebSocket, Workers, or any message-passing interface.
- 🎯 Perfect Type Inference - Zero configuration, complete IntelliSense
- 🌐 Universal Transport - HTTP, WebSocket, Workers, any message-passing boundary
- 📡 Reactive Streams - Stream-based response handling with per-action filtering
- 🔒 Context-Aware - Type-safe context injection per action
- 📚 Documentation-as-Distribution - Copy-paste transport integrations embedded in JSDoc, no separate packages needed. Hover over
Action.handle()andClient.create()in your IDE to see all available integrations - ⚡ Zero Dependencies - Lightweight with optional stream integration
- 🛠️ Framework Agnostic - Works with any server framework
- 📘 Full TypeScript - End-to-end type safety
npm install @soffinal/rpc
# or
bun add @soffinal/rpcOptional: Advanced Stream Transformations
For advanced stream operations like filter, map, merge, and other reactive primitives:
npm install @soffinal/stream
# or
bun add @soffinal/streamimport { Action } from "@soffinal/rpc";
// Actions are simple functions that return Action.Response ( {data} | {error} )
function addUser(ctx: undefined, name: string, age: number) {
if (name.length < 2) {
return { error: "Name too short" as const };
}
if (age < 18) {
return { error: "Must be 18 or older" as const };
}
return { data: { id: Math.random(), name, age } };
}
function getUserProfile(ctx: { userId: string }, profileId: string) {
if (ctx.userId !== profileId) {
return { error: "Unauthorized" as const };
}
return { data: { id: profileId, name: "John Doe" } };
}
// Or use Action.create helper for convenience
const addUser = Action.create((ctx: undefined, name: string, age: number) => {
if (name.length < 2) {
return Action.error("Name too short" as const);
}
if (age < 18) {
return Action.error("Must be 18 or older" as const);
}
return Action.success({ id: Math.random(), name, age });
});
// Create action registry
const actions = { addUser, getUserProfile };
export type Actions = typeof actions;
// HTTP Server (Bun example)
const server = Bun.serve({
port: 3000,
async fetch(req) {
// ... do the logic to get the contexts if needed
const response = await Action.handle(await req.json(), actions, {
getUserProfileContext: { userId: "current-user" },
});
return Response.json(response);
},
});import { Client } from "@soffinal/rpc";
import type { Actions } from "./server";
// Create client with HTTP transport
const client = Client.create<Actions>(async (request) => {
const response = await fetch("http://localhost:3000/rpc", {
method: "POST",
body: JSON.stringify(request),
});
return response.json();
});
// Use as functions with perfect type inference
const user = await client.addUser("John", 25);
// ^ { data: { id: number, name: string, age: number } } | { error: "Name too short" | "Must be 18 or older" }
// Use as reactive streams
client.addUser.data.listen((user) => {
// ^ { id: number, name: string, age: number }
console.log("User created:", user);
});
client.addUser.error.listen((error) => {
// ^ "Name too short" | "Must be 18 or older"
console.error("Failed to create user:", error);
});
// Global response monitoring
client.responses.listen((response) => {
// ^ { actionName: "addUser", data?: { id: number, name: string, age: number }, error?: "Name too short" | "Must be 18 or older" } | { actionName: "getUserProfile", data?: { id: string, name: string }, error?: "Unauthorized" }
console.log(`Action ${response.actionName}:`, response);
});Actions are type-safe functions that define your RPC endpoints:
// Raw action function
const myAction = (context: MyContext, arg1: string, arg2: number): Action.Response<MyData, MyError> => {
// Your logic here
if (success) {
return { data: myData };
} else {
return { error: myError };
}
};
// Or use Action.create helper
const myAction = Action.create((context: MyContext, arg1: string, arg2: number) => {
// Your logic here
if (success) {
return Action.success(myData);
} else {
return Action.error(myError);
}
});Actions can require specific context, enforced at compile time:
// Action requiring authentication context
const authenticatedAction = Action.create((ctx: { userId: string }, data: any) => {
return Action.success(`User ${ctx.userId} processed ${data}`);
});
// TypeScript enforces required context
await Action.handle(request, actions, {
authenticatedActionContext: { userId: "user123" }, // Required!
});Every action becomes both a function and a reactive stream:
// Call as function
const result = await client.myAction(arg1, arg2);
// Listen as stream
client.myAction.data.listen((data) => console.log("Success:", data));
// ^ MyData
client.myAction.error.listen((error) => console.log("Error:", error));
// ^ MyError
// Global streams
client.responses.listen((response) => {
if (response.actionName === "myAction") {
// ^ TypeScript narrows to myAction response type
// Handle specific action responses
}
});Advanced Stream Transformations
For complex reactive patterns, install @soffinal/stream for additional primitives:
import { filter, map, merge } from "@soffinal/stream";
// Transform and filter responses
client.addUser.data
.pipe(filter({}, (_, user) => [user.age > 21, {}]))
// ^ { id: number, name: string, age: number }
.pipe(map({}, (_, user) => [`Welcome ${user.name}!`, {}]))
.listen((message) => console.log(message));
// ^ string
// Centralized error handling with filtering
client.error
.pipe(filter({}, (_, response) => [response.actionName === "addUser", {}]))
.listen(({ error }) => showUserError(error));
// Merge multiple action streams
const allUserActions = client.addUser.data.pipe(merge(client.updateUser.data, client.deleteUser.data));The library includes copy-paste ready integrations for popular frameworks. Hover over Action.handle() and Client.create() in your IDE to see all available integrations:
Express.js:
app.post("/rpc", async (req, res) => {
const response = await Action.handle(req.body, actions, {
// Your context here
});
res.json(response);
});Next.js API Routes:
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "POST") {
const response = await Action.handle(req.body, actions, {
// Your context here
});
res.json(response);
}
}ws Library:
wss.on("connection", (ws) => {
ws.on("message", async (data) => {
const { id, request } = JSON.parse(data.toString());
const response = await Action.handle(request, actions, {});
ws.send(JSON.stringify({ requestId: id, response }));
});
});Web Worker:
// Client
const client = Client.create<Actions>((request) => {
return new Promise((resolve) => {
const handler = (event) => {
worker.removeEventListener("message", handler);
resolve(event.data);
};
worker.addEventListener("message", handler);
worker.postMessage(request);
});
});
// Worker
self.onmessage = async (event) => {
const response = await Action.handle(event.data, actions, {});
self.postMessage(response);
};Node.js Worker Threads:
import { parentPort } from "worker_threads";
parentPort?.on("message", async (request) => {
const response = await Action.handle(request, actions, {});
parentPort?.postMessage(response);
});const createPost = Action.create(async (ctx: { db: Database; userId: string }, title: string, content: string) => {
try {
const post = await ctx.db.posts.create({
title,
content,
authorId: ctx.userId,
});
return Action.success(post);
} catch (error) {
return Action.error("Database error");
}
});import { z } from "zod";
const userSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
age: z.number().min(18),
});
const createUser = Action.create((ctx: undefined, userData: unknown) => {
const result = userSchema.safeParse(userData);
if (!result.success) {
return Action.error(result.error.issues);
}
return Action.success({ id: Math.random(), ...result.data });
});Action.create<CTX, ARGS, DATA, ERROR>(fn)- Create typed actionAction.handle(request, actions, context)- Handle RPC requestsAction.success<DATA>(data)- Create success responseAction.error<ERROR>(error)- Create error response
Client.create<ACTIONS>(transport)- Create typed clientclient.actionName(...args)- Call action as functionclient.actionName.data- Success stream for actionclient.actionName.error- Error stream for actionclient.responses- All responses streamclient.data- All success responses streamclient.error- All error responses stream
- Node.js 16+
- Bun 1.0+
- Deno 1.0+
- Modern browsers with ES2020+
- Cloudflare Workers
- Vercel Edge Runtime
MIT © Soffinal
- Author: Soffinal
- Email: [email protected]
- GitHub: @soffinal