fix: middleware crash in dev mode with cloudflare plugin#344
fix: middleware crash in dev mode with cloudflare plugin#344james-elicx merged 3 commits intomainfrom
Conversation
commit: |
|
7aa66fd to
f0fe564
Compare
|
/bigbonk review this pr please |
There was a problem hiding this comment.
Review: middleware crash in dev mode with cloudflare plugin
Overall this is a well-structured fix with good test coverage. The root cause analysis is accurate, the fix is minimal and targeted, and the regression tests are well-designed. A few observations:
What looks good
-
createDirectRunner()approach is correct.DevEnvironment.fetchModule()is a plain async method that doesn't touch the hot channel — this cleanly avoids theoutsideEmittercrash without any fragile workarounds. The transport implementation indev-module-runner.tsis clean and handles bothfetchModuleandgetBuiltinsinvoke types. -
Lazy initialization of
pagesRunnerviagetPagesRunner()is the right call. It ensures all environments are fully registered before inspection, and the fallback chain ("ssr"→ non-"rsc"→ first available) is sensible. -
hasCloudflarePluginearly-return at line 2947 is well-placed. It sits after middleware execution (so rewrites are applied toreq.url) and before the host-side config redirect/rewrite/SSR logic (which would duplicate what the Worker entry already does). The extensive block comment explaining the reasoning is appreciated. -
Test design is clever. Using
navigator.userAgent === "Cloudflare-Workers"to assert Worker execution, and the competingpages/index.tsxvsapp/page.tsxto catch routing bypass — both are effective regression detectors. -
shims.test.ts changes are mechanical (mock shape update from
ssrLoadModuletoimport) and correct.
Observations / minor items
-
DevEnvironmentLikeinterface typing (dev-module-runner.ts:72-78):fetchModule()returnsPromise<Record<string, unknown>>in your interface, but Vite's actualDevEnvironment.fetchModule()returnsPromise<FetchResult>(which is{ externalize?: string; id?: string; code?: string; ... }). This is fine in practice since theinvokehandler passes the result through opaquely (result: await environment.fetchModule(...)) andModuleRunnerknows how to interpret it. But the interface comment says "duck-typed to the minimal surface we need" — worth noting the return type is actuallyFetchResult, not an arbitrary record. Not blocking. -
runner.close()lifecycle: The docstring example indev-module-runner.tsshowsawait runner.close(), butpagesRunnerinindex.tsis never closed (e.g., in aserver.closehook). This is fine for dev servers (process exits), but if there's ever a graceful shutdown path, this is a minor leak. Not blocking. -
instrumentation.ts still uses
server.ssrLoadModule()(line 118). The file's own comment at line 32-33 says this is safe because "Pages Router always uses plain Vite + Node.js (never@cloudflare/vite-plugin)". That reasoning holds today —configureServer()callsrunInstrumentation()only for Pages Router. But if someone adds apages/directory to an app-router-cloudflare project (which this PR's test fixture literally does!),runInstrumentation()would run with@cloudflare/vite-pluginloaded and hit the sameoutsideEmittercrash. ThehasCloudflarePluginearly-return at line 2947 papers over the routing bug but doesn't protect the instrumentation path. Worth verifying whether instrumentation is skipped for the cloudflare case. -
api-handler.tsalso usesserver.ssrLoadModule()(line 179). Same concern as above — this is only called for Pages Router API routes, and thehasCloudflarePluginearly-return at line 2947 prevents it from running. But if someone has a Pages Router project deployed to Cloudflare Workers (thepages-router-cloudflareexample exists), the dev server would still callssrLoadModulefor API routes. ThehasCloudflarePluginguard only protects the connect handler that runs middleware — not the API handler itself. This is pre-existing and not introduced by this PR, but worth noting. -
No cleanup of
pagesRunneron HMR invalidation: Whenmiddleware.tsis edited, theModuleRunnerwill re-import the module viafetchModule()on the next request.ModuleRunner.import()should handle invalidation correctly since it delegates tofetchModule()which respects the Vite module graph. If there's module-level state in the middleware file, it will be re-evaluated. This matches the existingssrLoadModulebehavior, so no regression.
Verdict
The fix is correct for the bugs described. The createDirectRunner() approach is clean and well-documented. The tests are good regression coverage. The remaining ssrLoadModule callers in instrumentation.ts and api-handler.ts are protected by the hasCloudflarePlugin early-return for now, but could be fragile if the control flow changes in the future — consider migrating those to createDirectRunner() in a follow-up.
|
Review posted. Here's a summary: Overall: Good fix, well-tested, correct approach. The PR fixes two bugs:
Key observations in the review:
|
92bf083 to
37f9760
Compare
|
/bigbonk can you re-review please |
There was a problem hiding this comment.
Re-review: middleware crash in dev mode with cloudflare plugin
Took a thorough second look at all 19 changed files. The fix is solid and CI is all green. A few additional observations beyond the first review:
Confirmed good
-
createDirectRunner()is clean and correct. The transport'sinvokehandler correctly unpacks theHotPayloadshape and delegates toenvironment.fetchModule(). ThegetBuiltinshandler returning[]is correct for the host Node.js process. The error fallback for unexpected invoke names is a nice touch. -
Lazy
getPagesRunner()with fallback chain is robust. The"ssr" → non-"rsc" → first availablepriority is the right call. Lazy init avoids the timing issue where environments aren't fully registered duringconfigureServer(). -
hasCloudflarePluginearly-return placement is exactly right — after middleware (so rewrites are applied toreq.url) and before config redirects/rewrites/headers/SSR (which the Worker handles internally). The extensive block comment at lines 2928-2946 is appreciated and accurate. -
Test design is effective. The
navigator.userAgent === "Cloudflare-Workers"check is a reliable signal for Worker execution. The competingpages/index.tsxvsapp/page.tsxtest on/is a clever way to detect the routing bypass bug. Thex-mw-ranheader checks confirm middleware actually ran. -
shims.test.tschanges are mechanical —mockServer.ssrLoadModule→mockRunner.importwith no logic changes. Correct. -
Playwright config additions are well-structured. The
cloudflare-pages-router-devproject correctly includes both its own spec files andinstrumentation-startup.spec.ts. Port assignments (4177-4179) don't collide with existing projects. -
instrumentation-state.tsusing a temp file is the correct approach for cross-process state bridging between the host Node.js process and the miniflare Worker subprocess. The file comment at lines 7-29 explains this well.
One real issue: runInstrumentation silently fails in cloudflare-pages-router-dev
The previous review flagged this as observation #3, but I want to elevate it because CI is masking the problem:
pages-router-cloudflarehasinstrumentation.ts+@cloudflare/vite-plugin+ noapp/directory- The guard
if (instrumentationPath && !hasAppDir)at line 2563 meansrunInstrumentation(server, instrumentationPath)will be called runInstrumentationusesserver.ssrLoadModule(), which should hit theoutsideEmittercrash- But it doesn't crash the server because
runInstrumentationwraps the call in try/catch and only logs the error (line 130-135) instrumentation-startup.spec.tsonly checksres.status() === 200— it doesn't verifyregisterCalled === true
So right now: the server starts, the test passes, but register() and onRequestError() are silently not working in cloudflare-pages-router-dev. The test gives a false sense of coverage.
This isn't blocking for the PR — the fix for middleware is the priority and it's correct. But I'd recommend either:
- (a) Adding a test assertion in
instrumentation-startup.spec.tsthatregisterCalledistrue(which will fail and force migratingrunInstrumentationto usegetPagesRunner()), or - (b) Migrating
runInstrumentationin this PR to be consistent with the middleware fix
Minor nits (not blocking)
-
DevEnvironmentLikereturn type (line 77):Promise<Record<string, unknown>>works in practice since the result is passed through opaquely, but the actual Vite type isPromise<FetchResult>. Consider importingFetchResultfromvite/module-runnerfor accuracy. -
No
runner.close()on server shutdown:pagesRunneris never closed. Fine for dev servers (process exits), but worth aserver.httpServer?.on("close", ...)hook if graceful shutdown ever matters.
Overall: good fix, well-tested for the bugs it targets, ship it. The instrumentation gap is the one thing I'd address before or shortly after merge.
|
Review posted on the PR. Here's a summary: Overall: Good fix, ship it. The One real issue I elevated: |
|
/bigbonk re-review again please |
There was a problem hiding this comment.
Re-review #3: middleware crash in dev mode with cloudflare plugin
Read every changed file again. All CI checks (25/25) are green. The instrumentation issue from the prior two reviews has been fully addressed. Ship it.
Instrumentation issue — resolved
Both prior reviews flagged that runInstrumentation() still used server.ssrLoadModule(), which would silently fail when @cloudflare/vite-plugin was present. This has been fixed:
-
instrumentation.tsnow accepts aModuleImporterinterface (runner.import()) instead ofserver— line 127-129. The module comment (lines 32-36) explicitly documents whycreateDirectRunner()is used instead ofssrLoadModule(). -
index.tspassesgetPagesRunner()torunInstrumentation()at line 2608 (wasserver). -
instrumentation-startup.spec.ts(line 44-45) now assertsdata.registerCalled === true, not just HTTP 200. This runs under bothcloudflare-pages-router(prod build) andcloudflare-pages-router-dev(vite dev) Playwright projects — both pass in CI. -
Production path is also covered:
generateServerEntry()now emitsinstrumentationImportCodeandinstrumentationInitCodeas top-level awaits in the Worker bundle (lines 821-827), matching the App Router pattern ingenerateRscEntry().
createDirectRunner — correct
The transport implementation is clean. invoke correctly unpacks the HotPayload shape that normalizeModuleRunnerTransport() passes through, handles fetchModule and getBuiltins, and returns a structured error for unexpected invoke names. hmr: false and sourcemapInterceptor: false are the right settings for a non-HMR runner.
hasCloudflarePlugin early-return — correct
The guard at line 2990 sits after middleware execution (rewrites applied to req.url at line 2964) and before config redirects/rewrites/headers/SSR. The block comment (lines 2971-2989) accurately explains the reasoning. This protects all remaining ssrLoadModule callers in the connect handler — handleApiRoute() and createSSRHandler() are never reached when @cloudflare/vite-plugin is present.
Remaining ssrLoadModule callers — not a concern
api-handler.ts:179 and dev-server.ts (20+ calls) still use server.ssrLoadModule(). These are only reachable when hasCloudflarePlugin is false (the early-return at line 2990 prevents them from running otherwise). This is correct: without the Cloudflare plugin, the SSR environment is a standard RunnableDevEnvironment and ssrLoadModule() works fine.
Test coverage — thorough
- cloudflare-dev:
navigator.userAgent === "Cloudflare-Workers"check on/api/helloand content check on/(competingpages/vsapp/routes) effectively catch both the crash bug and the routing bypass bug. - cloudflare-pages-router-dev:
x-mw-ranheader checks on matched/unmatched routes, plusnavigator.userAgentchecks on API routes and SSR pages. - cloudflare-pages-router (prod): Same middleware assertions against a built Worker bundle.
- instrumentation-startup: Asserts
registerCalled === trueunder both dev and prod Cloudflare projects. - Unit tests:
instrumentation.test.tsandfeatures.test.tsmocks updated fromssrLoadModuletorunner.import— mechanical and correct.
Two minor nits (not blocking)
-
DevEnvironmentLike.fetchModulereturn type (dev-module-runner.ts:77):Promise<Record<string, unknown>>works in practice since the result is passed through opaquely, but Vite's actual type isFetchResult. Consider importing it fromvite/module-runnerfor documentation accuracy. -
No
runner.close()on server shutdown:pagesRunneris never closed. Fine for dev servers (process exits), but aserver.httpServer?.on("close", () => pagesRunner?.close())hook would be cleaner if graceful shutdown ever matters.
|
Review posted with APPROVE. Here's the summary: The instrumentation issue flagged in the two prior reviews has been fully fixed. All 25 CI checks are green. The |
Adds regression tests for two bugs that occur when
@cloudflare/vite-pluginis present alongsidemiddleware.ts.Bug 1 —
outsideEmittercrashThe connect handler called
runMiddleware()→server.ssrLoadModule(), which constructs anSSRCompatModuleRunner. That runner requires a hot channel that doesn't exist in the host process when the Cloudflare plugin is loaded, crashing immediately with:Fixed by switching to
createDirectRunner(), which callsenvironment.fetchModule()directly and never touches the hot channel.Bug 2 — requests intercepted before reaching the Worker
After the crash was resolved, the connect handler was still calling
createSSRHandler()for any Pages Router route match, rendering pages in the host process instead of dispatching to the Cloudflare Worker. Routes like/that matched both apages/stub and anapp/route were served by the host instead of miniflare.Fixed by the
hasCloudflarePluginearly-return: after middleware runs, the connect handler callsnext()unconditionally, handing off to the Cloudflare plugin.How the tests assert Worker execution
/api/helloreturnsnavigator.userAgent—"Cloudflare-Workers"inside miniflare, absent or wrong if intercepted by the host./has competing handlers:app/page.tsxrenders"vinext on Cloudflare Workers",pages/index.tsxrenders"pages index". The wrong text appearing means the Worker was bypassed.