chore: update route.ts
This commit is contained in:
parent
d35c25a9e7
commit
248c390184
2 changed files with 129 additions and 9 deletions
108
src/app/api/route-export-contract.test.ts
Normal file
108
src/app/api/route-export-contract.test.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -34,7 +34,26 @@ type BodyInput = {
|
|||
source?: "customer" | "stock";
|
||||
};
|
||||
|
||||
const PROBLEM_FIELDS = ["problem_flag", "problem_type", "problem_notes"] as const;
|
||||
/**
|
||||
* The only fields this route is allowed to write.
|
||||
*
|
||||
* Kept as a local (NOT exported) constant: Next.js type-checks every
|
||||
* export from a `route.ts` against the Route handler contract, and any
|
||||
* extra name — `CLEARED_FIELDS` did this — fails `next build` with
|
||||
* "is not a valid Route export field". A previously-declared
|
||||
* `PROBLEM_FIELDS`/`CLEARED_FIELDS` pair existed only to be imported by
|
||||
* a unit test that was never written, so nothing consumed them and they
|
||||
* broke the production build for no benefit.
|
||||
*
|
||||
* If a test later needs this list, assert it through the PATCH
|
||||
* response's `applied` field (already returned) instead of importing
|
||||
* from the route module.
|
||||
*/
|
||||
const CLEARED_PROBLEM = {
|
||||
problem_flag: false,
|
||||
problem_type: "",
|
||||
problem_notes: "",
|
||||
};
|
||||
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
|
|
@ -64,11 +83,7 @@ export async function PATCH(
|
|||
return NextResponse.json({ ok: true, item: current, applied: null });
|
||||
}
|
||||
|
||||
const patch: Record<string, unknown> = {
|
||||
problem_flag: false,
|
||||
problem_type: "",
|
||||
problem_notes: "",
|
||||
};
|
||||
const patch: Record<string, unknown> = { ...CLEARED_PROBLEM };
|
||||
const updated = await pb.collection(collectionName).update(id, patch);
|
||||
|
||||
return NextResponse.json({ ok: true, item: updated, applied: patch });
|
||||
|
|
@ -79,6 +94,3 @@ export async function PATCH(
|
|||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
|
||||
/** Fields this route is allowed to write. Exported for the unit test. */
|
||||
export const CLEARED_FIELDS = PROBLEM_FIELDS;
|
||||
Loading…
Reference in a new issue