Sigitex/route

activeexperiment

A server-side web framework.

GITHUB ↗

DOCS

@sigitex/route

A server-side web framework.

bun add @sigitex/route

Note: This package currently exports TypeScript sources directly. A TypeScript-compatible runtime or bundler (Bun, etc.) is required.

Quick Start

import { route, get, post, prefix, cors, hardened, cookies } from "@sigitex/route"
import { bun } from "@sigitex/route/bun"

const fetch = route(
  bun({ assets: "./public" }),
  get("/health", () => ({ status: "ok" })),
  prefix("/api/", [cors(), cookies(), hardened()],
    get("/users", async () => {
      return Response.json(await getUsers())
    }),
    get("/users/:id", async ({ params }: { params: { id: string } }) => {
      return Response.json(await getUser(params.id))
    }),
    post("/users", async ({ request }: { request: Request }) => {
      const body = await request.json()
      return Response.json(await createUser(body))
    }),
  ),
)

Bun.serve({ fetch })

On Cloudflare Workers:

import { route, get } from "@sigitex/route"
import { cloudflare } from "@sigitex/route/cloudflare"

export default {
  fetch: route(
    cloudflare(),
    get("/hello", () => ({ hello: "world" })),
  ),
}

route(...handlers)

Creates a fetch function (request: Request, env: Env) => Promise<Response> from a list of handlers. Handlers are tried in order; the first to return a value produces the response. If none match, a 404 is returned.

const fetch = route(handler1, handler2, handler3)

An optional RouterOptions object can be passed as the first argument:

const fetch = route({ container, middlewares: [cors()] }, handler1, handler2)

Falsy values (null, undefined, false, 0) are silently ignored, allowing conditional handlers:

route(
  isDev && get("/debug", debugHandler),
  get("/", homeHandler),
)

RouterOptions

Option Type Description
container Container A @sigitex/bind IoC container for dependency injection
middlewares RouteMiddleware[] Global middlewares applied to every dispatched handler

Handlers

Handlers are functions that receive a context object and return a Response, a JSON-serializable value, or undefined to skip.

get(path, handler, ...middlewares)

Matches GET requests against path. Path parameters use regexparam syntax (:param, *).

get("/users/:id", ({ params }: { params: { id: string } }) => {
  return Response.json({ id: params.id })
})

post(path, handler, ...middlewares)

Matches POST requests.

put(path, handler, ...middlewares)

Matches PUT requests.

del(path, handler, ...middlewares)

Matches DELETE requests.

patch(path, handler, ...middlewares)

Matches PATCH requests.

pattern(method, path, handler, ...middlewares)

Generic version -- pass null as the method to match any HTTP method.

pattern(null, "/any-method/:id", handler)
pattern("GET", "/explicit", handler)

prefix(prefix, ...handlers)

Groups handlers under a URL prefix. The prefix is stripped from the URL before child handlers see it.

prefix("/api/v1/",
  get("/users", listUsers),   // matches /api/v1/users
  get("/posts", listPosts),   // matches /api/v1/posts
)

Accepts an optional middlewares array as the second argument:

prefix("/api/", [cors(), bodyLimit()],
  post("/upload", uploadHandler),
)

mount(fetchFn)

Wraps a standard (request: Request) => Promise<Response> function as a handler. Useful for mounting sub-applications or external fetch handlers.

mount(subApp.fetch)

assets()

Serves static files via the platform's Assets binding. Returns undefined on 404 so subsequent handlers can match.

route(bun(), assets(), get("/", homeHandler))

app(routes)

Serves index.html for paths matching a client-side RouteTree. Intended for single-page applications where the client handles routing.

app({
  users: "/users",
  user: "/users/:id",
  settings: { general: "/settings/general" },
})

noop

A handler that does nothing and returns undefined. Used internally by middleware composition.

use(middlewares, ...handlers)

Applies a set of middlewares to a group of handlers without creating a prefix.

use([cors(), cookies()],
  get("/a", handlerA),
  get("/b", handlerB),
)

filter(predicate)

Higher-order function that conditionally runs a handler based on a predicate.

const onlyJson = filter(({ request }) =>
  request.headers.get("Accept")?.includes("application/json") ?? false
)

onlyJson(get("/data", dataHandler))

https()

Redirects HTTP requests to HTTPS with a 301.

route(https(), get("/", homeHandler))

www(options)

Redirects non-www requests to the www subdomain.

www({ secure: true })  // also upgrades to https

Middleware

Middlewares are objects with optional before and after hooks. before runs before the handler; after runs after. Either can return a Response to short-circuit.

const myMiddleware: RouteMiddleware = {
  before: ({ request, bind }) => {
    bind({ startTime: Date.now() })
  },
  after: ({ response, startTime }) => {
    response.headers.set("X-Duration", String(Date.now() - startTime))
  },
}

cors(options?)

Handles CORS preflight and response headers.

Option Type Default
origin string | string[] | (origin: string) => boolean "*"
methods string[] all standard
allowHeaders string[] mirrors request
exposeHeaders string[] --
credentials boolean false
maxAge number --

cookies()

Parses request cookies and collects Set-Cookie headers on the response. Binds a Cookies object to context:

cookies.get("session")           // read
cookies.set("session", token, {  // write
  httpOnly: true,
  secure: true,
  sameSite: "strict",
  maxAge: 86400,
  path: "/",
})

CookieOptions

domain, expires, httpOnly, maxAge, path, sameSite ("strict" | "lax" | "none"), secure.

bodyLimit(options?)

Rejects requests exceeding a body size or with disallowed content types.

Option Type Default
maxSize number 1 MB
contentTypes string[] any

cache(options)

Sets Cache-Control (and optionally Vary) headers on responses.

cache({ public: true, maxAge: 3600 })
cache("no-store")

CacheOptions

public, private, maxAge, sMaxAge, noCache, noStore, mustRevalidate, proxyRevalidate, immutable, staleWhileRevalidate, staleIfError, vary.

csp(options)

Sets Content-Security-Policy headers. Supports automatic nonce generation via the CSP.nonce symbol -- the nonce is bound to context as cspNonce.

import { CSP } from "@sigitex/route"

csp({
  defaultSrc: [CSP.self],
  scriptSrc: [CSP.self, CSP.nonce],
  styleSrc: [CSP.self, CSP.unsafeInline],
  imgSrc: [CSP.self, CSP.data],
  reportOnly: true,
})

csrf(options?)

Double-submit cookie CSRF protection. Requires cookies() in the middleware stack.

Option Type Default
cookie string "csrf-token"
header string "X-CSRF-Token"
methods string[] POST, PUT, PATCH, DELETE

rateLimit(options?)

IP-based rate limiting with pluggable storage.

Option Type Default
window number (seconds) 60
max number 100
key (ctx) => string rateLimit.ip
store RateLimitStore rateLimit.memory()
headers boolean true

Built-in helpers:

hardened(options?)

Convenience bundle applying noSniff, frameGuard, referrerPolicy, and hsts. Any can be disabled:

hardened()                              // all defaults
hardened({ hsts: false })               // skip HSTS
hardened({ frameGuard: "sameOrigin" })  // override frame guard

hsts(options?)

Sets Strict-Transport-Security. Defaults: max-age=31536000; includeSubDomains.

Option Type Default
maxAge number 31536000
includeSubDomains boolean true
preload boolean false

noSniff

Sets X-Content-Type-Options: nosniff.

frameGuard.deny / frameGuard.sameOrigin

Sets X-Frame-Options.

referrerPolicy.*

Pre-built policies: noReferrer, noReferrerWhenDowngrade, origin, originWhenCrossOrigin, sameOrigin, strictOrigin, strictOriginWhenCrossOrigin, unsafeUrl.

requestId(options?)

Reads or generates a request ID, binds it to context as requestId, and echoes it on the response.

Option Type Default
header string "X-Request-Id"
generate () => string crypto.randomUUID

setHeader(header, value)

Sets a header on every response. Returns a ResponseHandler (use as an after hook).

{ after: setHeader("X-Powered-By", "sigitex") }

Errors

Error classes that can be thrown from handlers:

Class Status Default Message
RouterError any --
NotFound 404 "Not found."
MethodNotAllowed 405 "Method not allowed."
InvalidRequest 400 "Invalid request."
ServerError -- "Internal error."

Internals

Request Lifecycle

When route() is called it creates a Router and returns its route method. On each request:

  1. A single request context is created with request, env, url, bind, and dispatch.
  2. If a Container was provided, it is cloned once and that clone is used for the whole request.
  3. Global middlewares wrap the candidate loop once. Handlers are tried in order; the first non-undefined result wins.
  4. If no handler matches, a JSON 404 is returned. Uncaught errors produce a JSON 500.

bind(bindings)

Merges key-value pairs into the request context. Bindings stick for the rest of the request, including across nested dispatch() calls. With a container, values are registered on the per-request clone; without one, they're Object.assigned onto the context object. This is how middleware like cookies(), requestId(), and a session user expose state to downstream handlers.

Match-local keys are an exception at the call site: prefix() save/restores url, and pattern() save/restores params, so fallthrough does not leak a stripped path or stale params to siblings.

dispatch(handler, middlewares)

Runs a single handler through a middleware chain on the same request context (no binding scope stack):

  1. Each middleware's before hook runs in order. If any returns a value, it short-circuits as the response.
  2. The handler is invoked. If it returns undefined, dispatch returns undefined (no match) and after-hooks do not run.
  3. The response is bound via bind({ response }).
  4. Each entered middleware's after hook runs in reverse registration order. A returned value replaces the response.
  5. The final response is returned.

Both bind and dispatch are exposed on the context so use / prefix / pattern can nest middleware stacks without losing request bindings.

Container Integration

When RouterOptions.container is provided (a @sigitex/bind Container), the router uses dependency injection instead of plain context objects:

Without a container, handlers receive the context object directly as their first argument.

Response Coercion

Return values from handlers are coerced: Response instances pass through as-is; anything else is JSON.stringifyed into a Response. Returning undefined signals "no match" and the next handler is tried.