@thomas.f/prnext 0.1.0-alpha.1
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 +54 -0
- package/build/action-exports.mjs +107 -0
- package/build/actions.mjs +214 -0
- package/build/app-config.mjs +138 -0
- package/build/app-routing.mjs +119 -0
- package/build/app-static.mjs +158 -0
- package/build/app.mjs +536 -0
- package/build/cache-components.mjs +91 -0
- package/build/cache-handlers.mjs +54 -0
- package/build/check.mjs +40 -0
- package/build/compiler.mjs +222 -0
- package/build/config.mjs +193 -0
- package/build/css-modules.mjs +114 -0
- package/build/custom-routes.mjs +363 -0
- package/build/dev-worker.mjs +18 -0
- package/build/dev.mjs +76 -0
- package/build/directory-cache.mjs +29 -0
- package/build/dynamic.mjs +125 -0
- package/build/edge.mjs +141 -0
- package/build/font-data.LICENSE.txt +25 -0
- package/build/font-data.json +1 -0
- package/build/font-files.mjs +221 -0
- package/build/fonts.mjs +131 -0
- package/build/framework-imports.mjs +17 -0
- package/build/i18n.mjs +39 -0
- package/build/images-config.mjs +62 -0
- package/build/images.mjs +86 -0
- package/build/incremental.mjs +76 -0
- package/build/index.mjs +356 -0
- package/build/loader-cache.mjs +69 -0
- package/build/metadata-images.mjs +72 -0
- package/build/metadata-routes.mjs +77 -0
- package/build/middleware.mjs +231 -0
- package/build/migrate.mjs +289 -0
- package/build/module-resolution.mjs +78 -0
- package/build/native-discovery.mjs +134 -0
- package/build/native-packages.mjs +105 -0
- package/build/npm-packages.mjs +110 -0
- package/build/og.mjs +14 -0
- package/build/page-extensions.mjs +12 -0
- package/build/passthrough-loader.cjs +4 -0
- package/build/precompress.mjs +122 -0
- package/build/scan.mjs +178 -0
- package/build/script-workers.mjs +25 -0
- package/build/server-actions-config.mjs +27 -0
- package/build/source-maps.mjs +34 -0
- package/build/standalone-config.mjs +19 -0
- package/build/standalone-runner.mjs +21 -0
- package/build/standalone-worker.mjs +5 -0
- package/build/standalone.mjs +240 -0
- package/build/static-export.mjs +93 -0
- package/build/styles.mjs +174 -0
- package/build/transform-cache.mjs +50 -0
- package/build/transform.mjs +155 -0
- package/build/turbopack-conditions.mjs +59 -0
- package/build/webpack-bridge.cjs +3 -0
- package/build/webpack-graph.mjs +256 -0
- package/build/webpack-loader.cjs +9 -0
- package/cli.mjs +210 -0
- package/compat/app-client-page.cjs +32 -0
- package/compat/app-context.cjs +40 -0
- package/compat/app-context.d.cts +20 -0
- package/compat/app-dynamic.cjs +34 -0
- package/compat/app-error-boundary.cjs +53 -0
- package/compat/app-global-error-boundary.cjs +34 -0
- package/compat/app-layout-context.cjs +27 -0
- package/compat/app-navigation-boundary.cjs +47 -0
- package/compat/app.cjs +33 -0
- package/compat/app.d.cts +9 -0
- package/compat/cache-handlers.cjs +223 -0
- package/compat/cache-life.cjs +34 -0
- package/compat/cache.cjs +99 -0
- package/compat/cache.d.cts +36 -0
- package/compat/compat/router.d.cts +2 -0
- package/compat/compat-router.cjs +7 -0
- package/compat/constants.cjs +8 -0
- package/compat/constants.d.cts +7 -0
- package/compat/cookies.cjs +129 -0
- package/compat/data-cache.cjs +389 -0
- package/compat/dist/shared/lib/app-router-context.shared-runtime.d.cts +3 -0
- package/compat/dist/shared/lib/router-context.d.cts +1 -0
- package/compat/dist/shared/lib/router-context.shared-runtime.d.cts +3 -0
- package/compat/document.cjs +73 -0
- package/compat/document.d.cts +41 -0
- package/compat/draft.cjs +28 -0
- package/compat/dynamic-bailout.cjs +13 -0
- package/compat/dynamic.cjs +119 -0
- package/compat/dynamic.d.cts +32 -0
- package/compat/error.cjs +36 -0
- package/compat/error.d.cts +15 -0
- package/compat/fetch.d.cts +10 -0
- package/compat/font/google.d.cts +1 -0
- package/compat/font/local.d.cts +2 -0
- package/compat/font-google.cjs +2 -0
- package/compat/font-google.d.cts +1944 -0
- package/compat/font-local.cjs +5 -0
- package/compat/font-local.d.cts +3 -0
- package/compat/font-types.d.cts +25 -0
- package/compat/head.cjs +180 -0
- package/compat/head.d.cts +3 -0
- package/compat/headers.cjs +89 -0
- package/compat/headers.d.cts +26 -0
- package/compat/image-config.cjs +3 -0
- package/compat/image-imports.d.ts +12 -0
- package/compat/image-shared.cjs +62 -0
- package/compat/image.cjs +64 -0
- package/compat/image.d.cts +35 -0
- package/compat/incremental-cache.cjs +186 -0
- package/compat/index.d.ts +159 -0
- package/compat/instant-samples.cjs +39 -0
- package/compat/instant.cjs +13 -0
- package/compat/link.cjs +68 -0
- package/compat/link.d.cts +17 -0
- package/compat/locale.cjs +30 -0
- package/compat/metadata.d.cts +77 -0
- package/compat/navigation-controls.cjs +27 -0
- package/compat/navigation-server.cjs +25 -0
- package/compat/navigation.cjs +52 -0
- package/compat/navigation.d.cts +28 -0
- package/compat/next-app-router-context.cjs +6 -0
- package/compat/next-router-context.cjs +3 -0
- package/compat/og.cjs +32 -0
- package/compat/og.d.cts +1 -0
- package/compat/paths.cjs +33 -0
- package/compat/preview.cjs +41 -0
- package/compat/rewrite.cjs +41 -0
- package/compat/route-request.cjs +64 -0
- package/compat/router.cjs +137 -0
- package/compat/router.d.cts +58 -0
- package/compat/script-context.cjs +5 -0
- package/compat/script-loader.cjs +135 -0
- package/compat/script.cjs +69 -0
- package/compat/script.d.cts +17 -0
- package/compat/server-primitives.cjs +109 -0
- package/compat/server.cjs +13 -0
- package/compat/server.d.cts +37 -0
- package/compat/static-generation.cjs +108 -0
- package/compat/use-cache.cjs +129 -0
- package/index.mjs +1 -0
- package/native/platforms.json +6 -0
- package/native/resolve.mjs +81 -0
- package/package.json +127 -0
- package/release/verify.mjs +59 -0
- package/runtime/action-client.mjs +19 -0
- package/runtime/action-crypto.mjs +138 -0
- package/runtime/action-reference.mjs +2 -0
- package/runtime/action-server.mjs +161 -0
- package/runtime/action-ssr.mjs +5 -0
- package/runtime/api.mjs +326 -0
- package/runtime/app-client.mjs +539 -0
- package/runtime/app-content.mjs +8 -0
- package/runtime/app-errors.mjs +12 -0
- package/runtime/app-instant.mjs +30 -0
- package/runtime/app-metadata.mjs +410 -0
- package/runtime/app-partial-model.mjs +228 -0
- package/runtime/app-partial.mjs +130 -0
- package/runtime/app-prefetch.mjs +34 -0
- package/runtime/app-recovery.mjs +87 -0
- package/runtime/app-render.mjs +544 -0
- package/runtime/app-routing-access.mjs +143 -0
- package/runtime/app-routing-context.mjs +35 -0
- package/runtime/app-routing-state.mjs +61 -0
- package/runtime/app-routing.mjs +323 -0
- package/runtime/app-segments.mjs +22 -0
- package/runtime/app-static-params.mjs +120 -0
- package/runtime/app-static.mjs +86 -0
- package/runtime/app-stream-channel.mjs +92 -0
- package/runtime/app-stream-html.mjs +279 -0
- package/runtime/build-directory.mjs +48 -0
- package/runtime/cache-arguments.mjs +33 -0
- package/runtime/cache-components-rsc.mjs +64 -0
- package/runtime/cache-request.mjs +23 -0
- package/runtime/client-navigation.mjs +40 -0
- package/runtime/dev-client.mjs +129 -0
- package/runtime/dev-file-changes.mjs +110 -0
- package/runtime/document.mjs +96 -0
- package/runtime/edge.mjs +105 -0
- package/runtime/env.mjs +68 -0
- package/runtime/fetch-cache.mjs +379 -0
- package/runtime/font-preload.mjs +4 -0
- package/runtime/http.mjs +190 -0
- package/runtime/incremental-cache.mjs +83 -0
- package/runtime/metadata-route.mjs +83 -0
- package/runtime/middleware.mjs +186 -0
- package/runtime/module-loader.mjs +21 -0
- package/runtime/navigation.mjs +16 -0
- package/runtime/og-loader.mjs +17 -0
- package/runtime/pages-client.mjs +197 -0
- package/runtime/pages-error-props.mjs +6 -0
- package/runtime/pages-errors.mjs +85 -0
- package/runtime/pages-initial-props.mjs +43 -0
- package/runtime/pages-navigation.mjs +565 -0
- package/runtime/pages-paths.mjs +80 -0
- package/runtime/pages-revalidate.mjs +33 -0
- package/runtime/partial-artifact-cache.mjs +37 -0
- package/runtime/partial-template.mjs +174 -0
- package/runtime/profile.mjs +9 -0
- package/runtime/profiles.json +47 -0
- package/runtime/render.mjs +304 -0
- package/runtime/request-transport.mjs +41 -0
- package/runtime/request-work.mjs +7 -0
- package/runtime/route-static.mjs +117 -0
- package/runtime/rsc-worker.mjs +426 -0
- package/runtime/script-html.mjs +61 -0
- package/runtime/stream-select.mjs +25 -0
- package/runtime/stream-utils.mjs +127 -0
- package/runtime/transport.mjs +108 -0
- package/runtime/watch-project.mjs +130 -0
- package/runtime/worker-artifacts.mjs +22 -0
- package/runtime/worker-metadata.mjs +31 -0
- package/runtime/worker.mjs +197 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { currentRequest } from '../compat/headers.cjs';
|
|
2
|
+
|
|
3
|
+
const MAX_PATHS = 10_000;
|
|
4
|
+
const MAX_PARAMS_BYTES = 4 * 1024 * 1024;
|
|
5
|
+
const objectConstructor = Function.prototype.toString.call(Object);
|
|
6
|
+
function plainParams(value) {
|
|
7
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
8
|
+
const prototype = Object.getPrototypeOf(value);
|
|
9
|
+
if (prototype === null || prototype === Object.prototype) return true;
|
|
10
|
+
// An Edge realm has its own Object.prototype. Accept its ordinary objects,
|
|
11
|
+
// while still rejecting application class instances and custom prototypes.
|
|
12
|
+
const constructor = Object.getOwnPropertyDescriptor(prototype, 'constructor')?.value;
|
|
13
|
+
return Object.getPrototypeOf(prototype) === null && typeof constructor === 'function' && Function.prototype.toString.call(constructor) === objectConstructor;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function names(pattern) {
|
|
17
|
+
return new Set([...String(pattern || '').matchAll(/\[(?:\[)?(?:\.\.\.)?([^\]]+)\]\]?/g)].map(match => match[1]));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function publicParams(row, branchPattern, routePattern) {
|
|
21
|
+
const source = branchPattern.split('/').filter(Boolean), target = routePattern.split('/').filter(Boolean);
|
|
22
|
+
const result = {};
|
|
23
|
+
const name = part => /^\[\[?(?:\.\.\.)?([^\]]+)\]\]?$/.exec(part)?.[1];
|
|
24
|
+
for (let index = 0; index < target.length; index++) {
|
|
25
|
+
const targetName = name(target[index]);
|
|
26
|
+
if (!targetName) continue;
|
|
27
|
+
const sourceName = name(source[index] || '');
|
|
28
|
+
if (sourceName && Object.hasOwn(row, sourceName)) result[targetName] = row[sourceName];
|
|
29
|
+
else if (!sourceName && source[index]) result[targetName] = source[index];
|
|
30
|
+
}
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function validateRuntimeStaticConfig(entry) {
|
|
35
|
+
const fields = ['dynamic', 'dynamicParams', 'instant', 'revalidate', 'fetchCache', 'runtime', 'preferredRegion', 'maxDuration', 'experimental_ppr'];
|
|
36
|
+
const modules = (entry.segments || []).filter(segment => segment.staticConfig !== undefined)
|
|
37
|
+
.map(segment => [segment.layout, segment.staticConfig]);
|
|
38
|
+
if (entry.pageConfig !== undefined) modules.push([entry.page, entry.pageConfig]);
|
|
39
|
+
for (const [module, config] of modules) {
|
|
40
|
+
if (!module || module.default?.$$typeof === Symbol.for('react.client.reference')) continue;
|
|
41
|
+
for (const field of fields) {
|
|
42
|
+
if (module[field] !== undefined && (!Object.hasOwn(config, field) || JSON.stringify(module[field]) !== JSON.stringify(config[field]))) {
|
|
43
|
+
throw new Error(`Statically analyzable route config required: ${field} cannot be hidden behind wildcard re-exports`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function collectStaticParams(entry, routePattern, memo = new Map()) {
|
|
50
|
+
validateRuntimeStaticConfig(entry);
|
|
51
|
+
if (entry.routing) {
|
|
52
|
+
const branches = [];
|
|
53
|
+
function visit(node, parents = []) {
|
|
54
|
+
if (node.interception) return;
|
|
55
|
+
const segments = [...parents, { layout: node.files.layout, staticConfig: node.config?.layout || {}, path: node.pattern }];
|
|
56
|
+
if (node.files.page) branches.push({ page: node.files.page, pageConfig: node.config?.page || {}, segments, pattern: node.pattern });
|
|
57
|
+
for (const child of [...node.children, ...Object.values(node.slots)]) visit(child, segments);
|
|
58
|
+
}
|
|
59
|
+
visit(entry.routing);
|
|
60
|
+
const params = new Map();
|
|
61
|
+
let generated = false, bytes = 0;
|
|
62
|
+
for (const branch of branches) {
|
|
63
|
+
const result = await collectStaticParams(branch, branch.pattern, memo);
|
|
64
|
+
generated ||= result.generated;
|
|
65
|
+
for (const branchRow of result.params) {
|
|
66
|
+
const row = publicParams(branchRow, branch.pattern, routePattern);
|
|
67
|
+
const key = JSON.stringify(Object.fromEntries(Object.entries(row).sort(([a], [b]) => a.localeCompare(b))));
|
|
68
|
+
if (params.has(key)) continue;
|
|
69
|
+
bytes += Buffer.byteLength(key);
|
|
70
|
+
if (params.size >= MAX_PATHS || bytes > MAX_PARAMS_BYTES) throw new Error('generateStaticParams exceeds the PRNext path generation limit');
|
|
71
|
+
params.set(key, row);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return { params: [...params.values()], generated };
|
|
75
|
+
}
|
|
76
|
+
let params = [{}];
|
|
77
|
+
let generated = false;
|
|
78
|
+
const generators = (entry.segments || []).map(segment => ({ module: segment.layout, scope: segment.path || segment.segment || '' }));
|
|
79
|
+
generators.push({ module: entry.page, scope: routePattern });
|
|
80
|
+
for (const { module, scope } of generators) {
|
|
81
|
+
if (module?.generateStaticParams === undefined) continue;
|
|
82
|
+
if (typeof module.generateStaticParams !== 'function') throw new Error('generateStaticParams must export a function');
|
|
83
|
+
generated = true;
|
|
84
|
+
const allowed = names(scope);
|
|
85
|
+
const expanded = [];
|
|
86
|
+
let bytes = 0;
|
|
87
|
+
for (const parent of params) {
|
|
88
|
+
let results = memo.get(module.generateStaticParams);
|
|
89
|
+
if (!results) memo.set(module.generateStaticParams, results = new Map());
|
|
90
|
+
const parentKey = JSON.stringify(parent);
|
|
91
|
+
if (!results.has(parentKey)) results.set(parentKey, Promise.resolve().then(() => module.generateStaticParams({ params: { ...parent } })));
|
|
92
|
+
const rows = await results.get(parentKey);
|
|
93
|
+
if (!Array.isArray(rows)) throw new Error('generateStaticParams must return an array of parameter objects');
|
|
94
|
+
let cacheComponents = false;
|
|
95
|
+
try { cacheComponents = currentRequest().cacheComponents === true; } catch { /* Direct inspection outside a request. */ }
|
|
96
|
+
if (cacheComponents && rows.length === 0) throw new Error(`generateStaticParams for ${scope || '/'} must return at least one parameter object when cacheComponents is enabled.`);
|
|
97
|
+
for (const row of rows) {
|
|
98
|
+
if (!plainParams(row)) {
|
|
99
|
+
throw new Error('generateStaticParams must return plain parameter objects');
|
|
100
|
+
}
|
|
101
|
+
for (const [key, value] of Object.entries(row)) {
|
|
102
|
+
if (!allowed.has(key)) throw new Error(`generateStaticParams cannot generate child or unknown parameter ${key} from segment ${scope || '/'}`);
|
|
103
|
+
const absentOptional = String(scope).includes(`[[...${key}]]`) && (value === undefined || value === null || value === false);
|
|
104
|
+
if (!absentOptional && typeof value !== 'string' && (!Array.isArray(value) || Array.from(value).some(part => typeof part !== 'string'))) {
|
|
105
|
+
throw new Error(`generateStaticParams parameter ${key} must be a string or array of strings`);
|
|
106
|
+
}
|
|
107
|
+
if (Object.hasOwn(parent, key) && JSON.stringify(parent[key]) !== JSON.stringify(value)) {
|
|
108
|
+
throw new Error(`generateStaticParams cannot overwrite parent parameter ${key}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const combined = { ...parent, ...row };
|
|
112
|
+
bytes += Buffer.byteLength(JSON.stringify(combined));
|
|
113
|
+
if (expanded.length >= MAX_PATHS || bytes > MAX_PARAMS_BYTES) throw new Error('generateStaticParams exceeds the PRNext path generation limit');
|
|
114
|
+
expanded.push(combined);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
params = expanded;
|
|
118
|
+
}
|
|
119
|
+
return { params, generated };
|
|
120
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { addBasePath, removeBasePath } from '../compat/paths.cjs';
|
|
3
|
+
import { renderFlight, decodeFlight, renderHtml, completeAppHtml } from './app-render.mjs';
|
|
4
|
+
import { runRequestContext, currentRequest } from '../compat/headers.cjs';
|
|
5
|
+
import { flushCacheWork } from '../compat/data-cache.cjs';
|
|
6
|
+
import { trackStaticDependency, staticMetadata } from '../compat/static-generation.cjs';
|
|
7
|
+
import { prerenderPartialHtml } from './app-partial.mjs';
|
|
8
|
+
import { partialKeyMap } from './app-partial-model.mjs';
|
|
9
|
+
|
|
10
|
+
export function requestOptions(options) {
|
|
11
|
+
const route = options.route || {};
|
|
12
|
+
const canonical = new URL('http://prnext.local');
|
|
13
|
+
canonical.pathname = new URL(options.path || options.url || '/', canonical).pathname;
|
|
14
|
+
return { modulePath: options.modulePath, distDir: options.distDir || path.dirname(path.dirname(options.modulePath)),
|
|
15
|
+
routePattern: route.pattern, cacheConfig: route.cacheConfig || {}, css: route.css || [], fonts: route.fonts || [], production: options.production ?? true,
|
|
16
|
+
url: canonical.href, originalUrl: options.originalUrl ? new URL(new URL(options.originalUrl).pathname, canonical).href : undefined,
|
|
17
|
+
partialParams: options.partialParams, basePath: options.manifest?.config?.basePath || '', trailingSlash: options.manifest?.config?.trailingSlash || false, skipTrailingSlashRedirect: options.manifest?.config?.skipTrailingSlashRedirect || false, params: options.params || {}, method: 'GET', headers: {},
|
|
18
|
+
...(options.seed ? { partialFlight: options.seed.flight, partialKeys: [...partialKeyMap(options.seed.keyScopes, options.params, removeBasePath(canonical.pathname, options.manifest?.config?.basePath || ''))] } : {}),
|
|
19
|
+
clientModules: options.manifest?.app?.clientModules || {},
|
|
20
|
+
cacheComponents: options.manifest?.config?.cacheComponents, cacheLife: options.manifest?.config?.cacheLife, cacheHandlers: options.manifest?.config?.cacheHandlers, cacheHandler: options.manifest?.config?.cacheHandler, cacheMaxMemorySize: options.manifest?.config?.cacheMaxMemorySize,
|
|
21
|
+
actions: options.manifest?.app?.actions || {}, actionKey: options.manifest?.app?.actionKey };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function inspectAppStatic(options) {
|
|
25
|
+
return renderFlight({ ...requestOptions(options), operation: 'static-params' });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function prerenderAppRoute(options) {
|
|
29
|
+
const request = requestOptions(options);
|
|
30
|
+
const mode = request.cacheConfig.dynamic || 'auto';
|
|
31
|
+
if (request.cacheConfig.runtime === 'edge' || mode === 'force-dynamic' || (request.cacheConfig.revalidate === 0 && mode !== 'force-static')) {
|
|
32
|
+
return { dynamic: true, reason: request.cacheConfig.runtime === 'edge' ? 'runtime: edge' : mode === 'force-dynamic' ? 'dynamic: force-dynamic' : 'revalidate: 0' };
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
const partial = request.cacheComponents === true && mode === 'auto';
|
|
36
|
+
const result = await renderFlight({ ...request, staticGeneration: { mode, partial } });
|
|
37
|
+
return await runRequestContext({ ...request, phase: 'render', staticGeneration: { mode } }, async () => {
|
|
38
|
+
const context = currentRequest();
|
|
39
|
+
try {
|
|
40
|
+
trackStaticDependency(result.staticMetadata, context);
|
|
41
|
+
if (result.status >= 500) throw new Error('Static App rendering failed');
|
|
42
|
+
let html;
|
|
43
|
+
if (result.navigation) html = result.navigation.body;
|
|
44
|
+
else {
|
|
45
|
+
const model = await decodeFlight(result.body, request.clientModules, request.distDir, { production: request.production });
|
|
46
|
+
if (partial) {
|
|
47
|
+
const shell = await prerenderPartialHtml(model, options.route, { strictMode: options.manifest?.config?.reactStrictMode !== false, unknownParams: request.partialParams?.length > 0 });
|
|
48
|
+
if (!shell) {
|
|
49
|
+
if (request.cacheConfig.instant === false) return { dynamic: true, reason: 'instant: false permits blocking request data outside Suspense' };
|
|
50
|
+
throw Object.assign(new Error(`Route ${request.routePattern}: uncached data was accessed outside of <Suspense>. Move request APIs, uncached fetches or unknown params into a component beneath <Suspense>, cache the data with "use cache", or explicitly permit blocking with export const instant = false.`), { code: 'PRNEXT_MISSING_SUSPENSE' });
|
|
51
|
+
}
|
|
52
|
+
if (result.staticMetadata?.metadataDynamic && !result.staticMetadata?.contentDynamic && !shell.clientDynamic) {
|
|
53
|
+
throw Object.assign(new Error(`Route ${request.routePattern}: generateMetadata() accesses uncached or request data while the page content is static. Cache the metadata with "use cache" or render a component that awaits connection() beneath <Suspense>.`), { code: 'PRNEXT_DYNAMIC_METADATA' });
|
|
54
|
+
}
|
|
55
|
+
if (shell.postponed) return { partial: { version: 1, ...shell, flight: result.body.toString('base64'),
|
|
56
|
+
...(request.partialParams?.length ? { generic: { params: request.params, unknown: request.partialParams, path: new URL(request.url).pathname }, keyScopes: model.keyScopes || [] } : {}) },
|
|
57
|
+
metadata: { ...staticMetadata(context), generatedAt: Date.now() } };
|
|
58
|
+
if (result.staticMetadata?.dynamicReasons?.length) {
|
|
59
|
+
if (request.cacheConfig.instant === false) return { dynamic: true, reason: 'instant: false permits blocking request data outside Suspense' };
|
|
60
|
+
throw Object.assign(new Error(`Route ${request.routePattern}: request data was intercepted outside of a resumable <Suspense> boundary.`), { code: 'PRNEXT_MISSING_SUSPENSE' });
|
|
61
|
+
}
|
|
62
|
+
html = completeAppHtml(shell.shell, result.body, { ...options.route, css: [], fonts: [] });
|
|
63
|
+
} else {
|
|
64
|
+
html = completeAppHtml(await renderHtml(model.tree, model.router, { staticGeneration: true, strictMode: options.manifest?.config?.reactStrictMode !== false }), result.body, options.route);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (staticMetadata(context)?.externalCache) return { dynamic: true, reason: 'External cache handlers require validation on every request' };
|
|
68
|
+
return { body: html, flight: result.body, ...staticMetadata(context), generatedAt: Date.now(),
|
|
69
|
+
status: result.status, headers: { ...result.headers, ...(result.headers?.location ? { location: addBasePath(result.headers.location, request.basePath) } : {}), 'content-type': 'text/html; charset=utf-8' } };
|
|
70
|
+
} finally { await flushCacheWork(context); }
|
|
71
|
+
});
|
|
72
|
+
} catch (error) {
|
|
73
|
+
if (error.code === 'PRNEXT_DYNAMIC_SERVER_USAGE' && mode === 'auto') return { dynamic: true, reason: error.dynamicReason || error.message };
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function renderAppIsrPage(options) {
|
|
79
|
+
const rendered = await prerenderAppRoute(options);
|
|
80
|
+
if (rendered.dynamic) return { status: 200, headers: {}, body: [],
|
|
81
|
+
isr: { dynamic: true, revalidate: 0, htmlLength: 0, dataLength: 0, tags: [], paths: [] } };
|
|
82
|
+
const html = Buffer.from(rendered.body);
|
|
83
|
+
return { status: rendered.status, headers: rendered.headers, body: [html, rendered.flight],
|
|
84
|
+
isr: { revalidate: rendered.revalidate, htmlLength: html.byteLength, dataLength: rendered.flight.byteLength,
|
|
85
|
+
tags: rendered.tags, paths: rendered.paths } };
|
|
86
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// One initial transferable chunk, then one chunk per reader pull. The initial
|
|
2
|
+
// credit removes a round trip while keeping the queue bounded to 64 KiB.
|
|
3
|
+
export const APP_STREAM_CHUNK_BYTES = 64 * 1024;
|
|
4
|
+
|
|
5
|
+
export function workerStreamChannel(port, id) {
|
|
6
|
+
const abort = new AbortController();
|
|
7
|
+
let credit = 0;
|
|
8
|
+
let wake;
|
|
9
|
+
let started = false;
|
|
10
|
+
let metadata;
|
|
11
|
+
let ended = false;
|
|
12
|
+
let previousSignal;
|
|
13
|
+
let combinedSignal;
|
|
14
|
+
const wait = async signal => {
|
|
15
|
+
while (!credit) {
|
|
16
|
+
signal.throwIfAborted();
|
|
17
|
+
await new Promise(resolve => {
|
|
18
|
+
wake = () => { signal.removeEventListener('abort', wake); resolve(); };
|
|
19
|
+
signal.addEventListener('abort', wake, { once: true });
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
return {
|
|
24
|
+
signal: abort.signal,
|
|
25
|
+
get started() { return started; },
|
|
26
|
+
credit() { credit = 1; wake?.(); wake = undefined; },
|
|
27
|
+
cancel() { abort.abort(new Error('App Router response was cancelled')); wake?.(); wake = undefined; },
|
|
28
|
+
start(value) { started = true; metadata = value; credit = 1; },
|
|
29
|
+
async write(bytes, signal = abort.signal, done = false) {
|
|
30
|
+
if (signal !== previousSignal) {
|
|
31
|
+
previousSignal = signal;
|
|
32
|
+
combinedSignal = signal === abort.signal ? signal : AbortSignal.any([abort.signal, signal]);
|
|
33
|
+
}
|
|
34
|
+
for (let offset = 0; offset < bytes.byteLength; offset += APP_STREAM_CHUNK_BYTES) {
|
|
35
|
+
if (!credit) await wait(combinedSignal);
|
|
36
|
+
combinedSignal.throwIfAborted();
|
|
37
|
+
credit--;
|
|
38
|
+
const length = Math.min(APP_STREAM_CHUNK_BYTES, bytes.byteLength - offset);
|
|
39
|
+
// A byte ReadableStream transfers its allocation to the reader. Keep
|
|
40
|
+
// that ownership across this bridge; Node's pooled Buffers are copied.
|
|
41
|
+
let chunk;
|
|
42
|
+
if (!Buffer.isBuffer(bytes) && offset === 0 && length === bytes.byteLength && bytes.buffer.byteLength <= APP_STREAM_CHUNK_BYTES) chunk = bytes;
|
|
43
|
+
else { chunk = new Uint8Array(length); chunk.set(bytes.subarray(offset, offset + length)); }
|
|
44
|
+
const last = offset + length === bytes.byteLength;
|
|
45
|
+
const message = { id, type: metadata ? 'start' : 'chunk', ...metadata,
|
|
46
|
+
chunk: chunk.buffer, byteOffset: chunk.byteOffset, byteLength: chunk.byteLength, done: done && last };
|
|
47
|
+
metadata = undefined;
|
|
48
|
+
port.postMessage(message, [chunk.buffer]);
|
|
49
|
+
if (message.done) ended = true;
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
end() {
|
|
53
|
+
if (ended) return;
|
|
54
|
+
ended = true;
|
|
55
|
+
if (metadata) port.postMessage({ id, type: 'start', ...metadata, done: true });
|
|
56
|
+
else port.postMessage({ id, type: 'end' });
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function parentStreamChannel(port, id, cancelled) {
|
|
62
|
+
let controller;
|
|
63
|
+
let settlePull;
|
|
64
|
+
let closed = false;
|
|
65
|
+
const body = new ReadableStream({
|
|
66
|
+
start(value) { controller = value; },
|
|
67
|
+
pull() {
|
|
68
|
+
return new Promise(resolve => {
|
|
69
|
+
settlePull = resolve;
|
|
70
|
+
port.postMessage({ id, type: 'credit' });
|
|
71
|
+
});
|
|
72
|
+
},
|
|
73
|
+
cancel(reason) {
|
|
74
|
+
closed = true;
|
|
75
|
+
settlePull?.();
|
|
76
|
+
port.postMessage({ id, type: 'cancel' });
|
|
77
|
+
cancelled(reason);
|
|
78
|
+
},
|
|
79
|
+
}, { highWaterMark: 0 });
|
|
80
|
+
return {
|
|
81
|
+
body,
|
|
82
|
+
chunk(buffer, offset = 0, length = buffer?.byteLength) {
|
|
83
|
+
if (closed) return;
|
|
84
|
+
if (!(buffer instanceof ArrayBuffer) || buffer.byteLength > APP_STREAM_CHUNK_BYTES ||
|
|
85
|
+
!Number.isInteger(offset) || !Number.isInteger(length) || offset < 0 || length < 0 || offset + length > buffer.byteLength) throw new Error('Invalid App Router stream chunk');
|
|
86
|
+
controller.enqueue(new Uint8Array(buffer, offset, length));
|
|
87
|
+
settlePull?.(); settlePull = undefined;
|
|
88
|
+
},
|
|
89
|
+
end() { if (!closed) { closed = true; controller.close(); } settlePull?.(); },
|
|
90
|
+
error(error) { if (!closed) { closed = true; controller.error(error); } settlePull?.(); },
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { fontPreloads } from './font-preload.mjs';
|
|
3
|
+
import { isDynamicBailout } from '../compat/dynamic-bailout.cjs';
|
|
4
|
+
import { renderToReadableStream, resume } from 'react-dom/server';
|
|
5
|
+
import { AppRouterProvider } from '../compat/app-context.cjs';
|
|
6
|
+
import { appContent } from './app-content.mjs';
|
|
7
|
+
import { escapeHtml, MAX_RESPONSE_BYTES } from './http.mjs';
|
|
8
|
+
import { appTimeoutError, appErrorDigest } from './app-errors.mjs';
|
|
9
|
+
import { navigationResponse } from './navigation.mjs';
|
|
10
|
+
import { addBasePath } from '../compat/paths.cjs';
|
|
11
|
+
import { renderAppRecoveryShell } from './app-recovery.mjs';
|
|
12
|
+
import { partialModel } from './app-partial-model.mjs';
|
|
13
|
+
import { streamSelector } from './stream-select.mjs';
|
|
14
|
+
|
|
15
|
+
// Do not insert bootstrap scripts inside a split HTML tag or raw-text element.
|
|
16
|
+
// React may split a large inline script across arbitrary byte chunks.
|
|
17
|
+
function htmlEnvelope({ resumed = false } = {}) {
|
|
18
|
+
let pending = '';
|
|
19
|
+
let closing = '';
|
|
20
|
+
let received = 0;
|
|
21
|
+
return {
|
|
22
|
+
append(text) {
|
|
23
|
+
received += Buffer.byteLength(text);
|
|
24
|
+
if (received > MAX_RESPONSE_BYTES) throw new Error('HTML response exceeds the 16 MiB PRNext limit');
|
|
25
|
+
pending += text;
|
|
26
|
+
let ready = '';
|
|
27
|
+
while (pending) {
|
|
28
|
+
const start = pending.indexOf('<');
|
|
29
|
+
if (start < 0) { ready += pending; pending = ''; break; }
|
|
30
|
+
if (start) { ready += pending.slice(0, start); pending = pending.slice(start); }
|
|
31
|
+
let end = -1;
|
|
32
|
+
if (pending.startsWith('<!--')) {
|
|
33
|
+
const comment = pending.indexOf('-->');
|
|
34
|
+
if (comment >= 0) end = comment + 2;
|
|
35
|
+
} else {
|
|
36
|
+
let quote;
|
|
37
|
+
for (let index = 1; index < pending.length; index++) {
|
|
38
|
+
const char = pending[index];
|
|
39
|
+
if (quote) { if (char === quote) quote = undefined; }
|
|
40
|
+
else if (char === '"' || char === "'") quote = char;
|
|
41
|
+
else if (char === '>') { end = index; break; }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (end < 0) break;
|
|
45
|
+
const tag = pending.slice(0, end + 1);
|
|
46
|
+
const raw = /^<(script|style|textarea|title|svg|math)(?:\s|>)/i.exec(tag);
|
|
47
|
+
if (raw && !tag.endsWith('/>')) {
|
|
48
|
+
const match = new RegExp(`</${raw[1]}\\s*>`, 'i').exec(pending.slice(end + 1));
|
|
49
|
+
if (!match) break;
|
|
50
|
+
end += 1 + match.index + match[0].length;
|
|
51
|
+
ready += pending.slice(0, end); pending = pending.slice(end);
|
|
52
|
+
} else {
|
|
53
|
+
if (/^<\/(body|html)\s*>$/i.test(tag)) closing += tag;
|
|
54
|
+
else ready += tag;
|
|
55
|
+
pending = pending.slice(end + 1);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return ready;
|
|
59
|
+
},
|
|
60
|
+
finish() {
|
|
61
|
+
if (pending || !(resumed ? /^(?:<\/body><\/html>){1,2}$/i : /^<\/body><\/html>$/i).test(closing)) throw new Error('App Router root layout did not close its body and html elements');
|
|
62
|
+
return resumed ? '</body></html>' : closing;
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// The SSR decoder and HTML bootstrap share each Flight allocation. Unlike
|
|
68
|
+
// ReadableStream.tee(), this tap stops reading when the HTML consumer stalls.
|
|
69
|
+
// Before a document shell exists, at most the overall 16 MiB response limit can
|
|
70
|
+
// accumulate: an async root layout may need the complete model to produce it.
|
|
71
|
+
function flightTap(source) {
|
|
72
|
+
const reader = source.getReader();
|
|
73
|
+
const queue = [];
|
|
74
|
+
let bytes = 0;
|
|
75
|
+
let done = false;
|
|
76
|
+
let failure;
|
|
77
|
+
let outputStarted = false;
|
|
78
|
+
let wakeData;
|
|
79
|
+
let wakeSpace;
|
|
80
|
+
const finish = error => {
|
|
81
|
+
done = true; failure = error;
|
|
82
|
+
wakeData?.(); wakeData = undefined;
|
|
83
|
+
wakeSpace?.(); wakeSpace = undefined;
|
|
84
|
+
};
|
|
85
|
+
const cancel = async reason => { finish(reason); await reader.cancel(reason).catch(() => {}); };
|
|
86
|
+
const body = new ReadableStream({
|
|
87
|
+
async pull(controller) {
|
|
88
|
+
try {
|
|
89
|
+
while (!done && outputStarted && bytes >= 128 * 1024) await new Promise(resolve => { wakeSpace = resolve; });
|
|
90
|
+
if (done) { if (failure) controller.error(failure); else controller.close(); return; }
|
|
91
|
+
const next = await reader.read();
|
|
92
|
+
if (next.done) { finish(); controller.close(); return; }
|
|
93
|
+
queue.push(next.value); bytes += next.value.byteLength;
|
|
94
|
+
wakeData?.(); wakeData = undefined;
|
|
95
|
+
controller.enqueue(next.value);
|
|
96
|
+
} catch (error) { finish(error); controller.error(error); }
|
|
97
|
+
},
|
|
98
|
+
cancel,
|
|
99
|
+
}, { highWaterMark: 0 });
|
|
100
|
+
return {
|
|
101
|
+
body, cancel,
|
|
102
|
+
startOutput() { outputStarted = true; },
|
|
103
|
+
async next() {
|
|
104
|
+
while (!queue.length && !done) await new Promise(resolve => { wakeData = resolve; });
|
|
105
|
+
if (queue.length) {
|
|
106
|
+
const value = queue.shift(); bytes -= value.byteLength;
|
|
107
|
+
wakeSpace?.(); wakeSpace = undefined;
|
|
108
|
+
return { value, done: false };
|
|
109
|
+
}
|
|
110
|
+
if (failure) throw failure;
|
|
111
|
+
return { done: true };
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function renderProgressiveAppHtml({ result, request, route, responseHeaders, decodeFlight, signal, timeoutMs = 25_000, partial }) {
|
|
117
|
+
const nonceAttribute = request.nonce ? ` nonce="${escapeHtml(request.nonce)}"` : '';
|
|
118
|
+
const tap = flightTap(result.body);
|
|
119
|
+
const abort = new AbortController();
|
|
120
|
+
const timeout = setTimeout(() => abort.abort(appTimeoutError('HTML stream', timeoutMs)), timeoutMs);
|
|
121
|
+
const combinedSignal = signal ? AbortSignal.any([signal, abort.signal]) : abort.signal;
|
|
122
|
+
let reader;
|
|
123
|
+
let renderError;
|
|
124
|
+
let committed = Boolean(partial?.shellSent);
|
|
125
|
+
let control;
|
|
126
|
+
let model;
|
|
127
|
+
const cancel = async reason => {
|
|
128
|
+
clearTimeout(timeout);
|
|
129
|
+
combinedSignal.removeEventListener('abort', onAbort);
|
|
130
|
+
if (!abort.signal.aborted) abort.abort(reason || new Error('App Router response was cancelled'));
|
|
131
|
+
await Promise.allSettled([tap.cancel(reason), reader?.cancel(reason)]);
|
|
132
|
+
};
|
|
133
|
+
const onAbort = () => { void cancel(combinedSignal.reason); };
|
|
134
|
+
combinedSignal.addEventListener('abort', onAbort, { once: true });
|
|
135
|
+
try {
|
|
136
|
+
model = await decodeFlight(tap.body, request.clientModules, request.distDir, { production: request.production });
|
|
137
|
+
const renderOptions = {
|
|
138
|
+
signal: combinedSignal,
|
|
139
|
+
nonce: request.nonce,
|
|
140
|
+
bootstrapScriptContent: 'self.__PRNEXT_FLIGHT_STREAM__=self.__PRNEXT_FLIGHT_STREAM__||[];',
|
|
141
|
+
bootstrapModules: route.client ? [route.client] : undefined,
|
|
142
|
+
onError(error) {
|
|
143
|
+
if (isDynamicBailout(error)) return error.digest;
|
|
144
|
+
if (combinedSignal.aborted) return error?.digest;
|
|
145
|
+
const navigation = navigationResponse(error);
|
|
146
|
+
if (navigation) {
|
|
147
|
+
if (committed) control = navigation;
|
|
148
|
+
else renderError ??= error;
|
|
149
|
+
} else console.error('[prnext]', error?.stack || error);
|
|
150
|
+
return appErrorDigest(error);
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
const resumedModel = partial?.resumeKeys?.length ? partialModel(model, { keyMap: new Map(partial.resumeKeys) }) : model;
|
|
154
|
+
const element = React.createElement(request.strictMode !== false ? React.StrictMode : React.Fragment, null, React.createElement(AppRouterProvider, { router: resumedModel.router, nonce: request.nonce }, appContent(resumedModel.tree)));
|
|
155
|
+
// React owns and mutates its opaque continuation during resume. Parsed
|
|
156
|
+
// artifacts can be shared, but each HTML request needs its own continuation.
|
|
157
|
+
const html = partial ? await resume(element, structuredClone(partial.postponed), renderOptions) : await renderToReadableStream(element, renderOptions);
|
|
158
|
+
reader = html.getReader();
|
|
159
|
+
const decoder = new TextDecoder();
|
|
160
|
+
let prefix = partial?.shell || '';
|
|
161
|
+
let htmlDone = false;
|
|
162
|
+
// Validate the actual rendered root before handing headers to the transport.
|
|
163
|
+
// A Suspense shell already contains these tags, so delayed children do not
|
|
164
|
+
// block the first byte. A root that omits them fails before a 200 is sent.
|
|
165
|
+
while (!/<body(?:\s[^>]*)?>/i.test(prefix)) {
|
|
166
|
+
const next = await reader.read();
|
|
167
|
+
if (next.done) { htmlDone = true; prefix += decoder.decode(); break; }
|
|
168
|
+
prefix += decoder.decode(next.value, { stream: true });
|
|
169
|
+
if (Buffer.byteLength(prefix) > MAX_RESPONSE_BYTES) throw new Error('HTML shell exceeds the 16 MiB PRNext limit');
|
|
170
|
+
}
|
|
171
|
+
if (renderError) throw renderError;
|
|
172
|
+
if (!/<html(?:\s|>)/i.test(prefix) || !/<body(?:\s|>)/i.test(prefix) || !/<\/head>/i.test(prefix)) {
|
|
173
|
+
throw Object.assign(new Error('App Router root layout must render <html>, <head> and <body> elements'), { code: 'PRNEXT_INVALID_ROOT_LAYOUT' });
|
|
174
|
+
}
|
|
175
|
+
const styles = fontPreloads(route.fonts, request.nonce) + (route.css || []).map(href => `<link rel="stylesheet" href="${escapeHtml(href)}">`).join('');
|
|
176
|
+
if (styles && !partial) prefix = prefix.replace(/<\/head>/i, `${styles}</head>`);
|
|
177
|
+
tap.startOutput();
|
|
178
|
+
committed = true;
|
|
179
|
+
const body = (async function* () {
|
|
180
|
+
let total = 0;
|
|
181
|
+
const envelope = htmlEnvelope({ resumed: Boolean(partial) });
|
|
182
|
+
const encode = value => {
|
|
183
|
+
const bytes = Buffer.from(value);
|
|
184
|
+
total += bytes.byteLength;
|
|
185
|
+
if (total > MAX_RESPONSE_BYTES) throw new Error('Response exceeds the 16 MiB PRNext limit');
|
|
186
|
+
return bytes;
|
|
187
|
+
};
|
|
188
|
+
let flightDone = false;
|
|
189
|
+
const selection = streamSelector();
|
|
190
|
+
if (!htmlDone) selection.watch('html', reader.read());
|
|
191
|
+
selection.watch('flight', tap.next());
|
|
192
|
+
try {
|
|
193
|
+
const first = envelope.append(prefix);
|
|
194
|
+
prefix = '';
|
|
195
|
+
if (first && !partial?.shellSent) yield encode(first);
|
|
196
|
+
while (!htmlDone || !flightDone) {
|
|
197
|
+
combinedSignal.throwIfAborted();
|
|
198
|
+
const next = await selection.next();
|
|
199
|
+
if (next.type === 'html') {
|
|
200
|
+
if (next.done) {
|
|
201
|
+
htmlDone = true;
|
|
202
|
+
const text = envelope.append(decoder.decode());
|
|
203
|
+
if (text) yield encode(text);
|
|
204
|
+
} else {
|
|
205
|
+
const text = envelope.append(decoder.decode(next.value, { stream: true }));
|
|
206
|
+
selection.watch('html', reader.read());
|
|
207
|
+
if (text) yield encode(text);
|
|
208
|
+
}
|
|
209
|
+
} else {
|
|
210
|
+
if (next.done) { flightDone = true; }
|
|
211
|
+
else {
|
|
212
|
+
const encoded = Buffer.from(next.value.buffer, next.value.byteOffset, next.value.byteLength).toString('base64');
|
|
213
|
+
selection.watch('flight', tap.next());
|
|
214
|
+
yield encode(`<script${nonceAttribute}>(self.__PRNEXT_FLIGHT_STREAM__||=[]).push("${encoded}");document.currentScript.remove()</script>`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (control) {
|
|
218
|
+
const current = control; control = null;
|
|
219
|
+
if (current.headers.location) yield encode(`<meta http-equiv="refresh" content="${current.status === 308 ? 0 : 1};url=${escapeHtml(addBasePath(current.headers.location, request.basePath || ''))}">`);
|
|
220
|
+
else if (current.status === 404) yield encode('<meta name="robots" content="noindex">');
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
yield encode(`<script${nonceAttribute}>self.__PRNEXT_FLIGHT_STREAM__.push(null);document.currentScript.remove()</script>` + envelope.finish());
|
|
224
|
+
} finally {
|
|
225
|
+
selection.close();
|
|
226
|
+
combinedSignal.removeEventListener('abort', onAbort);
|
|
227
|
+
await cancel();
|
|
228
|
+
}
|
|
229
|
+
})();
|
|
230
|
+
// An error encoded in Flight may be contained by a valid Suspense shell.
|
|
231
|
+
// Ordinary GET errors select HTTP 500 only when that shell also fails.
|
|
232
|
+
const status = result.rscError && result.status === 500 && ['GET', 'HEAD'].includes(request.method) ? 200 : result.status;
|
|
233
|
+
return { status, headers: { ...responseHeaders, ...result.headers, 'content-type': 'text/html; charset=utf-8' }, body, cancel };
|
|
234
|
+
} catch (error) {
|
|
235
|
+
if (!committed && !combinedSignal.aborted && error?.statusCode !== 504 &&
|
|
236
|
+
error?.code !== 'PRNEXT_INVALID_ROOT_LAYOUT' && !isDynamicBailout(error) && (!navigationResponse(error) || (route.parallel && navigationResponse(error).status === 404))) {
|
|
237
|
+
try {
|
|
238
|
+
// Next emits an empty document after a shell failure. The original
|
|
239
|
+
// Flight stream still carries the failing subtree to client boundaries;
|
|
240
|
+
// never rerun a GET just to render an error UI on the server.
|
|
241
|
+
const shell = await renderAppRecoveryShell(model?.head, { signal: combinedSignal, timeoutMs });
|
|
242
|
+
const end = shell.lastIndexOf('</body>');
|
|
243
|
+
const bootstrap = `<script${nonceAttribute}>self.__PRNEXT_FLIGHT_STREAM__=self.__PRNEXT_FLIGHT_STREAM__||[];</script>` +
|
|
244
|
+
(route.client ? `<script type="module" async${nonceAttribute} src="${escapeHtml(route.client)}"></script>` : '');
|
|
245
|
+
const prefix = shell.slice(0, end) + bootstrap;
|
|
246
|
+
const closing = shell.slice(end);
|
|
247
|
+
tap.startOutput();
|
|
248
|
+
const body = (async function* () {
|
|
249
|
+
let total = 0;
|
|
250
|
+
const encode = value => {
|
|
251
|
+
const bytes = Buffer.from(value);
|
|
252
|
+
total += bytes.byteLength;
|
|
253
|
+
if (total > MAX_RESPONSE_BYTES) throw new Error('Response exceeds the 16 MiB PRNext limit');
|
|
254
|
+
return bytes;
|
|
255
|
+
};
|
|
256
|
+
try {
|
|
257
|
+
yield encode(prefix);
|
|
258
|
+
for (;;) {
|
|
259
|
+
combinedSignal.throwIfAborted();
|
|
260
|
+
const next = await tap.next();
|
|
261
|
+
if (next.done) break;
|
|
262
|
+
const encoded = Buffer.from(next.value.buffer, next.value.byteOffset, next.value.byteLength).toString('base64');
|
|
263
|
+
yield encode(`<script${nonceAttribute}>(self.__PRNEXT_FLIGHT_STREAM__||=[]).push("${encoded}");document.currentScript.remove()</script>`);
|
|
264
|
+
}
|
|
265
|
+
yield encode(`<script${nonceAttribute}>self.__PRNEXT_FLIGHT_STREAM__.push(null);document.currentScript.remove()</script>` + closing);
|
|
266
|
+
} finally { await cancel(); }
|
|
267
|
+
})();
|
|
268
|
+
return { status: route.parallel && navigationResponse(error)?.status === 404 ? 404 : 500, headers: { ...responseHeaders, ...result.headers, 'content-type': 'text/html; charset=utf-8' }, body, cancel };
|
|
269
|
+
} catch (recoveryError) {
|
|
270
|
+
await cancel(recoveryError);
|
|
271
|
+
throw combinedSignal.reason?.statusCode === 504 ? combinedSignal.reason : recoveryError;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
combinedSignal.removeEventListener('abort', onAbort);
|
|
275
|
+
await cancel(error);
|
|
276
|
+
if (combinedSignal.reason?.statusCode === 504) throw combinedSignal.reason;
|
|
277
|
+
throw error;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { open, realpath, lstat, mkdir, readFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export const OUTPUT_POINTER = '.prnext-output.json';
|
|
5
|
+
const reserved = new Set(['public','node_modules','app','pages','src','components','lib','packages','target','.git','.prnext-output.json']);
|
|
6
|
+
export function validateDistDir(value = '.prnext') {
|
|
7
|
+
if (typeof value !== 'string' || !value || Buffer.byteLength(value) > 1024 || path.isAbsolute(value) || /[\\\x00-\x1f\x7f:]/.test(value)) throw new TypeError('distDir must be a relative directory inside the project');
|
|
8
|
+
const segments = value.split('/');
|
|
9
|
+
if (segments.some(segment => !segment || segment === '.' || segment === '..') || reserved.has(segments[0])) throw new TypeError('distDir cannot traverse or replace project source, public, dependency or repository directories');
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Starting a published artifact never evaluates application configuration. */
|
|
14
|
+
export async function readBuildDirectory(root) {
|
|
15
|
+
let file;
|
|
16
|
+
try {
|
|
17
|
+
file = await open(path.join(root, OUTPUT_POINTER), 'r');
|
|
18
|
+
const buffer = Buffer.alloc(4097);
|
|
19
|
+
const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
|
|
20
|
+
if (bytesRead > 4096) throw new Error('Invalid PRNext output pointer: exceeds 4 KiB');
|
|
21
|
+
const pointer = JSON.parse(buffer.subarray(0, bytesRead).toString());
|
|
22
|
+
if (typeof pointer?.distDir !== 'string') throw new Error('Invalid PRNext output pointer: missing distDir');
|
|
23
|
+
return validateDistDir(pointer.distDir);
|
|
24
|
+
} catch (error) { if (error.code === 'ENOENT') return '.prnext'; throw error; }
|
|
25
|
+
finally { await file?.close(); }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Refuse symlink destinations or ancestors before any build can replace them. */
|
|
29
|
+
export async function prepareBuildDirectory(root, relative) {
|
|
30
|
+
const canonical = await realpath(root);
|
|
31
|
+
let current = canonical;
|
|
32
|
+
for (const part of validateDistDir(relative).split('/')) {
|
|
33
|
+
current = path.join(current, part);
|
|
34
|
+
try {
|
|
35
|
+
const entry = await lstat(current);
|
|
36
|
+
if (entry.isSymbolicLink() || !entry.isDirectory()) throw new Error('distDir must contain only real directories, not symbolic links or files');
|
|
37
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
await lstat(current);
|
|
41
|
+
const manifest = JSON.parse(await readFile(path.join(current, 'manifest.json'), 'utf8'));
|
|
42
|
+
if (manifest.version !== 1 || !Array.isArray(manifest.routes)) throw new Error('invalid manifest');
|
|
43
|
+
} catch (error) {
|
|
44
|
+
if (error.code !== 'ENOENT' || await lstat(current).then(() => true, () => false)) throw new Error('distDir already exists without a PRNext manifest; refusing to replace unrelated files');
|
|
45
|
+
}
|
|
46
|
+
await mkdir(path.dirname(current), {recursive:true});
|
|
47
|
+
return current;
|
|
48
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const MAX_BYTES = 2 * 1024 * 1024;
|
|
2
|
+
const tooLarge = () => new Error("'use cache' arguments exceed 2 MiB");
|
|
3
|
+
export async function encodeCacheArguments(body) {
|
|
4
|
+
if (typeof body === 'string') {
|
|
5
|
+
if (Buffer.byteLength(body) > MAX_BYTES) throw tooLarge();
|
|
6
|
+
return Buffer.from(body);
|
|
7
|
+
}
|
|
8
|
+
const entries = [];
|
|
9
|
+
let size = 0;
|
|
10
|
+
for (const [name, value] of body) {
|
|
11
|
+
const text = typeof value === 'string';
|
|
12
|
+
const head = JSON.stringify([name, text ? 'text' : 'blob', text ? '' : value.type, text ? '' : value.name]);
|
|
13
|
+
const headLength = Buffer.byteLength(head);
|
|
14
|
+
const length = text ? Buffer.byteLength(value) : value.size;
|
|
15
|
+
size += 8 + headLength + length;
|
|
16
|
+
if (size > MAX_BYTES) throw tooLarge();
|
|
17
|
+
if (entries.length >= 10000) throw new Error("'use cache' arguments exceed 10000 encoded fields");
|
|
18
|
+
entries.push({ head, headLength, value, length, text });
|
|
19
|
+
}
|
|
20
|
+
// Check the complete body before copying Blob bytes or invoking decodeReply.
|
|
21
|
+
const result = Buffer.allocUnsafe(size);
|
|
22
|
+
let offset = 0;
|
|
23
|
+
for (const entry of entries) {
|
|
24
|
+
result.writeUInt32BE(entry.headLength, offset);
|
|
25
|
+
result.writeUInt32BE(entry.length, offset + 4);
|
|
26
|
+
offset += 8;
|
|
27
|
+
offset += result.write(entry.head, offset);
|
|
28
|
+
if (entry.text) result.write(entry.value, offset);
|
|
29
|
+
else Buffer.from(await entry.value.arrayBuffer()).copy(result, offset);
|
|
30
|
+
offset += entry.length;
|
|
31
|
+
}
|
|
32
|
+
return result;
|
|
33
|
+
}
|