108 lines
No EOL
3.2 KiB
TypeScript
108 lines
No EOL
3.2 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
|
|
/**
|
|
* Guards the App Router export contract for API route handlers.
|
|
*
|
|
* WHY THIS EXISTS
|
|
*
|
|
* Next.js type-checks every export from a `route.ts` against the Route
|
|
* handler contract at BUILD time. An extra named export does not fail
|
|
* `tsc --noEmit`, does not fail eslint, and does not fail any test — it
|
|
* only fails `next build`, i.e. in CI/production. That is how
|
|
* `collection-map/items/[id]/clear-problem/route.ts` shipped an
|
|
* `export const CLEARED_FIELDS` and took a deployment down with:
|
|
*
|
|
* Type error: Route "..." does not match the required types of a
|
|
* Next.js Route. "CLEARED_FIELDS" is not a valid Route export field.
|
|
*
|
|
* The failing check ran ~5 minutes into a Docker build on the deploy
|
|
* server, long after every local signal was green. This test runs in
|
|
* seconds and turns that into a local failure.
|
|
*/
|
|
|
|
/**
|
|
* Names Next.js permits from a route module. Derived from the Route
|
|
* contract: HTTP verb handlers plus segment config and the metadata /
|
|
* viewport family. Anything else is a build error.
|
|
*/
|
|
const ALLOWED_ROUTE_EXPORTS = new Set([
|
|
// HTTP verbs
|
|
"GET",
|
|
"POST",
|
|
"PUT",
|
|
"PATCH",
|
|
"DELETE",
|
|
"HEAD",
|
|
"OPTIONS",
|
|
// Segment config
|
|
"runtime",
|
|
"dynamic",
|
|
"dynamicParams",
|
|
"revalidate",
|
|
"fetchCache",
|
|
"preferredRegion",
|
|
"maxDuration",
|
|
"dynamicIO",
|
|
// Metadata / viewport
|
|
"metadata",
|
|
"generateMetadata",
|
|
"viewport",
|
|
"generateViewport",
|
|
// Misc
|
|
"generateStaticParams",
|
|
"config",
|
|
"default",
|
|
]);
|
|
|
|
const API_ROOT = join(process.cwd(), "src", "app", "api");
|
|
|
|
function walk(dir: string): string[] {
|
|
const out: string[] = [];
|
|
for (const entry of readdirSync(dir)) {
|
|
const full = join(dir, entry);
|
|
if (statSync(full).isDirectory()) out.push(...walk(full));
|
|
else if (entry === "route.ts") out.push(full);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Exported binding names in a module, ignoring anything inside comments. */
|
|
function exportedNames(source: string): string[] {
|
|
const stripped = source
|
|
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
.replace(/^\s*\/\/.*$/gm, "");
|
|
return [
|
|
...stripped.matchAll(
|
|
/^\s*export\s+(?:async\s+)?(?:const|function|let|var|class)\s+([A-Za-z0-9_]+)/gm,
|
|
),
|
|
].map((m) => m[1]);
|
|
}
|
|
|
|
describe("App Router export contract", () => {
|
|
const routes = walk(API_ROOT);
|
|
|
|
it("finds the API routes (guard against a broken path silently passing)", () => {
|
|
expect(routes.length).toBeGreaterThan(50);
|
|
});
|
|
|
|
it("no route.ts exports a name Next.js rejects at build time", () => {
|
|
const violations: string[] = [];
|
|
for (const file of routes) {
|
|
for (const name of exportedNames(readFileSync(file, "utf8"))) {
|
|
if (!ALLOWED_ROUTE_EXPORTS.has(name)) {
|
|
violations.push(
|
|
`${file.replace(process.cwd() + "/", "")} exports "${name}"`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
expect(
|
|
violations,
|
|
"These exports break `next build` with \"is not a valid Route export " +
|
|
"field\". Move the shared value into a non-route module and import " +
|
|
"it, or assert it through the handler's response instead.",
|
|
).toEqual([]);
|
|
});
|
|
}); |