A dependency-free, server-side framework for Cloudflare Workers: HTTP routing + a typed, tRPC-like RPC engine + middleware. Trasa is buzola's server-side counterpart — buzola routes the browser, trasa routes the Worker, and an end-to-end-typed RPC client ties the two together.
- Zero runtime dependencies. Validators are any Standard Schema —
zod, valibot, arktype, … — so evenzodis just an OPTIONAL peer. - Raw TypeScript, no build step. Ships its
srcdirectly (Bun resolves it); nodist. - One package:
@trasa/core.
bun add @trasa/core zodA complete Worker — RPC router, authz, HTTP routes, middleware, and a typed browser client:
// worker.ts
import {
type AuthLike,
createRpcClient,
defineServer,
initRpc,
type Middleware,
route,
} from '@trasa/core'
import { z } from 'zod'
// 1. Your environment + per-request context.
interface Env {
ASSETS: { fetch(request: Request): Promise<Response> }
}
interface Ctx {
env: Env
auth: AuthLike // populated by the `auth` middleware below
}
// 2. The RPC router. `.query` / `.mutation` are metadata; `.output()` is optional.
const t = initRpc<Ctx>()
const appRouter = t.router({
projects: t.router({
list: t.procedure
.input(z.object({ limit: z.number().max(100).default(20) }))
.output(z.array(z.object({ id: z.string(), name: z.string() })))
.query(async ({ ctx, input }) => {
return loadProjects(ctx.env, input.limit)
}),
// `.require` is only available when Ctx has `auth: AuthLike`. It runs BEFORE
// the handler: `auth.can('project:write', { type: 'project', value: id })`.
rename: t.procedure
.input(z.object({ id: z.string(), name: z.string() }))
.require('project:write', (input) => ({ type: 'project', value: input.id }))
.mutation(async ({ ctx, input }) => {
await renameProject(ctx.env, input.id, input.name)
return { ok: true }
}),
}),
})
export type AppRouter = typeof appRouter
// 3. Middleware authenticates and attaches `ctx.auth`. It may also wrap `next()`
// (e.g. to append a Set-Cookie) or short-circuit (e.g. a 401 redirect).
const auth = (env: Env): Middleware<Ctx> => async (request, ctx, next) => {
ctx.auth = await authenticate(env, request) // your propustka integration
return next()
}
// 4. The Worker. `assets` is the SPA fallback for unmatched GETs.
export default defineServer<Env, Ctx>({
context: (env) => ({ env, auth: anonymous() }),
middleware: (env) => [auth(env)],
routes: [
route.get('/health', () => new Response('ok')),
route.get(
'/api/:project/envelope',
(ctx, params) => Response.json({ project: params.project }),
),
route.rpc('/api/rpc', appRouter),
],
assets: (env) => env.ASSETS,
})
// 5. The browser client — fully typed from `AppRouter`, no codegen.
export const api = createRpcClient<AppRouter>({ baseUrl: '/api/rpc' })
// await api.projects.rename({ id: 'p1', name: 'New name' })initRpc<Ctx>() returns a procedure builder and a router factory. A procedure is built fluently:
t.procedure
.input(schema) // any Standard Schema validator (zod, valibot, arktype, …)
.output(schema) // OPTIONAL — when set, the handler result is validated
.require(action, scopeResolver?) // OPTIONAL, repeatable — authz, checked before the handler
.query(fn) // or .mutation(fn), or the neutral .handler(fn)The wire protocol is a single POST:
- Request:
{ "method": "projects.rename", "input": { ... } }or a batch{ "batch": [ ... ] }. - Response:
{ "result": ... }or{ "error": { "type", "message", "issues?" } }(single) /{ "batch": [ ... ] }.
Single-error HTTP status comes from the thrown error's OWN httpStatus (ForbiddenError → 403, BadRequestError → 400, a custom { httpStatus: 503 } → 503, a plain error → 500); error.type is just the wire label the client reads. A failed input validation is a 400 validation error (with issues); a failed output validation is a 500.
route.get/post/put/patch/delete(pattern, handler) and route.rpc(path, router). :param segments are captured and typed from the pattern string literal — /api/:project/envelope gives the handler params: { project: string }. matchRoutes(routes, request) is method-aware (RPC routes match POST).
type Middleware<Ctx> = (
request: Request,
ctx: Ctx,
next: () => Promise<Response>,
) => Promise<Response>Middleware run in declared order around the inner dispatch. A middleware may mutate ctx (e.g. set ctx.auth), short-circuit by returning a Response without calling next(), or wrap next() (await it, then mutate the Response).
Global vs route-scoped. defineServer({ middleware }) runs for every request. A route may also carry its own middleware via opts.use, which runs after the global chain, scoped to that route only — the clean way to put a second auth scheme on one path group:
route.rpc('/api/rpc', appRouter) // global auth (human/service)
route.rpc('/s/rpc', shareRouter, { use: [iam.capabilityMiddleware()] }) // + capability tokens, only here
route.post('/api/:project/ingest', ingest, {
use: [iam.apiKeyMiddleware({ resolve })],
}) // + DSN key, only hereError handling is structural — there is no cross-package instanceof. Any thrown value may expose httpStatus, type, message, issues, and loginUrl; trasa reads those off whatever it is handed (status = httpStatus ?? 500). The provided classes are a convenience:
HttpError (the base) + BadRequestError (400), UnauthorizedError (401), ForbiddenError (403), NotFoundError (404), ConflictError (409). toErrorResponse(err) and defaultOnError do the mapping; override per-server with onError.
| Export | Kind | Description |
|---|---|---|
initRpc<Ctx>() |
fn | RPC builder + router factory |
InferRouter<T> / InferRouterClient<T> |
type | Server- / client-facing router views |
route |
obj | get/post/put/patch/delete + rpc builders |
matchRoutes(routes, request) |
fn | Method-aware route matcher |
defineServer<Env, Ctx>(config) |
fn | Returns { fetch, scheduled?, queue? } |
HttpError + subclasses |
class | Convenience HTTP errors |
toErrorResponse / defaultOnError |
fn | Structural error mapping |
Middleware / AuthLike / Scope |
type | Middleware + shared auth contract |
createRpcClient<T>({ baseUrl }) |
fn | Typed RPC client proxy |
RpcError |
class | Client-side RPC error |
MIT