fix(build): make the production build pass, and stop it eating 20GB
Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (push) Successful in 30s
CI / tests-integration (push) Successful in 1m39s
CI / tests-unit (push) Successful in 1m43s
CI / tests-ui (push) Successful in 2m31s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m17s
Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (push) Successful in 30s
CI / tests-integration (push) Successful in 1m39s
CI / tests-unit (push) Successful in 1m43s
CI / tests-ui (push) Successful in 2m31s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m17s
`next build` had never completed on this host, so three real defects were
sitting in the tree untested. All three are now fixed and the build is green.
- The build was not memory-bound the way it looked. Turbopack's builder reached
20.5GB RSS and died, and raising `--max-old-space-size` could never have
helped: that flag caps the V8 heap, while the 20GB sat in Turbopack's own Rust
allocator. The first symptom was misleading because the process doing the
allocating is a grandchild of `npx`, so watching the direct child shows a
95MB shim the whole time. Building with `--webpack` puts the build back under
the JS heap, where the flag actually applies: peak 5.9GB, 150s, exit 0.
- withAdmin's second parameter was typed `{ params?: ... }` and given a `= {}`
default, which made it optional and `RouteContext | undefined`. Next's
generated route types assert that argument against `ParamCheck<RouteContext>`
and reject it, across 113 route files. `tsc --noEmit` cannot see this, because
Next only adds `.next/types` to the project during a production build — so the
type check that everyone runs locally was structurally incapable of catching
the only type error that blocks a deploy. `params` is now required, which is
also what the code already assumed: it is awaited with no guard. The 35 test
call sites that invoked a handler with one argument now pass a real context,
and the await got a guard so a direct internal call cannot turn a missing
context into a 500.
- `src/app/api/admin/import/furni/route.ts` re-exported `ensureDirectories` and
`importSingleFurni` for "backward compatibility" that nothing used; the batch
route imports from `@/lib/services/furni-import` directly. Next rejects any
value export from a route module that is not an HTTP verb or config, so this
had been breaking the build for as long as it existed. Removed.
- `isomorphic-dompurify` builds its server-side DOM through jsdom. Bundled, that
pulls jsdom's `browser/default-stylesheet.css` into the server chunk, where the
path no longer resolves, and page-data collection dies with ENOENT on every
page that sanitizes HTML. Marked external so Node resolves it from
node_modules and the standalone tracer includes it.
The remaining build warning is a pre-existing circular dependency between
chunks that share the webpack runtime. It costs hash reuse, not correctness, and
is left alone rather than churned here.
Verified: build exit 0, 276 static pages generated, 3223 tests pass, tsc and
biome clean.
This commit is contained in:
1 parent
155bf750c3
commit
420210ffa0
13 files changed
+135
-37
No files matched your search
@@ -32,6 +32,7 @@ vi.mock("@/lib/services/catalog-git-queue", () => ({
|
||||
beginCatalogExport: vi.fn(),
|
||||
}));
|
||||
|
||||
import { emptyRouteContext } from "@/test/route-context";
|
||||
import { withAdmin } from "./api-handler";
|
||||
|
||||
const request = () =>
|
||||
@@ -50,7 +51,10 @@ describe("admin API correlation", () => {
|
||||
await Promise.resolve();
|
||||
return NextResponse.json(getOperationContext());
|
||||
});
|
||||
const [a, b] = await Promise.all([handler(request()), handler(request())]);
|
||||
const [a, b] = await Promise.all([
|
||||
handler(request(), emptyRouteContext()),
|
||||
handler(request(), emptyRouteContext()),
|
||||
]);
|
||||
expect(a.headers.get("x-operation-id")).not.toBe("forged-client-id");
|
||||
expect(a.headers.get("x-operation-id")).not.toBe(
|
||||
b.headers.get("x-operation-id"),
|
||||
@@ -71,7 +75,7 @@ describe("admin API correlation", () => {
|
||||
response.cookies.set("second", "two", { httpOnly: true });
|
||||
return response;
|
||||
});
|
||||
const response = await handler(request());
|
||||
const response = await handler(request(), emptyRouteContext());
|
||||
expect(response.status).toBe(307);
|
||||
expect(response.headers.get("location")).toBe(
|
||||
"https://hotel.test/admin/users",
|
||||
@@ -104,7 +108,7 @@ describe("admin API correlation", () => {
|
||||
},
|
||||
),
|
||||
);
|
||||
const response = await handler(request());
|
||||
const response = await handler(request(), emptyRouteContext());
|
||||
expect(response.headers.get("content-type")).toBe("text/event-stream");
|
||||
expect(response.headers.get("x-custom")).toBe("retained");
|
||||
const body = response.text();
|
||||
@@ -121,17 +125,17 @@ describe("admin API correlation", () => {
|
||||
throw new Error("private-database-message");
|
||||
});
|
||||
state.authorized = false;
|
||||
const unauthorized = await handler(request());
|
||||
const unauthorized = await handler(request(), emptyRouteContext());
|
||||
expect(unauthorized.status).toBe(401);
|
||||
expect(state.record).not.toHaveBeenCalled();
|
||||
expect(unauthorized.headers.get("x-operation-id")).toBeTruthy();
|
||||
state.authorized = true;
|
||||
state.permitted = false;
|
||||
const forbidden = await handler(request());
|
||||
const forbidden = await handler(request(), emptyRouteContext());
|
||||
expect(forbidden.status).toBe(403);
|
||||
expect(forbidden.headers.get("x-operation-id")).toBeTruthy();
|
||||
state.permitted = true;
|
||||
const failed = await handler(request());
|
||||
const failed = await handler(request(), emptyRouteContext());
|
||||
expect(failed.status).toBe(500);
|
||||
expect(failed.headers.get("x-operation-id")).toBeTruthy();
|
||||
expect(await failed.json()).toEqual({
|
||||
|
||||
+20
-3
@@ -25,7 +25,11 @@ const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
||||
const MAX_BODY_BYTES = 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
type AdminContext = NonNullable<Awaited<ReturnType<typeof getApiAdminContext>>>;
|
||||
type RouteContext = { params?: Promise<Record<string, string | string[]>> };
|
||||
// Next 15+ passes `params` as a promise and its generated route types require it
|
||||
// to be present, so this is deliberately not optional. `routeContext.params` is
|
||||
// awaited below without a guard, so an optional type would have been a lie
|
||||
// anyway. A route with no dynamic segments still receives `Promise<{}>`.
|
||||
type RouteContext = { params: Promise<Record<string, string | string[]>> };
|
||||
type AdminHandler = (
|
||||
request: NextRequest,
|
||||
context: AdminContext,
|
||||
@@ -40,7 +44,16 @@ export function withAdmin(
|
||||
},
|
||||
handler: AdminHandler,
|
||||
) {
|
||||
return async (request: NextRequest, routeContext: RouteContext = {}) => {
|
||||
// No default value here on purpose. `RouteContext` already has an optional
|
||||
// `params`, so `{}` was always assignable and the default only widened the
|
||||
// parameter to `RouteContext | undefined`. Next's generated route types assert
|
||||
// the second argument against `ParamCheck<RouteContext>`, and the `undefined`
|
||||
// fails that check. `tsc --noEmit` does not see it, because Next only adds
|
||||
// `.next/types` to the project during a production build.
|
||||
// Deliberately no default. A parameter with a default is optional in the
|
||||
// signature, which makes its type `RouteContext | undefined` and fails Next's
|
||||
// `ParamCheck`. Tests that call a handler directly pass `emptyRouteContext()`.
|
||||
return async (request: NextRequest, routeContext: RouteContext) => {
|
||||
const store = createStore("unknown" as IpAddress);
|
||||
const { value: response, metrics } = await collectPerformance(() =>
|
||||
runWithStore(store, async () => {
|
||||
@@ -153,7 +166,11 @@ export function withAdmin(
|
||||
operationId: store.requestId,
|
||||
route: routeTemplate(
|
||||
request.nextUrl.pathname,
|
||||
await routeContext.params,
|
||||
// Next always passes a context, and the type requires it, so
|
||||
// this is only a guard against a direct internal call. The
|
||||
// worst case is a slightly less specific metric label, which
|
||||
// is not worth a 500.
|
||||
await routeContext?.params,
|
||||
),
|
||||
method: request.method,
|
||||
status: response.status,
|
||||
|
||||
Reference in new issue
Block a user