diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 3df85cf0b1..f3a90a1c59 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -2967,7 +2967,13 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { applyRequestHeadersToNodeRequest(middlewareRequestHeaders); } } - const handled = await handleApiRoute(server, req, res, resolvedUrl, apiRoutes); + const handled = await handleApiRoute( + getPagesRunner(), + req, + res, + resolvedUrl, + apiRoutes, + ); if (handled) return; // No API route matched — if app dir exists, let the RSC plugin handle it @@ -3003,6 +3009,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { const handler = createSSRHandler( server, + getPagesRunner(), routes, pagesDir, nextConfig?.i18n, diff --git a/packages/vinext/src/server/api-handler.ts b/packages/vinext/src/server/api-handler.ts index 226e5e7653..64d1120761 100644 --- a/packages/vinext/src/server/api-handler.ts +++ b/packages/vinext/src/server/api-handler.ts @@ -7,11 +7,10 @@ * The req/res objects are Node.js IncomingMessage/ServerResponse with * Next.js extensions: req.query, req.body, res.json(), res.status(), etc. */ -import type { ViteDevServer } from "vite"; import type { IncomingMessage, ServerResponse } from "node:http"; import { decode as decodeQueryString } from "node:querystring"; import { type Route, matchRoute } from "../routing/pages-router.js"; -import { reportRequestError } from "./instrumentation.js"; +import { reportRequestError, importModule, type ModuleImporter } from "./instrumentation.js"; import { addQueryParam } from "../utils/query.js"; /** @@ -192,7 +191,7 @@ function enhanceApiObjects( * Returns true if the request was handled, false if no API route matched. */ export async function handleApiRoute( - server: ViteDevServer, + runner: ModuleImporter, req: IncomingMessage, res: ServerResponse, url: string, @@ -204,8 +203,8 @@ export async function handleApiRoute( const { route, params } = match; try { - // Load the API route module through Vite - const apiModule = await server.ssrLoadModule(route.filePath); + // Load the API route module through the ModuleRunner + const apiModule = await importModule(runner, route.filePath); const handler = apiModule.default; if (typeof handler !== "function") { @@ -242,7 +241,8 @@ export async function handleApiRoute( return true; } - server.ssrFixStacktrace(e as Error); + // ssrFixStacktrace() is specific to ssrLoadModule and is not applicable + // when using ModuleRunner — no stack trace fixup is needed here. console.error(e); void reportRequestError( e instanceof Error ? e : new Error(String(e)), diff --git a/packages/vinext/src/server/dev-server.ts b/packages/vinext/src/server/dev-server.ts index 2228ce1b35..4e494ea1e0 100644 --- a/packages/vinext/src/server/dev-server.ts +++ b/packages/vinext/src/server/dev-server.ts @@ -2,6 +2,8 @@ import type { ViteDevServer } from "vite"; import type { IncomingMessage, ServerResponse } from "node:http"; import type { Route } from "../routing/pages-router.js"; import { matchRoute, patternToNextFormat } from "../routing/pages-router.js"; +import type { ModuleImporter } from "./instrumentation.js"; +import { importModule, reportRequestError } from "./instrumentation.js"; import type { NextI18nConfig } from "../config/next-config.js"; import { isrGet, @@ -22,7 +24,6 @@ import { createRequestContext, runWithRequestContext } from "../shims/unified-re import "../shims/router-state.js"; import { runWithHeadState } from "../shims/head-state.js"; import { runWithServerInsertedHTMLState } from "../shims/navigation-state.js"; -import { reportRequestError } from "./instrumentation.js"; import { safeJsonStringify } from "./html.js"; import { parseQueryString as parseQuery } from "../utils/query.js"; import path from "node:path"; @@ -235,13 +236,14 @@ export function parseCookieLocale(req: IncomingMessage, i18nConfig: NextI18nConf * * For each request: * 1. Match the URL against discovered routes - * 2. Load the page module via Vite's SSR module loader + * 2. Load the page module via the ModuleRunner * 3. Call getServerSideProps/getStaticProps if present * 4. Render the component to HTML * 5. Wrap in _document shell and send response */ export function createSSRHandler( server: ViteDevServer, + runner: ModuleImporter, routes: Route[], pagesDir: string, i18nConfig?: NextI18nConfig | null, @@ -253,10 +255,10 @@ export function createSSRHandler( // Register ALS-backed accessors in the SSR module graph so head and // router state are per-request isolated under concurrent load. - // This is a one-time side-effect; ssrLoadModule caches internally. + // runner.import() caches internally. const _alsRegistration = Promise.all([ - server.ssrLoadModule("vinext/head-state"), - server.ssrLoadModule("vinext/router-state"), + runner.import("vinext/head-state"), + runner.import("vinext/router-state"), ]); // Suppress unhandled-rejection if the server closes before the first // request (common in tests). Errors still propagate when the first @@ -323,7 +325,7 @@ export function createSSRHandler( if (!match) { // No route matched — try to render custom 404 page - await renderErrorPage(server, req, res, url, pagesDir, 404, undefined, matcher); + await renderErrorPage(server, runner, req, res, url, pagesDir, 404, undefined, matcher); return; } @@ -338,7 +340,7 @@ export function createSSRHandler( // Set SSR context for the router shim so useRouter() returns // the correct URL and params during server-side rendering. - const routerShim = await server.ssrLoadModule("next/router"); + const routerShim = await importModule(runner, "next/router"); if (typeof routerShim.setSSRContext === "function") { routerShim.setSSRContext({ pathname: patternToNextFormat(route.pattern), @@ -352,14 +354,14 @@ export function createSSRHandler( } // Set per-request i18n context for Link component locale - // prop support during SSR. Use ssrLoadModule to set it on + // prop support during SSR. Use runner.import to set it on // the SSR environment's module instance (same pattern as // setSSRContext above). if (i18nConfig) { // Register ALS-backed i18n accessors in the SSR module graph so // next/link and other SSR imports read from the unified store. - await server.ssrLoadModule("vinext/i18n-state"); - const i18nCtx = await server.ssrLoadModule("vinext/i18n-context"); + await runner.import("vinext/i18n-state"); + const i18nCtx = await importModule(runner, "vinext/i18n-context"); if (typeof i18nCtx.setI18nContext === "function") { i18nCtx.setI18nContext({ locale: locale ?? currentDefaultLocale, @@ -373,7 +375,7 @@ export function createSSRHandler( // Load the page module through Vite's SSR pipeline // This gives us HMR and transform support for free - const pageModule = await server.ssrLoadModule(route.filePath); + const pageModule = await importModule(runner, route.filePath); // Mark end of compile phase: everything from here is rendering. _compileEnd = now(); @@ -416,6 +418,7 @@ export function createSSRHandler( if (!isValidPath) { await renderErrorPage( server, + runner, req, res, url, @@ -486,6 +489,7 @@ export function createSSRHandler( if (result && "notFound" in result && result.notFound) { await renderErrorPage( server, + runner, req, res, url, @@ -520,11 +524,11 @@ export function createSSRHandler( let earlyFontLinkHeader = ""; try { const earlyPreloads: Array<{ href: string; type: string }> = []; - const fontGoogleEarly = await server.ssrLoadModule("next/font/google"); + const fontGoogleEarly = await importModule(runner, "next/font/google"); if (typeof fontGoogleEarly.getSSRFontPreloads === "function") { earlyPreloads.push(...fontGoogleEarly.getSSRFontPreloads()); } - const fontLocalEarly = await server.ssrLoadModule("next/font/local"); + const fontLocalEarly = await importModule(runner, "next/font/local"); if (typeof fontLocalEarly.getSSRFontPreloads === "function") { earlyPreloads.push(...fontLocalEarly.getSSRFontPreloads()); } @@ -606,8 +610,8 @@ export function createSSRHandler( }); } if (i18nConfig) { - await server.ssrLoadModule("vinext/i18n-state"); - const i18nCtx = await server.ssrLoadModule("vinext/i18n-context"); + await runner.import("vinext/i18n-state"); + const i18nCtx = await importModule(runner, "vinext/i18n-context"); if (typeof i18nCtx.setI18nContext === "function") { i18nCtx.setI18nContext({ locale: locale ?? currentDefaultLocale, @@ -625,7 +629,7 @@ export function createSSRHandler( const appPath = path.join(pagesDir, "_app"); if (findFileWithExtensions(appPath, matcher)) { try { - const appMod = await server.ssrLoadModule(appPath); + const appMod = (await runner.import(appPath)) as Record; RegenApp = appMod.default ?? null; } catch { // _app failed to load @@ -726,6 +730,7 @@ export function createSSRHandler( if (result && "notFound" in result && result.notFound) { await renderErrorPage( server, + runner, req, res, url, @@ -748,7 +753,7 @@ export function createSSRHandler( const appPath = path.join(pagesDir, "_app"); if (findFileWithExtensions(appPath, matcher)) { try { - const appModule = await server.ssrLoadModule(appPath); + const appModule = await importModule(runner, appPath); AppComponent = appModule.default ?? null; } catch { // _app exists but failed to load @@ -779,13 +784,13 @@ export function createSSRHandler( } // Reset SSR head collector before rendering so tags are captured - const headShim = await server.ssrLoadModule("next/head"); + const headShim = await importModule(runner, "next/head"); if (typeof headShim.resetSSRHead === "function") { headShim.resetSSRHead(); } // Flush any pending dynamic() preloads so components are ready - const dynamicShim = await server.ssrLoadModule("next/dynamic"); + const dynamicShim = await importModule(runner, "next/dynamic"); if (typeof dynamicShim.flushPreloads === "function") { await dynamicShim.flushPreloads(); } @@ -799,7 +804,7 @@ export function createSSRHandler( const allFontStyles: string[] = []; const allFontPreloads: Array<{ href: string; type: string }> = []; try { - const fontGoogle = await server.ssrLoadModule("next/font/google"); + const fontGoogle = await importModule(runner, "next/font/google"); if (typeof fontGoogle.getSSRFontLinks === "function") { const fontUrls = fontGoogle.getSSRFontLinks(); for (const fontUrl of fontUrls) { @@ -818,7 +823,7 @@ export function createSSRHandler( // next/font/google not used — skip } try { - const fontLocal = await server.ssrLoadModule("next/font/local"); + const fontLocal = await importModule(runner, "next/font/local"); if (typeof fontLocal.getSSRFontStyles === "function") { allFontStyles.push(...fontLocal.getSSRFontStyles()); } @@ -904,7 +909,7 @@ hydrate(); let DocumentComponent: any = null; if (findFileWithExtensions(docPath, matcher)) { try { - const docModule = await server.ssrLoadModule(docPath); + const docModule = (await runner.import(docPath)) as Record; DocumentComponent = docModule.default ?? null; } catch { // _document exists but failed to load @@ -982,8 +987,8 @@ hydrate(); setRevalidateDuration(cacheKey, isrRevalidateSeconds); } } catch (e) { - // Let Vite fix the stack trace for better dev experience - server.ssrFixStacktrace?.(e as Error); + // ssrFixStacktrace() is specific to ssrLoadModule and is not applicable + // when using ModuleRunner — no stack trace fixup is needed here. console.error(e); // Report error via instrumentation hook if registered reportRequestError( @@ -1008,7 +1013,7 @@ hydrate(); }); // Try to render custom 500 error page try { - await renderErrorPage(server, req, res, url, pagesDir, 500, undefined, matcher); + await renderErrorPage(server, runner, req, res, url, pagesDir, 500, undefined, matcher); } catch (fallbackErr) { // If error page itself fails, fall back to plain text. // This is a dev-only code path (prod uses prod-server.ts), so @@ -1033,6 +1038,7 @@ hydrate(); */ async function renderErrorPage( server: ViteDevServer, + runner: ModuleImporter, _req: IncomingMessage, res: ServerResponse, url: string, @@ -1051,7 +1057,7 @@ async function renderErrorPage( const candidatePath = path.join(pagesDir, candidate); if (!findFileWithExtensions(candidatePath, matcher)) continue; - const errorModule = await server.ssrLoadModule(candidatePath); + const errorModule = await importModule(runner, candidatePath); const ErrorComponent = errorModule.default; if (!ErrorComponent) continue; @@ -1060,7 +1066,7 @@ async function renderErrorPage( const appPathErr = path.join(pagesDir, "_app"); if (findFileWithExtensions(appPathErr, matcher)) { try { - const appModule = await server.ssrLoadModule(appPathErr); + const appModule = await importModule(runner, appPathErr); AppComponent = appModule.default ?? null; } catch { // _app exists but failed to load @@ -1071,11 +1077,11 @@ async function renderErrorPage( const errorProps = { statusCode }; // If the caller didn't supply wrapWithRouterContext, load it now. - // ssrLoadModule caches internally so the cost is negligible. + // runner.import() caches internally so the cost is negligible. let wrapFn = wrapWithRouterContext; if (!wrapFn) { try { - const errRouterShim = await server.ssrLoadModule("next/router"); + const errRouterShim = await importModule(runner, "next/router"); wrapFn = errRouterShim.wrapWithRouterContext; } catch { // router shim not available — continue without it @@ -1104,7 +1110,7 @@ async function renderErrorPage( const docPathErr = path.join(pagesDir, "_document"); if (findFileWithExtensions(docPathErr, matcher)) { try { - const docModule = await server.ssrLoadModule(docPathErr); + const docModule = await importModule(runner, docPathErr); DocumentComponent = docModule.default ?? null; } catch { // _document exists but failed to load diff --git a/packages/vinext/src/server/instrumentation.ts b/packages/vinext/src/server/instrumentation.ts index 0f1d84c332..0e084f0382 100644 --- a/packages/vinext/src/server/instrumentation.ts +++ b/packages/vinext/src/server/instrumentation.ts @@ -48,6 +48,21 @@ export interface ModuleImporter { import(id: string): Promise; } +/** + * Import a module via the runner and cast the result to `Record`. + * + * Centralises the `as Record` cast so callers don't need + * per-call eslint-disable comments. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export async function importModule( + runner: ModuleImporter, + id: string, +): Promise> { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (await runner.import(id)) as Record; +} + /** Possible instrumentation file names. */ const INSTRUMENTATION_FILES = [ "instrumentation.ts", diff --git a/packages/vinext/src/shims/ALS-ARCHITECTURE.md b/packages/vinext/src/shims/ALS-ARCHITECTURE.md index fb0dd82f63..f81d5d956f 100644 --- a/packages/vinext/src/shims/ALS-ARCHITECTURE.md +++ b/packages/vinext/src/shims/ALS-ARCHITECTURE.md @@ -78,8 +78,8 @@ each shim module (e.g. `head-state.ts`, `router-state.ts`) follows this pattern: in dev, vite has separate module graphs for different environments (node vs ssr). the state module must be loaded in each environment that uses it. the dev server -calls `server.ssrLoadModule("vinext/head-state")` to make sure registration -happens in the ssr module graph. +calls `runner.import("vinext/head-state")` (via the `ModuleImporter` interface) +to make sure registration happens in the ssr module graph. in prod, bundling collapses everything into one module graph, so registration happens naturally through static imports. @@ -92,7 +92,7 @@ happens naturally through static imports. 4. in your shim, use `isInsideUnifiedScope()` to read from the unified store, falling back to standalone als when outside 5. if the state is accessed by react components during ssr in dev, load the - state module via `server.ssrLoadModule()` in `dev-server.ts` (node-side-only - state does not need this) + state module via `runner.import()` (using the `ModuleImporter` interface) + in `dev-server.ts` (node-side-only state does not need this) 6. if the state is per-call rather than per-request (like cache scopes), keep it in its own als - don't add it to the unified context diff --git a/tests/api-handler.test.ts b/tests/api-handler.test.ts index af84eddafc..5f2f46e894 100644 --- a/tests/api-handler.test.ts +++ b/tests/api-handler.test.ts @@ -15,16 +15,16 @@ import { PassThrough } from "node:stream"; import http from "node:http"; vi.mock("../packages/vinext/src/server/instrumentation.js", () => ({ reportRequestError: vi.fn(() => Promise.resolve()), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + importModule: (runner: { import(id: string): Promise }, id: string) => + runner.import(id) as Promise>, })); import { handleApiRoute } from "../packages/vinext/src/server/api-handler.js"; -import { reportRequestError } from "../packages/vinext/src/server/instrumentation.js"; +import { + reportRequestError, + type ModuleImporter, +} from "../packages/vinext/src/server/instrumentation.js"; import type { Route } from "../packages/vinext/src/routing/pages-router.js"; -import type { ViteDevServer } from "vite-plus"; - -type MockServer = ViteDevServer & { - ssrLoadModule: ReturnType; - ssrFixStacktrace: ReturnType; -}; beforeEach(() => { vi.clearAllMocks(); @@ -132,13 +132,12 @@ function route(pattern: string, filePath = "/fake/api/handler.ts"): Route { } /** - * Build a minimal mock ViteDevServer with configurable ssrLoadModule behavior. + * Build a minimal mock ModuleImporter with configurable import behavior. */ -function mockServer(moduleExport: Record): MockServer { +function mockServer(moduleExport: Record): ModuleImporter { return { - ssrLoadModule: vi.fn().mockResolvedValue(moduleExport), - ssrFixStacktrace: vi.fn(), - } as unknown as MockServer; + import: vi.fn().mockResolvedValue(moduleExport), + }; } // ── Tests ──────────────────────────────────────────────────────────────── @@ -211,7 +210,6 @@ describe("handleApiRoute", () => { expect(res._statusCode).toBe(400); expect(res.statusMessage).toBe("Invalid JSON"); expect(res._body).toBe("Invalid JSON"); - expect(server.ssrFixStacktrace.mock.calls).toHaveLength(0); expect(errorSpy).not.toHaveBeenCalled(); expect(reportRequestError).not.toHaveBeenCalled(); errorSpy.mockRestore(); @@ -811,7 +809,7 @@ describe("handleApiRoute", () => { expect(res._body).toBe("Internal Server Error"); }); - it("calls ssrFixStacktrace on handler errors", async () => { + it("still returns 500 on handler errors (no ssrFixStacktrace needed with Module Runner)", async () => { const error = new Error("test error"); const handler = vi.fn(() => { throw error; @@ -822,7 +820,7 @@ describe("handleApiRoute", () => { await handleApiRoute(server, req, res, "/api/users", [route("/api/users")]); - expect(server.ssrFixStacktrace.mock.calls).toContainEqual([error]); + expect(res._statusCode).toBe(500); }); }); }); diff --git a/tests/pages-router.test.ts b/tests/pages-router.test.ts index 02f6319065..d7e8f9fe7d 100644 --- a/tests/pages-router.test.ts +++ b/tests/pages-router.test.ts @@ -2911,53 +2911,55 @@ describe("Pages Router dev ISR regeneration", () => { }; const routeFile = path.join(FIXTURE_DIR, "pages", "isr-test.tsx"); - const server = { - transformIndexHtml: vi.fn(async (_url: string, html: string) => html), - ssrLoadModule: vi.fn(async (id: string) => { - // ALS registration side-effects loaded at createSSRHandler startup - if (id === "vinext/head-state" || id === "vinext/router-state") { - return {}; - } - - if (id === "next/router") { - return { - setSSRContext() { - getRequestContext().currentRequestTags.push("outer-tag"); - parentRequestTags = [...getRequestContext().currentRequestTags]; - }, - wrapWithRouterContext(element: unknown) { - return element; - }, - }; - } + const loadModule = async (id: string) => { + // ALS registration side-effects loaded at createSSRHandler startup + if (id === "vinext/head-state" || id === "vinext/router-state") { + return {}; + } + + if (id === "next/router") { + return { + setSSRContext() { + getRequestContext().currentRequestTags.push("outer-tag"); + parentRequestTags = [...getRequestContext().currentRequestTags]; + }, + wrapWithRouterContext(element: unknown) { + return element; + }, + }; + } - if (id === routeFile) { - return { - default() { - return null; - }, - async getStaticProps() { - regenSawUnifiedScope = isInsideUnifiedScope(); - regenTags = [...getRequestContext().currentRequestTags]; - regenExecutionContext = getRequestExecutionContext(); - regenUnifiedExecutionContext = getRequestContext().executionContext; - return { - props: { - timestamp: Date.now(), - message: "fresh", - }, - revalidate: 1, - }; - }, - }; - } + if (id === routeFile) { + return { + default() { + return null; + }, + async getStaticProps() { + regenSawUnifiedScope = isInsideUnifiedScope(); + regenTags = [...getRequestContext().currentRequestTags]; + regenExecutionContext = getRequestExecutionContext(); + regenUnifiedExecutionContext = getRequestContext().executionContext; + return { + props: { + timestamp: Date.now(), + message: "fresh", + }, + revalidate: 1, + }; + }, + }; + } - throw new Error(`Unexpected module load: ${id}`); - }), + throw new Error(`Unexpected module load: ${id}`); + }; + const server = { + transformIndexHtml: vi.fn(async (_url: string, html: string) => html), } as unknown as ViteDevServer; + const runner = { import: loadModule }; const handler = createSSRHandler( server, + runner, [ { pattern: "/isr-test",