Skip to content

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

Closed

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.

Activity

  1. added
    parityCompatibility gap with Node.js, ECMAScript, or the supported ecosystem
    toolingDeveloper tooling, CI, tests, or release infrastructure
    on Aug 13, 2026
  2. proggeramlug commented on Aug 14, 2026

    @proggeramlug
    ContributorAuthor

    Latest exact-main production replay boundary (b0f13fb5, merged #8058): the pinned Next.js 16.3.0 generated route graph remained byte-identical to the Node oracle, which passed all 21 requests. Perry then emitted all 91/91 native module objects; all four former #8057 split-unit failures fully cleared. The final app-only dylib link now fails on one duplicate ___perry_wrap_perry_unknown_func emitted by exactly webpack chunk 2.js, jsonwebtoken, app-page.runtime.prod.js, and app-route.runtime.prod.js. Dedicated blocker: #8064. Durable full log: /Users/amlug/cargo-targets/next-prod-gate/evidence/perry-route-compile-b0f13fb5.log. The concurrent ext-http/ext-net archive warnings are a likely later coherent-provider boundary, but they did not cause this duplicate-symbol link failure. No route behavior or GC acceptance is claimed yet.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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