@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
|
@@ -0,0 +1,1710 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { readFile, readdir, rm, stat, watch } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import {
|
|
6
|
+
DEFERRED_DYNAMIC_SIDECAR,
|
|
7
|
+
buildClientDynamicChunk,
|
|
8
|
+
buildClientEntry,
|
|
9
|
+
deferredDynamicRefById,
|
|
10
|
+
prebuiltRuntimeDir,
|
|
11
|
+
registerDeferredDynamicRef,
|
|
12
|
+
} from '../client/build';
|
|
13
|
+
import { drainPreplanBuilds } from '../runtime/vendor';
|
|
14
|
+
import { devClientCacheKey } from './client-key-cache';
|
|
15
|
+
import { clientChunkStoreDir, contentAddressDir, sweepClientChunkStore } from './client-chunk-store';
|
|
16
|
+
import { restartCacheEnabled } from './restart-cache';
|
|
17
|
+
import { installRouteFactsCache } from './route-facts-cache';
|
|
18
|
+
import { installGlobalCssCache } from './global-css-cache';
|
|
19
|
+
import { clientEntryName } from '../client/paths';
|
|
20
|
+
import { bootstrapCompat, bootstrapCompatBoot } from '../compat-bootstrap';
|
|
21
|
+
import type { ResolvedConfig } from '../config';
|
|
22
|
+
import {
|
|
23
|
+
buildClientReferenceCss,
|
|
24
|
+
buildGlobalCss,
|
|
25
|
+
buildRouteCss,
|
|
26
|
+
clearGlobalCssSourceCache,
|
|
27
|
+
isCssFile,
|
|
28
|
+
warmCssPipeline,
|
|
29
|
+
} from '../css';
|
|
30
|
+
import {
|
|
31
|
+
deferServerRuntimePlugins,
|
|
32
|
+
installServerRuntimePlugins,
|
|
33
|
+
registerServerRuntime,
|
|
34
|
+
serverBundleTargetForRuntime,
|
|
35
|
+
} from '../runtime/server';
|
|
36
|
+
import { applyProxyResponse, createProxyRunner, validateProxyFiles } from '../proxy';
|
|
37
|
+
import { renderGlobalNotFoundResponse, renderPageResponse } from '../render';
|
|
38
|
+
import { getFontExtensions } from '../render/hooks';
|
|
39
|
+
import { clearMetadataFileCaches, metadataRouteHandlerModule } from '../routing/metadata';
|
|
40
|
+
import { handleRouteModule, type RouteHandlerModule } from '../routing/handler';
|
|
41
|
+
import { malformedUrlResponse, trailingSlashRedirect } from '../routing/href';
|
|
42
|
+
import { withForwardedHeaders } from '../routing/forwarded';
|
|
43
|
+
import {
|
|
44
|
+
findLayouts,
|
|
45
|
+
matchRoute,
|
|
46
|
+
materializeRouteFactsPaced,
|
|
47
|
+
parseNavState,
|
|
48
|
+
routeFactsResolved,
|
|
49
|
+
routeFactsVersion,
|
|
50
|
+
scanRoutes,
|
|
51
|
+
selectRouteForRequest,
|
|
52
|
+
} from '../routing/routes';
|
|
53
|
+
import { writeTypegen } from '../typegen';
|
|
54
|
+
import { runWithCacheScope } from '../cache/context';
|
|
55
|
+
import {
|
|
56
|
+
finalizeResponse,
|
|
57
|
+
getAssetExtensions,
|
|
58
|
+
getBundlerExtensions,
|
|
59
|
+
getRequestExtensions,
|
|
60
|
+
getRouterProtocolExtensions,
|
|
61
|
+
reportRequestError,
|
|
62
|
+
runInitHooks,
|
|
63
|
+
runRequestWarmHooks,
|
|
64
|
+
withRouteRuntime,
|
|
65
|
+
} from '../extensions';
|
|
66
|
+
import {
|
|
67
|
+
flushWorkUnit,
|
|
68
|
+
flushWorkUnitOnClose,
|
|
69
|
+
getWorkUnit,
|
|
70
|
+
runWithWorkUnit,
|
|
71
|
+
setPhase,
|
|
72
|
+
setWorkUnitRoute,
|
|
73
|
+
} from '../request/context';
|
|
74
|
+
import { setRequestRuntime } from '../routing/request-runtime';
|
|
75
|
+
import { ensureDir, listFiles, writeText } from '../utils/fs';
|
|
76
|
+
import { contentType } from '../utils/content-type';
|
|
77
|
+
import {
|
|
78
|
+
flushDevProfileLines,
|
|
79
|
+
formatProfileDuration,
|
|
80
|
+
recordDevProfileLine,
|
|
81
|
+
} from '../utils/dev-profile';
|
|
82
|
+
import { clearResolverFsCache } from '../resolve/engine';
|
|
83
|
+
import { clearAppTreeResolutions, workspacePackageRoots } from '../resolve/imports';
|
|
84
|
+
import type { RouteManifestEntry, RouteParamValue } from '../types';
|
|
85
|
+
import {
|
|
86
|
+
devClientModuleHref,
|
|
87
|
+
devModuleGraph,
|
|
88
|
+
devRouteModuleLoaders,
|
|
89
|
+
devServerModuleHref,
|
|
90
|
+
warmDevModulePipeline,
|
|
91
|
+
} from './imports';
|
|
92
|
+
import { ssrClientReference } from '../client/reference';
|
|
93
|
+
import { cacheRoot } from './module-cache';
|
|
94
|
+
import { markBoot } from '../cli/boot-trace';
|
|
95
|
+
|
|
96
|
+
interface DevServerOptions {
|
|
97
|
+
config: ResolvedConfig;
|
|
98
|
+
port: number;
|
|
99
|
+
hostname: string;
|
|
100
|
+
/**
|
|
101
|
+
* Compile the entry route in the background as soon as the server is up, so the
|
|
102
|
+
* expensive cold build (esbuild cold start + Tailwind subprocess) overlaps with
|
|
103
|
+
* the browser launching instead of blocking the first click. The build caches
|
|
104
|
+
* dedup, so if the real request arrives mid-warm it awaits the same promise.
|
|
105
|
+
*/
|
|
106
|
+
warm?: boolean;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const clientBuilds = new Map<string, Promise<string>>();
|
|
110
|
+
const clientChunks = new Map<string, string>();
|
|
111
|
+
|
|
112
|
+
// esbuild.stop() (the memory watchdog) can leave an in-flight build's promise permanently unsettled;
|
|
113
|
+
// anything awaiting it hangs and the dedup map keeps re-serving the poisoned promise. Waiters race against
|
|
114
|
+
// this registry so a service restart fails them fast instead - failed builds drop from the caches and the
|
|
115
|
+
// next request retries against the respawned service.
|
|
116
|
+
const clientBuildStopWaiters = new Set<(err: Error) => void>();
|
|
117
|
+
export function failInFlightClientBuilds(reason: string) {
|
|
118
|
+
const err = new Error(reason);
|
|
119
|
+
for (const reject of clientBuildStopWaiters) reject(err);
|
|
120
|
+
clientBuildStopWaiters.clear();
|
|
121
|
+
clientBuilds.clear();
|
|
122
|
+
}
|
|
123
|
+
// Client out-dirs whose chunk list is already in clientChunks — the dir name is
|
|
124
|
+
// a content key, so it never needs a second readdir.
|
|
125
|
+
const indexedClientDirs = new Set<string>();
|
|
126
|
+
// Layout chains per route file: findLayouts + an existsSync per level, which a
|
|
127
|
+
// warm request would otherwise redo every time.
|
|
128
|
+
const routeLayoutFiles = new Map<string, string[]>();
|
|
129
|
+
const assetBuilds = new Map<string, Promise<unknown>>();
|
|
130
|
+
const routeCacheKeys = new Map<string, Promise<string>>();
|
|
131
|
+
// Routes whose bundles this process has already compiled, so the "Compiling"
|
|
132
|
+
// banner prints once per cold route (like Next.js) and not on warm hits.
|
|
133
|
+
// Cleared on reload.
|
|
134
|
+
const compiledRoutes = new Set<string>();
|
|
135
|
+
|
|
136
|
+
interface DevEventClient {
|
|
137
|
+
controller: ReadableStreamDefaultController<Uint8Array>;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* A save invalidates the changed files and their dependents - nothing else. Compiled modules are
|
|
142
|
+
* content-addressed, so every map keyed by an artifact path invalidates itself; only the memo caches keyed
|
|
143
|
+
* by route or source path need telling.
|
|
144
|
+
*/
|
|
145
|
+
function invalidateDevCaches(config: ResolvedConfig, changed: string[], structural: boolean) {
|
|
146
|
+
devModuleGraph(config).invalidate(changed);
|
|
147
|
+
// oxc caches fs lookups (including misses) until cleared, so a file added or
|
|
148
|
+
// deleted since the last resolve stays invisible without this. A plain edit
|
|
149
|
+
// changes no lookup answer.
|
|
150
|
+
if (structural) {
|
|
151
|
+
clearResolverFsCache();
|
|
152
|
+
clearAppTreeResolutions();
|
|
153
|
+
clearMetadataFileCaches();
|
|
154
|
+
routeLayoutFiles.clear();
|
|
155
|
+
}
|
|
156
|
+
// Route CSS is derived from the whole source tree (Tailwind scans it), and
|
|
157
|
+
// the client cache key is a content hash whose inputs just moved.
|
|
158
|
+
clearGlobalCssSourceCache();
|
|
159
|
+
assetBuilds.clear();
|
|
160
|
+
routeCacheKeys.clear();
|
|
161
|
+
indexedClientDirs.clear();
|
|
162
|
+
compiledRoutes.clear();
|
|
163
|
+
// Long sessions accumulate one generation per save; sweep past the keep
|
|
164
|
+
// window here too, not just at boot. Best-effort and off the reload path.
|
|
165
|
+
void evictStaleClientCaches(config.outPath);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Every edit writes a fresh content-keyed generation under cache/client/ and
|
|
169
|
+
// nothing removed the old ones — ~6.6 MB per save, 1.24 GB after a 200-edit
|
|
170
|
+
// session (DEV-MEMORY §disk). Keep the newest N generations; an evicted dir is
|
|
171
|
+
// just a cache miss. Runs off the boot path.
|
|
172
|
+
async function evictStaleClientCaches(outPath: string) {
|
|
173
|
+
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
174
|
+
const keep = Number(process.env.PNEXT_DEV_CLIENT_CACHE_KEEP || 32);
|
|
175
|
+
if (!Number.isFinite(keep) || keep <= 0) return;
|
|
176
|
+
const clientRoot = path.join(outPath, 'cache', 'client');
|
|
177
|
+
try {
|
|
178
|
+
const entries = await readdir(clientRoot);
|
|
179
|
+
if (entries.length <= keep) return;
|
|
180
|
+
const dated = await Promise.all(
|
|
181
|
+
entries.map(async entry => {
|
|
182
|
+
const full = path.join(clientRoot, entry);
|
|
183
|
+
const info = await stat(full).catch(() => null);
|
|
184
|
+
return { full, mtime: info?.mtimeMs ?? 0 };
|
|
185
|
+
}),
|
|
186
|
+
);
|
|
187
|
+
dated.sort((a, b) => b.mtime - a.mtime);
|
|
188
|
+
await Promise.allSettled(
|
|
189
|
+
dated.slice(keep).map(entry => rm(entry.full, { recursive: true, force: true })),
|
|
190
|
+
);
|
|
191
|
+
// Evicted generations may have been the last link to a store entry; sweep
|
|
192
|
+
// after removal so orphaned store files (nlink back down to 1) don't linger.
|
|
193
|
+
await sweepClientChunkStore(clientChunkStoreDir(outPath));
|
|
194
|
+
} catch {
|
|
195
|
+
// ignore: eviction is best-effort; worst case is disk growth, not breakage
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Left behind by pre-persistence versions of the dev server, which swept the
|
|
200
|
+
// cache into a `.cache-stale-*` dir on every boot. Nothing creates them now.
|
|
201
|
+
async function removeLeakedStaleCaches(outPath: string) {
|
|
202
|
+
try {
|
|
203
|
+
const entries = await readdir(outPath);
|
|
204
|
+
await Promise.allSettled(
|
|
205
|
+
entries
|
|
206
|
+
.filter(entry => entry.startsWith('.cache-stale-'))
|
|
207
|
+
.map(entry => rm(path.join(outPath, entry), { recursive: true, force: true })),
|
|
208
|
+
);
|
|
209
|
+
} catch {
|
|
210
|
+
// ignore: leftover stale dirs are only a disk-space nuisance
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export async function startDevServer(options: DevServerOptions) {
|
|
215
|
+
const { config, port, hostname } = options;
|
|
216
|
+
// Spawn the CSS worker first: Tailwind's cold boot then runs off the event
|
|
217
|
+
// loop, alongside everything below, instead of on the first page's stylesheet.
|
|
218
|
+
warmCssPipeline(config, { dev: true });
|
|
219
|
+
markBoot('boot:css-warm');
|
|
220
|
+
// Compat plugin loader: the single gated seam that populates the core
|
|
221
|
+
// extension registries when compat is enabled (no-op for pure-core apps).
|
|
222
|
+
// Boot takes the cheap tier only — route conventions, aliases, proxy names,
|
|
223
|
+
// lifecycle hooks; the implementation graph loads on the first request.
|
|
224
|
+
await bootstrapCompatBoot(config);
|
|
225
|
+
markBoot('boot:compat');
|
|
226
|
+
// The compat implementation graph still has to load (first request / warmup).
|
|
227
|
+
// Registering the module plugins now would put that whole import through the
|
|
228
|
+
// plugin pipeline; arm them once compat is in, like build and start do.
|
|
229
|
+
deferServerRuntimePlugins();
|
|
230
|
+
registerServerRuntime(config);
|
|
231
|
+
// Compat installs the Next fetch-cache patch here (no-op for pure-core apps).
|
|
232
|
+
runInitHooks(config);
|
|
233
|
+
// No boot sweep: the compiled cache is content-addressed and persists across
|
|
234
|
+
// restarts, so validity is a name miss plus the
|
|
235
|
+
// cache marker, not a wipe.
|
|
236
|
+
void removeLeakedStaleCaches(config.outPath);
|
|
237
|
+
void evictStaleClientCaches(config.outPath);
|
|
238
|
+
// Route facts are the biggest single thing a restart's first page used to re-derive. Installed
|
|
239
|
+
// before the route table so the first touch - whoever forces it - reads the previous process's
|
|
240
|
+
// walk instead of repeating it.
|
|
241
|
+
installRouteFactsCache(config);
|
|
242
|
+
// Same deal for the root layout's CSS graph: 320 files read to find one
|
|
243
|
+
// stylesheet, and both the warm tier and the render force it at Ready+0.
|
|
244
|
+
installGlobalCssCache(config);
|
|
245
|
+
markBoot('boot:registry');
|
|
246
|
+
let routes = await scanRoutes(config.appPath);
|
|
247
|
+
markBoot('boot:scanRoutes');
|
|
248
|
+
await validateProxyFiles(config);
|
|
249
|
+
let proxyRunner = createProxyRunner(config);
|
|
250
|
+
markBoot('boot:proxy');
|
|
251
|
+
let devImportVersion = String(Date.now());
|
|
252
|
+
// The proxy runs serially in front of every request, so its compile+import is
|
|
253
|
+
// pure critical path when the first request pays it. Started here it overlaps
|
|
254
|
+
// the CSS worker warmup, typegen and the first route's own module pass.
|
|
255
|
+
if (restartCacheEnabled()) proxyRunner.warm({ dev: true, devImportVersion });
|
|
256
|
+
markBoot('boot:proxy-warm');
|
|
257
|
+
// Publish the live routing state the compat request interceptors (action
|
|
258
|
+
// dispatch, rewrites) + client-action discovery read. Compat re-runs action
|
|
259
|
+
// discovery lazily whenever the dev import version changes (see below), so the
|
|
260
|
+
// registry + client-stub set that startup/reload armed inline stay current.
|
|
261
|
+
publishRuntime();
|
|
262
|
+
const clients = new Set<DevEventClient>();
|
|
263
|
+
let watcher: DevWatcher | undefined;
|
|
264
|
+
/** Requests currently being served — background work defers to them. */
|
|
265
|
+
let inFlight = 0;
|
|
266
|
+
|
|
267
|
+
function publishRuntime() {
|
|
268
|
+
setRequestRuntime({ config, routes, dev: true, devImportVersion });
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Typegen's route walk is the longest CPU run dev does outside a compile, and the only one with no
|
|
272
|
+
// deadline, so it yields between routes and waits out anything the server is actually serving.
|
|
273
|
+
// Bounded: a dev server under continuous load would otherwise never emit the .d.ts at all, so a
|
|
274
|
+
// route waits out requests for at most this long and then takes its turn regardless.
|
|
275
|
+
// `PNEXT_DEV_TYPEGEN_PACE=0` restores the uninterrupted walk.
|
|
276
|
+
const TYPEGEN_MAX_WAIT_MS = 3_000;
|
|
277
|
+
|
|
278
|
+
async function typegenPause() {
|
|
279
|
+
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
280
|
+
if (process.env.PNEXT_DEV_TYPEGEN_PACE === '0') return;
|
|
281
|
+
const deadline = performance.now() + TYPEGEN_MAX_WAIT_MS;
|
|
282
|
+
do {
|
|
283
|
+
await new Promise(resolve => setTimeout(resolve, inFlight > 0 ? 25 : 0));
|
|
284
|
+
} while (inFlight > 0 && performance.now() < deadline);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function typegenInBackground() {
|
|
288
|
+
const generation = routes;
|
|
289
|
+
try {
|
|
290
|
+
await writeTypegen(config, await materializeRouteFactsPaced(generation, typegenPause));
|
|
291
|
+
} catch (error) {
|
|
292
|
+
if (generation === routes) logDevPreloadError('typegen', error);
|
|
293
|
+
}
|
|
294
|
+
if (generation === routes) syncWatchRoots();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function reload(change: DevChange) {
|
|
298
|
+
// Editors save atomically (write + rename), so a rename event alone does not
|
|
299
|
+
// mean the tree moved: it only did if a file is gone, or is one the module
|
|
300
|
+
// graph has never seen.
|
|
301
|
+
const structural =
|
|
302
|
+
change.renamed &&
|
|
303
|
+
change.files.some(file => !existsSync(file) || !devModuleGraph(config).knows(file));
|
|
304
|
+
invalidateDevCaches(config, change.files, structural);
|
|
305
|
+
// A burst that only touched plain stylesheets cannot have moved the module graph, the route
|
|
306
|
+
// table, the proxy or the client bundles - only the built CSS assets. The page swaps its <link>s
|
|
307
|
+
// in place instead of navigating.
|
|
308
|
+
if (isCssOnlyChange(change.files)) {
|
|
309
|
+
broadcast(clients, 'css-update');
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
// The route table is built from file paths only, so only an added, removed
|
|
313
|
+
// or renamed file can change it — a plain save never does.
|
|
314
|
+
if (structural) routes = await scanRoutes(config.appPath);
|
|
315
|
+
await validateProxyFiles(config);
|
|
316
|
+
proxyRunner = createProxyRunner(config);
|
|
317
|
+
devImportVersion = `${Date.now()}`;
|
|
318
|
+
proxyRunner.warm({ dev: true, devImportVersion });
|
|
319
|
+
// Republish with the bumped version so compat re-discovers actions before
|
|
320
|
+
// the next action request / client build.
|
|
321
|
+
publishRuntime();
|
|
322
|
+
void typegenInBackground();
|
|
323
|
+
watchedFactsVersion = -1;
|
|
324
|
+
syncWatchRoots();
|
|
325
|
+
broadcast(clients, 'reload');
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Watch roots outside app/ come from route sourceFiles, which only exist once
|
|
329
|
+
// a route has resolved its deferred facts — so they are re-derived whenever
|
|
330
|
+
// another route materializes (its first compile), not once at boot.
|
|
331
|
+
let watchedFactsVersion = -1;
|
|
332
|
+
function syncWatchRoots() {
|
|
333
|
+
if (watchedFactsVersion === routeFactsVersion()) return;
|
|
334
|
+
watchedFactsVersion = routeFactsVersion();
|
|
335
|
+
watcher = refreshWatcher(config, routes, watcher, reload);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
syncWatchRoots();
|
|
339
|
+
markBoot('boot:watcher');
|
|
340
|
+
|
|
341
|
+
const server = Bun.serve({
|
|
342
|
+
hostname,
|
|
343
|
+
port,
|
|
344
|
+
idleTimeout: 60,
|
|
345
|
+
fetch(request, server) {
|
|
346
|
+
// One work unit spans the whole request; its after-queue flushes once the
|
|
347
|
+
// response fully closes (stream end, redirect, notFound, error, abort).
|
|
348
|
+
inFlight++;
|
|
349
|
+
return runWithWorkUnit('render', () => handleDevRequest(request, server)).finally(() => {
|
|
350
|
+
inFlight--;
|
|
351
|
+
});
|
|
352
|
+
},
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
async function handleDevRequest(
|
|
356
|
+
request: Request,
|
|
357
|
+
server: { timeout(request: Request, seconds: number): void },
|
|
358
|
+
): Promise<Response> {
|
|
359
|
+
const prologueStart = performance.now();
|
|
360
|
+
// The compat implementation graph loads here, on the first request, not at
|
|
361
|
+
// boot — everything a request touches (interceptors, render, compile) is
|
|
362
|
+
// registered by the time this resolves.
|
|
363
|
+
await bootstrapCompat(config, { serve: true });
|
|
364
|
+
installServerRuntimePlugins();
|
|
365
|
+
const unit = getWorkUnit();
|
|
366
|
+
const badRequest = malformedUrlResponse(request);
|
|
367
|
+
if (badRequest) return badRequest;
|
|
368
|
+
request = withForwardedHeaders(request);
|
|
369
|
+
let url = new URL(request.url);
|
|
370
|
+
const profile = devRequestProfile(request, url);
|
|
371
|
+
if (profile) logDevProfile(profile, 'prologue (bootstrap/headers/url)', prologueStart);
|
|
372
|
+
let pageLog = pendingDevPageLoadLog(routes, request, url);
|
|
373
|
+
// Work this request wants started, but only once its own response is out.
|
|
374
|
+
let afterResponse: (() => void) | undefined;
|
|
375
|
+
const logAbort = () => {
|
|
376
|
+
const pending = pageLog;
|
|
377
|
+
pageLog = undefined;
|
|
378
|
+
logDevPageAbort(pending);
|
|
379
|
+
};
|
|
380
|
+
request.signal.addEventListener('abort', logAbort, { once: true });
|
|
381
|
+
const finish = async (response: Response): Promise<Response> => {
|
|
382
|
+
const pending = pageLog;
|
|
383
|
+
pageLog = undefined;
|
|
384
|
+
request.signal.removeEventListener('abort', logAbort);
|
|
385
|
+
const finalized = await profileDevStep(profile, 'finalize response', () =>
|
|
386
|
+
finalizeResponse(
|
|
387
|
+
response,
|
|
388
|
+
{ method: request.method, url: new URL(request.url), headers: request.headers },
|
|
389
|
+
{ routeKind: unit?.routeKind ?? 'html', routeMode: unit?.routeMode },
|
|
390
|
+
),
|
|
391
|
+
);
|
|
392
|
+
const logged = logDevPageResponse(pending, finalized);
|
|
393
|
+
// A timer, not a bare call: the response is only handed to Bun when this returns, so anything
|
|
394
|
+
// started synchronously here still races it. That is also why the watch-root sync moved in -
|
|
395
|
+
// a request that compiled a route just materialized its source graph, and re-deriving the
|
|
396
|
+
// roots from it is a synchronous walk the document does not need.
|
|
397
|
+
const tasks = afterResponse;
|
|
398
|
+
afterResponse = undefined;
|
|
399
|
+
setTimeout(() => {
|
|
400
|
+
tasks?.();
|
|
401
|
+
syncWatchRoots();
|
|
402
|
+
}, 0);
|
|
403
|
+
if (profile) flushDevProfileLines();
|
|
404
|
+
return flushWorkUnitOnClose(logged, unit, request.signal);
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
try {
|
|
408
|
+
const initialPrefetchResponse = maybeDevPagePrefetchResponse(routes, url.pathname, request);
|
|
409
|
+
if (initialPrefetchResponse) return finish(initialPrefetchResponse);
|
|
410
|
+
|
|
411
|
+
let proxyResponse: Response | undefined;
|
|
412
|
+
const proxyResult = await profileDevStep(profile, 'proxy', () =>
|
|
413
|
+
proxyRunner(request, { dev: true, devImportVersion }),
|
|
414
|
+
);
|
|
415
|
+
if (proxyResult instanceof Response) return finish(proxyResult);
|
|
416
|
+
if (proxyResult) {
|
|
417
|
+
request = proxyResult.request;
|
|
418
|
+
proxyResponse = proxyResult.response;
|
|
419
|
+
url = new URL(request.url);
|
|
420
|
+
pageLog = pendingDevPageLoadLog(routes, request, url, pageLog?.start);
|
|
421
|
+
|
|
422
|
+
const rewrittenPrefetchResponse = maybeDevPagePrefetchResponse(
|
|
423
|
+
routes,
|
|
424
|
+
url.pathname,
|
|
425
|
+
request,
|
|
426
|
+
);
|
|
427
|
+
if (rewrittenPrefetchResponse)
|
|
428
|
+
return finish(applyProxyResponse(rewrittenPrefetchResponse, proxyResponse));
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (url.pathname === '/__pnext/events') {
|
|
432
|
+
server.timeout(request, 0);
|
|
433
|
+
return finish(eventStream(clients));
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const assetResponse = await profileDevStep(profile, 'built asset lookup', () =>
|
|
437
|
+
maybeBuiltAsset(config, routes, url.pathname),
|
|
438
|
+
);
|
|
439
|
+
if (assetResponse) return finish(applyProxyResponse(assetResponse, proxyResponse));
|
|
440
|
+
|
|
441
|
+
const clientChunkResponse = await profileDevStep(profile, 'client chunk lookup', () =>
|
|
442
|
+
maybeDevClientChunk(config, routes, url.pathname),
|
|
443
|
+
);
|
|
444
|
+
if (clientChunkResponse)
|
|
445
|
+
return finish(applyProxyResponse(clientChunkResponse, proxyResponse));
|
|
446
|
+
|
|
447
|
+
const runtimeResponse = maybePrebuiltRuntime(config, url.pathname);
|
|
448
|
+
if (runtimeResponse) return finish(applyProxyResponse(runtimeResponse, proxyResponse));
|
|
449
|
+
|
|
450
|
+
const dynChunkMatch = /^\/__pnext\/client-dyn\/([A-Za-z0-9-]+)\.js$/.exec(url.pathname);
|
|
451
|
+
if (dynChunkMatch?.[1]) {
|
|
452
|
+
const found = findDeferredDynamicReference(routes, dynChunkMatch[1]);
|
|
453
|
+
if (!found)
|
|
454
|
+
return finish(
|
|
455
|
+
applyProxyResponse(new Response('not found', { status: 404 }), proxyResponse),
|
|
456
|
+
);
|
|
457
|
+
const file = await profileDevStep(profile, `client dyn chunk ${dynChunkMatch[1]}`, () =>
|
|
458
|
+
buildDevDynamicChunk(config, found.route, found.reference, devImportVersion),
|
|
459
|
+
);
|
|
460
|
+
return finish(
|
|
461
|
+
applyProxyResponse(
|
|
462
|
+
devResponse(await readFile(file), 'text/javascript; charset=utf-8'),
|
|
463
|
+
proxyResponse,
|
|
464
|
+
),
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
const dynSharedChunk = /^\/__pnext\/client-dyn\/chunks\/(.+\.js)$/.exec(url.pathname);
|
|
468
|
+
if (dynSharedChunk?.[1]) {
|
|
469
|
+
const chunkFile = dynChunkFiles.get(dynSharedChunk[1]);
|
|
470
|
+
if (chunkFile && existsSync(chunkFile))
|
|
471
|
+
return finish(
|
|
472
|
+
applyProxyResponse(devChunkResponse(await readFile(chunkFile)), proxyResponse),
|
|
473
|
+
);
|
|
474
|
+
return finish(
|
|
475
|
+
applyProxyResponse(new Response('not found', { status: 404 }), proxyResponse),
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const clientMatch = /^\/__pnext\/client\/(.+)\.js$/.exec(url.pathname);
|
|
480
|
+
if (clientMatch?.[1]) {
|
|
481
|
+
const route = routes.find(
|
|
482
|
+
item =>
|
|
483
|
+
item.id === clientMatch[1] &&
|
|
484
|
+
(item.client || item.clientReferences.length > 0 || item.needsRouterEntry),
|
|
485
|
+
);
|
|
486
|
+
if (!route)
|
|
487
|
+
return finish(
|
|
488
|
+
applyProxyResponse(new Response('not found', { status: 404 }), proxyResponse),
|
|
489
|
+
);
|
|
490
|
+
const file = await profileDevStep(profile, `client build ${route.route}`, () =>
|
|
491
|
+
buildDevClient(config, route),
|
|
492
|
+
);
|
|
493
|
+
return finish(
|
|
494
|
+
applyProxyResponse(
|
|
495
|
+
devResponse(
|
|
496
|
+
await profileDevStep(profile, 'client read', () => readFile(file)),
|
|
497
|
+
'text/javascript; charset=utf-8',
|
|
498
|
+
),
|
|
499
|
+
proxyResponse,
|
|
500
|
+
),
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const staticResponse = await profileDevStep(profile, 'static lookup', () =>
|
|
505
|
+
maybeStaticFile(config.publicPath, url.pathname),
|
|
506
|
+
);
|
|
507
|
+
if (staticResponse) {
|
|
508
|
+
setWorkUnitRoute('static-asset', 'static');
|
|
509
|
+
return finish(applyProxyResponse(staticResponse, proxyResponse));
|
|
510
|
+
}
|
|
511
|
+
// Emitted assets land in the build output's public dir, not the project public dir.
|
|
512
|
+
if (
|
|
513
|
+
getAssetExtensions()
|
|
514
|
+
.staticAssetPublicPrefixes()
|
|
515
|
+
.some(prefix => url.pathname.startsWith(prefix))
|
|
516
|
+
) {
|
|
517
|
+
const emitted = await maybeStaticFile(
|
|
518
|
+
path.join(config.outPath, 'public'),
|
|
519
|
+
url.pathname,
|
|
520
|
+
);
|
|
521
|
+
if (emitted) {
|
|
522
|
+
setWorkUnitRoute('static-asset', 'static');
|
|
523
|
+
return finish(applyProxyResponse(emitted, proxyResponse));
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
const staticAssetPathname = getNextStaticAssetPathname(
|
|
527
|
+
url.pathname,
|
|
528
|
+
config.basePath,
|
|
529
|
+
config.assetPrefix,
|
|
530
|
+
);
|
|
531
|
+
if (staticAssetPathname) {
|
|
532
|
+
const outPublicStatic = await maybeStaticFile(
|
|
533
|
+
path.join(config.outPath, 'public'),
|
|
534
|
+
staticAssetPathname,
|
|
535
|
+
);
|
|
536
|
+
if (outPublicStatic) {
|
|
537
|
+
setWorkUnitRoute('static-asset', 'static');
|
|
538
|
+
return finish(applyProxyResponse(outPublicStatic, proxyResponse));
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Standalone chunks a bundler extension supplies (compat: the
|
|
542
|
+
// no-module polyfills chunk). The prod build writes them to disk; dev
|
|
543
|
+
// has no client out-dir for them, so serve the contents directly.
|
|
544
|
+
const staticChunk = maybeStaticClientChunk(config, staticAssetPathname);
|
|
545
|
+
if (staticChunk) {
|
|
546
|
+
setWorkUnitRoute('static-asset', 'static');
|
|
547
|
+
return finish(applyProxyResponse(staticChunk, proxyResponse));
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
setWorkUnitRoute('static-asset', 'static');
|
|
551
|
+
return finish(
|
|
552
|
+
applyProxyResponse(
|
|
553
|
+
new Response('Not Found', {
|
|
554
|
+
status: 404,
|
|
555
|
+
headers: { 'content-type': 'text/plain' },
|
|
556
|
+
}),
|
|
557
|
+
proxyResponse,
|
|
558
|
+
),
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// The compat request interceptors run before route matching (registration order: action dispatch -
|
|
563
|
+
// POSTs to a page URL with the action id - then next.config rewrites). A Response short-circuits
|
|
564
|
+
// (wrapped with the proxy response); a `{ request }` swaps the request (a rewrite) and continues.
|
|
565
|
+
// The render keeps the ORIGINAL request URL as canonical; only matching and lookup follow the
|
|
566
|
+
// rewritten url. Pure-core apps register no interceptors.
|
|
567
|
+
const canonicalRequest = request;
|
|
568
|
+
const canonicalUrl = new URL(request.url);
|
|
569
|
+
for (const [index, interceptor] of getRequestExtensions().interceptors.entries()) {
|
|
570
|
+
const result = await profileDevStep(
|
|
571
|
+
profile,
|
|
572
|
+
`interceptor ${interceptor.name || index}`,
|
|
573
|
+
() => interceptor(request, { config }),
|
|
574
|
+
);
|
|
575
|
+
if (result instanceof Response) return finish(applyProxyResponse(result, proxyResponse));
|
|
576
|
+
if (result) {
|
|
577
|
+
request = result.request;
|
|
578
|
+
url = new URL(request.url);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const canonicalRedirect = trailingSlashRedirect(config, url, request.method.toUpperCase());
|
|
583
|
+
if (canonicalRedirect) return finish(applyProxyResponse(canonicalRedirect, proxyResponse));
|
|
584
|
+
|
|
585
|
+
const nav = parseNavState(request);
|
|
586
|
+
const matched = profileDevSyncStep(profile, 'match route', () =>
|
|
587
|
+
selectRouteForRequest(routes, url.pathname, nav),
|
|
588
|
+
);
|
|
589
|
+
|
|
590
|
+
if (!matched) {
|
|
591
|
+
return finish(
|
|
592
|
+
applyProxyResponse(
|
|
593
|
+
await renderGlobalNotFoundResponse({
|
|
594
|
+
config,
|
|
595
|
+
url,
|
|
596
|
+
request: canonicalRequest,
|
|
597
|
+
dev: true,
|
|
598
|
+
devImportVersion,
|
|
599
|
+
}),
|
|
600
|
+
proxyResponse,
|
|
601
|
+
),
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
if (matched.route.kind === 'handler') {
|
|
606
|
+
setWorkUnitRoute('route-handler');
|
|
607
|
+
setPhase('handler');
|
|
608
|
+
return finish(
|
|
609
|
+
applyProxyResponse(
|
|
610
|
+
await profileDevStep(profile, `handler ${matched.route.route}`, () =>
|
|
611
|
+
handleRoute(
|
|
612
|
+
config,
|
|
613
|
+
matched.route,
|
|
614
|
+
canonicalRequest,
|
|
615
|
+
matched.params,
|
|
616
|
+
devImportVersion,
|
|
617
|
+
),
|
|
618
|
+
),
|
|
619
|
+
proxyResponse,
|
|
620
|
+
),
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
setWorkUnitRoute('html', matched.route.mode === 'static' ? 'static' : 'dynamic');
|
|
625
|
+
pageRequestsSeen++;
|
|
626
|
+
noteDevCompileStart(matched.route);
|
|
627
|
+
noteDevRouteServed(config, matched.route);
|
|
628
|
+
// Stylesheets are a SEPARATE browser fetch - the HTML never awaits them - so building them here only
|
|
629
|
+
// takes cores off the import the response is blocked on. Handed to `finish` instead: the browser
|
|
630
|
+
// still has to receive and parse the document before it asks, and by then the build is running (or,
|
|
631
|
+
// on a warm hit, already memoized). The hydration chunk is a separate fetch on the same terms.
|
|
632
|
+
const eagerClient = !deferClientStage();
|
|
633
|
+
if (eagerClient) preloadDevClient(config, matched.route, profile);
|
|
634
|
+
const preloadAssets = () => {
|
|
635
|
+
preloadDevPageAssets(config, matched.route, profile);
|
|
636
|
+
if (!eagerClient) preloadDevClient(config, matched.route, profile);
|
|
637
|
+
};
|
|
638
|
+
afterResponse = preloadAssets;
|
|
639
|
+
if (!deferAssetPreload()) {
|
|
640
|
+
afterResponse = undefined;
|
|
641
|
+
preloadAssets();
|
|
642
|
+
}
|
|
643
|
+
const layoutFiles = profileDevSyncStep(profile, 'find layouts', () =>
|
|
644
|
+
devLayoutFiles(config, matched.route.file),
|
|
645
|
+
);
|
|
646
|
+
let loaders: Awaited<ReturnType<typeof devRouteModuleLoaders>> | undefined;
|
|
647
|
+
preloadDevClientReferences(config, matched.route, profile);
|
|
648
|
+
try {
|
|
649
|
+
loaders = await profileDevStep(
|
|
650
|
+
profile,
|
|
651
|
+
`route module loaders ${matched.route.route}`,
|
|
652
|
+
() => devRouteModuleLoaders(config, matched.route, layoutFiles),
|
|
653
|
+
);
|
|
654
|
+
} catch (error) {
|
|
655
|
+
logDevRouteLoaderFallback(matched.route, error);
|
|
656
|
+
}
|
|
657
|
+
// Second chance for the font stage: on most apps the fonts are declared
|
|
658
|
+
// by the root LAYOUT, which the route bundle brings in — so this is
|
|
659
|
+
// where they first exist. Memoized, so the earlier call is not repeated.
|
|
660
|
+
void preloadDevFonts(config, profile);
|
|
661
|
+
return finish(
|
|
662
|
+
applyProxyResponse(
|
|
663
|
+
await profileDevStep(profile, `render page ${matched.route.route}`, () =>
|
|
664
|
+
withRouteRuntime(matched.route.segmentConfig?.runtime, () =>
|
|
665
|
+
renderPageResponse({
|
|
666
|
+
config,
|
|
667
|
+
route: matched.route,
|
|
668
|
+
params: matched.params,
|
|
669
|
+
// The render keeps the ORIGINAL requested URL as canonical
|
|
670
|
+
// (usePathname/useSearchParams); a rewrite only steered matching.
|
|
671
|
+
url: canonicalUrl,
|
|
672
|
+
// Non-action POSTs to a page render the page (Next's MPA
|
|
673
|
+
// fallback for plain form posts / followed 307s), not a 405.
|
|
674
|
+
request:
|
|
675
|
+
request.method.toUpperCase() === 'POST'
|
|
676
|
+
? new Request(canonicalRequest.url, { headers: canonicalRequest.headers })
|
|
677
|
+
: canonicalRequest,
|
|
678
|
+
dev: true,
|
|
679
|
+
devImportVersion,
|
|
680
|
+
layoutFiles,
|
|
681
|
+
moduleLoader: loaders?.moduleLoader,
|
|
682
|
+
clientModuleLoader: loaders?.clientModuleLoader,
|
|
683
|
+
...(nav
|
|
684
|
+
? {
|
|
685
|
+
nav: {
|
|
686
|
+
soft: true,
|
|
687
|
+
state: nav,
|
|
688
|
+
childrenPath: matched.childrenPath,
|
|
689
|
+
targetPath: matched.targetPath,
|
|
690
|
+
},
|
|
691
|
+
}
|
|
692
|
+
: {}),
|
|
693
|
+
}),
|
|
694
|
+
),
|
|
695
|
+
),
|
|
696
|
+
proxyResponse,
|
|
697
|
+
),
|
|
698
|
+
);
|
|
699
|
+
} catch (error) {
|
|
700
|
+
request.signal.removeEventListener('abort', logAbort);
|
|
701
|
+
// The error funnel (compat classifies + reports) fires from this single
|
|
702
|
+
// catch, then dev rethrows so Bun surfaces its own overlay/500.
|
|
703
|
+
await reportRequestError(
|
|
704
|
+
error,
|
|
705
|
+
{ method: request.method, url: request.url, headers: request.headers },
|
|
706
|
+
{ phase: unit?.phase, routeKind: unit?.routeKind },
|
|
707
|
+
);
|
|
708
|
+
logDevPageError(pageLog);
|
|
709
|
+
flushWorkUnit(unit);
|
|
710
|
+
throw error;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const warmup = options.warm
|
|
715
|
+
? afterReady(() => warmDev(config, routes)).catch(error => logDevPreloadError('warm', error))
|
|
716
|
+
: undefined;
|
|
717
|
+
// Typegen is the one consumer that needs EVERY route's content facts (mode,
|
|
718
|
+
// hydrated), so it runs last and off the critical path — after the entry
|
|
719
|
+
// route's warm compile. The .d.ts is for the editor, not for serving.
|
|
720
|
+
void Promise.resolve(warmup).then(typegenInBackground);
|
|
721
|
+
|
|
722
|
+
return Object.assign(server, { warmup });
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* The Ready banner is printed by the caller in the microtask that resolves
|
|
727
|
+
* `startDevServer`, so a timer callback is the earliest point that provably
|
|
728
|
+
* runs after it: warmup can never push the banner out.
|
|
729
|
+
*/
|
|
730
|
+
function afterReady<T>(task: () => Promise<T>) {
|
|
731
|
+
return new Promise<T>(resolve => setTimeout(() => resolve(task()), 0));
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
async function warmDev(config: ResolvedConfig, routes: RouteManifestEntry[]) {
|
|
735
|
+
// The compat implementation graph — what the first request used to wait on —
|
|
736
|
+
// plus the compile pipeline's fixed first-use costs. Both are memoized at
|
|
737
|
+
// their source, so a request that beats us here joins the same promises
|
|
738
|
+
// instead of starting a second copy.
|
|
739
|
+
await bootstrapCompat(config, { serve: true });
|
|
740
|
+
installServerRuntimePlugins();
|
|
741
|
+
runRequestWarmHooks(config);
|
|
742
|
+
await warmDevModulePipeline(config);
|
|
743
|
+
await warmDevRoutes(config, routes);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// Warm what this developer actually opens, not `/` (core spec change 6). The
|
|
747
|
+
// routes they hit are recorded next to the compiled cache, so the first boot of
|
|
748
|
+
// a checkout compiles nothing and every later one warms the last routes served —
|
|
749
|
+
// which, with the cache persisted, is usually a disk read rather than a compile.
|
|
750
|
+
const WARM_ROUTES = 3;
|
|
751
|
+
|
|
752
|
+
// Page requests served since boot. The boot warmer reads it to stand down once
|
|
753
|
+
// the developer has asked for something concrete (see warmDevRoutes).
|
|
754
|
+
let pageRequestsSeen = 0;
|
|
755
|
+
|
|
756
|
+
async function warmDevRoutes(config: ResolvedConfig, routes: RouteManifestEntry[]) {
|
|
757
|
+
const wanted = readDevRouteHistory(config)
|
|
758
|
+
.map(id => routes.find(route => route.id === id && route.kind === 'page'))
|
|
759
|
+
.filter((route): route is RouteManifestEntry => Boolean(route))
|
|
760
|
+
.slice(0, WARM_ROUTES);
|
|
761
|
+
if (wanted.length === 0) return;
|
|
762
|
+
// Every route is warmed on its own, in history order, and the whole pass stops the moment a real
|
|
763
|
+
// page request arrives: a request is the developer saying which route they want, which beats the
|
|
764
|
+
// history guess outright, and warming a *different* route beside it only steals CPU from the page
|
|
765
|
+
// they are waiting on (a save landing on in-flight warm work invalidates it and the page pays for
|
|
766
|
+
// it twice). The stand-down covers the stylesheet too, not just the route loop: a page request
|
|
767
|
+
// that arrived first is blocked on its own import, and global.css is a build it does not consume.
|
|
768
|
+
if (deferAssetPreload() && pageRequestsSeen > 0) return;
|
|
769
|
+
await buildDevAsset('/assets/global.css', () => buildGlobalCss(config, { dev: true }));
|
|
770
|
+
for (const route of wanted) {
|
|
771
|
+
if (pageRequestsSeen > 0) return;
|
|
772
|
+
await warmDevRoute(config, route).catch(() => undefined);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
async function warmDevRoute(config: ResolvedConfig, route: RouteManifestEntry) {
|
|
777
|
+
noteDevCompileStart(route);
|
|
778
|
+
const start = performance.now();
|
|
779
|
+
const layoutFiles = devLayoutFiles(config, route.file);
|
|
780
|
+
const hasClient = route.client || route.clientReferences.length > 0;
|
|
781
|
+
await Promise.allSettled([
|
|
782
|
+
route.cssImports.length > 0
|
|
783
|
+
? buildDevAsset(`/assets/${route.id}.css`, () => buildRouteCss(config, route, { dev: true }))
|
|
784
|
+
: Promise.resolve(),
|
|
785
|
+
devRouteModuleLoaders(config, route, layoutFiles),
|
|
786
|
+
hasClient ? buildDevClient(config, route) : Promise.resolve(),
|
|
787
|
+
warmDevClientReferences(config, route),
|
|
788
|
+
warmDevPageModule(config, route),
|
|
789
|
+
]);
|
|
790
|
+
noteDevCompileDone(route, performance.now() - start);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* A `'use client'` page is the one module of the route the route bundle does NOT carry
|
|
795
|
+
* (`writeDevRouteBundle` leaves it out), so the render imports it on its own. Same href the render
|
|
796
|
+
* resolves, so both callers share one compile and one module registry entry.
|
|
797
|
+
*/
|
|
798
|
+
async function warmDevPageModule(config: ResolvedConfig, route: RouteManifestEntry) {
|
|
799
|
+
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
800
|
+
if (process.env.PNEXT_DEV_WARM_PAGE_MODULE === '0' || !route.client || route.kind !== 'page') {
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
const href = await devServerModuleHref(config, route.file, undefined, {
|
|
804
|
+
conditionTarget: serverBundleTargetForRuntime(route.segmentConfig?.runtime),
|
|
805
|
+
});
|
|
806
|
+
await drainPreplanBuilds();
|
|
807
|
+
await import(href);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* The client-SSR half of the route: `markClientReferences` imports every SSR-able reference through
|
|
812
|
+
* the CLIENT layer, a different artifact from the browser bundle `buildDevClient` warms, and naming
|
|
813
|
+
* those artifacts walks the reference's whole source graph. Resolving the href here is the same
|
|
814
|
+
* memoized call the render makes, so a request that beats us joins the work instead of repeating it.
|
|
815
|
+
*/
|
|
816
|
+
async function warmDevClientReferences(config: ResolvedConfig, route: RouteManifestEntry) {
|
|
817
|
+
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
818
|
+
if (process.env.PNEXT_DEV_WARM_CLIENT_REFS === '0') return;
|
|
819
|
+
const target = serverBundleTargetForRuntime(route.segmentConfig?.runtime);
|
|
820
|
+
const files = new Set(
|
|
821
|
+
route.clientReferences.filter(ssrClientReference).map(reference => reference.file),
|
|
822
|
+
);
|
|
823
|
+
await Promise.allSettled(
|
|
824
|
+
// The version argument is unused by devClientModuleHref (artifacts are named
|
|
825
|
+
// by content), so there is nothing to keep in sync with the render's call.
|
|
826
|
+
[...files].map(file => devClientModuleHref(config, file, undefined, target)),
|
|
827
|
+
);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
const HISTORY_LIMIT = 8;
|
|
831
|
+
let history: string[] | undefined;
|
|
832
|
+
let historyWrite: Timer | undefined;
|
|
833
|
+
|
|
834
|
+
function historyFile(config: ResolvedConfig) {
|
|
835
|
+
return path.join(cacheRoot(config.outPath), 'warm.json');
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function readDevRouteHistory(config: ResolvedConfig): string[] {
|
|
839
|
+
if (history) return history;
|
|
840
|
+
try {
|
|
841
|
+
const parsed = JSON.parse(readFileSync(historyFile(config), 'utf8')) as unknown;
|
|
842
|
+
history = Array.isArray(parsed) ? parsed.filter(id => typeof id === 'string') : [];
|
|
843
|
+
} catch {
|
|
844
|
+
history = [];
|
|
845
|
+
}
|
|
846
|
+
return history;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/** Remember a served route, most recent first — the next boot's warm list. */
|
|
850
|
+
function noteDevRouteServed(config: ResolvedConfig, route: RouteManifestEntry) {
|
|
851
|
+
if (route.kind !== 'page') return;
|
|
852
|
+
const seen = readDevRouteHistory(config);
|
|
853
|
+
if (seen[0] === route.id) return;
|
|
854
|
+
history = [route.id, ...seen.filter(id => id !== route.id)].slice(0, HISTORY_LIMIT);
|
|
855
|
+
if (historyWrite) return;
|
|
856
|
+
historyWrite = setTimeout(() => {
|
|
857
|
+
historyWrite = undefined;
|
|
858
|
+
void writeText(historyFile(config), JSON.stringify(history ?? [])).catch(() => undefined);
|
|
859
|
+
}, 500);
|
|
860
|
+
historyWrite.unref?.();
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function logDevRouteLoaderFallback(route: RouteManifestEntry, error: unknown) {
|
|
864
|
+
console.error(
|
|
865
|
+
[
|
|
866
|
+
`PNext dev route bundle failed for ${route.route}; falling back to per-module loading.`,
|
|
867
|
+
error instanceof Error ? (error.stack ?? error.message) : String(error),
|
|
868
|
+
].join('\n'),
|
|
869
|
+
);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
interface DevRequestProfile {
|
|
873
|
+
label: string;
|
|
874
|
+
start: number;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function devRequestProfile(request: Request, url: URL): DevRequestProfile | undefined {
|
|
878
|
+
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
879
|
+
if (!process.env.PNEXT_DEV_PROFILE) return undefined;
|
|
880
|
+
return {
|
|
881
|
+
label: `${request.method} ${url.pathname}`,
|
|
882
|
+
start: performance.now(),
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
async function profileDevStep<T>(
|
|
887
|
+
profile: DevRequestProfile | undefined,
|
|
888
|
+
label: string,
|
|
889
|
+
task: () => Promise<T>,
|
|
890
|
+
) {
|
|
891
|
+
if (!profile) return task();
|
|
892
|
+
const start = performance.now();
|
|
893
|
+
try {
|
|
894
|
+
return await task();
|
|
895
|
+
} finally {
|
|
896
|
+
logDevProfile(profile, label, start);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function profileDevSyncStep<T>(
|
|
901
|
+
profile: DevRequestProfile | undefined,
|
|
902
|
+
label: string,
|
|
903
|
+
task: () => T,
|
|
904
|
+
) {
|
|
905
|
+
if (!profile) return task();
|
|
906
|
+
const start = performance.now();
|
|
907
|
+
try {
|
|
908
|
+
return task();
|
|
909
|
+
} finally {
|
|
910
|
+
logDevProfile(profile, label, start);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
function logDevProfile(profile: DevRequestProfile, label: string, start: number) {
|
|
915
|
+
const end = performance.now();
|
|
916
|
+
// Both offsets, not just the end: stages on this path run as concurrent
|
|
917
|
+
// workflows, and overlap is only readable from where each one STARTED.
|
|
918
|
+
recordDevProfileLine(
|
|
919
|
+
`dev-profile ${profile.label} ${label} in ${formatProfileDuration(end - start)} (@${formatProfileDuration(start - profile.start)}..+${formatProfileDuration(end - profile.start)})`,
|
|
920
|
+
);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
async function handleRoute(
|
|
924
|
+
config: ResolvedConfig,
|
|
925
|
+
route: RouteManifestEntry,
|
|
926
|
+
request: Request,
|
|
927
|
+
params: Record<string, RouteParamValue>,
|
|
928
|
+
devImportVersion: string,
|
|
929
|
+
) {
|
|
930
|
+
const routeHref = await devServerModuleHref(config, route.file, devImportVersion, {
|
|
931
|
+
conditionTarget: serverBundleTargetForRuntime(route.segmentConfig?.runtime),
|
|
932
|
+
});
|
|
933
|
+
await drainPreplanBuilds();
|
|
934
|
+
const imported = (await import(routeHref)) as RouteHandlerModule &
|
|
935
|
+
Parameters<typeof metadataRouteHandlerModule>[0];
|
|
936
|
+
const module = (metadataRouteHandlerModule(imported, route) ?? imported) as RouteHandlerModule;
|
|
937
|
+
return withRouteRuntime(route.segmentConfig?.runtime, () =>
|
|
938
|
+
runWithCacheScope(() => handleRouteModule(module, request, params, { routeFile: route.file })),
|
|
939
|
+
);
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
function routeClientCacheKey(config: ResolvedConfig, route: RouteManifestEntry) {
|
|
943
|
+
const existing = routeCacheKeys.get(route.id);
|
|
944
|
+
if (existing) return existing;
|
|
945
|
+
const key = devClientCacheKey(config, route, Boolean(config.compat?.next));
|
|
946
|
+
routeCacheKeys.set(route.id, key);
|
|
947
|
+
return key;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
function preloadDevClient(
|
|
951
|
+
config: ResolvedConfig,
|
|
952
|
+
route: RouteManifestEntry,
|
|
953
|
+
profile: DevRequestProfile | undefined,
|
|
954
|
+
) {
|
|
955
|
+
if (!route.client && route.clientReferences.length === 0 && !route.needsRouterEntry) return;
|
|
956
|
+
void profileDevStep(profile, `client preload ${route.route}`, () =>
|
|
957
|
+
buildDevClient(config, route),
|
|
958
|
+
)
|
|
959
|
+
.catch(error => logDevPreloadError(`client build for ${route.route}`, error))
|
|
960
|
+
.finally(() => {
|
|
961
|
+
if (profile) flushDevProfileLines();
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/**
|
|
966
|
+
* The client-SSR artifacts `markClientReferences` imports, named at the top of the request instead of
|
|
967
|
+
* inside the render. The boot warm already does this for a history route, but a first page that outran the
|
|
968
|
+
* warm used to reach it only after the route bundle had imported, so the two ran back to back. Fired here
|
|
969
|
+
* they overlap, and the render's own call joins the same memoized promises.
|
|
970
|
+
*/
|
|
971
|
+
function preloadDevClientReferences(
|
|
972
|
+
config: ResolvedConfig,
|
|
973
|
+
route: RouteManifestEntry,
|
|
974
|
+
profile: DevRequestProfile | undefined,
|
|
975
|
+
) {
|
|
976
|
+
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
977
|
+
if (process.env.PNEXT_DEV_PRELOAD_REFS === '0') return;
|
|
978
|
+
if (route.clientReferences.length > 0) {
|
|
979
|
+
void profileDevStep(profile, `client references preload ${route.route}`, () =>
|
|
980
|
+
warmDevClientReferences(config, route),
|
|
981
|
+
).catch(error => logDevPreloadError(`client references for ${route.route}`, error));
|
|
982
|
+
}
|
|
983
|
+
void profileDevStep(profile, `page module preload ${route.route}`, () =>
|
|
984
|
+
warmDevPageModule(config, route),
|
|
985
|
+
)
|
|
986
|
+
.then(() => preloadDevFonts(config, profile))
|
|
987
|
+
.catch(error => logDevPreloadError(`page module for ${route.route}`, error));
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/**
|
|
991
|
+
* Font resolution is network-bound on a cold checkout and the render only asks for it at the very
|
|
992
|
+
* end, so it used to run alone with the box idle. The definitions exist as soon as the app's font
|
|
993
|
+
* module evaluates, so it runs as its own stage alongside the compiles and the render then joins a
|
|
994
|
+
* settled memo.
|
|
995
|
+
*/
|
|
996
|
+
function preloadDevFonts(config: ResolvedConfig, profile: DevRequestProfile | undefined) {
|
|
997
|
+
return profileDevStep(profile, 'font prewarm', () =>
|
|
998
|
+
getFontExtensions().prewarmFontAssets(config, { dev: true }),
|
|
999
|
+
).catch(error => logDevPreloadError('font prewarm', error));
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/** Bisect seam: `PNEXT_DEV_CLIENT_STAGE=eager` restores the in-request firing. */
|
|
1003
|
+
function deferClientStage() {
|
|
1004
|
+
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
1005
|
+
return process.env.PNEXT_DEV_CLIENT_STAGE !== 'eager';
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
/** Bisect seam: `PNEXT_DEV_ASSET_PRELOAD=eager` restores the pre-request firing. */
|
|
1009
|
+
function deferAssetPreload() {
|
|
1010
|
+
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
1011
|
+
return process.env.PNEXT_DEV_ASSET_PRELOAD !== 'eager';
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
function preloadDevPageAssets(
|
|
1015
|
+
config: ResolvedConfig,
|
|
1016
|
+
route: RouteManifestEntry,
|
|
1017
|
+
profile: DevRequestProfile | undefined,
|
|
1018
|
+
) {
|
|
1019
|
+
void profileDevStep(profile, 'asset preload /assets/global.css', () =>
|
|
1020
|
+
buildDevAsset('/assets/global.css', () => buildGlobalCss(config, { dev: true })),
|
|
1021
|
+
).catch(error => logDevPreloadError('global css build', error));
|
|
1022
|
+
|
|
1023
|
+
if (route.cssImports.length === 0) return;
|
|
1024
|
+
void profileDevStep(profile, `asset preload /assets/${route.id}.css`, () =>
|
|
1025
|
+
buildDevAsset(`/assets/${route.id}.css`, () => buildRouteCss(config, route, { dev: true })),
|
|
1026
|
+
).catch(error => logDevPreloadError(`route css build for ${route.route}`, error));
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
/** The route's existing layout chain, memoized until a structural save. */
|
|
1030
|
+
function devLayoutFiles(config: ResolvedConfig, routeFile: string) {
|
|
1031
|
+
const cached = routeLayoutFiles.get(routeFile);
|
|
1032
|
+
if (cached) return cached;
|
|
1033
|
+
const files = findLayouts(config.appPath, routeFile).filter(file => existsSync(file));
|
|
1034
|
+
routeLayoutFiles.set(routeFile, files);
|
|
1035
|
+
return files;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
function logDevPreloadError(label: string, error: unknown) {
|
|
1039
|
+
console.error(
|
|
1040
|
+
[
|
|
1041
|
+
`PNext dev preload failed for ${label}.`,
|
|
1042
|
+
error instanceof Error ? (error.stack ?? error.message) : String(error),
|
|
1043
|
+
].join('\n'),
|
|
1044
|
+
);
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
/**
|
|
1048
|
+
* Deferred dynamic reference by chunk id: a route-scanned dynamic reference
|
|
1049
|
+
* (server-component dynamic()), else the pipeline registry (dynamic() inside a
|
|
1050
|
+
* 'use client' module, registered by the entry build's rewrite or a sidecar).
|
|
1051
|
+
*/
|
|
1052
|
+
function findDeferredDynamicReference(routes: RouteManifestEntry[], id: string) {
|
|
1053
|
+
for (const route of routes) {
|
|
1054
|
+
const reference = route.clientReferences.find(
|
|
1055
|
+
item => item.id === id && item.dynamic && !ssrClientReference(item),
|
|
1056
|
+
);
|
|
1057
|
+
if (reference) return { route, reference };
|
|
1058
|
+
}
|
|
1059
|
+
const registered = deferredDynamicRefById(id);
|
|
1060
|
+
if (registered) return { route: undefined, reference: { id, ...registered } };
|
|
1061
|
+
return undefined;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// On-demand dynamic chunk builds: keyed by (id, dev import version) so a save
|
|
1065
|
+
// invalidates; chunk files index feeds /__pnext/client-dyn/chunks/ requests.
|
|
1066
|
+
const dynChunkBuilds = new Map<string, Promise<string>>();
|
|
1067
|
+
const dynChunkFiles = new Map<string, string>();
|
|
1068
|
+
|
|
1069
|
+
async function buildDevDynamicChunk(
|
|
1070
|
+
config: ResolvedConfig,
|
|
1071
|
+
route: RouteManifestEntry | undefined,
|
|
1072
|
+
reference: { id: string; file: string; exportName: string },
|
|
1073
|
+
devImportVersion: string,
|
|
1074
|
+
) {
|
|
1075
|
+
const key = `${reference.id}\0${devImportVersion}`;
|
|
1076
|
+
const existing = dynChunkBuilds.get(key);
|
|
1077
|
+
if (existing) return existing;
|
|
1078
|
+
const outDir = path.join(
|
|
1079
|
+
config.outPath,
|
|
1080
|
+
'cache',
|
|
1081
|
+
'client-dyn',
|
|
1082
|
+
`${reference.id}-${createHash('sha1').update(devImportVersion).digest('hex').slice(0, 8)}`,
|
|
1083
|
+
);
|
|
1084
|
+
const build = (async () => {
|
|
1085
|
+
const outfile = path.join(outDir, `${reference.id}.js`);
|
|
1086
|
+
if (!existsSync(outfile)) {
|
|
1087
|
+
await buildClientDynamicChunk({ config, route, reference, outDir });
|
|
1088
|
+
}
|
|
1089
|
+
const chunksDir = path.join(outDir, 'chunks');
|
|
1090
|
+
if (existsSync(chunksDir)) {
|
|
1091
|
+
for (const entry of await readdir(chunksDir)) {
|
|
1092
|
+
if (entry.endsWith('.js')) dynChunkFiles.set(entry, path.join(chunksDir, entry));
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
return outfile;
|
|
1096
|
+
})().catch(error => {
|
|
1097
|
+
dynChunkBuilds.delete(key);
|
|
1098
|
+
throw error;
|
|
1099
|
+
});
|
|
1100
|
+
dynChunkBuilds.set(key, build);
|
|
1101
|
+
return build;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
/** A cached entry never re-ran the pipeline rewrite: recover its chunk-id map. */
|
|
1105
|
+
async function loadDeferredDynamicSidecar(outDir: string) {
|
|
1106
|
+
try {
|
|
1107
|
+
const parsed = JSON.parse(
|
|
1108
|
+
await readFile(path.join(outDir, DEFERRED_DYNAMIC_SIDECAR), 'utf8'),
|
|
1109
|
+
) as Record<string, { file: string; exportName: string }>;
|
|
1110
|
+
for (const [id, ref] of Object.entries(parsed)) {
|
|
1111
|
+
if (ref?.file && ref.exportName) registerDeferredDynamicRef(id, ref);
|
|
1112
|
+
}
|
|
1113
|
+
} catch {
|
|
1114
|
+
// No sidecar: the entry has no deferred dynamic refs.
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
async function buildDevClient(config: ResolvedConfig, route: RouteManifestEntry) {
|
|
1119
|
+
const cacheKey = await routeClientCacheKey(config, route);
|
|
1120
|
+
const outDir = path.join(config.outPath, 'cache', 'client', cacheKey);
|
|
1121
|
+
const outFile = path.join(outDir, `${clientEntryName(route)}.js`);
|
|
1122
|
+
|
|
1123
|
+
// Dedup in-flight builds first. A single page view fires up to three requests
|
|
1124
|
+
// for the same route's client bundle — the background preload, the entry, and
|
|
1125
|
+
// each chunk — and they must share one build. Two esbuild runs into the same
|
|
1126
|
+
// outDir would race nameSharedClientChunks' rename and fail with ENOENT.
|
|
1127
|
+
const existing = clientBuilds.get(outDir);
|
|
1128
|
+
if (existing) return existing;
|
|
1129
|
+
|
|
1130
|
+
if (existsSync(outFile)) {
|
|
1131
|
+
// Index chunks so chunk requests skip the rebuild fallback — once per outDir,
|
|
1132
|
+
// not once per request: the dir name is a content key, so its chunk list is
|
|
1133
|
+
// fixed for as long as it exists.
|
|
1134
|
+
if (!indexedClientDirs.has(outDir)) {
|
|
1135
|
+
await indexClientChunks(outDir);
|
|
1136
|
+
await loadDeferredDynamicSidecar(outDir);
|
|
1137
|
+
indexedClientDirs.add(outDir);
|
|
1138
|
+
}
|
|
1139
|
+
return outFile;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
// Register the promise synchronously (no await between the get above and this
|
|
1143
|
+
// set) so concurrent callers can't both slip past the dedup check.
|
|
1144
|
+
const inner = (async () => {
|
|
1145
|
+
await ensureDir(outDir);
|
|
1146
|
+
const file = await buildClientEntry({ config, route, outDir, dev: true });
|
|
1147
|
+
// Dedup this generation's chunk bytes against the shared store before serving
|
|
1148
|
+
//: identical chunks recur across generations, and this is
|
|
1149
|
+
// the one place all of a generation's output exists before requests read it.
|
|
1150
|
+
await contentAddressDir(outDir, clientChunkStoreDir(config.outPath));
|
|
1151
|
+
await indexClientChunks(outDir);
|
|
1152
|
+
indexedClientDirs.add(outDir);
|
|
1153
|
+
return file;
|
|
1154
|
+
})();
|
|
1155
|
+
let unregister: () => void = () => undefined;
|
|
1156
|
+
const stopSignal = new Promise<never>((_, reject) => {
|
|
1157
|
+
clientBuildStopWaiters.add(reject);
|
|
1158
|
+
unregister = () => void clientBuildStopWaiters.delete(reject);
|
|
1159
|
+
});
|
|
1160
|
+
// If the watchdog wins the race, `inner` may settle later with no listener.
|
|
1161
|
+
inner.catch(() => undefined);
|
|
1162
|
+
const build = Promise.race([inner, stopSignal]).finally(() => {
|
|
1163
|
+
unregister();
|
|
1164
|
+
clientBuilds.delete(outDir);
|
|
1165
|
+
});
|
|
1166
|
+
clientBuilds.set(outDir, build);
|
|
1167
|
+
return build;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
async function maybeDevClientChunk(
|
|
1171
|
+
config: ResolvedConfig,
|
|
1172
|
+
routes: RouteManifestEntry[],
|
|
1173
|
+
pathname: string,
|
|
1174
|
+
) {
|
|
1175
|
+
const chunkMatch = /^\/__pnext\/client\/chunks\/(.+\.js)$/.exec(pathname);
|
|
1176
|
+
if (!chunkMatch?.[1]) return null;
|
|
1177
|
+
const cached = clientChunks.get(chunkMatch[1]);
|
|
1178
|
+
if (cached && existsSync(cached)) {
|
|
1179
|
+
return devChunkResponse(await readFile(cached));
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
for (const route of routes) {
|
|
1183
|
+
if (!route.client && route.clientReferences.length === 0) continue;
|
|
1184
|
+
const cacheKey = await routeClientCacheKey(config, route);
|
|
1185
|
+
const outDir = path.join(config.outPath, 'cache', 'client', cacheKey);
|
|
1186
|
+
await buildDevClient(config, route);
|
|
1187
|
+
const file = path.join(outDir, 'chunks', chunkMatch[1]);
|
|
1188
|
+
if (!isInside(path.join(outDir, 'chunks'), file) || !existsSync(file)) continue;
|
|
1189
|
+
return devChunkResponse(await readFile(file));
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
return null;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
/**
|
|
1196
|
+
* Serve the prebuilt client runtime. It lives outside `config.outPath` on purpose - a cold page is defined
|
|
1197
|
+
* by that directory being wiped, and an artifact wiped with it would be rebuilt on exactly the request it
|
|
1198
|
+
* exists to make cheap - so it needs its own route rather than the built-asset lookup.
|
|
1199
|
+
*/
|
|
1200
|
+
function maybePrebuiltRuntime(config: ResolvedConfig, pathname: string) {
|
|
1201
|
+
const match = /^\/__pnext\/runtime\/([0-9a-f]+)\/(.+\.js)$/.exec(pathname);
|
|
1202
|
+
if (!match?.[1] || !match[2]) return null;
|
|
1203
|
+
const dir = prebuiltRuntimeDir(config, match[1]);
|
|
1204
|
+
const file = path.join(dir, match[2]);
|
|
1205
|
+
if (!isInside(dir, file) || !existsSync(file)) return null;
|
|
1206
|
+
return devChunkResponse(readFileSync(file));
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
/** Serve an extension-supplied standalone chunk by its `/_next/static/chunks/<name>` path. */
|
|
1210
|
+
function maybeStaticClientChunk(config: ResolvedConfig, staticAssetPathname: string) {
|
|
1211
|
+
const match = /^\/_next\/static\/chunks\/([^/]+\.js)$/.exec(staticAssetPathname);
|
|
1212
|
+
if (!match?.[1]) return null;
|
|
1213
|
+
const chunk = getBundlerExtensions()
|
|
1214
|
+
.staticClientChunks(config)
|
|
1215
|
+
.find(item => item.name === match[1]);
|
|
1216
|
+
return chunk ? devChunkResponse(chunk.contents) : null;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
async function indexClientChunks(outDir: string) {
|
|
1220
|
+
const chunksDir = path.join(outDir, 'chunks');
|
|
1221
|
+
if (!existsSync(chunksDir)) return;
|
|
1222
|
+
for (const file of await listFiles(chunksDir)) {
|
|
1223
|
+
if (file.endsWith('.js')) clientChunks.set(path.basename(file), file);
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
async function maybeStaticFile(publicPath: string, pathname: string) {
|
|
1228
|
+
const filePath = path.join(publicPath, pathname.replace(/^\/+/, ''));
|
|
1229
|
+
if (!isInside(publicPath, filePath) || !existsSync(filePath)) return null;
|
|
1230
|
+
const fileStat = await stat(filePath);
|
|
1231
|
+
if (!fileStat.isFile()) return null;
|
|
1232
|
+
return devResponse(await readFile(filePath), contentType(filePath));
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
function getNextStaticAssetPathname(
|
|
1236
|
+
pathname: string,
|
|
1237
|
+
basePath: string | undefined,
|
|
1238
|
+
assetPrefix: string | undefined,
|
|
1239
|
+
) {
|
|
1240
|
+
if (pathname.startsWith('/_next/static/')) return pathname;
|
|
1241
|
+
const normalizedBasePath = normalizePathPrefix(basePath);
|
|
1242
|
+
if (normalizedBasePath && pathname.startsWith(`${normalizedBasePath}/_next/static/`)) {
|
|
1243
|
+
return pathname.slice(normalizedBasePath.length);
|
|
1244
|
+
}
|
|
1245
|
+
const normalizedAssetPrefix = normalizePathPrefix(assetPrefix);
|
|
1246
|
+
if (normalizedAssetPrefix && pathname.startsWith(`${normalizedAssetPrefix}/_next/static/`)) {
|
|
1247
|
+
return pathname.slice(normalizedAssetPrefix.length);
|
|
1248
|
+
}
|
|
1249
|
+
return null;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
function normalizePathPrefix(prefix: string | undefined): string | null {
|
|
1253
|
+
if (!prefix) return null;
|
|
1254
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(prefix)) return null;
|
|
1255
|
+
const normalized = `/${prefix.replace(/^\/+|\/+$/g, '')}`;
|
|
1256
|
+
if (normalized === '/') return null;
|
|
1257
|
+
return normalized;
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
function findClientReferenceCss(routes: RouteManifestEntry[], id: string) {
|
|
1261
|
+
return routes
|
|
1262
|
+
.flatMap(route => route.clientReferences)
|
|
1263
|
+
.find(reference => reference.id === id && reference.cssImports?.length);
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
async function maybeBuiltAsset(
|
|
1267
|
+
config: ResolvedConfig,
|
|
1268
|
+
routes: RouteManifestEntry[],
|
|
1269
|
+
pathname: string,
|
|
1270
|
+
) {
|
|
1271
|
+
if (!pathname.startsWith('/assets/')) return null;
|
|
1272
|
+
if (pathname === '/assets/global.css') {
|
|
1273
|
+
await buildDevAsset(pathname, () => buildGlobalCss(config, { dev: true }));
|
|
1274
|
+
} else {
|
|
1275
|
+
const cssMatch = /^\/assets\/(.+)\.css$/.exec(pathname);
|
|
1276
|
+
const route = cssMatch?.[1] ? routes.find(item => item.id === cssMatch[1]) : undefined;
|
|
1277
|
+
// Island CSS is only ever requested for a route that already rendered, so
|
|
1278
|
+
// the resolved routes are searched first — scanning the rest would compile
|
|
1279
|
+
// the whole app to answer one stylesheet request.
|
|
1280
|
+
const reference =
|
|
1281
|
+
!route && cssMatch?.[1]
|
|
1282
|
+
? findClientReferenceCss(routes.filter(routeFactsResolved), cssMatch[1]) ??
|
|
1283
|
+
findClientReferenceCss(routes, cssMatch[1])
|
|
1284
|
+
: undefined;
|
|
1285
|
+
if (route) {
|
|
1286
|
+
await buildDevAsset(pathname, () => buildRouteCss(config, route, { dev: true }));
|
|
1287
|
+
} else if (reference) {
|
|
1288
|
+
await buildDevAsset(pathname, () =>
|
|
1289
|
+
buildClientReferenceCss(config, reference, { dev: true }),
|
|
1290
|
+
);
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
const outPath = config.outPath;
|
|
1295
|
+
const filePath = path.join(outPath, 'cache', pathname.replace(/^\/+/, ''));
|
|
1296
|
+
if (!isInside(path.join(outPath, 'cache'), filePath) || !existsSync(filePath)) return null;
|
|
1297
|
+
const fileStat = await stat(filePath);
|
|
1298
|
+
if (!fileStat.isFile()) return null;
|
|
1299
|
+
return new Response(await readFile(filePath), {
|
|
1300
|
+
headers: { 'content-type': contentType(filePath) },
|
|
1301
|
+
});
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
function buildDevAsset(key: string, build: () => Promise<unknown>) {
|
|
1305
|
+
const existing = assetBuilds.get(key);
|
|
1306
|
+
if (existing) return existing;
|
|
1307
|
+
// Build each asset once per dev version (reload() clears it); drop on failure to allow retry.
|
|
1308
|
+
const next = build().catch(error => {
|
|
1309
|
+
assetBuilds.delete(key);
|
|
1310
|
+
throw error;
|
|
1311
|
+
});
|
|
1312
|
+
assetBuilds.set(key, next);
|
|
1313
|
+
return next;
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
function maybeDevPagePrefetchResponse(
|
|
1317
|
+
routes: RouteManifestEntry[],
|
|
1318
|
+
pathname: string,
|
|
1319
|
+
request: Request,
|
|
1320
|
+
) {
|
|
1321
|
+
if (!isDevPagePrefetchRequest(request)) return null;
|
|
1322
|
+
const matched = matchRoute(routes, pathname);
|
|
1323
|
+
if (matched?.route.kind !== 'page') return null;
|
|
1324
|
+
return devPagePrefetchResponse();
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
export function isDevPagePrefetchRequest(request: Request) {
|
|
1328
|
+
if (request.method.toUpperCase() !== 'GET') return false;
|
|
1329
|
+
return (
|
|
1330
|
+
headerHasPrefetchToken(request.headers.get('sec-purpose')) ||
|
|
1331
|
+
headerHasPrefetchToken(request.headers.get('purpose')) ||
|
|
1332
|
+
getRouterProtocolExtensions()
|
|
1333
|
+
.prefetchRequestHeaders()
|
|
1334
|
+
.some(header => request.headers.get(header) === '1')
|
|
1335
|
+
);
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
function headerHasPrefetchToken(value: string | null) {
|
|
1339
|
+
return Boolean(value?.split(/[;,\s]+/).some(token => token.toLowerCase() === 'prefetch'));
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
function devPagePrefetchResponse() {
|
|
1343
|
+
return new Response(null, {
|
|
1344
|
+
status: 204,
|
|
1345
|
+
headers: {
|
|
1346
|
+
'cache-control': 'no-store',
|
|
1347
|
+
'x-pnext-dev-prefetch': 'skipped',
|
|
1348
|
+
},
|
|
1349
|
+
});
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
interface PendingDevPageLoadLog {
|
|
1353
|
+
method: string;
|
|
1354
|
+
pathname: string;
|
|
1355
|
+
route: string;
|
|
1356
|
+
start: number;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
interface DevPageLoadLog {
|
|
1360
|
+
method: string;
|
|
1361
|
+
pathname: string;
|
|
1362
|
+
route: string;
|
|
1363
|
+
status: number;
|
|
1364
|
+
durationMs: number;
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
function pendingDevPageLoadLog(
|
|
1368
|
+
routes: RouteManifestEntry[],
|
|
1369
|
+
request: Request,
|
|
1370
|
+
url: URL,
|
|
1371
|
+
start = performance.now(),
|
|
1372
|
+
): PendingDevPageLoadLog | undefined {
|
|
1373
|
+
const matched = matchRoute(routes, url.pathname);
|
|
1374
|
+
if (matched?.route.kind !== 'page') return undefined;
|
|
1375
|
+
return {
|
|
1376
|
+
method: request.method,
|
|
1377
|
+
pathname: url.pathname,
|
|
1378
|
+
route: matched.route.route,
|
|
1379
|
+
start,
|
|
1380
|
+
};
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
function logDevPageResponse(pending: PendingDevPageLoadLog | undefined, response: Response) {
|
|
1384
|
+
if (!pending) return response;
|
|
1385
|
+
logDevPageLoad({
|
|
1386
|
+
method: pending.method,
|
|
1387
|
+
pathname: pending.pathname,
|
|
1388
|
+
route: pending.route,
|
|
1389
|
+
status: response.status,
|
|
1390
|
+
durationMs: performance.now() - pending.start,
|
|
1391
|
+
});
|
|
1392
|
+
return response;
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
function logDevPageError(pending: PendingDevPageLoadLog | undefined) {
|
|
1396
|
+
if (!pending) return;
|
|
1397
|
+
logDevPageLoad({
|
|
1398
|
+
method: pending.method,
|
|
1399
|
+
pathname: pending.pathname,
|
|
1400
|
+
route: pending.route,
|
|
1401
|
+
status: 500,
|
|
1402
|
+
durationMs: performance.now() - pending.start,
|
|
1403
|
+
});
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
function logDevPageAbort(pending: PendingDevPageLoadLog | undefined) {
|
|
1407
|
+
if (!pending) return;
|
|
1408
|
+
logDevPageLoad({
|
|
1409
|
+
method: pending.method,
|
|
1410
|
+
pathname: pending.pathname,
|
|
1411
|
+
route: pending.route,
|
|
1412
|
+
status: 499,
|
|
1413
|
+
durationMs: performance.now() - pending.start,
|
|
1414
|
+
});
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
// Print a "Compiling" banner the first time a route is hit in this process,
|
|
1418
|
+
// so the several-second cold esbuild + Tailwind build isn't a silent stall. The
|
|
1419
|
+
// timed `page GET ... in Xs` line follows once the response resolves.
|
|
1420
|
+
function noteDevCompileStart(route: RouteManifestEntry) {
|
|
1421
|
+
if (compiledRoutes.has(route.id)) return;
|
|
1422
|
+
compiledRoutes.add(route.id);
|
|
1423
|
+
console.log(`${cyan('○')} ${dim('Compiling')} ${cyan(route.route)} ${dim('...')}`);
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
// Completion line for the warm compile, since no request log fires before the
|
|
1427
|
+
// browser opens. Mirrors the `page ... in Xs` timing style.
|
|
1428
|
+
function noteDevCompileDone(route: RouteManifestEntry, durationMs: number) {
|
|
1429
|
+
console.log(
|
|
1430
|
+
`${green('✓')} ${dim('Compiled')} ${cyan(route.route)} ${dim('in')} ${durationLabel(durationMs)}`,
|
|
1431
|
+
);
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
function logDevPageLoad(entry: DevPageLoadLog) {
|
|
1435
|
+
console.log(formatDevPageLoadLog(entry));
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
export function formatDevPageLoadLog(entry: DevPageLoadLog) {
|
|
1439
|
+
const route = entry.route === entry.pathname ? '' : dim(`(${entry.route})`);
|
|
1440
|
+
return [
|
|
1441
|
+
dim('page'),
|
|
1442
|
+
dim(entry.method),
|
|
1443
|
+
cyan(entry.pathname),
|
|
1444
|
+
route,
|
|
1445
|
+
statusLabel(entry.status),
|
|
1446
|
+
dim('in'),
|
|
1447
|
+
durationLabel(entry.durationMs),
|
|
1448
|
+
]
|
|
1449
|
+
.filter(Boolean)
|
|
1450
|
+
.join(' ');
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
function statusLabel(status: number) {
|
|
1454
|
+
const label = String(status);
|
|
1455
|
+
if (status >= 500) return red(label);
|
|
1456
|
+
if (status >= 400) return yellow(label);
|
|
1457
|
+
if (status >= 300) return cyan(label);
|
|
1458
|
+
return green(label);
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
function durationLabel(durationMs: number) {
|
|
1462
|
+
const label = formatDuration(durationMs);
|
|
1463
|
+
if (durationMs >= 1000) return yellow(label);
|
|
1464
|
+
if (durationMs >= 250) return cyan(label);
|
|
1465
|
+
return green(label);
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
function formatDuration(durationMs: number) {
|
|
1469
|
+
const ms = Math.max(0, durationMs);
|
|
1470
|
+
if (ms < 10) return `${ms.toFixed(1)}ms`;
|
|
1471
|
+
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
1472
|
+
return `${(ms / 1000).toFixed(ms < 10000 ? 2 : 1)}s`;
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
function dim(value: string) {
|
|
1476
|
+
return color(2, 22, value);
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
function red(value: string) {
|
|
1480
|
+
return color(31, 39, value);
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
function yellow(value: string) {
|
|
1484
|
+
return color(33, 39, value);
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
function green(value: string) {
|
|
1488
|
+
return color(32, 39, value);
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
function cyan(value: string) {
|
|
1492
|
+
return color(36, 39, value);
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
function color(open: number, close: number, value: string) {
|
|
1496
|
+
if (!process.stdout.isTTY) return value;
|
|
1497
|
+
return `\x1b[${open}m${value}\x1b[${close}m`;
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
function devResponse(body: BodyInit, contentType: string) {
|
|
1501
|
+
return new Response(body, {
|
|
1502
|
+
headers: {
|
|
1503
|
+
'content-type': contentType,
|
|
1504
|
+
'cache-control': 'no-store',
|
|
1505
|
+
},
|
|
1506
|
+
});
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
// Client chunk filenames are content-hashed (chunks/[name]-[hash].js), so they
|
|
1510
|
+
// are safe to cache permanently: a source change yields a new hash (new URL).
|
|
1511
|
+
// The page entry stays `no-store`, so a dev reload re-fetches only the entry and
|
|
1512
|
+
// any changed chunks instead of the whole 7MB+ graph on every navigation.
|
|
1513
|
+
function devChunkResponse(body: BodyInit) {
|
|
1514
|
+
return new Response(body, {
|
|
1515
|
+
headers: {
|
|
1516
|
+
'content-type': 'text/javascript; charset=utf-8',
|
|
1517
|
+
'cache-control': 'public, max-age=31536000, immutable',
|
|
1518
|
+
},
|
|
1519
|
+
});
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
function isInside(root: string, file: string) {
|
|
1523
|
+
const relative = path.relative(root, file);
|
|
1524
|
+
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
interface DevWatcher {
|
|
1528
|
+
rootsKey: string;
|
|
1529
|
+
stop(): void;
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
function refreshWatcher(
|
|
1533
|
+
config: ResolvedConfig,
|
|
1534
|
+
routes: RouteManifestEntry[],
|
|
1535
|
+
current: DevWatcher | undefined,
|
|
1536
|
+
onChange: (change: DevChange) => Promise<void>,
|
|
1537
|
+
) {
|
|
1538
|
+
const roots = devWatchRoots(config, routes);
|
|
1539
|
+
const rootsKey = roots.join('\0');
|
|
1540
|
+
if (current?.rootsKey === rootsKey) return current;
|
|
1541
|
+
current?.stop();
|
|
1542
|
+
return watchRoots(roots, rootsKey, onChange);
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
function devWatchRoots(config: ResolvedConfig, routes: RouteManifestEntry[]) {
|
|
1546
|
+
const packageRoots = workspacePackageRoots(config.workspaceRoot);
|
|
1547
|
+
const roots = new Set([config.appPath]);
|
|
1548
|
+
|
|
1549
|
+
// Reading sourceFiles off an unresolved route would scan the whole app at
|
|
1550
|
+
// boot — exactly what the deferred route table exists to avoid.
|
|
1551
|
+
for (const route of routes.filter(routeFactsResolved)) {
|
|
1552
|
+
for (const file of route.sourceFiles) {
|
|
1553
|
+
if (isInside(config.appPath, file)) continue;
|
|
1554
|
+
const packageRoot = packageRoots.find(root => isInside(root, file));
|
|
1555
|
+
if (packageRoot) roots.add(packageWatchRoot(packageRoot, file));
|
|
1556
|
+
else {
|
|
1557
|
+
// An app that is not a declared workspace member has no package root,
|
|
1558
|
+
// which used to leave its own components/, lib/ and styles/ unwatched:
|
|
1559
|
+
// the server served fresh content but the open page was never told.
|
|
1560
|
+
const projectRoot = projectWatchRoot(config.root, file);
|
|
1561
|
+
if (projectRoot) roots.add(projectRoot);
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
return [...roots].sort();
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
/**
|
|
1570
|
+
* The top-level directory under the project root that `file` lives in. Watching
|
|
1571
|
+
* the root itself would pull in node_modules and .pnext, so dependencies, dot
|
|
1572
|
+
* directories and files sitting directly in the root are not covered.
|
|
1573
|
+
*/
|
|
1574
|
+
function projectWatchRoot(root: string, file: string) {
|
|
1575
|
+
if (!isInside(root, file)) return undefined;
|
|
1576
|
+
const [segment, ...rest] = path.relative(root, file).split(path.sep);
|
|
1577
|
+
if (!segment || rest.length === 0) return undefined;
|
|
1578
|
+
if (segment === 'node_modules' || segment.startsWith('.')) return undefined;
|
|
1579
|
+
return path.join(root, segment);
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
function packageWatchRoot(packageRoot: string, file: string) {
|
|
1583
|
+
const relative = path.relative(packageRoot, file);
|
|
1584
|
+
const [firstSegment] = relative.split(path.sep);
|
|
1585
|
+
if (!firstSegment || firstSegment === '..' || path.isAbsolute(relative)) return packageRoot;
|
|
1586
|
+
return path.join(packageRoot, firstSegment);
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
/**
|
|
1590
|
+
* Can this burst be answered by re-fetching stylesheets alone? Only if every
|
|
1591
|
+
* touched file is a plain stylesheet that still exists: a CSS *module* feeds
|
|
1592
|
+
* class names into JS bundles, and a file that is gone (or a rename) can move
|
|
1593
|
+
* the route table's CSS imports, so both keep the full reload.
|
|
1594
|
+
*/
|
|
1595
|
+
function isCssOnlyChange(files: string[]) {
|
|
1596
|
+
return (
|
|
1597
|
+
files.length > 0 &&
|
|
1598
|
+
files.every(file => isCssFile(file) && !/\.module\.[^.]+$/.test(file) && existsSync(file))
|
|
1599
|
+
);
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
/** What a watcher burst touched. */
|
|
1603
|
+
interface DevChange {
|
|
1604
|
+
files: string[];
|
|
1605
|
+
/** At least one event was a create/delete/rename (an atomic save is one too). */
|
|
1606
|
+
renamed: boolean;
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
function watchRoots(
|
|
1610
|
+
roots: string[],
|
|
1611
|
+
rootsKey: string,
|
|
1612
|
+
onChange: (change: DevChange) => Promise<void>,
|
|
1613
|
+
): DevWatcher {
|
|
1614
|
+
const controller = new AbortController();
|
|
1615
|
+
let pending: Timer | undefined;
|
|
1616
|
+
let running = false;
|
|
1617
|
+
let batch: DevChange = { files: [], renamed: false };
|
|
1618
|
+
|
|
1619
|
+
async function flush() {
|
|
1620
|
+
if (running || batch.files.length === 0) return;
|
|
1621
|
+
const change = batch;
|
|
1622
|
+
batch = { files: [], renamed: false };
|
|
1623
|
+
running = true;
|
|
1624
|
+
try {
|
|
1625
|
+
await onChange(change);
|
|
1626
|
+
} finally {
|
|
1627
|
+
running = false;
|
|
1628
|
+
}
|
|
1629
|
+
if (batch.files.length > 0) await flush();
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
function schedule(file: string, renamed: boolean) {
|
|
1633
|
+
batch.files.push(file);
|
|
1634
|
+
batch.renamed ||= renamed;
|
|
1635
|
+
if (pending) clearTimeout(pending);
|
|
1636
|
+
// The coalescing window exists so one save that lands as several events costs one recompile. A
|
|
1637
|
+
// stylesheet-only burst has no recompile to coalesce, so it waits a tenth as long; a JS file
|
|
1638
|
+
// arriving late just triggers the ordinary reload behind it.
|
|
1639
|
+
pending = setTimeout(
|
|
1640
|
+
() => {
|
|
1641
|
+
pending = undefined;
|
|
1642
|
+
void flush();
|
|
1643
|
+
},
|
|
1644
|
+
isCssOnlyChange(batch.files) ? 4 : 40,
|
|
1645
|
+
);
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
for (const root of roots) {
|
|
1649
|
+
void watchRoot(root, controller.signal, schedule);
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
return {
|
|
1653
|
+
rootsKey,
|
|
1654
|
+
stop() {
|
|
1655
|
+
if (pending) clearTimeout(pending);
|
|
1656
|
+
controller.abort();
|
|
1657
|
+
},
|
|
1658
|
+
};
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
async function watchRoot(
|
|
1662
|
+
root: string,
|
|
1663
|
+
signal: AbortSignal,
|
|
1664
|
+
onChange: (file: string, structural: boolean) => void,
|
|
1665
|
+
) {
|
|
1666
|
+
try {
|
|
1667
|
+
const watcher = watch(root, { recursive: true, signal });
|
|
1668
|
+
for await (const event of watcher) {
|
|
1669
|
+
// No filename (rare, platform-dependent) means we cannot scope the
|
|
1670
|
+
// invalidation: treat it as structural so everything is re-derived.
|
|
1671
|
+
if (!event.filename) onChange(root, true);
|
|
1672
|
+
else onChange(path.resolve(root, event.filename), event.eventType === 'rename');
|
|
1673
|
+
}
|
|
1674
|
+
} catch (error) {
|
|
1675
|
+
if (signal.aborted || (error instanceof Error && error.name === 'AbortError')) return;
|
|
1676
|
+
// Some platforms do not support recursive watch. Dev still works without live reload.
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
function eventStream(clients: Set<DevEventClient>) {
|
|
1681
|
+
let client: DevEventClient | undefined;
|
|
1682
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
1683
|
+
start(controller) {
|
|
1684
|
+
client = { controller };
|
|
1685
|
+
clients.add(client);
|
|
1686
|
+
controller.enqueue(new TextEncoder().encode('event: ready\ndata: ok\n\n'));
|
|
1687
|
+
},
|
|
1688
|
+
cancel() {
|
|
1689
|
+
if (client) clients.delete(client);
|
|
1690
|
+
},
|
|
1691
|
+
});
|
|
1692
|
+
return new Response(stream, {
|
|
1693
|
+
headers: {
|
|
1694
|
+
'content-type': 'text/event-stream',
|
|
1695
|
+
'cache-control': 'no-cache',
|
|
1696
|
+
connection: 'keep-alive',
|
|
1697
|
+
},
|
|
1698
|
+
});
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
function broadcast(clients: Set<DevEventClient>, event: string) {
|
|
1702
|
+
const payload = new TextEncoder().encode(`event: ${event}\ndata: ${Date.now()}\n\n`);
|
|
1703
|
+
for (const client of clients) {
|
|
1704
|
+
try {
|
|
1705
|
+
client.controller.enqueue(payload);
|
|
1706
|
+
} catch {
|
|
1707
|
+
clients.delete(client);
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
}
|