@wular/pnext 0.0.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 +153 -0
- package/bin/pnext +67 -0
- package/config/lint/base.js +48 -0
- package/config/ts/base.json +26 -0
- package/config/ts/react.json +13 -0
- package/package.json +70 -0
- package/reference/compat.md +63 -0
- package/reference/config.md +120 -0
- package/reference/css.md +58 -0
- package/reference/dev.md +69 -0
- package/reference/env.md +40 -0
- package/reference/metadata.md +86 -0
- package/reference/navigation.md +149 -0
- package/reference/overview.md +35 -0
- package/reference/performance.md +97 -0
- package/reference/rendering.md +127 -0
- package/reference/routing.md +167 -0
- package/reference/typegen.md +64 -0
- package/src/api/cache.ts +80 -0
- package/src/api/client-cache.ts +9 -0
- package/src/api/client-navigation.ts +279 -0
- package/src/api/dynamic.tsx +102 -0
- package/src/api/link.tsx +119 -0
- package/src/api/navigation.ts +198 -0
- package/src/api/router/events.ts +53 -0
- package/src/api/router/history.ts +70 -0
- package/src/api/router/hub.ts +194 -0
- package/src/api/router/policies.ts +107 -0
- package/src/api/router/runtime.ts +5238 -0
- package/src/api/router/types.ts +299 -0
- package/src/api/router.ts +167 -0
- package/src/api/server.ts +323 -0
- package/src/api/suspense.ts +16 -0
- package/src/cache/context.ts +61 -0
- package/src/cli/adapters/vercel-warm.ts +375 -0
- package/src/cli/adapters/vercel.ts +1310 -0
- package/src/cli/analyze-print.ts +181 -0
- package/src/cli/analyze.ts +328 -0
- package/src/cli/boot-trace.ts +29 -0
- package/src/cli/build.ts +3114 -0
- package/src/cli/dev.ts +276 -0
- package/src/cli/index.ts +196 -0
- package/src/cli/named-bin.ts +119 -0
- package/src/cli/serve-ui.ts +160 -0
- package/src/cli/start.ts +1425 -0
- package/src/client/build.ts +2136 -0
- package/src/client/chunk-fold.ts +526 -0
- package/src/client/entry.ts +1525 -0
- package/src/client/paths.ts +22 -0
- package/src/client/prebuilt.ts +621 -0
- package/src/client/profile.ts +75 -0
- package/src/client/react-compiler.ts +94 -0
- package/src/client/reference-stub.ts +145 -0
- package/src/client/reference.ts +47 -0
- package/src/compat/actions/action-client.ts +531 -0
- package/src/compat/actions/action-dispatch.ts +676 -0
- package/src/compat/actions/action-router.ts +40 -0
- package/src/compat/actions/action-shared.ts +95 -0
- package/src/compat/actions/client-plugin.ts +135 -0
- package/src/compat/actions/client-stub.ts +65 -0
- package/src/compat/actions/config.ts +164 -0
- package/src/compat/actions/detect.ts +208 -0
- package/src/compat/actions/discovery.ts +244 -0
- package/src/compat/actions/early-submit.ts +40 -0
- package/src/compat/actions/endpoint.ts +602 -0
- package/src/compat/actions/flight.ts +52 -0
- package/src/compat/actions/form-state.ts +73 -0
- package/src/compat/actions/hoist.ts +485 -0
- package/src/compat/actions/ids.ts +39 -0
- package/src/compat/actions/index.ts +41 -0
- package/src/compat/actions/instances.ts +144 -0
- package/src/compat/actions/origin.ts +109 -0
- package/src/compat/actions/protocol.ts +125 -0
- package/src/compat/actions/registry.ts +74 -0
- package/src/compat/actions/rewrite.ts +282 -0
- package/src/compat/actions/serve.ts +414 -0
- package/src/compat/actions/server-tag.ts +21 -0
- package/src/compat/actions/unrecognized-error.ts +30 -0
- package/src/compat/adapter/build-complete.ts +257 -0
- package/src/compat/bundler/bun-externals.ts +53 -0
- package/src/compat/bundler/cjs-exports.ts +542 -0
- package/src/compat/bundler/config.ts +363 -0
- package/src/compat/bundler/externals.ts +34 -0
- package/src/compat/bundler/import-meta-url.ts +60 -0
- package/src/compat/bundler/modularize-imports.ts +119 -0
- package/src/compat/bundler/new-url-asset.ts +87 -0
- package/src/compat/bundler/optimize-package-imports.ts +273 -0
- package/src/compat/bundler/polyfill.ts +88 -0
- package/src/compat/bundler/react-compiler.ts +61 -0
- package/src/compat/bundler/react-profiler.tsx +25 -0
- package/src/compat/bundler/relay-transform.ts +116 -0
- package/src/compat/bundler/require-context.ts +281 -0
- package/src/compat/bundler/resolve-extensions.ts +75 -0
- package/src/compat/bundler/source-cache.ts +61 -0
- package/src/compat/bundler/static-imports.ts +25 -0
- package/src/compat/bundler/symlink-imports.ts +119 -0
- package/src/compat/bundler/tsconfig-paths.ts +50 -0
- package/src/compat/bundler/wasm.ts +153 -0
- package/src/compat/bundler/webpack-loaders.ts +685 -0
- package/src/compat/bundler/worker.ts +278 -0
- package/src/compat/cache/build-flags.ts +81 -0
- package/src/compat/cache/build-prerender-errors.ts +163 -0
- package/src/compat/cache/custom-handler.ts +159 -0
- package/src/compat/cache/fetch-patch.ts +745 -0
- package/src/compat/cache/handler.ts +100 -0
- package/src/compat/cache/modern-handler.ts +275 -0
- package/src/compat/cache/resume-data-cache.ts +143 -0
- package/src/compat/cache/revalidate.ts +759 -0
- package/src/compat/cache/runtime-error.ts +124 -0
- package/src/compat/cache/use-cache-transform.ts +961 -0
- package/src/compat/cache/use-cache.ts +1695 -0
- package/src/compat/cache-control.ts +269 -0
- package/src/compat/client/base-path.ts +64 -0
- package/src/compat/client/css-order.ts +36 -0
- package/src/compat/client/errors/control-flow.ts +92 -0
- package/src/compat/client/errors/error-boundary.ts +222 -0
- package/src/compat/client/errors/global-error.ts +238 -0
- package/src/compat/client/errors/install.ts +217 -0
- package/src/compat/client/errors/lazy.ts +53 -0
- package/src/compat/client/errors/primitive-throw.ts +126 -0
- package/src/compat/client/errors/soft-refresh.ts +14 -0
- package/src/compat/client/link-status.ts +86 -0
- package/src/compat/client/nav-compat-runtime.ts +57 -0
- package/src/compat/client/nav-compat.ts +42 -0
- package/src/compat/client/navigation-scroll.ts +154 -0
- package/src/compat/client/optimistic-routing.ts +206 -0
- package/src/compat/client/prefetch-cache.ts +111 -0
- package/src/compat/client/route-announcer.ts +72 -0
- package/src/compat/client/segment-cache-policy.ts +159 -0
- package/src/compat/client/segment-cache.ts +1077 -0
- package/src/compat/client/segment-prefetch.ts +375 -0
- package/src/compat/client/trailing-slash.ts +24 -0
- package/src/compat/css/chunking.ts +254 -0
- package/src/compat/css/inline-css.ts +73 -0
- package/src/compat/css/lightningcss.ts +90 -0
- package/src/compat/css/modules.ts +373 -0
- package/src/compat/css/nonce.ts +30 -0
- package/src/compat/css/sass-plugin.ts +65 -0
- package/src/compat/css/sass.ts +392 -0
- package/src/compat/css/styled-jsx-runtime.ts +80 -0
- package/src/compat/css/styled-jsx.ts +49 -0
- package/src/compat/edge-runtime.ts +71 -0
- package/src/compat/export/client.ts +112 -0
- package/src/compat/export/index.ts +272 -0
- package/src/compat/export/standalone.ts +207 -0
- package/src/compat/image-optimizer/cache.ts +119 -0
- package/src/compat/image-optimizer/detect.ts +143 -0
- package/src/compat/image-optimizer/index.ts +601 -0
- package/src/compat/image-optimizer/source.ts +243 -0
- package/src/compat/index.ts +458 -0
- package/src/compat/lifecycle/after-scope.ts +86 -0
- package/src/compat/lifecycle/after.ts +173 -0
- package/src/compat/lifecycle/error-funnel.ts +306 -0
- package/src/compat/lifecycle/error-serialize.ts +87 -0
- package/src/compat/lifecycle/error-ui.ts +167 -0
- package/src/compat/lifecycle/instrumentation-client.ts +138 -0
- package/src/compat/lifecycle/instrumentation.ts +277 -0
- package/src/compat/lifecycle/node-console.ts +19 -0
- package/src/compat/lifecycle/testmode.ts +263 -0
- package/src/compat/mdx/compile.ts +219 -0
- package/src/compat/mdx/next-mdx-stub.ts +46 -0
- package/src/compat/mdx/plugin.ts +37 -0
- package/src/compat/metadata-route-artifacts.ts +458 -0
- package/src/compat/metadata.ts +295 -0
- package/src/compat/middleware/manifest.ts +210 -0
- package/src/compat/misc/action-return.ts +173 -0
- package/src/compat/next/cache.ts +211 -0
- package/src/compat/next/canonical-url.ts +35 -0
- package/src/compat/next/client-cache.ts +57 -0
- package/src/compat/next/client-navigation.ts +313 -0
- package/src/compat/next/client-only.ts +3 -0
- package/src/compat/next/client-script.tsx +215 -0
- package/src/compat/next/client-server.ts +39 -0
- package/src/compat/next/config-loader.ts +569 -0
- package/src/compat/next/config.ts +29 -0
- package/src/compat/next/constants.cjs +6 -0
- package/src/compat/next/constants.ts +6 -0
- package/src/compat/next/custom-server.ts +236 -0
- package/src/compat/next/dist/client/components/app-router-headers.ts +32 -0
- package/src/compat/next/dist/server/app-render/work-unit-async-storage.external.cjs +38 -0
- package/src/compat/next/dist/server/web/spec-extension/revalidate.ts +1 -0
- package/src/compat/next/dist/server/web/spec-extension/unstable-cache.ts +1 -0
- package/src/compat/next/dist/server/web/spec-extension/unstable-no-store.ts +1 -0
- package/src/compat/next/dynamic.tsx +46 -0
- package/src/compat/next/error.tsx +148 -0
- package/src/compat/next/font/cache.ts +171 -0
- package/src/compat/next/font/google.ts +2 -0
- package/src/compat/next/font/index.ts +8 -0
- package/src/compat/next/font/local.ts +5 -0
- package/src/compat/next/font/runtime-client.ts +71 -0
- package/src/compat/next/font/runtime.ts +974 -0
- package/src/compat/next/font/shared.ts +281 -0
- package/src/compat/next/form.tsx +156 -0
- package/src/compat/next/head.tsx +10 -0
- package/src/compat/next/headers.ts +247 -0
- package/src/compat/next/image/config.ts +196 -0
- package/src/compat/next/image/optimizer.ts +96 -0
- package/src/compat/next/image/patterns.ts +103 -0
- package/src/compat/next/image/shared.ts +141 -0
- package/src/compat/next/image/static-metadata.ts +283 -0
- package/src/compat/next/image/validate.ts +269 -0
- package/src/compat/next/image-client.tsx +215 -0
- package/src/compat/next/image-props.ts +575 -0
- package/src/compat/next/image-usage.ts +102 -0
- package/src/compat/next/image.tsx +56 -0
- package/src/compat/next/index.ts +1 -0
- package/src/compat/next/legacy-image.tsx +97 -0
- package/src/compat/next/link-usage.ts +29 -0
- package/src/compat/next/link-validation-transform.ts +200 -0
- package/src/compat/next/link.tsx +466 -0
- package/src/compat/next/navigation.cjs +21 -0
- package/src/compat/next/navigation.ts +188 -0
- package/src/compat/next/offline.ts +51 -0
- package/src/compat/next/og.ts +324 -0
- package/src/compat/next/optimistic-route-state.ts +188 -0
- package/src/compat/next/preferred-region.ts +39 -0
- package/src/compat/next/redirects.ts +131 -0
- package/src/compat/next/resource-hints.ts +136 -0
- package/src/compat/next/rewrites.ts +350 -0
- package/src/compat/next/root-params.ts +142 -0
- package/src/compat/next/router.cjs +49 -0
- package/src/compat/next/router.ts +143 -0
- package/src/compat/next/script.tsx +355 -0
- package/src/compat/next/server-only.ts +3 -0
- package/src/compat/next/server.ts +28 -0
- package/src/compat/next/svgr.ts +58 -0
- package/src/compat/next/telemetry.ts +77 -0
- package/src/compat/next/user-agent.ts +100 -0
- package/src/compat/next/web-vitals.ts +56 -0
- package/src/compat/otel/api.ts +95 -0
- package/src/compat/otel/client-trace-metadata.ts +71 -0
- package/src/compat/otel/fetch-span.ts +77 -0
- package/src/compat/otel/tracer.ts +944 -0
- package/src/compat/pages/client-plugin.ts +108 -0
- package/src/compat/pages/index.ts +527 -0
- package/src/compat/pages/router-state.ts +94 -0
- package/src/compat/ppr/io.ts +38 -0
- package/src/compat/ppr/missing-root-params.ts +105 -0
- package/src/compat/ppr/root-params-scan.ts +164 -0
- package/src/compat/ppr/root-params-transform.ts +75 -0
- package/src/compat/ppr/root-params.ts +129 -0
- package/src/compat/ppr/segment-config-incompat.ts +34 -0
- package/src/compat/protocol.ts +202 -0
- package/src/compat/react/client.ts +59 -0
- package/src/compat/react/compiler-runtime.ts +60 -0
- package/src/compat/react/dom-client.ts +115 -0
- package/src/compat/react/dom-react-server.ts +20 -0
- package/src/compat/react/dom-server.ts +40 -0
- package/src/compat/react/dom.ts +154 -0
- package/src/compat/react/preact.ts +522 -0
- package/src/compat/react/react-server.ts +84 -0
- package/src/compat/react/router-shim.ts +26 -0
- package/src/compat/react/server-component-use.ts +48 -0
- package/src/compat/react/server-inserted-html.ts +87 -0
- package/src/compat/react/server.ts +156 -0
- package/src/compat/react/view-transition.ts +60 -0
- package/src/compat/register/actions.ts +875 -0
- package/src/compat/register/boot.ts +141 -0
- package/src/compat/register/build-tier.ts +11 -0
- package/src/compat/register/build.ts +182 -0
- package/src/compat/register/bundler.ts +587 -0
- package/src/compat/register/cache.ts +85 -0
- package/src/compat/register/client-errors.ts +18 -0
- package/src/compat/register/config.ts +17 -0
- package/src/compat/register/css-extras.ts +120 -0
- package/src/compat/register/edge-runtime.ts +6 -0
- package/src/compat/register/errors.ts +46 -0
- package/src/compat/register/export.ts +23 -0
- package/src/compat/register/font.ts +36 -0
- package/src/compat/register/hooks.ts +34 -0
- package/src/compat/register/image.ts +133 -0
- package/src/compat/register/index.ts +111 -0
- package/src/compat/register/instrumentation-client.ts +35 -0
- package/src/compat/register/lifecycle.ts +86 -0
- package/src/compat/register/mdx.ts +48 -0
- package/src/compat/register/middleware.ts +36 -0
- package/src/compat/register/misc.ts +44 -0
- package/src/compat/register/otel.ts +288 -0
- package/src/compat/register/pages-api.ts +473 -0
- package/src/compat/register/ppr.ts +56 -0
- package/src/compat/register/protocol.ts +57 -0
- package/src/compat/register/proxy.ts +127 -0
- package/src/compat/register/render.ts +268 -0
- package/src/compat/register/routing.ts +410 -0
- package/src/compat/register/segment.ts +1903 -0
- package/src/compat/register/static-image.ts +21 -0
- package/src/compat/register/typed-routes.ts +35 -0
- package/src/compat/register/usecache.ts +131 -0
- package/src/compat/register/validation.ts +56 -0
- package/src/compat/segment/loading-boundary.ts +113 -0
- package/src/compat/segment/page-slot.ts +200 -0
- package/src/compat/segment/tree.ts +481 -0
- package/src/compat/segment/vary-key.ts +102 -0
- package/src/compat/segment/vary-params.ts +551 -0
- package/src/compat/static-params.ts +33 -0
- package/src/compat/tsconfig-defaults.ts +301 -0
- package/src/compat/typecheck/index.ts +1481 -0
- package/src/compat/typecheck/worker.ts +26 -0
- package/src/compat/typed-routes/index.ts +92 -0
- package/src/compat/typed-routes/manifest.ts +356 -0
- package/src/compat/typed-routes/typegen.ts +566 -0
- package/src/compat/validation/errors.ts +159 -0
- package/src/compat/validation/index.ts +1770 -0
- package/src/compat/validation/prerender-diagnostics.ts +1508 -0
- package/src/compat-bootstrap.ts +67 -0
- package/src/config.ts +218 -0
- package/src/css/build.ts +697 -0
- package/src/css/index.ts +2 -0
- package/src/css/postcss.ts +236 -0
- package/src/css/worker.ts +34 -0
- package/src/dev/client-actions.ts +35 -0
- package/src/dev/client-chunk-store.ts +92 -0
- package/src/dev/client-key-cache.ts +178 -0
- package/src/dev/global-css-cache.ts +212 -0
- package/src/dev/imports.ts +2430 -0
- package/src/dev/module-cache.ts +721 -0
- package/src/dev/module-generations.ts +38 -0
- package/src/dev/module-transform.ts +188 -0
- package/src/dev/node-module-bundle-cache.ts +63 -0
- package/src/dev/restart-cache.ts +10 -0
- package/src/dev/route-bundle-key-cache.ts +154 -0
- package/src/dev/route-facts-cache.ts +223 -0
- package/src/dev/server.ts +1710 -0
- package/src/dynamic/source.ts +307 -0
- package/src/dynamic/tree-shake.ts +262 -0
- package/src/env.ts +92 -0
- package/src/extensions.ts +1898 -0
- package/src/index.ts +34 -0
- package/src/internal.ts +43 -0
- package/src/islands/boundary-error.ts +8 -0
- package/src/islands/static-children.ts +37 -0
- package/src/islands/static-slots.ts +106 -0
- package/src/ppr-postpone.ts +24 -0
- package/src/ppr.ts +784 -0
- package/src/proxy.ts +752 -0
- package/src/render/hooks.ts +384 -0
- package/src/render/index.ts +1 -0
- package/src/render/island-context.ts +47 -0
- package/src/render/metadata.ts +857 -0
- package/src/render/renderer.ts +7391 -0
- package/src/render/resource-hints.ts +44 -0
- package/src/render/slots.tsx +679 -0
- package/src/request/context.ts +396 -0
- package/src/resolve/engine.ts +219 -0
- package/src/resolve/imports.ts +1104 -0
- package/src/resolve/scan-facts.ts +474 -0
- package/src/resolve/source-text.ts +86 -0
- package/src/routing/forwarded.ts +41 -0
- package/src/routing/handler.ts +271 -0
- package/src/routing/href.ts +203 -0
- package/src/routing/metadata.ts +1018 -0
- package/src/routing/request-runtime.ts +43 -0
- package/src/routing/routes.ts +2560 -0
- package/src/routing/slots.ts +432 -0
- package/src/runtime/server.ts +3453 -0
- package/src/runtime/vendor.ts +1160 -0
- package/src/style-modules.d.ts +9 -0
- package/src/typegen.ts +151 -0
- package/src/types.ts +725 -0
- package/src/utils/ansi.ts +9 -0
- package/src/utils/content-type.ts +31 -0
- package/src/utils/decode.ts +7 -0
- package/src/utils/dev-profile.ts +31 -0
- package/src/utils/error-log.ts +29 -0
- package/src/utils/fs-cache.ts +31 -0
- package/src/utils/fs.ts +119 -0
- package/src/utils/html.ts +46 -0
- package/src/utils/serialize.ts +378 -0
- package/src/utils/source.ts +35 -0
- package/src/utils/verbose.ts +39 -0
- package/tsconfig.json +10 -0
package/src/cli/build.ts
ADDED
|
@@ -0,0 +1,3114 @@
|
|
|
1
|
+
import { copyFile, mkdir, rename, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { writeVercelOutput } from './adapters/vercel';
|
|
7
|
+
import { startWarmChild } from './adapters/vercel-warm';
|
|
8
|
+
import { loadConfig, pathToFileHref } from '../config';
|
|
9
|
+
import { bootstrapCompat } from '../compat-bootstrap';
|
|
10
|
+
import {
|
|
11
|
+
buildParallelPhaseError,
|
|
12
|
+
clearBuildParallelPhases,
|
|
13
|
+
getBuildExtensions,
|
|
14
|
+
type BuildResponseVary,
|
|
15
|
+
type BuildStep,
|
|
16
|
+
type BuildStepContext,
|
|
17
|
+
getCompatModeExtensions,
|
|
18
|
+
getRenderExtensions,
|
|
19
|
+
runInitHooks,
|
|
20
|
+
withRouteRuntime,
|
|
21
|
+
} from '../extensions';
|
|
22
|
+
import { buildClientEntries, emitStaticClientChunks, startClientSources } from '../client/build';
|
|
23
|
+
import { beginSourceScope, endSourceScope, sourceCacheStats } from '../resolve/source-text';
|
|
24
|
+
import { scanFactsStats } from '../resolve/scan-facts';
|
|
25
|
+
import { clientEntryName } from '../client/paths';
|
|
26
|
+
import { registerServerRuntime, serverBundleTargetForRuntime } from '../runtime/server';
|
|
27
|
+
import {
|
|
28
|
+
buildClientReferenceCss,
|
|
29
|
+
buildGlobalCss,
|
|
30
|
+
buildNotFoundCss,
|
|
31
|
+
buildRouteCss,
|
|
32
|
+
prepareRouteCssChunks,
|
|
33
|
+
registerCssRuntime,
|
|
34
|
+
warmCssPipeline,
|
|
35
|
+
} from '../css';
|
|
36
|
+
import { ensureEmptyDir, listFiles, readText, toPosixPath, writeText } from '../utils/fs';
|
|
37
|
+
import {
|
|
38
|
+
discoverStaticMetadataFiles,
|
|
39
|
+
metadataRouteHandlerModule,
|
|
40
|
+
staticMetadataCacheControl,
|
|
41
|
+
staticMetadataForPathFromFiles,
|
|
42
|
+
staticMetadataForRouteFromFiles,
|
|
43
|
+
staticMetadataOutputFile,
|
|
44
|
+
staticRouteMetadataKey,
|
|
45
|
+
withDynamicMetadataRoutes,
|
|
46
|
+
type StaticMetadataFile,
|
|
47
|
+
} from '../routing/metadata';
|
|
48
|
+
import { readModuleMetadata, readModuleViewport } from '../render/metadata';
|
|
49
|
+
import {
|
|
50
|
+
defaultNotFoundDocument,
|
|
51
|
+
pprShellPath,
|
|
52
|
+
pprSubShellPath,
|
|
53
|
+
renderFallbackShell,
|
|
54
|
+
renderGlobalNotFoundResponse,
|
|
55
|
+
renderPageWithStatus,
|
|
56
|
+
renderPartialShell,
|
|
57
|
+
renderSubShell,
|
|
58
|
+
staticParamsFor,
|
|
59
|
+
} from '../render';
|
|
60
|
+
import {
|
|
61
|
+
abortActivePrerenderScopes,
|
|
62
|
+
beginShellSourceTracking,
|
|
63
|
+
cacheComponents,
|
|
64
|
+
endShellSourceTracking,
|
|
65
|
+
} from '../ppr';
|
|
66
|
+
import { handleRouteModule, staticRouteParams, type RouteHandlerModule } from '../routing/handler';
|
|
67
|
+
import { runWithCacheScope } from '../cache/context';
|
|
68
|
+
import { devServerModuleHref, setEmitCompiledSpecifiersManifest } from '../dev/imports';
|
|
69
|
+
import {
|
|
70
|
+
beginDynamicBailoutProbe,
|
|
71
|
+
endDynamicBailoutProbe,
|
|
72
|
+
runWithWorkUnit,
|
|
73
|
+
} from '../request/context';
|
|
74
|
+
import { findProxyFile, proxyExternalLoadTarget, validateProxyFiles } from '../proxy';
|
|
75
|
+
import { setRequestRuntime } from '../routing/request-runtime';
|
|
76
|
+
import {
|
|
77
|
+
addClientEntryReason,
|
|
78
|
+
findLayouts,
|
|
79
|
+
materializeRouteFacts,
|
|
80
|
+
scanRoutes,
|
|
81
|
+
} from '../routing/routes';
|
|
82
|
+
import { interceptionMarkerLevels } from '../routing/slots';
|
|
83
|
+
import { writeTypegen } from '../typegen';
|
|
84
|
+
import { createVerboseLogger, type VerboseLogger } from '../utils/verbose';
|
|
85
|
+
import { bold, cyan, dim, green } from '../utils/ansi';
|
|
86
|
+
import type {
|
|
87
|
+
ActionManifestEntry,
|
|
88
|
+
BuildManifest,
|
|
89
|
+
RouteManifestEntry,
|
|
90
|
+
RouteParamValue,
|
|
91
|
+
StaticFileMetadata,
|
|
92
|
+
StaticModuleMetadata,
|
|
93
|
+
StaticRouteMetadata,
|
|
94
|
+
} from '../types';
|
|
95
|
+
|
|
96
|
+
/** Mutable accumulator handed to compat build steps as their `manifest` ctx. */
|
|
97
|
+
interface BuildStepState {
|
|
98
|
+
actions: ActionManifestEntry[];
|
|
99
|
+
/** Root-relative 'use server' module paths — known from discovery, so the
|
|
100
|
+
* client stage keys off this instead of waiting for their compile. */
|
|
101
|
+
actionSources?: string[];
|
|
102
|
+
/** Work a step handed back rather than finishing inline; awaited before the
|
|
103
|
+
* build manifest is written, so it lands under the client stage. */
|
|
104
|
+
deferred?: Promise<void>;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
type CacheLifeStash = ReturnType<typeof getBuildExtensions>['compat'] extends {
|
|
108
|
+
takeCacheLifeStash: () => infer T;
|
|
109
|
+
}
|
|
110
|
+
? NonNullable<T>
|
|
111
|
+
: never;
|
|
112
|
+
type SegmentMeta = ReturnType<ReturnType<typeof getBuildExtensions>['compat']['buildSegmentMeta']>;
|
|
113
|
+
|
|
114
|
+
function nextCompatEnabled(config: Awaited<ReturnType<typeof loadConfig>>) {
|
|
115
|
+
return getCompatModeExtensions().nextEnabled(config);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function reactCompatEnabled(config: Awaited<ReturnType<typeof loadConfig>>) {
|
|
119
|
+
return getCompatModeExtensions().reactEnabled(config);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function buildCompat() {
|
|
123
|
+
return getBuildExtensions().compat;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
interface BuildOptions {
|
|
127
|
+
adapter?: 'vercel';
|
|
128
|
+
verbose?: boolean;
|
|
129
|
+
/**
|
|
130
|
+
* `next build --debug-build-paths <glob>` parity: restrict the build to the route files matching
|
|
131
|
+
* the (comma-separated) pattern(s), relative to the project root.
|
|
132
|
+
*/
|
|
133
|
+
debugBuildPaths?: string;
|
|
134
|
+
/**
|
|
135
|
+
* `next build --experimental-build-mode compile|generate` parity: `compile`
|
|
136
|
+
* bundles without prerendering; `generate` runs the prerender/export pass
|
|
137
|
+
* with Next-shaped page-data output (and fails with Next's exact
|
|
138
|
+
* blocking-prerender diagnostics under cacheComponents).
|
|
139
|
+
*/
|
|
140
|
+
buildMode?: 'compile' | 'generate';
|
|
141
|
+
/** `next build --debug-prerender`: unminified prerender stacks + codeframes. */
|
|
142
|
+
debugPrerender?: boolean;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function buildProject(root?: string, options: BuildOptions = {}) {
|
|
146
|
+
clearBuildParallelPhases();
|
|
147
|
+
// One read per source for the whole build (route-fact walk, action discovery,
|
|
148
|
+
// client loader), released here so nothing survives the build.
|
|
149
|
+
beginSourceScope();
|
|
150
|
+
// Only the vercel adapter's trace step reads the per-artifact specifier
|
|
151
|
+
// sidecars; every other build path pays nothing for them.
|
|
152
|
+
const restoreSpecifiersManifest = setEmitCompiledSpecifiersManifest(options.adapter === 'vercel');
|
|
153
|
+
try {
|
|
154
|
+
return await runBuild(root, options);
|
|
155
|
+
} catch (error) {
|
|
156
|
+
// A parallel phase that already failed (a type error) is the root cause of
|
|
157
|
+
// whatever broke downstream; report it instead of the symptom.
|
|
158
|
+
const phaseError = buildParallelPhaseError();
|
|
159
|
+
throw phaseError ?? error;
|
|
160
|
+
} finally {
|
|
161
|
+
endSourceScope();
|
|
162
|
+
restoreSpecifiersManifest();
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function runBuild(root: string | undefined, options: BuildOptions) {
|
|
167
|
+
const verbose = options.verbose ?? false;
|
|
168
|
+
const log = createVerboseLogger(verbose, 'build');
|
|
169
|
+
const startedAt = performance.now();
|
|
170
|
+
|
|
171
|
+
console.log(
|
|
172
|
+
`${cyan('▲')} ${bold('pnext')} ${dim('— Creating an optimized production build ...\n')}`,
|
|
173
|
+
);
|
|
174
|
+
const config = await loadConfig(root);
|
|
175
|
+
// Load Tailwind on the CSS worker while the steps below run, so the first
|
|
176
|
+
// stylesheet doesn't pay its cold boot.
|
|
177
|
+
warmCssPipeline(config);
|
|
178
|
+
// Compat plugin loader: the single gated seam that populates the core
|
|
179
|
+
// extension registries when compat is enabled (no-op for pure-core apps).
|
|
180
|
+
// A build compiles immediately, so it takes both tiers up front.
|
|
181
|
+
await bootstrapCompat(config);
|
|
182
|
+
log.log(`config loaded — out ${path.relative(config.root, config.outPath) || '.'}`);
|
|
183
|
+
// Run registered init hooks (compat installs the Next fetch-cache patch so prerenders observe
|
|
184
|
+
// force-cache / next: { revalidate, tags }). No-op for pure-core apps. `build: true` keeps
|
|
185
|
+
// server-boot-only hooks out of the build - Next never runs register() during `next build`, so
|
|
186
|
+
// build prerenders must see a noop tracer.
|
|
187
|
+
runInitHooks(config, { build: true });
|
|
188
|
+
// The vercel adapter's warm pass runs in its own process; start it here so
|
|
189
|
+
// its boot overlaps the build and only the warming itself lands on the
|
|
190
|
+
// adapter step.
|
|
191
|
+
const warm = options.adapter === 'vercel' ? startWarmChild(config) : undefined;
|
|
192
|
+
await log.step('prepare output directory', async () => {
|
|
193
|
+
await ensureEmptyDir(config.outPath);
|
|
194
|
+
await copyPublicDir(config.publicPath, path.join(config.outPath, 'public'));
|
|
195
|
+
});
|
|
196
|
+
// The document-level stylesheets run their postcss/Tailwind pass on the CSS
|
|
197
|
+
// worker, so they overlap with the route scan below instead of serializing
|
|
198
|
+
// ahead of it. Awaited before prepareRouteCssChunks — route CSS still builds
|
|
199
|
+
// strictly after these two.
|
|
200
|
+
const documentCss = Promise.resolve()
|
|
201
|
+
.then(() => buildGlobalCss(config, { verbose }))
|
|
202
|
+
// 404/not-found documents reference their own CSS chunk; emit it alongside
|
|
203
|
+
// the global chunk so the synthetic not-found routes don't 404 their styles.
|
|
204
|
+
.then(() => buildNotFoundCss(config, { verbose }));
|
|
205
|
+
// Nothing awaits it until below; park the rejection so a CSS failure reports
|
|
206
|
+
// at that await instead of as an unhandled rejection mid-scan.
|
|
207
|
+
documentCss.catch(() => undefined);
|
|
208
|
+
// experimental.nextScriptWorkers: copy the Partytown library so `worker`
|
|
209
|
+
// scripts (rewritten to type="text/partytown") can load it from
|
|
210
|
+
// /_next/static/~partytown/. Tolerates the package being absent. Nothing
|
|
211
|
+
// downstream reads it, so it runs beside the scan instead of ahead of it.
|
|
212
|
+
const partytownLib = log.step('partytown lib', () => copyPartytownLib(config));
|
|
213
|
+
partytownLib.catch(() => undefined);
|
|
214
|
+
|
|
215
|
+
let routes = await log.step('scan routes', () => scanRoutes(config.appPath));
|
|
216
|
+
log.log(`found ${routes.length} route${routes.length === 1 ? '' : 's'}`);
|
|
217
|
+
if (options.debugBuildPaths) {
|
|
218
|
+
routes = filterDebugBuildRoutes(config.root, routes, options.debugBuildPaths);
|
|
219
|
+
log.log(
|
|
220
|
+
`--debug-build-paths ${options.debugBuildPaths} — ${routes.length} route${routes.length === 1 ? '' : 's'} kept`,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
// The page routes' server modules are the bulk of the adapter's warm pass and
|
|
224
|
+
// depend on nothing but the scan; hand them over now so the child compiles
|
|
225
|
+
// them under the client stage instead of after it.
|
|
226
|
+
warm?.prewarm(routes.filter(route => route.kind === 'page').map(route => route.file));
|
|
227
|
+
// Compat build steps (action discovery/bundling + server-reference manifest)
|
|
228
|
+
// populate their output onto this accumulator. Pure-core registers no steps.
|
|
229
|
+
const buildState: BuildStepState = { actions: [] };
|
|
230
|
+
const stepContext = { config, routes, manifest: buildState, log };
|
|
231
|
+
const { steps } = getBuildExtensions();
|
|
232
|
+
// Steps that declare themselves route-fact-independent (action discovery) run UNDER the scan below
|
|
233
|
+
// rather than after it, which would make both strictly serial.
|
|
234
|
+
const earlySteps = runBuildSteps(
|
|
235
|
+
steps.filter(step => step.early),
|
|
236
|
+
stepContext,
|
|
237
|
+
);
|
|
238
|
+
// Nothing awaits it until the client-entry set is computed; park the rejection
|
|
239
|
+
// so a discovery failure reports there instead of as an unhandled rejection.
|
|
240
|
+
earlySteps.catch(() => undefined);
|
|
241
|
+
// The table arrives with its content facts deferred (dev boots on paths
|
|
242
|
+
// alone); a build needs all of them, and needs their scan errors up front.
|
|
243
|
+
// The client stage needs nothing but each route's client file list, so it
|
|
244
|
+
// starts here, route by route as the facts land: the transform chain and the
|
|
245
|
+
// React Compiler run on oxc's threadpool under the rest of the build instead
|
|
246
|
+
// of inside the client stage's own wall.
|
|
247
|
+
const clientSources = startClientSources(config);
|
|
248
|
+
// Unpaced on purpose: the walk holds the loop for ~0.55 s and action
|
|
249
|
+
// discovery beside it cannot resume, but pacing it (setImmediate per route)
|
|
250
|
+
// measures a wash — discovery's 0.6 s of starvation is hidden entirely inside
|
|
251
|
+
// this walk's own wall, and the client stage cannot start before either ends.
|
|
252
|
+
await log.step(
|
|
253
|
+
'scan route facts',
|
|
254
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
255
|
+
async () => materializeRouteFacts(routes, route => clientSources.warmRoutes([route])),
|
|
256
|
+
);
|
|
257
|
+
// Render-time extensions read the route table from the request runtime (e.g.
|
|
258
|
+
// compat's static-sibling route state); publish it for the prerender pass the
|
|
259
|
+
// same way the serve handlers do.
|
|
260
|
+
setRequestRuntime({ config, routes, dev: false });
|
|
261
|
+
// Compute the compat CSS-chunk plan across all routes up front (Next's CSS chunking needs the
|
|
262
|
+
// global picture); buildRouteCss then emits one asset per planned slice. No-op for pure-core apps.
|
|
263
|
+
// The plan is derived from the route table alone, so it does NOT wait on the document stylesheets
|
|
264
|
+
// - those are awaited just before the first route stylesheet is emitted, which lets a Tailwind
|
|
265
|
+
// pass run under the build steps and the client bundles instead of ahead of them.
|
|
266
|
+
prepareRouteCssChunks(routes);
|
|
267
|
+
// output:'export' with trailingSlash:false lays prerendered pages out flat
|
|
268
|
+
// (`/a.html` rather than `/a/index.html`). Only meaningful under compat (the
|
|
269
|
+
// Next config carries `output`); pure-core apps always use the dir layout.
|
|
270
|
+
const flatExportLayout =
|
|
271
|
+
nextCompatEnabled(config) &&
|
|
272
|
+
buildCompat().nextOutputExport() &&
|
|
273
|
+
config.trailingSlash !== true;
|
|
274
|
+
// The proxy bundle is independent of the remaining build steps; overlap them.
|
|
275
|
+
const proxyBuild = log.step('proxy module', () => buildProxyModule(config));
|
|
276
|
+
proxyBuild.catch(() => undefined);
|
|
277
|
+
// Gate steps (validation) run here so their diagnostics still precede any
|
|
278
|
+
// failure the client stage or the not-found prerender would raise for the
|
|
279
|
+
// same broken app; every other step is a manifest write nothing downstream
|
|
280
|
+
// reads, so it moves under the client bundles below (stage parallelism, §3).
|
|
281
|
+
await runBuildSteps(
|
|
282
|
+
steps.filter(step => !step.early && step.gate),
|
|
283
|
+
stepContext,
|
|
284
|
+
);
|
|
285
|
+
// Static metadata depends on the scan, not on action discovery — collect and
|
|
286
|
+
// copy it while the early steps finish rather than after them.
|
|
287
|
+
const staticFiles: Record<string, StaticFileMetadata> = {};
|
|
288
|
+
const staticMetadata = (async () => {
|
|
289
|
+
const files = await log.step('discover static metadata files', () =>
|
|
290
|
+
Promise.resolve(discoverStaticMetadataFiles(config.appPath)),
|
|
291
|
+
);
|
|
292
|
+
await log.step('static metadata files', () =>
|
|
293
|
+
copyStaticMetadataFiles(config, staticFiles, files),
|
|
294
|
+
);
|
|
295
|
+
return files;
|
|
296
|
+
})();
|
|
297
|
+
staticMetadata.catch(() => undefined);
|
|
298
|
+
// Action discovery arms the client-stub set buildClientEntries consumes, so
|
|
299
|
+
// this is the point the early steps have to have landed by. What the step
|
|
300
|
+
// deferred is not part of that arming and is awaited further below.
|
|
301
|
+
await earlySteps;
|
|
302
|
+
const deferredSteps = buildState.deferred ?? Promise.resolve();
|
|
303
|
+
deferredSteps.catch(() => undefined);
|
|
304
|
+
const staticMetadataFiles = await staticMetadata;
|
|
305
|
+
// Warnings only - nothing downstream reads the result. Started here and awaited after the client
|
|
306
|
+
// stage so it costs the build nothing, and still prints before the first prerendered route line.
|
|
307
|
+
const metadataWarnings = nextCompatEnabled(config)
|
|
308
|
+
? log.step('next metadata warnings', () =>
|
|
309
|
+
buildCompat().warnMetadataIssues({
|
|
310
|
+
appPath: config.appPath,
|
|
311
|
+
routes,
|
|
312
|
+
staticMetadataFiles,
|
|
313
|
+
}),
|
|
314
|
+
)
|
|
315
|
+
: undefined;
|
|
316
|
+
metadataWarnings?.catch(() => undefined);
|
|
317
|
+
const staticModuleMetadata = await log.step('core module metadata', () =>
|
|
318
|
+
collectStaticModuleMetadata(config, routes),
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
const actionSources = buildState.actionSources ?? buildState.actions.map(a => a.sourceKey);
|
|
322
|
+
const actionFiles = new Set(actionSources.map(source => path.resolve(config.root, source)));
|
|
323
|
+
for (const route of routes) {
|
|
324
|
+
if (
|
|
325
|
+
route.kind === 'page' &&
|
|
326
|
+
route.sourceFiles.some(file => actionFiles.has(path.resolve(file)))
|
|
327
|
+
) {
|
|
328
|
+
addClientEntryReason(route, 'actions');
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
const clientRoutes = routes.filter(
|
|
332
|
+
route =>
|
|
333
|
+
route.kind !== 'handler' &&
|
|
334
|
+
(route.client || route.clientReferences.length > 0 || route.needsRouterEntry),
|
|
335
|
+
);
|
|
336
|
+
for (const route of clientRoutes) route.clientEntry = `assets/${clientEntryName(route)}.js`;
|
|
337
|
+
// A prerenderable 404 boots the server graph; the client bundles are esbuild.
|
|
338
|
+
// Both need only the stub set and the entry names decided just above, so run
|
|
339
|
+
// them side by side instead of paying the render after the bundle. Result is
|
|
340
|
+
// consumed after the prerender pass, where it always was.
|
|
341
|
+
const notFoundDocuments = renderNotFoundDocuments({
|
|
342
|
+
config,
|
|
343
|
+
log,
|
|
344
|
+
skip: options.buildMode === 'compile',
|
|
345
|
+
documentCss,
|
|
346
|
+
staticMetadataFiles,
|
|
347
|
+
staticModuleMetadata,
|
|
348
|
+
});
|
|
349
|
+
notFoundDocuments.catch(() => undefined);
|
|
350
|
+
const clientBundles = log.step(
|
|
351
|
+
`client bundles (${clientRoutes.length} route${clientRoutes.length === 1 ? '' : 's'})`,
|
|
352
|
+
() =>
|
|
353
|
+
buildClientEntries({
|
|
354
|
+
config,
|
|
355
|
+
routes: clientRoutes,
|
|
356
|
+
outDir: path.join(config.outPath, 'public', 'assets'),
|
|
357
|
+
verbose,
|
|
358
|
+
hasServerActions: actionSources.length > 0,
|
|
359
|
+
pipeline: clientSources,
|
|
360
|
+
}),
|
|
361
|
+
);
|
|
362
|
+
clientBundles.catch(() => undefined);
|
|
363
|
+
// The manifest-writing steps and typegen depend on nothing the client stage produces, so they run
|
|
364
|
+
// under it instead of ahead of it. Awaited first so a step failure still reports before a
|
|
365
|
+
// bundling error.
|
|
366
|
+
const remainingSteps = runBuildSteps(
|
|
367
|
+
steps.filter(step => !step.early && !step.gate),
|
|
368
|
+
stepContext,
|
|
369
|
+
).then(() =>
|
|
370
|
+
log.step('typegen', () => writeTypegen(config, routes)),
|
|
371
|
+
);
|
|
372
|
+
remainingSteps.catch(() => undefined);
|
|
373
|
+
await remainingSteps;
|
|
374
|
+
await clientBundles;
|
|
375
|
+
const proxyModule = await proxyBuild;
|
|
376
|
+
await partytownLib;
|
|
377
|
+
// Standalone static chunks (compat: the no-module polyfills chunk) live
|
|
378
|
+
// outside the esbuild entry graph, so emit them regardless of whether any
|
|
379
|
+
// route produced a client bundle.
|
|
380
|
+
await emitStaticClientChunks(config, path.join(config.outPath, 'public', 'assets'));
|
|
381
|
+
// All three source consumers have run by here; the split says how much of the
|
|
382
|
+
// app was read once and shared rather than read per consumer.
|
|
383
|
+
if (verbose) {
|
|
384
|
+
const sources = sourceCacheStats();
|
|
385
|
+
log.log(
|
|
386
|
+
`source cache — ${sources.reads} reads, ${sources.hits} shared, ${sources.files} files · parses ${JSON.stringify(scanFactsStats())}`,
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
await metadataWarnings;
|
|
390
|
+
|
|
391
|
+
// A throwing after() during a prerender must fail the whole build (Next
|
|
392
|
+
// exits 1), but with `prerenderEarlyExit: false` the errors for every route
|
|
393
|
+
// are collected first — so record it and keep prerendering, then fail at the
|
|
394
|
+
// end once all routes have logged their prerender-error lines.
|
|
395
|
+
let hadAfterPrerenderError = false;
|
|
396
|
+
// `dynamic = 'error'` routes that read dynamic data can't be rendered
|
|
397
|
+
// statically; Next fails the build naming each offending route + API. Collect
|
|
398
|
+
// them all (matching prerenderEarlyExit:false) and fail once at the end.
|
|
399
|
+
let hadDynamicErrorFailure = false;
|
|
400
|
+
|
|
401
|
+
// A handler route whose logical output path doubles as a parent directory for a descendant
|
|
402
|
+
// route's static output cannot be written as a plain file AND host a child directory on one
|
|
403
|
+
// filesystem (Next sidesteps this with a `.body` suffix; pnext mirrors output under public/, so
|
|
404
|
+
// the file and the directory would collide). The ancestor keeps the physical file so it serves
|
|
405
|
+
// statically; each descendant records its prerender-manifest entry but skips the physical copy
|
|
406
|
+
// and serves dynamically. Descendants are detected up-front so the outcome is order-independent.
|
|
407
|
+
const descendantHandlerIds = collectDescendantHandlerIds(routes);
|
|
408
|
+
|
|
409
|
+
const isCompile = options.buildMode === 'compile';
|
|
410
|
+
const isGenerate = options.buildMode === 'generate';
|
|
411
|
+
const debugPrerender = options.debugPrerender ?? false;
|
|
412
|
+
// Record the build inputs the serving runtime needs (compat: --debug-prerender
|
|
413
|
+
// selects the shape of the runtime 'use cache' error log). No-op for core.
|
|
414
|
+
buildCompat().recordBuildFlags(config.outPath, debugPrerender);
|
|
415
|
+
// Generate mode: Next prints the page-data banner, then ONLY prerender diagnostics until the
|
|
416
|
+
// route table - the cache-components-errors suites capture everything after this line and
|
|
417
|
+
// inline-snapshot it. Per-path lines are buffered and printed after a `Route (app)` header, which
|
|
418
|
+
// ends the suites' capture window.
|
|
419
|
+
const generateRouteLines: string[] = [];
|
|
420
|
+
/** Route ids that prerendered a partial (cache-components) shell — Next's `◐`. */
|
|
421
|
+
const partialPrerenders = new Set<string>();
|
|
422
|
+
/** Routes that failed generate-mode prerender diagnostics, in scan order. */
|
|
423
|
+
const generateFailures: { route: string; omitErrorLine: boolean }[] = [];
|
|
424
|
+
if (isGenerate) console.log(' Collecting page data ...');
|
|
425
|
+
|
|
426
|
+
// Route stylesheets build strictly after the document ones; this is the first
|
|
427
|
+
// point that needs them, so everything above ran alongside the CSS worker.
|
|
428
|
+
await documentCss;
|
|
429
|
+
|
|
430
|
+
for (const route of routes) {
|
|
431
|
+
if (route.dynamicErrorApi) {
|
|
432
|
+
console.error(
|
|
433
|
+
`Error: Route ${toNextRoutePattern(route.route || '/')} with \`dynamic = "error"\` ` +
|
|
434
|
+
`couldn't be rendered statically because it used ${route.dynamicErrorApi}. ` +
|
|
435
|
+
`See more info here: https://nextjs.org/docs/app/building-your-application/rendering/static-and-dynamic#dynamic-rendering`,
|
|
436
|
+
);
|
|
437
|
+
hadDynamicErrorFailure = true;
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
if (route.kind === 'handler') {
|
|
441
|
+
if (isCompile) continue;
|
|
442
|
+
if (!(await staticRouteHandlerCandidate(route))) continue;
|
|
443
|
+
const dynamicUsage = await staticHandlerDynamicUsage(route);
|
|
444
|
+
if (dynamicUsage) {
|
|
445
|
+
console.log(
|
|
446
|
+
`Caught Error: Dynamic server usage: Route ${route.route || '/'} couldn't be rendered statically because it used \`${dynamicUsage}\`.`,
|
|
447
|
+
);
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
// A single handler prerender failure must not kill the whole build:
|
|
451
|
+
// skip the static copy and let the route serve dynamically at runtime.
|
|
452
|
+
try {
|
|
453
|
+
Object.assign(
|
|
454
|
+
staticFiles,
|
|
455
|
+
await log.step(`route handler ${route.route}`, () =>
|
|
456
|
+
buildStaticRouteHandler(config, route, {
|
|
457
|
+
skipPhysicalWrite: descendantHandlerIds.has(route.id),
|
|
458
|
+
}),
|
|
459
|
+
),
|
|
460
|
+
);
|
|
461
|
+
} catch (error) {
|
|
462
|
+
rethrowIfProgrammingError(error);
|
|
463
|
+
if (isAfterPrerenderError(error)) hadAfterPrerenderError = true;
|
|
464
|
+
warnSkippedStatic(route.route, error instanceof Error ? error.message : String(error));
|
|
465
|
+
}
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
await buildRouteCss(config, route, { verbose });
|
|
470
|
+
for (const reference of route.clientReferences) {
|
|
471
|
+
await buildClientReferenceCss(config, reference, { verbose });
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Compile mode bundles only — every prerender/export step is generate's.
|
|
475
|
+
if (isCompile) continue;
|
|
476
|
+
|
|
477
|
+
// Generate mode fails a cacheComponents route whose prerender would block,
|
|
478
|
+
// with Next's exact diagnostic block (owner stack + codeframe under
|
|
479
|
+
// --debug-prerender). Detected before any shell attempt so the failing
|
|
480
|
+
// route emits nothing else into the captured output window.
|
|
481
|
+
if (isGenerate && cacheComponents() && !route.client && !route.interception) {
|
|
482
|
+
// Next names the route by its source pattern (`/use-cache-params/[slug]`),
|
|
483
|
+
// not pnext's `:param` form — the diagnostics quote it verbatim.
|
|
484
|
+
const diagnosticRoute = toNextRoutePattern(route.route || '/');
|
|
485
|
+
const diagnostic = buildCompat().diagnoseCacheComponentsPrerender({
|
|
486
|
+
route: diagnosticRoute,
|
|
487
|
+
pageFile: route.file,
|
|
488
|
+
appPath: config.appPath,
|
|
489
|
+
debugPrerender,
|
|
490
|
+
});
|
|
491
|
+
if (diagnostic) {
|
|
492
|
+
console.error(diagnostic);
|
|
493
|
+
generateFailures.push({
|
|
494
|
+
route: diagnosticRoute,
|
|
495
|
+
omitErrorLine: buildCompat().diagnosticLeadsWithErrorLine(diagnostic),
|
|
496
|
+
});
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (route.ppr) {
|
|
502
|
+
await log.step(`ppr shell ${route.route}`, () => buildPprShell(config, route));
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Under the global cacheComponents flag every page is a PPR candidate:
|
|
507
|
+
// attempt a shell (a param-independent fallback shell for dynamic-param
|
|
508
|
+
// routes). If dynamic data escapes every boundary the route falls through
|
|
509
|
+
// to the normal static/dynamic build below.
|
|
510
|
+
if (
|
|
511
|
+
cacheComponents() &&
|
|
512
|
+
!route.interception &&
|
|
513
|
+
(!route.client || (route.params.length === 0 && !route.catchAll))
|
|
514
|
+
) {
|
|
515
|
+
const built = await log.step(`cache-components shell ${route.route}`, () =>
|
|
516
|
+
buildCacheComponentsShell(config, route),
|
|
517
|
+
);
|
|
518
|
+
if (built) {
|
|
519
|
+
// Next marks a partially prerendered route with a distinct glyph in its build output, and
|
|
520
|
+
// suites grep for it. That glyph is for a PARTIAL prerender: a shell with dynamic holes (or
|
|
521
|
+
// blocked metadata), or a param FALLBACK shell whose params only resolve per request. A
|
|
522
|
+
// hole-less shell of a fully-known route keeps Next's static marker.
|
|
523
|
+
const paramFallback =
|
|
524
|
+
(route.params.length > 0 || Boolean(route.catchAll)) && !route.hasStaticParams;
|
|
525
|
+
if (paramFallback || (route.pprHoles?.length ?? 0) > 0 || route.pprMetadata) {
|
|
526
|
+
partialPrerenders.add(route.id);
|
|
527
|
+
}
|
|
528
|
+
if (isGenerate) generateRouteLines.push(` ◐ ${route.route || '/'}`);
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
// A whole-client page cannot consume hanging dynamic params during a shell
|
|
533
|
+
// prerender. Keep it runtime-only until that boundary can postpone.
|
|
534
|
+
if (cacheComponents() && route.client && (route.params.length > 0 || route.catchAll)) continue;
|
|
535
|
+
|
|
536
|
+
// Segment config gates prerendering: force-dynamic / revalidate 0 /
|
|
537
|
+
// force-no-store routes are never prebuilt; force-static prerenders even
|
|
538
|
+
// when the route reads request data (with an empty synthetic request,
|
|
539
|
+
// Next-style).
|
|
540
|
+
const segmentConfig = route.segmentConfig;
|
|
541
|
+
const forceStatic = segmentConfig?.dynamic === 'force-static';
|
|
542
|
+
if (forceStatic && segmentConfig?.runtime === 'edge') {
|
|
543
|
+
console.warn(
|
|
544
|
+
`Page "${route.route}" is using runtime = 'edge' which is currently incompatible with dynamic = 'force-static'. Please remove either "runtime" or "force-static" for correct behavior`,
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
const configForcesDynamic =
|
|
548
|
+
segmentConfig?.dynamic === 'force-dynamic' ||
|
|
549
|
+
segmentConfig?.revalidate === 0 ||
|
|
550
|
+
segmentConfig?.fetchCache === 'force-no-store' ||
|
|
551
|
+
// Next never statically prerenders `runtime = 'edge'` pages (they are
|
|
552
|
+
// absent from its prerender manifest / static output); their data
|
|
553
|
+
// stability comes from the fetch cache, not prebuilt HTML. force-static
|
|
554
|
+
// keeps the existing (warned-about) prerender behavior.
|
|
555
|
+
(segmentConfig?.runtime === 'edge' && !forceStatic);
|
|
556
|
+
|
|
557
|
+
let staticParams: Awaited<ReturnType<typeof staticParamsFor>> | null = null;
|
|
558
|
+
if (!configForcesDynamic && route.hasStaticParams) {
|
|
559
|
+
try {
|
|
560
|
+
staticParams = await log.step(`static params ${route.route}`, () =>
|
|
561
|
+
// Run generateStaticParams inside a prerender meta so an after()
|
|
562
|
+
// scheduled there is drained (and its throw propagates) instead of
|
|
563
|
+
// being logged-and-swallowed as a stray runtime after().
|
|
564
|
+
getRenderExtensions()
|
|
565
|
+
.collectRenderMeta(() => staticParamsFor(config, route), {
|
|
566
|
+
route: route.route,
|
|
567
|
+
prerender: true,
|
|
568
|
+
})
|
|
569
|
+
.then(result => result.value),
|
|
570
|
+
);
|
|
571
|
+
} catch (error) {
|
|
572
|
+
rethrowIfProgrammingError(error);
|
|
573
|
+
if (!isAfterPrerenderError(error)) throw error;
|
|
574
|
+
// Next reports a generateStaticParams failure with this exact prefix
|
|
575
|
+
// (naming the route in `[param]` form), followed by the underlying
|
|
576
|
+
// error (the thrown after() message).
|
|
577
|
+
console.error(`Failed to collect page data for ${toNextRoutePattern(route.route)}`);
|
|
578
|
+
console.error(error instanceof Error ? (error.stack ?? error.message) : String(error));
|
|
579
|
+
hadAfterPrerenderError = true;
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
// Dynamic request APIs normally skip page prerendering. An unstable_cache
|
|
584
|
+
// boundary is the exception: Next executes the fill during prerender so a
|
|
585
|
+
// request API read inside it can fail the build at the exact call site.
|
|
586
|
+
const validateUnstableCacheScope =
|
|
587
|
+
!configForcesDynamic && route.usesRequest && !forceStatic && (await usesUnstableCache(route));
|
|
588
|
+
const paramSets = staticParams
|
|
589
|
+
? staticParams.paths
|
|
590
|
+
: validateUnstableCacheScope
|
|
591
|
+
? [{}]
|
|
592
|
+
: configForcesDynamic || (route.usesRequest && !forceStatic)
|
|
593
|
+
? []
|
|
594
|
+
: route.mode === 'static' && route.params.length === 0 && !route.catchAll
|
|
595
|
+
? [{}]
|
|
596
|
+
: [];
|
|
597
|
+
// Partial sets included: dynamicParams=false checks match governed params
|
|
598
|
+
// against these at request time.
|
|
599
|
+
if (staticParams) route.prerenderedParams = staticParams.allSets;
|
|
600
|
+
// NEXT_DEBUG_BUILD parity: Next logs why a route that would otherwise have
|
|
601
|
+
// been prerendered fell back to dynamic (the e2e suite greps this line).
|
|
602
|
+
// This branch covers the request-API bailout (headers()/cookies() in the
|
|
603
|
+
// tree); the no-store fetch bailout is logged after the prerender below.
|
|
604
|
+
if (
|
|
605
|
+
process.env.NEXT_DEBUG_BUILD &&
|
|
606
|
+
paramSets.length === 0 &&
|
|
607
|
+
!staticParams &&
|
|
608
|
+
!configForcesDynamic &&
|
|
609
|
+
route.usesRequest &&
|
|
610
|
+
!forceStatic &&
|
|
611
|
+
route.params.length === 0 &&
|
|
612
|
+
!route.catchAll
|
|
613
|
+
) {
|
|
614
|
+
logStaticBailout(route.route || '/', await requestApiBailoutReason(route));
|
|
615
|
+
}
|
|
616
|
+
if (paramSets.length === 0) {
|
|
617
|
+
// Next prerenders every non-force-dynamic page and only marks it dynamic once a request API
|
|
618
|
+
// throws, so a call that OUTLIVES the render (scheduled in a timer/microtask) escapes as a
|
|
619
|
+
// build-time DynamicServerError instead. pnext's static scan skips those routes before
|
|
620
|
+
// rendering, so probe just the ones that schedule deferred work - the only shape where the
|
|
621
|
+
// call can outlive the render.
|
|
622
|
+
if (
|
|
623
|
+
!configForcesDynamic &&
|
|
624
|
+
!forceStatic &&
|
|
625
|
+
route.usesRequest &&
|
|
626
|
+
route.params.length === 0 &&
|
|
627
|
+
!route.catchAll &&
|
|
628
|
+
(await schedulesDeferredWork(route))
|
|
629
|
+
) {
|
|
630
|
+
await probeDeferredDynamicUsage(config, route, staticMetadataFiles, staticModuleMetadata);
|
|
631
|
+
}
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
634
|
+
if (!forceStatic && (await needsRequestOriginForMetadataImages(config.appPath, route)))
|
|
635
|
+
continue;
|
|
636
|
+
|
|
637
|
+
await log.step(
|
|
638
|
+
`prerender ${route.route} (${paramSets.length} page${paramSets.length === 1 ? '' : 's'})`,
|
|
639
|
+
async () => {
|
|
640
|
+
for (const rawParams of paramSets) {
|
|
641
|
+
// Next derives prerender params from the encoded URL it builds for a
|
|
642
|
+
// generateStaticParams value, so a page sees `sticks%20%26%20stones`,
|
|
643
|
+
// never the raw `sticks & stones` (prerender-encoding suite). File
|
|
644
|
+
// layout and dynamicParams matching keep the raw values.
|
|
645
|
+
const params = encodeStaticRouteParams(rawParams);
|
|
646
|
+
const routePath = fillRoutePath(route.route, rawParams);
|
|
647
|
+
const file = staticHtmlPath(config.outPath, routePath, flatExportLayout);
|
|
648
|
+
const collision = staticOutputCollision(config.outPath, file);
|
|
649
|
+
if (collision) {
|
|
650
|
+
warnSkippedStatic(routePath, collision);
|
|
651
|
+
continue;
|
|
652
|
+
}
|
|
653
|
+
const url = new URL(`http://pnext.local${routePath}`);
|
|
654
|
+
// A single page prerender failure must not kill the whole build:
|
|
655
|
+
// skip the static copy and let the route serve dynamically.
|
|
656
|
+
try {
|
|
657
|
+
// Wrap in a work unit so a `use cache` cacheLife() during the render
|
|
658
|
+
// stashes its effective revalidate/expire/stale (stashCacheLife
|
|
659
|
+
// reads the work-unit scope). We read + persist it below so a pure
|
|
660
|
+
// static HIT can re-emit the SWR headers from start.ts.
|
|
661
|
+
let cacheLife: CacheLifeStash | undefined;
|
|
662
|
+
let fontLinkHeader: string | undefined;
|
|
663
|
+
let prerenderVary: BuildResponseVary | undefined;
|
|
664
|
+
const rendered = await runWithWorkUnit('render', async () => {
|
|
665
|
+
const tracked = await buildCompat().withVaryParamsTracking(() =>
|
|
666
|
+
getRenderExtensions().collectRenderMeta(
|
|
667
|
+
() =>
|
|
668
|
+
runWithNextBuildPhase(() =>
|
|
669
|
+
withRouteRuntime(route.segmentConfig?.runtime, () =>
|
|
670
|
+
renderPageWithStatus({
|
|
671
|
+
config,
|
|
672
|
+
route,
|
|
673
|
+
params,
|
|
674
|
+
url,
|
|
675
|
+
// Empty request so request APIs (cookies/headers) resolve
|
|
676
|
+
// to empty values instead of failing the prerender.
|
|
677
|
+
...(route.usesRequest ? { request: new Request(url) } : {}),
|
|
678
|
+
staticMetadataFiles,
|
|
679
|
+
staticModuleMetadata,
|
|
680
|
+
resolveDynamicMetadataRoutes: true,
|
|
681
|
+
}),
|
|
682
|
+
),
|
|
683
|
+
),
|
|
684
|
+
{ fetchCache: segmentConfig?.fetchCache, route: routePath || '/', prerender: true },
|
|
685
|
+
),
|
|
686
|
+
);
|
|
687
|
+
const result = tracked.value;
|
|
688
|
+
prerenderVary = tracked.vary;
|
|
689
|
+
cacheLife = buildCompat().takeCacheLifeStash();
|
|
690
|
+
// A static prerender never runs the response finalizer that flushes
|
|
691
|
+
// font preloads to the `Link` header, so bake it into the manifest
|
|
692
|
+
// headers here (must read the work unit before it unwinds).
|
|
693
|
+
fontLinkHeader = buildCompat().takeFontLinkHeader();
|
|
694
|
+
return result;
|
|
695
|
+
});
|
|
696
|
+
// Explicit no-store signals (fetch no-store/no-cache, revalidate
|
|
697
|
+
// 0, unstable_noStore) keep the route dynamic — unless the
|
|
698
|
+
// segment config explicitly opts into static/ISR output.
|
|
699
|
+
const optedStatic =
|
|
700
|
+
forceStatic ||
|
|
701
|
+
typeof segmentConfig?.revalidate === 'number' ||
|
|
702
|
+
segmentConfig?.revalidate === false;
|
|
703
|
+
if (rendered.noStore && !optedStatic) {
|
|
704
|
+
if (process.env.NEXT_DEBUG_BUILD)
|
|
705
|
+
logStaticBailout(routePath || '/', 'no-store fetch');
|
|
706
|
+
continue;
|
|
707
|
+
}
|
|
708
|
+
await writeText(file, rendered.value.html);
|
|
709
|
+
recordPrerenderVary(route, routePath || '/', prerenderVary);
|
|
710
|
+
// Prerendered paths print like Next's build output; tooling (and
|
|
711
|
+
// the Next e2e suite) greps for them. Generate mode defers them
|
|
712
|
+
// below the `Route (app)` header so the diagnostics capture window
|
|
713
|
+
// stays clean.
|
|
714
|
+
const routeLine = `${dim(' ○')} ${routePath || '/'}`;
|
|
715
|
+
if (isGenerate) generateRouteLines.push(routeLine);
|
|
716
|
+
else console.log(routeLine);
|
|
717
|
+
await emitConcreteNextPageArtifacts(config, route, routePath || '/', {
|
|
718
|
+
html: rendered.value.html,
|
|
719
|
+
status: rendered.value.status,
|
|
720
|
+
});
|
|
721
|
+
const relative = toPosixPath(path.relative(path.join(config.outPath, 'public'), file));
|
|
722
|
+
const revalidateSeconds = combineRevalidate(
|
|
723
|
+
segmentConfig?.revalidate,
|
|
724
|
+
rendered.revalidateSeconds,
|
|
725
|
+
);
|
|
726
|
+
// A `use cache` render stashes its effective cacheLife; the SWR
|
|
727
|
+
// cache-control + x-nextjs-stale-time normally come from a response
|
|
728
|
+
// finalizer, but a pure static HIT never re-renders, so we bake the
|
|
729
|
+
// header strings and the expire/stale windows into the manifest so
|
|
730
|
+
// start.ts can re-emit them on the HIT. The work-unit stash is empty
|
|
731
|
+
// when the build prerender ran without a request work unit; the
|
|
732
|
+
// render cache meta carries the same windows, so fall back to it.
|
|
733
|
+
const defaultExpireTime = buildCompat().defaultExpireTimeSeconds();
|
|
734
|
+
const effectiveCacheLife: CacheLifeStash | undefined =
|
|
735
|
+
cacheLife ??
|
|
736
|
+
(rendered.expireSeconds !== undefined || rendered.staleSeconds !== undefined
|
|
737
|
+
? {
|
|
738
|
+
...(revalidateSeconds !== undefined ? { revalidateSeconds } : {}),
|
|
739
|
+
...(rendered.expireSeconds !== undefined
|
|
740
|
+
? { expireSeconds: rendered.expireSeconds }
|
|
741
|
+
: {}),
|
|
742
|
+
...(rendered.staleSeconds !== undefined
|
|
743
|
+
? { staleSeconds: rendered.staleSeconds }
|
|
744
|
+
: {}),
|
|
745
|
+
}
|
|
746
|
+
: revalidateSeconds !== undefined && defaultExpireTime !== undefined
|
|
747
|
+
? {
|
|
748
|
+
revalidateSeconds,
|
|
749
|
+
expireSeconds: defaultExpireTime,
|
|
750
|
+
}
|
|
751
|
+
: undefined);
|
|
752
|
+
const cacheLifeHeaders = cacheLifeResponseHeaders(effectiveCacheLife);
|
|
753
|
+
const headers: [string, string][] = [
|
|
754
|
+
...(rendered.value.location
|
|
755
|
+
? [['location', rendered.value.location] as [string, string]]
|
|
756
|
+
: []),
|
|
757
|
+
...(fontLinkHeader ? [['link', fontLinkHeader] as [string, string]] : []),
|
|
758
|
+
...cacheLifeHeaders,
|
|
759
|
+
];
|
|
760
|
+
staticFiles[relative] = {
|
|
761
|
+
status: rendered.value.status,
|
|
762
|
+
headers,
|
|
763
|
+
routeId: route.id,
|
|
764
|
+
kind: 'page',
|
|
765
|
+
...(revalidateSeconds !== undefined ? { revalidateSeconds } : {}),
|
|
766
|
+
...(effectiveCacheLife?.expireSeconds !== undefined
|
|
767
|
+
? { expireSeconds: effectiveCacheLife.expireSeconds }
|
|
768
|
+
: {}),
|
|
769
|
+
...(effectiveCacheLife?.staleSeconds !== undefined
|
|
770
|
+
? { staleSeconds: effectiveCacheLife.staleSeconds }
|
|
771
|
+
: {}),
|
|
772
|
+
...(rendered.tags.length > 0 ? { tags: rendered.tags } : {}),
|
|
773
|
+
};
|
|
774
|
+
} catch (error) {
|
|
775
|
+
rethrowIfProgrammingError(error);
|
|
776
|
+
if (isAfterPrerenderError(error)) hadAfterPrerenderError = true;
|
|
777
|
+
warnSkippedStatic(routePath, error instanceof Error ? error.message : String(error));
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
},
|
|
781
|
+
);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// Generate-mode prerender diagnostics fail the pass with Next's exact
|
|
785
|
+
// export-error footer, then end the e2e capture window the way Next's
|
|
786
|
+
// crashed build worker does. Everything before this printed at detection.
|
|
787
|
+
if (isGenerate && generateFailures.length > 0) {
|
|
788
|
+
for (const failed of generateFailures) {
|
|
789
|
+
console.error(
|
|
790
|
+
buildCompat().prerenderFailureFooter(failed.route, debugPrerender, failed.omitErrorLine),
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
console.error('Next.js build worker exited with code: 1 and signal: null');
|
|
794
|
+
process.exit(1);
|
|
795
|
+
}
|
|
796
|
+
if (isGenerate) {
|
|
797
|
+
console.log('\nRoute (app)');
|
|
798
|
+
for (const line of generateRouteLines) console.log(line);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// Fail the build after every route has been given the chance to log its
|
|
802
|
+
// prerender error (matching `prerenderEarlyExit: false`).
|
|
803
|
+
if (hadAfterPrerenderError) {
|
|
804
|
+
throw new Error('Build failed because an error was thrown inside `after()` while prerendering.');
|
|
805
|
+
}
|
|
806
|
+
if (hadDynamicErrorFailure) {
|
|
807
|
+
throw new Error(
|
|
808
|
+
'Build failed because a route with `dynamic = "error"` read dynamic data during prerendering.',
|
|
809
|
+
);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
const interceptionPrerenders = isCompile
|
|
813
|
+
? new Map<string, string[]>()
|
|
814
|
+
: await logInterceptionPrerenders(config, routes);
|
|
815
|
+
|
|
816
|
+
const fallback404 = await notFoundDocuments;
|
|
817
|
+
await emitNextNotFoundArtifacts(config, fallback404);
|
|
818
|
+
await emitProxyServerArtifacts(config, proxyModule);
|
|
819
|
+
|
|
820
|
+
// Next always prerenders the built-in `/_not-found` and `/_global-error`
|
|
821
|
+
// pseudo-routes for an app-router build (even when the app ships no custom
|
|
822
|
+
// not-found / global-error file). The harness synthesizes its
|
|
823
|
+
// prerender-manifest `routes` from staticFiles, and several suites assert the
|
|
824
|
+
// exact prerendered route set (metadata-static-generation, cache-components,
|
|
825
|
+
// metadata-dynamic-routes). Register the entries so they surface. No backing
|
|
826
|
+
// file is required: the runtime static lookup keys off files that exist on
|
|
827
|
+
// disk, so a fileless manifest entry is inert at request time.
|
|
828
|
+
if (config.compat?.next) {
|
|
829
|
+
// `/_global-error` only prerenders when the app has a singular top-level
|
|
830
|
+
// root layout; with route-group root layouts (no app/layout.*) Next skips
|
|
831
|
+
// it and the prerender manifest omits the route. The hybrid pages+app
|
|
832
|
+
// materializer synthesizes an app/layout.js, so inspect the ORIGINAL app
|
|
833
|
+
// (the shim's `source-app` symlink) when it exists.
|
|
834
|
+
const sourceApp = path.join(config.appPath, '..', 'source-app');
|
|
835
|
+
const layoutRoot = existsSync(sourceApp) ? sourceApp : config.appPath;
|
|
836
|
+
const hasTopLevelRootLayout = ['tsx', 'ts', 'jsx', 'js'].some(ext =>
|
|
837
|
+
existsSync(path.join(layoutRoot, `layout.${ext}`)),
|
|
838
|
+
);
|
|
839
|
+
const pseudos = hasTopLevelRootLayout
|
|
840
|
+
? ['_not-found.html', '_global-error.html']
|
|
841
|
+
: ['_not-found.html'];
|
|
842
|
+
for (const pseudo of pseudos) {
|
|
843
|
+
if (!(pseudo in staticFiles)) {
|
|
844
|
+
staticFiles[pseudo] = { status: 200, headers: [], kind: 'page' };
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// The action modules' server compile ran under the client stage; the manifest
|
|
850
|
+
// carries its entries, so this is where it has to have landed.
|
|
851
|
+
await deferredSteps;
|
|
852
|
+
const actions = buildState.actions;
|
|
853
|
+
const manifest: BuildManifest = {
|
|
854
|
+
version: 0,
|
|
855
|
+
root: config.root,
|
|
856
|
+
appDir: config.appPath,
|
|
857
|
+
outDir: config.outPath,
|
|
858
|
+
routes,
|
|
859
|
+
staticFiles,
|
|
860
|
+
staticMetadataFiles,
|
|
861
|
+
...(Object.keys(staticModuleMetadata).length > 0 ? { staticModuleMetadata } : {}),
|
|
862
|
+
...(!config.compat?.next
|
|
863
|
+
? {
|
|
864
|
+
staticRouteMetadata: await log.step('core route metadata', () =>
|
|
865
|
+
collectStaticRouteMetadata(config, routes, staticMetadataFiles),
|
|
866
|
+
),
|
|
867
|
+
}
|
|
868
|
+
: {}),
|
|
869
|
+
...(proxyModule ? { proxyModule } : {}),
|
|
870
|
+
...(actions.length > 0 ? { actions } : {}),
|
|
871
|
+
};
|
|
872
|
+
await writeBuildManifest(config.outPath, manifest);
|
|
873
|
+
log.log(`wrote manifest.json (${routes.length} route${routes.length === 1 ? '' : 's'})`);
|
|
874
|
+
for (const hook of getBuildExtensions().completeHooks) {
|
|
875
|
+
await hook({ config, manifest, log });
|
|
876
|
+
}
|
|
877
|
+
if (options.adapter === 'vercel')
|
|
878
|
+
await log.step('vercel adapter output', () =>
|
|
879
|
+
writeVercelOutput(config, manifest, { verbose, warm }),
|
|
880
|
+
);
|
|
881
|
+
|
|
882
|
+
// Bundling is done; the build metric stops here. Phases that ran alongside it
|
|
883
|
+
// (typecheck) are awaited next and reported on their own lines.
|
|
884
|
+
const buildDurationMs = performance.now() - startedAt;
|
|
885
|
+
const phases = await settleBuildParallelPhases();
|
|
886
|
+
|
|
887
|
+
printBuildSummary(
|
|
888
|
+
config,
|
|
889
|
+
routes,
|
|
890
|
+
staticFiles,
|
|
891
|
+
buildDurationMs,
|
|
892
|
+
Boolean(proxyModule),
|
|
893
|
+
interceptionPrerenders,
|
|
894
|
+
partialPrerenders,
|
|
895
|
+
phases,
|
|
896
|
+
);
|
|
897
|
+
return manifest;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/** Run build steps in registration order, each timed under its own label. */
|
|
901
|
+
async function runBuildSteps(steps: BuildStep[], ctx: BuildStepContext) {
|
|
902
|
+
for (const [index, step] of steps.entries()) {
|
|
903
|
+
await ctx.log.step(`build step ${step.name || index}`, () => step(ctx));
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/** Await every background phase, returning each one's own elapsed time. */
|
|
908
|
+
async function settleBuildParallelPhases() {
|
|
909
|
+
const phases = getBuildExtensions().parallelPhases;
|
|
910
|
+
const timings: { name: string; durationMs: number }[] = [];
|
|
911
|
+
for (const phase of phases) timings.push({ name: phase.name, durationMs: await phase.run });
|
|
912
|
+
return timings;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/**
|
|
916
|
+
* Interception routes that export generateStaticParams are prerendered as FLIGHT-ONLY entries:
|
|
917
|
+
* their paths are reported in the build output, but no public HTML document is written. An
|
|
918
|
+
* intercepted view only ever renders inside its host page, and a hard navigation to the underlying
|
|
919
|
+
* URL must keep hitting the real route - writing `/(.)john/1.html` would shadow neither, just add
|
|
920
|
+
* dead output. Returns the display paths per route id.
|
|
921
|
+
*/
|
|
922
|
+
async function logInterceptionPrerenders(
|
|
923
|
+
config: Awaited<ReturnType<typeof loadConfig>>,
|
|
924
|
+
routes: RouteManifestEntry[],
|
|
925
|
+
): Promise<Map<string, string[]>> {
|
|
926
|
+
const prerenders = new Map<string, string[]>();
|
|
927
|
+
for (const route of routes) {
|
|
928
|
+
if (route.kind !== 'page' || !route.interception) continue;
|
|
929
|
+
if (!routeFileExportsStaticParams(route.file)) continue;
|
|
930
|
+
let paths: string[];
|
|
931
|
+
try {
|
|
932
|
+
const staticParams = await staticParamsFor(config, route);
|
|
933
|
+
paths = staticParams.paths.map(params =>
|
|
934
|
+
interceptionDisplayRoute(route, fillRoutePath(route.route, params)),
|
|
935
|
+
);
|
|
936
|
+
} catch (error) {
|
|
937
|
+
rethrowIfProgrammingError(error);
|
|
938
|
+
warnSkippedStatic(route.route, error instanceof Error ? error.message : String(error));
|
|
939
|
+
continue;
|
|
940
|
+
}
|
|
941
|
+
if (paths.length === 0) continue;
|
|
942
|
+
prerenders.set(route.id, paths);
|
|
943
|
+
for (const routePath of paths) console.log(`${dim(' ○')} ${routePath}`);
|
|
944
|
+
}
|
|
945
|
+
return prerenders;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
function routeFileExportsStaticParams(file: string): boolean {
|
|
949
|
+
if (!existsSync(file)) return false;
|
|
950
|
+
const source = readTextSync(file);
|
|
951
|
+
return /\bexport\s+(?:async\s+)?function\s+generateStaticParams\b/.test(source) ||
|
|
952
|
+
/\bexport\s+const\s+generateStaticParams\b/.test(source);
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/**
|
|
956
|
+
* How Next names an interception route: its on-disk path with route groups and
|
|
957
|
+
* `@slot` segments removed, so the marker stays visible
|
|
958
|
+
* (`/(.)[username]/[id]`, `/generate-static-params/(.)[slug]`). `route.route`
|
|
959
|
+
* has already resolved the marker away (that IS the intercepted target), so the
|
|
960
|
+
* marker is re-inserted at the segment the rewind landed on.
|
|
961
|
+
*/
|
|
962
|
+
function interceptionDisplayRoute(route: RouteManifestEntry, filled?: string): string {
|
|
963
|
+
const interception = route.interception;
|
|
964
|
+
if (!interception) return route.route;
|
|
965
|
+
const base = interception.base === '/' ? [] : interception.base.split('/').filter(Boolean);
|
|
966
|
+
const levels = interceptionMarkerLevels(interception.marker);
|
|
967
|
+
const target = (filled ?? toNextRoutePattern(route.route)).split('/').filter(Boolean);
|
|
968
|
+
// `route.route` is `<kept base>/<segments below the marker>`; the marker dir
|
|
969
|
+
// itself is the first segment past what the rewind kept.
|
|
970
|
+
const cut = Number.isFinite(levels) ? Math.max(0, base.length - levels) : 0;
|
|
971
|
+
const below = target.slice(cut);
|
|
972
|
+
const segments = [...base, `${interception.marker}${below[0] ?? ''}`, ...below.slice(1)];
|
|
973
|
+
return `/${segments.join('/')}`;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
async function writeBuildManifest(outPath: string, manifest: BuildManifest) {
|
|
977
|
+
assertManifestServerArtifacts(outPath, manifest);
|
|
978
|
+
const file = path.join(outPath, 'manifest.json');
|
|
979
|
+
const temporary = path.join(outPath, `.manifest-${process.pid}.tmp`);
|
|
980
|
+
await writeFile(temporary, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
981
|
+
await rename(temporary, file);
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
function assertManifestServerArtifacts(outPath: string, manifest: BuildManifest) {
|
|
985
|
+
const artifacts = [
|
|
986
|
+
...(manifest.proxyModule ? [manifest.proxyModule] : []),
|
|
987
|
+
...(manifest.actions?.map(action => action.modulePath) ?? []),
|
|
988
|
+
];
|
|
989
|
+
for (const artifact of artifacts) {
|
|
990
|
+
const file = path.resolve(outPath, artifact);
|
|
991
|
+
if (file !== outPath && !file.startsWith(`${outPath}${path.sep}`)) {
|
|
992
|
+
throw new Error(`Build manifest server artifact escapes output directory: ${artifact}`);
|
|
993
|
+
}
|
|
994
|
+
if (!existsSync(file)) {
|
|
995
|
+
throw new Error(`Build manifest server artifact is missing: ${artifact}`);
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
function printBuildSummary(
|
|
1001
|
+
config: Awaited<ReturnType<typeof loadConfig>>,
|
|
1002
|
+
routes: RouteManifestEntry[],
|
|
1003
|
+
staticFiles: Record<string, StaticFileMetadata>,
|
|
1004
|
+
durationMs: number,
|
|
1005
|
+
hasMiddleware = false,
|
|
1006
|
+
interceptionPrerenders = new Map<string, string[]>(),
|
|
1007
|
+
partialPrerenders = new Set<string>(),
|
|
1008
|
+
parallelPhases: { name: string; durationMs: number }[] = [],
|
|
1009
|
+
) {
|
|
1010
|
+
const pages = routes.filter(route => route.kind !== 'handler').length;
|
|
1011
|
+
const handlers = routes.length - pages;
|
|
1012
|
+
const parts = [`${pages} ${pages === 1 ? 'page' : 'pages'}`];
|
|
1013
|
+
if (handlers > 0)
|
|
1014
|
+
parts.push(`${handlers} ${handlers === 1 ? 'route handler' : 'route handlers'}`);
|
|
1015
|
+
const outDir = path.relative(config.root, config.outPath) || '.';
|
|
1016
|
+
|
|
1017
|
+
// Route table: ○ static, ● SSG (params), ◐ partial prerender, ƒ dynamic.
|
|
1018
|
+
console.log('');
|
|
1019
|
+
console.log('Route (app)');
|
|
1020
|
+
for (const route of routes) {
|
|
1021
|
+
const intercepted = interceptionPrerenders.get(route.id);
|
|
1022
|
+
const marker =
|
|
1023
|
+
route.kind === 'handler'
|
|
1024
|
+
? Object.values(staticFiles).some(file => file.routeId === route.id)
|
|
1025
|
+
? '○'
|
|
1026
|
+
: 'ƒ'
|
|
1027
|
+
: intercepted
|
|
1028
|
+
? '●'
|
|
1029
|
+
: partialPrerenders.has(route.id)
|
|
1030
|
+
? '◐'
|
|
1031
|
+
: route.mode === 'dynamic' && !route.hasStaticParams
|
|
1032
|
+
? 'ƒ'
|
|
1033
|
+
: route.hasStaticParams
|
|
1034
|
+
? '●'
|
|
1035
|
+
: '○';
|
|
1036
|
+
// An interception route is named by its own directory path (marker kept),
|
|
1037
|
+
// never by the target route it resolves to — which is a real route of its own.
|
|
1038
|
+
const label = route.interception ? interceptionDisplayRoute(route) : route.route || '/';
|
|
1039
|
+
// Next names a dynamic route by its source pattern (`/[dyn]`), not pnext's
|
|
1040
|
+
// `:param` form; compat apps' e2e output is matched against Next's.
|
|
1041
|
+
console.log(` ${marker} ${nextCompatEnabled(config) ? toNextRoutePattern(label) : label}`);
|
|
1042
|
+
}
|
|
1043
|
+
for (const file of staticMetadataSummaryFiles(staticFiles)) {
|
|
1044
|
+
console.log(` ${dim('○')} /${file}`);
|
|
1045
|
+
}
|
|
1046
|
+
if (hasMiddleware) {
|
|
1047
|
+
console.log('');
|
|
1048
|
+
console.log(` ${dim('ƒ')} Middleware`);
|
|
1049
|
+
}
|
|
1050
|
+
console.log('');
|
|
1051
|
+
console.log(
|
|
1052
|
+
`${green('✓')} ${bold('Build complete')} ${dim(`in ${formatBuildDuration(durationMs)}`)}`,
|
|
1053
|
+
);
|
|
1054
|
+
// Phases that ran alongside the build are their own metric, never folded into
|
|
1055
|
+
// the build time — a typecheck fully hidden behind bundling costs 0 wall.
|
|
1056
|
+
for (const phase of parallelPhases) {
|
|
1057
|
+
console.log(
|
|
1058
|
+
`${green('✓')} ${bold(`${phase.name} complete`)} ${dim(`in ${formatBuildDuration(phase.durationMs)}`)}`,
|
|
1059
|
+
);
|
|
1060
|
+
}
|
|
1061
|
+
console.log(` ${dim(`${parts.join(', ')} → ${outDir}`)}`);
|
|
1062
|
+
console.log('');
|
|
1063
|
+
console.log(` Run ${cyan('pnext start')} to serve the production build.`);
|
|
1064
|
+
console.log(` ${cyan('pnext analyze')} to analyze the build.`);
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
function staticMetadataSummaryFiles(staticFiles: Record<string, StaticFileMetadata>) {
|
|
1068
|
+
return Object.entries(staticFiles)
|
|
1069
|
+
.filter(([, metadata]) =>
|
|
1070
|
+
metadata.headers.some(
|
|
1071
|
+
([name, value]) =>
|
|
1072
|
+
name.toLowerCase() === 'cache-control' && value === staticMetadataCacheControl,
|
|
1073
|
+
),
|
|
1074
|
+
)
|
|
1075
|
+
.map(([file]) => file)
|
|
1076
|
+
.sort();
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
function formatBuildDuration(durationMs: number) {
|
|
1080
|
+
const totalSeconds = Math.max(0, durationMs) / 1000;
|
|
1081
|
+
if (totalSeconds < 60) return `${totalSeconds.toFixed(totalSeconds < 10 ? 2 : 1)}s`;
|
|
1082
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
1083
|
+
const seconds = Math.round(totalSeconds % 60);
|
|
1084
|
+
return `${minutes}m ${seconds}s`;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/**
|
|
1088
|
+
* Effective cacheLife + cache tags collected from a shell prerender. A `use
|
|
1089
|
+
* cache` page never re-runs its cache scopes when served from prebuilt output,
|
|
1090
|
+
* so its SWR cache-control / x-nextjs-stale-time / x-next-cache-tags headers
|
|
1091
|
+
* must be captured here and baked into the segment `.meta` (and re-emitted from
|
|
1092
|
+
* start.ts on a static HIT). Mirrors the static-prerender loop's capture.
|
|
1093
|
+
*/
|
|
1094
|
+
interface ShellCacheMeta {
|
|
1095
|
+
cacheLife?: CacheLifeStash;
|
|
1096
|
+
tags: string[];
|
|
1097
|
+
/** Prerendered `Link` header (font preloads + react-dom resource hints). */
|
|
1098
|
+
linkHeader?: string;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
/** A baked partial/fallback shell (renderPartialShell's non-null result). */
|
|
1102
|
+
type PrebuiltShell = NonNullable<Awaited<ReturnType<typeof renderPartialShell>>>;
|
|
1103
|
+
|
|
1104
|
+
/**
|
|
1105
|
+
* Render a shell (partial/fallback) inside a work unit + render cache-meta scope
|
|
1106
|
+
* so the `use cache` cacheLife stash and the collected cache tags survive for
|
|
1107
|
+
* the caller to persist. Returns the shell (or null when dynamic data escaped).
|
|
1108
|
+
*/
|
|
1109
|
+
async function renderShellWithCacheMeta(
|
|
1110
|
+
config: Awaited<ReturnType<typeof loadConfig>>,
|
|
1111
|
+
route: RouteManifestEntry,
|
|
1112
|
+
render: () => Promise<PrebuiltShell | null>,
|
|
1113
|
+
): Promise<{ prebuilt: PrebuiltShell | null; meta: ShellCacheMeta; vary: BuildResponseVary | undefined }> {
|
|
1114
|
+
// A shell prerender resolves its `use cache` scopes asynchronously and the cacheLife-to-work-unit
|
|
1115
|
+
// stash is not in scope during that propagation. collectRenderMeta's render cache-meta IS, so the
|
|
1116
|
+
// effective revalidate/expire/stale windows come back on the result.
|
|
1117
|
+
let linkHeader: string | undefined;
|
|
1118
|
+
// A BAKED segment is rendered here, outside any request, so without this scope its param accesses
|
|
1119
|
+
// are never tracked and the artifact ships with no vary set - which keys every prerendered route
|
|
1120
|
+
// on its exact URL and re-fetches the shared shell for every param value.
|
|
1121
|
+
const tracked = await buildCompat().withVaryParamsTracking(async () =>
|
|
1122
|
+
runWithWorkUnit('render', async () => {
|
|
1123
|
+
const rendered = await getRenderExtensions().collectRenderMeta(
|
|
1124
|
+
() => runWithNextBuildPhase(render),
|
|
1125
|
+
{
|
|
1126
|
+
fetchCache: route.segmentConfig?.fetchCache,
|
|
1127
|
+
route: route.route || '/',
|
|
1128
|
+
prerender: true,
|
|
1129
|
+
},
|
|
1130
|
+
);
|
|
1131
|
+
// Font preloads + react-dom preload() hints recorded during the shell
|
|
1132
|
+
// render become the route's serve-time `Link` header (a PPR resume never
|
|
1133
|
+
// re-runs the components that emitted them). Read before the unit unwinds.
|
|
1134
|
+
linkHeader = buildCompat().takeFontLinkHeader();
|
|
1135
|
+
return rendered;
|
|
1136
|
+
}),
|
|
1137
|
+
);
|
|
1138
|
+
const result = tracked.value;
|
|
1139
|
+
const cacheLife: CacheLifeStash | undefined =
|
|
1140
|
+
result.revalidateSeconds !== undefined ||
|
|
1141
|
+
result.expireSeconds !== undefined ||
|
|
1142
|
+
result.staleSeconds !== undefined
|
|
1143
|
+
? {
|
|
1144
|
+
...(result.revalidateSeconds !== undefined
|
|
1145
|
+
? { revalidateSeconds: result.revalidateSeconds }
|
|
1146
|
+
: {}),
|
|
1147
|
+
...(result.expireSeconds !== undefined ? { expireSeconds: result.expireSeconds } : {}),
|
|
1148
|
+
...(result.staleSeconds !== undefined ? { staleSeconds: result.staleSeconds } : {}),
|
|
1149
|
+
}
|
|
1150
|
+
: undefined;
|
|
1151
|
+
return {
|
|
1152
|
+
prebuilt: result.value,
|
|
1153
|
+
meta: {
|
|
1154
|
+
...(cacheLife ? { cacheLife } : {}),
|
|
1155
|
+
tags: result.tags,
|
|
1156
|
+
...(linkHeader ? { linkHeader } : {}),
|
|
1157
|
+
},
|
|
1158
|
+
vary: tracked.vary,
|
|
1159
|
+
};
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
/**
|
|
1163
|
+
* `use cache` entries whose expiry falls inside Next's DYNAMIC_EXPIRE window (5 minutes - the
|
|
1164
|
+
* `seconds` cacheLife profile, or an explicit short `expire`) are OMITTED from the prerender the
|
|
1165
|
+
* client prefetches: such data is not worth prefetching, so its boundary stays a hole the
|
|
1166
|
+
* navigation fills live.
|
|
1167
|
+
*/
|
|
1168
|
+
const DYNAMIC_EXPIRE_SECONDS = 300;
|
|
1169
|
+
|
|
1170
|
+
/**
|
|
1171
|
+
* True when the route's own sources declare a cacheLife that expires inside the dynamic-expire
|
|
1172
|
+
* window. A cheap source scan used purely to decide whether the extra prefetch-shell prerender below
|
|
1173
|
+
* is worth running - every other route keeps a single shell render.
|
|
1174
|
+
*/
|
|
1175
|
+
function routeDeclaresShortLivedCache(route: RouteManifestEntry): boolean {
|
|
1176
|
+
for (const file of route.sourceFiles) {
|
|
1177
|
+
if (!existsSync(file)) continue;
|
|
1178
|
+
const source = readTextSync(file);
|
|
1179
|
+
for (const literal of source.matchAll(/cacheLife\s*\(\s*\{[^}]*\bexpire\s*:\s*([^,}]+)/gs)) {
|
|
1180
|
+
const value = staticNumberExpression(literal[1]!);
|
|
1181
|
+
if (value !== undefined && value < DYNAMIC_EXPIRE_SECONDS) return true;
|
|
1182
|
+
}
|
|
1183
|
+
for (const named of source.matchAll(/cacheLife\s*\(\s*['"]([^'"]+)['"]\s*\)/g)) {
|
|
1184
|
+
const expire = builtInCacheLifeExpire(named[1]!);
|
|
1185
|
+
if (expire !== undefined && expire < DYNAMIC_EXPIRE_SECONDS) return true;
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
return false;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
/**
|
|
1192
|
+
* The shell to publish as the route's PREFETCH body segment. pnext bakes ONE shell and serves it
|
|
1193
|
+
* both as the HTML document and as the body segment, so a route with a short-lived cache gets a
|
|
1194
|
+
* SECOND prerender whose `use cache` gate omits that cache. Only that render feeds the segment
|
|
1195
|
+
* artifacts - the prerendered document keeps the cached block inline. Undefined when the route needs
|
|
1196
|
+
* no separate prefetch body, or the gated render produced no shell.
|
|
1197
|
+
*/
|
|
1198
|
+
async function renderPrefetchBody(
|
|
1199
|
+
config: Awaited<ReturnType<typeof loadConfig>>,
|
|
1200
|
+
route: RouteManifestEntry,
|
|
1201
|
+
render: () => Promise<PrebuiltShell | null>,
|
|
1202
|
+
): Promise<{ prebuilt: PrebuiltShell; vary: BuildResponseVary | undefined } | undefined> {
|
|
1203
|
+
if (!routeDeclaresShortLivedCache(route)) return undefined;
|
|
1204
|
+
try {
|
|
1205
|
+
const { prebuilt, vary } = await renderShellWithCacheMeta(config, route, render);
|
|
1206
|
+
return prebuilt ? { prebuilt, vary } : undefined;
|
|
1207
|
+
} catch {
|
|
1208
|
+
// Best-effort: the document shell doubles as the prefetch body, as before.
|
|
1209
|
+
return undefined;
|
|
1210
|
+
} finally {
|
|
1211
|
+
abortActivePrerenderScopes();
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
async function buildPprShell(
|
|
1216
|
+
config: Awaited<ReturnType<typeof loadConfig>>,
|
|
1217
|
+
route: RouteManifestEntry,
|
|
1218
|
+
) {
|
|
1219
|
+
const url = new URL(`http://pnext.local${route.route}`);
|
|
1220
|
+
// Render under the production-build phase so build-phase sentinels
|
|
1221
|
+
// (process.env.NEXT_PHASE) resolve to their buildtime value in the shell.
|
|
1222
|
+
const { prebuilt, meta, vary } = await renderShellWithCacheMeta(config, route, () =>
|
|
1223
|
+
renderPartialShell({ config, route, url }),
|
|
1224
|
+
);
|
|
1225
|
+
if (!prebuilt) {
|
|
1226
|
+
// Dynamic data escaped every Suspense boundary — there is no static shell to
|
|
1227
|
+
// bake, so drop the PPR flag and let the route render fully dynamically.
|
|
1228
|
+
route.ppr = false;
|
|
1229
|
+
return;
|
|
1230
|
+
}
|
|
1231
|
+
route.pprHoles = prebuilt.holes;
|
|
1232
|
+
route.pprMetadata = prebuilt.metadataDynamic || undefined;
|
|
1233
|
+
persistRouteCacheLife(route, meta.cacheLife);
|
|
1234
|
+
recordRouteCacheTags(route, meta.tags);
|
|
1235
|
+
if (meta.linkHeader) route.linkHeader = meta.linkHeader;
|
|
1236
|
+
const file = pprShellPath(config.outPath, route.id);
|
|
1237
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
1238
|
+
await writeText(file, prebuilt.shell);
|
|
1239
|
+
const prefetchBody = (await renderPrefetchBody(config, route, () =>
|
|
1240
|
+
renderPartialShell({ config, route, url, prefetchShell: true }),
|
|
1241
|
+
)) ?? { prebuilt, vary };
|
|
1242
|
+
await emitSegmentArtifacts(
|
|
1243
|
+
config,
|
|
1244
|
+
route,
|
|
1245
|
+
prefetchBody.prebuilt.shell,
|
|
1246
|
+
prefetchBody.prebuilt.holes.length > 0,
|
|
1247
|
+
meta,
|
|
1248
|
+
undefined,
|
|
1249
|
+
prefetchBody.vary,
|
|
1250
|
+
// renderPartialShell against the PATTERN url: a route-level, params-hanging
|
|
1251
|
+
// render, so an empty tracked set is honest (see buildVaryTrusted).
|
|
1252
|
+
true,
|
|
1253
|
+
);
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
/** Union shell-prerender cache tags onto the route (PPR-shell tag staleness). */
|
|
1257
|
+
function recordRouteCacheTags(route: RouteManifestEntry, tags: readonly string[]): void {
|
|
1258
|
+
if (tags.length === 0) return;
|
|
1259
|
+
route.cacheTags = [...new Set([...(route.cacheTags ?? []), ...tags])];
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
/** Persist a shell render's effective cacheLife on the route so the static-serve
|
|
1263
|
+
* path (start.ts) can re-emit the SWR cache-control + x-nextjs-stale-time headers
|
|
1264
|
+
* on a pure static HIT/MISS (the header finalizer only fires on a live render). */
|
|
1265
|
+
function persistRouteCacheLife(route: RouteManifestEntry, life: CacheLifeStash | undefined): void {
|
|
1266
|
+
if (!life) return;
|
|
1267
|
+
route.cacheLife = {
|
|
1268
|
+
...(life.revalidateSeconds !== undefined ? { revalidateSeconds: life.revalidateSeconds } : {}),
|
|
1269
|
+
...(life.expireSeconds !== undefined ? { expireSeconds: life.expireSeconds } : {}),
|
|
1270
|
+
...(life.staleSeconds !== undefined ? { staleSeconds: life.staleSeconds } : {}),
|
|
1271
|
+
};
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
/**
|
|
1275
|
+
* Emit the per-route segment artifacts (Stage D M2) alongside a baked shell:
|
|
1276
|
+
* `_tree.segment.rsc` (the RootTreePrefetch), `index.segment.rsc` (the route
|
|
1277
|
+
* body as one segment), and `route.segment.meta` (status/postponed/segmentPaths).
|
|
1278
|
+
* The segment prefetch responder serves these; the client segment cache stitches
|
|
1279
|
+
* the body fragment on navigation. Whole-route single-segment milestone.
|
|
1280
|
+
*/
|
|
1281
|
+
async function emitSegmentArtifacts(
|
|
1282
|
+
config: Awaited<ReturnType<typeof loadConfig>>,
|
|
1283
|
+
route: RouteManifestEntry,
|
|
1284
|
+
body: string,
|
|
1285
|
+
postponed: boolean,
|
|
1286
|
+
cacheMeta?: ShellCacheMeta,
|
|
1287
|
+
statusOverride?: number,
|
|
1288
|
+
vary?: BuildResponseVary,
|
|
1289
|
+
/** The body came from the ROUTE-LEVEL shell render, whose params hung. An
|
|
1290
|
+
* EMPTY tracked set is then the truth (a param read would have postponed and
|
|
1291
|
+
* left a hole), not the untracked-render artifact `buildVaryTrusted`
|
|
1292
|
+
* otherwise defends against. Never pass `true` for a CONCRETE-param render. */
|
|
1293
|
+
fallbackParamsRender = false,
|
|
1294
|
+
): Promise<void> {
|
|
1295
|
+
const isStatic = route.mode === 'static' && !postponed;
|
|
1296
|
+
const compat = buildCompat();
|
|
1297
|
+
const staleTime =
|
|
1298
|
+
staleTimeFromRouteSources(route) ??
|
|
1299
|
+
(isStatic
|
|
1300
|
+
? compat.defaultStaticStaleTimeSeconds
|
|
1301
|
+
: compat.defaultDynamicStaleTimeSeconds);
|
|
1302
|
+
const bodySizeBytes = Buffer.byteLength(body);
|
|
1303
|
+
const tree = compat.buildRootTreePrefetch({
|
|
1304
|
+
pathname: route.route,
|
|
1305
|
+
isStatic,
|
|
1306
|
+
staleTimeSeconds: staleTime,
|
|
1307
|
+
routeId: route.id,
|
|
1308
|
+
bodySizeBytes,
|
|
1309
|
+
postponed,
|
|
1310
|
+
runtimePrefetch: (route.segmentConfig as { prefetch?: unknown } | undefined)?.prefetch === 'allow-runtime',
|
|
1311
|
+
// The route's <title> is dynamic and ships as its own `/_head` response;
|
|
1312
|
+
// announcing it in the TREE lets the client fetch the head before the body
|
|
1313
|
+
// (Next's response order) instead of learning it from the body response.
|
|
1314
|
+
// A route with dynamic PARAMS answers that head FIRST: its body is shared
|
|
1315
|
+
// across param values while the head varies per URL. A PARAMLESS route's
|
|
1316
|
+
// head is outlined out of its single body response and follows it.
|
|
1317
|
+
...(route.pprMetadata === true
|
|
1318
|
+
? { headOutlined: true, ...(/[:*]/.test(route.route) ? { headFirst: true } : {}) }
|
|
1319
|
+
: {}),
|
|
1320
|
+
});
|
|
1321
|
+
const meta = compat.buildSegmentMeta({
|
|
1322
|
+
status: statusOverride ?? 200,
|
|
1323
|
+
staleTime,
|
|
1324
|
+
postponed,
|
|
1325
|
+
bodySizeBytes,
|
|
1326
|
+
prefetchHints: { [route.route]: tree.tree.prefetchHints },
|
|
1327
|
+
...(vary && buildVaryTrusted(route, vary, fallbackParamsRender) ? { vary } : {}),
|
|
1328
|
+
});
|
|
1329
|
+
await mkdir(compat.segmentDir(config.outPath, route.id), { recursive: true });
|
|
1330
|
+
await writeText(compat.treeSegmentFile(config.outPath, route.id), JSON.stringify(tree));
|
|
1331
|
+
await writeText(compat.bodySegmentFile(config.outPath, route.id), body);
|
|
1332
|
+
await writeText(compat.segmentMetaFile(config.outPath, route.id), JSON.stringify(meta));
|
|
1333
|
+
await emitNextSegmentArtifacts(
|
|
1334
|
+
config.root,
|
|
1335
|
+
route,
|
|
1336
|
+
body,
|
|
1337
|
+
compat.rootTreePrefetchText(tree, 'flight'),
|
|
1338
|
+
meta,
|
|
1339
|
+
segmentMetaHeaders(cacheMeta),
|
|
1340
|
+
);
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
/**
|
|
1344
|
+
* Whether a BUILD render's tracked vary set may be persisted as the route's published set. Mirrors
|
|
1345
|
+
* the request path's `varyTrusted`: an EMPTY set on a params-bearing route is more often an artifact
|
|
1346
|
+
* (nothing was tracked) than the truth, and under-varying serves one param's content for another, so
|
|
1347
|
+
* such a render stays "unknown" (exact-URL keying).
|
|
1348
|
+
*
|
|
1349
|
+
* The exception is a ROUTE-LEVEL shell render (`fallbackParamsRender`): its params HANG, so a
|
|
1350
|
+
* component that read one postponed and left a hole instead of recording an access. The empty set is
|
|
1351
|
+
* then the truth - the baked bytes are param-independent by construction.
|
|
1352
|
+
*/
|
|
1353
|
+
/**
|
|
1354
|
+
* Persist a concrete-param prerender's tracked vary sets on the route entry. The request-time render
|
|
1355
|
+
* of such a URL is answered from the prerender, so it executes no user code and tracks nothing;
|
|
1356
|
+
* without this the response publishes no vary set and every param value keys its own entry. Only a
|
|
1357
|
+
* set the render really recorded is kept - an empty one on a params-bearing route is the
|
|
1358
|
+
* untracked-render artifact `buildVaryTrusted` guards against.
|
|
1359
|
+
*/
|
|
1360
|
+
function recordPrerenderVary(
|
|
1361
|
+
route: RouteManifestEntry,
|
|
1362
|
+
routePath: string,
|
|
1363
|
+
vary: BuildResponseVary | undefined,
|
|
1364
|
+
): void {
|
|
1365
|
+
if (!vary || !buildVaryTrusted(route, vary, false)) return;
|
|
1366
|
+
if (vary.params.length === 0 && !vary.search) return;
|
|
1367
|
+
route.prerenderVary ??= {};
|
|
1368
|
+
route.prerenderVary[routePath] = {
|
|
1369
|
+
vary: buildCompat().varyNamesFor(vary, 'body'),
|
|
1370
|
+
layoutVary: buildCompat().varyNamesFor(vary, 'layout'),
|
|
1371
|
+
pageVary: buildCompat().varyNamesFor(vary, 'page'),
|
|
1372
|
+
};
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
function buildVaryTrusted(
|
|
1376
|
+
route: RouteManifestEntry,
|
|
1377
|
+
vary: BuildResponseVary,
|
|
1378
|
+
fallbackParamsRender: boolean,
|
|
1379
|
+
): boolean {
|
|
1380
|
+
if (vary.params.length > 0 || vary.search) return true;
|
|
1381
|
+
if (fallbackParamsRender) return true;
|
|
1382
|
+
return route.params.length === 0 && !route.catchAll;
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
/**
|
|
1386
|
+
* The Next-shaped `<route>.meta` `headers` a `use cache` prerender contributes:
|
|
1387
|
+
* the SWR cache-control, x-nextjs-stale-time (from cacheLife), and the joined
|
|
1388
|
+
* x-next-cache-tags (from the render's collected cache tags). Suites read these
|
|
1389
|
+
* directly off the `.meta` sidecar (use-cache: stale config / unstable_cache
|
|
1390
|
+
* tags). Empty when the route declared no cacheLife and no tags.
|
|
1391
|
+
*/
|
|
1392
|
+
function segmentMetaHeaders(cacheMeta: ShellCacheMeta | undefined): Record<string, string> {
|
|
1393
|
+
const headers: Record<string, string> = {};
|
|
1394
|
+
if (!cacheMeta) return headers;
|
|
1395
|
+
for (const [key, value] of cacheLifeResponseHeaders(cacheMeta.cacheLife)) headers[key] = value;
|
|
1396
|
+
if (cacheMeta.tags.length > 0) headers['x-next-cache-tags'] = cacheMeta.tags.join(',');
|
|
1397
|
+
return headers;
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
async function emitNextSegmentArtifacts(
|
|
1401
|
+
root: string,
|
|
1402
|
+
route: RouteManifestEntry,
|
|
1403
|
+
body: string,
|
|
1404
|
+
tree: string,
|
|
1405
|
+
meta: SegmentMeta,
|
|
1406
|
+
headers: Record<string, string> = {},
|
|
1407
|
+
): Promise<void> {
|
|
1408
|
+
const appPath = nextAppRoutePath(route.route);
|
|
1409
|
+
const appFile = path.join(root, '.next', 'server', 'app', appPath);
|
|
1410
|
+
const segmentsDir = `${appFile}.segments`;
|
|
1411
|
+
const nextMeta = {
|
|
1412
|
+
status: meta.status,
|
|
1413
|
+
// Next's page `.meta` carries the route's response headers (x-nextjs-stale-
|
|
1414
|
+
// time, x-next-cache-tags, SWR cache-control) so tooling/suites read a
|
|
1415
|
+
// prerendered `use cache` route's effective cache config off disk.
|
|
1416
|
+
headers,
|
|
1417
|
+
staleTime: meta.staleTime,
|
|
1418
|
+
...(meta.postponed ? { postponed: '1' } : {}),
|
|
1419
|
+
segmentPaths: meta.segmentPaths,
|
|
1420
|
+
...(meta.segmentSizes ? { segmentSizes: meta.segmentSizes } : {}),
|
|
1421
|
+
...(meta.inlinedSegmentPaths ? { inlinedSegmentPaths: meta.inlinedSegmentPaths } : {}),
|
|
1422
|
+
...(meta.prefetchHints ? { prefetchHints: meta.prefetchHints } : {}),
|
|
1423
|
+
};
|
|
1424
|
+
|
|
1425
|
+
await mkdir(segmentsDir, { recursive: true });
|
|
1426
|
+
// A baked shell is stored OPEN (no closing tags) so the serve path can stream resumed holes into
|
|
1427
|
+
// it. Next's equivalent file is instead the finished document whenever nothing was postponed, and
|
|
1428
|
+
// suites tell a complete prerender from an incomplete shell by testing for the `</html>` tail.
|
|
1429
|
+
// Close a hole-free shell here; a postponed one stays open, exactly as Next leaves its partials.
|
|
1430
|
+
await writeText(`${appFile}.html`, meta.postponed ? body : `${body}\n </body></html>`);
|
|
1431
|
+
await writeText(`${appFile}.meta`, JSON.stringify(nextMeta));
|
|
1432
|
+
await writeText(path.join(segmentsDir, '_tree.segment.rsc'), tree);
|
|
1433
|
+
await writeText(path.join(segmentsDir, '_full.segment.rsc'), body);
|
|
1434
|
+
await writeText(path.join(segmentsDir, '_index.segment.rsc'), body);
|
|
1435
|
+
|
|
1436
|
+
const routeSegment = appPath === 'index' ? '__PAGE__' : appPath;
|
|
1437
|
+
await writeNestedSegment(segmentsDir, `${routeSegment}.segment.rsc`, body);
|
|
1438
|
+
await writeNestedSegment(segmentsDir, path.join(routeSegment, '__PAGE__.segment.rsc'), body);
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
/**
|
|
1442
|
+
* Emit Next-shaped `.next/server/app/<path>.{html,meta}` + `.segments/
|
|
1443
|
+
* _tree.segment.rsc` for one CONCRETE statically prerendered page path (a
|
|
1444
|
+
* generateStaticParams entry or a param-free static page), carrying the real
|
|
1445
|
+
* prerender STATUS (404 for a notFound() prerender, 403 forbidden, 401
|
|
1446
|
+
* unauthorized). The http-access-fallback-prerender suite reads these directly
|
|
1447
|
+
* (`meta.status`, `meta.segmentPaths` containing '/_tree', the tree segment
|
|
1448
|
+
* file). cacheComponents-only: the classic static path keeps its existing
|
|
1449
|
+
* artifact set.
|
|
1450
|
+
*/
|
|
1451
|
+
async function emitConcreteNextPageArtifacts(
|
|
1452
|
+
config: Awaited<ReturnType<typeof loadConfig>>,
|
|
1453
|
+
route: RouteManifestEntry,
|
|
1454
|
+
routePath: string,
|
|
1455
|
+
rendered: { html: string; status: number; postponed?: boolean },
|
|
1456
|
+
): Promise<void> {
|
|
1457
|
+
if (!config.compat?.next || !cacheComponents()) return;
|
|
1458
|
+
const compat = buildCompat();
|
|
1459
|
+
const postponed = rendered.postponed ?? false;
|
|
1460
|
+
const bodySizeBytes = Buffer.byteLength(rendered.html);
|
|
1461
|
+
const staleTime = staleTimeFromRouteSources(route) ?? compat.defaultStaticStaleTimeSeconds;
|
|
1462
|
+
const tree = compat.buildRootTreePrefetch({
|
|
1463
|
+
pathname: routePath,
|
|
1464
|
+
isStatic: !postponed,
|
|
1465
|
+
staleTimeSeconds: staleTime,
|
|
1466
|
+
routeId: route.id,
|
|
1467
|
+
bodySizeBytes,
|
|
1468
|
+
postponed,
|
|
1469
|
+
});
|
|
1470
|
+
const meta = compat.buildSegmentMeta({
|
|
1471
|
+
status: rendered.status,
|
|
1472
|
+
staleTime,
|
|
1473
|
+
postponed,
|
|
1474
|
+
bodySizeBytes,
|
|
1475
|
+
});
|
|
1476
|
+
const appFile = path.join(config.root, '.next', 'server', 'app', nextAppRoutePath(routePath));
|
|
1477
|
+
const segmentsDir = `${appFile}.segments`;
|
|
1478
|
+
await mkdir(segmentsDir, { recursive: true });
|
|
1479
|
+
await writeText(`${appFile}.html`, rendered.html);
|
|
1480
|
+
await writeText(
|
|
1481
|
+
`${appFile}.meta`,
|
|
1482
|
+
JSON.stringify({
|
|
1483
|
+
status: meta.status,
|
|
1484
|
+
headers: {},
|
|
1485
|
+
staleTime: meta.staleTime,
|
|
1486
|
+
...(postponed ? { postponed: '1' } : {}),
|
|
1487
|
+
segmentPaths: meta.segmentPaths,
|
|
1488
|
+
...(meta.segmentSizes ? { segmentSizes: meta.segmentSizes } : {}),
|
|
1489
|
+
}),
|
|
1490
|
+
);
|
|
1491
|
+
await writeText(
|
|
1492
|
+
path.join(segmentsDir, '_tree.segment.rsc'),
|
|
1493
|
+
compat.rootTreePrefetchText(tree, 'flight'),
|
|
1494
|
+
);
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
function staleTimeFromRouteSources(route: RouteManifestEntry): number | undefined {
|
|
1498
|
+
let found: number | undefined;
|
|
1499
|
+
for (const file of route.sourceFiles) {
|
|
1500
|
+
if (!existsSync(file)) continue;
|
|
1501
|
+
const source = readTextSync(file);
|
|
1502
|
+
for (const literal of source.matchAll(/cacheLife\s*\(\s*\{[^}]*\bstale\s*:\s*([^,}]+)/gs)) {
|
|
1503
|
+
const value = staticNumberExpression(literal[1]!);
|
|
1504
|
+
if (value !== undefined) found = minDefined(found, value);
|
|
1505
|
+
}
|
|
1506
|
+
for (const named of source.matchAll(/cacheLife\s*\(\s*['"]([^'"]+)['"]\s*\)/g)) {
|
|
1507
|
+
const namedValue = builtInCacheLifeStale(named[1]!);
|
|
1508
|
+
if (namedValue !== undefined) found = minDefined(found, namedValue);
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
return found;
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
function readTextSync(file: string): string {
|
|
1515
|
+
try {
|
|
1516
|
+
return existsSync(file) && statSync(file).isFile() ? readFileSync(file, 'utf8') : '';
|
|
1517
|
+
} catch {
|
|
1518
|
+
return '';
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
function staticNumberExpression(expression: string): number | undefined {
|
|
1523
|
+
const trimmed = expression.trim();
|
|
1524
|
+
if (/^\d+(?:\.\d+)?$/.test(trimmed)) return Number(trimmed);
|
|
1525
|
+
const product = trimmed.split('*').map(part => part.trim());
|
|
1526
|
+
if (product.length > 1 && product.every(part => /^\d+(?:\.\d+)?$/.test(part))) {
|
|
1527
|
+
return product.reduce((value, part) => value * Number(part), 1);
|
|
1528
|
+
}
|
|
1529
|
+
return undefined;
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
function builtInCacheLifeStale(profile: string): number | undefined {
|
|
1533
|
+
switch (profile) {
|
|
1534
|
+
case 'seconds':
|
|
1535
|
+
// Caches on the 'seconds' profile are OMITTED from static prerenders
|
|
1536
|
+
// (their client stale window is below the runtime threshold), so their
|
|
1537
|
+
// staleness must not shorten the route's prefetch expiry — only the
|
|
1538
|
+
// longer-lived caches that actually land in the prerender count.
|
|
1539
|
+
return undefined;
|
|
1540
|
+
case 'default':
|
|
1541
|
+
case 'minutes':
|
|
1542
|
+
case 'hours':
|
|
1543
|
+
case 'days':
|
|
1544
|
+
case 'weeks':
|
|
1545
|
+
case 'max':
|
|
1546
|
+
return 300;
|
|
1547
|
+
default:
|
|
1548
|
+
return undefined;
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
/** `expire` (seconds) of a built-in cacheLife profile. */
|
|
1553
|
+
function builtInCacheLifeExpire(profile: string): number | undefined {
|
|
1554
|
+
switch (profile) {
|
|
1555
|
+
case 'seconds':
|
|
1556
|
+
return 60;
|
|
1557
|
+
case 'minutes':
|
|
1558
|
+
return 3600;
|
|
1559
|
+
case 'hours':
|
|
1560
|
+
return 86400;
|
|
1561
|
+
case 'days':
|
|
1562
|
+
return 604800;
|
|
1563
|
+
case 'weeks':
|
|
1564
|
+
return 2592000;
|
|
1565
|
+
case 'default':
|
|
1566
|
+
case 'max':
|
|
1567
|
+
return 31536000;
|
|
1568
|
+
default:
|
|
1569
|
+
return undefined;
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
function minDefined(a: number | undefined, b: number | undefined): number | undefined {
|
|
1574
|
+
if (a === undefined) return b;
|
|
1575
|
+
if (b === undefined) return a;
|
|
1576
|
+
return Math.min(a, b);
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
async function writeNestedSegment(root: string, relative: string, body: string): Promise<void> {
|
|
1580
|
+
const file = path.join(root, relative);
|
|
1581
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
1582
|
+
await writeText(file, body);
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
function nextAppRoutePath(route: string): string {
|
|
1586
|
+
return route.replace(/^\/+/, '') || 'index';
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
/**
|
|
1590
|
+
* True when a prebuilt shell's `<body>` renders anything of its own - content that sits OUTSIDE
|
|
1591
|
+
* every postponed boundary and therefore reaches the browser as static markup.
|
|
1592
|
+
*
|
|
1593
|
+
* The postponed boundaries themselves (which carry only their fallback) do not count: a shell whose
|
|
1594
|
+
* whole document is one boundary has nothing static to serve. Neither do the runtime's own scripts.
|
|
1595
|
+
* Text is the signal, with the self-closing media tags added because they render something too.
|
|
1596
|
+
*/
|
|
1597
|
+
function shellRendersContentOutsideHoles(shell: string): boolean {
|
|
1598
|
+
// A shell is a PARTIAL document: it usually stops mid-stream with no closing
|
|
1599
|
+
// `</body>`, so read from the body tag to whichever comes first.
|
|
1600
|
+
const open = /<body[^>]*>/.exec(shell);
|
|
1601
|
+
if (!open) return false;
|
|
1602
|
+
const rest = shell.slice(open.index + open[0].length);
|
|
1603
|
+
const body = rest.split('</body>')[0] ?? '';
|
|
1604
|
+
const outside = stripBalanced(body, 'pnext-suspense')
|
|
1605
|
+
.replace(/<(script|template|style)[^>]*>[\s\S]*?<\/\1>/g, '')
|
|
1606
|
+
.replace(/<!--[\s\S]*?-->/g, '');
|
|
1607
|
+
if (/<(img|svg|video|canvas|input)[\s>]/.test(outside)) return true;
|
|
1608
|
+
return outside.replace(/<[^>]*>/g, '').trim().length > 0;
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
/**
|
|
1612
|
+
* True when the shell's postponed boundaries carry visible fallback UI (a `loading.tsx` / non-empty
|
|
1613
|
+
* `<Suspense fallback>`) - a fallback shell worth serving even when everything outside the holes is
|
|
1614
|
+
* empty. This is the discriminator between an empty `<Suspense>` wrapped around `<body>` itself
|
|
1615
|
+
* (nothing to show, stays blocking) and a route whose whole body is one hole that still paints.
|
|
1616
|
+
*/
|
|
1617
|
+
function shellHolesRenderFallbackContent(shell: string): boolean {
|
|
1618
|
+
const open = /<body[^>]*>/.exec(shell);
|
|
1619
|
+
if (!open) return false;
|
|
1620
|
+
const body = (shell.slice(open.index + open[0].length).split('</body>')[0] ?? '')
|
|
1621
|
+
.replace(/<(script|template|style)[^>]*>[\s\S]*?<\/\1>/g, '')
|
|
1622
|
+
.replace(/<!--[\s\S]*?-->/g, '');
|
|
1623
|
+
const outside = stripBalanced(body, 'pnext-suspense');
|
|
1624
|
+
const text = (h: string) => h.replace(/<[^>]*>/g, '').trim().length;
|
|
1625
|
+
const media = (h: string) => /<(img|svg|video|canvas|input)[\s>]/.test(h);
|
|
1626
|
+
return text(body) - text(outside) > 0 || (media(body) && !media(outside));
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
/** Remove every balanced `<tag>…</tag>` region (nesting-aware) from `html`. */
|
|
1630
|
+
function stripBalanced(html: string, tag: string): string {
|
|
1631
|
+
const open = new RegExp(`<${tag}[\\s>]`, 'g');
|
|
1632
|
+
const boundary = new RegExp(`<${tag}[\\s>]|</${tag}>`, 'g');
|
|
1633
|
+
let out = '';
|
|
1634
|
+
let cursor = 0;
|
|
1635
|
+
for (let start = open.exec(html); start; start = open.exec(html)) {
|
|
1636
|
+
if (start.index < cursor) continue;
|
|
1637
|
+
out += html.slice(cursor, start.index);
|
|
1638
|
+
boundary.lastIndex = start.index + 1;
|
|
1639
|
+
let depth = 1;
|
|
1640
|
+
let end = html.length;
|
|
1641
|
+
for (let hit = boundary.exec(html); hit; hit = boundary.exec(html)) {
|
|
1642
|
+
depth += hit[0].startsWith('</') ? -1 : 1;
|
|
1643
|
+
if (depth === 0) {
|
|
1644
|
+
end = hit.index + hit[0].length;
|
|
1645
|
+
break;
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
cursor = end;
|
|
1649
|
+
open.lastIndex = cursor;
|
|
1650
|
+
}
|
|
1651
|
+
return out + html.slice(cursor);
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
/**
|
|
1655
|
+
* cacheComponents shell build for a route that did not opt in via
|
|
1656
|
+
* experimental_ppr. Param-free routes render a normal partial shell; routes
|
|
1657
|
+
* with dynamic params render a param-independent fallback shell (params hang).
|
|
1658
|
+
* Returns true when a shell was baked (route is now PPR), false when dynamic
|
|
1659
|
+
* data escaped every boundary (route builds normally).
|
|
1660
|
+
*
|
|
1661
|
+
* Sub-shells (Stage C.2): when a dynamic-param route ships generateStaticParams
|
|
1662
|
+
* covering a leading prefix of its params (e.g. `lang` for `/[lang]/[slug]`),
|
|
1663
|
+
* one sub-shell is baked per distinct prefix (lang concrete, slug hanging) in
|
|
1664
|
+
* addition to the base fallback (all params hang). The request path serves the
|
|
1665
|
+
* deepest matching sub-shell so `/es/1` reuses the `/es/[slug]` shell (lang
|
|
1666
|
+
* layout buildtime) while `/xx/1` (uncovered lang) falls back to the base shell.
|
|
1667
|
+
*/
|
|
1668
|
+
async function buildCacheComponentsShell(
|
|
1669
|
+
config: Awaited<ReturnType<typeof loadConfig>>,
|
|
1670
|
+
route: RouteManifestEntry,
|
|
1671
|
+
): Promise<boolean> {
|
|
1672
|
+
const url = new URL(`http://pnext.local${route.route}`);
|
|
1673
|
+
const hasDynamicParams = route.params.length > 0 || Boolean(route.catchAll);
|
|
1674
|
+
// Build-phase so buildtime sentinels resolve correctly in the shell.
|
|
1675
|
+
beginShellSourceTracking();
|
|
1676
|
+
let renderResult: Awaited<ReturnType<typeof renderShellWithCacheMeta>>;
|
|
1677
|
+
try {
|
|
1678
|
+
renderResult = await renderShellWithCacheMeta(config, route, () =>
|
|
1679
|
+
hasDynamicParams
|
|
1680
|
+
? renderFallbackShell({ config, route, url })
|
|
1681
|
+
: renderPartialShell({ config, route, url }),
|
|
1682
|
+
);
|
|
1683
|
+
} finally {
|
|
1684
|
+
// Belt-and-braces: force-abort any prerender scope this route left armed so a
|
|
1685
|
+
// stray cacheSignal-bound timer can never keep the build's event loop alive.
|
|
1686
|
+
abortActivePrerenderScopes();
|
|
1687
|
+
}
|
|
1688
|
+
const { prebuilt, meta } = renderResult;
|
|
1689
|
+
const shellSources = endShellSourceTracking();
|
|
1690
|
+
// A base fallback shell provides NO partial-shell value when it is either null (dynamic data
|
|
1691
|
+
// escaped every boundary) or params-only-empty (its only holes come from `params`, which are
|
|
1692
|
+
// URL-derived and resolve at request time). Such a base shell is never served as a postponed
|
|
1693
|
+
// response: an uncovered param renders BLOCKING.
|
|
1694
|
+
//
|
|
1695
|
+
// The tracking has one blind spot, repaired by a source scan: a component that awaits `params`
|
|
1696
|
+
// BEFORE calling a request API never reaches that call during the fallback prerender - the params
|
|
1697
|
+
// postpone unwinds it at the first await - so `nonParamsRequest` stays false and a genuinely
|
|
1698
|
+
// dynamic route reads as params-only. Those routes DO ship a fallback shell in Next. Scanning is
|
|
1699
|
+
// one-directional: it can only keep a shell that dynamic tracking already produced, never invent one.
|
|
1700
|
+
//
|
|
1701
|
+
// "Params-only" is about the HOLES, not the shell: a params-only shell that still renders content
|
|
1702
|
+
// OUTSIDE its boundaries is a genuine partial shell - Next serves it and streams the rest, which is
|
|
1703
|
+
// what makes the root layout's buildtime sentinel survive. Only a shell with nothing outside its
|
|
1704
|
+
// holes is the "no partial-shell value" case the blocking rule is for.
|
|
1705
|
+
//
|
|
1706
|
+
// ...with one carve-out: a hole that declared its own `cacheLife` is a CACHE entry keyed by the
|
|
1707
|
+
// params it postponed on, and the baked base shell would be served as a params-independent
|
|
1708
|
+
// prerender (one stale-time, no per-params vary metadata) for every param combination. Routes whose
|
|
1709
|
+
// only dynamic content is such a boundary stay dynamic, so each request re-renders the boundary
|
|
1710
|
+
// with its own params. The rescue above is for uncached params access.
|
|
1711
|
+
const baseShellIsEmpty =
|
|
1712
|
+
prebuilt === null ||
|
|
1713
|
+
(prebuilt.holes.length > 0 &&
|
|
1714
|
+
!prebuilt.metadataDynamic &&
|
|
1715
|
+
!shellSources.cacheIO &&
|
|
1716
|
+
!shellSources.nonParamsRequest &&
|
|
1717
|
+
!(
|
|
1718
|
+
meta.cacheLife === undefined &&
|
|
1719
|
+
(shellRendersContentOutsideHoles(prebuilt.shell) ||
|
|
1720
|
+
shellHolesRenderFallbackContent(prebuilt.shell))
|
|
1721
|
+
) &&
|
|
1722
|
+
!(hasDynamicParams && routeSourcesUseRequestApi(config.root, route)));
|
|
1723
|
+
|
|
1724
|
+
if (!baseShellIsEmpty) {
|
|
1725
|
+
route.ppr = true;
|
|
1726
|
+
route.pprHoles = prebuilt.holes;
|
|
1727
|
+
route.pprMetadata = prebuilt.metadataDynamic || undefined;
|
|
1728
|
+
persistRouteCacheLife(route, meta.cacheLife);
|
|
1729
|
+
recordRouteCacheTags(route, meta.tags);
|
|
1730
|
+
if (meta.linkHeader) route.linkHeader = meta.linkHeader;
|
|
1731
|
+
const file = pprShellPath(config.outPath, route.id);
|
|
1732
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
1733
|
+
await writeText(file, prebuilt.shell);
|
|
1734
|
+
// A param-free shell that resolved through http-access fallback recovery (a
|
|
1735
|
+
// notFound()/forbidden()/unauthorized() above the boundary) must carry the fallback STATUS in
|
|
1736
|
+
// its `.meta`, not 200. The shell prerender does not surface it, so probe with a blocking render
|
|
1737
|
+
// - the same pattern as the buildSubShells full-param probe.
|
|
1738
|
+
let fallbackStatus: number | undefined;
|
|
1739
|
+
if (!hasDynamicParams && routeSourcesUseHttpFallback(config.root, route)) {
|
|
1740
|
+
try {
|
|
1741
|
+
const probe = await runWithWorkUnit('render', () =>
|
|
1742
|
+
getRenderExtensions()
|
|
1743
|
+
.collectRenderMeta(
|
|
1744
|
+
() =>
|
|
1745
|
+
runWithNextBuildPhase(() => renderPageWithStatus({ config, route, params: {}, url })),
|
|
1746
|
+
{ route: route.route || '/', prerender: true },
|
|
1747
|
+
)
|
|
1748
|
+
.then(result => result.value),
|
|
1749
|
+
);
|
|
1750
|
+
if (probe.status !== 200) fallbackStatus = probe.status;
|
|
1751
|
+
} catch {
|
|
1752
|
+
// Probe render failed — the shell itself is unaffected.
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
const prefetchBody = (await renderPrefetchBody(config, route, () =>
|
|
1756
|
+
hasDynamicParams
|
|
1757
|
+
? renderFallbackShell({ config, route, url, prefetchShell: true })
|
|
1758
|
+
: renderPartialShell({ config, route, url, prefetchShell: true }),
|
|
1759
|
+
)) ?? { prebuilt, vary: renderResult.vary };
|
|
1760
|
+
await emitSegmentArtifacts(
|
|
1761
|
+
config,
|
|
1762
|
+
route,
|
|
1763
|
+
prefetchBody.prebuilt.shell,
|
|
1764
|
+
prefetchBody.prebuilt.holes.length > 0,
|
|
1765
|
+
meta,
|
|
1766
|
+
fallbackStatus,
|
|
1767
|
+
prefetchBody.vary,
|
|
1768
|
+
// renderFallbackShell/renderPartialShell against the PATTERN url: a
|
|
1769
|
+
// route-level, params-hanging render (see buildVaryTrusted).
|
|
1770
|
+
true,
|
|
1771
|
+
);
|
|
1772
|
+
|
|
1773
|
+
// Descending-specificity sub-shells from generateStaticParams prefixes.
|
|
1774
|
+
if (hasDynamicParams && route.hasStaticParams) {
|
|
1775
|
+
await buildSubShells(config, route);
|
|
1776
|
+
}
|
|
1777
|
+
return true;
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
// The base shell is empty, but a route whose generateStaticParams cover a PARTIAL prefix still
|
|
1781
|
+
// ships per-prefix sub-shells that DO resolve the covered params around the remaining hole -
|
|
1782
|
+
// genuine partial shells that must postpone. Build those without writing a base shell file, so a
|
|
1783
|
+
// request for an uncovered param finds no shell in loadPprShell and falls through to a blocking
|
|
1784
|
+
// render, while a covered prefix resumes its sub-shell.
|
|
1785
|
+
//
|
|
1786
|
+
// Under cacheComponents a FULL static param set is a sub-shell too WHEN the route still holds
|
|
1787
|
+
// request-time data: its params are concrete at build time while a connection()/cookies() boundary
|
|
1788
|
+
// must postpone. Without this the route falls through to the blocking static prerender, which
|
|
1789
|
+
// BAKES that data and leaves the client no dynamic request. The request-API scan keeps this narrow
|
|
1790
|
+
// - a route whose full param sets render with no request-time data is a COMPLETE static prerender.
|
|
1791
|
+
if (
|
|
1792
|
+
hasDynamicParams &&
|
|
1793
|
+
route.hasStaticParams &&
|
|
1794
|
+
((await hasPartialStaticPrefix(config, route)) ||
|
|
1795
|
+
(cacheComponents() &&
|
|
1796
|
+
routeSourcesUseRequestApi(config.root, route) &&
|
|
1797
|
+
(await hasFullStaticParamSet(config, route))))
|
|
1798
|
+
) {
|
|
1799
|
+
route.ppr = true;
|
|
1800
|
+
route.pprMetadata = prebuilt?.metadataDynamic || undefined;
|
|
1801
|
+
if (prebuilt) {
|
|
1802
|
+
persistRouteCacheLife(route, meta.cacheLife);
|
|
1803
|
+
recordRouteCacheTags(route, meta.tags);
|
|
1804
|
+
if (meta.linkHeader) route.linkHeader = meta.linkHeader;
|
|
1805
|
+
}
|
|
1806
|
+
await buildSubShells(config, route);
|
|
1807
|
+
return true;
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
// No partial-prefix sub-shells either: fall through to the normal
|
|
1811
|
+
// static/dynamic build (full generateStaticParams sets still prerender;
|
|
1812
|
+
// uncovered params render dynamically).
|
|
1813
|
+
return false;
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
/**
|
|
1817
|
+
* True when the route's generateStaticParams cover a PARTIAL leading param prefix (fewer params than
|
|
1818
|
+
* the route declares) - the shape that produces a postponing sub-shell with statically-resolved
|
|
1819
|
+
* params around a remaining hole. A route whose static params are all FULL sets (or has none) yields
|
|
1820
|
+
* no partial sub-shell, so a params-only base fallback shell is served with a blocking render.
|
|
1821
|
+
*/
|
|
1822
|
+
async function hasPartialStaticPrefix(
|
|
1823
|
+
config: Awaited<ReturnType<typeof loadConfig>>,
|
|
1824
|
+
route: RouteManifestEntry,
|
|
1825
|
+
): Promise<boolean> {
|
|
1826
|
+
if (!route.hasStaticParams) return false;
|
|
1827
|
+
let staticParams: Awaited<ReturnType<typeof staticParamsFor>>;
|
|
1828
|
+
try {
|
|
1829
|
+
staticParams = await staticParamsFor(config, route);
|
|
1830
|
+
} catch {
|
|
1831
|
+
return false;
|
|
1832
|
+
}
|
|
1833
|
+
const paramKeys = [...route.params, ...(route.catchAll ? [route.catchAll] : [])];
|
|
1834
|
+
for (const set of staticParams.allSets) {
|
|
1835
|
+
let filled = 0;
|
|
1836
|
+
while (filled < paramKeys.length && set[paramKeys[filled]!] !== undefined) filled++;
|
|
1837
|
+
if (filled > 0 && filled < paramKeys.length) return true;
|
|
1838
|
+
}
|
|
1839
|
+
return false;
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
/**
|
|
1843
|
+
* True when some generateStaticParams set fills EVERY param the route declares.
|
|
1844
|
+
* buildSubShells already renders those as concrete, postponing sub-shells; this
|
|
1845
|
+
* predicate is what lets them be reached under cacheComponents.
|
|
1846
|
+
*/
|
|
1847
|
+
async function hasFullStaticParamSet(
|
|
1848
|
+
config: Awaited<ReturnType<typeof loadConfig>>,
|
|
1849
|
+
route: RouteManifestEntry,
|
|
1850
|
+
): Promise<boolean> {
|
|
1851
|
+
if (!route.hasStaticParams) return false;
|
|
1852
|
+
let staticParams: Awaited<ReturnType<typeof staticParamsFor>>;
|
|
1853
|
+
try {
|
|
1854
|
+
staticParams = await staticParamsFor(config, route);
|
|
1855
|
+
} catch {
|
|
1856
|
+
return false;
|
|
1857
|
+
}
|
|
1858
|
+
const paramKeys = [...route.params, ...(route.catchAll ? [route.catchAll] : [])];
|
|
1859
|
+
if (paramKeys.length === 0) return false;
|
|
1860
|
+
return staticParams.allSets.some(set => paramKeys.every(key => set[key] !== undefined));
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
/**
|
|
1864
|
+
* Bake one sub-shell per distinct PARTIAL param prefix from the route's
|
|
1865
|
+
* generateStaticParams. A prefix fills a proper leading subset of the route's
|
|
1866
|
+
* params (in declaration order) and leaves the rest hanging. Sub-shells are
|
|
1867
|
+
* recorded on the route MOST-specific first.
|
|
1868
|
+
*/
|
|
1869
|
+
async function buildSubShells(
|
|
1870
|
+
config: Awaited<ReturnType<typeof loadConfig>>,
|
|
1871
|
+
route: RouteManifestEntry,
|
|
1872
|
+
): Promise<void> {
|
|
1873
|
+
let staticParams: Awaited<ReturnType<typeof staticParamsFor>>;
|
|
1874
|
+
try {
|
|
1875
|
+
staticParams = await staticParamsFor(config, route);
|
|
1876
|
+
} catch {
|
|
1877
|
+
return;
|
|
1878
|
+
}
|
|
1879
|
+
route.prerenderedParams = staticParams.allSets;
|
|
1880
|
+
const paramKeys = [...route.params, ...(route.catchAll ? [route.catchAll] : [])];
|
|
1881
|
+
// Distinct leading prefixes AND full param sets. A partial prefix fills keys
|
|
1882
|
+
// [0..k) (k < len) and leaves the rest hanging → a sub-shell (postponed). A
|
|
1883
|
+
// FULL set fills every param → a fully-prerendered shell with no holes (not
|
|
1884
|
+
// postponed) that the request path serves for that exact URL.
|
|
1885
|
+
const prefixes = new Map<string, Record<string, RouteParamValue>>();
|
|
1886
|
+
for (const set of staticParams.allSets) {
|
|
1887
|
+
let filled = 0;
|
|
1888
|
+
while (filled < paramKeys.length && set[paramKeys[filled]!] !== undefined) filled++;
|
|
1889
|
+
if (filled === 0) continue;
|
|
1890
|
+
const concrete: Record<string, RouteParamValue> = {};
|
|
1891
|
+
for (let i = 0; i < filled; i++) concrete[paramKeys[i]!] = set[paramKeys[i]!]!;
|
|
1892
|
+
const key = subShellKey(concrete);
|
|
1893
|
+
if (!prefixes.has(key)) prefixes.set(key, concrete);
|
|
1894
|
+
}
|
|
1895
|
+
if (prefixes.size === 0) return;
|
|
1896
|
+
|
|
1897
|
+
const subShells: NonNullable<RouteManifestEntry['pprSubShells']> = [];
|
|
1898
|
+
for (const [key, concreteParams] of prefixes) {
|
|
1899
|
+
const routePath = fillRoutePath(route.route, concreteParams);
|
|
1900
|
+
const url = new URL(`http://pnext.local${routePath}`);
|
|
1901
|
+
// Collect cache meta so a sub-shell's cache tags (its concrete-param
|
|
1902
|
+
// prefix lets `use cache` scopes fill that the fallback shell postponed)
|
|
1903
|
+
// join the route's tag set for PPR-shell tag staleness.
|
|
1904
|
+
const { value: prebuilt, tags } = await runWithWorkUnit('render', () =>
|
|
1905
|
+
getRenderExtensions().collectRenderMeta(
|
|
1906
|
+
() =>
|
|
1907
|
+
runWithNextBuildPhase(() =>
|
|
1908
|
+
renderSubShell({ config, route, url, concreteParams, paramKeys }),
|
|
1909
|
+
),
|
|
1910
|
+
{ fetchCache: route.segmentConfig?.fetchCache, route: routePath || '/', prerender: true },
|
|
1911
|
+
),
|
|
1912
|
+
);
|
|
1913
|
+
recordRouteCacheTags(route, tags);
|
|
1914
|
+
if (!prebuilt) continue;
|
|
1915
|
+
const file = pprSubShellPath(config.outPath, route.id, key);
|
|
1916
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
1917
|
+
await writeText(file, prebuilt.shell);
|
|
1918
|
+
subShells.push({ key, concreteParams, holes: prebuilt.holes });
|
|
1919
|
+
// A FULL param set is a concrete prerendered path - emit Next's `.{html,meta}` plus tree-segment
|
|
1920
|
+
// artifacts for it. The sub-shell is the prerendered html (dynamic holes stay excluded, so a
|
|
1921
|
+
// dynamic <head> never leaks into the static copy); a blocking probe render supplies the real
|
|
1922
|
+
// HTTP status when the page resolves to an http-access fallback. Postponed iff the sub-shell kept
|
|
1923
|
+
// holes, mirrored as Next's `.meta` postponed marker string.
|
|
1924
|
+
if (Object.keys(concreteParams).length === paramKeys.length) {
|
|
1925
|
+
try {
|
|
1926
|
+
const probe = await runWithWorkUnit('render', () =>
|
|
1927
|
+
getRenderExtensions()
|
|
1928
|
+
.collectRenderMeta(
|
|
1929
|
+
() =>
|
|
1930
|
+
runWithNextBuildPhase(() =>
|
|
1931
|
+
renderPageWithStatus({
|
|
1932
|
+
config,
|
|
1933
|
+
route,
|
|
1934
|
+
params: encodeStaticRouteParams(concreteParams),
|
|
1935
|
+
url,
|
|
1936
|
+
}),
|
|
1937
|
+
),
|
|
1938
|
+
{ route: routePath || '/', prerender: true },
|
|
1939
|
+
)
|
|
1940
|
+
.then(result => result.value),
|
|
1941
|
+
);
|
|
1942
|
+
// Dynamic metadata counts: the head resumes at request time, so the shell is partial even
|
|
1943
|
+
// with zero body holes. The sub-shell render may not re-flag metadataDynamic once the
|
|
1944
|
+
// fallback shell recorded it, so the route-level flag joins in. http-access fallback recovery
|
|
1945
|
+
// renders a BOUNDARY file's metadata, which the shell flags never see, so the route's source
|
|
1946
|
+
// graph is scanned for a dynamic generateMetadata/generateViewport too.
|
|
1947
|
+
const postponed =
|
|
1948
|
+
prebuilt.holes.length > 0 ||
|
|
1949
|
+
prebuilt.metadataDynamic ||
|
|
1950
|
+
route.pprMetadata === true ||
|
|
1951
|
+
routeSourcesHaveDynamicHead(route);
|
|
1952
|
+
await emitConcreteNextPageArtifacts(config, route, routePath, {
|
|
1953
|
+
html: postponed ? prebuilt.shell : `${prebuilt.shell}\n </body></html>`,
|
|
1954
|
+
status: probe.status,
|
|
1955
|
+
postponed,
|
|
1956
|
+
});
|
|
1957
|
+
} catch {
|
|
1958
|
+
// Probe render failed — the sub-shell itself is unaffected.
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
subShells.sort(
|
|
1963
|
+
(a, b) => Object.keys(b.concreteParams).length - Object.keys(a.concreteParams).length,
|
|
1964
|
+
);
|
|
1965
|
+
if (subShells.length > 0) route.pprSubShells = subShells;
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
/**
|
|
1969
|
+
* Whether any file in the route's source graph CALLS an http-access fallback API
|
|
1970
|
+
* (notFound/forbidden/unauthorized) - the only shape whose prerendered `.meta` status can differ from
|
|
1971
|
+
* 200, so only these routes pay for a blocking status-probe render alongside their shell.
|
|
1972
|
+
*/
|
|
1973
|
+
function routeSourcesUseHttpFallback(root: string, route: RouteManifestEntry): boolean {
|
|
1974
|
+
for (const file of route.sourceFiles) {
|
|
1975
|
+
// App files only: `sourceFiles` also carries framework entries (pnext's own
|
|
1976
|
+
// compat sources name these APIs), which would blocking-probe every route
|
|
1977
|
+
// that imports next/server.
|
|
1978
|
+
if (!file.startsWith(root) || file.includes(`${path.sep}node_modules${path.sep}`)) continue;
|
|
1979
|
+
if (!existsSync(file)) continue;
|
|
1980
|
+
const source = readTextSync(file);
|
|
1981
|
+
if (/\b(?:notFound|forbidden|unauthorized)\s*\(\s*\)/.test(source)) return true;
|
|
1982
|
+
}
|
|
1983
|
+
return false;
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
/**
|
|
1987
|
+
* Whether any APP file in the route's source graph CALLS a request API that is
|
|
1988
|
+
* NOT params (connection()/cookies()/headers()/draftMode()). Used only to
|
|
1989
|
+
* repair the params-only fallback-shell classification, where a call sequenced
|
|
1990
|
+
* after an `await params` is invisible to shell-source tracking. Deliberately
|
|
1991
|
+
* narrow on both axes: `searchParams` is left out because the bare identifier
|
|
1992
|
+
* appears in far too many prop signatures to be a reliable call signal, and the
|
|
1993
|
+
* scan skips the framework/node_modules entries `sourceFiles` also carries
|
|
1994
|
+
* (pnext's own `src/ppr.ts` names every one of these APIs). Missing a source
|
|
1995
|
+
* only leaves the route on today's blocking path.
|
|
1996
|
+
*/
|
|
1997
|
+
function routeSourcesUseRequestApi(root: string, route: RouteManifestEntry): boolean {
|
|
1998
|
+
for (const file of route.sourceFiles) {
|
|
1999
|
+
if (!file.startsWith(root) || file.includes(`${path.sep}node_modules${path.sep}`)) continue;
|
|
2000
|
+
if (!existsSync(file)) continue;
|
|
2001
|
+
// Comments are stripped first: fixtures routinely SAY "no connection()" in
|
|
2002
|
+
// prose above a page that has none (`//` inside a string only costs a
|
|
2003
|
+
// false negative, which is the safe direction here).
|
|
2004
|
+
const source = readTextSync(file)
|
|
2005
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
2006
|
+
.replace(/(^|[^:])\/\/.*$/gm, '$1');
|
|
2007
|
+
if (/\b(?:connection|cookies|headers|draftMode)\s*\(\s*\)/.test(source)) return true;
|
|
2008
|
+
}
|
|
2009
|
+
return false;
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
/**
|
|
2013
|
+
* Whether any file in the route's source graph exports a dynamic (awaiting, uncached)
|
|
2014
|
+
* generateMetadata/generateViewport - including boundary files like not-found.tsx whose dynamic head
|
|
2015
|
+
* only renders during fallback recovery.
|
|
2016
|
+
*/
|
|
2017
|
+
function routeSourcesHaveDynamicHead(route: RouteManifestEntry): boolean {
|
|
2018
|
+
for (const file of route.sourceFiles) {
|
|
2019
|
+
if (!existsSync(file)) continue;
|
|
2020
|
+
const source = readTextSync(file);
|
|
2021
|
+
const match =
|
|
2022
|
+
/export\s+(?:async\s+)?function\s+(?:generateMetadata|generateViewport)\s*\([^)]*\)\s*(?::[^{]+)?\{/.exec(
|
|
2023
|
+
source,
|
|
2024
|
+
);
|
|
2025
|
+
if (!match) continue;
|
|
2026
|
+
const body = source.slice(match.index + match[0].length);
|
|
2027
|
+
if (/\bawait\b/.test(body.split('\nexport ')[0] ?? body) && !body.includes('use cache')) {
|
|
2028
|
+
return true;
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
return false;
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
/**
|
|
2035
|
+
* Restrict the scanned route table to files matching `--debug-build-paths` (Next's debugging flag):
|
|
2036
|
+
* comma-separated root-relative paths or simple globs. A route is kept when its own file matches;
|
|
2037
|
+
* everything else - validation, bundling, prerendering - never sees the dropped routes.
|
|
2038
|
+
*/
|
|
2039
|
+
function filterDebugBuildRoutes(
|
|
2040
|
+
root: string,
|
|
2041
|
+
routes: RouteManifestEntry[],
|
|
2042
|
+
patterns: string,
|
|
2043
|
+
): RouteManifestEntry[] {
|
|
2044
|
+
const matchers = patterns
|
|
2045
|
+
.split(',')
|
|
2046
|
+
.map(pattern => pattern.trim())
|
|
2047
|
+
.filter(pattern => pattern.length > 0 && !pattern.startsWith('!'))
|
|
2048
|
+
.map(debugBuildPathMatcher);
|
|
2049
|
+
if (matchers.length === 0) return routes;
|
|
2050
|
+
return routes.filter(route => {
|
|
2051
|
+
const relative = toPosixPath(path.relative(root, route.file));
|
|
2052
|
+
return matchers.some(matcher => matcher(relative));
|
|
2053
|
+
});
|
|
2054
|
+
}
|
|
2055
|
+
|
|
2056
|
+
function debugBuildPathMatcher(pattern: string): (file: string) => boolean {
|
|
2057
|
+
if (!/[*?]/.test(pattern)) {
|
|
2058
|
+
return file => file === pattern || file.endsWith(`/${pattern}`);
|
|
2059
|
+
}
|
|
2060
|
+
const regex = new RegExp(
|
|
2061
|
+
`^${pattern
|
|
2062
|
+
.split(/(\*\*\/|\*\*|\*|\?)/)
|
|
2063
|
+
.map(part => {
|
|
2064
|
+
if (part === '**/') return '(?:.*/)?';
|
|
2065
|
+
if (part === '**') return '.*';
|
|
2066
|
+
if (part === '*') return '[^/]*';
|
|
2067
|
+
if (part === '?') return '[^/]';
|
|
2068
|
+
return part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
2069
|
+
})
|
|
2070
|
+
.join('')}$`,
|
|
2071
|
+
);
|
|
2072
|
+
return file => regex.test(file);
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
/** Stable on-disk signature for a sub-shell's concrete-param prefix. */
|
|
2076
|
+
function subShellKey(concrete: Record<string, RouteParamValue>): string {
|
|
2077
|
+
return Object.entries(concrete)
|
|
2078
|
+
.map(([k, v]) => `${k}-${(Array.isArray(v) ? v.join('_') : v).replace(/[^a-zA-Z0-9_]/g, '_')}`)
|
|
2079
|
+
.join('.');
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
/**
|
|
2083
|
+
* Bundle the proxy/middleware file with compat server aliases baked in. Raw
|
|
2084
|
+
* Bun imports at request time cannot resolve bare compat specifiers
|
|
2085
|
+
* (next/server etc.), so start must load this build-time artifact instead.
|
|
2086
|
+
*/
|
|
2087
|
+
async function buildProxyModule(config: Awaited<ReturnType<typeof loadConfig>>) {
|
|
2088
|
+
await validateProxyFiles(config);
|
|
2089
|
+
const file = findProxyFile(config);
|
|
2090
|
+
if (!file) return undefined;
|
|
2091
|
+
const href = await devServerModuleHref(config, file, 'prod', {
|
|
2092
|
+
conditionTarget: 'edge',
|
|
2093
|
+
externalLoadTarget: proxyExternalLoadTarget(file),
|
|
2094
|
+
reactServerLayer: true,
|
|
2095
|
+
});
|
|
2096
|
+
const compiled = fileURLToPath(href);
|
|
2097
|
+
return toPosixPath(path.relative(config.outPath, compiled));
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
/**
|
|
2101
|
+
* Render the build's 404 documents: `public/404.html` when the app has a prerenderable not-found
|
|
2102
|
+
* convention, and the `_not-found` document Next always emits for a compat build. Returns the
|
|
2103
|
+
* latter's HTML. Runs as its own stage alongside the client bundles - it only needs the route table,
|
|
2104
|
+
* the stub set and the metadata collected before them.
|
|
2105
|
+
*/
|
|
2106
|
+
async function renderNotFoundDocuments({
|
|
2107
|
+
config,
|
|
2108
|
+
log,
|
|
2109
|
+
skip,
|
|
2110
|
+
documentCss,
|
|
2111
|
+
staticMetadataFiles,
|
|
2112
|
+
staticModuleMetadata,
|
|
2113
|
+
}: {
|
|
2114
|
+
config: Awaited<ReturnType<typeof loadConfig>>;
|
|
2115
|
+
log: VerboseLogger;
|
|
2116
|
+
skip: boolean;
|
|
2117
|
+
documentCss: Promise<unknown>;
|
|
2118
|
+
staticMetadataFiles: StaticMetadataFile[];
|
|
2119
|
+
staticModuleMetadata: Record<string, StaticModuleMetadata>;
|
|
2120
|
+
}): Promise<string | undefined> {
|
|
2121
|
+
if (skip) return undefined;
|
|
2122
|
+
const render = async () => {
|
|
2123
|
+
const response = await renderGlobalNotFoundResponse({
|
|
2124
|
+
config,
|
|
2125
|
+
url: new URL('http://pnext.local/_not-found'),
|
|
2126
|
+
staticMetadataFiles,
|
|
2127
|
+
staticModuleMetadata,
|
|
2128
|
+
resolveDynamicMetadataRoutes: true,
|
|
2129
|
+
});
|
|
2130
|
+
return response.text();
|
|
2131
|
+
};
|
|
2132
|
+
// The document links the not-found stylesheet chunk buildNotFoundCss emits.
|
|
2133
|
+
await documentCss;
|
|
2134
|
+
|
|
2135
|
+
if (await shouldBuildGlobalNotFound(config)) {
|
|
2136
|
+
await log.step('global not-found', async () =>
|
|
2137
|
+
writeText(path.join(config.outPath, 'public', '404.html'), await render()),
|
|
2138
|
+
);
|
|
2139
|
+
}
|
|
2140
|
+
// Next always prerenders the built-in `/_not-found` document for a compat
|
|
2141
|
+
// build even without a prerenderable not-found file (not-found/default reads
|
|
2142
|
+
// `.next/server/app/_not-found.html`). When public/404.html was written, the
|
|
2143
|
+
// .next artifacts are copied from it; otherwise they need a document here.
|
|
2144
|
+
if (!config.compat?.next) return undefined;
|
|
2145
|
+
if (existsSync(path.join(config.outPath, 'public', '404.html'))) return undefined;
|
|
2146
|
+
// Nothing prerenderable to render into the document: either the app authored no not-found.* at
|
|
2147
|
+
// all, or it authored one and declared it force-dynamic, in which case Next leaves `/_not-found`
|
|
2148
|
+
// dynamic and prerenders no custom 404 either. Emit the standalone default rather than boot the
|
|
2149
|
+
// whole server graph for an artifact that is never served - the app's *runtime* 404, still
|
|
2150
|
+
// dynamic, is what users see.
|
|
2151
|
+
return defaultNotFoundDocument();
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
/**
|
|
2155
|
+
* The app's 404 convention and whether it is prerenderable. `force-dynamic`
|
|
2156
|
+
* opts the not-found route out of prerendering exactly as it does any page.
|
|
2157
|
+
*/
|
|
2158
|
+
async function shouldBuildGlobalNotFound(config: Awaited<ReturnType<typeof loadConfig>>) {
|
|
2159
|
+
const file = ['tsx', 'ts', 'jsx', 'js']
|
|
2160
|
+
.flatMap(ext => [
|
|
2161
|
+
path.join(config.appPath, `global-not-found.${ext}`),
|
|
2162
|
+
path.join(config.appPath, `not-found.${ext}`),
|
|
2163
|
+
])
|
|
2164
|
+
.find(candidate => existsSync(candidate));
|
|
2165
|
+
if (!file) return false;
|
|
2166
|
+
return !/\bexport\s+const\s+dynamic\s*=\s*['"]force-dynamic['"]/.test(await readText(file));
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
/**
|
|
2170
|
+
* Materialize Next's `.next/server/middleware.js` (+ `.nft.json`) for a compat build with a
|
|
2171
|
+
* proxy/middleware file. buildProxyModule bundles the compiled proxy under a hashed name; Next
|
|
2172
|
+
* instead names it `middleware.js` (renaming even a `proxy.ts` source), and providers read the
|
|
2173
|
+
* sibling `.nft.json` to know which files to deploy. The trace lists only the emitted
|
|
2174
|
+
* `middleware.js` itself, never the original `proxy.js` name - the mismatch this artifact avoids.
|
|
2175
|
+
*/
|
|
2176
|
+
async function emitProxyServerArtifacts(
|
|
2177
|
+
config: Awaited<ReturnType<typeof loadConfig>>,
|
|
2178
|
+
proxyModule: string | undefined,
|
|
2179
|
+
) {
|
|
2180
|
+
if (!config.compat?.next || !proxyModule) return;
|
|
2181
|
+
const compiled = path.resolve(config.outPath, proxyModule);
|
|
2182
|
+
if (!existsSync(compiled)) return;
|
|
2183
|
+
|
|
2184
|
+
const serverDir = path.join(config.root, '.next', 'server');
|
|
2185
|
+
await mkdir(serverDir, { recursive: true });
|
|
2186
|
+
const middlewareFile = path.join(serverDir, 'middleware.js');
|
|
2187
|
+
await copyFile(compiled, middlewareFile);
|
|
2188
|
+
|
|
2189
|
+
// NFT files are relative to the trace file's directory and must all exist on
|
|
2190
|
+
// disk. The self-contained bundle is the only traced output.
|
|
2191
|
+
const files = ['middleware.js'].filter(name => existsSync(path.join(serverDir, name)));
|
|
2192
|
+
await writeFile(
|
|
2193
|
+
path.join(serverDir, 'middleware.js.nft.json'),
|
|
2194
|
+
`${JSON.stringify({ version: 1, files })}\n`,
|
|
2195
|
+
);
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
async function emitNextNotFoundArtifacts(
|
|
2199
|
+
config: Awaited<ReturnType<typeof loadConfig>>,
|
|
2200
|
+
fallback404?: string,
|
|
2201
|
+
) {
|
|
2202
|
+
if (!config.compat?.next) return;
|
|
2203
|
+
const source = path.join(config.outPath, 'public', '404.html');
|
|
2204
|
+
const html = existsSync(source) ? readFileSync(source, 'utf8') : fallback404;
|
|
2205
|
+
if (html === undefined) return;
|
|
2206
|
+
|
|
2207
|
+
const serverDir = path.join(config.root, '.next', 'server');
|
|
2208
|
+
const pagesDir = path.join(serverDir, 'pages');
|
|
2209
|
+
const notFoundDir = path.join(serverDir, 'app', '_not-found');
|
|
2210
|
+
await mkdir(pagesDir, { recursive: true });
|
|
2211
|
+
await mkdir(notFoundDir, { recursive: true });
|
|
2212
|
+
await writeFile(path.join(pagesDir, '404.html'), html);
|
|
2213
|
+
// Next's app-router prerender output for the built-in /_not-found route. The
|
|
2214
|
+
// .rsc mirror carries the document body like the segment artifacts do
|
|
2215
|
+
// (not-found/default greps both for the noindex marker).
|
|
2216
|
+
await writeFile(path.join(serverDir, 'app', '_not-found.html'), html);
|
|
2217
|
+
await writeFile(path.join(serverDir, 'app', '_not-found.rsc'), html);
|
|
2218
|
+
|
|
2219
|
+
const pagesManifestFile = path.join(serverDir, 'pages-manifest.json');
|
|
2220
|
+
let pagesManifest: Record<string, string> = {};
|
|
2221
|
+
try {
|
|
2222
|
+
pagesManifest = JSON.parse(readFileSync(pagesManifestFile, 'utf8')) as Record<string, string>;
|
|
2223
|
+
} catch {
|
|
2224
|
+
// First compat build has no pages manifest yet.
|
|
2225
|
+
}
|
|
2226
|
+
pagesManifest['/404'] = 'pages/404.html';
|
|
2227
|
+
await writeFile(pagesManifestFile, `${JSON.stringify(pagesManifest, null, 2)}\n`);
|
|
2228
|
+
|
|
2229
|
+
const clientReferenceManifest = 'page_client-reference-manifest.js';
|
|
2230
|
+
await writeFile(path.join(notFoundDir, clientReferenceManifest), 'self.__RSC_MANIFEST={}\n');
|
|
2231
|
+
await writeFile(
|
|
2232
|
+
path.join(notFoundDir, 'page.js.nft.json'),
|
|
2233
|
+
`${JSON.stringify({ version: 1, files: [clientReferenceManifest] }, null, 2)}\n`,
|
|
2234
|
+
);
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
async function collectStaticModuleMetadata(
|
|
2238
|
+
config: Awaited<ReturnType<typeof loadConfig>>,
|
|
2239
|
+
routes: RouteManifestEntry[],
|
|
2240
|
+
) {
|
|
2241
|
+
const metadata: Record<string, StaticModuleMetadata> = {};
|
|
2242
|
+
if (config.compat?.next) return metadata;
|
|
2243
|
+
|
|
2244
|
+
const files = new Set<string>();
|
|
2245
|
+
for (const route of routes) {
|
|
2246
|
+
if (route.kind !== 'page') continue;
|
|
2247
|
+
for (const file of findLayouts(config.appPath, route.file)) {
|
|
2248
|
+
if (existsSync(file)) files.add(file);
|
|
2249
|
+
}
|
|
2250
|
+
files.add(route.file);
|
|
2251
|
+
}
|
|
2252
|
+
if (files.size === 0) return metadata;
|
|
2253
|
+
|
|
2254
|
+
registerServerRuntime(config, [...files]);
|
|
2255
|
+
registerCssRuntime();
|
|
2256
|
+
for (const file of files) {
|
|
2257
|
+
const href = reactCompatEnabled(config)
|
|
2258
|
+
? await devServerModuleHref(config, file, 'build')
|
|
2259
|
+
: pathToFileHref(file);
|
|
2260
|
+
const module = (await import(href)) as {
|
|
2261
|
+
metadata?: Parameters<typeof readModuleMetadata>[0]['metadata'];
|
|
2262
|
+
viewport?: Parameters<typeof readModuleViewport>[0]['viewport'];
|
|
2263
|
+
};
|
|
2264
|
+
const entry: StaticModuleMetadata = {};
|
|
2265
|
+
const routeMetadata = await readModuleMetadata(module);
|
|
2266
|
+
const viewport = await readModuleViewport(module);
|
|
2267
|
+
if (routeMetadata) entry.metadata = routeMetadata;
|
|
2268
|
+
if (viewport) entry.viewport = viewport;
|
|
2269
|
+
if (entry.metadata || entry.viewport) metadata[file] = entry;
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
return metadata;
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
async function collectStaticRouteMetadata(
|
|
2276
|
+
config: Awaited<ReturnType<typeof loadConfig>>,
|
|
2277
|
+
routes: RouteManifestEntry[],
|
|
2278
|
+
staticMetadataFiles: StaticMetadataFile[],
|
|
2279
|
+
) {
|
|
2280
|
+
const metadata: Record<string, StaticRouteMetadata> = {};
|
|
2281
|
+
if (config.compat?.next) return metadata;
|
|
2282
|
+
|
|
2283
|
+
for (const route of routes) {
|
|
2284
|
+
if (route.kind !== 'page' || route.interception) continue;
|
|
2285
|
+
const paramSets = route.prerenderedParams ?? (route.params.length === 0 ? [{}] : []);
|
|
2286
|
+
for (const params of paramSets) {
|
|
2287
|
+
const routePath = fillRoutePath(route.route, params);
|
|
2288
|
+
const base = route.interception
|
|
2289
|
+
? staticMetadataForRouteFromFiles(
|
|
2290
|
+
staticMetadataFiles,
|
|
2291
|
+
config.appPath,
|
|
2292
|
+
route.file,
|
|
2293
|
+
routePath,
|
|
2294
|
+
)
|
|
2295
|
+
: staticMetadataForPathFromFiles(staticMetadataFiles, routePath);
|
|
2296
|
+
const resolved = await withDynamicMetadataRoutes(
|
|
2297
|
+
base,
|
|
2298
|
+
config.appPath,
|
|
2299
|
+
route.file,
|
|
2300
|
+
routePath,
|
|
2301
|
+
file => importMetadataModule(config, file),
|
|
2302
|
+
);
|
|
2303
|
+
metadata[staticRouteMetadataKey(routePath)] = resolved;
|
|
2304
|
+
}
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
return metadata;
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
async function importMetadataModule(
|
|
2311
|
+
config: Awaited<ReturnType<typeof loadConfig>>,
|
|
2312
|
+
file: string,
|
|
2313
|
+
): Promise<Record<string, unknown>> {
|
|
2314
|
+
const href = reactCompatEnabled(config)
|
|
2315
|
+
? await devServerModuleHref(config, file, 'build')
|
|
2316
|
+
: pathToFileHref(file);
|
|
2317
|
+
return import(href) as Promise<Record<string, unknown>>;
|
|
2318
|
+
}
|
|
2319
|
+
|
|
2320
|
+
async function buildStaticRouteHandler(
|
|
2321
|
+
config: Awaited<ReturnType<typeof loadConfig>>,
|
|
2322
|
+
route: RouteManifestEntry,
|
|
2323
|
+
options: { skipPhysicalWrite?: boolean } = {},
|
|
2324
|
+
) {
|
|
2325
|
+
const skipPhysicalWrite = options.skipPhysicalWrite ?? false;
|
|
2326
|
+
registerServerRuntime(config, route.sourceFiles);
|
|
2327
|
+
// Compat mode loads the compiled module (aliases baked in) so bare next/*
|
|
2328
|
+
// imports (next/headers etc.) resolve to the compat layer, not an installed
|
|
2329
|
+
// next package. Same pattern as page prerenders.
|
|
2330
|
+
const moduleHref = reactCompatEnabled(config)
|
|
2331
|
+
? await devServerModuleHref(config, route.file, 'build', {
|
|
2332
|
+
conditionTarget: serverBundleTargetForRuntime(route.segmentConfig?.runtime),
|
|
2333
|
+
})
|
|
2334
|
+
: pathToFileHref(route.file);
|
|
2335
|
+
const imported = (await import(moduleHref)) as Parameters<typeof metadataRouteHandlerModule>[0];
|
|
2336
|
+
const routeModule = metadataRouteHandlerModule(imported, route) ?? imported;
|
|
2337
|
+
const module = (
|
|
2338
|
+
nextCompatEnabled(config)
|
|
2339
|
+
? buildCompat().normalizeStaticParamsModule(routeModule as Record<string, unknown>)
|
|
2340
|
+
: routeModule
|
|
2341
|
+
) as RouteHandlerModule;
|
|
2342
|
+
const paramSets = await staticRouteParams(route, module);
|
|
2343
|
+
const staticFiles: Record<string, StaticFileMetadata> = {};
|
|
2344
|
+
|
|
2345
|
+
// A generated-param metadata route (`generateSitemaps` / `generateImageMetadata`)
|
|
2346
|
+
// knows every id ahead of time, so each expanded pathname is a prerendered
|
|
2347
|
+
// route even when the response body itself can't be baked (an ImageResponse
|
|
2348
|
+
// settles in a later task, so the loop below skips the physical copy).
|
|
2349
|
+
// Record the expansion for the compat prerender-manifest writer.
|
|
2350
|
+
if (route.metadataRoute?.generatedParam && paramSets.length > 0) {
|
|
2351
|
+
route.metadataRoute.generatedRoutes = paramSets.map(params =>
|
|
2352
|
+
fillRoutePath(route.route, params, route.metadataRoute?.generatedParam),
|
|
2353
|
+
);
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
// force-static handlers see a canonicalized, origin-normalized request (no
|
|
2357
|
+
// search/headers/cookies); Next reports the URL against a fixed base host.
|
|
2358
|
+
const requestBase =
|
|
2359
|
+
route.segmentConfig?.dynamic === 'force-static'
|
|
2360
|
+
? 'http://localhost:3000'
|
|
2361
|
+
: 'http://pnext.local';
|
|
2362
|
+
for (const params of paramSets) {
|
|
2363
|
+
const routePath = fillRoutePath(route.route, params, route.metadataRoute?.generatedParam);
|
|
2364
|
+
const file = staticRouteHandlerPath(config.outPath, routePath);
|
|
2365
|
+
// A descendant handler intentionally forgoes the physical file (its ancestor
|
|
2366
|
+
// owns public/<parent>), so don't treat the guaranteed collision as a skip —
|
|
2367
|
+
// still render it to capture the prerender-manifest metadata below.
|
|
2368
|
+
if (!skipPhysicalWrite) {
|
|
2369
|
+
const collision = staticOutputCollision(config.outPath, file);
|
|
2370
|
+
if (collision) {
|
|
2371
|
+
warnSkippedStatic(routePath, collision);
|
|
2372
|
+
continue;
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
// Wrap in a work unit (like the page prerender path) so an after()
|
|
2376
|
+
// scheduled during the handler prerender drains in phase 'after' — the
|
|
2377
|
+
// signal draftMode().enable()/disable() guards read to throw "used inside
|
|
2378
|
+
// after()" (next-after-app-api-usage draft-mode static route handler).
|
|
2379
|
+
const prerender = runWithWorkUnit('render', () =>
|
|
2380
|
+
getRenderExtensions().collectRenderMeta(
|
|
2381
|
+
() =>
|
|
2382
|
+
runWithCacheScope(() =>
|
|
2383
|
+
runWithNextBuildPhase(() =>
|
|
2384
|
+
withRouteRuntime(route.segmentConfig?.runtime, () =>
|
|
2385
|
+
handleRouteModule(module, new Request(`${requestBase}${routePath}`), params, {
|
|
2386
|
+
routeFile: route.file,
|
|
2387
|
+
}),
|
|
2388
|
+
),
|
|
2389
|
+
),
|
|
2390
|
+
),
|
|
2391
|
+
{
|
|
2392
|
+
fetchCache: route.segmentConfig?.fetchCache,
|
|
2393
|
+
route: routePath || '/',
|
|
2394
|
+
prerender: true,
|
|
2395
|
+
handler: true,
|
|
2396
|
+
dynamicError: route.segmentConfig?.dynamic === 'error',
|
|
2397
|
+
},
|
|
2398
|
+
),
|
|
2399
|
+
);
|
|
2400
|
+
// Cache-components only prerenders handlers that settle synchronously or
|
|
2401
|
+
// in a microtask. A later task (including a delayed response stream)
|
|
2402
|
+
// depends on runtime work and must not produce a static file.
|
|
2403
|
+
const early = await settleBeforeNextTask(prerender);
|
|
2404
|
+
const completed = early ?? (await prerender);
|
|
2405
|
+
const rendered =
|
|
2406
|
+
early ??
|
|
2407
|
+
(route.segmentConfig?.dynamic === 'force-static' ||
|
|
2408
|
+
(await handlerHasEntirelyCachedIo(route))
|
|
2409
|
+
? completed
|
|
2410
|
+
: undefined);
|
|
2411
|
+
if (!rendered) continue;
|
|
2412
|
+
const response = rendered.value;
|
|
2413
|
+
// A 5xx means the handler threw during prerender (handleRouteModule funnels
|
|
2414
|
+
// uncaught throws to an empty 500). Don't bake a broken response into static
|
|
2415
|
+
// output — surface it to the caller's skip-and-warn path so the route serves
|
|
2416
|
+
// dynamically instead.
|
|
2417
|
+
if (response.status >= 500) {
|
|
2418
|
+
throw new Error(`route handler prerender returned ${response.status}`);
|
|
2419
|
+
}
|
|
2420
|
+
if (rendered.noStore && !staticRouteHandlerExplicitlyCached(route)) continue;
|
|
2421
|
+
const body = await settleBeforeNextTask(response.clone().arrayBuffer());
|
|
2422
|
+
if (!body) continue;
|
|
2423
|
+
const relative = toPosixPath(path.relative(path.join(config.outPath, 'public'), file));
|
|
2424
|
+
// Descendant handlers record their manifest entry (so the synthesized
|
|
2425
|
+
// prerender-manifest lists them) but skip the on-disk copy that would
|
|
2426
|
+
// collide with the ancestor's file; they serve dynamically at runtime.
|
|
2427
|
+
if (!skipPhysicalWrite) {
|
|
2428
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
2429
|
+
await writeFile(file, new Uint8Array(body));
|
|
2430
|
+
}
|
|
2431
|
+
const revalidateSeconds = combineRevalidate(
|
|
2432
|
+
route.segmentConfig?.revalidate,
|
|
2433
|
+
rendered.revalidateSeconds,
|
|
2434
|
+
);
|
|
2435
|
+
staticFiles[relative] = {
|
|
2436
|
+
status: response.status,
|
|
2437
|
+
headers: [...response.headers.entries()],
|
|
2438
|
+
routeId: route.id,
|
|
2439
|
+
...(revalidateSeconds !== undefined ? { revalidateSeconds } : {}),
|
|
2440
|
+
...(rendered.tags.length > 0 ? { tags: rendered.tags } : {}),
|
|
2441
|
+
};
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
return staticFiles;
|
|
2445
|
+
}
|
|
2446
|
+
|
|
2447
|
+
function settleBeforeNextTask<T>(value: Promise<T>): Promise<T | undefined> {
|
|
2448
|
+
return new Promise((resolve, reject) => {
|
|
2449
|
+
let settled = false;
|
|
2450
|
+
const timer = setTimeout(() => {
|
|
2451
|
+
if (!settled) resolve(undefined);
|
|
2452
|
+
}, 0);
|
|
2453
|
+
void value.then(
|
|
2454
|
+
result => {
|
|
2455
|
+
settled = true;
|
|
2456
|
+
clearTimeout(timer);
|
|
2457
|
+
resolve(result);
|
|
2458
|
+
},
|
|
2459
|
+
(error: unknown) => {
|
|
2460
|
+
settled = true;
|
|
2461
|
+
clearTimeout(timer);
|
|
2462
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
2463
|
+
},
|
|
2464
|
+
);
|
|
2465
|
+
});
|
|
2466
|
+
}
|
|
2467
|
+
|
|
2468
|
+
async function handlerHasEntirelyCachedIo(route: RouteManifestEntry): Promise<boolean> {
|
|
2469
|
+
// Synthetic slot routes carry a phantom `page.tsx` anchor that never exists
|
|
2470
|
+
// on disk — source sniffing must skip them or the build ENOENTs.
|
|
2471
|
+
if (route.synthetic || !existsSync(route.file)) return false;
|
|
2472
|
+
const source = await readText(route.file);
|
|
2473
|
+
// A metadata route handler exports a `default` function rather than `GET`; pnext wraps it into the
|
|
2474
|
+
// handler internally. When that default is a top-level `use cache` function every read inside it is
|
|
2475
|
+
// cached, so the whole route is prerenderable. Without this the GET sniff below returns false and
|
|
2476
|
+
// the route stays runtime-only.
|
|
2477
|
+
if (route.metadataRoute && metadataDefaultUsesCache(source)) return true;
|
|
2478
|
+
const getStart = source.search(/\bexport\s+(?:async\s+)?function\s+GET\s*\(/);
|
|
2479
|
+
if (getStart === -1) return false;
|
|
2480
|
+
const afterGet = source.slice(getStart);
|
|
2481
|
+
const nextDeclaration = afterGet.slice(1).search(/^(?:async\s+)?(?:function|const|let)\s+/m);
|
|
2482
|
+
const body = nextDeclaration === -1 ? afterGet : afterGet.slice(0, nextDeclaration + 1);
|
|
2483
|
+
// Reading the wall clock straight in the GET body - outside any `use cache` scope - is dynamic
|
|
2484
|
+
// under cacheComponents: Next taints such a route as dynamic and never prerenders it. Prebuilding
|
|
2485
|
+
// it would serve a build-time body that is already stale by the first request, and the background
|
|
2486
|
+
// regen would race a subsequent read. Empty-parens only: `new Date(ms)` is deterministic.
|
|
2487
|
+
if (/\bnew\s+Date\s*\(\s*\)/.test(body) || /\bDate\s*\.\s*now\s*\(/.test(body)) {
|
|
2488
|
+
return false;
|
|
2489
|
+
}
|
|
2490
|
+
const awaited = [...body.matchAll(/\bawait\s+([A-Za-z_$][\w$]*)\s*\(/g)].map(
|
|
2491
|
+
match => match[1]!,
|
|
2492
|
+
);
|
|
2493
|
+
if (awaited.length === 0) return false;
|
|
2494
|
+
|
|
2495
|
+
// `await import(...)` is module evaluation, not request IO: a module that top-level-awaits still
|
|
2496
|
+
// resolves the same for every request, so the route stays prerenderable. It only settles in a later
|
|
2497
|
+
// task the FIRST time, which is exactly the build's prerender, so the awaited-call check forgives it.
|
|
2498
|
+
const cached = new Set<string>(['import']);
|
|
2499
|
+
for (const match of source.matchAll(/\bconst\s+([A-Za-z_$][\w$]*)\s*=\s*([A-Za-z_$][\w$]*)\s*\(/g)) {
|
|
2500
|
+
if (match[2] === 'cache' || match[2] === 'unstable_cache') cached.add(match[1]!);
|
|
2501
|
+
}
|
|
2502
|
+
for (const match of source.matchAll(
|
|
2503
|
+
/\b(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*\{\s*['"]use cache['"]/g,
|
|
2504
|
+
)) {
|
|
2505
|
+
cached.add(match[1]!);
|
|
2506
|
+
}
|
|
2507
|
+
for (const name of awaited) {
|
|
2508
|
+
const start = source.search(new RegExp(`\\b(?:const\\s+${name}\\s*=|function\\s+${name}\\s*\\()`));
|
|
2509
|
+
if (start === -1) continue;
|
|
2510
|
+
const tail = source.slice(start);
|
|
2511
|
+
const next = tail.slice(1).search(/^(?:async\s+)?(?:function|const|let)\s+/m);
|
|
2512
|
+
const declaration = next === -1 ? tail : tail.slice(0, next + 1);
|
|
2513
|
+
if (/\bcache\s*:\s*['"]force-cache['"]/.test(declaration)) cached.add(name);
|
|
2514
|
+
}
|
|
2515
|
+
return awaited.every(name => cached.has(name));
|
|
2516
|
+
}
|
|
2517
|
+
|
|
2518
|
+
/**
|
|
2519
|
+
* True when a metadata route handler's `default` export is a top-level `'use cache'` function - the
|
|
2520
|
+
* whole handler body is cached, so the route prerenders at build time. Allows a leading return-type
|
|
2521
|
+
* annotation and comment lines before the directive.
|
|
2522
|
+
*/
|
|
2523
|
+
function metadataDefaultUsesCache(source: string): boolean {
|
|
2524
|
+
return /\bexport\s+default\s+(?:async\s+)?function\b[^(]*\([^)]*\)\s*(?::[^{]*)?\{\s*(?:\/\/[^\n]*\n\s*|\/\*[\s\S]*?\*\/\s*)*['"]use cache['"]/.test(
|
|
2525
|
+
source,
|
|
2526
|
+
);
|
|
2527
|
+
}
|
|
2528
|
+
|
|
2529
|
+
async function usesUnstableCache(route: RouteManifestEntry): Promise<boolean> {
|
|
2530
|
+
if (route.synthetic || !existsSync(route.file)) return false;
|
|
2531
|
+
const source = await readText(route.file);
|
|
2532
|
+
const localNames: string[] = [];
|
|
2533
|
+
const dynamicNames: string[] = [];
|
|
2534
|
+
for (const match of source.matchAll(
|
|
2535
|
+
/\bimport\s*\{([^}]*)\}\s*from\s*['"]next\/(cache|headers|server)(?:\.js)?['"]/g,
|
|
2536
|
+
)) {
|
|
2537
|
+
for (const item of match[1]!.split(',')) {
|
|
2538
|
+
const [imported, local] = item.trim().split(/\s+as\s+/);
|
|
2539
|
+
if (match[2] === 'cache' && imported === 'unstable_cache') {
|
|
2540
|
+
localNames.push(local ?? imported);
|
|
2541
|
+
} else if (
|
|
2542
|
+
(match[2] === 'headers' && (imported === 'cookies' || imported === 'headers')) ||
|
|
2543
|
+
(match[2] === 'server' && imported === 'connection')
|
|
2544
|
+
) {
|
|
2545
|
+
dynamicNames.push(local ?? imported);
|
|
2546
|
+
}
|
|
2547
|
+
}
|
|
2548
|
+
}
|
|
2549
|
+
if (dynamicNames.length === 0) return false;
|
|
2550
|
+
const dynamicCall = dynamicNames.join('|');
|
|
2551
|
+
return localNames.some(name =>
|
|
2552
|
+
new RegExp(
|
|
2553
|
+
`\\b${name}\\s*\\(\\s*(?:async\\s*)?(?:\\([^)]*\\)|[A-Za-z_$][\\w$]*)?\\s*=>[\\s\\S]{0,1000}?\\b(?:${dynamicCall})\\s*\\(`,
|
|
2554
|
+
).test(source),
|
|
2555
|
+
);
|
|
2556
|
+
}
|
|
2557
|
+
|
|
2558
|
+
async function staticHandlerDynamicUsage(route: RouteManifestEntry): Promise<string | undefined> {
|
|
2559
|
+
if (!route.usesRequest) return undefined;
|
|
2560
|
+
if (
|
|
2561
|
+
route.segmentConfig?.dynamic === 'force-static' ||
|
|
2562
|
+
route.segmentConfig?.dynamic === 'error'
|
|
2563
|
+
) {
|
|
2564
|
+
return undefined;
|
|
2565
|
+
}
|
|
2566
|
+
if (route.synthetic || !existsSync(route.file)) return undefined;
|
|
2567
|
+
const source = await readText(route.file);
|
|
2568
|
+
if (/\b(?:request|req)\.url\b/.test(source)) return 'request.url';
|
|
2569
|
+
if (/\bnextUrl\s*\.\s*toString\s*\(/.test(source)) return 'nextUrl.toString';
|
|
2570
|
+
if (/\bheaders\s*\(/.test(source)) return 'headers()';
|
|
2571
|
+
if (/\bcookies\s*\(/.test(source)) return 'cookies()';
|
|
2572
|
+
if (/\bconnection\s*\(/.test(source)) return 'connection()';
|
|
2573
|
+
return undefined;
|
|
2574
|
+
}
|
|
2575
|
+
|
|
2576
|
+
/**
|
|
2577
|
+
* Handler routes whose logical output path would collide with a descendant route's output directory.
|
|
2578
|
+
* A route is a descendant when another route's path is a strict segment-boundary prefix of it. Only
|
|
2579
|
+
* handler descendants matter - pages already write to `public/<route>/index.html`, which nests
|
|
2580
|
+
* cleanly. The ancestor keeps its physical file; descendants skip theirs.
|
|
2581
|
+
*/
|
|
2582
|
+
function collectDescendantHandlerIds(routes: RouteManifestEntry[]): Set<string> {
|
|
2583
|
+
const ids = new Set<string>();
|
|
2584
|
+
const paths = routes.map(route => ({ route, norm: (route.route || '/').replace(/\/+$/, '') }));
|
|
2585
|
+
for (const { route, norm } of paths) {
|
|
2586
|
+
if (route.kind !== 'handler') continue;
|
|
2587
|
+
const hasAncestor = paths.some(
|
|
2588
|
+
other =>
|
|
2589
|
+
other.route !== route &&
|
|
2590
|
+
other.norm.length > 0 &&
|
|
2591
|
+
norm !== other.norm &&
|
|
2592
|
+
norm.startsWith(`${other.norm}/`),
|
|
2593
|
+
);
|
|
2594
|
+
if (hasAncestor) ids.add(route.id);
|
|
2595
|
+
}
|
|
2596
|
+
return ids;
|
|
2597
|
+
}
|
|
2598
|
+
|
|
2599
|
+
async function staticRouteHandlerCandidate(route: RouteManifestEntry) {
|
|
2600
|
+
const config = route.segmentConfig;
|
|
2601
|
+
if (config?.dynamic === 'force-dynamic') return false;
|
|
2602
|
+
if (config?.revalidate === 0) return false;
|
|
2603
|
+
if (config?.fetchCache === 'force-no-store') return false;
|
|
2604
|
+
// A handler that calls revalidatePath/revalidateTag performs a side effect
|
|
2605
|
+
// per request; it can never be prerendered (Next keeps it dynamic even with a
|
|
2606
|
+
// `revalidate` export), so serving a cached body would drop the revalidation.
|
|
2607
|
+
if (route.handlerUsesRevalidationApi) return false;
|
|
2608
|
+
if (route.metadataRoute) return !route.usesRequest;
|
|
2609
|
+
// `dynamic = 'error'` deliberately exercises a static render so request API
|
|
2610
|
+
// reads can fail at their call sites instead of turning the handler dynamic.
|
|
2611
|
+
if (config?.dynamic === 'error') return true;
|
|
2612
|
+
if (config?.dynamic === 'force-static') return true;
|
|
2613
|
+
// A `revalidate` export or `generateStaticParams` opts a handler into static
|
|
2614
|
+
// (ISR) output even if it reads request data — Next prerenders it with a
|
|
2615
|
+
// canonical request. Truly dynamic access (cookies/headers) would surface at
|
|
2616
|
+
// runtime, but reading e.g. `req.nextUrl.pathname` is prerender-safe.
|
|
2617
|
+
if (staticRouteHandlerExplicitlyCached(route) || route.hasStaticParams) return true;
|
|
2618
|
+
if (route.usesRequest) return false;
|
|
2619
|
+
// Since Next 15, GET route handlers are dynamic by default: only an explicit opt-in prerenders one.
|
|
2620
|
+
// Under cacheComponents every request-independent handler is a prerender candidate again, EXCEPT
|
|
2621
|
+
// one that performs uncached IO (time/randomness outside any `use cache` scope): baking it would
|
|
2622
|
+
// freeze `new Date()` at build time while its `use cache` parts must keep live SWR semantics.
|
|
2623
|
+
if (!cacheComponents() || route.mode !== 'static') return false;
|
|
2624
|
+
if (!handlerUsesUncachedIo(route)) return true;
|
|
2625
|
+
// The uncached-IO sniff is textual, so it also trips on time/randomness that
|
|
2626
|
+
// sits inside a helper the handler only ever reaches through an
|
|
2627
|
+
// `unstable_cache()` wrapper (cache-components routes `/routes/io-cached`).
|
|
2628
|
+
// The call-graph check is authoritative there: when EVERY awaited call in GET
|
|
2629
|
+
// resolves to a cached wrapper the IO is cached after all. A handler that
|
|
2630
|
+
// also awaits the raw helper (`/routes/io-mixed`) still fails it.
|
|
2631
|
+
return handlerHasEntirelyCachedIo(route);
|
|
2632
|
+
}
|
|
2633
|
+
|
|
2634
|
+
/**
|
|
2635
|
+
* Whether a handler reads time/randomness OUTSIDE any `use cache` function
|
|
2636
|
+
* body. Cache-scoped reads are cached with the entry (fine to prerender);
|
|
2637
|
+
* unscoped reads make the handler's output request-dependent under
|
|
2638
|
+
* cacheComponents, so it must render per-request.
|
|
2639
|
+
*/
|
|
2640
|
+
function handlerUsesUncachedIo(route: RouteManifestEntry): boolean {
|
|
2641
|
+
const source = readTextSync(route.file);
|
|
2642
|
+
if (!source) return false;
|
|
2643
|
+
return /\bnew Date\s*\(|\bDate\.now\s*\(|\bMath\.random\s*\(/.test(
|
|
2644
|
+
stripUseCacheBodies(source),
|
|
2645
|
+
);
|
|
2646
|
+
}
|
|
2647
|
+
|
|
2648
|
+
/** Blank out every function body whose prologue opens with a 'use cache' directive. */
|
|
2649
|
+
function stripUseCacheBodies(source: string): string {
|
|
2650
|
+
let out = source;
|
|
2651
|
+
for (;;) {
|
|
2652
|
+
const directive = /\{\s*(['"])use cache(?:\s*:\s*[\w-]+)?\1\s*;?/.exec(out);
|
|
2653
|
+
if (!directive) break;
|
|
2654
|
+
const bodyOpen = directive.index + 1;
|
|
2655
|
+
let balance = 1;
|
|
2656
|
+
let end = out.length;
|
|
2657
|
+
for (let i = bodyOpen; i < out.length; i += 1) {
|
|
2658
|
+
const ch = out[i];
|
|
2659
|
+
if (ch === '{') balance += 1;
|
|
2660
|
+
else if (ch === '}') {
|
|
2661
|
+
balance -= 1;
|
|
2662
|
+
if (balance === 0) {
|
|
2663
|
+
end = i;
|
|
2664
|
+
break;
|
|
2665
|
+
}
|
|
2666
|
+
}
|
|
2667
|
+
}
|
|
2668
|
+
out = `${out.slice(0, bodyOpen)}${out.slice(end)}`;
|
|
2669
|
+
}
|
|
2670
|
+
return out;
|
|
2671
|
+
}
|
|
2672
|
+
|
|
2673
|
+
function staticRouteHandlerExplicitlyCached(route: RouteManifestEntry) {
|
|
2674
|
+
const config = route.segmentConfig;
|
|
2675
|
+
return (
|
|
2676
|
+
config?.dynamic === 'force-static' ||
|
|
2677
|
+
config?.revalidate === false ||
|
|
2678
|
+
(typeof config?.revalidate === 'number' && config.revalidate > 0)
|
|
2679
|
+
);
|
|
2680
|
+
}
|
|
2681
|
+
|
|
2682
|
+
async function needsRequestOriginForMetadataImages(appPath: string, route: RouteManifestEntry) {
|
|
2683
|
+
if (!(await routeNullsMetadataBase(route))) return false;
|
|
2684
|
+
return routeMetadataImageFiles(appPath, route.file);
|
|
2685
|
+
}
|
|
2686
|
+
|
|
2687
|
+
async function routeNullsMetadataBase(route: RouteManifestEntry) {
|
|
2688
|
+
const sourceFiles = route.sourceFiles.filter(file => /\.(tsx?|jsx?|mjs|cjs)$/.test(file));
|
|
2689
|
+
for (const file of sourceFiles) {
|
|
2690
|
+
try {
|
|
2691
|
+
if (/\bmetadataBase\s*:\s*null\b|\bmetadataBase\s*=\s*null\b/.test(await readText(file))) {
|
|
2692
|
+
return true;
|
|
2693
|
+
}
|
|
2694
|
+
} catch {
|
|
2695
|
+
// Ignore generated/virtual paths that are not readable at build time.
|
|
2696
|
+
}
|
|
2697
|
+
}
|
|
2698
|
+
return false;
|
|
2699
|
+
}
|
|
2700
|
+
|
|
2701
|
+
function routeMetadataImageFiles(appPath: string, routeFile: string) {
|
|
2702
|
+
let dir = path.dirname(routeFile);
|
|
2703
|
+
const root = path.resolve(appPath);
|
|
2704
|
+
while (dir.startsWith(root)) {
|
|
2705
|
+
if (existsSync(dir)) {
|
|
2706
|
+
for (const entry of readdirSync(dir)) {
|
|
2707
|
+
if (
|
|
2708
|
+
/^(opengraph-image|twitter-image)\d*\.(tsx?|jsx?|mjs|png|jpe?g|gif|webp|svg)$/.test(entry)
|
|
2709
|
+
) {
|
|
2710
|
+
return true;
|
|
2711
|
+
}
|
|
2712
|
+
}
|
|
2713
|
+
}
|
|
2714
|
+
if (dir === root) break;
|
|
2715
|
+
dir = path.dirname(dir);
|
|
2716
|
+
}
|
|
2717
|
+
return false;
|
|
2718
|
+
}
|
|
2719
|
+
|
|
2720
|
+
async function copyStaticMetadataFiles(
|
|
2721
|
+
config: Awaited<ReturnType<typeof loadConfig>>,
|
|
2722
|
+
staticFiles: Record<string, StaticFileMetadata>,
|
|
2723
|
+
metadataFiles = discoverStaticMetadataFiles(config.appPath),
|
|
2724
|
+
) {
|
|
2725
|
+
for (const metadataFile of metadataFiles) {
|
|
2726
|
+
const target = staticMetadataOutputFile(config.outPath, metadataFile);
|
|
2727
|
+
const collision = staticOutputCollision(config.outPath, target);
|
|
2728
|
+
if (collision) {
|
|
2729
|
+
warnSkippedStatic(`/${metadataFile.outputPath}`, collision);
|
|
2730
|
+
continue;
|
|
2731
|
+
}
|
|
2732
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
2733
|
+
await copyFile(metadataFile.file, target);
|
|
2734
|
+
staticFiles[metadataFile.outputPath] = {
|
|
2735
|
+
status: 200,
|
|
2736
|
+
headers: [
|
|
2737
|
+
['content-type', metadataFile.contentType],
|
|
2738
|
+
['cache-control', staticMetadataCacheControl],
|
|
2739
|
+
],
|
|
2740
|
+
};
|
|
2741
|
+
}
|
|
2742
|
+
}
|
|
2743
|
+
|
|
2744
|
+
export function staticHtmlPath(outPath: string, routePath: string, flatLayout = false) {
|
|
2745
|
+
if (routePath === '/') return path.join(outPath, 'public', 'index.html');
|
|
2746
|
+
const normalized = routePath.replace(/^\/|\/$/g, '');
|
|
2747
|
+
// output:'export' with trailingSlash:false lays pages out flat (`/a.html`);
|
|
2748
|
+
// otherwise (and always for trailingSlash:true) each page gets its own dir
|
|
2749
|
+
// (`/a/index.html`).
|
|
2750
|
+
if (flatLayout) return safePublicPath(outPath, `${normalized}.html`);
|
|
2751
|
+
return safePublicPath(outPath, normalized, 'index.html');
|
|
2752
|
+
}
|
|
2753
|
+
|
|
2754
|
+
// Percent-encode generateStaticParams values the way Next encodes them into
|
|
2755
|
+
// prerender URLs: params reach the page render as URL segments (`%20`, `%26`),
|
|
2756
|
+
// one encoded element per catch-all entry.
|
|
2757
|
+
function encodeStaticRouteParams(
|
|
2758
|
+
params: Record<string, RouteParamValue>,
|
|
2759
|
+
): Record<string, RouteParamValue> {
|
|
2760
|
+
return Object.fromEntries(
|
|
2761
|
+
Object.entries(params).map(([key, value]) => [
|
|
2762
|
+
key,
|
|
2763
|
+
Array.isArray(value) ? value.map(encodeURIComponent) : encodeURIComponent(value),
|
|
2764
|
+
]),
|
|
2765
|
+
);
|
|
2766
|
+
}
|
|
2767
|
+
|
|
2768
|
+
function fillRoutePath(
|
|
2769
|
+
route: string,
|
|
2770
|
+
params: Record<string, RouteParamValue>,
|
|
2771
|
+
generatedParam?: string,
|
|
2772
|
+
) {
|
|
2773
|
+
let value = route;
|
|
2774
|
+
for (const [key, param] of Object.entries(params)) {
|
|
2775
|
+
const segment = Array.isArray(param) ? param.join('/') : param;
|
|
2776
|
+
value = value.replace(`:${key}*`, segment).replace(`:${key}`, segment);
|
|
2777
|
+
}
|
|
2778
|
+
if (generatedParam && params[generatedParam] !== undefined) {
|
|
2779
|
+
const param = params[generatedParam];
|
|
2780
|
+
const segment = Array.isArray(param) ? param.join('/') : param;
|
|
2781
|
+
value = value.replace(':id', segment);
|
|
2782
|
+
}
|
|
2783
|
+
return value;
|
|
2784
|
+
}
|
|
2785
|
+
|
|
2786
|
+
async function runWithNextBuildPhase<T>(task: () => Promise<T>): Promise<T> {
|
|
2787
|
+
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
2788
|
+
const previous = process.env.NEXT_PHASE;
|
|
2789
|
+
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
2790
|
+
process.env.NEXT_PHASE = 'phase-production-build';
|
|
2791
|
+
try {
|
|
2792
|
+
return await task();
|
|
2793
|
+
} finally {
|
|
2794
|
+
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
2795
|
+
if (previous === undefined) delete process.env.NEXT_PHASE;
|
|
2796
|
+
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
2797
|
+
else process.env.NEXT_PHASE = previous;
|
|
2798
|
+
}
|
|
2799
|
+
}
|
|
2800
|
+
|
|
2801
|
+
/**
|
|
2802
|
+
* The `use cache` SWR response headers for a stashed cacheLife, mirroring the register-usecache.ts
|
|
2803
|
+
* finalizer. Baked into the manifest so a pure static HIT can re-emit them from start.ts - the
|
|
2804
|
+
* finalizer only fires on a live render. Returns [] when no cacheLife applies.
|
|
2805
|
+
*/
|
|
2806
|
+
function cacheLifeResponseHeaders(life: CacheLifeStash | undefined): [string, string][] {
|
|
2807
|
+
if (!life) return [];
|
|
2808
|
+
const headers: [string, string][] = [];
|
|
2809
|
+
const { revalidateSeconds, expireSeconds, staleSeconds } = life;
|
|
2810
|
+
if (revalidateSeconds !== undefined && expireSeconds !== undefined) {
|
|
2811
|
+
const swr = Math.max(0, expireSeconds - revalidateSeconds);
|
|
2812
|
+
headers.push(['cache-control', `s-maxage=${revalidateSeconds}, stale-while-revalidate=${swr}`]);
|
|
2813
|
+
}
|
|
2814
|
+
if (staleSeconds !== undefined) {
|
|
2815
|
+
headers.push(['x-nextjs-stale-time', String(staleSeconds)]);
|
|
2816
|
+
}
|
|
2817
|
+
return headers;
|
|
2818
|
+
}
|
|
2819
|
+
|
|
2820
|
+
/** Effective ISR TTL: the lowest of the segment `revalidate` export and any data-cache revalidate used during the render. */
|
|
2821
|
+
function combineRevalidate(
|
|
2822
|
+
segmentRevalidate: number | false | undefined,
|
|
2823
|
+
collected: number | undefined,
|
|
2824
|
+
): number | undefined {
|
|
2825
|
+
const candidates = [
|
|
2826
|
+
...(typeof segmentRevalidate === 'number' && segmentRevalidate > 0 ? [segmentRevalidate] : []),
|
|
2827
|
+
...(collected !== undefined && collected > 0 ? [collected] : []),
|
|
2828
|
+
];
|
|
2829
|
+
if (candidates.length === 0) return undefined;
|
|
2830
|
+
return Math.min(...candidates);
|
|
2831
|
+
}
|
|
2832
|
+
|
|
2833
|
+
function staticRouteHandlerPath(outPath: string, routePath: string) {
|
|
2834
|
+
return safePublicPath(outPath, routePath.replace(/^\/+/, '') || 'index');
|
|
2835
|
+
}
|
|
2836
|
+
|
|
2837
|
+
/**
|
|
2838
|
+
* A handler route '/api' wants the FILE public/api while any static output under '/api/...' needs
|
|
2839
|
+
* public/api to be a DIRECTORY. Whichever lands second cannot be written, so detect that instead of
|
|
2840
|
+
* letting writeFile/mkdir hard-fail the build. Callers skip the static copy with a warning and the
|
|
2841
|
+
* route serves dynamically (start's static lookup only matches real files, then falls through).
|
|
2842
|
+
*/
|
|
2843
|
+
function staticOutputCollision(outPath: string, file: string): string | null {
|
|
2844
|
+
const publicPath = path.join(outPath, 'public');
|
|
2845
|
+
if (existsSync(file) && statSync(file).isDirectory()) {
|
|
2846
|
+
return `${toPosixPath(path.relative(publicPath, file))} already exists as a directory`;
|
|
2847
|
+
}
|
|
2848
|
+
let dir = path.dirname(file);
|
|
2849
|
+
while (dir !== publicPath && dir !== path.dirname(dir)) {
|
|
2850
|
+
if (existsSync(dir)) {
|
|
2851
|
+
if (!statSync(dir).isDirectory()) {
|
|
2852
|
+
return `${toPosixPath(path.relative(publicPath, dir))} already exists as a file`;
|
|
2853
|
+
}
|
|
2854
|
+
break;
|
|
2855
|
+
}
|
|
2856
|
+
dir = path.dirname(dir);
|
|
2857
|
+
}
|
|
2858
|
+
return null;
|
|
2859
|
+
}
|
|
2860
|
+
|
|
2861
|
+
/**
|
|
2862
|
+
* The prerender skip-and-warn path is for RENDER-TIME failures - a user component throwing while
|
|
2863
|
+
* generating static output. A `ReferenceError`/`TypeError`/`SyntaxError` originating in the build
|
|
2864
|
+
* pipeline itself is a programming bug: swallowing it into `warnSkippedStatic` misattributes the
|
|
2865
|
+
* message and hides the real stack. Rethrow those with their original stack so the build fails
|
|
2866
|
+
* loudly; keep skip-and-warn for everything else.
|
|
2867
|
+
*/
|
|
2868
|
+
function rethrowIfProgrammingError(error: unknown): void {
|
|
2869
|
+
if (
|
|
2870
|
+
error instanceof ReferenceError ||
|
|
2871
|
+
error instanceof SyntaxError ||
|
|
2872
|
+
(error instanceof TypeError && isBuildInternalStack(error))
|
|
2873
|
+
) {
|
|
2874
|
+
throw error;
|
|
2875
|
+
}
|
|
2876
|
+
}
|
|
2877
|
+
|
|
2878
|
+
// A TypeError is ambiguous — user render code throws them legitimately. Only
|
|
2879
|
+
// rethrow when the top of the stack points at pnext's own build/routing/render
|
|
2880
|
+
// internals rather than app code, so a genuine render-time `TypeError` still
|
|
2881
|
+
// degrades to skip-and-warn.
|
|
2882
|
+
function isBuildInternalStack(error: Error): boolean {
|
|
2883
|
+
const stack = error.stack ?? '';
|
|
2884
|
+
const firstFrame = stack.split('\n').find(line => /^\s*at\s/.test(line)) ?? '';
|
|
2885
|
+
return /packages[/\\]pnext[/\\]src[/\\]/.test(firstFrame);
|
|
2886
|
+
}
|
|
2887
|
+
|
|
2888
|
+
// Next's NEXT_DEBUG_BUILD static-bailout diagnostic, grepped verbatim by the
|
|
2889
|
+
// e2e suites ("should output debug info for static bailouts").
|
|
2890
|
+
function logStaticBailout(routePath: string, reason: string) {
|
|
2891
|
+
console.log(
|
|
2892
|
+
`Static generation failed due to dynamic usage on ${routePath}, reason: ${reason}`,
|
|
2893
|
+
);
|
|
2894
|
+
}
|
|
2895
|
+
|
|
2896
|
+
// Best-effort label for WHICH request API kept the route dynamic (Next names
|
|
2897
|
+
// the first dynamic API hit during its build attempt, e.g. "headers").
|
|
2898
|
+
async function requestApiBailoutReason(route: RouteManifestEntry): Promise<string> {
|
|
2899
|
+
for (const file of ownSourceFiles(route)) {
|
|
2900
|
+
let source: string;
|
|
2901
|
+
try {
|
|
2902
|
+
source = await readText(file);
|
|
2903
|
+
} catch {
|
|
2904
|
+
continue;
|
|
2905
|
+
}
|
|
2906
|
+
const match = /\b(headers|cookies|draftMode|connection)\s*\(/.exec(source);
|
|
2907
|
+
if (match?.[1]) return match[1];
|
|
2908
|
+
}
|
|
2909
|
+
return 'dynamic usage';
|
|
2910
|
+
}
|
|
2911
|
+
|
|
2912
|
+
// The route's own source files (the closure also carries framework/compat
|
|
2913
|
+
// modules, whose bodies mention every request API and every scheduler).
|
|
2914
|
+
function ownSourceFiles(route: RouteManifestEntry): string[] {
|
|
2915
|
+
const frameworkRoot = path.resolve(fileURLToPath(import.meta.url), '..', '..');
|
|
2916
|
+
return route.sourceFiles.filter(file => {
|
|
2917
|
+
const resolved = path.resolve(file);
|
|
2918
|
+
return (
|
|
2919
|
+
!resolved.includes(`${path.sep}node_modules${path.sep}`) &&
|
|
2920
|
+
!resolved.startsWith(frameworkRoot + path.sep)
|
|
2921
|
+
);
|
|
2922
|
+
});
|
|
2923
|
+
}
|
|
2924
|
+
|
|
2925
|
+
/**
|
|
2926
|
+
* Does the route schedule work that outlives its render? Only then can a request
|
|
2927
|
+
* API call escape the render pass, so only then is the deferred-usage probe
|
|
2928
|
+
* worth a build-time render.
|
|
2929
|
+
*/
|
|
2930
|
+
async function schedulesDeferredWork(route: RouteManifestEntry): Promise<boolean> {
|
|
2931
|
+
for (const file of ownSourceFiles(route)) {
|
|
2932
|
+
let source: string;
|
|
2933
|
+
try {
|
|
2934
|
+
source = await readText(file);
|
|
2935
|
+
} catch {
|
|
2936
|
+
continue;
|
|
2937
|
+
}
|
|
2938
|
+
if (/\b(?:setTimeout|setImmediate|queueMicrotask)\s*\(/.test(source)) return true;
|
|
2939
|
+
}
|
|
2940
|
+
return false;
|
|
2941
|
+
}
|
|
2942
|
+
|
|
2943
|
+
/**
|
|
2944
|
+
* Wrap the deferred-work schedulers so a callback that throws after the render has finished is
|
|
2945
|
+
* reported instead of taking the build process down with it. The callbacks still run for real, so
|
|
2946
|
+
* what surfaces is the genuine error - this only decides who catches it.
|
|
2947
|
+
*/
|
|
2948
|
+
function captureDeferredFailures(record: (error: unknown) => void) {
|
|
2949
|
+
const globals = globalThis as unknown as Record<string, (...args: unknown[]) => unknown>;
|
|
2950
|
+
const originals = new Map<string, (...args: unknown[]) => unknown>();
|
|
2951
|
+
for (const name of ['setTimeout', 'setImmediate', 'queueMicrotask']) {
|
|
2952
|
+
const original = globals[name];
|
|
2953
|
+
if (typeof original !== 'function') continue;
|
|
2954
|
+
originals.set(name, original);
|
|
2955
|
+
globals[name] = function patched(this: unknown, callback: unknown, ...rest: unknown[]) {
|
|
2956
|
+
if (typeof callback !== 'function') return original.call(this, callback, ...rest);
|
|
2957
|
+
const guarded = function guardedCallback(this: unknown, ...args: unknown[]) {
|
|
2958
|
+
try {
|
|
2959
|
+
const value = (callback as (...inner: unknown[]) => unknown).apply(this, args);
|
|
2960
|
+
if (isThenable(value)) void value.then(undefined, record);
|
|
2961
|
+
return value;
|
|
2962
|
+
} catch (error) {
|
|
2963
|
+
record(error);
|
|
2964
|
+
}
|
|
2965
|
+
};
|
|
2966
|
+
return original.call(this, guarded, ...rest);
|
|
2967
|
+
};
|
|
2968
|
+
}
|
|
2969
|
+
return () => {
|
|
2970
|
+
for (const [name, original] of originals) globals[name] = original;
|
|
2971
|
+
};
|
|
2972
|
+
}
|
|
2973
|
+
|
|
2974
|
+
function isThenable(value: unknown): value is PromiseLike<unknown> {
|
|
2975
|
+
return typeof (value as { then?: unknown } | null)?.then === 'function';
|
|
2976
|
+
}
|
|
2977
|
+
|
|
2978
|
+
/**
|
|
2979
|
+
* Render the route once with no request so a request API called from deferred
|
|
2980
|
+
* work throws, and report it the way Next reports a prerender that leaked a
|
|
2981
|
+
* dynamic API. A throw DURING the render is the ordinary static bailout (Next
|
|
2982
|
+
* catches it and serves the route dynamically), so that stays silent.
|
|
2983
|
+
*/
|
|
2984
|
+
async function probeDeferredDynamicUsage(
|
|
2985
|
+
config: Awaited<ReturnType<typeof loadConfig>>,
|
|
2986
|
+
route: RouteManifestEntry,
|
|
2987
|
+
staticMetadataFiles: StaticMetadataFile[],
|
|
2988
|
+
staticModuleMetadata: Record<string, StaticModuleMetadata>,
|
|
2989
|
+
): Promise<void> {
|
|
2990
|
+
const routePath = toNextRoutePattern(route.route || '/');
|
|
2991
|
+
const failures: unknown[] = [];
|
|
2992
|
+
const restore = captureDeferredFailures(error => failures.push(error));
|
|
2993
|
+
beginDynamicBailoutProbe(routePath);
|
|
2994
|
+
try {
|
|
2995
|
+
await runWithWorkUnit('render', () =>
|
|
2996
|
+
getRenderExtensions().collectRenderMeta(
|
|
2997
|
+
() =>
|
|
2998
|
+
runWithNextBuildPhase(() =>
|
|
2999
|
+
withRouteRuntime(route.segmentConfig?.runtime, () =>
|
|
3000
|
+
renderPageWithStatus({
|
|
3001
|
+
config,
|
|
3002
|
+
route,
|
|
3003
|
+
params: {},
|
|
3004
|
+
url: new URL(`http://pnext.local${route.route || '/'}`),
|
|
3005
|
+
staticMetadataFiles,
|
|
3006
|
+
staticModuleMetadata,
|
|
3007
|
+
}),
|
|
3008
|
+
),
|
|
3009
|
+
),
|
|
3010
|
+
{ route: route.route || '/', prerender: true },
|
|
3011
|
+
),
|
|
3012
|
+
);
|
|
3013
|
+
// Give a 0ms timer scheduled during the render its turn before we stop
|
|
3014
|
+
// listening; anything slower keeps the route dynamic without a report.
|
|
3015
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
3016
|
+
} catch {
|
|
3017
|
+
// Ordinary bailout (or an unrelated prerender failure): the route just
|
|
3018
|
+
// stays dynamic, exactly as it already did without the probe.
|
|
3019
|
+
return;
|
|
3020
|
+
} finally {
|
|
3021
|
+
endDynamicBailoutProbe();
|
|
3022
|
+
restore();
|
|
3023
|
+
}
|
|
3024
|
+
for (const failure of failures) {
|
|
3025
|
+
if (!(failure instanceof Error) || failure.name !== 'DynamicServerError') continue;
|
|
3026
|
+
console.error(
|
|
3027
|
+
`Error occurred prerendering page "${routePath}". Read more: https://nextjs.org/docs/messages/prerender-error`,
|
|
3028
|
+
);
|
|
3029
|
+
console.error(`${failure.name}: ${failure.message}`);
|
|
3030
|
+
}
|
|
3031
|
+
}
|
|
3032
|
+
|
|
3033
|
+
function warnSkippedStatic(routePath: string, reason: string) {
|
|
3034
|
+
console.warn(
|
|
3035
|
+
`pnext build: skipping static output for ${routePath} (${reason}); the route will be served dynamically`,
|
|
3036
|
+
);
|
|
3037
|
+
}
|
|
3038
|
+
|
|
3039
|
+
// pnext routes carry Express-style `:param` / `:...catchAll` segments; Next's
|
|
3040
|
+
// build diagnostics name routes in `[param]` / `[...catchAll]` form.
|
|
3041
|
+
function toNextRoutePattern(route: string): string {
|
|
3042
|
+
return route.replace(/:\.\.\.([^/]+)/g, '[...$1]').replace(/:([^/]+)/g, '[$1]');
|
|
3043
|
+
}
|
|
3044
|
+
|
|
3045
|
+
// A throwing after() during a prerender is tagged by the compat after() runtime
|
|
3046
|
+
// (Symbol.for keeps this file decoupled from the compat layer). Unlike a plain
|
|
3047
|
+
// render throw — which degrades the route to dynamic — it must fail the build.
|
|
3048
|
+
function isAfterPrerenderError(error: unknown): boolean {
|
|
3049
|
+
return (
|
|
3050
|
+
typeof error === 'object' &&
|
|
3051
|
+
error !== null &&
|
|
3052
|
+
(error as Record<symbol, unknown>)[Symbol.for('pnext.afterPrerenderError')] === true
|
|
3053
|
+
);
|
|
3054
|
+
}
|
|
3055
|
+
|
|
3056
|
+
function safePublicPath(outPath: string, ...segments: string[]) {
|
|
3057
|
+
const publicPath = path.join(outPath, 'public');
|
|
3058
|
+
const file = path.join(publicPath, ...segments);
|
|
3059
|
+
const relative = path.relative(publicPath, file);
|
|
3060
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
3061
|
+
throw new Error(`Static output path escapes public directory: ${segments.join('/')}`);
|
|
3062
|
+
}
|
|
3063
|
+
return file;
|
|
3064
|
+
}
|
|
3065
|
+
|
|
3066
|
+
/**
|
|
3067
|
+
* Copy the Partytown vendored library into public/_next/static/~partytown/ when
|
|
3068
|
+
* experimental.nextScriptWorkers is enabled. Resolves the package `lib/` dir from the app root
|
|
3069
|
+
* (current or legacy scope); a missing package is tolerated - the injected loader snippet still
|
|
3070
|
+
* points here, and the 404 is the user's to resolve by installing partytown.
|
|
3071
|
+
*/
|
|
3072
|
+
async function copyPartytownLib(config: Awaited<ReturnType<typeof loadConfig>>) {
|
|
3073
|
+
if (!nextCompatEnabled(config)) return;
|
|
3074
|
+
if (!buildCompat().nextScriptWorkersEnabled()) return;
|
|
3075
|
+
|
|
3076
|
+
const libDir = resolvePartytownLibDir(config.root);
|
|
3077
|
+
if (!libDir) {
|
|
3078
|
+
console.warn(
|
|
3079
|
+
'pnext build: experimental.nextScriptWorkers is enabled but no Partytown package was found ' +
|
|
3080
|
+
'(@qwik.dev/partytown or @builder.io/partytown); worker scripts will 404 the library until it is installed',
|
|
3081
|
+
);
|
|
3082
|
+
return;
|
|
3083
|
+
}
|
|
3084
|
+
const target = path.join(config.outPath, 'public', '_next', 'static', '~partytown');
|
|
3085
|
+
await mkdir(target, { recursive: true });
|
|
3086
|
+
await copyPublicDir(libDir, target);
|
|
3087
|
+
}
|
|
3088
|
+
|
|
3089
|
+
// Locate the Partytown package `lib/` dir from the app root, preferring the
|
|
3090
|
+
// current `@qwik.dev/partytown` over the legacy `@builder.io/partytown`. Returns
|
|
3091
|
+
// null when neither resolves.
|
|
3092
|
+
function resolvePartytownLibDir(root: string): string | null {
|
|
3093
|
+
const requireFromRoot = createRequire(path.join(root, 'package.json'));
|
|
3094
|
+
for (const pkg of ['@qwik.dev/partytown', '@builder.io/partytown']) {
|
|
3095
|
+
try {
|
|
3096
|
+
const pkgJson = requireFromRoot.resolve(`${pkg}/package.json`);
|
|
3097
|
+
const libDir = path.join(path.dirname(pkgJson), 'lib');
|
|
3098
|
+
if (existsSync(libDir) && statSync(libDir).isDirectory()) return libDir;
|
|
3099
|
+
} catch {
|
|
3100
|
+
// Package not installed; try the next candidate.
|
|
3101
|
+
}
|
|
3102
|
+
}
|
|
3103
|
+
return null;
|
|
3104
|
+
}
|
|
3105
|
+
|
|
3106
|
+
async function copyPublicDir(from: string, to: string) {
|
|
3107
|
+
const files = await listFiles(from);
|
|
3108
|
+
for (const file of files) {
|
|
3109
|
+
const relative = toPosixPath(path.relative(from, file));
|
|
3110
|
+
const target = path.join(to, relative);
|
|
3111
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
3112
|
+
await copyFile(file, target);
|
|
3113
|
+
}
|
|
3114
|
+
}
|