Getting Started

Migration Guide

Incrementally migrate existing Express or Fastify APIs to Taser.js with zero downtime using the host pass-through architecture.

Taser.js is engineered for incremental, zero-downtime adoption. Using the Host Pass-Through Architecture, you do not need to rewrite your application all at once. Your existing Express or Fastify server continues running legacy controllers and middlewares, while all newly migrated routes benefit from Taser's filesystem routing, type inference, and response contracts.


Migration Architecture

When embedded inside an existing Express or Fastify project, Taser.js sits in front of your legacy server:

[Incoming HTTP Request]


┌──────────────────────────────────────┐
│  Taser.js File Routes (src/routes/)  │
└──────────────────────────────────────┘

      Route Match?
     ├── YES ──► [Execute Taser.js Handler & Middleware]

     └── NO  ──► ┌──────────────────────────────────────┐
                 │ Host Application (src/server.node.ts)│
                 │ Express / Fastify Routes & Plugins   │
                 └──────────────────────────────────────┘

                          Route Match?
                         ├── YES ──► [Execute Legacy Controller]
                         └── NO  ──► [Return 404 Not Found]
  1. Taser.js Evaluates First: Requests matching files in src/routes/ are processed with full type safety.
  2. Host Server Fallback: Any unmatched route immediately falls through to your existing Express or Fastify application.
  3. 404 Handling: If neither Taser.js nor your host application handles the URL, a standard 404 response is returned.

Concept Translation Reference

Use this literal lookup table to translate familiar Express and Fastify paradigms into their Taser.js equivalents:

Architectural ConceptExpressFastifyTaser.js Equivalent
Route Declarationapp.get("/users/:id", fn)fastify.get("/users/:id", fn)File: src/routes/users/$id.get.ts
Builder: t.get("/users/:id")
Path Parametersreq.params.idrequest.params.idctx.params.id
(inferred as string or validated via .params(schema))
Query Parametersreq.query.pagerequest.query.pagectx.query.page
(validated and coerced via .query(schema))
Request Payloadreq.bodyrequest.bodyctx.body
(validated via .body(schema))
JSON Responseres.json(data)reply.send(data)return json(data) from @taserjs/router/reply
HTTP Status Coderes.status(201).json(data)reply.code(201).send(data)return created(data) or return reply.status(201).json(data)
Error Handlingres.status(404).json({ error })reply.code(404).send({ error })return notFound({ error }) or return badRequest(...)
Request Headersreq.headers["authorization"]request.headers["authorization"]ctx.headers.get("authorization")
Cookie Jarreq.cookies["token"]request.cookies["token"]ctx.cookies.get("token")
Middleware Contextreq.user = userrequest.user = userreturn next({ user }) (merges into ctx.state)
Global Middlewareapp.use(cors())fastify.register(cors)Root layout: src/routes/$.ts
Scoped Middlewareapp.use("/admin", authMw)fastify.register(adminRoutes, { prefix: "/admin" })Folder layout: src/routes/admin.ts

Route File Conversion

Express to Taser.js

The following example demonstrates converting an existing Express controller file with route-level parameter validation into a Taser.js file-based route:

legacy/routes/users.ts
import { Router, Request, Response } from "express";
import { z } from "zod";
import { db } from "../database";

const router = Router();

const paramsSchema = z.object({
  id: z.string().uuid(),
});

router.get("/users/:id", async (req: Request, res: Response) => {
  const parsed = paramsSchema.safeParse(req.params);
  if (!parsed.success) {
    return res.status(400).json({ error: parsed.error.issues });
  }

  const user = await db.users.findUnique({ where: { id: parsed.data.id } });
  if (!user) {
    return res.status(404).json({ message: "User not found" });
  }

  return res.json(user);
});

export default router;

Fastify to Taser.js

The following example demonstrates converting an existing Fastify endpoint with JSON body and URL param validation:

legacy/routes/items.ts
import { FastifyPluginAsync } from "fastify";
import { z } from "zod";

const itemRoutes: FastifyPluginAsync = async (fastify) => {
  fastify.post(
    "/items/:id",
    {
      schema: {
        params: {
          type: "object",
          properties: { id: { type: "string" } },
          required: ["id"],
        },
        body: {
          type: "object",
          properties: {
            title: { type: "string" },
            price: { type: "number" },
          },
          required: ["title", "price"],
        },
      },
    },
    async (request, reply) => {
      const { id } = request.params as { id: string };
      const { title, price } = request.body as { title: string; price: number };

      const createdItem = await fastify.db.saveItem({ id, title, price });
      return reply.code(201).send(createdItem);
    },
  );
};

export default itemRoutes;

Mapping Middleware to Layout Files

In Express and Fastify, middlewares are attached imperatively via .use() or router scopes. In Taser.js, middleware pipelines are organized declaratively via layout files that mirror your route hierarchy:

ScopeLegacy PatternTaser.js Layout FileExecution Order
Root (Global)app.use(authMiddleware)src/routes/$.tsRuns before every route in the project
Route Groupapp.use("/admin", adminAuth)src/routes/admin.tsRuns before any route under /admin/*
Dynamic Parameterapp.use("/orgs/:orgId", verifyOrg)src/routes/orgs/$orgId.tsRuns before any route under /orgs/:orgId/*
Pathless GroupCustom sub-router groupingsrc/routes/_authenticated.tsScopes middleware without adding a URL segment

Before / After: Converting Authentication Middleware

In legacy Express, middleware typically mutates the req object (req.user = user), which lacks end-to-end type safety:

legacy/middleware/auth.ts
import { Request, Response, NextFunction } from "express";

// Requires declaration merging to patch Express.Request
declare global {
  namespace Express {
    interface Request {
      user?: { id: string; role: string };
    }
  }
}

export async function requireAuth(req: Request, res: Response, next: NextFunction) {
  const token = req.headers.authorization?.replace("Bearer ", "");
  if (!token) {
    return res.status(401).json({ error: "Unauthorized" });
  }

  const user = await verifyToken(token);
  if (!user) {
    return res.status(401).json({ error: "Invalid token" });
  }

  req.user = user;
  next();
}

Any child route inside src/routes/admin/... automatically receives ctx.state.user with full TypeScript inference and zero manual type assertion.


Configuring Host Pass-Through (src/server.node.ts)

To enable seamless coexistence between Taser.js and your legacy server, configure src/server.node.ts:

src/server.node.ts
import express from "express";
import legacyRoutes from "../legacy/routes/index.js";

const app = express();

// Standard Express middlewares:
app.use(express.json());

// Mount un-migrated legacy Express routes:
app.use("/api/legacy", legacyRoutes);

// Export the Express application instance as the default export
export default app;

Known Limitations & Manual Steps

Certain patterns common in legacy Node.js frameworks cannot be migrated automatically and require intentional restructuring:

Legacy PatternWhy It Cannot Be AutomatedRecommended Migration Strategy
Callback-Style Middleware (req, res, next)Taser.js uses Web Standard Promise-based (ctx, next) => Promise<Response> architecture; Express callbacks calling res.end() bypass the pipeline.Convert to async functions returning next({ ... }), or adapt Hono/Web middleware using honoMw().
Imperative Route Loops (routes.forEach(...))Taser.js discovers routes statically at build time from the filesystem.Create explicit .ts files per route (or use @taserjs/router-cli generate to scaffold empty route files).
Raw Node Stream Piping (stream.pipe(res))Node.js WritableStream (res) is absent in Web Standard environments.Return a Web Standard Response using stream(), file(), or buffer() helpers from @taserjs/router/reply/stream.
Arbitrary Mutating Property Assignment (req.foo = bar)Mutating global request objects breaks compile-time type safety.Return state updates via return next({ foo: bar }) in layout middleware, exposing them on ctx.state.
WebSocket & HTTP Upgrade HandlersTaser.js file routes handle standard HTTP request/response lifecycles.Keep WebSocket upgrade hooks (server.on("upgrade", ...)) inside src/server.node.ts or deployment presets.
Direct Socket Access (req.socket)Web Standard Request abstracts away raw TCP sockets for multi-runtime edge compatibility.Extract IP address, TLS info, or socket metadata inside src/context.ts or custom middleware adapters.

Next Steps