railiz-rpc
v1.1.0
Published
Lightweight, adapter-first, type-safe RPC framework inspired by tRPC
Maintainers
Readme
🌐 railiz-rpc
🚀 Lightweight tRPC-style RPC framework built on Railiz engine
Built for people who want tRPC DX but don’t want tRPC complexity.
Why railiz-rpc?
- No router boilerplate
- No REST endpoints
- No schema duplication
- Works on Edge, Node, Serverless
- Plugin-first architecture
rpc.user.get({ id: 1 })What is railiz-rpc?
- Type-safe RPC like tRPC
- Multi-router architecture
- Edge / Node / Serverless compatible
- Plugin system (auth, logger, cache)
- React Query hooks generator
- OpenAPI generator built-in
👉 Minimal core
👉 Maximum flexibility
Installation
npm install railiz-rpc railiz zodMental Model
Client → Transport → RPC Handler → Router → Procedure → Middleware → ResponseEverything is:
- fully typed
- composable
- framework-agnostic
Quick Start
import { json, Railiz } from 'railiz'
import { createProcedure, createRouter, createRPC } from 'railiz-rpc'
import { rpcClient, rpcHandler } from 'railiz-rpc/transport'
import { z } from 'zod'
const app = new Railiz()
type AppContext = {
headers: Record<string, string>
}
const t = createProcedure<AppContext>()
const userRouter = createRouter({
get: t
.input(z.object({ id: z.number() }))
.resolve(({ input }) => {
return { id: input.id, name: 'Tung' }
}),
})
const rpc = createRPC({
user: userRouter,
})
app.use(json())
app.plugin(
rpcHandler(rpc, {
prefix: '/rpc',
// rpc plugin
plugins: [
loggerPlugin(),
// cachePlugin({ ttl: 5000 })
],
}),
)
// use from client
export type AppRouter = typeof rpc
app.run(3000)Advanced Example (Auth + Cache + React)
// assume auth + cache plugin already configured
const userRouter = createRouter({
get: t
.input(z.object({ id: z.number() }))
.resolve(({ input }) => {
return { id: input.id }
}),
me: t.use(auth).resolve(({ context }) => {
return context.user
}),
list: t.resolve(() => {
return [
{ id: 1 },
{ id: 2 },
]
}),
})
// Cache moved to RPC plugin (recommended)
app.plugin(
rpcHandler(rpc, {
prefix: '/rpc',
cache: {
enabled: true,
ttl: 5000,
key: ({ path, input, context }) => {
return `${path}:${context?.state?.user?.id ?? 'anon'}`
},
condition: ({ path, input }) => {
// better than string matching
return path === 'user.list'
},
},
}),
)Client:
import { rpcClient, rpcHandler } from 'railiz-rpc/transport'
import type { AppRouter } from 'yourbackend'
const rpc = rpcClient<AppRouter>('/rpc')
await rpc.user.get({ id: 1 })
await rpc.user.me()
await rpc.user.list()React:
const { data } = rpc.user.get.useQuery({ id: 1 })React Query Proxy Client
Example usage of rpcReactClient to create a type-safe client with automatic React Query hooks.
Server Router
const userRouter = createRouter({
get: t
.input(z.object({ id: z.number() }))
.resolve(({ input }) => {
return {
id: input.id,
name: 'Tung',
}
}),
})
const rpc = createRPC({
user: userRouter,
})Create React RPC Client
import { rpcReactClient } from 'railiz-rpc/transport'
import type { AppRouter } from 'yourbackend'
const client = rpcReactClient<AppRouter>({
'user.get': async (input: { id: number }) => {
const res = await fetch('/rpc/user.get', {
method: 'POST',
body: JSON.stringify(input),
})
return res.json()
},
})- This is a low-level adapter example
Use in React (useQuery)
import { useQuery } from '@tanstack/react-query'
function UserPage() {
const { queryKey, queryFn } = client.user.get.useQuery({ id: 1 })
const { data } = useQuery({
queryKey,
queryFn,
})
return <div>{data?.name}</div>
}Use Mutation
function UpdateUser() {
const { mutate } = client.user.get.useMutation()
return (
<button
onClick={async () => {
const res = await mutate({ id: 1 })
console.log(res)
}}
>
Load user
</button>
)
}Direct Call (optional fallback)
await client.user.get({ id: 1 })rpcReactClient -> Proxy (user.get) -> useQuery / useMutation / direct call -> fetch transport -> railiz-rpc serverMulti Router
const rpc = createRPC({
user: userRouter,
post: postRouter,
})Middleware
const auth = createAuthPlugin({
validate: async (token) => {
if (token === 'admin') return { id: 1 }
return null
},
})OpenAPI
import { openApi } from 'railiz-rpc/openapi'
const spec = openApi(rpc)Multi RPC Instances
app.plugin( rpcHandler(rpc, { prefix: '/rpc', }))
app.plugin( rpcHandler(rpc2, { prefix: '/rpc2', }))- Fully supported — isolate domains / services easily
Protocol Spec
All RPC calls are sent to a single endpoint:
POST /rpc
Request
Single call
{
"path": "user.get",
"input": { "id": 1 }
}Batch call
[
{ "path": "user.get", "input": { "id": 1 } },
{ "path": "post.list", "input": {} }
]Response
All responses are always wrapped in a standard envelope:
{
"success": true,
"data": "result"
}HTTP Status Codes
- 200 OK → RPC executed (even if some procedures fail)
- 400 Bad Request → invalid request (e.g. empty body)
Error model
- Errors are not thrown as HTTP errors.
- Instead, they are returned inside the response:
{
"success": true,
"data": {
"error": "RPC_ERROR"
}
}or in batch:
{
"success": true,
"data": [
{ "error": "USER_NOT_FOUND" },
{ "id": 1, "name": "Tung" }
]
}Design Note
Railiz-rpc uses a single endpoint + RPC envelope model:
- HTTP handles transport only
- Business results are returned in data
- Errors are normalized inside response payload
There is only one HTTP endpoint. All logic is resolved via RPC path.
HTTP status is always 200 unless request is invalid
Transport Layer (Adapters)
Use the same router across different runtimes.
Fetch (Edge / Cloudflare / Bun)
import { createFetchHandler } from 'railiz-rpc/adapter'
export default {
fetch: createFetchHandler(rpc),
}Express
import express from 'express'
import { createExpressHandler } from 'railiz-rpc/adapter'
const app = express()
app.use(express.json())
app.post('/rpc', createExpressHandler(rpc))Fastify
import Fastify from 'fastify'
import { createFastifyHandler } from 'railiz-rpc/adapter'
const app = Fastify()
app.post('/rpc', createFastifyHandler(rpc))Hono
import { Hono } from 'hono'
import { createHonoHandler } from 'railiz-rpc/adapter'
const app = new Hono()
app.post('/rpc', createHonoHandler(rpc))Next.js (Route Handler)
import { createFetchHandler } from 'railiz-rpc/adapter'
export const POST = createFetchHandler(rpc)WebSocket
import { createWebSocketHandler } from 'railiz-rpc/adapter'
const ws = createWebSocketHandler(rpc)createApp (Advanced Server)
One function to bootstrap everything
Basic
import { createApp } from 'railiz-rpc/transport'
const app = createApp({ rpc })
app.run(3000)Full Setup
import { createApp } from 'railiz-rpc/transport'
const app = createApp({
rpc,
prefix: '/rpc',
version: 'v1',
rpcPlugins: [
loggerPlugin(),
// cachePlugin({ ttl: 5000 })
],
createContext: (ctx) => ({
headers: ctx.req?.headers ?? {},
}),
openapi: true,
logger: true,
beforeRequest: (ctx) => {
ctx.start = Date.now()
},
afterResponse: (ctx, result) => ({
success: true,
data: result,
}),
onError: (err, ctx) => {
return ctx.statusCode(400).json({
success: false,
message: err.message,
})
},
dev: true,
})Built-in Routes
POST /rpc
POST /rpc/v1
GET /openapi.json
GET /healthHooks
beforeRequest(ctx)
afterResponse(ctx, result)
onError(err, ctx)Logger
[RPC] POST /rpc/v1 - 12ms
[RPC ERROR] POST /rpc/v1 - 5msUnified Handler (Node / Edge / Lambda)
One handler for all runtimes
Usage
import { createHandler } from 'railiz-rpc/adapter'
export const handler = createHandler({
router: rpc,
runtime: 'lambda', // 'node' | 'edge' | 'lambda'
})Real Example (RPC)
import { createRPC, createRouter, createProcedure } from 'railiz-rpc'
import { createHandler } from 'railiz-rpc/adapter'
// ====================
// Procedure
// ====================
const t = createProcedure()
// ====================
// Router
// ====================
const router = createRouter({
hello: t.resolve(() => {
return 'hello world'
}),
})
// ====================
// RPC
// ====================
const rpc = createRPC({
app: router,
})
// ====================
// Lambda handler
// ====================
export const handler = createHandler({
router: rpc,
runtime: 'lambda',
})Call
POST /rpc
{
"path": "app.hello",
"input": {}
}Response
{
"success": true,
"data": "hello world"
}Supported Runtimes
| Runtime | Usage | | ------- | ----------------------------- | | Node | Express / Fastify | | Edge | Cloudflare / Bun / Workers | | Lambda | AWS Lambda / Vercel / Netlify |
What you get
- ✅ One API → all runtimes
- ✅ Auto normalize headers
- ✅ Base64 body support (AWS)
- ✅ Multi-value headers support
- ✅ Query string support
- ✅ Built-in CORS fallback
- ✅ Zero core changes
Runtime → Request → RPC → Response → Runtime
Result
createHandler({ router, runtime: 'lambda' })→ deploy anywhere
Bridge Layer (Core Concept)
Server Side (Source of Truth)
const rpc = createRPC({
user: userRouter,
post: postRouter,
})
// export
export type AppRouter = typeof rpcThen you mount it into any runtime (Railiz / Node / Edge / Lambda):
app.plugin(
rpcHandler(rpc, {
prefix: '/rpc',
}),
)At this point, your entire backend is exposed through a single endpoint:
POST /rpcClient Side (Type-Safe Proxy)
On the client, you do NOT recreate APIs or endpoints. You simply import the type from the server and call it like a local function:
// ts
import type { AppRouter } from 'yourbackend'
const rpc = rpcClient<AppRouter>('/rpc')
await rpc.user.get({ id: 1 })
await rpc.post.list()No REST. No DTO duplication. No manual API layer.
Bridge Flow
Client Call
↓
Type-safe Proxy
↓
HTTP Transport (/rpc)
↓
RPC Handler (Railiz / Node / Edge)
↓
Router → Procedure → Middleware
↓
ResponseMental Model
Think of Railiz-rpc as:
- Server = function registry
- Client = typed function proxy
- Transport = invisible bridge
- RPC = shared contract layer
You are no longer building APIs. You are sharing functions across runtime boundaries.
Key Idea
- Write functions once on the server
- Call them anywhere on the client
- Let Railiz-rpc handle everything in between This is the core philosophy of Railiz-rpc: “Functions over APIs.”
tRPC vs railiz-rpc
| ⚙️ Criteria | 🧩 tRPC | 🚀 railiz-rpc | | ---------------- | ------------------- | -------------------------- | | Runtime | Node only | Node + Edge + Serverless | | Architecture | Fixed / opinionated | Flexible / composable | | Router system | Centralized | Multi-router (modular) | | Plugins | Limited | Fully extensible | | Middleware | Basic | First-class + composable | | Transport layer | HTTP-centric | Pluggable (fetch, ws, etc) | | Client DX | Excellent | Excellent (tRPC-like) | | Setup complexity | Medium | Minimal | | Core size | Heavy | Lightweight | | OpenAPI | External / partial | Built-in | | Edge support | Limited | First-class | | Serverless | Workarounds needed | Native support |
Why this matters
- Call backend like local functions
- Share types automatically
- Works across runtimes (Node, Edge, Lambda)
- No API layer duplication
Philosophy
RPC should feel like:
rpc.user.get({ id: 1 })Not HTTP. Not REST.
License
MIT
