Skip to content

Commit e59e65d

Browse files
committed
Silence the edge-runtime complaint by giving it nothing to complain about
The dev server flagged instrumentation.ts on every compile: Next builds the instrumentation hook for its edge runtime as well as Node, and the edge compiler statically flags any node: import it can see, including one sitting inside the NEXT_RUNTIME guard's untaken branch. Moving the imports inside the function was not enough; the file itself has to be import-free. It now is. The hook guards and dynamically imports instrumentation-node.ts, which calls runtime(), the same idempotent door every route handler already uses, so startup and first-request initialisation are one code path. That also closed a real hole: the SSE events route called jobManager() directly, bypassing the door, and would have thrown if a stream were the first request after a restart. It goes through runtime() now. Proving the fix caught a false pass on the way. The first retest showed a clean log because the old dev server was still holding the port and answering with the old code; the new one had died on EADDRINUSE and the clean log proved nothing. Verified properly: stale listener killed, fresh server confirmed Ready, both routes served, zero edge warnings. The production build's tracer warnings are down from three to one. The migrations folder joined from import.meta.url up to the repository root was a real cause and carries the documented turbopackIgnore comment now. The survivor is the engine's per-review log streams, whose paths only exist at run time and cannot be static; its consequence is confined to the standalone-output manifest, which this app does not use. Accepted and recorded in DECISIONS.md with a re-check trigger: adopting output "standalone" voids the acceptance.
1 parent 060f969 commit e59e65d

5 files changed

Lines changed: 57 additions & 24 deletions

File tree

docs/DECISIONS.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -614,3 +614,26 @@ verified evidence, in writing, here.
614614
- 2026-07-31 DECIDED (W7): no colour in the demo output. Escape sequences are
615615
control characters, which the house style gate refuses, and the output is
616616
written to a file as evidence where escapes are noise rather than emphasis.
617+
- 2026-07-31 FIXED: the dev server flagged instrumentation.ts on every
618+
compile ("A Node.js module is loaded ('node:os') which is not supported in
619+
the Edge Runtime"). Next compiles the instrumentation hook for its edge
620+
runtime as well as Node, and the edge compiler statically flags any node:
621+
import it can see, including one inside the NEXT_RUNTIME guard's untaken
622+
branch. The hook now imports nothing: it guards, then dynamically imports
623+
instrumentation-node.ts, which simply calls runtime(), the same idempotent
624+
door every route handler uses. The SSE events route previously bypassed
625+
that door by calling jobManager() directly, and would have thrown if it
626+
were the first request after a restart; it goes through runtime() now.
627+
- 2026-07-31 ACCEPTED: one Turbopack build warning remains ("Encountered
628+
unexpected file in NFT list"). Root cause established, not assumed: the
629+
engine's process spawner opens per-review log streams at paths only known
630+
at run time, and Next's file tracer treats a filesystem operation with a
631+
dynamic path as "the whole project might ship", so the standalone output
632+
manifest over-includes. The paths cannot be static, because they live
633+
under the user's data directory per review. The consequence is confined
634+
to the standalone-output file manifest, which this app does not use: it
635+
runs with next start from the repository. A second cause, the migrations
636+
folder joined from import.meta.url up to the repository root, was real
637+
and is fixed with the documented turbopackIgnore comment. Re-check
638+
trigger: if the app ever adopts output "standalone", this acceptance is
639+
void and the warning must be resolved.

src/app/api/reviews/[id]/events/route.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
* SQLite handle and spawns processes, neither of which the edge runtime can do.
66
*/
77

8-
import { jobManager } from "@/server/jobs/manager";
98
import { reviewEventStream } from "@/server/jobs/stream";
9+
import { runtime as appRuntime } from "@/server/runtime";
1010

1111
export const runtime = "nodejs";
1212
export const dynamic = "force-dynamic";
@@ -16,5 +16,7 @@ export async function GET(
1616
context: { params: Promise<{ id: string }> },
1717
): Promise<Response> {
1818
const { id } = await context.params;
19-
return reviewEventStream(jobManager(), id, request);
19+
// Through runtime() like every other handler, so a stream opened as the
20+
// first request after a restart still finds an initialised manager.
21+
return reviewEventStream(appRuntime().manager, id, request);
2022
}

src/instrumentation-node.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/**
2+
* The Node side of startup.
3+
*
4+
* Imported dynamically from `instrumentation.ts` only when the runtime is
5+
* Node, so the edge compilation of the instrumentation hook never sees a
6+
* node: import. The work itself is `runtime()`, the same idempotent door
7+
* every route handler uses: it opens the database once per process, migrates,
8+
* and recovers orphaned reviews, and calling it here simply makes that happen
9+
* at server start instead of at the first request.
10+
*/
11+
12+
import { runtime } from "@/server/runtime";
13+
14+
runtime();

src/instrumentation.ts

Lines changed: 11 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,20 @@
11
/**
22
* What runs once, when the server starts.
33
*
4-
* Next calls `register()` a single time per server process, which is the only
5-
* hook that fits work that must not run per request: opening the database,
6-
* migrating it, and recovering reviews that a previous process left marked as
7-
* running. A review in that state cannot be running, because nothing survived
8-
* the restart that could be running it, and until it is recovered it can
9-
* neither be started nor cancelled.
4+
* Next compiles this file for its edge runtime as well as for Node, and the
5+
* edge compiler statically flags any node: import it can see, including one
6+
* inside an unreached branch. So this file imports nothing at all: the guard
7+
* decides the runtime, and the Node-only work lives in `instrumentation-node`,
8+
* reached through a dynamic import the edge build does not follow.
109
*
11-
* Every import is inside the function and behind the runtime check. Next builds
12-
* this file for its edge runtime as well, where none of it can load, and a
13-
* top-level import of anything touching node:path fails that build.
10+
* The work itself is opening the database, migrating it, and recovering
11+
* reviews a previous process left marked as running. A review in that state
12+
* cannot be running, because nothing survived the restart that could be
13+
* running it, and until it is recovered it can neither be started nor
14+
* cancelled.
1415
*/
1516

1617
export async function register(): Promise<void> {
1718
if (process.env.NEXT_RUNTIME !== "nodejs") return;
18-
19-
const { homedir } = await import("node:os");
20-
const { dbPath, resolveDataDir } = await import("@/lib/paths");
21-
const { createDb } = await import("@/server/db/client");
22-
const { runMigrations } = await import("@/server/db/migrate");
23-
const { jobManager } = await import("@/server/jobs/manager");
24-
25-
const dataDir = resolveDataDir(process.env, homedir());
26-
const db = createDb(dbPath(dataDir));
27-
runMigrations(db);
28-
jobManager().init({ db, dataDir });
19+
await import("./instrumentation-node");
2920
}

src/server/db/migrate.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,12 @@ import type { Db } from "./client";
1313

1414
// Assembled from parts rather than written as one URL literal, because a
1515
// bundler reads `new URL("../../../drizzle", import.meta.url)` as a module it
16-
// should resolve and fails the build. This is a directory read at run time.
16+
// should resolve and fails the build. The ignore comment stops Turbopack's
17+
// file tracer from reading a join that reaches the repository root as "the
18+
// whole repository ships": this is a directory read at run time, never
19+
// bundled.
1720
const MIGRATIONS_FOLDER = join(
18-
dirname(fileURLToPath(import.meta.url)),
21+
/* turbopackIgnore: true */ dirname(fileURLToPath(import.meta.url)),
1922
"..",
2023
"..",
2124
"..",

0 commit comments

Comments
 (0)