Repository navigation
[Next.js/dylib] Add a pinned production App Route parity fixture and CI gate #8034
Description
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 withnext build --webpack - React / React DOM
19.2.4 - Perry
0.5.1503, commit564c56308d221a51b50308d9165578fbb176e877 - App library built with
--output-type dylib; runtime and stdlib are separate provider images - No source rewrite that calls the userland
GETdirectly, fabricates the response, or bypassesAppRouteRouteModule.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 verifyExpected 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:
- compile the production webpack output, not
lib/route-impl.tsalone; - enter the generated module's real
routeModule.handle/AppRouteRouteModule.handlepath; - produce an app-only dylib whose undefined Perry ABI symbols resolve from separately built runtime and stdlib providers;
- load providers once, then the app with eager relocation; and
- 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
GETdirectly is not an acceptable substitute for the production App Route pipeline.
Activity
proggeramlug commentedon Aug 14, 2026
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_funcemitted by exactly webpack chunk2.js,jsonwebtoken,app-page.runtime.prod.js, andapp-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.test(panic-profile): audit every workspace in the repo, not just the …
test(next): add pinned production App Route fixture (#8034)
ci(next): add the app-only-dylib App Route gate (#8034)
ci(next): add the app-only-dylib App Route gate (#8034)
test(panic-profile): audit every workspace in the repo, not just the …
test(next): add pinned production App Route fixture (#8034)
ci(next): add the app-only-dylib App Route gate (#8034)
test(next): add pinned production App Route fixture (#8034)
ci(next): add the app-only-dylib App Route gate (#8034)
ci(next): add the app-only-dylib App Route gate (#8034)
test(next): land the pinned production App Route fixture and its dyli…