#!/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"
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
16.3.0, built withnext build --webpack19.2.40.5.1503, commit564c56308d221a51b50308d9165578fbb176e877--output-type dylib; runtime and stdlib are separate provider imagesGETdirectly, fabricates the response, or bypassesAppRouteRouteModule.handleRelated 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.shand run it. It creates the complete application and its 21-request concurrency verifier; nocreate-next-appprompts or app design are required.make-next-app-route-fixture.shNode oracle
Expected final line:
Perry gate
Add this generated app (including its lockfile) to Perry's integration fixtures and run the same
verify.mjsagainst the Perry/shared-library host. The Perry path must:lib/route-impl.tsalone;routeModule.handle/AppRouteRouteModule.handlepath;Acceptance criteria
verify.mjs.207,x-perry-repro,Set-Cookie, and the two-chunk streamed JSON body arrive intact.[perry-gc] ... SKIPPED, unsettled-await, unimplemented, or compatibility-fallback diagnostics are emitted.Non-goals
GETdirectly is not an acceptable substitute for the production App Route pipeline.