vinext 0.0.25 → 0.0.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +95 -86
- package/dist/build/static-export.d.ts.map +1 -1
- package/dist/build/static-export.js +3 -8
- package/dist/build/static-export.js.map +1 -1
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +154 -50
- package/dist/check.js.map +1 -1
- package/dist/cli.js +42 -12
- package/dist/cli.js.map +1 -1
- package/dist/client/entry.js.map +1 -1
- package/dist/client/vinext-next-data.d.ts +22 -0
- package/dist/client/vinext-next-data.d.ts.map +1 -0
- package/dist/client/vinext-next-data.js +2 -0
- package/dist/client/vinext-next-data.js.map +1 -0
- package/dist/cloudflare/kv-cache-handler.d.ts +32 -1
- package/dist/cloudflare/kv-cache-handler.d.ts.map +1 -1
- package/dist/cloudflare/kv-cache-handler.js +47 -21
- package/dist/cloudflare/kv-cache-handler.js.map +1 -1
- package/dist/cloudflare/tpr.d.ts.map +1 -1
- package/dist/cloudflare/tpr.js +15 -4
- package/dist/cloudflare/tpr.js.map +1 -1
- package/dist/config/config-matchers.d.ts +27 -0
- package/dist/config/config-matchers.d.ts.map +1 -1
- package/dist/config/config-matchers.js +312 -62
- package/dist/config/config-matchers.js.map +1 -1
- package/dist/config/dotenv.d.ts.map +1 -1
- package/dist/config/dotenv.js +1 -6
- package/dist/config/dotenv.js.map +1 -1
- package/dist/config/next-config.d.ts +38 -4
- package/dist/config/next-config.d.ts.map +1 -1
- package/dist/config/next-config.js +194 -31
- package/dist/config/next-config.js.map +1 -1
- package/dist/deploy.d.ts +11 -0
- package/dist/deploy.d.ts.map +1 -1
- package/dist/deploy.js +74 -39
- package/dist/deploy.js.map +1 -1
- package/dist/entries/app-browser-entry.d.ts +9 -0
- package/dist/entries/app-browser-entry.d.ts.map +1 -0
- package/dist/entries/app-browser-entry.js +340 -0
- package/dist/entries/app-browser-entry.js.map +1 -0
- package/dist/{server/app-dev-server.d.ts → entries/app-rsc-entry.d.ts} +4 -17
- package/dist/entries/app-rsc-entry.d.ts.map +1 -0
- package/dist/{server/app-dev-server.js → entries/app-rsc-entry.js} +438 -1232
- package/dist/entries/app-rsc-entry.js.map +1 -0
- package/dist/entries/app-ssr-entry.d.ts +8 -0
- package/dist/entries/app-ssr-entry.d.ts.map +1 -0
- package/dist/entries/app-ssr-entry.js +449 -0
- package/dist/entries/app-ssr-entry.js.map +1 -0
- package/dist/entries/pages-client-entry.d.ts +4 -0
- package/dist/entries/pages-client-entry.d.ts.map +1 -0
- package/dist/entries/pages-client-entry.js +96 -0
- package/dist/entries/pages-client-entry.js.map +1 -0
- package/dist/entries/pages-entry-helpers.d.ts +7 -0
- package/dist/entries/pages-entry-helpers.d.ts.map +1 -0
- package/dist/entries/pages-entry-helpers.js +18 -0
- package/dist/entries/pages-entry-helpers.js.map +1 -0
- package/dist/entries/pages-server-entry.d.ts +8 -0
- package/dist/entries/pages-server-entry.d.ts.map +1 -0
- package/dist/entries/pages-server-entry.js +1015 -0
- package/dist/entries/pages-server-entry.js.map +1 -0
- package/dist/index.d.ts +2 -26
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +407 -1357
- package/dist/index.js.map +1 -1
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +6 -5
- package/dist/init.js.map +1 -1
- package/dist/routing/app-router.d.ts +2 -0
- package/dist/routing/app-router.d.ts.map +1 -1
- package/dist/routing/app-router.js +10 -18
- package/dist/routing/app-router.js.map +1 -1
- package/dist/routing/file-matcher.d.ts.map +1 -1
- package/dist/routing/file-matcher.js.map +1 -1
- package/dist/routing/pages-router.d.ts +2 -0
- package/dist/routing/pages-router.d.ts.map +1 -1
- package/dist/routing/pages-router.js +8 -5
- package/dist/routing/pages-router.js.map +1 -1
- package/dist/routing/utils.d.ts.map +1 -1
- package/dist/routing/utils.js.map +1 -1
- package/dist/server/api-handler.d.ts.map +1 -1
- package/dist/server/api-handler.js +7 -2
- package/dist/server/api-handler.js.map +1 -1
- package/dist/server/app-router-entry.d.ts +3 -2
- package/dist/server/app-router-entry.d.ts.map +1 -1
- package/dist/server/app-router-entry.js +8 -4
- package/dist/server/app-router-entry.js.map +1 -1
- package/dist/server/dev-module-runner.d.ts.map +1 -1
- package/dist/server/dev-module-runner.js +1 -1
- package/dist/server/dev-module-runner.js.map +1 -1
- package/dist/server/dev-origin-check.d.ts.map +1 -1
- package/dist/server/dev-origin-check.js.map +1 -1
- package/dist/server/dev-server.d.ts.map +1 -1
- package/dist/server/dev-server.js +30 -18
- package/dist/server/dev-server.js.map +1 -1
- package/dist/server/image-optimization.d.ts.map +1 -1
- package/dist/server/image-optimization.js.map +1 -1
- package/dist/server/instrumentation.d.ts +1 -1
- package/dist/server/instrumentation.js +2 -2
- package/dist/server/instrumentation.js.map +1 -1
- package/dist/server/isr-cache.d.ts +13 -1
- package/dist/server/isr-cache.d.ts.map +1 -1
- package/dist/server/isr-cache.js +10 -1
- package/dist/server/isr-cache.js.map +1 -1
- package/dist/server/metadata-routes.d.ts.map +1 -1
- package/dist/server/metadata-routes.js +6 -18
- package/dist/server/metadata-routes.js.map +1 -1
- package/dist/server/middleware-codegen.d.ts +1 -1
- package/dist/server/middleware-codegen.d.ts.map +1 -1
- package/dist/server/middleware-codegen.js +13 -11
- package/dist/server/middleware-codegen.js.map +1 -1
- package/dist/server/middleware-request-headers.d.ts +9 -0
- package/dist/server/middleware-request-headers.d.ts.map +1 -0
- package/dist/server/middleware-request-headers.js +77 -0
- package/dist/server/middleware-request-headers.js.map +1 -0
- package/dist/server/middleware.d.ts.map +1 -1
- package/dist/server/middleware.js +38 -19
- package/dist/server/middleware.js.map +1 -1
- package/dist/server/normalize-path.js.map +1 -1
- package/dist/server/prod-server.d.ts +1 -1
- package/dist/server/prod-server.d.ts.map +1 -1
- package/dist/server/prod-server.js +71 -41
- package/dist/server/prod-server.js.map +1 -1
- package/dist/server/request-pipeline.d.ts +93 -0
- package/dist/server/request-pipeline.d.ts.map +1 -0
- package/dist/server/request-pipeline.js +200 -0
- package/dist/server/request-pipeline.js.map +1 -0
- package/dist/shims/cache-runtime.d.ts.map +1 -1
- package/dist/shims/cache-runtime.js +21 -16
- package/dist/shims/cache-runtime.js.map +1 -1
- package/dist/shims/cache.d.ts.map +1 -1
- package/dist/shims/cache.js +18 -17
- package/dist/shims/cache.js.map +1 -1
- package/dist/shims/constants.d.ts +120 -3
- package/dist/shims/constants.d.ts.map +1 -1
- package/dist/shims/constants.js +165 -3
- package/dist/shims/constants.js.map +1 -1
- package/dist/shims/dynamic.d.ts.map +1 -1
- package/dist/shims/dynamic.js +1 -1
- package/dist/shims/dynamic.js.map +1 -1
- package/dist/shims/error-boundary.d.ts.map +1 -1
- package/dist/shims/error-boundary.js +2 -3
- package/dist/shims/error-boundary.js.map +1 -1
- package/dist/shims/error.d.ts.map +1 -1
- package/dist/shims/error.js +1 -3
- package/dist/shims/error.js.map +1 -1
- package/dist/shims/fetch-cache.d.ts.map +1 -1
- package/dist/shims/fetch-cache.js +53 -29
- package/dist/shims/fetch-cache.js.map +1 -1
- package/dist/shims/font-google-base.d.ts.map +1 -1
- package/dist/shims/font-google-base.js +16 -4
- package/dist/shims/font-google-base.js.map +1 -1
- package/dist/shims/font-google.d.ts +1 -1
- package/dist/shims/font-google.d.ts.map +1 -1
- package/dist/shims/font-google.generated.d.ts.map +1 -1
- package/dist/shims/font-google.generated.js +412 -206
- package/dist/shims/font-google.generated.js.map +1 -1
- package/dist/shims/font-google.js +1 -1
- package/dist/shims/font-google.js.map +1 -1
- package/dist/shims/font-local.d.ts.map +1 -1
- package/dist/shims/font-local.js +13 -3
- package/dist/shims/font-local.js.map +1 -1
- package/dist/shims/form.d.ts.map +1 -1
- package/dist/shims/form.js +2 -2
- package/dist/shims/form.js.map +1 -1
- package/dist/shims/head.d.ts.map +1 -1
- package/dist/shims/head.js +10 -8
- package/dist/shims/head.js.map +1 -1
- package/dist/shims/headers.d.ts +23 -5
- package/dist/shims/headers.d.ts.map +1 -1
- package/dist/shims/headers.js +98 -37
- package/dist/shims/headers.js.map +1 -1
- package/dist/shims/image.d.ts.map +1 -1
- package/dist/shims/image.js +35 -8
- package/dist/shims/image.js.map +1 -1
- package/dist/shims/legacy-image.d.ts.map +1 -1
- package/dist/shims/legacy-image.js +1 -1
- package/dist/shims/legacy-image.js.map +1 -1
- package/dist/shims/link.d.ts.map +1 -1
- package/dist/shims/link.js +31 -17
- package/dist/shims/link.js.map +1 -1
- package/dist/shims/metadata.d.ts +19 -3
- package/dist/shims/metadata.d.ts.map +1 -1
- package/dist/shims/metadata.js +19 -11
- package/dist/shims/metadata.js.map +1 -1
- package/dist/shims/navigation-state.d.ts.map +1 -1
- package/dist/shims/navigation-state.js +3 -2
- package/dist/shims/navigation-state.js.map +1 -1
- package/dist/shims/navigation.d.ts.map +1 -1
- package/dist/shims/navigation.js +26 -19
- package/dist/shims/navigation.js.map +1 -1
- package/dist/shims/og.d.ts +6 -6
- package/dist/shims/og.js +6 -6
- package/dist/shims/og.js.map +1 -1
- package/dist/shims/request-context.d.ts +50 -0
- package/dist/shims/request-context.d.ts.map +1 -0
- package/dist/shims/request-context.js +59 -0
- package/dist/shims/request-context.js.map +1 -0
- package/dist/shims/router-state.d.ts.map +1 -1
- package/dist/shims/router-state.js +2 -1
- package/dist/shims/router-state.js.map +1 -1
- package/dist/shims/router.d.ts.map +1 -1
- package/dist/shims/router.js +18 -25
- package/dist/shims/router.js.map +1 -1
- package/dist/shims/script.d.ts.map +1 -1
- package/dist/shims/script.js.map +1 -1
- package/dist/shims/server.d.ts +13 -0
- package/dist/shims/server.d.ts.map +1 -1
- package/dist/shims/server.js +100 -34
- package/dist/shims/server.js.map +1 -1
- package/dist/shims/url-utils.d.ts.map +1 -1
- package/dist/shims/url-utils.js +1 -3
- package/dist/shims/url-utils.js.map +1 -1
- package/dist/utils/base-path.d.ts +17 -0
- package/dist/utils/base-path.d.ts.map +1 -0
- package/dist/utils/base-path.js +25 -0
- package/dist/utils/base-path.js.map +1 -0
- package/dist/utils/project.d.ts +15 -0
- package/dist/utils/project.d.ts.map +1 -1
- package/dist/utils/project.js +50 -4
- package/dist/utils/project.js.map +1 -1
- package/dist/utils/query.d.ts.map +1 -1
- package/dist/utils/query.js +3 -1
- package/dist/utils/query.js.map +1 -1
- package/package.json +47 -33
- package/dist/server/app-dev-server.d.ts.map +0 -1
- package/dist/server/app-dev-server.js.map +0 -1
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* App Router
|
|
2
|
+
* App Router RSC entry generator.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Generates the virtual RSC entry module for the App Router.
|
|
5
|
+
* The RSC entry does route matching and renders the component tree,
|
|
6
|
+
* then delegates to the SSR entry for HTML generation.
|
|
7
|
+
*
|
|
8
|
+
* Previously housed in server/app-dev-server.ts.
|
|
8
9
|
*/
|
|
9
10
|
import fs from "node:fs";
|
|
10
11
|
import { fileURLToPath } from "node:url";
|
|
11
|
-
import { generateDevOriginCheckCode } from "
|
|
12
|
-
import { generateSafeRegExpCode, generateMiddlewareMatcherCode, generateNormalizePathCode } from "
|
|
13
|
-
import { isProxyFile } from "
|
|
12
|
+
import { generateDevOriginCheckCode } from "../server/dev-origin-check.js";
|
|
13
|
+
import { generateSafeRegExpCode, generateMiddlewareMatcherCode, generateNormalizePathCode, } from "../server/middleware-codegen.js";
|
|
14
|
+
import { isProxyFile } from "../server/middleware.js";
|
|
15
|
+
// Pre-computed absolute paths for generated-code imports. The virtual RSC
|
|
16
|
+
// entry can't use relative imports (it has no real file location), so we
|
|
17
|
+
// resolve these at code-generation time and embed them as absolute paths.
|
|
18
|
+
const configMatchersPath = fileURLToPath(new URL("../config/config-matchers.js", import.meta.url)).replace(/\\/g, "/");
|
|
19
|
+
const requestPipelinePath = fileURLToPath(new URL("../server/request-pipeline.js", import.meta.url)).replace(/\\/g, "/");
|
|
14
20
|
/**
|
|
15
21
|
* Generate the virtual RSC entry module.
|
|
16
22
|
*
|
|
@@ -25,6 +31,7 @@ export function generateRscEntry(appDir, routes, middlewarePath, metadataRoutes,
|
|
|
25
31
|
const rewrites = config?.rewrites ?? { beforeFiles: [], afterFiles: [], fallback: [] };
|
|
26
32
|
const headers = config?.headers ?? [];
|
|
27
33
|
const allowedOrigins = config?.allowedOrigins ?? [];
|
|
34
|
+
const bodySizeLimit = config?.bodySizeLimit ?? 1 * 1024 * 1024;
|
|
28
35
|
// Build import map for all page and layout files
|
|
29
36
|
const imports = [];
|
|
30
37
|
const importMap = new Map();
|
|
@@ -89,7 +96,7 @@ export function generateRscEntry(appDir, routes, middlewarePath, metadataRoutes,
|
|
|
89
96
|
const routeEntries = routes.map((route) => {
|
|
90
97
|
const layoutVars = route.layouts.map((l) => getImportVar(l));
|
|
91
98
|
const templateVars = route.templates.map((t) => getImportVar(t));
|
|
92
|
-
const notFoundVars = (route.notFoundPaths || []).map((nf) => nf ? getImportVar(nf) : "null");
|
|
99
|
+
const notFoundVars = (route.notFoundPaths || []).map((nf) => (nf ? getImportVar(nf) : "null"));
|
|
93
100
|
const slotEntries = route.parallelSlots.map((slot) => {
|
|
94
101
|
const interceptEntries = slot.interceptingRoutes.map((ir) => {
|
|
95
102
|
return ` {
|
|
@@ -114,6 +121,7 @@ ${interceptEntries.join(",\n")}
|
|
|
114
121
|
const layoutErrorVars = (route.layoutErrorPaths || []).map((ep) => ep ? getImportVar(ep) : "null");
|
|
115
122
|
return ` {
|
|
116
123
|
pattern: ${JSON.stringify(route.pattern)},
|
|
124
|
+
patternParts: ${JSON.stringify(route.patternParts)},
|
|
117
125
|
isDynamic: ${route.isDynamic},
|
|
118
126
|
params: ${JSON.stringify(route.params)},
|
|
119
127
|
page: ${route.pagePath ? getImportVar(route.pagePath) : "null"},
|
|
@@ -136,18 +144,12 @@ ${slotEntries.join(",\n")}
|
|
|
136
144
|
});
|
|
137
145
|
// Find root not-found/forbidden/unauthorized pages and root layouts for global error handling
|
|
138
146
|
const rootRoute = routes.find((r) => r.pattern === "/");
|
|
139
|
-
const rootNotFoundVar = rootRoute?.notFoundPath
|
|
140
|
-
|
|
141
|
-
: null;
|
|
142
|
-
const rootForbiddenVar = rootRoute?.forbiddenPath
|
|
143
|
-
? getImportVar(rootRoute.forbiddenPath)
|
|
144
|
-
: null;
|
|
147
|
+
const rootNotFoundVar = rootRoute?.notFoundPath ? getImportVar(rootRoute.notFoundPath) : null;
|
|
148
|
+
const rootForbiddenVar = rootRoute?.forbiddenPath ? getImportVar(rootRoute.forbiddenPath) : null;
|
|
145
149
|
const rootUnauthorizedVar = rootRoute?.unauthorizedPath
|
|
146
150
|
? getImportVar(rootRoute.unauthorizedPath)
|
|
147
151
|
: null;
|
|
148
|
-
const rootLayoutVars = rootRoute
|
|
149
|
-
? rootRoute.layouts.map((l) => getImportVar(l))
|
|
150
|
-
: [];
|
|
152
|
+
const rootLayoutVars = rootRoute ? rootRoute.layouts.map((l) => getImportVar(l)) : [];
|
|
151
153
|
// Global error boundary (app/global-error.tsx)
|
|
152
154
|
const globalErrorVar = globalErrorPath ? getImportVar(globalErrorPath) : null;
|
|
153
155
|
// Build metadata route handling
|
|
@@ -205,7 +207,9 @@ import { LayoutSegmentProvider } from "vinext/layout-segment-context";
|
|
|
205
207
|
import { MetadataHead, mergeMetadata, resolveModuleMetadata, ViewportHead, mergeViewport, resolveModuleViewport } from "vinext/metadata";
|
|
206
208
|
${middlewarePath ? `import * as middlewareModule from ${JSON.stringify(middlewarePath.replace(/\\/g, "/"))};` : ""}
|
|
207
209
|
${instrumentationPath ? `import * as _instrumentation from ${JSON.stringify(instrumentationPath.replace(/\\/g, "/"))};` : ""}
|
|
208
|
-
${effectiveMetaRoutes.length > 0 ? `import { sitemapToXml, robotsToText, manifestToJson } from ${JSON.stringify(fileURLToPath(new URL("
|
|
210
|
+
${effectiveMetaRoutes.length > 0 ? `import { sitemapToXml, robotsToText, manifestToJson } from ${JSON.stringify(fileURLToPath(new URL("../server/metadata-routes.js", import.meta.url)).replace(/\\/g, "/"))};` : ""}
|
|
211
|
+
import { requestContextFromRequest, matchRedirect, matchRewrite, matchHeaders, isExternalUrl, proxyExternalRequest, sanitizeDestination } from ${JSON.stringify(configMatchersPath)};
|
|
212
|
+
import { validateCsrfOrigin, validateImageUrl, guardProtocolRelativeUrl, hasBasePath, stripBasePath, normalizeTrailingSlash, processMiddlewareHeaders } from ${JSON.stringify(requestPipelinePath)};
|
|
209
213
|
import { _consumeRequestScopedCacheLife, _runWithCacheState } from "next/cache";
|
|
210
214
|
import { runWithFetchCache } from "vinext/fetch-cache";
|
|
211
215
|
import { runWithPrivateCache as _runWithPrivateCache } from "vinext/cache-runtime";
|
|
@@ -339,7 +343,7 @@ function __sanitizeErrorForClient(error) {
|
|
|
339
343
|
// thrown during RSC streaming (e.g. inside Suspense boundaries).
|
|
340
344
|
// For non-navigation errors in production, generates a digest hash so the
|
|
341
345
|
// error can be correlated with server logs without leaking details.
|
|
342
|
-
function rscOnError(error) {
|
|
346
|
+
function rscOnError(error, requestInfo, errorContext) {
|
|
343
347
|
if (error && typeof error === "object" && "digest" in error) {
|
|
344
348
|
return String(error.digest);
|
|
345
349
|
}
|
|
@@ -385,6 +389,16 @@ function rscOnError(error) {
|
|
|
385
389
|
return undefined;
|
|
386
390
|
}
|
|
387
391
|
|
|
392
|
+
if (requestInfo && errorContext && error) {
|
|
393
|
+
_reportRequestError(
|
|
394
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
395
|
+
requestInfo,
|
|
396
|
+
errorContext,
|
|
397
|
+
).catch((reportErr) => {
|
|
398
|
+
console.error("[vinext] Failed to report render error:", reportErr);
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
388
402
|
// In production, generate a digest hash for non-navigation errors
|
|
389
403
|
if (process.env.NODE_ENV === "production" && error) {
|
|
390
404
|
const msg = error instanceof Error ? error.message : String(error);
|
|
@@ -394,24 +408,56 @@ function rscOnError(error) {
|
|
|
394
408
|
return undefined;
|
|
395
409
|
}
|
|
396
410
|
|
|
411
|
+
function createRscOnErrorHandler(request, pathname, routePath) {
|
|
412
|
+
const requestInfo = {
|
|
413
|
+
path: pathname,
|
|
414
|
+
method: request.method,
|
|
415
|
+
headers: Object.fromEntries(request.headers.entries()),
|
|
416
|
+
};
|
|
417
|
+
const errorContext = {
|
|
418
|
+
routerKind: "App Router",
|
|
419
|
+
routePath: routePath || pathname,
|
|
420
|
+
routeType: "render",
|
|
421
|
+
};
|
|
422
|
+
return function(error) {
|
|
423
|
+
return rscOnError(error, requestInfo, errorContext);
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
397
427
|
${imports.join("\n")}
|
|
398
428
|
|
|
399
|
-
${instrumentationPath
|
|
400
|
-
|
|
401
|
-
//
|
|
402
|
-
//
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
if (
|
|
413
|
-
|
|
414
|
-
|
|
429
|
+
${instrumentationPath
|
|
430
|
+
? `// Run instrumentation register() exactly once, lazily on the first request.
|
|
431
|
+
// Previously this was a top-level await, which blocked the entire module graph
|
|
432
|
+
// from finishing initialization until register() resolved — adding that latency
|
|
433
|
+
// to every cold start. Moving it here preserves the "runs before any request is
|
|
434
|
+
// handled" guarantee while not blocking V8 isolate initialization.
|
|
435
|
+
// On Cloudflare Workers, module evaluation happens synchronously in the isolate
|
|
436
|
+
// startup phase; a top-level await extends that phase and increases cold-start
|
|
437
|
+
// wall time for all requests, not just the first.
|
|
438
|
+
let __instrumentationInitialized = false;
|
|
439
|
+
let __instrumentationInitPromise = null;
|
|
440
|
+
async function __ensureInstrumentation() {
|
|
441
|
+
if (__instrumentationInitialized) return;
|
|
442
|
+
if (__instrumentationInitPromise) return __instrumentationInitPromise;
|
|
443
|
+
__instrumentationInitPromise = (async () => {
|
|
444
|
+
if (typeof _instrumentation.register === "function") {
|
|
445
|
+
await _instrumentation.register();
|
|
446
|
+
}
|
|
447
|
+
// Store the onRequestError handler on globalThis so it is visible to
|
|
448
|
+
// reportRequestError() (imported as _reportRequestError above) regardless
|
|
449
|
+
// of which Vite environment module graph it is called from. With
|
|
450
|
+
// @vitejs/plugin-rsc the RSC and SSR environments run in the same Node.js
|
|
451
|
+
// process and share globalThis. With @cloudflare/vite-plugin everything
|
|
452
|
+
// runs inside the Worker so globalThis is the Worker's global — also correct.
|
|
453
|
+
if (typeof _instrumentation.onRequestError === "function") {
|
|
454
|
+
globalThis.__VINEXT_onRequestErrorHandler__ = _instrumentation.onRequestError;
|
|
455
|
+
}
|
|
456
|
+
__instrumentationInitialized = true;
|
|
457
|
+
})();
|
|
458
|
+
return __instrumentationInitPromise;
|
|
459
|
+
}`
|
|
460
|
+
: ""}
|
|
415
461
|
|
|
416
462
|
const routes = [
|
|
417
463
|
${routeEntries.join(",\n")}
|
|
@@ -452,16 +498,26 @@ async function renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, req
|
|
|
452
498
|
|
|
453
499
|
// Resolve metadata and viewport from parent layouts so that not-found/error
|
|
454
500
|
// pages inherit title, description, OG tags etc. — matching Next.js behavior.
|
|
455
|
-
|
|
456
|
-
const
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
501
|
+
// Build the serial parent chain for layout metadata (same as buildPageElement).
|
|
502
|
+
const _filteredLayouts = layouts.filter(Boolean);
|
|
503
|
+
const _fallbackParams = opts?.matchedParams ?? route?.params ?? {};
|
|
504
|
+
const _layoutMetaPromises = [];
|
|
505
|
+
let _accumulatedMeta = Promise.resolve({});
|
|
506
|
+
for (let _i = 0; _i < _filteredLayouts.length; _i++) {
|
|
507
|
+
const _parentForLayout = _accumulatedMeta;
|
|
508
|
+
const _metaP = resolveModuleMetadata(_filteredLayouts[_i], _fallbackParams, undefined, _parentForLayout)
|
|
509
|
+
.catch((err) => { console.error("[vinext] Layout generateMetadata() failed:", err); return null; });
|
|
510
|
+
_layoutMetaPromises.push(_metaP);
|
|
511
|
+
_accumulatedMeta = _metaP.then(async (_r) =>
|
|
512
|
+
_r ? mergeMetadata([await _parentForLayout, _r]) : await _parentForLayout
|
|
513
|
+
);
|
|
464
514
|
}
|
|
515
|
+
const [_metaResults, _vpResults] = await Promise.all([
|
|
516
|
+
Promise.all(_layoutMetaPromises),
|
|
517
|
+
Promise.all(_filteredLayouts.map((mod) => resolveModuleViewport(mod, _fallbackParams).catch((err) => { console.error("[vinext] Layout generateViewport() failed:", err); return null; }))),
|
|
518
|
+
]);
|
|
519
|
+
const metadataList = _metaResults.filter(Boolean);
|
|
520
|
+
const viewportList = _vpResults.filter(Boolean);
|
|
465
521
|
const resolvedMetadata = metadataList.length > 0 ? mergeMetadata(metadataList) : null;
|
|
466
522
|
const resolvedViewport = mergeViewport(viewportList);
|
|
467
523
|
|
|
@@ -497,7 +553,8 @@ async function renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, req
|
|
|
497
553
|
element = createElement(LayoutSegmentProvider, { childSegments: _cs }, element);
|
|
498
554
|
}
|
|
499
555
|
}
|
|
500
|
-
${globalErrorVar
|
|
556
|
+
${globalErrorVar
|
|
557
|
+
? `
|
|
501
558
|
const _GlobalErrorComponent = ${globalErrorVar}.default;
|
|
502
559
|
if (_GlobalErrorComponent) {
|
|
503
560
|
element = createElement(ErrorBoundary, {
|
|
@@ -505,10 +562,21 @@ async function renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, req
|
|
|
505
562
|
children: element,
|
|
506
563
|
});
|
|
507
564
|
}
|
|
508
|
-
`
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
565
|
+
`
|
|
566
|
+
: ""}
|
|
567
|
+
const _pathname = new URL(request.url).pathname;
|
|
568
|
+
const onRenderError = createRscOnErrorHandler(
|
|
569
|
+
request,
|
|
570
|
+
_pathname,
|
|
571
|
+
route?.pattern ?? _pathname,
|
|
572
|
+
);
|
|
573
|
+
const rscStream = renderToReadableStream(element, { onError: onRenderError });
|
|
574
|
+
// Do NOT clear context here — the RSC stream is consumed lazily by the client.
|
|
575
|
+
// Clearing context now would cause async server components (e.g. NextIntlClientProviderServer)
|
|
576
|
+
// that run during stream consumption to see null headers/navigation context and throw,
|
|
577
|
+
// resulting in missing provider context on the client (e.g. next-intl useTranslations fails
|
|
578
|
+
// with "context from NextIntlClientProvider was not found").
|
|
579
|
+
// Context is cleared naturally when the ALS scope from runWithHeadersContext unwinds.
|
|
512
580
|
return new Response(rscStream, {
|
|
513
581
|
status: statusCode,
|
|
514
582
|
headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" },
|
|
@@ -524,7 +592,13 @@ async function renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, req
|
|
|
524
592
|
element = createElement(LayoutComponent, { children: element, params: _asyncFallbackParamsHtml });
|
|
525
593
|
}
|
|
526
594
|
}
|
|
527
|
-
const
|
|
595
|
+
const _pathname = new URL(request.url).pathname;
|
|
596
|
+
const onRenderError = createRscOnErrorHandler(
|
|
597
|
+
request,
|
|
598
|
+
_pathname,
|
|
599
|
+
route?.pattern ?? _pathname,
|
|
600
|
+
);
|
|
601
|
+
const rscStream = renderToReadableStream(element, { onError: onRenderError });
|
|
528
602
|
// Collect font data from RSC environment
|
|
529
603
|
const fontData = {
|
|
530
604
|
links: _getSSRFontLinks(),
|
|
@@ -560,6 +634,7 @@ async function renderErrorBoundaryPage(route, error, isRscRequest, request, matc
|
|
|
560
634
|
// Resolve the error boundary component: leaf error.tsx first, then walk per-layout
|
|
561
635
|
// errors from innermost to outermost (matching ancestor inheritance), then global-error.tsx.
|
|
562
636
|
let ErrorComponent = route?.error?.default ?? null;
|
|
637
|
+
let _isGlobalError = false;
|
|
563
638
|
if (!ErrorComponent && route?.errors) {
|
|
564
639
|
for (let i = route.errors.length - 1; i >= 0; i--) {
|
|
565
640
|
if (route.errors[i]?.default) {
|
|
@@ -568,7 +643,14 @@ async function renderErrorBoundaryPage(route, error, isRscRequest, request, matc
|
|
|
568
643
|
}
|
|
569
644
|
}
|
|
570
645
|
}
|
|
571
|
-
|
|
646
|
+
${globalErrorVar
|
|
647
|
+
? `
|
|
648
|
+
if (!ErrorComponent) {
|
|
649
|
+
ErrorComponent = ${globalErrorVar}?.default ?? null;
|
|
650
|
+
_isGlobalError = !!ErrorComponent;
|
|
651
|
+
}
|
|
652
|
+
`
|
|
653
|
+
: ""}
|
|
572
654
|
if (!ErrorComponent) return null;
|
|
573
655
|
|
|
574
656
|
const rawError = error instanceof Error ? error : new Error(String(error));
|
|
@@ -583,52 +665,76 @@ async function renderErrorBoundaryPage(route, error, isRscRequest, request, matc
|
|
|
583
665
|
let element = createElement(ErrorComponent, {
|
|
584
666
|
error: errorObj,
|
|
585
667
|
});
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
const
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
const
|
|
602
|
-
|
|
668
|
+
|
|
669
|
+
// global-error.tsx provides its own <html> and <body> (it replaces the root
|
|
670
|
+
// layout). Skip layout wrapping when rendering it to avoid double <html> tags.
|
|
671
|
+
if (!_isGlobalError) {
|
|
672
|
+
const layouts = route?.layouts ?? rootLayouts;
|
|
673
|
+
if (isRscRequest) {
|
|
674
|
+
// For RSC requests (client-side navigation), wrap with the same component
|
|
675
|
+
// wrappers that buildPageElement() uses (LayoutSegmentProvider, GlobalErrorBoundary).
|
|
676
|
+
// This ensures React can reconcile the tree without destroying the DOM.
|
|
677
|
+
// Same rationale as renderHTTPAccessFallbackPage — see comment there.
|
|
678
|
+
const _errTreePositions = route?.layoutTreePositions;
|
|
679
|
+
const _errRouteSegs = route?.routeSegments || [];
|
|
680
|
+
const _errParams = matchedParams ?? route?.params ?? {};
|
|
681
|
+
const _asyncErrParams = makeThenableParams(_errParams);
|
|
682
|
+
for (let i = layouts.length - 1; i >= 0; i--) {
|
|
683
|
+
const LayoutComponent = layouts[i]?.default;
|
|
684
|
+
if (LayoutComponent) {
|
|
685
|
+
element = createElement(LayoutComponent, { children: element, params: _asyncErrParams });
|
|
686
|
+
const _etp = _errTreePositions ? _errTreePositions[i] : 0;
|
|
687
|
+
const _ecs = __resolveChildSegments(_errRouteSegs, _etp, _errParams);
|
|
688
|
+
element = createElement(LayoutSegmentProvider, { childSegments: _ecs }, element);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
${globalErrorVar
|
|
692
|
+
? `
|
|
693
|
+
const _ErrGlobalComponent = ${globalErrorVar}.default;
|
|
694
|
+
if (_ErrGlobalComponent) {
|
|
695
|
+
element = createElement(ErrorBoundary, {
|
|
696
|
+
fallback: _ErrGlobalComponent,
|
|
697
|
+
children: element,
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
`
|
|
701
|
+
: ""}
|
|
702
|
+
} else {
|
|
703
|
+
// For HTML (full page load) responses, wrap with layouts only.
|
|
704
|
+
const _errParamsHtml = matchedParams ?? route?.params ?? {};
|
|
705
|
+
const _asyncErrParamsHtml = makeThenableParams(_errParamsHtml);
|
|
706
|
+
for (let i = layouts.length - 1; i >= 0; i--) {
|
|
707
|
+
const LayoutComponent = layouts[i]?.default;
|
|
708
|
+
if (LayoutComponent) {
|
|
709
|
+
element = createElement(LayoutComponent, { children: element, params: _asyncErrParamsHtml });
|
|
710
|
+
}
|
|
603
711
|
}
|
|
604
712
|
}
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
const _pathname = new URL(request.url).pathname;
|
|
716
|
+
const onRenderError = createRscOnErrorHandler(
|
|
717
|
+
request,
|
|
718
|
+
_pathname,
|
|
719
|
+
route?.pattern ?? _pathname,
|
|
720
|
+
);
|
|
721
|
+
|
|
722
|
+
if (isRscRequest) {
|
|
723
|
+
const rscStream = renderToReadableStream(element, { onError: onRenderError });
|
|
724
|
+
// Do NOT clear context here — the RSC stream is consumed lazily by the client.
|
|
725
|
+
// Clearing context now would cause async server components (e.g. NextIntlClientProviderServer)
|
|
726
|
+
// that run during stream consumption to see null headers/navigation context and throw,
|
|
727
|
+
// resulting in missing provider context on the client (e.g. next-intl useTranslations fails
|
|
728
|
+
// with "context from NextIntlClientProvider was not found").
|
|
729
|
+
// Context is cleared naturally when the ALS scope from runWithHeadersContext unwinds.
|
|
617
730
|
return new Response(rscStream, {
|
|
618
731
|
status: 200,
|
|
619
732
|
headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" },
|
|
620
733
|
});
|
|
621
734
|
}
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
const
|
|
625
|
-
for (let i = layouts.length - 1; i >= 0; i--) {
|
|
626
|
-
const LayoutComponent = layouts[i]?.default;
|
|
627
|
-
if (LayoutComponent) {
|
|
628
|
-
element = createElement(LayoutComponent, { children: element, params: _asyncErrParamsHtml });
|
|
629
|
-
}
|
|
630
|
-
}
|
|
631
|
-
const rscStream = renderToReadableStream(element, { onError: rscOnError });
|
|
735
|
+
|
|
736
|
+
// HTML (full page load) response — render through RSC → SSR pipeline
|
|
737
|
+
const rscStream = renderToReadableStream(element, { onError: onRenderError });
|
|
632
738
|
// Collect font data from RSC environment so error pages include font styles
|
|
633
739
|
const fontData = {
|
|
634
740
|
links: _getSSRFontLinks(),
|
|
@@ -654,16 +760,15 @@ function matchRoute(url, routes) {
|
|
|
654
760
|
// NOTE: Do NOT decodeURIComponent here. The caller is responsible for decoding
|
|
655
761
|
// the pathname exactly once at the request entry point. Decoding again here
|
|
656
762
|
// would cause inconsistent path matching between middleware and routing.
|
|
763
|
+
const urlParts = normalizedUrl.split("/").filter(Boolean);
|
|
657
764
|
for (const route of routes) {
|
|
658
|
-
const params = matchPattern(
|
|
765
|
+
const params = matchPattern(urlParts, route.patternParts);
|
|
659
766
|
if (params !== null) return { route, params };
|
|
660
767
|
}
|
|
661
768
|
return null;
|
|
662
769
|
}
|
|
663
770
|
|
|
664
|
-
function matchPattern(
|
|
665
|
-
const urlParts = url.split("/").filter(Boolean);
|
|
666
|
-
const patternParts = pattern.split("/").filter(Boolean);
|
|
771
|
+
function matchPattern(urlParts, patternParts) {
|
|
667
772
|
const params = Object.create(null);
|
|
668
773
|
for (let i = 0; i < patternParts.length; i++) {
|
|
669
774
|
const pp = patternParts[i];
|
|
@@ -703,6 +808,7 @@ for (let ri = 0; ri < routes.length; ri++) {
|
|
|
703
808
|
sourceRouteIndex: ri,
|
|
704
809
|
slotName,
|
|
705
810
|
targetPattern: intercept.targetPattern,
|
|
811
|
+
targetPatternParts: intercept.targetPattern.split("/").filter(Boolean),
|
|
706
812
|
page: intercept.page,
|
|
707
813
|
params: intercept.params,
|
|
708
814
|
});
|
|
@@ -715,8 +821,9 @@ for (let ri = 0; ri < routes.length; ri++) {
|
|
|
715
821
|
* Returns the match info or null.
|
|
716
822
|
*/
|
|
717
823
|
function findIntercept(pathname) {
|
|
824
|
+
const urlParts = pathname.split("/").filter(Boolean);
|
|
718
825
|
for (const entry of interceptLookup) {
|
|
719
|
-
const params = matchPattern(
|
|
826
|
+
const params = matchPattern(urlParts, entry.targetPatternParts);
|
|
720
827
|
if (params !== null) {
|
|
721
828
|
return { ...entry, matchedParams: params };
|
|
722
829
|
}
|
|
@@ -730,23 +837,79 @@ async function buildPageElement(route, params, opts, searchParams) {
|
|
|
730
837
|
return createElement("div", null, "Page has no default export");
|
|
731
838
|
}
|
|
732
839
|
|
|
733
|
-
// Resolve metadata and viewport from layouts and page
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
840
|
+
// Resolve metadata and viewport from layouts and page.
|
|
841
|
+
//
|
|
842
|
+
// generateMetadata() accepts a "parent" (Promise of ResolvedMetadata) as its
|
|
843
|
+
// second argument (Next.js 13+). The parent resolves to the accumulated
|
|
844
|
+
// merged metadata of all ancestor segments, enabling patterns like:
|
|
845
|
+
//
|
|
846
|
+
// const previousImages = (await parent).openGraph?.images ?? []
|
|
847
|
+
// return { openGraph: { images: ['/new-image.jpg', ...previousImages] } }
|
|
848
|
+
//
|
|
849
|
+
// Next.js uses an eager-execution-with-serial-resolution approach:
|
|
850
|
+
// all generateMetadata() calls are kicked off concurrently, but each
|
|
851
|
+
// segment's "parent" promise resolves only after the preceding segment's
|
|
852
|
+
// metadata is resolved and merged. This preserves concurrency for I/O-bound
|
|
853
|
+
// work while guaranteeing that parent data is available when needed.
|
|
854
|
+
//
|
|
855
|
+
// We build a chain: layoutParentPromises[0] = Promise.resolve({}) (no parent
|
|
856
|
+
// for root layout), layoutParentPromises[i+1] resolves to merge(layouts[0..i]),
|
|
857
|
+
// and pageParentPromise resolves to merge(all layouts).
|
|
858
|
+
//
|
|
859
|
+
// IMPORTANT: Layout metadata errors are swallowed (.catch(() => null)) because
|
|
860
|
+
// a layout's generateMetadata() failing should not crash the page.
|
|
861
|
+
// Page metadata errors are NOT swallowed — if the page's generateMetadata()
|
|
862
|
+
// throws, the error propagates out of buildPageElement() so the caller can
|
|
863
|
+
// route it to the nearest error.tsx boundary (or global-error.tsx).
|
|
864
|
+
const layoutMods = route.layouts.filter(Boolean);
|
|
865
|
+
|
|
866
|
+
// Build the parent promise chain and kick off metadata resolution in one pass.
|
|
867
|
+
// Each layout module is called exactly once. layoutMetaPromises[i] is the
|
|
868
|
+
// promise for layout[i]'s own metadata result.
|
|
869
|
+
//
|
|
870
|
+
// All calls are kicked off immediately (concurrent I/O), but each layout's
|
|
871
|
+
// "parent" promise only resolves after the preceding layout's metadata is done.
|
|
872
|
+
const layoutMetaPromises = [];
|
|
873
|
+
let accumulatedMetaPromise = Promise.resolve({});
|
|
874
|
+
for (let i = 0; i < layoutMods.length; i++) {
|
|
875
|
+
const parentForThisLayout = accumulatedMetaPromise;
|
|
876
|
+
// Kick off this layout's metadata resolution now (concurrent with others).
|
|
877
|
+
const metaPromise = resolveModuleMetadata(layoutMods[i], params, undefined, parentForThisLayout)
|
|
878
|
+
.catch((err) => { console.error("[vinext] Layout generateMetadata() failed:", err); return null; });
|
|
879
|
+
layoutMetaPromises.push(metaPromise);
|
|
880
|
+
// Advance accumulator: resolves to merged(layouts[0..i]) once layout[i] is done.
|
|
881
|
+
accumulatedMetaPromise = metaPromise.then(async (result) =>
|
|
882
|
+
result ? mergeMetadata([await parentForThisLayout, result]) : await parentForThisLayout
|
|
883
|
+
);
|
|
743
884
|
}
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
885
|
+
// Page's parent is the fully-accumulated layout metadata.
|
|
886
|
+
const pageParentPromise = accumulatedMetaPromise;
|
|
887
|
+
|
|
888
|
+
// Convert URLSearchParams → plain object so we can pass it to
|
|
889
|
+
// resolveModuleMetadata (which expects Record<string, string | string[]>).
|
|
890
|
+
// This same object is reused for pageProps.searchParams below.
|
|
891
|
+
const spObj = {};
|
|
892
|
+
let hasSearchParams = false;
|
|
893
|
+
if (searchParams && searchParams.forEach) {
|
|
894
|
+
searchParams.forEach(function(v, k) {
|
|
895
|
+
hasSearchParams = true;
|
|
896
|
+
if (k in spObj) {
|
|
897
|
+
spObj[k] = Array.isArray(spObj[k]) ? spObj[k].concat(v) : [spObj[k], v];
|
|
898
|
+
} else {
|
|
899
|
+
spObj[k] = v;
|
|
900
|
+
}
|
|
901
|
+
});
|
|
749
902
|
}
|
|
903
|
+
|
|
904
|
+
const [layoutMetaResults, layoutVpResults, pageMeta, pageVp] = await Promise.all([
|
|
905
|
+
Promise.all(layoutMetaPromises),
|
|
906
|
+
Promise.all(layoutMods.map((mod) => resolveModuleViewport(mod, params).catch((err) => { console.error("[vinext] Layout generateViewport() failed:", err); return null; }))),
|
|
907
|
+
route.page ? resolveModuleMetadata(route.page, params, spObj, pageParentPromise) : Promise.resolve(null),
|
|
908
|
+
route.page ? resolveModuleViewport(route.page, params) : Promise.resolve(null),
|
|
909
|
+
]);
|
|
910
|
+
|
|
911
|
+
const metadataList = [...layoutMetaResults.filter(Boolean), ...(pageMeta ? [pageMeta] : [])];
|
|
912
|
+
const viewportList = [...layoutVpResults.filter(Boolean), ...(pageVp ? [pageVp] : [])];
|
|
750
913
|
const resolvedMetadata = metadataList.length > 0 ? mergeMetadata(metadataList) : null;
|
|
751
914
|
const resolvedViewport = mergeViewport(viewportList);
|
|
752
915
|
|
|
@@ -757,17 +920,10 @@ async function buildPageElement(route, params, opts, searchParams) {
|
|
|
757
920
|
const asyncParams = makeThenableParams(params);
|
|
758
921
|
const pageProps = { params: asyncParams };
|
|
759
922
|
if (searchParams) {
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
if (k in spObj) {
|
|
765
|
-
// Multi-value: promote to array (Next.js returns string[] for duplicate keys)
|
|
766
|
-
spObj[k] = Array.isArray(spObj[k]) ? spObj[k].concat(v) : [spObj[k], v];
|
|
767
|
-
} else {
|
|
768
|
-
spObj[k] = v;
|
|
769
|
-
}
|
|
770
|
-
});
|
|
923
|
+
// Always provide searchParams prop when the URL object is available, even
|
|
924
|
+
// when the query string is empty -- pages that do "await searchParams" need
|
|
925
|
+
// it to be a thenable rather than undefined.
|
|
926
|
+
pageProps.searchParams = makeThenableParams(spObj);
|
|
771
927
|
// If the URL has query parameters, mark the page as dynamic.
|
|
772
928
|
// In Next.js, only accessing the searchParams prop signals dynamic usage,
|
|
773
929
|
// but a Proxy-based approach doesn't work here because React's RSC debug
|
|
@@ -777,7 +933,6 @@ async function buildPageElement(route, params, opts, searchParams) {
|
|
|
777
933
|
// approximation: pages with query params in the URL are almost always
|
|
778
934
|
// dynamic, and this avoids false positives from React internals.
|
|
779
935
|
if (hasSearchParams) markDynamicUsage();
|
|
780
|
-
pageProps.searchParams = makeThenableParams(spObj);
|
|
781
936
|
}
|
|
782
937
|
let element = createElement(PageComponent, pageProps);
|
|
783
938
|
|
|
@@ -946,8 +1101,16 @@ async function buildPageElement(route, params, opts, searchParams) {
|
|
|
946
1101
|
}
|
|
947
1102
|
|
|
948
1103
|
// Wrap with global error boundary if app/global-error.tsx exists.
|
|
949
|
-
// This
|
|
950
|
-
|
|
1104
|
+
// This must be present in both HTML and RSC paths so the component tree
|
|
1105
|
+
// structure matches — otherwise React reconciliation on client-side navigation
|
|
1106
|
+
// would see a mismatched tree and destroy/recreate the DOM.
|
|
1107
|
+
//
|
|
1108
|
+
// For RSC requests (client-side nav), this provides error recovery on the client.
|
|
1109
|
+
// For HTML requests (initial page load), the ErrorBoundary catches during SSR
|
|
1110
|
+
// but produces double <html>/<body> (root layout + global-error). The request
|
|
1111
|
+
// handler detects this via the rscOnError flag and re-renders without layouts.
|
|
1112
|
+
${globalErrorVar
|
|
1113
|
+
? `
|
|
951
1114
|
const GlobalErrorComponent = ${globalErrorVar}.default;
|
|
952
1115
|
if (GlobalErrorComponent) {
|
|
953
1116
|
element = createElement(ErrorBoundary, {
|
|
@@ -955,7 +1118,8 @@ async function buildPageElement(route, params, opts, searchParams) {
|
|
|
955
1118
|
children: element,
|
|
956
1119
|
});
|
|
957
1120
|
}
|
|
958
|
-
`
|
|
1121
|
+
`
|
|
1122
|
+
: ""}
|
|
959
1123
|
|
|
960
1124
|
return element;
|
|
961
1125
|
}
|
|
@@ -971,168 +1135,18 @@ const __allowedOrigins = ${JSON.stringify(allowedOrigins)};
|
|
|
971
1135
|
|
|
972
1136
|
${generateDevOriginCheckCode(config?.allowedDevOrigins)}
|
|
973
1137
|
|
|
974
|
-
// ──
|
|
975
|
-
// Matches Next.js behavior: compare the Origin header against the Host header.
|
|
976
|
-
// If they don't match, the request is rejected with 403 unless the origin is
|
|
977
|
-
// in the allowedOrigins list (from experimental.serverActions.allowedOrigins).
|
|
978
|
-
function __isOriginAllowed(origin, allowed) {
|
|
979
|
-
for (const pattern of allowed) {
|
|
980
|
-
if (pattern.startsWith("*.")) {
|
|
981
|
-
// Wildcard: *.example.com matches sub.example.com, a.b.example.com
|
|
982
|
-
const suffix = pattern.slice(1); // ".example.com"
|
|
983
|
-
if (origin === pattern.slice(2) || origin.endsWith(suffix)) return true;
|
|
984
|
-
} else if (origin === pattern) {
|
|
985
|
-
return true;
|
|
986
|
-
}
|
|
987
|
-
}
|
|
988
|
-
return false;
|
|
989
|
-
}
|
|
990
|
-
|
|
991
|
-
function __validateCsrfOrigin(request) {
|
|
992
|
-
const originHeader = request.headers.get("origin");
|
|
993
|
-
// If there's no Origin header, allow the request — same-origin requests
|
|
994
|
-
// from non-fetch navigations (e.g. SSR) may lack an Origin header.
|
|
995
|
-
// The x-rsc-action custom header already provides protection against simple
|
|
996
|
-
// form-based CSRF since custom headers can't be set by cross-origin forms.
|
|
997
|
-
if (!originHeader || originHeader === "null") return null;
|
|
998
|
-
|
|
999
|
-
let originHost;
|
|
1000
|
-
try {
|
|
1001
|
-
originHost = new URL(originHeader).host.toLowerCase();
|
|
1002
|
-
} catch {
|
|
1003
|
-
return new Response("Forbidden", { status: 403, headers: { "Content-Type": "text/plain" } });
|
|
1004
|
-
}
|
|
1005
|
-
|
|
1006
|
-
// Only use the Host header for origin comparison — never trust
|
|
1007
|
-
// X-Forwarded-Host here, since it can be freely set by the client
|
|
1008
|
-
// and would allow the check to be bypassed if it matched a spoofed
|
|
1009
|
-
// Origin. The prod server's resolveHost() handles trusted proxy
|
|
1010
|
-
// scenarios separately.
|
|
1011
|
-
const hostHeader = (
|
|
1012
|
-
request.headers.get("host") ||
|
|
1013
|
-
""
|
|
1014
|
-
).split(",")[0].trim().toLowerCase();
|
|
1015
|
-
|
|
1016
|
-
if (!hostHeader) return null;
|
|
1017
|
-
|
|
1018
|
-
// Same origin — allow
|
|
1019
|
-
if (originHost === hostHeader) return null;
|
|
1020
|
-
|
|
1021
|
-
// Check allowedOrigins from next.config.js
|
|
1022
|
-
if (__allowedOrigins.length > 0 && __isOriginAllowed(originHost, __allowedOrigins)) return null;
|
|
1023
|
-
|
|
1024
|
-
console.warn(
|
|
1025
|
-
\`[vinext] CSRF origin mismatch: origin "\${originHost}" does not match host "\${hostHeader}". Blocking server action request.\`
|
|
1026
|
-
);
|
|
1027
|
-
return new Response("Forbidden", { status: 403, headers: { "Content-Type": "text/plain" } });
|
|
1028
|
-
}
|
|
1029
|
-
|
|
1030
|
-
// ── ReDoS-safe regex compilation ────────────────────────────────────────
|
|
1138
|
+
// ── ReDoS-safe regex compilation (still needed for middleware matching) ──
|
|
1031
1139
|
${generateSafeRegExpCode("modern")}
|
|
1032
1140
|
|
|
1033
1141
|
// ── Path normalization ──────────────────────────────────────────────────
|
|
1034
1142
|
${generateNormalizePathCode("modern")}
|
|
1035
1143
|
|
|
1036
|
-
// ── Config pattern matching
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
.replace(/\\./g, "\\\\.")
|
|
1043
|
-
.replace(/:([\\w-]+)\\*(?:\\(([^)]+)\\))?/g, (_, name, c) => { paramNames.push(name); return c ? "(" + c + ")" : "(.*)"; })
|
|
1044
|
-
.replace(/:([\\w-]+)\\+(?:\\(([^)]+)\\))?/g, (_, name, c) => { paramNames.push(name); return c ? "(" + c + ")" : "(.+)"; })
|
|
1045
|
-
.replace(/:([\\w-]+)\\(([^)]+)\\)/g, (_, name, c) => { paramNames.push(name); return "(" + c + ")"; })
|
|
1046
|
-
.replace(/:([\\w-]+)/g, (_, name) => { paramNames.push(name); return "([^/]+)"; });
|
|
1047
|
-
const re = __safeRegExp("^" + regexStr + "$");
|
|
1048
|
-
if (!re) return null;
|
|
1049
|
-
const match = re.exec(pathname);
|
|
1050
|
-
if (!match) return null;
|
|
1051
|
-
const params = Object.create(null);
|
|
1052
|
-
for (let i = 0; i < paramNames.length; i++) params[paramNames[i]] = match[i + 1] || "";
|
|
1053
|
-
return params;
|
|
1054
|
-
} catch { /* fall through */ }
|
|
1055
|
-
}
|
|
1056
|
-
const catchAllMatch = pattern.match(/:([\\w-]+)(\\*|\\+)$/);
|
|
1057
|
-
if (catchAllMatch) {
|
|
1058
|
-
const prefix = pattern.slice(0, pattern.lastIndexOf(":"));
|
|
1059
|
-
const paramName = catchAllMatch[1];
|
|
1060
|
-
const isPlus = catchAllMatch[2] === "+";
|
|
1061
|
-
if (!pathname.startsWith(prefix.replace(/\\/$/, ""))) return null;
|
|
1062
|
-
const rest = pathname.slice(prefix.replace(/\\/$/, "").length);
|
|
1063
|
-
if (isPlus && (!rest || rest === "/")) return null;
|
|
1064
|
-
let restValue = rest.startsWith("/") ? rest.slice(1) : rest;
|
|
1065
|
-
// NOTE: Do NOT decodeURIComponent here. The pathname is already decoded at
|
|
1066
|
-
// the request entry point. Decoding again would produce incorrect param values.
|
|
1067
|
-
return { [paramName]: restValue };
|
|
1068
|
-
}
|
|
1069
|
-
const parts = pattern.split("/");
|
|
1070
|
-
const pathParts = pathname.split("/");
|
|
1071
|
-
if (parts.length !== pathParts.length) return null;
|
|
1072
|
-
const params = Object.create(null);
|
|
1073
|
-
for (let i = 0; i < parts.length; i++) {
|
|
1074
|
-
if (parts[i].startsWith(":")) params[parts[i].slice(1)] = pathParts[i];
|
|
1075
|
-
else if (parts[i] !== pathParts[i]) return null;
|
|
1076
|
-
}
|
|
1077
|
-
return params;
|
|
1078
|
-
}
|
|
1079
|
-
|
|
1080
|
-
function __parseCookies(cookieHeader) {
|
|
1081
|
-
if (!cookieHeader) return {};
|
|
1082
|
-
const cookies = {};
|
|
1083
|
-
for (const part of cookieHeader.split(";")) {
|
|
1084
|
-
const eq = part.indexOf("=");
|
|
1085
|
-
if (eq === -1) continue;
|
|
1086
|
-
const key = part.slice(0, eq).trim();
|
|
1087
|
-
const value = part.slice(eq + 1).trim();
|
|
1088
|
-
if (key) cookies[key] = value;
|
|
1089
|
-
}
|
|
1090
|
-
return cookies;
|
|
1091
|
-
}
|
|
1092
|
-
|
|
1093
|
-
function __checkSingleCondition(condition, ctx) {
|
|
1094
|
-
switch (condition.type) {
|
|
1095
|
-
case "header": {
|
|
1096
|
-
const v = ctx.headers.get(condition.key);
|
|
1097
|
-
if (v === null) return false;
|
|
1098
|
-
if (condition.value !== undefined) { const re = __safeRegExp(condition.value); return re ? re.test(v) : v === condition.value; }
|
|
1099
|
-
return true;
|
|
1100
|
-
}
|
|
1101
|
-
case "cookie": {
|
|
1102
|
-
const v = ctx.cookies[condition.key];
|
|
1103
|
-
if (v === undefined) return false;
|
|
1104
|
-
if (condition.value !== undefined) { const re = __safeRegExp(condition.value); return re ? re.test(v) : v === condition.value; }
|
|
1105
|
-
return true;
|
|
1106
|
-
}
|
|
1107
|
-
case "query": {
|
|
1108
|
-
const v = ctx.query.get(condition.key);
|
|
1109
|
-
if (v === null) return false;
|
|
1110
|
-
if (condition.value !== undefined) { const re = __safeRegExp(condition.value); return re ? re.test(v) : v === condition.value; }
|
|
1111
|
-
return true;
|
|
1112
|
-
}
|
|
1113
|
-
case "host": {
|
|
1114
|
-
if (condition.value !== undefined) { const re = __safeRegExp(condition.value); return re ? re.test(ctx.host) : ctx.host === condition.value; }
|
|
1115
|
-
return ctx.host === condition.key;
|
|
1116
|
-
}
|
|
1117
|
-
default: return false;
|
|
1118
|
-
}
|
|
1119
|
-
}
|
|
1120
|
-
|
|
1121
|
-
function __checkHasConditions(has, missing, ctx) {
|
|
1122
|
-
if (has) { for (const c of has) { if (!__checkSingleCondition(c, ctx)) return false; } }
|
|
1123
|
-
if (missing) { for (const c of missing) { if (__checkSingleCondition(c, ctx)) return false; } }
|
|
1124
|
-
return true;
|
|
1125
|
-
}
|
|
1126
|
-
|
|
1127
|
-
function __buildRequestContext(request) {
|
|
1128
|
-
const url = new URL(request.url);
|
|
1129
|
-
return {
|
|
1130
|
-
headers: request.headers,
|
|
1131
|
-
cookies: __parseCookies(request.headers.get("cookie")),
|
|
1132
|
-
query: url.searchParams,
|
|
1133
|
-
host: request.headers.get("host") || url.host,
|
|
1134
|
-
};
|
|
1135
|
-
}
|
|
1144
|
+
// ── Config pattern matching, redirects, rewrites, headers, CSRF validation,
|
|
1145
|
+
// external URL proxy, cookie parsing, and request context are imported from
|
|
1146
|
+
// config-matchers.ts and request-pipeline.ts (see import statements above).
|
|
1147
|
+
// This eliminates ~250 lines of duplicated inline code and ensures the
|
|
1148
|
+
// single-pass tokenizer in config-matchers.ts is used consistently
|
|
1149
|
+
// (fixing the chained .replace() divergence flagged by CodeQL).
|
|
1136
1150
|
|
|
1137
1151
|
/**
|
|
1138
1152
|
* Build a request context from the live ALS HeadersContext, which reflects
|
|
@@ -1143,7 +1157,7 @@ function __buildRequestContext(request) {
|
|
|
1143
1157
|
function __buildPostMwRequestContext(request) {
|
|
1144
1158
|
const url = new URL(request.url);
|
|
1145
1159
|
const ctx = getHeadersContext();
|
|
1146
|
-
if (!ctx) return
|
|
1160
|
+
if (!ctx) return requestContextFromRequest(request);
|
|
1147
1161
|
// ctx.cookies is a Map<string, string> (HeadersContext), but RequestContext
|
|
1148
1162
|
// requires a plain Record<string, string> for has/missing cookie evaluation
|
|
1149
1163
|
// (config-matchers.ts uses obj[key] not Map.get()). Convert here.
|
|
@@ -1156,51 +1170,14 @@ function __buildPostMwRequestContext(request) {
|
|
|
1156
1170
|
};
|
|
1157
1171
|
}
|
|
1158
1172
|
|
|
1159
|
-
function __sanitizeDestination(dest) {
|
|
1160
|
-
if (dest.startsWith("http://") || dest.startsWith("https://")) return dest;
|
|
1161
|
-
dest = dest.replace(/^[\\\\/]+/, "/");
|
|
1162
|
-
return dest;
|
|
1163
|
-
}
|
|
1164
|
-
|
|
1165
|
-
function __applyConfigRedirects(pathname, ctx) {
|
|
1166
|
-
for (const rule of __configRedirects) {
|
|
1167
|
-
const params = __matchConfigPattern(pathname, rule.source);
|
|
1168
|
-
if (params) {
|
|
1169
|
-
if (ctx && (rule.has || rule.missing)) { if (!__checkHasConditions(rule.has, rule.missing, ctx)) continue; }
|
|
1170
|
-
let dest = rule.destination;
|
|
1171
|
-
for (const [key, value] of Object.entries(params)) { dest = dest.replace(":" + key + "*", value); dest = dest.replace(":" + key + "+", value); dest = dest.replace(":" + key, value); }
|
|
1172
|
-
dest = __sanitizeDestination(dest);
|
|
1173
|
-
return { destination: dest, permanent: rule.permanent };
|
|
1174
|
-
}
|
|
1175
|
-
}
|
|
1176
|
-
return null;
|
|
1177
|
-
}
|
|
1178
|
-
|
|
1179
|
-
function __applyConfigRewrites(pathname, rules, ctx) {
|
|
1180
|
-
for (const rule of rules) {
|
|
1181
|
-
const params = __matchConfigPattern(pathname, rule.source);
|
|
1182
|
-
if (params) {
|
|
1183
|
-
if (ctx && (rule.has || rule.missing)) { if (!__checkHasConditions(rule.has, rule.missing, ctx)) continue; }
|
|
1184
|
-
let dest = rule.destination;
|
|
1185
|
-
for (const [key, value] of Object.entries(params)) { dest = dest.replace(":" + key + "*", value); dest = dest.replace(":" + key + "+", value); dest = dest.replace(":" + key, value); }
|
|
1186
|
-
dest = __sanitizeDestination(dest);
|
|
1187
|
-
return dest;
|
|
1188
|
-
}
|
|
1189
|
-
}
|
|
1190
|
-
return null;
|
|
1191
|
-
}
|
|
1192
|
-
|
|
1193
|
-
function __isExternalUrl(url) {
|
|
1194
|
-
return /^[a-z][a-z0-9+.-]*:/i.test(url) || url.startsWith("//");
|
|
1195
|
-
}
|
|
1196
|
-
|
|
1197
1173
|
/**
|
|
1198
|
-
* Maximum server-action request body size
|
|
1199
|
-
*
|
|
1174
|
+
* Maximum server-action request body size.
|
|
1175
|
+
* Configurable via experimental.serverActions.bodySizeLimit in next.config.
|
|
1176
|
+
* Defaults to 1MB, matching the Next.js default.
|
|
1200
1177
|
* @see https://nextjs.org/docs/app/api-reference/config/next-config-js/serverActions#bodysizelimit
|
|
1201
1178
|
* Prevents unbounded request body buffering.
|
|
1202
1179
|
*/
|
|
1203
|
-
var __MAX_ACTION_BODY_SIZE =
|
|
1180
|
+
var __MAX_ACTION_BODY_SIZE = ${JSON.stringify(bodySizeLimit)};
|
|
1204
1181
|
|
|
1205
1182
|
/**
|
|
1206
1183
|
* Read a request body as text with a size limit.
|
|
@@ -1259,71 +1236,13 @@ async function __readFormDataWithLimit(request, maxBytes) {
|
|
|
1259
1236
|
return new Response(combined, { headers: { "Content-Type": contentType } }).formData();
|
|
1260
1237
|
}
|
|
1261
1238
|
|
|
1262
|
-
const __hopByHopHeaders = new Set(["connection","keep-alive","proxy-authenticate","proxy-authorization","te","trailers","transfer-encoding","upgrade"]);
|
|
1263
|
-
|
|
1264
|
-
async function __proxyExternalRequest(request, externalUrl) {
|
|
1265
|
-
const originalUrl = new URL(request.url);
|
|
1266
|
-
const targetUrl = new URL(externalUrl);
|
|
1267
|
-
for (const [key, value] of originalUrl.searchParams) {
|
|
1268
|
-
if (!targetUrl.searchParams.has(key)) targetUrl.searchParams.set(key, value);
|
|
1269
|
-
}
|
|
1270
|
-
const headers = new Headers(request.headers);
|
|
1271
|
-
headers.set("host", targetUrl.host);
|
|
1272
|
-
headers.delete("connection");
|
|
1273
|
-
for (const key of [...headers.keys()]) {
|
|
1274
|
-
if (key.startsWith("x-middleware-")) headers.delete(key);
|
|
1275
|
-
}
|
|
1276
|
-
const method = request.method;
|
|
1277
|
-
const hasBody = method !== "GET" && method !== "HEAD";
|
|
1278
|
-
const init = { method, headers, redirect: "manual", signal: AbortSignal.timeout(30000) };
|
|
1279
|
-
if (hasBody && request.body) { init.body = request.body; init.duplex = "half"; }
|
|
1280
|
-
let upstream;
|
|
1281
|
-
try { upstream = await fetch(targetUrl.href, init); }
|
|
1282
|
-
catch (e) {
|
|
1283
|
-
if (e && e.name === "TimeoutError") return new Response("Gateway Timeout", { status: 504 });
|
|
1284
|
-
console.error("[vinext] External rewrite proxy error:", e); return new Response("Bad Gateway", { status: 502 });
|
|
1285
|
-
}
|
|
1286
|
-
const respHeaders = new Headers();
|
|
1287
|
-
// Node.js fetch() auto-decompresses response bodies, while Workers fetch()
|
|
1288
|
-
// preserves wire encoding. Only strip encoding/length on Node.js to avoid
|
|
1289
|
-
// double-decompression errors without breaking Workers parity.
|
|
1290
|
-
const __isNodeRuntime = typeof process !== "undefined" && !!(process.versions && process.versions.node);
|
|
1291
|
-
upstream.headers.forEach(function(value, key) {
|
|
1292
|
-
var lower = key.toLowerCase();
|
|
1293
|
-
if (__hopByHopHeaders.has(lower)) return;
|
|
1294
|
-
if (__isNodeRuntime && (lower === "content-encoding" || lower === "content-length")) return;
|
|
1295
|
-
respHeaders.append(key, value);
|
|
1296
|
-
});
|
|
1297
|
-
return new Response(upstream.body, { status: upstream.status, statusText: upstream.statusText, headers: respHeaders });
|
|
1298
|
-
}
|
|
1299
|
-
|
|
1300
|
-
function __applyConfigHeaders(pathname, ctx) {
|
|
1301
|
-
const result = [];
|
|
1302
|
-
for (const rule of __configHeaders) {
|
|
1303
|
-
const groups = [];
|
|
1304
|
-
const withPlaceholders = rule.source.replace(/\\(([^)]+)\\)/g, (_, inner) => {
|
|
1305
|
-
groups.push(inner);
|
|
1306
|
-
return "___GROUP_" + (groups.length - 1) + "___";
|
|
1307
|
-
});
|
|
1308
|
-
const escaped = withPlaceholders
|
|
1309
|
-
.replace(/\\./g, "\\\\.")
|
|
1310
|
-
.replace(/\\+/g, "\\\\+")
|
|
1311
|
-
.replace(/\\?/g, "\\\\?")
|
|
1312
|
-
.replace(/\\*/g, ".*")
|
|
1313
|
-
.replace(/:[\\w-]+/g, "[^/]+")
|
|
1314
|
-
.replace(/___GROUP_(\\d+)___/g, (_, idx) => "(" + groups[Number(idx)] + ")");
|
|
1315
|
-
const sourceRegex = __safeRegExp("^" + escaped + "$");
|
|
1316
|
-
if (sourceRegex && sourceRegex.test(pathname)) {
|
|
1317
|
-
if (ctx && (rule.has || rule.missing)) {
|
|
1318
|
-
if (!__checkHasConditions(rule.has, rule.missing, ctx)) continue;
|
|
1319
|
-
}
|
|
1320
|
-
result.push(...rule.headers);
|
|
1321
|
-
}
|
|
1322
|
-
}
|
|
1323
|
-
return result;
|
|
1324
|
-
}
|
|
1325
|
-
|
|
1326
1239
|
export default async function handler(request) {
|
|
1240
|
+
${instrumentationPath
|
|
1241
|
+
? `// Ensure instrumentation.register() has run before handling the first request.
|
|
1242
|
+
// This is a no-op after the first call (guarded by __instrumentationInitialized).
|
|
1243
|
+
await __ensureInstrumentation();
|
|
1244
|
+
`
|
|
1245
|
+
: ""}
|
|
1327
1246
|
// Wrap the entire request in nested AsyncLocalStorage.run() scopes to ensure
|
|
1328
1247
|
// per-request isolation for all state modules. Each runWith*() creates an
|
|
1329
1248
|
// ALS scope that propagates through all async continuations (including RSC
|
|
@@ -1335,7 +1254,7 @@ export default async function handler(request) {
|
|
|
1335
1254
|
_runWithCacheState(() =>
|
|
1336
1255
|
_runWithPrivateCache(() =>
|
|
1337
1256
|
runWithFetchCache(async () => {
|
|
1338
|
-
const __reqCtx =
|
|
1257
|
+
const __reqCtx = requestContextFromRequest(request);
|
|
1339
1258
|
// Per-request container for middleware state. Passed into
|
|
1340
1259
|
// _handleRequest which fills in .headers and .status;
|
|
1341
1260
|
// avoids module-level variables that race on Workers.
|
|
@@ -1350,7 +1269,7 @@ export default async function handler(request) {
|
|
|
1350
1269
|
let pathname;
|
|
1351
1270
|
try { pathname = __normalizePath(decodeURIComponent(url.pathname)); } catch { pathname = url.pathname; }
|
|
1352
1271
|
${bp ? `if (pathname.startsWith(${JSON.stringify(bp)})) pathname = pathname.slice(${JSON.stringify(bp)}.length) || "/";` : ""}
|
|
1353
|
-
const extraHeaders =
|
|
1272
|
+
const extraHeaders = matchHeaders(pathname, __configHeaders, __reqCtx);
|
|
1354
1273
|
for (const h of extraHeaders) {
|
|
1355
1274
|
// Use append() for headers where multiple values must coexist
|
|
1356
1275
|
// (Vary, Set-Cookie). Using set() on these would destroy
|
|
@@ -1384,22 +1303,18 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
|
|
|
1384
1303
|
// Format: "handlerStart,compileMs,renderMs" - all as integers (ms). Dev-only.
|
|
1385
1304
|
const url = new URL(request.url);
|
|
1386
1305
|
|
|
1387
|
-
// ── Cross-origin request protection
|
|
1306
|
+
// ── Cross-origin request protection (dev only) ─────────────────────
|
|
1388
1307
|
// Block requests from non-localhost origins to prevent data exfiltration.
|
|
1389
|
-
|
|
1390
|
-
if (
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
// Paths like //example.com/ would be redirected to //example.com by the
|
|
1394
|
-
// trailing-slash normalizer, which browsers interpret as http://example.com.
|
|
1395
|
-
// Backslashes are equivalent to forward slashes in the URL spec
|
|
1396
|
-
// (e.g. /\\evil.com is treated as //evil.com by browsers and the URL constructor).
|
|
1397
|
-
// Next.js returns 404 for these paths. Check the RAW pathname before
|
|
1398
|
-
// normalization so the guard fires before normalizePath collapses //.
|
|
1399
|
-
if (url.pathname.replaceAll("\\\\", "/").startsWith("//")) {
|
|
1400
|
-
return new Response("404 Not Found", { status: 404 });
|
|
1308
|
+
// Skipped in production — Vite replaces NODE_ENV at build time.
|
|
1309
|
+
if (process.env.NODE_ENV !== "production") {
|
|
1310
|
+
const __originBlock = __validateDevRequestOrigin(request);
|
|
1311
|
+
if (__originBlock) return __originBlock;
|
|
1401
1312
|
}
|
|
1402
1313
|
|
|
1314
|
+
// Guard against protocol-relative URL open redirects (see request-pipeline.ts).
|
|
1315
|
+
const __protoGuard = guardProtocolRelativeUrl(url.pathname);
|
|
1316
|
+
if (__protoGuard) return __protoGuard;
|
|
1317
|
+
|
|
1403
1318
|
// Decode percent-encoding and normalize pathname to canonical form.
|
|
1404
1319
|
// decodeURIComponent prevents /%61dmin from bypassing /admin matchers.
|
|
1405
1320
|
// __normalizePath collapses //foo///bar → /foo/bar, resolves . and .. segments.
|
|
@@ -1409,22 +1324,16 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
|
|
|
1409
1324
|
}
|
|
1410
1325
|
let pathname = __normalizePath(decodedUrlPathname);
|
|
1411
1326
|
|
|
1412
|
-
${bp
|
|
1327
|
+
${bp
|
|
1328
|
+
? `
|
|
1413
1329
|
// Strip basePath prefix
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
` : ""}
|
|
1330
|
+
pathname = stripBasePath(pathname, __basePath);
|
|
1331
|
+
`
|
|
1332
|
+
: ""}
|
|
1418
1333
|
|
|
1419
1334
|
// Trailing slash normalization (redirect to canonical form)
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
if (__trailingSlash && !hasTrailing && !pathname.endsWith(".rsc")) {
|
|
1423
|
-
return Response.redirect(new URL(__basePath + pathname + "/" + url.search, request.url), 308);
|
|
1424
|
-
} else if (!__trailingSlash && hasTrailing) {
|
|
1425
|
-
return Response.redirect(new URL(__basePath + pathname.replace(/\\/+$/, "") + url.search, request.url), 308);
|
|
1426
|
-
}
|
|
1427
|
-
}
|
|
1335
|
+
const __tsRedirect = normalizeTrailingSlash(pathname, __basePath, __trailingSlash, url.search);
|
|
1336
|
+
if (__tsRedirect) return __tsRedirect;
|
|
1428
1337
|
|
|
1429
1338
|
// ── Apply redirects from next.config.js ───────────────────────────────
|
|
1430
1339
|
if (__configRedirects.length) {
|
|
@@ -1432,10 +1341,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
|
|
|
1432
1341
|
// arrive as /some/path.rsc but redirect patterns are defined without it (e.g.
|
|
1433
1342
|
// /some/path). Without this, soft-nav fetches bypass all config redirects.
|
|
1434
1343
|
const __redirPathname = pathname.endsWith(".rsc") ? pathname.slice(0, -4) : pathname;
|
|
1435
|
-
const __redir =
|
|
1344
|
+
const __redir = matchRedirect(__redirPathname, __configRedirects, __reqCtx);
|
|
1436
1345
|
if (__redir) {
|
|
1437
|
-
const __redirDest =
|
|
1438
|
-
__basePath &&
|
|
1346
|
+
const __redirDest = sanitizeDestination(
|
|
1347
|
+
__basePath &&
|
|
1348
|
+
!isExternalUrl(__redir.destination) &&
|
|
1349
|
+
!hasBasePath(__redir.destination, __basePath)
|
|
1439
1350
|
? __basePath + __redir.destination
|
|
1440
1351
|
: __redir.destination
|
|
1441
1352
|
);
|
|
@@ -1453,7 +1364,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
|
|
|
1453
1364
|
// _mwCtx (per-request container) so handler() can merge them into
|
|
1454
1365
|
// every response path without module-level state that races on Workers.
|
|
1455
1366
|
|
|
1456
|
-
${middlewarePath
|
|
1367
|
+
${middlewarePath
|
|
1368
|
+
? `
|
|
1457
1369
|
// Run proxy/middleware if present and path matches.
|
|
1458
1370
|
// Validate exports match the file type (proxy.ts vs middleware.ts), matching Next.js behavior.
|
|
1459
1371
|
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/proxy-missing-export/proxy-missing-export.test.ts
|
|
@@ -1533,30 +1445,27 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
|
|
|
1533
1445
|
// internal routing signals and must never reach clients.
|
|
1534
1446
|
if (_mwCtx.headers) {
|
|
1535
1447
|
applyMiddlewareRequestHeaders(_mwCtx.headers);
|
|
1536
|
-
|
|
1537
|
-
if (key.startsWith("x-middleware-")) {
|
|
1538
|
-
_mwCtx.headers.delete(key);
|
|
1539
|
-
}
|
|
1540
|
-
}
|
|
1448
|
+
processMiddlewareHeaders(_mwCtx.headers);
|
|
1541
1449
|
}
|
|
1542
|
-
`
|
|
1450
|
+
`
|
|
1451
|
+
: ""}
|
|
1543
1452
|
|
|
1544
1453
|
// Build post-middleware request context for afterFiles/fallback rewrites.
|
|
1545
1454
|
// These run after middleware in the App Router execution order and should
|
|
1546
1455
|
// evaluate has/missing conditions against middleware-modified headers.
|
|
1547
|
-
// When no middleware is present, this falls back to
|
|
1456
|
+
// When no middleware is present, this falls back to requestContextFromRequest.
|
|
1548
1457
|
const __postMwReqCtx = __buildPostMwRequestContext(request);
|
|
1549
1458
|
|
|
1550
1459
|
// ── Apply beforeFiles rewrites from next.config.js ────────────────────
|
|
1551
1460
|
// In App Router execution order, beforeFiles runs after middleware so that
|
|
1552
1461
|
// has/missing conditions can evaluate against middleware-modified headers.
|
|
1553
1462
|
if (__configRewrites.beforeFiles && __configRewrites.beforeFiles.length) {
|
|
1554
|
-
const __rewritten =
|
|
1463
|
+
const __rewritten = matchRewrite(cleanPathname, __configRewrites.beforeFiles, __postMwReqCtx);
|
|
1555
1464
|
if (__rewritten) {
|
|
1556
|
-
if (
|
|
1465
|
+
if (isExternalUrl(__rewritten)) {
|
|
1557
1466
|
setHeadersContext(null);
|
|
1558
1467
|
setNavigationContext(null);
|
|
1559
|
-
return
|
|
1468
|
+
return proxyExternalRequest(request, __rewritten);
|
|
1560
1469
|
}
|
|
1561
1470
|
cleanPathname = __rewritten;
|
|
1562
1471
|
}
|
|
@@ -1564,26 +1473,42 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
|
|
|
1564
1473
|
|
|
1565
1474
|
// ── Image optimization passthrough (dev mode — no transformation) ───────
|
|
1566
1475
|
if (cleanPathname === "/_vinext/image") {
|
|
1567
|
-
const
|
|
1568
|
-
|
|
1569
|
-
// /\\evil.com as protocol-relative (//evil.com), bypassing the // check.
|
|
1570
|
-
const __imgUrl = __rawImgUrl?.replaceAll("\\\\", "/") ?? null;
|
|
1571
|
-
// Allowlist: must start with "/" but not "//" — blocks absolute URLs,
|
|
1572
|
-
// protocol-relative, backslash variants, and exotic schemes.
|
|
1573
|
-
if (!__imgUrl || !__imgUrl.startsWith("/") || __imgUrl.startsWith("//")) {
|
|
1574
|
-
return new Response(!__rawImgUrl ? "Missing url parameter" : "Only relative URLs allowed", { status: 400 });
|
|
1575
|
-
}
|
|
1576
|
-
// Validate the constructed URL's origin hasn't changed (defense in depth).
|
|
1577
|
-
const __resolvedImg = new URL(__imgUrl, request.url);
|
|
1578
|
-
if (__resolvedImg.origin !== url.origin) {
|
|
1579
|
-
return new Response("Only relative URLs allowed", { status: 400 });
|
|
1580
|
-
}
|
|
1476
|
+
const __imgResult = validateImageUrl(url.searchParams.get("url"), request.url);
|
|
1477
|
+
if (__imgResult instanceof Response) return __imgResult;
|
|
1581
1478
|
// In dev, redirect to the original asset URL so Vite's static serving handles it.
|
|
1582
|
-
return Response.redirect(
|
|
1479
|
+
return Response.redirect(new URL(__imgResult, url.origin).href, 302);
|
|
1583
1480
|
}
|
|
1584
1481
|
|
|
1585
1482
|
// Handle metadata routes (sitemap.xml, robots.txt, manifest.webmanifest, etc.)
|
|
1586
1483
|
for (const metaRoute of metadataRoutes) {
|
|
1484
|
+
// generateSitemaps() support — paginated sitemaps at /{prefix}/sitemap/{id}.xml
|
|
1485
|
+
// When a sitemap module exports generateSitemaps, the base URL (e.g. /products/sitemap.xml)
|
|
1486
|
+
// is no longer served. Instead, individual sitemaps are served at /products/sitemap/{id}.xml.
|
|
1487
|
+
if (
|
|
1488
|
+
metaRoute.type === "sitemap" &&
|
|
1489
|
+
metaRoute.isDynamic &&
|
|
1490
|
+
typeof metaRoute.module.generateSitemaps === "function"
|
|
1491
|
+
) {
|
|
1492
|
+
const sitemapPrefix = metaRoute.servedUrl.slice(0, -4); // strip ".xml"
|
|
1493
|
+
// Match exactly /{prefix}/{id}.xml — one segment only (no slashes in id)
|
|
1494
|
+
if (cleanPathname.startsWith(sitemapPrefix + "/") && cleanPathname.endsWith(".xml")) {
|
|
1495
|
+
const rawId = cleanPathname.slice(sitemapPrefix.length + 1, -4);
|
|
1496
|
+
if (rawId.includes("/")) continue; // multi-segment — not a paginated sitemap
|
|
1497
|
+
const sitemaps = await metaRoute.module.generateSitemaps();
|
|
1498
|
+
const matched = sitemaps.find(function(s) { return String(s.id) === rawId; });
|
|
1499
|
+
if (!matched) return new Response("Not Found", { status: 404 });
|
|
1500
|
+
// Pass the original typed id from generateSitemaps() so numeric IDs stay numeric.
|
|
1501
|
+
// TODO: wrap with makeThenableParams-style Promise when upgrading to Next.js 16
|
|
1502
|
+
// full-Promise param semantics (id becomes Promise<string> in v16).
|
|
1503
|
+
const result = await metaRoute.module.default({ id: matched.id });
|
|
1504
|
+
if (result instanceof Response) return result;
|
|
1505
|
+
return new Response(sitemapToXml(result), {
|
|
1506
|
+
headers: { "Content-Type": metaRoute.contentType },
|
|
1507
|
+
});
|
|
1508
|
+
}
|
|
1509
|
+
// Skip — the base servedUrl is not served when generateSitemaps exists
|
|
1510
|
+
continue;
|
|
1511
|
+
}
|
|
1587
1512
|
if (cleanPathname === metaRoute.servedUrl) {
|
|
1588
1513
|
if (metaRoute.isDynamic) {
|
|
1589
1514
|
// Dynamic metadata route — call the default export and serialize
|
|
@@ -1635,7 +1560,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
|
|
|
1635
1560
|
// ── CSRF protection ─────────────────────────────────────────────────
|
|
1636
1561
|
// Verify that the Origin header matches the Host header to prevent
|
|
1637
1562
|
// cross-site request forgery, matching Next.js server action behavior.
|
|
1638
|
-
const csrfResponse =
|
|
1563
|
+
const csrfResponse = validateCsrfOrigin(request, __allowedOrigins);
|
|
1639
1564
|
if (csrfResponse) return csrfResponse;
|
|
1640
1565
|
|
|
1641
1566
|
// ── Body size limit ─────────────────────────────────────────────────
|
|
@@ -1744,16 +1669,23 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
|
|
|
1744
1669
|
element = createElement("div", null, "Page not found");
|
|
1745
1670
|
}
|
|
1746
1671
|
|
|
1672
|
+
const onRenderError = createRscOnErrorHandler(
|
|
1673
|
+
request,
|
|
1674
|
+
cleanPathname,
|
|
1675
|
+
match ? match.route.pattern : cleanPathname,
|
|
1676
|
+
);
|
|
1747
1677
|
const rscStream = renderToReadableStream(
|
|
1748
1678
|
{ root: element, returnValue },
|
|
1749
|
-
{ temporaryReferences, onError:
|
|
1679
|
+
{ temporaryReferences, onError: onRenderError },
|
|
1750
1680
|
);
|
|
1751
1681
|
|
|
1752
|
-
// Collect cookies set during the action
|
|
1682
|
+
// Collect cookies set during the action synchronously (before stream is consumed).
|
|
1683
|
+
// Do NOT clear headers/navigation context here — the RSC stream is consumed lazily
|
|
1684
|
+
// by the client, and async server components that run during consumption need the
|
|
1685
|
+
// context to still be live. The AsyncLocalStorage scope from runWithHeadersContext
|
|
1686
|
+
// handles cleanup naturally when all async continuations complete.
|
|
1753
1687
|
const actionPendingCookies = getAndClearPendingCookies();
|
|
1754
1688
|
const actionDraftCookie = getDraftModeCookieHeader();
|
|
1755
|
-
setHeadersContext(null);
|
|
1756
|
-
setNavigationContext(null);
|
|
1757
1689
|
|
|
1758
1690
|
const actionHeaders = { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" };
|
|
1759
1691
|
const actionResponse = new Response(rscStream, { headers: actionHeaders });
|
|
@@ -1787,12 +1719,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
|
|
|
1787
1719
|
|
|
1788
1720
|
// ── Apply afterFiles rewrites from next.config.js ──────────────────────
|
|
1789
1721
|
if (__configRewrites.afterFiles && __configRewrites.afterFiles.length) {
|
|
1790
|
-
const __afterRewritten =
|
|
1722
|
+
const __afterRewritten = matchRewrite(cleanPathname, __configRewrites.afterFiles, __postMwReqCtx);
|
|
1791
1723
|
if (__afterRewritten) {
|
|
1792
|
-
if (
|
|
1724
|
+
if (isExternalUrl(__afterRewritten)) {
|
|
1793
1725
|
setHeadersContext(null);
|
|
1794
1726
|
setNavigationContext(null);
|
|
1795
|
-
return
|
|
1727
|
+
return proxyExternalRequest(request, __afterRewritten);
|
|
1796
1728
|
}
|
|
1797
1729
|
cleanPathname = __afterRewritten;
|
|
1798
1730
|
}
|
|
@@ -1802,12 +1734,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
|
|
|
1802
1734
|
|
|
1803
1735
|
// ── Fallback rewrites from next.config.js (if no route matched) ───────
|
|
1804
1736
|
if (!match && __configRewrites.fallback && __configRewrites.fallback.length) {
|
|
1805
|
-
const __fallbackRewritten =
|
|
1737
|
+
const __fallbackRewritten = matchRewrite(cleanPathname, __configRewrites.fallback, __postMwReqCtx);
|
|
1806
1738
|
if (__fallbackRewritten) {
|
|
1807
|
-
if (
|
|
1739
|
+
if (isExternalUrl(__fallbackRewritten)) {
|
|
1808
1740
|
setHeadersContext(null);
|
|
1809
1741
|
setNavigationContext(null);
|
|
1810
|
-
return
|
|
1742
|
+
return proxyExternalRequest(request, __fallbackRewritten);
|
|
1811
1743
|
}
|
|
1812
1744
|
cleanPathname = __fallbackRewritten;
|
|
1813
1745
|
match = matchRoute(cleanPathname, routes);
|
|
@@ -1870,10 +1802,17 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
|
|
|
1870
1802
|
if (typeof handlerFn === "function") {
|
|
1871
1803
|
try {
|
|
1872
1804
|
const response = await handlerFn(request, { params });
|
|
1805
|
+
const dynamicUsedInHandler = consumeDynamicUsage();
|
|
1873
1806
|
|
|
1874
1807
|
// Apply Cache-Control from route segment config (export const revalidate = N).
|
|
1875
|
-
//
|
|
1876
|
-
|
|
1808
|
+
// Runtime request APIs like headers() / cookies() make GET handlers dynamic,
|
|
1809
|
+
// so only attach ISR headers when the handler stayed static.
|
|
1810
|
+
if (
|
|
1811
|
+
revalidateSeconds !== null &&
|
|
1812
|
+
!dynamicUsedInHandler &&
|
|
1813
|
+
(method === "GET" || isAutoHead) &&
|
|
1814
|
+
!response.headers.has("cache-control")
|
|
1815
|
+
) {
|
|
1877
1816
|
response.headers.set("cache-control", "s-maxage=" + revalidateSeconds + ", stale-while-revalidate");
|
|
1878
1817
|
}
|
|
1879
1818
|
|
|
@@ -2065,9 +2004,16 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
|
|
|
2065
2004
|
interceptPage: intercept.page,
|
|
2066
2005
|
interceptParams: intercept.matchedParams,
|
|
2067
2006
|
}, url.searchParams);
|
|
2068
|
-
const
|
|
2069
|
-
|
|
2070
|
-
|
|
2007
|
+
const interceptOnError = createRscOnErrorHandler(
|
|
2008
|
+
request,
|
|
2009
|
+
cleanPathname,
|
|
2010
|
+
sourceRoute.pattern,
|
|
2011
|
+
);
|
|
2012
|
+
const interceptStream = renderToReadableStream(interceptElement, { onError: interceptOnError });
|
|
2013
|
+
// Do NOT clear headers/navigation context here — the RSC stream is consumed lazily
|
|
2014
|
+
// by the client, and async server components that run during consumption need the
|
|
2015
|
+
// context to still be live. The AsyncLocalStorage scope from runWithHeadersContext
|
|
2016
|
+
// handles cleanup naturally when all async continuations complete.
|
|
2071
2017
|
return new Response(interceptStream, {
|
|
2072
2018
|
headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" },
|
|
2073
2019
|
});
|
|
@@ -2254,8 +2200,19 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
|
|
|
2254
2200
|
// Mark end of compile phase: route matching, middleware, tree building are done.
|
|
2255
2201
|
if (process.env.NODE_ENV !== "production") __compileEnd = performance.now();
|
|
2256
2202
|
|
|
2257
|
-
// Render to RSC stream
|
|
2258
|
-
|
|
2203
|
+
// Render to RSC stream.
|
|
2204
|
+
// Track non-navigation RSC errors so we can detect when the in-tree global
|
|
2205
|
+
// ErrorBoundary catches during SSR (producing double <html>/<body>) and
|
|
2206
|
+
// re-render with renderErrorBoundaryPage (which skips layouts for global-error).
|
|
2207
|
+
let _rscErrorForRerender = null;
|
|
2208
|
+
const _baseOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern);
|
|
2209
|
+
const onRenderError = function(error, requestInfo, errorContext) {
|
|
2210
|
+
if (!(error && typeof error === "object" && "digest" in error)) {
|
|
2211
|
+
_rscErrorForRerender = error;
|
|
2212
|
+
}
|
|
2213
|
+
return _baseOnError(error, requestInfo, errorContext);
|
|
2214
|
+
};
|
|
2215
|
+
const rscStream = renderToReadableStream(element, { onError: onRenderError });
|
|
2259
2216
|
|
|
2260
2217
|
if (isRscRequest) {
|
|
2261
2218
|
// Direct RSC stream response (for client-side navigation)
|
|
@@ -2359,6 +2316,22 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
|
|
|
2359
2316
|
throw ssrErr;
|
|
2360
2317
|
}
|
|
2361
2318
|
|
|
2319
|
+
// If an RSC error was caught by the in-tree global ErrorBoundary during SSR,
|
|
2320
|
+
// the HTML output has double <html>/<body> (root layout + global-error.tsx).
|
|
2321
|
+
// Discard it and re-render using renderErrorBoundaryPage which skips layouts
|
|
2322
|
+
// when the error falls through to global-error.tsx.
|
|
2323
|
+
${globalErrorVar
|
|
2324
|
+
? `
|
|
2325
|
+
if (_rscErrorForRerender && !isRscRequest) {
|
|
2326
|
+
const _hasLocalBoundary = !!(route?.error?.default) || !!(route?.errors && route.errors.some(function(e) { return e?.default; }));
|
|
2327
|
+
if (!_hasLocalBoundary) {
|
|
2328
|
+
const cleanResp = await renderErrorBoundaryPage(route, _rscErrorForRerender, false, request, params);
|
|
2329
|
+
if (cleanResp) return cleanResp;
|
|
2330
|
+
}
|
|
2331
|
+
}
|
|
2332
|
+
`
|
|
2333
|
+
: ""}
|
|
2334
|
+
|
|
2362
2335
|
// Check for draftMode Set-Cookie header (from draftMode().enable()/disable())
|
|
2363
2336
|
const draftCookie = getDraftModeCookieHeader();
|
|
2364
2337
|
|
|
@@ -2482,771 +2455,4 @@ if (import.meta.hot) {
|
|
|
2482
2455
|
}
|
|
2483
2456
|
`;
|
|
2484
2457
|
}
|
|
2485
|
-
|
|
2486
|
-
* Generate the virtual SSR entry module.
|
|
2487
|
-
*
|
|
2488
|
-
* This runs in the `ssr` Vite environment. It receives an RSC stream,
|
|
2489
|
-
* deserializes it to a React tree, and renders to HTML.
|
|
2490
|
-
*/
|
|
2491
|
-
export function generateSsrEntry() {
|
|
2492
|
-
return `
|
|
2493
|
-
import { createFromReadableStream } from "@vitejs/plugin-rsc/ssr";
|
|
2494
|
-
import { renderToReadableStream, renderToStaticMarkup } from "react-dom/server.edge";
|
|
2495
|
-
import { setNavigationContext, ServerInsertedHTMLContext } from "next/navigation";
|
|
2496
|
-
import { runWithNavigationContext as _runWithNavCtx } from "vinext/navigation-state";
|
|
2497
|
-
import { safeJsonStringify } from "vinext/html";
|
|
2498
|
-
import { createElement as _ssrCE } from "react";
|
|
2499
|
-
|
|
2500
|
-
/**
|
|
2501
|
-
* Collect all chunks from a ReadableStream into an array of text strings.
|
|
2502
|
-
* Used to capture the RSC payload for embedding in HTML.
|
|
2503
|
-
* The RSC flight protocol is text-based (line-delimited key:value pairs),
|
|
2504
|
-
* so we decode to text strings instead of byte arrays — this is dramatically
|
|
2505
|
-
* more compact when JSON-serialized into inline <script> tags.
|
|
2506
|
-
*/
|
|
2507
|
-
async function collectStreamChunks(stream) {
|
|
2508
|
-
const reader = stream.getReader();
|
|
2509
|
-
const decoder = new TextDecoder();
|
|
2510
|
-
const chunks = [];
|
|
2511
|
-
while (true) {
|
|
2512
|
-
const { done, value } = await reader.read();
|
|
2513
|
-
if (done) break;
|
|
2514
|
-
// Decode Uint8Array to text string for compact JSON serialization
|
|
2515
|
-
chunks.push(decoder.decode(value, { stream: true }));
|
|
2516
|
-
}
|
|
2517
|
-
return chunks;
|
|
2518
|
-
}
|
|
2519
|
-
|
|
2520
|
-
// React 19 dev-mode workaround (see VinextFlightRoot in handleSsr):
|
|
2521
|
-
//
|
|
2522
|
-
// In dev, Flight error decoding in react-server-dom-webpack/client.edge
|
|
2523
|
-
// can hit resolveErrorDev() which (via React's dev error stack capture)
|
|
2524
|
-
// expects a non-null hooks dispatcher.
|
|
2525
|
-
//
|
|
2526
|
-
// Vinext previously called createFromReadableStream() outside of any React render.
|
|
2527
|
-
// When an RSC stream contains an error, dev-mode decoding could crash with:
|
|
2528
|
-
// - "Invalid hook call"
|
|
2529
|
-
// - "Cannot read properties of null (reading 'useContext')"
|
|
2530
|
-
//
|
|
2531
|
-
// Fix: call createFromReadableStream() lazily inside a React component render.
|
|
2532
|
-
// This mirrors Next.js behavior and ensures the dispatcher is set.
|
|
2533
|
-
|
|
2534
|
-
/**
|
|
2535
|
-
* Create a TransformStream that appends RSC chunks as inline <script> tags
|
|
2536
|
-
* to the HTML stream. This allows progressive hydration — the browser receives
|
|
2537
|
-
* RSC data incrementally as Suspense boundaries resolve, rather than waiting
|
|
2538
|
-
* for the entire RSC payload before hydration can begin.
|
|
2539
|
-
*
|
|
2540
|
-
* Each chunk is written as:
|
|
2541
|
-
* <script>self.__VINEXT_RSC_CHUNKS__=self.__VINEXT_RSC_CHUNKS__||[];self.__VINEXT_RSC_CHUNKS__.push("...")</script>
|
|
2542
|
-
*
|
|
2543
|
-
* Chunks are embedded as text strings (not byte arrays) since the RSC flight
|
|
2544
|
-
* protocol is text-based. The browser entry encodes them back to Uint8Array.
|
|
2545
|
-
* This is ~3x more compact than the previous byte-array format.
|
|
2546
|
-
*/
|
|
2547
|
-
function createRscEmbedTransform(embedStream) {
|
|
2548
|
-
const reader = embedStream.getReader();
|
|
2549
|
-
const _decoder = new TextDecoder();
|
|
2550
|
-
let done = false;
|
|
2551
|
-
let pendingChunks = [];
|
|
2552
|
-
let reading = false;
|
|
2553
|
-
|
|
2554
|
-
// Fix invalid preload "as" values in RSC Flight hint lines before
|
|
2555
|
-
// they reach the client. React Flight emits HL hints with
|
|
2556
|
-
// as="stylesheet" for CSS, but the HTML spec requires as="style"
|
|
2557
|
-
// for <link rel="preload">. The fixPreloadAs() below only fixes the
|
|
2558
|
-
// server-rendered HTML stream; this fixes the raw Flight data that
|
|
2559
|
-
// gets embedded as __VINEXT_RSC_CHUNKS__ and processed client-side.
|
|
2560
|
-
function fixFlightHints(text) {
|
|
2561
|
-
// Flight hint format: <id>:HL["url","stylesheet"] or with options
|
|
2562
|
-
return text.replace(/(\\d+:HL\\[.*?),"stylesheet"(\\]|,)/g, '$1,"style"$2');
|
|
2563
|
-
}
|
|
2564
|
-
|
|
2565
|
-
// Start reading RSC chunks in the background, accumulating them as text strings.
|
|
2566
|
-
// The RSC flight protocol is text-based, so decoding to strings and embedding
|
|
2567
|
-
// as JSON strings is ~3x more compact than the byte-array format.
|
|
2568
|
-
async function pumpReader() {
|
|
2569
|
-
if (reading) return;
|
|
2570
|
-
reading = true;
|
|
2571
|
-
try {
|
|
2572
|
-
while (true) {
|
|
2573
|
-
const result = await reader.read();
|
|
2574
|
-
if (result.done) {
|
|
2575
|
-
done = true;
|
|
2576
|
-
break;
|
|
2577
|
-
}
|
|
2578
|
-
const text = _decoder.decode(result.value, { stream: true });
|
|
2579
|
-
pendingChunks.push(fixFlightHints(text));
|
|
2580
|
-
}
|
|
2581
|
-
} catch (err) {
|
|
2582
|
-
if (process.env.NODE_ENV !== "production") {
|
|
2583
|
-
console.warn("[vinext] RSC embed stream read error:", err);
|
|
2584
|
-
}
|
|
2585
|
-
done = true;
|
|
2586
|
-
}
|
|
2587
|
-
reading = false;
|
|
2588
|
-
}
|
|
2589
|
-
|
|
2590
|
-
// Fire off the background reader immediately
|
|
2591
|
-
const pumpPromise = pumpReader();
|
|
2592
|
-
|
|
2593
|
-
return {
|
|
2594
|
-
/**
|
|
2595
|
-
* Flush any accumulated RSC chunks as <script> tags.
|
|
2596
|
-
* Called after each HTML chunk is enqueued.
|
|
2597
|
-
*/
|
|
2598
|
-
flush() {
|
|
2599
|
-
if (pendingChunks.length === 0) return "";
|
|
2600
|
-
const chunks = pendingChunks;
|
|
2601
|
-
pendingChunks = [];
|
|
2602
|
-
let scripts = "";
|
|
2603
|
-
for (const chunk of chunks) {
|
|
2604
|
-
scripts += "<script>self.__VINEXT_RSC_CHUNKS__=self.__VINEXT_RSC_CHUNKS__||[];self.__VINEXT_RSC_CHUNKS__.push(" + safeJsonStringify(chunk) + ")</script>";
|
|
2605
|
-
}
|
|
2606
|
-
return scripts;
|
|
2607
|
-
},
|
|
2608
|
-
|
|
2609
|
-
/**
|
|
2610
|
-
* Wait for the RSC stream to fully complete and return any final
|
|
2611
|
-
* script tags plus the closing signal.
|
|
2612
|
-
*/
|
|
2613
|
-
async finalize() {
|
|
2614
|
-
await pumpPromise;
|
|
2615
|
-
let scripts = this.flush();
|
|
2616
|
-
// Signal that all RSC chunks have been sent.
|
|
2617
|
-
// Params are already embedded in <head> — no need to include here.
|
|
2618
|
-
scripts += "<script>self.__VINEXT_RSC_DONE__=true</script>";
|
|
2619
|
-
return scripts;
|
|
2620
|
-
},
|
|
2621
|
-
};
|
|
2622
|
-
}
|
|
2623
|
-
|
|
2624
|
-
/**
|
|
2625
|
-
* Render the RSC stream to HTML.
|
|
2626
|
-
*
|
|
2627
|
-
* @param rscStream - The RSC payload stream from the RSC environment
|
|
2628
|
-
* @param navContext - Navigation context for client component SSR hooks.
|
|
2629
|
-
* "use client" components like those using usePathname() need the current
|
|
2630
|
-
* request URL during SSR, and they run in this SSR environment (separate
|
|
2631
|
-
* from the RSC environment where the context was originally set).
|
|
2632
|
-
* @param fontData - Font links and styles collected from the RSC environment.
|
|
2633
|
-
* Fonts are loaded during RSC rendering (when layout calls Geist() etc.),
|
|
2634
|
-
* and the data needs to be passed to SSR since they're separate module instances.
|
|
2635
|
-
*/
|
|
2636
|
-
export async function handleSsr(rscStream, navContext, fontData) {
|
|
2637
|
-
// Wrap in a navigation ALS scope for per-request isolation in the SSR
|
|
2638
|
-
// environment. The SSR environment has separate module instances from RSC,
|
|
2639
|
-
// so it needs its own ALS scope.
|
|
2640
|
-
return _runWithNavCtx(async () => {
|
|
2641
|
-
// Set navigation context so hooks like usePathname() work during SSR
|
|
2642
|
-
// of "use client" components
|
|
2643
|
-
if (navContext) {
|
|
2644
|
-
setNavigationContext(navContext);
|
|
2645
|
-
}
|
|
2646
|
-
|
|
2647
|
-
// Clear any stale callbacks from previous requests
|
|
2648
|
-
const { clearServerInsertedHTML, flushServerInsertedHTML, useServerInsertedHTML: _addInsertedHTML } = await import("next/navigation");
|
|
2649
|
-
clearServerInsertedHTML();
|
|
2650
|
-
|
|
2651
|
-
try {
|
|
2652
|
-
// Tee the RSC stream - one for SSR rendering, one for embedding in HTML.
|
|
2653
|
-
// This ensures the browser uses the SAME RSC payload for hydration that
|
|
2654
|
-
// was used to generate the HTML, avoiding hydration mismatches (React #418).
|
|
2655
|
-
const [ssrStream, embedStream] = rscStream.tee();
|
|
2656
|
-
|
|
2657
|
-
// Create the progressive RSC embed helper — it reads the embed stream
|
|
2658
|
-
// in the background and provides script tags to inject into the HTML stream.
|
|
2659
|
-
const rscEmbed = createRscEmbedTransform(embedStream);
|
|
2660
|
-
|
|
2661
|
-
// Deserialize RSC stream back to React VDOM.
|
|
2662
|
-
// IMPORTANT: Do NOT await this — createFromReadableStream returns a thenable
|
|
2663
|
-
// that React's renderToReadableStream can consume progressively. By passing
|
|
2664
|
-
// the unresolved thenable, React will render Suspense fallbacks (loading.tsx)
|
|
2665
|
-
// immediately in the HTML shell, then stream in resolved content as RSC
|
|
2666
|
-
// chunks arrive. Awaiting here would block until all async server components
|
|
2667
|
-
// complete, collapsing the streaming behavior.
|
|
2668
|
-
// Lazily create the Flight root inside render so React's hook dispatcher is set
|
|
2669
|
-
// (avoids React 19 dev-mode resolveErrorDev() crash). VinextFlightRoot returns
|
|
2670
|
-
// a thenable (not a ReactNode), which React 19 consumes via its internal
|
|
2671
|
-
// thenable-as-child suspend/resume behavior. This matches Next.js's approach.
|
|
2672
|
-
let flightRoot;
|
|
2673
|
-
function VinextFlightRoot() {
|
|
2674
|
-
if (!flightRoot) {
|
|
2675
|
-
flightRoot = createFromReadableStream(ssrStream);
|
|
2676
|
-
}
|
|
2677
|
-
return flightRoot;
|
|
2678
|
-
}
|
|
2679
|
-
const root = _ssrCE(VinextFlightRoot);
|
|
2680
|
-
|
|
2681
|
-
// Wrap with ServerInsertedHTMLContext.Provider so libraries that use
|
|
2682
|
-
// useContext(ServerInsertedHTMLContext) (Apollo Client, styled-components,
|
|
2683
|
-
// etc.) get a working callback registration function during SSR.
|
|
2684
|
-
// The provider value is useServerInsertedHTML — same function that direct
|
|
2685
|
-
// callers use — so both paths push to the same ALS-backed callback array.
|
|
2686
|
-
const ssrRoot = ServerInsertedHTMLContext
|
|
2687
|
-
? _ssrCE(ServerInsertedHTMLContext.Provider, { value: _addInsertedHTML }, root)
|
|
2688
|
-
: root;
|
|
2689
|
-
|
|
2690
|
-
// Get the bootstrap script content for the browser entry
|
|
2691
|
-
const bootstrapScriptContent =
|
|
2692
|
-
await import.meta.viteRsc.loadBootstrapScriptContent("index");
|
|
2693
|
-
|
|
2694
|
-
// djb2 hash for digest generation in the SSR environment.
|
|
2695
|
-
// Matches the RSC environment's __errorDigest function.
|
|
2696
|
-
function ssrErrorDigest(str) {
|
|
2697
|
-
let hash = 5381;
|
|
2698
|
-
for (let i = str.length - 1; i >= 0; i--) {
|
|
2699
|
-
hash = (hash * 33) ^ str.charCodeAt(i);
|
|
2700
|
-
}
|
|
2701
|
-
return (hash >>> 0).toString();
|
|
2702
|
-
}
|
|
2703
|
-
|
|
2704
|
-
// Render HTML (streaming SSR)
|
|
2705
|
-
// useServerInsertedHTML callbacks are registered during this render.
|
|
2706
|
-
// The onError callback preserves the digest for Next.js navigation errors
|
|
2707
|
-
// (redirect, notFound, forbidden, unauthorized) thrown inside Suspense
|
|
2708
|
-
// boundaries during RSC streaming. Without this, React's default onError
|
|
2709
|
-
// returns undefined and the digest is lost in the $RX() call, preventing
|
|
2710
|
-
// client-side error boundaries from identifying the error type.
|
|
2711
|
-
// In production, non-navigation errors also get a digest hash so they
|
|
2712
|
-
// can be correlated with server logs without leaking details to clients.
|
|
2713
|
-
const htmlStream = await renderToReadableStream(ssrRoot, {
|
|
2714
|
-
bootstrapScriptContent,
|
|
2715
|
-
onError(error) {
|
|
2716
|
-
if (error && typeof error === "object" && "digest" in error) {
|
|
2717
|
-
return String(error.digest);
|
|
2718
|
-
}
|
|
2719
|
-
// In production, generate a digest hash for non-navigation errors
|
|
2720
|
-
if (process.env.NODE_ENV === "production" && error) {
|
|
2721
|
-
const msg = error instanceof Error ? error.message : String(error);
|
|
2722
|
-
const stack = error instanceof Error ? (error.stack || "") : "";
|
|
2723
|
-
return ssrErrorDigest(msg + stack);
|
|
2724
|
-
}
|
|
2725
|
-
return undefined;
|
|
2726
|
-
},
|
|
2727
|
-
});
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
// Flush useServerInsertedHTML callbacks (CSS-in-JS style injection)
|
|
2731
|
-
const insertedElements = flushServerInsertedHTML();
|
|
2732
|
-
|
|
2733
|
-
// Render the inserted elements to HTML strings
|
|
2734
|
-
const { Fragment } = await import("react");
|
|
2735
|
-
let insertedHTML = "";
|
|
2736
|
-
for (const el of insertedElements) {
|
|
2737
|
-
try {
|
|
2738
|
-
insertedHTML += renderToStaticMarkup(_ssrCE(Fragment, null, el));
|
|
2739
|
-
} catch {
|
|
2740
|
-
// Skip elements that can't be rendered
|
|
2741
|
-
}
|
|
2742
|
-
}
|
|
2743
|
-
|
|
2744
|
-
// Escape HTML attribute values (defense-in-depth for font URLs/types).
|
|
2745
|
-
function _escAttr(s) { return s.replace(/&/g, "&").replace(/"/g, """); }
|
|
2746
|
-
|
|
2747
|
-
// Build font HTML from data passed from RSC environment
|
|
2748
|
-
// (Fonts are loaded during RSC rendering, and RSC/SSR are separate module instances)
|
|
2749
|
-
let fontHTML = "";
|
|
2750
|
-
if (fontData) {
|
|
2751
|
-
if (fontData.links && fontData.links.length > 0) {
|
|
2752
|
-
for (const url of fontData.links) {
|
|
2753
|
-
fontHTML += '<link rel="stylesheet" href="' + _escAttr(url) + '" />\\n';
|
|
2754
|
-
}
|
|
2755
|
-
}
|
|
2756
|
-
// Emit <link rel="preload"> for local font files
|
|
2757
|
-
if (fontData.preloads && fontData.preloads.length > 0) {
|
|
2758
|
-
for (const preload of fontData.preloads) {
|
|
2759
|
-
fontHTML += '<link rel="preload" href="' + _escAttr(preload.href) + '" as="font" type="' + _escAttr(preload.type) + '" crossorigin />\\n';
|
|
2760
|
-
}
|
|
2761
|
-
}
|
|
2762
|
-
if (fontData.styles && fontData.styles.length > 0) {
|
|
2763
|
-
fontHTML += '<style data-vinext-fonts>' + fontData.styles.join("\\n") + '</style>\\n';
|
|
2764
|
-
}
|
|
2765
|
-
}
|
|
2766
|
-
|
|
2767
|
-
// Extract client entry module URL from bootstrapScriptContent to emit
|
|
2768
|
-
// a <link rel="modulepreload"> hint. The RSC plugin formats bootstrap
|
|
2769
|
-
// content as: import("URL") — we extract the URL so the browser can
|
|
2770
|
-
// speculatively fetch and parse the JS module while still processing
|
|
2771
|
-
// the HTML body, instead of waiting until it reaches the inline script.
|
|
2772
|
-
let modulePreloadHTML = "";
|
|
2773
|
-
if (bootstrapScriptContent) {
|
|
2774
|
-
const m = bootstrapScriptContent.match(/import\\("([^"]+)"\\)/);
|
|
2775
|
-
if (m && m[1]) {
|
|
2776
|
-
modulePreloadHTML = '<link rel="modulepreload" href="' + _escAttr(m[1]) + '" />\\n';
|
|
2777
|
-
}
|
|
2778
|
-
}
|
|
2779
|
-
|
|
2780
|
-
// Head-injected HTML: server-inserted HTML, font HTML, route params,
|
|
2781
|
-
// and modulepreload hints.
|
|
2782
|
-
// RSC payload is now embedded progressively via script tags in the body stream.
|
|
2783
|
-
// Params are embedded eagerly in <head> so they're available before client
|
|
2784
|
-
// hydration starts, avoiding the need for polling on the client.
|
|
2785
|
-
const paramsScript = '<script>self.__VINEXT_RSC_PARAMS__=' + safeJsonStringify(navContext?.params || {}) + '</script>';
|
|
2786
|
-
const injectHTML = paramsScript + modulePreloadHTML + insertedHTML + fontHTML;
|
|
2787
|
-
|
|
2788
|
-
// Inject the collected HTML before </head> and progressively embed RSC
|
|
2789
|
-
// chunks as script tags throughout the HTML body stream.
|
|
2790
|
-
const decoder = new TextDecoder();
|
|
2791
|
-
const encoder = new TextEncoder();
|
|
2792
|
-
let injected = false;
|
|
2793
|
-
|
|
2794
|
-
// Fix invalid preload "as" values in server-rendered HTML.
|
|
2795
|
-
// React Fizz emits <link rel="preload" as="stylesheet"> for CSS,
|
|
2796
|
-
// but the HTML spec requires as="style" for <link rel="preload">.
|
|
2797
|
-
// Note: fixFlightHints() in createRscEmbedTransform handles the
|
|
2798
|
-
// complementary case — fixing the raw Flight stream data before
|
|
2799
|
-
// it's embedded as __VINEXT_RSC_CHUNKS__ for client-side processing.
|
|
2800
|
-
// See: https://html.spec.whatwg.org/multipage/links.html#link-type-preload
|
|
2801
|
-
function fixPreloadAs(html) {
|
|
2802
|
-
// Match <link ...rel="preload"... as="stylesheet"...> in any attribute order
|
|
2803
|
-
return html.replace(/<link(?=[^>]*\\srel="preload")[^>]*>/g, function(tag) {
|
|
2804
|
-
return tag.replace(' as="stylesheet"', ' as="style"');
|
|
2805
|
-
});
|
|
2806
|
-
}
|
|
2807
|
-
|
|
2808
|
-
// Tick-buffered RSC script injection.
|
|
2809
|
-
//
|
|
2810
|
-
// React's renderToReadableStream (Fizz) flushes chunks synchronously
|
|
2811
|
-
// within one microtask — all chunks from a single flushCompletedQueues
|
|
2812
|
-
// call arrive in the same macrotask. We buffer HTML chunks as they
|
|
2813
|
-
// arrive, then use setTimeout(0) to defer emitting them plus any
|
|
2814
|
-
// accumulated RSC scripts to the next macrotask. This guarantees we
|
|
2815
|
-
// never inject <script> tags between partial HTML chunks (which would
|
|
2816
|
-
// corrupt split elements like "<linearGradi" + "ent>"), while still
|
|
2817
|
-
// delivering RSC data progressively as Suspense boundaries resolve.
|
|
2818
|
-
//
|
|
2819
|
-
// Reference: rsc-html-stream by Devon Govett (credited by Next.js)
|
|
2820
|
-
// https://github.com/devongovett/rsc-html-stream
|
|
2821
|
-
let buffered = [];
|
|
2822
|
-
let timeoutId = null;
|
|
2823
|
-
|
|
2824
|
-
const transform = new TransformStream({
|
|
2825
|
-
transform(chunk, controller) {
|
|
2826
|
-
const text = decoder.decode(chunk, { stream: true });
|
|
2827
|
-
const fixed = fixPreloadAs(text);
|
|
2828
|
-
buffered.push(fixed);
|
|
2829
|
-
|
|
2830
|
-
if (timeoutId !== null) return;
|
|
2831
|
-
|
|
2832
|
-
timeoutId = setTimeout(() => {
|
|
2833
|
-
// Flush all buffered HTML chunks from this React flush cycle
|
|
2834
|
-
for (const buf of buffered) {
|
|
2835
|
-
if (!injected) {
|
|
2836
|
-
const headEnd = buf.indexOf("</head>");
|
|
2837
|
-
if (headEnd !== -1) {
|
|
2838
|
-
const before = buf.slice(0, headEnd);
|
|
2839
|
-
const after = buf.slice(headEnd);
|
|
2840
|
-
controller.enqueue(encoder.encode(before + injectHTML + after));
|
|
2841
|
-
injected = true;
|
|
2842
|
-
continue;
|
|
2843
|
-
}
|
|
2844
|
-
}
|
|
2845
|
-
controller.enqueue(encoder.encode(buf));
|
|
2846
|
-
}
|
|
2847
|
-
buffered = [];
|
|
2848
|
-
|
|
2849
|
-
// Now safe to inject any accumulated RSC scripts — we're between
|
|
2850
|
-
// React flush cycles, so no partial HTML chunks can follow until
|
|
2851
|
-
// the next macrotask.
|
|
2852
|
-
const rscScripts = rscEmbed.flush();
|
|
2853
|
-
if (rscScripts) {
|
|
2854
|
-
controller.enqueue(encoder.encode(rscScripts));
|
|
2855
|
-
}
|
|
2856
|
-
|
|
2857
|
-
timeoutId = null;
|
|
2858
|
-
}, 0);
|
|
2859
|
-
},
|
|
2860
|
-
async flush(controller) {
|
|
2861
|
-
// Cancel any pending setTimeout callback — flush() drains
|
|
2862
|
-
// everything itself, so the callback would be a no-op but
|
|
2863
|
-
// cancelling makes the code obviously correct.
|
|
2864
|
-
if (timeoutId !== null) {
|
|
2865
|
-
clearTimeout(timeoutId);
|
|
2866
|
-
timeoutId = null;
|
|
2867
|
-
}
|
|
2868
|
-
|
|
2869
|
-
// Flush any remaining buffered HTML chunks
|
|
2870
|
-
for (const buf of buffered) {
|
|
2871
|
-
if (!injected) {
|
|
2872
|
-
const headEnd = buf.indexOf("</head>");
|
|
2873
|
-
if (headEnd !== -1) {
|
|
2874
|
-
const before = buf.slice(0, headEnd);
|
|
2875
|
-
const after = buf.slice(headEnd);
|
|
2876
|
-
controller.enqueue(encoder.encode(before + injectHTML + after));
|
|
2877
|
-
injected = true;
|
|
2878
|
-
continue;
|
|
2879
|
-
}
|
|
2880
|
-
}
|
|
2881
|
-
controller.enqueue(encoder.encode(buf));
|
|
2882
|
-
}
|
|
2883
|
-
buffered = [];
|
|
2884
|
-
|
|
2885
|
-
if (!injected && injectHTML) {
|
|
2886
|
-
controller.enqueue(encoder.encode(injectHTML));
|
|
2887
|
-
}
|
|
2888
|
-
// Finalize: wait for the RSC stream to complete and emit remaining
|
|
2889
|
-
// chunks plus the __VINEXT_RSC_DONE__ signal.
|
|
2890
|
-
const finalScripts = await rscEmbed.finalize();
|
|
2891
|
-
if (finalScripts) {
|
|
2892
|
-
controller.enqueue(encoder.encode(finalScripts));
|
|
2893
|
-
}
|
|
2894
|
-
},
|
|
2895
|
-
});
|
|
2896
|
-
|
|
2897
|
-
return htmlStream.pipeThrough(transform);
|
|
2898
|
-
} finally {
|
|
2899
|
-
// Clean up so we don't leak context between requests
|
|
2900
|
-
setNavigationContext(null);
|
|
2901
|
-
clearServerInsertedHTML();
|
|
2902
|
-
}
|
|
2903
|
-
}); // end _runWithNavCtx
|
|
2904
|
-
}
|
|
2905
|
-
|
|
2906
|
-
export default {
|
|
2907
|
-
async fetch(request) {
|
|
2908
|
-
const url = new URL(request.url);
|
|
2909
|
-
if (url.pathname.startsWith("//")) {
|
|
2910
|
-
return new Response("404 Not Found", { status: 404 });
|
|
2911
|
-
}
|
|
2912
|
-
const rscModule = await import.meta.viteRsc.loadModule("rsc", "index");
|
|
2913
|
-
const result = await rscModule.default(request);
|
|
2914
|
-
if (result instanceof Response) {
|
|
2915
|
-
return result;
|
|
2916
|
-
}
|
|
2917
|
-
if (result === null || result === undefined) {
|
|
2918
|
-
return new Response("Not Found", { status: 404 });
|
|
2919
|
-
}
|
|
2920
|
-
return new Response(String(result), { status: 200 });
|
|
2921
|
-
},
|
|
2922
|
-
};
|
|
2923
|
-
`;
|
|
2924
|
-
}
|
|
2925
|
-
/**
|
|
2926
|
-
* Generate the virtual browser entry module.
|
|
2927
|
-
*
|
|
2928
|
-
* This runs in the client (browser). It hydrates the page from the
|
|
2929
|
-
* embedded RSC payload and handles client-side navigation by re-fetching
|
|
2930
|
-
* RSC streams.
|
|
2931
|
-
*/
|
|
2932
|
-
export function generateBrowserEntry() {
|
|
2933
|
-
return `
|
|
2934
|
-
import {
|
|
2935
|
-
createFromReadableStream,
|
|
2936
|
-
createFromFetch,
|
|
2937
|
-
setServerCallback,
|
|
2938
|
-
encodeReply,
|
|
2939
|
-
createTemporaryReferenceSet,
|
|
2940
|
-
} from "@vitejs/plugin-rsc/browser";
|
|
2941
|
-
import { hydrateRoot } from "react-dom/client";
|
|
2942
|
-
import { flushSync } from "react-dom";
|
|
2943
|
-
import { setClientParams, toRscUrl, getPrefetchCache, getPrefetchedUrls, PREFETCH_CACHE_TTL } from "next/navigation";
|
|
2944
|
-
|
|
2945
|
-
let reactRoot;
|
|
2946
|
-
|
|
2947
|
-
/**
|
|
2948
|
-
* Convert the embedded RSC chunks back to a ReadableStream.
|
|
2949
|
-
* Each chunk is a text string that needs to be encoded back to Uint8Array.
|
|
2950
|
-
*/
|
|
2951
|
-
function chunksToReadableStream(chunks) {
|
|
2952
|
-
const encoder = new TextEncoder();
|
|
2953
|
-
return new ReadableStream({
|
|
2954
|
-
start(controller) {
|
|
2955
|
-
for (const chunk of chunks) {
|
|
2956
|
-
controller.enqueue(encoder.encode(chunk));
|
|
2957
|
-
}
|
|
2958
|
-
controller.close();
|
|
2959
|
-
}
|
|
2960
|
-
});
|
|
2961
|
-
}
|
|
2962
|
-
|
|
2963
|
-
/**
|
|
2964
|
-
* Create a ReadableStream from progressively-embedded RSC chunks.
|
|
2965
|
-
* The server injects RSC data as <script> tags that push to
|
|
2966
|
-
* self.__VINEXT_RSC_CHUNKS__ throughout the HTML stream, and sets
|
|
2967
|
-
* self.__VINEXT_RSC_DONE__ = true when complete.
|
|
2968
|
-
*
|
|
2969
|
-
* Instead of polling with setTimeout, we monkey-patch the array's
|
|
2970
|
-
* push() method so new chunks are delivered immediately when the
|
|
2971
|
-
* server's <script> tags execute. This eliminates unnecessary
|
|
2972
|
-
* wakeups and reduces latency — same pattern Next.js uses with
|
|
2973
|
-
* __next_f. The stream closes on DOMContentLoaded (when all
|
|
2974
|
-
* server-injected scripts have executed) or when __VINEXT_RSC_DONE__
|
|
2975
|
-
* is set, whichever comes first.
|
|
2976
|
-
*/
|
|
2977
|
-
function createProgressiveRscStream() {
|
|
2978
|
-
const encoder = new TextEncoder();
|
|
2979
|
-
return new ReadableStream({
|
|
2980
|
-
start(controller) {
|
|
2981
|
-
const chunks = self.__VINEXT_RSC_CHUNKS__ || [];
|
|
2982
|
-
|
|
2983
|
-
// Deliver any chunks that arrived before this code ran
|
|
2984
|
-
// (from <script> tags that executed before the browser entry loaded)
|
|
2985
|
-
for (const chunk of chunks) {
|
|
2986
|
-
controller.enqueue(encoder.encode(chunk));
|
|
2987
|
-
}
|
|
2988
|
-
|
|
2989
|
-
// If the stream is already complete, close immediately
|
|
2990
|
-
if (self.__VINEXT_RSC_DONE__) {
|
|
2991
|
-
controller.close();
|
|
2992
|
-
return;
|
|
2993
|
-
}
|
|
2994
|
-
|
|
2995
|
-
// Monkey-patch push() so future chunks stream in immediately
|
|
2996
|
-
// when the server's <script> tags execute
|
|
2997
|
-
let closed = false;
|
|
2998
|
-
function closeOnce() {
|
|
2999
|
-
if (!closed) {
|
|
3000
|
-
closed = true;
|
|
3001
|
-
controller.close();
|
|
3002
|
-
}
|
|
3003
|
-
}
|
|
3004
|
-
|
|
3005
|
-
const arr = self.__VINEXT_RSC_CHUNKS__ = self.__VINEXT_RSC_CHUNKS__ || [];
|
|
3006
|
-
arr.push = function(chunk) {
|
|
3007
|
-
Array.prototype.push.call(this, chunk);
|
|
3008
|
-
if (!closed) {
|
|
3009
|
-
controller.enqueue(encoder.encode(chunk));
|
|
3010
|
-
if (self.__VINEXT_RSC_DONE__) {
|
|
3011
|
-
closeOnce();
|
|
3012
|
-
}
|
|
3013
|
-
}
|
|
3014
|
-
return this.length;
|
|
3015
|
-
};
|
|
3016
|
-
|
|
3017
|
-
// Safety net: if the server crashes mid-stream and __VINEXT_RSC_DONE__
|
|
3018
|
-
// never arrives, close the stream when all server-injected scripts
|
|
3019
|
-
// have executed (DOMContentLoaded). Without this, a truncated response
|
|
3020
|
-
// leaves the ReadableStream open forever, hanging hydration.
|
|
3021
|
-
if (typeof document !== "undefined") {
|
|
3022
|
-
if (document.readyState === "loading") {
|
|
3023
|
-
document.addEventListener("DOMContentLoaded", closeOnce);
|
|
3024
|
-
} else {
|
|
3025
|
-
// Document already loaded — close immediately if not already done
|
|
3026
|
-
closeOnce();
|
|
3027
|
-
}
|
|
3028
|
-
}
|
|
3029
|
-
}
|
|
3030
|
-
});
|
|
3031
|
-
}
|
|
3032
|
-
|
|
3033
|
-
// Register the server action callback — React calls this internally
|
|
3034
|
-
// when a "use server" function is invoked from client code.
|
|
3035
|
-
setServerCallback(async (id, args) => {
|
|
3036
|
-
const temporaryReferences = createTemporaryReferenceSet();
|
|
3037
|
-
const body = await encodeReply(args, { temporaryReferences });
|
|
3038
|
-
|
|
3039
|
-
const fetchResponse = await fetch(toRscUrl(window.location.pathname + window.location.search), {
|
|
3040
|
-
method: "POST",
|
|
3041
|
-
headers: { "x-rsc-action": id },
|
|
3042
|
-
body,
|
|
3043
|
-
});
|
|
3044
|
-
|
|
3045
|
-
// Check for redirect signal from server action that called redirect()
|
|
3046
|
-
const actionRedirect = fetchResponse.headers.get("x-action-redirect");
|
|
3047
|
-
if (actionRedirect) {
|
|
3048
|
-
// External URLs (different origin) need a hard redirect — client-side
|
|
3049
|
-
// RSC navigation only works for same-origin paths.
|
|
3050
|
-
try {
|
|
3051
|
-
const redirectUrl = new URL(actionRedirect, window.location.origin);
|
|
3052
|
-
if (redirectUrl.origin !== window.location.origin) {
|
|
3053
|
-
window.location.href = actionRedirect;
|
|
3054
|
-
return undefined;
|
|
3055
|
-
}
|
|
3056
|
-
} catch {
|
|
3057
|
-
// If URL parsing fails, fall through to client-side navigation
|
|
3058
|
-
}
|
|
3059
|
-
|
|
3060
|
-
// Navigate to the redirect target using client-side navigation
|
|
3061
|
-
const redirectType = fetchResponse.headers.get("x-action-redirect-type") || "replace";
|
|
3062
|
-
if (redirectType === "push") {
|
|
3063
|
-
window.history.pushState(null, "", actionRedirect);
|
|
3064
|
-
} else {
|
|
3065
|
-
window.history.replaceState(null, "", actionRedirect);
|
|
3066
|
-
}
|
|
3067
|
-
// Trigger RSC navigation to the redirect target
|
|
3068
|
-
if (typeof window.__VINEXT_RSC_NAVIGATE__ === "function") {
|
|
3069
|
-
window.__VINEXT_RSC_NAVIGATE__(actionRedirect);
|
|
3070
|
-
}
|
|
3071
|
-
return undefined;
|
|
3072
|
-
}
|
|
3073
|
-
|
|
3074
|
-
const result = await createFromFetch(Promise.resolve(fetchResponse), { temporaryReferences });
|
|
3075
|
-
|
|
3076
|
-
// The RSC response for actions contains { root, returnValue }.
|
|
3077
|
-
// Re-render the page with the updated tree.
|
|
3078
|
-
if (result && typeof result === "object" && "root" in result) {
|
|
3079
|
-
reactRoot.render(result.root);
|
|
3080
|
-
// Return the action's return value to the caller
|
|
3081
|
-
if (result.returnValue) {
|
|
3082
|
-
if (!result.returnValue.ok) throw result.returnValue.data;
|
|
3083
|
-
return result.returnValue.data;
|
|
3084
|
-
}
|
|
3085
|
-
return undefined;
|
|
3086
|
-
}
|
|
3087
|
-
|
|
3088
|
-
// Fallback: render the entire result as the tree
|
|
3089
|
-
reactRoot.render(result);
|
|
3090
|
-
return result;
|
|
3091
|
-
});
|
|
3092
|
-
|
|
3093
|
-
async function main() {
|
|
3094
|
-
let rscStream;
|
|
3095
|
-
|
|
3096
|
-
// Use embedded RSC data for initial hydration if available.
|
|
3097
|
-
// This ensures we use the SAME RSC payload that generated the HTML,
|
|
3098
|
-
// avoiding hydration mismatches (React error #418).
|
|
3099
|
-
//
|
|
3100
|
-
// The server embeds RSC chunks progressively as <script> tags that push
|
|
3101
|
-
// to self.__VINEXT_RSC_CHUNKS__. When complete, self.__VINEXT_RSC_DONE__
|
|
3102
|
-
// is set and self.__VINEXT_RSC_PARAMS__ contains route params.
|
|
3103
|
-
// For backwards compat, also check the legacy self.__VINEXT_RSC__ format.
|
|
3104
|
-
if (self.__VINEXT_RSC_CHUNKS__ || self.__VINEXT_RSC_DONE__ || self.__VINEXT_RSC__) {
|
|
3105
|
-
if (self.__VINEXT_RSC__) {
|
|
3106
|
-
// Legacy format: single object with all chunks
|
|
3107
|
-
const embedData = self.__VINEXT_RSC__;
|
|
3108
|
-
delete self.__VINEXT_RSC__;
|
|
3109
|
-
if (embedData.params) {
|
|
3110
|
-
setClientParams(embedData.params);
|
|
3111
|
-
}
|
|
3112
|
-
rscStream = chunksToReadableStream(embedData.rsc);
|
|
3113
|
-
} else {
|
|
3114
|
-
// Progressive format: chunks arrive incrementally via script tags.
|
|
3115
|
-
// Params are embedded in <head> so they're always available by this point.
|
|
3116
|
-
if (self.__VINEXT_RSC_PARAMS__) {
|
|
3117
|
-
setClientParams(self.__VINEXT_RSC_PARAMS__);
|
|
3118
|
-
}
|
|
3119
|
-
rscStream = createProgressiveRscStream();
|
|
3120
|
-
}
|
|
3121
|
-
} else {
|
|
3122
|
-
// Fallback: fetch fresh RSC (shouldn't happen on initial page load)
|
|
3123
|
-
const rscResponse = await fetch(toRscUrl(window.location.pathname + window.location.search));
|
|
3124
|
-
|
|
3125
|
-
// Hydrate useParams() with route params from the server before React hydration
|
|
3126
|
-
const paramsHeader = rscResponse.headers.get("X-Vinext-Params");
|
|
3127
|
-
if (paramsHeader) {
|
|
3128
|
-
try { setClientParams(JSON.parse(paramsHeader)); } catch (_e) { /* ignore */ }
|
|
3129
|
-
}
|
|
3130
|
-
|
|
3131
|
-
rscStream = rscResponse.body;
|
|
3132
|
-
}
|
|
3133
|
-
|
|
3134
|
-
const root = await createFromReadableStream(rscStream);
|
|
3135
|
-
|
|
3136
|
-
// Hydrate the document
|
|
3137
|
-
// In development, suppress Vite's error overlay for errors caught by React error
|
|
3138
|
-
// boundaries. Without this, React re-throws caught errors to the global handler,
|
|
3139
|
-
// which triggers Vite's overlay even though the error was handled by an error.tsx.
|
|
3140
|
-
// In production, preserve React's default onCaughtError (console.error) so
|
|
3141
|
-
// boundary-caught errors remain visible to error monitoring.
|
|
3142
|
-
reactRoot = hydrateRoot(document, root, import.meta.env.DEV ? {
|
|
3143
|
-
onCaughtError: function() {},
|
|
3144
|
-
} : undefined);
|
|
3145
|
-
|
|
3146
|
-
// Store for client-side navigation
|
|
3147
|
-
window.__VINEXT_RSC_ROOT__ = reactRoot;
|
|
3148
|
-
|
|
3149
|
-
// Client-side navigation handler
|
|
3150
|
-
// Checks the prefetch cache (populated by <Link> IntersectionObserver and
|
|
3151
|
-
// router.prefetch()) before making a network request. This makes navigation
|
|
3152
|
-
// near-instant for prefetched routes.
|
|
3153
|
-
window.__VINEXT_RSC_NAVIGATE__ = async function navigateRsc(href, __redirectDepth) {
|
|
3154
|
-
if ((__redirectDepth || 0) > 10) {
|
|
3155
|
-
console.error("[vinext] Too many RSC redirects — aborting navigation to prevent infinite loop.");
|
|
3156
|
-
window.location.href = href;
|
|
3157
|
-
return;
|
|
3158
|
-
}
|
|
3159
|
-
try {
|
|
3160
|
-
const url = new URL(href, window.location.origin);
|
|
3161
|
-
const rscUrl = toRscUrl(url.pathname + url.search);
|
|
3162
|
-
|
|
3163
|
-
// Check the in-memory prefetch cache first
|
|
3164
|
-
let navResponse;
|
|
3165
|
-
const prefetchCache = getPrefetchCache();
|
|
3166
|
-
const cached = prefetchCache.get(rscUrl);
|
|
3167
|
-
if (cached && (Date.now() - cached.timestamp) < PREFETCH_CACHE_TTL) {
|
|
3168
|
-
navResponse = cached.response;
|
|
3169
|
-
prefetchCache.delete(rscUrl); // Consume the cached entry (one-time use)
|
|
3170
|
-
getPrefetchedUrls().delete(rscUrl); // Allow re-prefetch when link is visible again
|
|
3171
|
-
} else if (cached) {
|
|
3172
|
-
prefetchCache.delete(rscUrl); // Expired, clean up
|
|
3173
|
-
getPrefetchedUrls().delete(rscUrl);
|
|
3174
|
-
}
|
|
3175
|
-
|
|
3176
|
-
// Fallback to network fetch if not in cache
|
|
3177
|
-
if (!navResponse) {
|
|
3178
|
-
navResponse = await fetch(rscUrl, {
|
|
3179
|
-
headers: { Accept: "text/x-component" },
|
|
3180
|
-
credentials: "include",
|
|
3181
|
-
});
|
|
3182
|
-
}
|
|
3183
|
-
|
|
3184
|
-
// Detect if fetch followed a redirect: compare the final response URL to
|
|
3185
|
-
// what we requested. If they differ, the server issued a 3xx — push the
|
|
3186
|
-
// canonical destination URL into history before rendering.
|
|
3187
|
-
const __finalUrl = new URL(navResponse.url);
|
|
3188
|
-
const __requestedUrl = new URL(rscUrl, window.location.origin);
|
|
3189
|
-
if (__finalUrl.pathname !== __requestedUrl.pathname) {
|
|
3190
|
-
// Strip .rsc suffix from the final URL to get the page path for history.
|
|
3191
|
-
// Use replaceState instead of pushState: the caller (navigateImpl) already
|
|
3192
|
-
// pushed the pre-redirect URL; replacing it avoids a stale history entry.
|
|
3193
|
-
const __destPath = __finalUrl.pathname.replace(/\\.rsc$/, "") + __finalUrl.search;
|
|
3194
|
-
window.history.replaceState(null, "", __destPath);
|
|
3195
|
-
return window.__VINEXT_RSC_NAVIGATE__(__destPath, (__redirectDepth || 0) + 1);
|
|
3196
|
-
}
|
|
3197
|
-
|
|
3198
|
-
// Update useParams() with route params from the server before re-rendering
|
|
3199
|
-
const navParamsHeader = navResponse.headers.get("X-Vinext-Params");
|
|
3200
|
-
if (navParamsHeader) {
|
|
3201
|
-
try { setClientParams(JSON.parse(navParamsHeader)); } catch (_e) { /* ignore */ }
|
|
3202
|
-
} else {
|
|
3203
|
-
setClientParams({});
|
|
3204
|
-
}
|
|
3205
|
-
|
|
3206
|
-
const rscPayload = await createFromFetch(Promise.resolve(navResponse));
|
|
3207
|
-
// Use flushSync to guarantee React commits the new tree to the DOM
|
|
3208
|
-
// synchronously before this function returns. Callers scroll to top
|
|
3209
|
-
// after awaiting, so the new content must be painted first.
|
|
3210
|
-
flushSync(function () { reactRoot.render(rscPayload); });
|
|
3211
|
-
} catch (err) {
|
|
3212
|
-
console.error("[vinext] RSC navigation error:", err);
|
|
3213
|
-
// Fallback to full page load
|
|
3214
|
-
window.location.href = href;
|
|
3215
|
-
}
|
|
3216
|
-
};
|
|
3217
|
-
|
|
3218
|
-
// Handle popstate (browser back/forward)
|
|
3219
|
-
// Store the navigation promise on a well-known property so that
|
|
3220
|
-
// restoreScrollPosition (in navigation.ts) can await it before scrolling.
|
|
3221
|
-
// This prevents a flash where the old content is visible at the restored
|
|
3222
|
-
// scroll position before the new RSC payload has rendered.
|
|
3223
|
-
window.addEventListener("popstate", () => {
|
|
3224
|
-
const p = window.__VINEXT_RSC_NAVIGATE__(window.location.href);
|
|
3225
|
-
window.__VINEXT_RSC_PENDING__ = p;
|
|
3226
|
-
p.finally(() => {
|
|
3227
|
-
// Clear once settled so stale promises aren't awaited later
|
|
3228
|
-
if (window.__VINEXT_RSC_PENDING__ === p) {
|
|
3229
|
-
window.__VINEXT_RSC_PENDING__ = null;
|
|
3230
|
-
}
|
|
3231
|
-
});
|
|
3232
|
-
});
|
|
3233
|
-
|
|
3234
|
-
// HMR: re-render on server module updates
|
|
3235
|
-
if (import.meta.hot) {
|
|
3236
|
-
import.meta.hot.on("rsc:update", async () => {
|
|
3237
|
-
try {
|
|
3238
|
-
const rscPayload = await createFromFetch(
|
|
3239
|
-
fetch(toRscUrl(window.location.pathname + window.location.search))
|
|
3240
|
-
);
|
|
3241
|
-
reactRoot.render(rscPayload);
|
|
3242
|
-
} catch (err) {
|
|
3243
|
-
console.error("[vinext] RSC HMR error:", err);
|
|
3244
|
-
}
|
|
3245
|
-
});
|
|
3246
|
-
}
|
|
3247
|
-
}
|
|
3248
|
-
|
|
3249
|
-
main();
|
|
3250
|
-
`;
|
|
3251
|
-
}
|
|
3252
|
-
//# sourceMappingURL=app-dev-server.js.map
|
|
2458
|
+
//# sourceMappingURL=app-rsc-entry.js.map
|