Skip to content

contember/trasa

Repository files navigation

Trasa

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 Schemazod, valibot, arktype, … — so even zod is just an OPTIONAL peer.
  • Raw TypeScript, no build step. Ships its src directly (Bun resolves it); no dist.
  • One package: @trasa/core.

Installation

bun add @trasa/core zod

Quick Start

A 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' })

Concepts

RPC

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.

Routing

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).

Middleware

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 here

Errors

Error 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.

API

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

License

MIT

About

trasa — a dependency-free server-side framework for Cloudflare Workers: HTTP routing + a typed tRPC-like RPC engine + middleware. buzola's server-side counterpart.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors