refactor: extract instrumentation lazy init to server/instrumentation-runtime.ts#1015
Conversation
commit: |
…-runtime.ts The 25-line __ensureInstrumentation block in entries/app-rsc-entry.ts was module-level state plus idempotent init logic embedded in a generated entry template. This is the canonical 'imperative shell with mutable bookkeeping' pattern — it never belongs in codegen. Lift it to a new server/instrumentation-runtime.ts module exporting ensureInstrumentationRegistered(instrumentationModule). The generated RSC entry now only: 1. Imports the helper (conditional on instrumentationPath). 2. Calls await ensureInstrumentationRegistered(_instrumentation) in the request handler. All bookkeeping (initialized flag, shared promise, prerender skip) lives in the typed helper, making it unit-testable and keeping the entry template thin. New tests cover idempotency, concurrent racing, prerender skipping, and graceful handling of modules without register/onRequestError. Refs: - Next.js instrumentation docs: https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation - Next.js source: https://github.com/vercel/next.js/blob/canary/packages/next/src/server/dev/next-dev-server.ts (instrumentation boot) - Next.js source: https://github.com/vercel/next.js/blob/canary/packages/next/src/build/webpack/plugins/nextjs-require-cache-hot-relayer.ts (onRequestError global wiring)
f0df1d6 to
40384c9
Compare
|
/bigbonk review |
There was a problem hiding this comment.
Good refactor — the extraction is clean and the tests cover the right cases. The generated entry is meaningfully thinner now and the behavioral semantics are preserved exactly. Two issues worth addressing before merge:
-
Bug:
register()rejection poisons the shared promise permanently. Ifregister()throws, the rejected promise is cached ininitPromiseandinitializednever becomestrue. Every subsequent call returns the same rejected promise — the handler is permanently broken with no recovery path. The original inline code had the same bug, but now that this is a proper module with tests, it's worth fixing. Either catch inside the IIFE and still setinitialized = true(soonRequestErrorstill gets wired), or null outinitPromiseon failure to allow retry. -
Minor:
isOnRequestErrorHandlertype guard is unnecessary indirection. It's a single-use function that just wrapstypeof value === "function"— the same check used forregistertwo lines above. The asymmetry makes the reader wonder if there's a deeper validation happening. Inlining it (with a cast) would be more consistent.
| initPromise = (async () => { | ||
| if (typeof instrumentationModule.register === "function") { | ||
| await instrumentationModule.register(); | ||
| } | ||
|
|
||
| // Store the onRequestError handler on globalThis so it is visible to | ||
| // reportRequestError() regardless of which Vite environment module graph | ||
| // it is called from. With @vitejs/plugin-rsc the RSC and SSR environments | ||
| // run in the same Node.js process and share globalThis. With | ||
| // @cloudflare/vite-plugin everything runs inside the Worker so globalThis | ||
| // is the Worker's global — also correct. | ||
| if (isOnRequestErrorHandler(instrumentationModule.onRequestError)) { | ||
| globalThis.__VINEXT_onRequestErrorHandler__ = instrumentationModule.onRequestError; | ||
| } | ||
|
|
||
| initialized = true; | ||
| })(); |
There was a problem hiding this comment.
If register() rejects, the rejected promise is stored in initPromise and initialized never flips to true. Every subsequent call to ensureInstrumentationRegistered returns the cached rejected promise — the handler is permanently poisoned with no recovery.
The original inline code had the same flaw, but since this refactor is explicitly about making the behavior testable and correct, it's worth fixing here. Two options:
Option A — fail open, still wire onRequestError:
| initPromise = (async () => { | |
| if (typeof instrumentationModule.register === "function") { | |
| await instrumentationModule.register(); | |
| } | |
| // Store the onRequestError handler on globalThis so it is visible to | |
| // reportRequestError() regardless of which Vite environment module graph | |
| // it is called from. With @vitejs/plugin-rsc the RSC and SSR environments | |
| // run in the same Node.js process and share globalThis. With | |
| // @cloudflare/vite-plugin everything runs inside the Worker so globalThis | |
| // is the Worker's global — also correct. | |
| if (isOnRequestErrorHandler(instrumentationModule.onRequestError)) { | |
| globalThis.__VINEXT_onRequestErrorHandler__ = instrumentationModule.onRequestError; | |
| } | |
| initialized = true; | |
| })(); | |
| initPromise = (async () => { | |
| try { | |
| if (typeof instrumentationModule.register === "function") { | |
| await instrumentationModule.register(); | |
| } | |
| } catch (err) { | |
| console.error( | |
| "[vinext] instrumentation.register() threw:", | |
| err instanceof Error ? err.message : String(err), | |
| ); | |
| } | |
| // Store the onRequestError handler on globalThis so it is visible to | |
| // reportRequestError() regardless of which Vite environment module graph | |
| // it is called from. With @vitejs/plugin-rsc the RSC and SSR environments | |
| // run in the same Node.js process and share globalThis. With | |
| // @cloudflare/vite-plugin everything runs inside the Worker so globalThis | |
| // is the Worker's global — also correct. | |
| if (isOnRequestErrorHandler(instrumentationModule.onRequestError)) { | |
| globalThis.__VINEXT_onRequestErrorHandler__ = instrumentationModule.onRequestError; | |
| } | |
| initialized = true; | |
| })(); |
This matches how runInstrumentation (Pages Router path) already handles errors — it catches and logs, then continues. Keeps dev/prod parity.
Option B — allow retry by clearing initPromise on failure. Less preferred because it could cause register() side effects to run multiple times on a flaky init.
Either way, a test for register() rejection behavior would be good to add.
| function isOnRequestErrorHandler(value: unknown): value is OnRequestErrorHandler { | ||
| return typeof value === "function"; | ||
| } |
There was a problem hiding this comment.
Nit: this type guard is a single-use wrapper around typeof value === "function" — the exact same check used for register on line 58. The asymmetry makes the reader pause to check if there's deeper validation. Consider inlining it:
| function isOnRequestErrorHandler(value: unknown): value is OnRequestErrorHandler { | |
| return typeof value === "function"; | |
| } |
and on line 68:
if (typeof instrumentationModule.onRequestError === "function") {
globalThis.__VINEXT_onRequestErrorHandler__ = instrumentationModule.onRequestError as OnRequestErrorHandler;
}Or alternatively, if you prefer the type guard style, use it for both checks (register and onRequestError) so the pattern is consistent.
|
|
||
| // Should not throw | ||
| await reportRequestError(new Error("boom"), sampleRequest, sampleContext); | ||
| }); |
There was a problem hiding this comment.
Missing test: what happens when register() throws? Currently, a rejection from register() poisons initPromise permanently — every subsequent call re-throws the same error. Worth adding a test that verifies the desired behavior (whether that's fail-open, retry, or intentional propagation) so the contract is explicit.
Something like:
it("handles register() rejection gracefully", async () => {
const register = vi.fn().mockRejectedValue(new Error("init failed"));
const onRequestError = vi.fn();
// Should not throw (or should throw — depends on desired semantics)
await ensureInstrumentationRegistered({ register, onRequestError });
// onRequestError should still be wired despite register() failure
await reportRequestError(new Error("boom"), sampleRequest, sampleContext);
expect(onRequestError).toHaveBeenCalledOnce();
});|
Review posted. Here's a summary of the feedback: Overall: Clean refactor that correctly extracts instrumentation lazy-init from codegen into a testable module. The generated entry is meaningfully thinner and the behavioral semantics are preserved. Key findings:
|
|
Pre-existing bug - will merge refactor. |
Testing Kimi2.6
Summary
Extracts the 25-line
__ensureInstrumentationblock from the generated App Router RSC entry into a normal, typed, unit-testable module:server/instrumentation-runtime.ts.Principle: Codegen should describe the app shape; normal modules should implement behavior.
What changed
Before
entries/app-rsc-entry.tsemitted ~25 lines of inline generated code for every App Router project that usesinstrumentation.ts:__instrumentationInitialized,__instrumentationInitPromise)async function __ensureInstrumentation()register()andonRequestErrorwiringVINEXT_PRERENDERshort-circuitThis is the canonical "imperative shell with mutable bookkeeping" pattern. Embedding it in a template string makes it untestable, hard to review, and easy to drift between dev/prod paths.
After
packages/vinext/src/server/instrumentation-runtime.tsexportsensureInstrumentationRegistered(instrumentationModule).await ensureInstrumentationRegistered(_instrumentation)— two lines instead of twenty-five.register/onRequestError.Why this matters
ensureInstrumentationRegisteredimport, fixes or additions to instrumentation behavior automatically apply everywhere.Next.js references
Next.js supports
instrumentation.tsvia aregister()hook called once at server startup, plus an optionalonRequestError()handler. The semantics we preserve:packages/next/src/server/dev/next-dev-server.tsonRequestErrorso the handler is reachable across module graphs — Next.js stores the hook on a shared instrumentation instance:packages/next/src/server/lib/instrumentation/internal-lifecycle.tsTest plan
All pass locally.