Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { NextResponse } from "next/server";
import {
isRegisterCalled,
getCapturedErrors,
resetInstrumentationState,
} from "../../../lib/instrumentation-state";

/**
* API route that exposes the current instrumentation state for e2e testing.
*
* GET /api/instrumentation-test
* Returns { registerCalled, errors } so Playwright tests can assert that
* instrumentation.ts register() was called on startup and that
* onRequestError() fired for any unhandled route errors.
*
* DELETE /api/instrumentation-test
* Resets the captured state so tests can start from a clean slate.
*/
export async function GET() {
return NextResponse.json({
registerCalled: isRegisterCalled(),
errors: getCapturedErrors(),
});
}

export async function DELETE() {
resetInstrumentationState();
return NextResponse.json({ ok: true });
}
67 changes: 67 additions & 0 deletions examples/app-router-cloudflare/instrumentation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* instrumentation.ts for app-router-cloudflare example.
*
* This file exercises the instrumentation.ts feature with @cloudflare/vite-plugin.
*
* ## How it works (new approach)
*
* register() is emitted as a top-level `await` inside the generated RSC entry
* module by `generateRscEntry` in `app-dev-server.ts`. This means it runs:
*
* - Inside the Cloudflare Worker subprocess (miniflare) when
* @cloudflare/vite-plugin is present — the same process as the API routes.
* - Inside the RSC Vite environment when @vitejs/plugin-rsc is used standalone.
*
* In both cases, register() runs in the same process/environment as request
* handling, which is exactly what Next.js specifies: "called once when the
* server starts, before any request handling."
*
* ## Why the old approach was broken
*
* The previous implementation called runInstrumentation() from configureServer()
* in the host Node.js process. With @cloudflare/vite-plugin, the Worker runs in
* a miniflare subprocess — a completely separate process with its own isolated
* globalThis. Any state set by register() in the host was invisible to the Worker
* where the API routes execute.
*
* Additionally, the old code crashed before the server ever started because it
* fell through to server.ssrLoadModule() (or attempted to build a ModuleRunner
* via the hot channel) during configureServer(), before the dev server was
* listening — triggering:
*
* TypeError: Cannot read properties of undefined (reading 'outsideEmitter')
*
* ## State visibility
*
* Because register() and the API routes now run in the same Worker module graph,
* plain module-level variables in instrumentation-state.ts are shared between
* them. No temp-file bridge or globalThis tricks are needed.
*/

import {
markRegisterCalled,
recordRequestError,
} from "./lib/instrumentation-state";

export async function register(): Promise<void> {
markRegisterCalled();
}

export async function onRequestError(
error: Error,
request: { path: string; method: string; headers: Record<string, string> },
context: {
routerKind: string;
routePath: string;
routeType: string;
},
): Promise<void> {
recordRequestError({
message: error.message,
path: request.path,
method: request.method,
routerKind: context.routerKind,
routePath: context.routePath,
routeType: context.routeType,
});
}
51 changes: 51 additions & 0 deletions examples/app-router-cloudflare/lib/instrumentation-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* Shared state for instrumentation.ts testing in app-router-cloudflare.
*
* ## Why plain module-level variables work here
*
* register() is now emitted as a top-level `await` inside the generated RSC
* entry module (see `generateRscEntry` in `app-dev-server.ts`). This means it
* runs inside the Cloudflare Worker process — the same process and module graph
* as the API routes. Plain module-level variables are therefore visible to both
* the instrumentation code and the API route that reads them.
*
* This is different from the old approach (writing to a temp file on disk) which
* was needed when register() ran in the host Node.js process and API routes ran
* in the miniflare Worker subprocess (two separate processes, no shared memory).
*/

export interface CapturedRequestError {
message: string;
path: string;
method: string;
routerKind: string;
routePath: string;
routeType: string;
}

/** Set to true when instrumentation.ts register() is called. */
let registerCalled = false;

/** List of errors captured by onRequestError(). */
const capturedErrors: CapturedRequestError[] = [];

export function isRegisterCalled(): boolean {
return registerCalled;
}

export function getCapturedErrors(): CapturedRequestError[] {
return [...capturedErrors];
}

export function markRegisterCalled(): void {
registerCalled = true;
}

export function recordRequestError(entry: CapturedRequestError): void {
capturedErrors.push(entry);
}

export function resetInstrumentationState(): void {
registerCalled = false;
capturedErrors.length = 0;
}
32 changes: 32 additions & 0 deletions examples/app-router-cloudflare/middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from "next/server";

/**
* Middleware for app-router-cloudflare example.
*
* This file exists to reproduce the `ssrLoadModule` / `outsideEmitter` crash
* that occurs when @cloudflare/vite-plugin is present and a middleware.ts file
* exists. The Pages Router connect handler in index.ts calls:
*
* runMiddleware(server, middlewarePath, request)
*
* which in turn calls server.ssrLoadModule(). With @cloudflare/vite-plugin,
* server.ssrLoadModule() constructs an SSRCompatModuleRunner synchronously and
* immediately calls connect() on its transport, which reads
* environment.hot.api.outsideEmitter — a property that doesn't exist on the
* Cloudflare DevEnvironment:
*
* TypeError: Cannot read properties of undefined (reading 'outsideEmitter')
*
* If the regression is present, the first request to any route crashes the
* dev server. The e2e test in cloudflare-dev/middleware.spec.ts reproduces
* this by making a request and asserting a successful response.
*/
export function middleware(request: NextRequest) {
const response = NextResponse.next();
response.headers.set("x-middleware-ran", "true");
return response;
}

export const config = {
matcher: ["/api/:path*", "/"],
};
3 changes: 3 additions & 0 deletions examples/app-router-cloudflare/pages/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function Page() {
return <div>pages index</div>;
}
22 changes: 11 additions & 11 deletions examples/app-router-cloudflare/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["@cloudflare/workers-types"]
},
"include": ["app", "worker"]
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["@cloudflare/workers-types"],
},
"include": ["app", "worker", "lib", "*.ts"],
}
10 changes: 8 additions & 2 deletions examples/pages-router-cloudflare/pages/ssr.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,28 @@ import type { GetServerSidePropsResult } from "next";
interface SSRProps {
message: string;
timestamp: string;
runtime: string;
}

export async function getServerSideProps(): Promise<GetServerSidePropsResult<SSRProps>> {
export async function getServerSideProps(): Promise<
GetServerSidePropsResult<SSRProps>
> {
return {
props: {
message: "Server-Side Rendered on Workers",
timestamp: new Date().toISOString(),
runtime:
typeof navigator !== "undefined" ? navigator.userAgent : "unknown",
},
};
}

export default function SSRPage({ message, timestamp }: SSRProps) {
export default function SSRPage({ message, timestamp, runtime }: SSRProps) {
return (
<>
<h1>{message}</h1>
<p>Generated at: {timestamp}</p>
<span data-testid="runtime">{runtime}</span>
</>
);
}
80 changes: 75 additions & 5 deletions packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { pagesRouter, apiRouter, invalidateRouteCache, matchRoute, patternToNext
import { appRouter, invalidateAppRouteCache } from "./routing/app-router.js";
import { createSSRHandler } from "./server/dev-server.js";
import { handleApiRoute } from "./server/api-handler.js";
import { createDirectRunner } from "./server/dev-module-runner.js";
import {
generateRscEntry,
generateSsrEntry,
Expand Down Expand Up @@ -2242,7 +2243,7 @@ hydrate();
headers: nextConfig?.headers,
allowedOrigins: nextConfig?.serverActionsAllowedOrigins,
allowedDevOrigins: nextConfig?.serverActionsAllowedOrigins,
});
}, instrumentationPath);
}
if (id === RESOLVED_APP_SSR_ENTRY && hasAppDir) {
return generateSsrEntry();
Expand Down Expand Up @@ -2270,6 +2271,9 @@ hydrate();
},
transform(code, id, options) {
if (!mdxDelegate?.transform) return;
// Skip ?raw and other query imports — @mdx-js/rollup ignores the query
// and would compile the file as MDX instead of returning raw text.
if (id.includes('?')) return;
const hook = mdxDelegate.transform;
const fn = typeof hook === "function" ? hook : hook.handler;
return fn.call(this, code, id, options);
Expand Down Expand Up @@ -2353,6 +2357,34 @@ hydrate();
// Watch pages directory for file additions/removals to invalidate route cache.
const pageExtensions = /\.(tsx?|jsx?|mdx)$/;

// Build a long-lived ModuleRunner for loading all Pages Router modules
// (middleware, API routes, SSR page rendering) on every request.
//
// We must NOT use server.ssrLoadModule() here: when @cloudflare/vite-plugin
// is present its environments replace the SSR transport, causing
// SSRCompatModuleRunner to crash with:
// TypeError: Cannot read properties of undefined (reading 'outsideEmitter')
// on the very first request.
//
// createDirectRunner() builds a runner on environment.fetchModule() which
// is a plain async method — safe with all plugin combinations, including
// @cloudflare/vite-plugin.
//
// The runner is created lazily on first use so that all environments are
// fully registered before we inspect them. We prefer "ssr", then any
// non-"rsc" environment, then whatever is available.
let pagesRunner: import("vite/module-runner").ModuleRunner | null = null;
function getPagesRunner() {
if (!pagesRunner) {
const env =
server.environments["ssr"] ??
Object.values(server.environments).find((e) => e !== server.environments["rsc"]) ??
Object.values(server.environments)[0];
pagesRunner = createDirectRunner(env);
}
return pagesRunner;
}

/**
* Invalidate the virtual RSC entry module in Vite's module graph.
*
Expand Down Expand Up @@ -2391,8 +2423,21 @@ hydrate();
}
});

// Run instrumentation.ts register() if present (once at server startup)
if (instrumentationPath) {
// Run instrumentation.ts register() if present (once at server startup).
//
// App Router: register() is baked into the generated RSC entry as a
// top-level await at module evaluation time. This means it runs inside
// the Worker process (or RSC Vite environment) — the same process that
// handles requests — which is exactly what Next.js specifies. We do NOT
// call runInstrumentation() here for App Router; doing so would run
// register() in the host Node.js process, which is a separate process
// from the Cloudflare Worker when @cloudflare/vite-plugin is present.
//
// Pages Router: there is no RSC entry, so configureServer() is the right
// place to call register(). Pages Router never uses @cloudflare/vite-plugin
// (it relies on plain Vite + Node.js), so server.ssrLoadModule() is safe
// here — no outsideEmitter crash risk.
if (instrumentationPath && hasPagesDir && !hasAppDir) {
runInstrumentation(server, instrumentationPath).catch((err) => {
console.error("[vinext] Instrumentation error:", err);
});
Expand Down Expand Up @@ -2682,7 +2727,7 @@ hydrate();
.map(([k, v]) => [k, Array.isArray(v) ? v.join(", ") : String(v)])
),
});
const result = await runMiddleware(server, middlewarePath, middlewareRequest);
const result = await runMiddleware(getPagesRunner(), middlewarePath, middlewareRequest);

if (!result.continue) {
if (result.redirectUrl) {
Expand Down Expand Up @@ -2724,12 +2769,36 @@ hydrate();
// Apply middleware rewrite (URL and optional status code)
if (result.rewriteUrl) {
url = result.rewriteUrl;
// Propagate the rewritten URL back onto req so the Cloudflare
// plugin's handler (which reads req.url) sees the correct path.
req.url = url;
}
if (result.rewriteStatus) {
(req as any).__vinextRewriteStatus = result.rewriteStatus;
}
}

// ── Cloudflare Workers dev mode ────────────────────────────
// When @cloudflare/vite-plugin is present, ALL rendering runs
// inside the miniflare Worker subprocess — both App Router (via
// virtual:vinext-rsc-entry) and Pages Router (via
// virtual:vinext-server-entry → renderPage/handleApiRoute).
//
// The Worker entry already handles config redirects, rewrites,
// headers, and all routing internally. Running them here too
// would duplicate that logic and produce incorrect behaviour
// (double redirects, headers set on the wrong object, etc.).
//
// Middleware.ts is the only thing that belongs in the host connect
// handler — it has already run above. Any terminal middleware
// result (redirect, block response) has already been sent.
// Any rewrite has been written back to req.url above so the
// Cloudflare plugin's handler sees the correct path.
//
// Call next() to hand off to the Cloudflare plugin's connect
// handler, which dispatches the request to miniflare.
if (hasCloudflarePlugin) return next();

// Build request context once for has/missing condition checks
// across headers, redirects, and rewrites.
const reqUrl = new URL(url, `http://${req.headers.host || "localhost"}`);
Expand Down Expand Up @@ -2785,6 +2854,7 @@ hydrate();
) {
const apiRoutes = await apiRouter(pagesDir);
const handled = await handleApiRoute(
getPagesRunner(),
server,
req,
res,
Expand Down Expand Up @@ -2823,7 +2893,7 @@ hydrate();
return;
}

const handler = createSSRHandler(server, routes, pagesDir, nextConfig?.i18n);
const handler = createSSRHandler(getPagesRunner(), server, routes, pagesDir, nextConfig?.i18n);
const mwStatus = (req as any).__vinextRewriteStatus as number | undefined;

// Try rendering the resolved URL
Expand Down
Loading