Skip to content
Merged
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
9 changes: 8 additions & 1 deletion packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -3003,6 +3009,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {

const handler = createSSRHandler(
server,
getPagesRunner(),
routes,
pagesDir,
nextConfig?.i18n,
Expand Down
12 changes: 6 additions & 6 deletions packages/vinext/src/server/api-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -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,
Expand All @@ -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") {
Expand Down Expand Up @@ -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)),
Expand Down
66 changes: 36 additions & 30 deletions packages/vinext/src/server/dev-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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;
}

Expand All @@ -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),
Expand All @@ -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,
Expand All @@ -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();

Expand Down Expand Up @@ -416,6 +418,7 @@ export function createSSRHandler(
if (!isValidPath) {
await renderErrorPage(
server,
runner,
req,
res,
url,
Expand Down Expand Up @@ -486,6 +489,7 @@ export function createSSRHandler(
if (result && "notFound" in result && result.notFound) {
await renderErrorPage(
server,
runner,
req,
res,
url,
Expand Down Expand Up @@ -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());
}
Expand Down Expand Up @@ -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,
Expand All @@ -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<string, any>;
RegenApp = appMod.default ?? null;
} catch {
// _app failed to load
Expand Down Expand Up @@ -726,6 +730,7 @@ export function createSSRHandler(
if (result && "notFound" in result && result.notFound) {
await renderErrorPage(
server,
runner,
req,
res,
url,
Expand All @@ -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
Expand Down Expand Up @@ -779,13 +784,13 @@ export function createSSRHandler(
}

// Reset SSR head collector before rendering so <Head> 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();
}
Expand All @@ -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) {
Expand All @@ -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());
}
Expand Down Expand Up @@ -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<string, any>;
DocumentComponent = docModule.default ?? null;
} catch {
// _document exists but failed to load
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand All @@ -1033,6 +1038,7 @@ hydrate();
*/
async function renderErrorPage(
server: ViteDevServer,
runner: ModuleImporter,
_req: IncomingMessage,
res: ServerResponse,
url: string,
Expand All @@ -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;

Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions packages/vinext/src/server/instrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,21 @@ export interface ModuleImporter {
import(id: string): Promise<unknown>;
}

/**
* Import a module via the runner and cast the result to `Record<string, any>`.
*
* Centralises the `as Record<string, any>` 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<Record<string, any>> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (await runner.import(id)) as Record<string, any>;
}

/** Possible instrumentation file names. */
const INSTRUMENTATION_FILES = [
"instrumentation.ts",
Expand Down
8 changes: 4 additions & 4 deletions packages/vinext/src/shims/ALS-ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Loading
Loading