Skip to content

[Next.js/dylib] Add a pinned production App Route parity fixture and CI gate #8034

Description

@proggeramlug

Summary

Add one pinned, production-built Next.js App Route fixture that is the release gate for Perry's shared-library embedding path. The fixture is included below in full so the implementer does not need to design an app or decide what to assert.

This closes the gap between "Perry can compile selected Next userland code" and "Perry can execute the production Next App Route pipeline as an app-only dylib with shared runtime/stdlib providers."

Baseline

  • Next.js 16.3.0, built with next build --webpack
  • React / React DOM 19.2.4
  • Perry 0.5.1503, commit 564c56308d221a51b50308d9165578fbb176e877
  • App library built with --output-type dylib; runtime and stdlib are separate provider images
  • No source rewrite that calls the userland GET directly, fabricates the response, or bypasses AppRouteRouteModule.handle

Related groundwork already landed in #5438, #6738, and #7252. The broad Node async-context backlog is #6764.

Attached fixture generator

Save this block as make-next-app-route-fixture.sh and run it. It creates the complete application and its 21-request concurrency verifier; no create-next-app prompts or app design are required.

make-next-app-route-fixture.sh
#!/usr/bin/env bash
set -euo pipefail

app_dir="${1:-perry-next-app-route-fixture}"
mkdir -p "$app_dir/app/api/benchmark" "$app_dir/lib"

cat >"$app_dir/package.json" <<'JSON'
{
  "name": "perry-next-app-route-fixture",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "build": "next build --webpack",
    "start": "next start",
    "verify": "node verify.mjs"
  },
  "dependencies": {
    "next": "16.3.0",
    "react": "19.2.4",
    "react-dom": "19.2.4"
  },
  "devDependencies": {
    "@types/node": "25.3.3",
    "@types/react": "19.2.14",
    "@types/react-dom": "19.2.3",
    "typescript": "5.9.3"
  }
}
JSON

cat >"$app_dir/next.config.ts" <<'TS'
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  output: "standalone",
};

export default nextConfig;
TS

cat >"$app_dir/tsconfig.json" <<'JSON'
{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["dom", "dom.iterable", "es2022"],
    "strict": true,
    "noEmit": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "jsx": "preserve",
    "plugins": [{ "name": "next" }]
  },
  "include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"],
  "exclude": ["node_modules"]
}
JSON

cat >"$app_dir/next-env.d.ts" <<'TS'
/// <reference types="next" />
/// <reference types="next/image-types/global" />
TS

cat >"$app_dir/app/layout.tsx" <<'TSX'
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
  return <html><body>{children}</body></html>;
}
TSX

cat >"$app_dir/app/page.tsx" <<'TSX'
export default function Page() {
  return <main>Perry Next.js App Route fixture</main>;
}
TSX

cat >"$app_dir/lib/lazy-work.ts" <<'TS'
export function checksum(iterations: number): number {
  let value = 0x811c9dc5;
  for (let index = 0; index < iterations; index += 1) {
    value = Math.imul(value ^ index, 0x01000193) >>> 0;
  }
  return value;
}
TS

cat >"$app_dir/lib/route-impl.ts" <<'TS'
import { headers } from "next/headers";
import { NextRequest, NextResponse } from "next/server";

async function handle(request: NextRequest): Promise<NextResponse> {
  const id = request.nextUrl.searchParams.get("id") ?? "missing";
  const requestedIterations = Number(request.nextUrl.searchParams.get("iterations") ?? "100");
  const iterations = Number.isInteger(requestedIterations)
    ? Math.max(1, Math.min(1_000, requestedIterations))
    : 100;

  const beforeAwait = (await headers()).get("x-request-id");
  const { checksum } = await import("./lazy-work");
  await new Promise<void>((resolve) => setTimeout(resolve, 1));
  const afterAwait = (await headers()).get("x-request-id");
  const requestBody = request.method === "POST" ? await request.text() : "";

  const payload = JSON.stringify({
    runtime: "next",
    method: request.method,
    pathname: request.nextUrl.pathname,
    id,
    iterations,
    checksum: checksum(iterations),
    beforeAwait,
    afterAwait,
    requestBody,
  });
  const bytes = new TextEncoder().encode(payload);
  const split = Math.max(1, Math.floor(bytes.length / 2));
  const stream = new ReadableStream<Uint8Array>({
    start(controller) {
      controller.enqueue(bytes.subarray(0, split));
      queueMicrotask(() => {
        controller.enqueue(bytes.subarray(split));
        controller.close();
      });
    },
  });

  const response = new NextResponse(stream, {
    status: 207,
    headers: {
      "content-type": "application/json; charset=utf-8",
      "x-perry-repro": id,
    },
  });
  response.cookies.set("perry_ctx", id, { httpOnly: true, sameSite: "strict" });
  return response;
}

export const GET = handle;
export const POST = handle;
TS

cat >"$app_dir/app/api/benchmark/route.ts" <<'TS'
export const dynamic = "force-dynamic";
export { GET, POST } from "../../../lib/route-impl";
TS

cat >"$app_dir/verify.mjs" <<'JS'
const base = process.env.BASE_URL ?? "http://127.0.0.1:3100";

function checksum(iterations) {
  let value = 0x811c9dc5;
  for (let index = 0; index < iterations; index += 1) {
    value = Math.imul(value ^ index, 0x01000193) >>> 0;
  }
  return value;
}

async function verify(id, iterations, method = "GET", requestBody = "") {
  const response = await fetch(
    `${base}/api/benchmark?id=${encodeURIComponent(id)}&iterations=${iterations}`,
    {
      method,
      headers: {
        "x-request-id": id,
        ...(method === "POST" ? { "content-type": "text/plain" } : {}),
      },
      ...(method === "POST" ? { body: requestBody } : {}),
    },
  );
  const body = await response.json();
  const cookie = response.headers.get("set-cookie") ?? "";
  const expected = {
    runtime: "next",
    method,
    pathname: "/api/benchmark",
    id,
    iterations,
    checksum: checksum(iterations),
    beforeAwait: id,
    afterAwait: id,
    requestBody,
  };
  if (response.status !== 207) throw new Error(`${id}: status ${response.status}`);
  if (response.headers.get("x-perry-repro") !== id) throw new Error(`${id}: response header lost`);
  if (!cookie.includes(`perry_ctx=${id}`)) throw new Error(`${id}: response cookie lost`);
  if (JSON.stringify(body) !== JSON.stringify(expected)) {
    throw new Error(`${id}: ${JSON.stringify(body)} != ${JSON.stringify(expected)}`);
  }
}

await Promise.all(
  Array.from({ length: 20 }, (_, index) => verify(`request-${index}`, index + 1)),
);
await verify("post-request", 31, "POST", "perry-request-body");
console.log("PASS: 21 production App Route requests");
JS

printf 'Fixture created at %s\n' "$app_dir"
printf 'Run: cd %q && npm install --package-lock-only && npm ci && npm run build\n' "$app_dir"

Node oracle

bash make-next-app-route-fixture.sh
cd perry-next-app-route-fixture
npm install --package-lock-only
npm ci
npm run build
PORT=3100 npm start
# In another shell:
BASE_URL=http://127.0.0.1:3100 npm run verify

Expected final line:

PASS: 21 production App Route requests

Perry gate

Add this generated app (including its lockfile) to Perry's integration fixtures and run the same verify.mjs against the Perry/shared-library host. The Perry path must:

  1. compile the production webpack output, not lib/route-impl.ts alone;
  2. enter the generated module's real routeModule.handle / AppRouteRouteModule.handle path;
  3. produce an app-only dylib whose undefined Perry ABI symbols resolve from separately built runtime and stdlib providers;
  4. load providers once, then the app with eager relocation; and
  5. contain no Perry-only response fabrication or compatibility fallback.

Acceptance criteria

  • The exact Node oracle and Perry host both pass verify.mjs.
  • The Perry run passes 10 consecutive cold process starts and two consecutive verifier runs per process.
  • All 20 concurrent request IDs remain isolated.
  • GET query/path/method/header state and POST body arrive intact.
  • Status 207, x-perry-repro, Set-Cookie, and the two-chunk streamed JSON body arrive intact.
  • The dynamic import executes on the first cold request and on warm requests without deadlock or lost exports.
  • No [perry-gc] ... SKIPPED, unsettled-await, unimplemented, or compatibility-fallback diagnostics are emitted.
  • CI records the Perry commit, Next version, compile mode, and provider ABI hash on failure.

Non-goals

  • Performance thresholds belong to the follow-up 1/10/100 deployment benchmark.
  • Turbopack is not part of this first gate; the fixture deliberately pins webpack.
  • Calling the route's exported GET directly is not an acceptable substitute for the production App Route pipeline.

Metadata

Metadata

Assignees

No one assigned

    Labels

    parityCompatibility gap with Node.js, ECMAScript, or the supported ecosystemtoolingDeveloper tooling, CI, tests, or release infrastructure

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions