Routing System

Layouts and Middleware

Compose shared logic, authentication guards, and cascading typed state with layout files. Understand middleware execution ordering and state propagation.

Layout files in Taser allow you to organize cross-cutting concerns (authentication, CORS, rate limiting, logging) and inject typed state into child routes without repeating code in every handler.


What is a Layout File?

A layout file is any TypeScript file inside src/routes/ that does not end with an HTTP method. It exports a default middleware pipeline initialized with t.layout(layoutId) or layout(layoutId).

src/routes/admin.ts
import { forbidden, unauthorized } from "@taserjs/router/reply";
import { t } from "@taserjs/router";

export default t.layout("/admin").use(async (ctx, next) => {
  const authHeader = ctx.headers.get("authorization");
  if (!authHeader?.startsWith("Bearer ")) {
    return unauthorized({ message: "Admin authorization required" });
  }

  const token = authHeader.slice(7);
  const adminUser = await verifyAdminToken(token);
  if (!adminUser) {
    return forbidden({ message: "Insufficient permissions" });
  }

  // Injects adminUser directly into ctx.state for all downstream routes
  return next({ adminUser });
});

All route files in src/routes/admin/ (or src/routes/admin.*.ts) automatically inherit this middleware and receive ctx.state.adminUser fully typed.


Root Layout (src/routes/$.ts)

The root layout applies to every route in your application. It is the ideal place for global concerns like CORS, security headers, request timing, and tenant resolution:

src/routes/$.ts
import { cors } from "@taserjs/router/cors";
import { secureHeaders } from "@taserjs/router/secure-headers";
import { timing } from "@taserjs/router/timing";
import { t } from "@taserjs/router";

export default t
  .layout("/*")
  .use(
    cors({
      origin: ["https://app.example.com", "https://admin.example.com"],
      credentials: true,
    }),
  )
  .use(secureHeaders())
  .use(timing());

Root Index Layout (src/routes/index.ts)

While src/routes/$.ts applies globally to every route in your application, src/routes/index.ts is scoped specifically to root index endpoints (such as src/routes/index.get.ts). It mounts with t.layout('/index'):

src/routes/index.ts
import { t } from "@taserjs/router";

export default t.layout("/index").use(async (_ctx, next) => {
  // Only runs for the root '/' endpoint
  return next();
});

Nested directory index files (e.g. src/routes/admin/index.ts) similarly mount with t.layout('/admin/index') and execute only for GET /admin (src/routes/admin/index.get.ts).


Nested Layout Hierarchy & Execution Order

Taser executes layouts in a predictable cascading sequence from outermost to innermost:

1. ROOT$.ts
/*
2. SCOPEDapi.ts
/api/*
3. PATHLESS_auth.ts
/api/* (_auth)
4. ROLEadmin.ts
/api/admin/*
5. ROUTEusers.get.ts
/api/admin/users

Execution sequence for GET /api/users:

[1. Root Layout: $.ts]


[2. API Layout: api.ts]


[3. Pathless Layout: _auth.ts]


[4. Role Layout: admin.ts]


[5. Route Handler: users.get.ts]

Injecting Typed State

When middleware calls next({ session }), the properties merge directly into ctx.state. Downstream handlers access these properties with complete TypeScript inference:

src/routes/dashboard.ts
import { unauthorized } from "@taserjs/router/reply";
import { t } from "@taserjs/router";

export default t.layout("/dashboard").use(async (ctx, next) => {
  const session = await getSession(ctx.headers.get("cookie"));
  if (!session) {
    return unauthorized({ message: "Invalid session" });
  }

  return next({ session });
});

Standalone Middlewares with middleware()

To create reusable, type-safe middlewares across layout files or projects, use middleware() or t.middleware():

src/middleware/rate-limit.ts
import { middleware } from "@taserjs/router";
import { badRequest } from "@taserjs/router/reply";

export function rateLimiter(options: { maxRequests: number; windowSeconds: number }) {
  return middleware(async (ctx, next) => {
    const clientIp = ctx.headers.get("x-forwarded-for") ?? "127.0.0.1";
    const remaining = await checkRateLimit(clientIp, options);

    if (remaining <= 0) {
      return badRequest({ message: "Rate limit exceeded" });
    }

    return next({ rateLimitRemaining: remaining });
  });
}

App Context Inheritance in Middlewares

Middlewares created via middleware() or t.middleware() automatically inherit your application's boot and request context (ctx.db, ctx.requestId, etc.) via ambient type registration with zero manual type annotations:

src/middleware/tenant-logger.ts
import { middleware } from "@taserjs/router";

export const tenantLogger = middleware(async (ctx, next) => {
  // ctx.db and ctx.requestId are 100% typed from your taser.context definition!
  ctx.db.logTenantAccess(ctx.requestId);
  return next();
});

Layout-Scoped Middlewares

When creating middleware tailored for a specific route hierarchy, pass the layout identifier as the first argument to middleware().

1. Single Layout Binding

Passing a layout identifier binds the middleware directly to that layout branch:

src/middleware/user-guard.ts
import { middleware } from "@taserjs/router";
import { forbidden } from "@taserjs/router/reply";

export const requireActiveUser = middleware("/users", async (ctx, next) => {
  // 1. ctx.state automatically inherits all state provided by the "/users" layout chain:
  const user = ctx.state.user;

  if (!user.isActive) {
    return forbidden({ message: "User account suspended" });
  }

  // 2. Injects additional state downstream:
  return next({ userTier: user.tier });
});

Compile-Time Branch Safety

TypeScript guarantees that requireActiveUser can only be attached to routes or layouts under the "/users" branch:

src/routes/users/settings.get.ts
import { json } from "@taserjs/router/reply";
import { t } from "@taserjs/router";
import { requireActiveUser } from "../../middleware/user-guard";

// Allowed: Route inherits "/users" layout
export default t
  .get("/users/settings")
  .use(requireActiveUser)
  .handler((ctx) => {
    return json({ tier: ctx.state.userTier });
  });

If you attempt to mount it on an unrelated branch, TypeScript emits an immediate compile error:

src/routes/posts/$id.get.ts
import { ok } from "@taserjs/router/reply";
import { t } from "@taserjs/router";
// ❌ TypeScript Error: Cannot attach middleware scoped to "/users" layout on "/posts" branch
export default t
  .get("/posts/:id")
  .use(requireActiveUser)
  .handler((ctx) => ok());

2. Multi-Layout Binding (Branch Union)

If a middleware applies to multiple distinct layout branches (such as both "/dashboard" and "/admin"), pass an array of layout IDs:

src/middleware/tenant-guard.ts
import { middleware } from "@taserjs/router";
import { forbidden } from "@taserjs/router/reply";

export const requireTenant = middleware(["/dashboard", "/admin"], async (ctx, next) => {
  // ctx.state receives the common state guaranteed across all specified layouts:
  const session = ctx.state.session;

  if (!session.tenantId) {
    return forbidden({ message: "Tenant required" });
  }

  return next({ tenantId: session.tenantId });
});

This middleware can now be safely attached to routes inheriting either the "/dashboard" or "/admin" layout branches with total type safety.


Precondition Requirements (.requires<{ state?, params?, query?, body? }>())

[!TIP] Faceted Requirements Middleware can declare compile-time preconditions across all 4 request facets: params, query, body, and state.

middleware().requires<{
  params?: { id: string };
  query?: { filter: string };
  body?: { token: string };
  state?: { user: User };
}>();

For middlewares that require certain properties to exist in ctx.state, ctx.params, ctx.query, or ctx.body before running, use .requires<{ ... }>():

src/middleware/admin-guard.ts
import { middleware } from "@taserjs/router";
import { forbidden } from "@taserjs/router/reply";

type User = {
  id: string;
  role: "admin" | "superadmin" | "user";
};

export const requireAdmin = middleware()
  .requires<{ state: { user: User } }>()
  .handler(async (ctx, next) => {
    // ctx.state.user is guaranteed by TypeScript to exist:
    if (ctx.state.user.role !== "admin" && ctx.state.user.role !== "superadmin") {
      return forbidden({ message: "Administrator privileges required" });
    }

    return next({ isAdmin: true });
  });

Param-Guarded & Query-Guarded Middlewares

Middlewares can also enforce path parameters (validated against the route URL string e.g. /users/:userId) or query schemas provided by upstream layouts:

src/middleware/load-user.ts
export const loadUser = middleware()
  .requires<{ params: { userId: string } }>()
  .handler(async (ctx, next) => {
    const user = await db.users.findById(ctx.params.userId);
    return next({ user });
  });

// Allowed: Route has ":userId" in its path
t.get("/users/:userId/profile").use(loadUser)...

// ❌ TypeScript Error: Route "/profile" has no :userId path parameter
t.get("/profile").use(loadUser)...

Compile-Time Precondition Validation

When .use(...) is added to a route or layout, TypeScript inspects the preceding middleware chain and route path. If the required facets are not satisfied, TypeScript produces a compile error:

import { json } from "@taserjs/router/reply";

// ❌ TypeScript Error: Preconditions not satisfied
t.get("/unprotected").use(requireAdmin);

// Allowed: Upstream auth middleware provides { user: ... }
t.get("/protected")
  .use(authMiddleware)
  .use(requireAdmin)
  .handler((ctx) => json({ ok: true }));

Phased Route Builder Lifecycle

Route definitions in Taser follow a strict phased lifecycle:

  1. Middleware Phase (.use(...)) — Chained at the beginning of the route.
  2. Contract / Schema Phase (.query(), .params(), .body(), .returns()) — Once schemas or return maps are declared, .use() is locked out to maintain deterministic execution order.
  3. Execution Phase (.handler(...)) — The terminal route handler.
export default t
  .get("/users/:id")
  // 1. Middlewares at the top:
  .use(cors())
  .use(requireAdmin)
  // 2. Contracts and schemas:
  .query(z.object({ details: z.boolean().default(false) }))
  .returns({ 200: UserResponseSchema })
  // 3. Handler:
  .handler((ctx) => {
    return json({ id: ctx.params.id, isAdmin: ctx.state.isAdmin });
  });