@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,2136 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
3
|
+
import { copyFile, mkdir, readFile, rename, stat } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import {
|
|
6
|
+
build,
|
|
7
|
+
type BuildOptions,
|
|
8
|
+
type Metafile,
|
|
9
|
+
type OnResolveArgs,
|
|
10
|
+
type OnResolveOptions,
|
|
11
|
+
type OnResolveResult,
|
|
12
|
+
type Plugin,
|
|
13
|
+
type PluginBuild,
|
|
14
|
+
type ResolveResult,
|
|
15
|
+
} from 'esbuild';
|
|
16
|
+
import { foldInitialChunks } from './chunk-fold';
|
|
17
|
+
import { clientEntryName } from './paths';
|
|
18
|
+
import { clientProfile } from './profile';
|
|
19
|
+
import { publicEnvDefines, type ResolvedConfig } from '../config';
|
|
20
|
+
import { cssModuleClientPlugin } from '../css';
|
|
21
|
+
import { withAssetPrefix } from '../css/build';
|
|
22
|
+
import { devDynamicSplitEnabled, rewriteDeferredDynamicImports, rewriteLiteralDynamicCalls } from '../dynamic/source';
|
|
23
|
+
import { scanFacts } from '../resolve/scan-facts';
|
|
24
|
+
import { readSourceText } from '../resolve/source-text';
|
|
25
|
+
import { ensureDir, listFiles, readText, writeText } from '../utils/fs';
|
|
26
|
+
import { escapeRegex } from '../utils/source';
|
|
27
|
+
import {
|
|
28
|
+
getExternalPackagePolicy,
|
|
29
|
+
resolveImport,
|
|
30
|
+
resolveLinkedPackageSpecifier,
|
|
31
|
+
} from '../resolve/imports';
|
|
32
|
+
import {
|
|
33
|
+
CLIENT_RUNTIME_MODULE,
|
|
34
|
+
DYN_SHARED_GLOBAL,
|
|
35
|
+
dynSharedSpecifiers,
|
|
36
|
+
clientEntrySource,
|
|
37
|
+
clientRuntimeFacts,
|
|
38
|
+
clientRuntimeSource,
|
|
39
|
+
type ClientRuntimeFacts,
|
|
40
|
+
} from './entry';
|
|
41
|
+
import {
|
|
42
|
+
ensurePrebuiltRuntime,
|
|
43
|
+
prebuiltAssets,
|
|
44
|
+
prebuiltExternalPlugin,
|
|
45
|
+
prebuiltModuleUrl,
|
|
46
|
+
prebuiltRuntimeKey,
|
|
47
|
+
prebuiltSpecifierFilter,
|
|
48
|
+
settlePrebuiltRuntime,
|
|
49
|
+
type PrebuiltRuntime,
|
|
50
|
+
} from './prebuilt';
|
|
51
|
+
import { findLayouts } from '../routing/routes';
|
|
52
|
+
import { clientReferenceId, ssrClientReference, type ClientReference } from './reference';
|
|
53
|
+
import { shouldReactCompile, transformReactCompiler } from './react-compiler';
|
|
54
|
+
import { createVerboseLogger } from '../utils/verbose';
|
|
55
|
+
import { dim as dimText } from '../utils/ansi';
|
|
56
|
+
import type { RouteManifestEntry } from '../types';
|
|
57
|
+
import {
|
|
58
|
+
applyClientSourceAsyncPreTransforms,
|
|
59
|
+
applyClientSourceTransforms,
|
|
60
|
+
getAssetExtensions,
|
|
61
|
+
getBundlerExtensions,
|
|
62
|
+
getCompatModeExtensions,
|
|
63
|
+
getImportAliasExtensions,
|
|
64
|
+
hasClientSourceAsyncPreTransforms,
|
|
65
|
+
type CompatModeExtensions,
|
|
66
|
+
} from '../extensions';
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Write the standalone static chunks a bundler extension supplies (compat: the
|
|
70
|
+
* no-module polyfills chunk) verbatim into the client `chunks/` dir. These sit
|
|
71
|
+
* outside esbuild's entry graph, so they are emitted here rather than through a
|
|
72
|
+
* build. No-op for pure-core apps (the seam returns none).
|
|
73
|
+
*/
|
|
74
|
+
export async function emitStaticClientChunks(config: ResolvedConfig, outDir: string) {
|
|
75
|
+
const chunks = getBundlerExtensions().staticClientChunks(config);
|
|
76
|
+
if (chunks.length === 0) return;
|
|
77
|
+
const chunksDir = path.join(outDir, 'chunks');
|
|
78
|
+
await ensureDir(chunksDir);
|
|
79
|
+
for (const chunk of chunks) {
|
|
80
|
+
await writeText(path.join(chunksDir, chunk.name), chunk.contents);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface ClientBuildOptions {
|
|
85
|
+
config: ResolvedConfig;
|
|
86
|
+
route: RouteManifestEntry;
|
|
87
|
+
outDir: string;
|
|
88
|
+
dev?: boolean;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface ClientBatchBuildOptions {
|
|
92
|
+
config: ResolvedConfig;
|
|
93
|
+
routes: RouteManifestEntry[];
|
|
94
|
+
outDir: string;
|
|
95
|
+
verbose?: boolean;
|
|
96
|
+
/**
|
|
97
|
+
* The app defines at least one server action (the build's action manifest). Unset means "unknown" -
|
|
98
|
+
* dev, where the manifest is discovered per request - and the action runtime is emitted, since only
|
|
99
|
+
* prod bundles are budgeted.
|
|
100
|
+
*/
|
|
101
|
+
hasServerActions?: boolean;
|
|
102
|
+
/**
|
|
103
|
+
* A pipeline already warmed by the caller (the build starts one under the
|
|
104
|
+
* route-fact scan). Omitted — dev, tests — the stage opens its own.
|
|
105
|
+
*/
|
|
106
|
+
pipeline?: ClientSourcePipeline;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const virtualEntryNamespace = 'pnext-client-entry';
|
|
110
|
+
|
|
111
|
+
function nextCompatEnabled(config: ResolvedConfig) {
|
|
112
|
+
return getCompatModeExtensions().nextEnabled(config);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Convention-file lookups (error / not-found / global-error / layout) are directory walks with four
|
|
117
|
+
* `existsSync` probes per level, and the entry builder asks the same questions of the same
|
|
118
|
+
* directories once per route. Memoized for the duration of one build and cleared at each entry
|
|
119
|
+
* point, so a dev rebuild re-stats after a file is added or removed.
|
|
120
|
+
*/
|
|
121
|
+
const conventionCache = new Map<string, unknown>();
|
|
122
|
+
|
|
123
|
+
function memoConvention<T>(key: string, compute: () => T): T {
|
|
124
|
+
if (conventionCache.has(key)) return conventionCache.get(key) as T;
|
|
125
|
+
const value = compute();
|
|
126
|
+
conventionCache.set(key, value);
|
|
127
|
+
return value;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Whether the action client runtime is emitted at all. Two app facts feed it:
|
|
132
|
+
* the build's own action manifest, and the scan's `actions`/`form` client-entry
|
|
133
|
+
* reasons (a `<form action={fn}>` or next/form the manifest cannot see). Either
|
|
134
|
+
* one unset (dev) keeps the runtime, so only a provably action-free prod app
|
|
135
|
+
* drops it.
|
|
136
|
+
*/
|
|
137
|
+
function appUsesActions(routes: RouteManifestEntry[], hasServerActions?: boolean) {
|
|
138
|
+
if (hasServerActions !== false) return true;
|
|
139
|
+
return routes.some(route =>
|
|
140
|
+
route.clientEntryReasons?.some(reason => reason === 'actions' || reason === 'form'),
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Whether this route has an `error.*` or `not-found.*` for the in-tree ClientErrorBoundary to
|
|
146
|
+
* render. Without one the boundary would only re-throw, and the deferred window last resort already
|
|
147
|
+
* owns that path - so the whole boundary cluster stays off first paint.
|
|
148
|
+
*/
|
|
149
|
+
function hasRouteErrorBoundary(config: ResolvedConfig, route: RouteManifestEntry): boolean {
|
|
150
|
+
return (
|
|
151
|
+
Boolean(routeErrorFile(config, route) ?? routeNotFoundFile(config, route)) ||
|
|
152
|
+
routeThrowsClientControlFlow(route)
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* A `'use client'` module in the route graph calls notFound()/forbidden()/unauthorized(). The throw
|
|
158
|
+
* happens during hydration, so the boundary - which renders its built-in 404 with no not-found.*
|
|
159
|
+
* file - has to be in the tree to catch it.
|
|
160
|
+
*/
|
|
161
|
+
function routeThrowsClientControlFlow(route: RouteManifestEntry): boolean {
|
|
162
|
+
return route.clientEntryReasons?.includes('control-flow') === true;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function routeErrorFile(config: ResolvedConfig, route: RouteManifestEntry): string | undefined {
|
|
166
|
+
return routeConventionFile(config, route, 'error');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function routeNotFoundFile(config: ResolvedConfig, route: RouteManifestEntry): string | undefined {
|
|
170
|
+
return routeConventionFile(config, route, 'not-found');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Nearest segment convention file for a route: walked from the page's own
|
|
174
|
+
// segment up to the app root.
|
|
175
|
+
function routeConventionFile(
|
|
176
|
+
config: ResolvedConfig,
|
|
177
|
+
route: RouteManifestEntry,
|
|
178
|
+
name: string,
|
|
179
|
+
): string | undefined {
|
|
180
|
+
if (!nextCompatEnabled(config)) return undefined;
|
|
181
|
+
const appPath = path.resolve(config.appPath);
|
|
182
|
+
const start = path.dirname(path.resolve(route.file));
|
|
183
|
+
const relative = path.relative(appPath, start);
|
|
184
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) return undefined;
|
|
185
|
+
// Memoized per directory, not per route: sibling routes walk the same chain,
|
|
186
|
+
// and a parent's answer is the tail of every child's walk.
|
|
187
|
+
return memoConvention(`${name}\0${start}`, () => {
|
|
188
|
+
let dir = start;
|
|
189
|
+
for (;;) {
|
|
190
|
+
for (const ext of ['tsx', 'ts', 'jsx', 'js']) {
|
|
191
|
+
const candidate = path.join(dir, `${name}.${ext}`);
|
|
192
|
+
if (existsSync(candidate)) return candidate;
|
|
193
|
+
}
|
|
194
|
+
if (dir === appPath) return undefined;
|
|
195
|
+
const parent = path.dirname(dir);
|
|
196
|
+
if (parent === dir) return undefined;
|
|
197
|
+
dir = parent;
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// The app-root global-error.* convention file (single per app, unlike error.*
|
|
203
|
+
// which is per-segment): threaded into the client entry so installClientErrors
|
|
204
|
+
// can mount a user global-error component for client throws that escape every
|
|
205
|
+
// route error boundary. Absent → the built-in fallback document is used.
|
|
206
|
+
function globalErrorFile(config: ResolvedConfig): string | undefined {
|
|
207
|
+
if (!nextCompatEnabled(config)) return undefined;
|
|
208
|
+
const appPath = path.resolve(config.appPath);
|
|
209
|
+
return memoConvention(`global-error\0${appPath}`, () => {
|
|
210
|
+
for (const ext of ['tsx', 'ts', 'jsx', 'js']) {
|
|
211
|
+
const candidate = path.join(appPath, `global-error.${ext}`);
|
|
212
|
+
if (existsSync(candidate)) return candidate;
|
|
213
|
+
}
|
|
214
|
+
return undefined;
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Whether the route's ROOT layout is itself a `'use client'` component. Only
|
|
220
|
+
* then does the client entry re-render the whole document body through that
|
|
221
|
+
* layout at hydration (the client-shell path). Mirrors the renderer's
|
|
222
|
+
* `rootIsClient` check (render/renderer.ts) so the two stay in lock-step: the
|
|
223
|
+
* renderer marks the root layout's slots with `data-pnext-slot` exactly when
|
|
224
|
+
* this is true, and the shell path depends on those markers.
|
|
225
|
+
*/
|
|
226
|
+
function hasClientRootLayout(config: ResolvedConfig, route: RouteManifestEntry): boolean {
|
|
227
|
+
if (route.client) return false;
|
|
228
|
+
const rootLayout = shellLayoutOrder(config, route)[0];
|
|
229
|
+
if (!rootLayout) return false;
|
|
230
|
+
return route.clientReferences.some(reference => reference.file === rootLayout);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Layout files outermost-first, from the layout hierarchy - NOT from `route.clientReferences`, whose
|
|
235
|
+
* path-hash sort can put a non-layout reference ahead of the root layout and make the shell drop its
|
|
236
|
+
* subtree.
|
|
237
|
+
*/
|
|
238
|
+
function shellLayoutOrder(config: ResolvedConfig, route: RouteManifestEntry): string[] {
|
|
239
|
+
// Asked three times per route (shell order, client-root check, runtime facts).
|
|
240
|
+
return memoConvention(`layouts\0${config.appPath}\0${route.file}`, () =>
|
|
241
|
+
findLayouts(config.appPath, route.file),
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Where the artifact lives. In dev it must sit OUTSIDE `config.outPath`: a cold
|
|
247
|
+
* page is defined by that directory being wiped, and an artifact wiped with it
|
|
248
|
+
* would be rebuilt on exactly the request it exists to make cheap. In prod it
|
|
249
|
+
* ships beside the entries, and entries reach it by a RELATIVE url so any
|
|
250
|
+
* basePath/assetPrefix applies to it for free.
|
|
251
|
+
*/
|
|
252
|
+
function prebuiltLocation(config: ResolvedConfig, outDir: string, key: string, dev: boolean) {
|
|
253
|
+
if (dev) {
|
|
254
|
+
return {
|
|
255
|
+
dir: prebuiltRuntimeDir(config, key),
|
|
256
|
+
publicPath: `/__pnext/runtime/${key}`,
|
|
257
|
+
assetPath: `__pnext/runtime/${key}`,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
// The SERVED url, not a relative one: chunks sit a directory below the
|
|
261
|
+
// entries, so no single relative path is correct from both — and esbuild
|
|
262
|
+
// writes an external path through verbatim, the same string everywhere.
|
|
263
|
+
const served = nextCompatEnabled(config)
|
|
264
|
+
? `/_next/static/rt/${key}`
|
|
265
|
+
: `/assets/rt/${key}`;
|
|
266
|
+
return {
|
|
267
|
+
dir: path.join(outDir, 'rt', key),
|
|
268
|
+
publicPath: withAssetPrefix(config, served),
|
|
269
|
+
assetPath: `assets/rt/${key}`,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Where the dev server reads the artifact back from. */
|
|
274
|
+
export function prebuiltRuntimeDir(config: ResolvedConfig, key: string) {
|
|
275
|
+
return path.join(config.root, 'node_modules', '.cache', 'pnext', 'client-runtime', key);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Opt-in, not default: the prebuild halves a cold page's client preload stage but costs bytes, and
|
|
280
|
+
* per-surface keying (`surfaceGroups`) does not recover them - the artifact's re-export boundary
|
|
281
|
+
* adds gz per page and pushes the core "all features used" ceiling over its cell. Keying only pays
|
|
282
|
+
* when routes demand genuinely different surfaces.
|
|
283
|
+
* `=1` to enable, `PNEXT_PREBUILT_GROUPS=0` for the shared-artifact arm.
|
|
284
|
+
*/
|
|
285
|
+
function prebuiltEnabled() {
|
|
286
|
+
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
287
|
+
return process.env.PNEXT_PREBUILT_RUNTIME === '1';
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* The framework surface one route's generated entry demands, as a string. A pure function of the
|
|
292
|
+
* compat facts `routerImportSource` emits its imports from and of the route's own
|
|
293
|
+
* `ClientRuntimeFacts` - the two records that decide every framework import an entry makes. Error /
|
|
294
|
+
* not-found files enter as booleans, not paths: two routes with different `error.tsx` files import
|
|
295
|
+
* the same framework names.
|
|
296
|
+
*/
|
|
297
|
+
function surfaceSignature(config: ResolvedConfig, route: RouteManifestEntry, actions: boolean) {
|
|
298
|
+
const nextCompat = nextCompatEnabled(config);
|
|
299
|
+
const facts = clientRuntimeFacts(
|
|
300
|
+
[
|
|
301
|
+
{
|
|
302
|
+
route,
|
|
303
|
+
shell: hasClientRootLayout(config, route),
|
|
304
|
+
boundary: hasRouteErrorBoundary(config, route),
|
|
305
|
+
notFound: Boolean(routeNotFoundFile(config, route)),
|
|
306
|
+
},
|
|
307
|
+
],
|
|
308
|
+
nextCompat,
|
|
309
|
+
);
|
|
310
|
+
return JSON.stringify([
|
|
311
|
+
nextCompat,
|
|
312
|
+
actions,
|
|
313
|
+
Boolean(routeErrorFile(config, route)),
|
|
314
|
+
Boolean(globalErrorFile(config)),
|
|
315
|
+
Boolean(routeNotFoundFile(config, route)),
|
|
316
|
+
routeThrowsClientControlFlow(route) === true,
|
|
317
|
+
Boolean(route.needsRouterEntry),
|
|
318
|
+
facts,
|
|
319
|
+
]);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
interface PreparedPrebuilt {
|
|
323
|
+
runtime: PrebuiltRuntime;
|
|
324
|
+
plugin: Plugin;
|
|
325
|
+
/** Fold what the build demanded into the artifact. */
|
|
326
|
+
settle(): Promise<void>;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Open the prebuilt artifact for a client build, or return undefined when the
|
|
331
|
+
* prebuild is off. The returned plugin routes every framework specifier at the
|
|
332
|
+
* artifact; `settle()` afterwards covers whatever the build turned out to need.
|
|
333
|
+
*/
|
|
334
|
+
async function preparePrebuilt(
|
|
335
|
+
config: ResolvedConfig,
|
|
336
|
+
outDir: string,
|
|
337
|
+
dev: boolean,
|
|
338
|
+
sourceOf: (importer: string) => string | undefined,
|
|
339
|
+
signature = '',
|
|
340
|
+
): Promise<PreparedPrebuilt | undefined> {
|
|
341
|
+
if (!prebuiltEnabled()) return undefined;
|
|
342
|
+
const buildOptions = baseClientBuildOptions(config, dev);
|
|
343
|
+
const key = prebuiltRuntimeKey(config, buildOptions, signature);
|
|
344
|
+
const { dir, publicPath, assetPath } = prebuiltLocation(config, outDir, key, dev);
|
|
345
|
+
// The artifact build gets the same plugin chain — and therefore the same
|
|
346
|
+
// aliases, transforms and asset seam — as the route build it stands in for.
|
|
347
|
+
// Not the prebuilt plugin itself: inside the artifact, framework imports are
|
|
348
|
+
// exactly what is being bundled.
|
|
349
|
+
const options = {
|
|
350
|
+
aliases: Object.keys(getImportAliasExtensions().aliases(config, 'client')),
|
|
351
|
+
key,
|
|
352
|
+
dir,
|
|
353
|
+
publicPath,
|
|
354
|
+
assetPath,
|
|
355
|
+
buildOptions,
|
|
356
|
+
plugins: () => clientBuildPlugins(config, createClientSourcePipeline(config)),
|
|
357
|
+
};
|
|
358
|
+
const runtime = await ensurePrebuiltRuntime(options);
|
|
359
|
+
const unprobed = new Set<string>();
|
|
360
|
+
return {
|
|
361
|
+
runtime,
|
|
362
|
+
plugin: prebuiltExternalPlugin(runtime, prebuiltSpecifierFilter(config), unprobed, sourceOf),
|
|
363
|
+
async settle() {
|
|
364
|
+
await settlePrebuiltRuntime(runtime, options, outDir, unprobed);
|
|
365
|
+
},
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** The served URL of a deferred dynamic reference's on-demand chunk (dev split). */
|
|
370
|
+
export function deferredDynamicChunkHref(reference: Pick<ClientReference, 'id'>) {
|
|
371
|
+
return `/__pnext/client-dyn/${reference.id}.js`;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export interface DeferredDynamicRef {
|
|
375
|
+
file: string;
|
|
376
|
+
exportName: string;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Process-level registry the dev chunk endpoint resolves ids through. Entries
|
|
380
|
+
// come from the pipeline rewrite below and from each out-dir's sidecar (a
|
|
381
|
+
// restart serving a cached entry never re-ran the rewrite).
|
|
382
|
+
const deferredDynamicRefs = new Map<string, DeferredDynamicRef>();
|
|
383
|
+
|
|
384
|
+
export function registerDeferredDynamicRef(id: string, ref: DeferredDynamicRef) {
|
|
385
|
+
deferredDynamicRefs.set(id, ref);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export function deferredDynamicRefById(id: string) {
|
|
389
|
+
return deferredDynamicRefs.get(id);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Sidecar name persisted next to a dev entry naming its deferred dynamic refs. */
|
|
393
|
+
export const DEFERRED_DYNAMIC_SIDECAR = 'dyn-refs.json';
|
|
394
|
+
|
|
395
|
+
/** Dev-only: deferred dynamic references load from the on-demand chunk endpoint. */
|
|
396
|
+
function devDeferredDynamicHref(dev: boolean | undefined) {
|
|
397
|
+
if (!dev || !devDynamicSplitEnabled()) return undefined;
|
|
398
|
+
return (reference: ClientReference) =>
|
|
399
|
+
reference.dynamic && !ssrClientReference(reference)
|
|
400
|
+
? deferredDynamicChunkHref(reference)
|
|
401
|
+
: undefined;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Deferred chunk URLs resolve at runtime, never inside the entry build. */
|
|
405
|
+
function deferredDynamicExternalPlugin(): Plugin {
|
|
406
|
+
return {
|
|
407
|
+
name: 'pnext-dyn-split-external',
|
|
408
|
+
setup(build) {
|
|
409
|
+
build.onResolve({ filter: /^\/__pnext\/client-dyn\// }, args => ({
|
|
410
|
+
path: args.path,
|
|
411
|
+
external: true,
|
|
412
|
+
}));
|
|
413
|
+
},
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export async function buildClientEntry({ config, route, outDir, dev }: ClientBuildOptions) {
|
|
418
|
+
conventionCache.clear();
|
|
419
|
+
await ensureDir(outDir);
|
|
420
|
+
const source = clientEntrySource({
|
|
421
|
+
deferredDynamicHref: devDeferredDynamicHref(dev),
|
|
422
|
+
pageFile: route.client ? route.file : undefined,
|
|
423
|
+
clientReferences: route.clientReferences,
|
|
424
|
+
nextCompat: nextCompatEnabled(config),
|
|
425
|
+
// Single-route (dev / batch fallback) build: no app-wide action manifest here.
|
|
426
|
+
actions: true,
|
|
427
|
+
errorFile: routeErrorFile(config, route),
|
|
428
|
+
globalErrorFile: globalErrorFile(config),
|
|
429
|
+
notFoundFile: routeNotFoundFile(config, route),
|
|
430
|
+
controlFlow: routeThrowsClientControlFlow(route),
|
|
431
|
+
router: Boolean(route.needsRouterEntry),
|
|
432
|
+
clientRootLayout: hasClientRootLayout(config, route),
|
|
433
|
+
shellLayoutOrder: shellLayoutOrder(config, route),
|
|
434
|
+
});
|
|
435
|
+
const entryName = clientEntryName(route);
|
|
436
|
+
const outfile = path.join(outDir, `${entryName}.js`);
|
|
437
|
+
const pipeline = createClientSourcePipeline(config, dev === true);
|
|
438
|
+
pipeline.warmRoutes([route]);
|
|
439
|
+
const runtimeFacts = clientRuntimeFacts(
|
|
440
|
+
[
|
|
441
|
+
{
|
|
442
|
+
route,
|
|
443
|
+
shell: hasClientRootLayout(config, route),
|
|
444
|
+
boundary: hasRouteErrorBoundary(config, route),
|
|
445
|
+
notFound: Boolean(routeNotFoundFile(config, route)),
|
|
446
|
+
},
|
|
447
|
+
],
|
|
448
|
+
nextCompatEnabled(config),
|
|
449
|
+
);
|
|
450
|
+
const prebuilt = await clientProfile.timeAsync('prebuilt', () => preparePrebuilt(
|
|
451
|
+
config,
|
|
452
|
+
outDir,
|
|
453
|
+
dev === true,
|
|
454
|
+
importer =>
|
|
455
|
+
importer === CLIENT_RUNTIME_MODULE
|
|
456
|
+
? clientRuntimeSource(runtimeFacts)
|
|
457
|
+
: (pipeline.sourceOf(importer) ?? readTextSyncSafe(importer)),
|
|
458
|
+
// One route is its own surface group, so the key is the same one the batch
|
|
459
|
+
// build would give this route's group — dev and prod derive it identically.
|
|
460
|
+
surfaceSignature(config, route, true),
|
|
461
|
+
));
|
|
462
|
+
|
|
463
|
+
let metafile: Metafile | undefined;
|
|
464
|
+
try {
|
|
465
|
+
const result = await profileClientBuild(route, dev, () =>
|
|
466
|
+
clientProfile.timeAsync('esbuild', () => build({
|
|
467
|
+
...baseClientBuildOptions(config, dev),
|
|
468
|
+
stdin: {
|
|
469
|
+
contents: source,
|
|
470
|
+
loader: 'ts',
|
|
471
|
+
resolveDir: process.cwd(),
|
|
472
|
+
sourcefile: `${route.id}.ts`,
|
|
473
|
+
},
|
|
474
|
+
outdir: outDir,
|
|
475
|
+
entryNames: entryName,
|
|
476
|
+
metafile: true,
|
|
477
|
+
plugins: clientBuildPlugins(config, pipeline, [
|
|
478
|
+
...(prebuilt ? [prebuilt.plugin] : []),
|
|
479
|
+
...(devDeferredDynamicHref(dev) ? [deferredDynamicExternalPlugin()] : []),
|
|
480
|
+
clientRuntimePlugin(runtimeFacts),
|
|
481
|
+
]),
|
|
482
|
+
})),
|
|
483
|
+
);
|
|
484
|
+
metafile = result.metafile;
|
|
485
|
+
} catch (error) {
|
|
486
|
+
throw withClientImportTrace(error, config, route, source);
|
|
487
|
+
}
|
|
488
|
+
await clientProfile.timeAsync('prebuiltSettle', () => prebuilt?.settle() ?? Promise.resolve());
|
|
489
|
+
|
|
490
|
+
if (metafile) {
|
|
491
|
+
metafile = await clientProfile.timeAsync('fold', () => foldInitialChunks({
|
|
492
|
+
outDir,
|
|
493
|
+
metafile: metafile!,
|
|
494
|
+
buildOptions: baseClientBuildOptions(config, dev),
|
|
495
|
+
}));
|
|
496
|
+
}
|
|
497
|
+
await clientProfile.timeAsync('write', () => nameSharedClientChunks(outDir, metafile));
|
|
498
|
+
if (dev && devDynamicSplitEnabled() && pipeline.deferredRefs().size > 0) {
|
|
499
|
+
await writeText(
|
|
500
|
+
path.join(outDir, DEFERRED_DYNAMIC_SIDECAR),
|
|
501
|
+
JSON.stringify(Object.fromEntries(pipeline.deferredRefs())),
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
clientProfile.report(`client entry ${route.id}`);
|
|
505
|
+
|
|
506
|
+
return outfile;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Dev split: bundle ONE deferred dynamic reference on browser demand. The
|
|
511
|
+
* chunk is its own esbuild build, so single-instance vendors (preact and its
|
|
512
|
+
* facades) resolve to shared shims reading the entry's published namespaces
|
|
513
|
+
* (see dynSharedTableSource) instead of bundling a second copy whose hooks
|
|
514
|
+
* would never see the entry renderer's dispatch.
|
|
515
|
+
*/
|
|
516
|
+
export async function buildClientDynamicChunk({
|
|
517
|
+
config,
|
|
518
|
+
route,
|
|
519
|
+
reference,
|
|
520
|
+
outDir,
|
|
521
|
+
}: {
|
|
522
|
+
config: ResolvedConfig;
|
|
523
|
+
route?: RouteManifestEntry;
|
|
524
|
+
reference: DeferredDynamicRef & { id: string };
|
|
525
|
+
outDir: string;
|
|
526
|
+
}) {
|
|
527
|
+
await ensureDir(outDir);
|
|
528
|
+
const pipeline = createClientSourcePipeline(config, true);
|
|
529
|
+
const prebuilt = await preparePrebuilt(
|
|
530
|
+
config,
|
|
531
|
+
outDir,
|
|
532
|
+
true,
|
|
533
|
+
importer => pipeline.sourceOf(importer) ?? readTextSyncSafe(importer),
|
|
534
|
+
route ? surfaceSignature(config, route, true) : '',
|
|
535
|
+
);
|
|
536
|
+
const source = [
|
|
537
|
+
reference.exportName === 'default'
|
|
538
|
+
? `export { default } from ${JSON.stringify(reference.file)};`
|
|
539
|
+
: '',
|
|
540
|
+
`export * from ${JSON.stringify(reference.file)};`,
|
|
541
|
+
].join('\n');
|
|
542
|
+
const outfile = path.join(outDir, `${reference.id}.js`);
|
|
543
|
+
await clientProfile.timeAsync('dynChunk', () => build({
|
|
544
|
+
...baseClientBuildOptions(config, true),
|
|
545
|
+
stdin: {
|
|
546
|
+
contents: source,
|
|
547
|
+
loader: 'ts',
|
|
548
|
+
resolveDir: process.cwd(),
|
|
549
|
+
sourcefile: `${reference.id}.dyn.ts`,
|
|
550
|
+
},
|
|
551
|
+
outdir: outDir,
|
|
552
|
+
entryNames: reference.id,
|
|
553
|
+
plugins: clientBuildPlugins(config, pipeline, [
|
|
554
|
+
...(prebuilt ? [prebuilt.plugin] : []),
|
|
555
|
+
dynSharedVendorPlugin(nextCompatEnabled(config)),
|
|
556
|
+
deferredDynamicExternalPlugin(),
|
|
557
|
+
]),
|
|
558
|
+
}));
|
|
559
|
+
await prebuilt?.settle();
|
|
560
|
+
clientProfile.report(`client dynamic chunk ${reference.id}`);
|
|
561
|
+
return outfile;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** Shared-vendor shims: preact resolves to the entry's published namespace. */
|
|
565
|
+
function dynSharedVendorPlugin(nextCompat: boolean): Plugin {
|
|
566
|
+
const namespace = 'pnext-dyn-shared';
|
|
567
|
+
const filter = new RegExp(`^(?:${dynSharedSpecifiers(nextCompat).map(escapeRegex).join('|')})$`);
|
|
568
|
+
return {
|
|
569
|
+
name: 'pnext-dyn-shared-vendor',
|
|
570
|
+
setup(build) {
|
|
571
|
+
build.onResolve({ filter }, args =>
|
|
572
|
+
args.namespace === namespace ? undefined : { path: args.path, namespace },
|
|
573
|
+
);
|
|
574
|
+
build.onLoad({ filter: /.*/, namespace }, async args => {
|
|
575
|
+
const names = Object.keys((await import(args.path)) as Record<string, unknown>).filter(
|
|
576
|
+
name => /^[A-Za-z_$][\w$]*$/.test(name) && name !== 'default',
|
|
577
|
+
);
|
|
578
|
+
const lines = [
|
|
579
|
+
`const m = window.${DYN_SHARED_GLOBAL}[${JSON.stringify(args.path)}];`,
|
|
580
|
+
'export default (m && m.default);',
|
|
581
|
+
...names.map(name => `export const ${name} = m.${name};`),
|
|
582
|
+
];
|
|
583
|
+
return { contents: lines.join('\n'), loader: 'js' };
|
|
584
|
+
});
|
|
585
|
+
},
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Bundle every route's client entry in a single esbuild build. With `splitting`
|
|
591
|
+
* enabled, esbuild emits the shared dependency graph (preact runtime, ui kit,
|
|
592
|
+
* trpc/auth clients) into shared chunks once, instead of re-bundling it from
|
|
593
|
+
* scratch for each route. This turns N independent multi-second builds into one.
|
|
594
|
+
*/
|
|
595
|
+
export async function buildClientEntries({
|
|
596
|
+
config,
|
|
597
|
+
routes,
|
|
598
|
+
outDir,
|
|
599
|
+
verbose,
|
|
600
|
+
hasServerActions,
|
|
601
|
+
pipeline: warmed,
|
|
602
|
+
}: ClientBatchBuildOptions) {
|
|
603
|
+
if (!warmed) conventionCache.clear();
|
|
604
|
+
const actions = appUsesActions(routes, hasServerActions);
|
|
605
|
+
const entries = clientProfile.time('entrySources', () => routes
|
|
606
|
+
.filter(route => route.client || route.clientReferences.length > 0 || route.needsRouterEntry)
|
|
607
|
+
.map(route => ({
|
|
608
|
+
route,
|
|
609
|
+
entryName: clientEntryName(route),
|
|
610
|
+
source: clientEntrySource({
|
|
611
|
+
pageFile: route.client ? route.file : undefined,
|
|
612
|
+
clientReferences: route.clientReferences,
|
|
613
|
+
nextCompat: nextCompatEnabled(config),
|
|
614
|
+
actions,
|
|
615
|
+
errorFile: routeErrorFile(config, route),
|
|
616
|
+
globalErrorFile: globalErrorFile(config),
|
|
617
|
+
notFoundFile: routeNotFoundFile(config, route),
|
|
618
|
+
controlFlow: routeThrowsClientControlFlow(route),
|
|
619
|
+
router: Boolean(route.needsRouterEntry),
|
|
620
|
+
clientRootLayout: hasClientRootLayout(config, route),
|
|
621
|
+
shellLayoutOrder: shellLayoutOrder(config, route),
|
|
622
|
+
}),
|
|
623
|
+
})));
|
|
624
|
+
if (entries.length === 0) return;
|
|
625
|
+
// Every route's client sources start their transform and React Compiler pass here, in one batch on
|
|
626
|
+
// oxc's threadpool, while esbuild is still booting and walking the graph. A caller-warmed pipeline
|
|
627
|
+
// has them in flight already; warming twice is a Map hit, so the same call covers both.
|
|
628
|
+
const pipeline = warmed ?? createClientSourcePipeline(config);
|
|
629
|
+
pipeline.warmRoutes(entries.map(entry => entry.route));
|
|
630
|
+
await ensureDir(outDir);
|
|
631
|
+
const groups = surfaceGroups(config, entries, actions);
|
|
632
|
+
clientProfile.count('group#', groups.length);
|
|
633
|
+
const built: BuiltEntryGroup[] = [];
|
|
634
|
+
for (const group of groups) {
|
|
635
|
+
built.push(await buildEntryGroup({ config, outDir, entries, group, pipeline, verbose }));
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// Fold and rename once over the finished output, never per group. Chunk names
|
|
639
|
+
// are content hashes, so two groups emit the same file for the same chunk —
|
|
640
|
+
// and the fold DELETES the members it merges, so a per-group fold would
|
|
641
|
+
// unlink a chunk another group's manifest still points at.
|
|
642
|
+
let merged: Metafile = { inputs: {}, outputs: {} };
|
|
643
|
+
for (const item of built) {
|
|
644
|
+
if (!item.metafile) continue;
|
|
645
|
+
Object.assign(merged.inputs, item.metafile.inputs);
|
|
646
|
+
Object.assign(merged.outputs, item.metafile.outputs);
|
|
647
|
+
}
|
|
648
|
+
merged = await clientProfile.timeAsync('fold', () =>
|
|
649
|
+
foldInitialChunks({ outDir, metafile: merged, buildOptions: baseClientBuildOptions(config) }),
|
|
650
|
+
);
|
|
651
|
+
const renames = await clientProfile.timeAsync('write', () =>
|
|
652
|
+
nameSharedClientChunks(outDir, merged),
|
|
653
|
+
);
|
|
654
|
+
clientProfile.time('entryImports', () => {
|
|
655
|
+
for (const item of built) {
|
|
656
|
+
if (item.metafile) {
|
|
657
|
+
assignClientEntryImports(item.group.entries, merged, outDir, renames, item.prebuilt);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
});
|
|
661
|
+
clientProfile.report('client stage');
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
interface ClientEntry {
|
|
665
|
+
route: RouteManifestEntry;
|
|
666
|
+
entryName: string;
|
|
667
|
+
source: string;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
interface SurfaceGroup {
|
|
671
|
+
signature: string;
|
|
672
|
+
facts: ClientRuntimeFacts;
|
|
673
|
+
entries: ClientEntry[];
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
interface BuiltEntryGroup {
|
|
677
|
+
group: SurfaceGroup;
|
|
678
|
+
metafile: Metafile | undefined;
|
|
679
|
+
prebuilt: PreparedPrebuilt | undefined;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Split the app's entries into the sets that share a framework surface - one esbuild build each,
|
|
684
|
+
* because a shared module graph cannot resolve one specifier to two artifacts. With the prebuild
|
|
685
|
+
* off there is exactly one group and this is the single batched build it has always been. The
|
|
686
|
+
* split is what makes the artifact pay off on a multi-route app: shared, its initial tier is the
|
|
687
|
+
* UNION of every route's demand, so a light route downloads a heavy one's closure. The cost is
|
|
688
|
+
* that user code shared across two groups is bundled into both.
|
|
689
|
+
*/
|
|
690
|
+
function surfaceGroups(
|
|
691
|
+
config: ResolvedConfig,
|
|
692
|
+
entries: ClientEntry[],
|
|
693
|
+
actions: boolean,
|
|
694
|
+
): SurfaceGroup[] {
|
|
695
|
+
const facts = (members: ClientEntry[]) =>
|
|
696
|
+
clientRuntimeFacts(
|
|
697
|
+
members.map(entry => ({
|
|
698
|
+
route: entry.route,
|
|
699
|
+
shell: hasClientRootLayout(config, entry.route),
|
|
700
|
+
boundary: hasRouteErrorBoundary(config, entry.route),
|
|
701
|
+
notFound: Boolean(routeNotFoundFile(config, entry.route)),
|
|
702
|
+
})),
|
|
703
|
+
nextCompatEnabled(config),
|
|
704
|
+
);
|
|
705
|
+
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
706
|
+
if (!prebuiltEnabled() || process.env.PNEXT_PREBUILT_GROUPS === '0')
|
|
707
|
+
return [{ signature: '', facts: facts(entries), entries }];
|
|
708
|
+
const bySignature = new Map<string, ClientEntry[]>();
|
|
709
|
+
for (const entry of entries) {
|
|
710
|
+
const signature = surfaceSignature(config, entry.route, actions);
|
|
711
|
+
const members = bySignature.get(signature) ?? [];
|
|
712
|
+
bySignature.set(signature, members);
|
|
713
|
+
members.push(entry);
|
|
714
|
+
}
|
|
715
|
+
return [...bySignature].map(([signature, members]) => ({
|
|
716
|
+
signature,
|
|
717
|
+
// Every member shares the signature, so the group's runtime facts ARE each
|
|
718
|
+
// member's own — the shared runtime module stops carrying glue for shapes
|
|
719
|
+
// no route in this group has.
|
|
720
|
+
facts: facts(members),
|
|
721
|
+
entries: members,
|
|
722
|
+
}));
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
async function buildEntryGroup({
|
|
726
|
+
config,
|
|
727
|
+
outDir,
|
|
728
|
+
entries,
|
|
729
|
+
group,
|
|
730
|
+
pipeline,
|
|
731
|
+
verbose,
|
|
732
|
+
}: {
|
|
733
|
+
config: ResolvedConfig;
|
|
734
|
+
outDir: string;
|
|
735
|
+
/** Every entry in the app: the batch fallback and `sourceOf` span groups. */
|
|
736
|
+
entries: ClientEntry[];
|
|
737
|
+
group: SurfaceGroup;
|
|
738
|
+
pipeline: ClientSourcePipeline;
|
|
739
|
+
verbose?: boolean;
|
|
740
|
+
}): Promise<BuiltEntryGroup> {
|
|
741
|
+
const prebuilt = await clientProfile.timeAsync('prebuilt', () =>
|
|
742
|
+
preparePrebuilt(
|
|
743
|
+
config,
|
|
744
|
+
outDir,
|
|
745
|
+
false,
|
|
746
|
+
importer => {
|
|
747
|
+
if (importer === CLIENT_RUNTIME_MODULE) return clientRuntimeSource(group.facts);
|
|
748
|
+
const entry = entries.find(item => item.route.id === importer);
|
|
749
|
+
return entry?.source ?? pipeline.sourceOf(importer) ?? readTextSyncSafe(importer);
|
|
750
|
+
},
|
|
751
|
+
group.signature,
|
|
752
|
+
),
|
|
753
|
+
);
|
|
754
|
+
|
|
755
|
+
let metafile: Metafile | undefined;
|
|
756
|
+
try {
|
|
757
|
+
const result = await clientProfile.timeAsync('esbuild', () => build({
|
|
758
|
+
...baseClientBuildOptions(config),
|
|
759
|
+
entryPoints: group.entries.map(entry => ({
|
|
760
|
+
in: virtualEntryPath(entry.route.id),
|
|
761
|
+
out: entry.entryName,
|
|
762
|
+
})),
|
|
763
|
+
outdir: outDir,
|
|
764
|
+
metafile: true,
|
|
765
|
+
plugins: clientBuildPlugins(config, pipeline, [
|
|
766
|
+
...(prebuilt ? [prebuilt.plugin] : []),
|
|
767
|
+
virtualEntryPlugin(group.entries),
|
|
768
|
+
clientRuntimePlugin(group.facts),
|
|
769
|
+
]),
|
|
770
|
+
}));
|
|
771
|
+
metafile = result.metafile;
|
|
772
|
+
} catch (error) {
|
|
773
|
+
// A batched build cannot attribute a "Could not resolve" error to a single route, so fall back to
|
|
774
|
+
// per-route builds - the offending route then throws with its precise client import trace. Other
|
|
775
|
+
// diagnostics already carry a clear message from the batch build; re-throw rather than let a
|
|
776
|
+
// per-route build surface a more confusing downstream error.
|
|
777
|
+
if (!hasUnresolvedImportError(error)) throw error;
|
|
778
|
+
for (const { route } of group.entries) {
|
|
779
|
+
await buildClientEntry({ config, route, outDir });
|
|
780
|
+
}
|
|
781
|
+
throw error;
|
|
782
|
+
}
|
|
783
|
+
await clientProfile.timeAsync('prebuiltSettle', () => prebuilt?.settle() ?? Promise.resolve());
|
|
784
|
+
|
|
785
|
+
if (verbose && metafile) reportClientBundleSizes(group.entries, metafile, outDir);
|
|
786
|
+
return { group, metafile, prebuilt };
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* The client sources the route table already names - every `'use client'` reference plus a client
|
|
791
|
+
* page's own file. Deliberately NOT a module walk: this set is free (the scan produced it), it is
|
|
792
|
+
* the bulk of any app's first-party client graph, and whatever it misses esbuild still discovers.
|
|
793
|
+
*/
|
|
794
|
+
function routeClientSources(routes: RouteManifestEntry[]) {
|
|
795
|
+
const files = new Set<string>();
|
|
796
|
+
for (const route of routes) {
|
|
797
|
+
if (route.client) files.add(route.file);
|
|
798
|
+
for (const reference of route.clientReferences) files.add(reference.file);
|
|
799
|
+
}
|
|
800
|
+
return files;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// Record each entry's static chunk closure on the route so rendered pages can
|
|
804
|
+
// modulepreload it — without this the browser discovers the chunk list only
|
|
805
|
+
// after the entry itself downloads, serializing the two round trips.
|
|
806
|
+
function assignClientEntryImports(
|
|
807
|
+
entries: { route: RouteManifestEntry; entryName: string }[],
|
|
808
|
+
metafile: Metafile,
|
|
809
|
+
outDir: string,
|
|
810
|
+
renames: Map<string, string>,
|
|
811
|
+
prebuilt: PreparedPrebuilt | undefined,
|
|
812
|
+
) {
|
|
813
|
+
const outputs = metafile.outputs;
|
|
814
|
+
// One basename index for the whole entry set: the linear scan this replaces
|
|
815
|
+
// was O(entries × outputs), which on a route-per-page app is the entire
|
|
816
|
+
// output list walked once per route.
|
|
817
|
+
const byBasename = new Map<string, string>();
|
|
818
|
+
for (const item of Object.keys(outputs)) {
|
|
819
|
+
// First match wins, as the linear scan did.
|
|
820
|
+
const basename = path.basename(item);
|
|
821
|
+
if (!byBasename.has(basename)) byBasename.set(basename, item);
|
|
822
|
+
}
|
|
823
|
+
for (const { route, entryName } of entries) {
|
|
824
|
+
const entryOutput = byBasename.get(`${entryName}.js`);
|
|
825
|
+
if (!entryOutput) continue;
|
|
826
|
+
const staticImports = new Set<string>();
|
|
827
|
+
const dynamicImports = new Set<string>();
|
|
828
|
+
const visited = new Set([`${entryOutput}:static`]);
|
|
829
|
+
const queue = [{ output: entryOutput, dynamic: false }];
|
|
830
|
+
// eslint-disable-next-line @typescript-eslint/prefer-for-of
|
|
831
|
+
for (let head = 0; head < queue.length; head += 1) {
|
|
832
|
+
const current = queue[head];
|
|
833
|
+
if (!current) continue;
|
|
834
|
+
for (const imported of outputs[current.output]?.imports ?? []) {
|
|
835
|
+
if (imported.kind !== 'import-statement' && imported.kind !== 'dynamic-import') continue;
|
|
836
|
+
const dynamic = current.dynamic || imported.kind === 'dynamic-import';
|
|
837
|
+
const visitKey = `${imported.path}:${dynamic ? 'dynamic' : 'static'}`;
|
|
838
|
+
if (visited.has(visitKey)) continue;
|
|
839
|
+
visited.add(visitKey);
|
|
840
|
+
// Prebuilt-runtime shims are external: already a url relative to the
|
|
841
|
+
// assets root, and with no output in THIS metafile to walk into. Their
|
|
842
|
+
// own chunk closure comes from the artifact's manifest instead, so the
|
|
843
|
+
// page preloads the whole set rather than discovering chunks one
|
|
844
|
+
// round trip after the shim lands.
|
|
845
|
+
if (imported.external) {
|
|
846
|
+
if (!prebuilt || imported.path !== prebuiltModuleUrl(prebuilt.runtime)) continue;
|
|
847
|
+
const assets = prebuiltAssets(prebuilt.runtime);
|
|
848
|
+
for (const asset of assets.static) (dynamic ? dynamicImports : staticImports).add(asset);
|
|
849
|
+
for (const asset of assets.dynamic) dynamicImports.add(asset);
|
|
850
|
+
continue;
|
|
851
|
+
}
|
|
852
|
+
queue.push({ output: imported.path, dynamic });
|
|
853
|
+
const basename = path.basename(imported.path);
|
|
854
|
+
const relative = path.relative(outDir, path.resolve(imported.path));
|
|
855
|
+
const publicRelative = path
|
|
856
|
+
.join('assets', path.dirname(relative), renames.get(basename) ?? basename)
|
|
857
|
+
.split(path.sep)
|
|
858
|
+
.join('/');
|
|
859
|
+
(dynamic ? dynamicImports : staticImports).add(publicRelative);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
if (staticImports.size > 0) route.clientEntryImports = [...staticImports];
|
|
863
|
+
for (const asset of staticImports) dynamicImports.delete(asset);
|
|
864
|
+
if (dynamicImports.size > 0) route.clientDynamicImports = [...dynamicImports];
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* A single batched build can't report per-route *time* (esbuild shares and
|
|
870
|
+
* parallelizes the work), but the metafile gives per-route *size*: the entry
|
|
871
|
+
* plus every chunk reachable from it. Shared chunks are counted for each route
|
|
872
|
+
* that pulls them in, so this reflects real download weight, not disk footprint.
|
|
873
|
+
*/
|
|
874
|
+
function reportClientBundleSizes(
|
|
875
|
+
entries: { route: RouteManifestEntry; entryName: string }[],
|
|
876
|
+
metafile: Metafile,
|
|
877
|
+
outDir: string,
|
|
878
|
+
) {
|
|
879
|
+
const log = createVerboseLogger(true, 'client');
|
|
880
|
+
const outputs = metafile.outputs;
|
|
881
|
+
const rows = entries
|
|
882
|
+
.map(({ route, entryName }) => {
|
|
883
|
+
const key = `${path.relative(process.cwd(), path.join(outDir, `${entryName}.js`))}`;
|
|
884
|
+
const output = outputs[key]
|
|
885
|
+
? key
|
|
886
|
+
: Object.keys(outputs).find(item => path.basename(item) === `${entryName}.js`);
|
|
887
|
+
const bytes = output ? reachableOutputBytes(output, outputs) : 0;
|
|
888
|
+
return { route: route.route, bytes };
|
|
889
|
+
})
|
|
890
|
+
.sort((a, b) => b.bytes - a.bytes);
|
|
891
|
+
|
|
892
|
+
for (const row of rows) {
|
|
893
|
+
log.log(`${row.route} ${dimText(`→ ${formatBytes(row.bytes)}`)}`);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function reachableOutputBytes(entry: string, outputs: Metafile['outputs']) {
|
|
898
|
+
const visited = new Set<string>();
|
|
899
|
+
const queue = [entry];
|
|
900
|
+
let bytes = 0;
|
|
901
|
+
|
|
902
|
+
while (queue.length > 0) {
|
|
903
|
+
const current = queue.shift();
|
|
904
|
+
if (!current || visited.has(current)) continue;
|
|
905
|
+
visited.add(current);
|
|
906
|
+
const output = outputs[current];
|
|
907
|
+
if (!output) continue;
|
|
908
|
+
bytes += output.bytes;
|
|
909
|
+
for (const imported of output.imports) {
|
|
910
|
+
if (imported.kind === 'import-statement' || imported.kind === 'dynamic-import') {
|
|
911
|
+
queue.push(imported.path);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
return bytes;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
function formatBytes(bytes: number) {
|
|
920
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
921
|
+
const units = ['KB', 'MB'] as const;
|
|
922
|
+
let value = bytes / 1024;
|
|
923
|
+
let unitIndex = 0;
|
|
924
|
+
while (value >= 1024 && unitIndex < units.length - 1) {
|
|
925
|
+
value /= 1024;
|
|
926
|
+
unitIndex += 1;
|
|
927
|
+
}
|
|
928
|
+
return `${value >= 10 ? value.toFixed(1) : value.toFixed(2)} ${units[unitIndex]}`;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
function baseClientBuildOptions(config: ResolvedConfig, dev = false): BuildOptions {
|
|
932
|
+
const extensions = getBundlerExtensions();
|
|
933
|
+
const pure = extensions.clientPureFunctions(config);
|
|
934
|
+
const inject = extensions.clientInjects();
|
|
935
|
+
return {
|
|
936
|
+
...(inject.length > 0 ? { inject } : {}),
|
|
937
|
+
chunkNames: 'chunks/[name]-[hash]',
|
|
938
|
+
bundle: true,
|
|
939
|
+
format: 'esm',
|
|
940
|
+
splitting: true,
|
|
941
|
+
target: 'es2022',
|
|
942
|
+
jsx: 'automatic',
|
|
943
|
+
jsxImportSource: 'preact',
|
|
944
|
+
// Minification is dead weight in dev: nobody reads the bundle and it roughly
|
|
945
|
+
// doubles esbuild time. Ship minified only for production builds.
|
|
946
|
+
minify: !dev,
|
|
947
|
+
...(pure.length > 0 ? { pure } : {}),
|
|
948
|
+
// Browser sourcemaps are opt-in (`productionBrowserSourceMaps`, matching Next's default of
|
|
949
|
+
// off): shipping them publishes first-party source to every visitor. Opted in, prod emits
|
|
950
|
+
// external `.js.map` linked by a `//# sourceMappingURL=` comment - never inline, which would
|
|
951
|
+
// bloat the served JS with source text. Dev never emits: the un-minified output is readable.
|
|
952
|
+
sourcemap: !dev && config.productionBrowserSourceMaps === true ? 'linked' : false,
|
|
953
|
+
conditions: ['browser', 'style'],
|
|
954
|
+
define: {
|
|
955
|
+
...publicEnvDefines(),
|
|
956
|
+
// compiler.define / defineServer (compat); defineServer keys fold to
|
|
957
|
+
// `undefined` client-side so fallback branches stay reachable.
|
|
958
|
+
...extensions.clientDefines(),
|
|
959
|
+
'process.browser': 'true',
|
|
960
|
+
// Next inlines the deployment id into every graph (main thread included),
|
|
961
|
+
// so a client component reading `process.env.NEXT_DEPLOYMENT_ID` during
|
|
962
|
+
// render doesn't hit an undefined `process` in the browser.
|
|
963
|
+
...deploymentIdDefine(),
|
|
964
|
+
},
|
|
965
|
+
logOverride: { 'suspicious-logical-operator': 'silent' },
|
|
966
|
+
// Static image imports (.png/.jpg/.svg/…) are handled by
|
|
967
|
+
// clientStaticAssetPlugin, which emits the Next static-image metadata object
|
|
968
|
+
// — NOT a bare `file` loader URL string. Only the font formats keep the file
|
|
969
|
+
// loader here.
|
|
970
|
+
loader: {
|
|
971
|
+
'.woff': 'file',
|
|
972
|
+
'.woff2': 'file',
|
|
973
|
+
'.ttf': 'file',
|
|
974
|
+
'.otf': 'file',
|
|
975
|
+
'.eot': 'file',
|
|
976
|
+
},
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
function deploymentIdDefine(): Record<string, string> {
|
|
981
|
+
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
982
|
+
const deploymentId = process.env.NEXT_DEPLOYMENT_ID;
|
|
983
|
+
return deploymentId === undefined
|
|
984
|
+
? {}
|
|
985
|
+
: { 'process.env.NEXT_DEPLOYMENT_ID': JSON.stringify(deploymentId) };
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
/**
|
|
989
|
+
* Wrap every plugin's onResolve/onLoad so the profile reports, per plugin, how
|
|
990
|
+
* many callbacks esbuild made and how long they held the JS thread. Each call
|
|
991
|
+
* is an IPC round trip, so the counts are what distinguishes "our transforms are
|
|
992
|
+
* slow" from "we are answering 40k questions". Off unless PNEXT_CLIENT_PROFILE.
|
|
993
|
+
*/
|
|
994
|
+
function instrumentedPlugins(plugins: Plugin[]): Plugin[] {
|
|
995
|
+
if (!clientProfile.enabled) return plugins;
|
|
996
|
+
return plugins.map(plugin => ({
|
|
997
|
+
name: plugin.name,
|
|
998
|
+
setup(build) {
|
|
999
|
+
const wrap = (kind: 'resolve' | 'load', register: (options: any, cb: any) => void) =>
|
|
1000
|
+
(options: any, callback: any) =>
|
|
1001
|
+
register(options, async (args: any) => {
|
|
1002
|
+
clientProfile.count(`${plugin.name}:${kind}#`);
|
|
1003
|
+
const start = performance.now();
|
|
1004
|
+
try {
|
|
1005
|
+
return await (callback as (a: unknown) => unknown)(args);
|
|
1006
|
+
} finally {
|
|
1007
|
+
clientProfile.count(`${plugin.name}:${kind}ms`, performance.now() - start);
|
|
1008
|
+
}
|
|
1009
|
+
});
|
|
1010
|
+
return plugin.setup({
|
|
1011
|
+
...build,
|
|
1012
|
+
onResolve: wrap('resolve', build.onResolve.bind(build)),
|
|
1013
|
+
onLoad: wrap('load', build.onLoad.bind(build)),
|
|
1014
|
+
});
|
|
1015
|
+
},
|
|
1016
|
+
}));
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* One esbuild onResolve hook for the whole chain instead of one per plugin. esbuild offers an
|
|
1021
|
+
* import path to every plugin whose filter matches, in order, until one answers - and each offer is
|
|
1022
|
+
* an IPC round trip against the single JS thread that serves every concurrent build. The client
|
|
1023
|
+
* chain's filters overlap almost completely, so dispatching in-process collapses that to one round
|
|
1024
|
+
* trip per import path: same order, same first-answer-wins rule, same plugin credited for each
|
|
1025
|
+
* diagnostic (`pluginName` is stamped back on).
|
|
1026
|
+
*
|
|
1027
|
+
* `PNEXT_CLIENT_COALESCE=0` restores a hook per plugin, to bisect it.
|
|
1028
|
+
*/
|
|
1029
|
+
export function coalesceResolveHooks(plugins: Plugin[], name: string): Plugin[] {
|
|
1030
|
+
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
1031
|
+
if (process.env.PNEXT_CLIENT_COALESCE === '0') return plugins;
|
|
1032
|
+
interface Registration {
|
|
1033
|
+
plugin: string;
|
|
1034
|
+
filter: RegExp;
|
|
1035
|
+
namespace?: string;
|
|
1036
|
+
callback: (args: OnResolveArgs) => unknown;
|
|
1037
|
+
}
|
|
1038
|
+
return [
|
|
1039
|
+
{
|
|
1040
|
+
name,
|
|
1041
|
+
async setup(build) {
|
|
1042
|
+
const registered: Registration[] = [];
|
|
1043
|
+
// Registrations made after setup (rare, and esbuild allows it) keep
|
|
1044
|
+
// their own hook: the chain's order is already fixed by then.
|
|
1045
|
+
let sealed = false;
|
|
1046
|
+
/** The chain, minus one plugin's own hooks — see `resolve` below. */
|
|
1047
|
+
const dispatch = async (args: OnResolveArgs, skip?: string) => {
|
|
1048
|
+
for (const entry of registered) {
|
|
1049
|
+
if (entry.plugin === skip) continue;
|
|
1050
|
+
if (entry.namespace !== undefined && entry.namespace !== args.namespace) continue;
|
|
1051
|
+
if (!entry.filter.test(args.path)) continue;
|
|
1052
|
+
const result = (await entry.callback(args)) as OnResolveResult | null | undefined;
|
|
1053
|
+
if (result == null) continue;
|
|
1054
|
+
for (const message of [...(result.errors ?? []), ...(result.warnings ?? [])]) {
|
|
1055
|
+
message.pluginName ??= entry.plugin;
|
|
1056
|
+
}
|
|
1057
|
+
return result;
|
|
1058
|
+
}
|
|
1059
|
+
return undefined;
|
|
1060
|
+
};
|
|
1061
|
+
for (const plugin of plugins) {
|
|
1062
|
+
const proxy: PluginBuild = {
|
|
1063
|
+
...build,
|
|
1064
|
+
onResolve(options: OnResolveOptions, callback: (args: OnResolveArgs) => unknown) {
|
|
1065
|
+
if (sealed) return build.onResolve(options, callback as never);
|
|
1066
|
+
registered.push({
|
|
1067
|
+
plugin: plugin.name,
|
|
1068
|
+
// Drop /g and /y: a sticky or global regex carries lastIndex
|
|
1069
|
+
// between tests, which would make dispatch order-dependent.
|
|
1070
|
+
filter: new RegExp(options.filter.source, options.filter.flags.replace(/[gy]/g, '')),
|
|
1071
|
+
namespace: options.namespace,
|
|
1072
|
+
callback,
|
|
1073
|
+
});
|
|
1074
|
+
},
|
|
1075
|
+
// esbuild's own `resolve` re-runs every plugin EXCEPT the caller's (its recursion guard,
|
|
1076
|
+
// keyed on plugin name). Sharing one name would make it skip the whole chain and fall
|
|
1077
|
+
// straight to native resolution, so run the chain here minus this plugin and only hand
|
|
1078
|
+
// esbuild what nobody claimed.
|
|
1079
|
+
resolve: (specifier, options = {}) =>
|
|
1080
|
+
dispatch(
|
|
1081
|
+
{
|
|
1082
|
+
path: specifier,
|
|
1083
|
+
importer: options.importer ?? '',
|
|
1084
|
+
namespace: options.namespace ?? '',
|
|
1085
|
+
resolveDir: options.resolveDir ?? '',
|
|
1086
|
+
kind: options.kind ?? 'import-statement',
|
|
1087
|
+
pluginData: options.pluginData as unknown,
|
|
1088
|
+
with: options.with ?? {},
|
|
1089
|
+
},
|
|
1090
|
+
plugin.name,
|
|
1091
|
+
).then(result =>
|
|
1092
|
+
result && resolveHookSettled(result)
|
|
1093
|
+
? resolvedFromHook(specifier, result)
|
|
1094
|
+
: build.resolve(specifier, { ...options, pluginName: name }),
|
|
1095
|
+
),
|
|
1096
|
+
};
|
|
1097
|
+
await plugin.setup(proxy);
|
|
1098
|
+
}
|
|
1099
|
+
sealed = true;
|
|
1100
|
+
if (registered.length === 0) return;
|
|
1101
|
+
const filter = new RegExp(registered.map(entry => `(?:${entry.filter.source})`).join('|'));
|
|
1102
|
+
build.onResolve({ filter }, args => dispatch(args));
|
|
1103
|
+
},
|
|
1104
|
+
},
|
|
1105
|
+
];
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/**
|
|
1109
|
+
* A chain answer in the shape `resolve()` promises its callers. `external` without a path means "keep
|
|
1110
|
+
* this specifier, do not bundle it" - esbuild's own rule, verified against the binary.
|
|
1111
|
+
*/
|
|
1112
|
+
function resolvedFromHook(specifier: string, result: OnResolveResult): ResolveResult {
|
|
1113
|
+
const resolved = result.path ?? (result.external ? specifier : '');
|
|
1114
|
+
return {
|
|
1115
|
+
errors: (result.errors ?? []) as ResolveResult['errors'],
|
|
1116
|
+
warnings: (result.warnings ?? []) as ResolveResult['warnings'],
|
|
1117
|
+
path: resolved,
|
|
1118
|
+
external: result.external ?? false,
|
|
1119
|
+
sideEffects: result.sideEffects ?? true,
|
|
1120
|
+
namespace: result.namespace ?? (result.path ? 'file' : ''),
|
|
1121
|
+
suffix: result.suffix ?? '',
|
|
1122
|
+
pluginData: result.pluginData as unknown,
|
|
1123
|
+
};
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
/**
|
|
1127
|
+
* Whether a chain answer settles the resolve on its own. Verified against the esbuild binary:
|
|
1128
|
+
* `null`/`undefined` defers to the next callback, and ANY other object ends the chain - an empty one
|
|
1129
|
+
* included, which then falls through to esbuild's native resolution rather than to the next plugin.
|
|
1130
|
+
*/
|
|
1131
|
+
function resolveHookSettled(result: OnResolveResult) {
|
|
1132
|
+
return (
|
|
1133
|
+
result.path !== undefined ||
|
|
1134
|
+
result.external !== undefined ||
|
|
1135
|
+
(result.errors?.length ?? 0) > 0 ||
|
|
1136
|
+
(result.warnings?.length ?? 0) > 0
|
|
1137
|
+
);
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
function clientBuildPlugins(
|
|
1141
|
+
config: ResolvedConfig,
|
|
1142
|
+
pipeline: ClientSourcePipeline,
|
|
1143
|
+
extra: Plugin[] = [],
|
|
1144
|
+
): Plugin[] {
|
|
1145
|
+
return coalesceResolveHooks(instrumentedPlugins([
|
|
1146
|
+
serverOnlyClientImportPlugin(),
|
|
1147
|
+
// The virtual-module plugins (`extra`: generated entries + the shared route
|
|
1148
|
+
// runtime) go first so they claim their own synthetic specifiers before the
|
|
1149
|
+
// resolve-chain plugins are asked about them — one entry point per route is
|
|
1150
|
+
// otherwise offered to every plugin in the chain that only ever declines it.
|
|
1151
|
+
...extra,
|
|
1152
|
+
...getBundlerExtensions().clientEsbuildPlugins(config),
|
|
1153
|
+
linkedPackageClientResolvePlugin(config),
|
|
1154
|
+
clientStaticAssetPlugin(config),
|
|
1155
|
+
pipeline.plugin(),
|
|
1156
|
+
importAliasPlugin(config),
|
|
1157
|
+
cssModuleClientPlugin(),
|
|
1158
|
+
]), 'pnext-client-resolve-chain');
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
function importAliasPlugin(config: ResolvedConfig): Plugin {
|
|
1162
|
+
const aliases = getImportAliasExtensions().aliases(config, 'client');
|
|
1163
|
+
const specifiers = Object.keys(aliases);
|
|
1164
|
+
return {
|
|
1165
|
+
name: 'pnext-import-alias',
|
|
1166
|
+
setup(build) {
|
|
1167
|
+
build.onResolve({ filter: /^(?:react(?:-dom)?|next)(?:\/|$)/ }, args => {
|
|
1168
|
+
if (aliases[args.path]) return undefined;
|
|
1169
|
+
const message = getImportAliasExtensions().missingImportError(config, args.path);
|
|
1170
|
+
return message ? { errors: [{ text: message }] } : undefined;
|
|
1171
|
+
});
|
|
1172
|
+
if (specifiers.length === 0) return;
|
|
1173
|
+
build.onResolve(
|
|
1174
|
+
{ filter: new RegExp(`^(${specifiers.map(escapeRegex).join('|')})$`) },
|
|
1175
|
+
async args => {
|
|
1176
|
+
const target = aliases[args.path];
|
|
1177
|
+
if (!target) return undefined;
|
|
1178
|
+
if (path.isAbsolute(target)) return { path: target };
|
|
1179
|
+
return build.resolve(target, {
|
|
1180
|
+
kind: args.kind,
|
|
1181
|
+
importer: args.importer,
|
|
1182
|
+
namespace: args.namespace,
|
|
1183
|
+
resolveDir: args.resolveDir,
|
|
1184
|
+
});
|
|
1185
|
+
},
|
|
1186
|
+
);
|
|
1187
|
+
},
|
|
1188
|
+
};
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
// preact core + preact/compat + react/react-dom/next are single-instance: they must ALWAYS resolve
|
|
1192
|
+
// through the framework/compat alias, which pins one canonical absolute file per specifier. The
|
|
1193
|
+
// linked-package resolver runs BEFORE that alias and matches bare specifiers, so without this guard
|
|
1194
|
+
// a workspace declaring `preact` (or react/react-dom) as a `file:`/`link:` dependency resolves bare
|
|
1195
|
+
// `preact` to the linked copy while pnext-internal compat code keeps resolving to the alias -
|
|
1196
|
+
// shipping TWO physical preact cores. That breaks single-instance option hooks, context identity and
|
|
1197
|
+
// error interception. Never let the linked-package resolver shadow these; the alias owns them.
|
|
1198
|
+
const frameworkAliasedSpecifier = /^(?:preact|react|react-dom|next)(?:\/|$)/;
|
|
1199
|
+
|
|
1200
|
+
function linkedPackageClientResolvePlugin(config: ResolvedConfig): Plugin {
|
|
1201
|
+
const compatPreact = path.resolve(import.meta.dirname, '..', 'compat', 'react', 'preact.ts');
|
|
1202
|
+
const compatReactClient = path.resolve(import.meta.dirname, '..', 'compat', 'react', 'client.ts');
|
|
1203
|
+
return {
|
|
1204
|
+
name: 'pnext-linked-package-client-resolve',
|
|
1205
|
+
setup(build) {
|
|
1206
|
+
build.onResolve({ filter: /^\.\/preact$/ }, args => {
|
|
1207
|
+
// Also matches MIRRORED copies of the compat react shim (build-cache
|
|
1208
|
+
// outputs under node_modules paths, e.g. @next/third-parties client
|
|
1209
|
+
// components) where no sibling ./preact exists; only a real sibling
|
|
1210
|
+
// file (an app's own ./preact.*) opts out.
|
|
1211
|
+
if (path.resolve(args.importer) === compatReactClient) return { path: compatPreact };
|
|
1212
|
+
const hasRealSibling = ['.ts', '.tsx', '.js', '.mjs', '.jsx'].some(ext =>
|
|
1213
|
+
existsSync(path.join(args.resolveDir, `preact${ext}`)),
|
|
1214
|
+
);
|
|
1215
|
+
if (hasRealSibling) return undefined;
|
|
1216
|
+
return { path: compatPreact };
|
|
1217
|
+
});
|
|
1218
|
+
// One build's worth of memo, keyed on exactly what esbuild hands us: the resolver walks
|
|
1219
|
+
// node_modules and probes package targets per call, and a directory of components importing
|
|
1220
|
+
// the same few packages asks the same question repeatedly. Per-BUILD, so a linked package's
|
|
1221
|
+
// layout changing between builds is still seen - esbuild already assumes it is fixed within one.
|
|
1222
|
+
const linked = new Map<string, string | undefined>();
|
|
1223
|
+
build.onResolve({ filter: /^[^./#][^:]*$/ }, args => {
|
|
1224
|
+
if (frameworkAliasedSpecifier.test(args.path)) return undefined;
|
|
1225
|
+
const dir = args.resolveDir || config.root;
|
|
1226
|
+
const key = `${dir}\0${args.path}`;
|
|
1227
|
+
let resolved = linked.get(key);
|
|
1228
|
+
if (resolved === undefined && !linked.has(key)) {
|
|
1229
|
+
resolved = resolveLinkedPackageSpecifier(
|
|
1230
|
+
config.root,
|
|
1231
|
+
path.join(dir, 'pnext-resolve.ts'),
|
|
1232
|
+
args.path,
|
|
1233
|
+
['browser', 'style', 'import', 'default'],
|
|
1234
|
+
);
|
|
1235
|
+
linked.set(key, resolved);
|
|
1236
|
+
}
|
|
1237
|
+
return resolved ? { path: resolved } : undefined;
|
|
1238
|
+
});
|
|
1239
|
+
},
|
|
1240
|
+
};
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
// Mirror of the server build's static-asset seam for the CLIENT bundle. A static image import from a
|
|
1244
|
+
// 'use client' component resolves to a generated ESM module exporting the Next static-image metadata
|
|
1245
|
+
// object - with the asset emitted under `/_next/static/media/...` - rather than a bare `file` loader
|
|
1246
|
+
// URL string. Uses core's `staticAssetModule` extension seam so compat (next/image) supplies the
|
|
1247
|
+
// descriptor and pure-core apps fall back to the generic URL module.
|
|
1248
|
+
const staticImageAssetFilter = /\.(?:png|jpe?g|gif|webp|avif|svg|ico|bmp)(?:$|[?#])/;
|
|
1249
|
+
|
|
1250
|
+
function clientStaticAssetPlugin(config: ResolvedConfig): Plugin {
|
|
1251
|
+
return {
|
|
1252
|
+
name: 'pnext-client-static-assets',
|
|
1253
|
+
setup(build) {
|
|
1254
|
+
build.onResolve({ filter: staticImageAssetFilter }, args => {
|
|
1255
|
+
// A configured `turbopack.rules` loader chain for this extension (e.g.
|
|
1256
|
+
// `*.svg`) preempts the generic static-image resolver: defer so the
|
|
1257
|
+
// compat loader-rule plugin runs the chain instead.
|
|
1258
|
+
if (getAssetExtensions().hasLoaderRuleFor(args.path)) {
|
|
1259
|
+
return undefined;
|
|
1260
|
+
}
|
|
1261
|
+
return {
|
|
1262
|
+
path: resolveAssetPath(args.path, args.resolveDir),
|
|
1263
|
+
namespace: 'pnext-client-static-asset',
|
|
1264
|
+
};
|
|
1265
|
+
});
|
|
1266
|
+
build.onLoad({ filter: /.*/, namespace: 'pnext-client-static-asset' }, async args => ({
|
|
1267
|
+
contents: await staticImageModuleSource(config, args.path),
|
|
1268
|
+
loader: 'js',
|
|
1269
|
+
}));
|
|
1270
|
+
},
|
|
1271
|
+
};
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
function resolveAssetPath(specifier: string, resolveDir: string) {
|
|
1275
|
+
const { sourcePath, hash } = splitAssetHash(specifier);
|
|
1276
|
+
const resolved = path.isAbsolute(sourcePath) ? sourcePath : path.resolve(resolveDir, sourcePath);
|
|
1277
|
+
return `${resolved}${hash}`;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
function splitAssetHash(specifier: string) {
|
|
1281
|
+
const index = specifier.search(/[?#]/);
|
|
1282
|
+
return index === -1
|
|
1283
|
+
? { sourcePath: specifier, hash: '' }
|
|
1284
|
+
: { sourcePath: specifier.slice(0, index), hash: specifier.slice(index) };
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
async function staticImageModuleSource(config: ResolvedConfig, file: string) {
|
|
1288
|
+
const { sourcePath } = splitAssetHash(file);
|
|
1289
|
+
const bytes = new Uint8Array(await readFile(sourcePath));
|
|
1290
|
+
const emitted: string[] = [];
|
|
1291
|
+
const emit = (relative: string) => {
|
|
1292
|
+
emitted.push(relative);
|
|
1293
|
+
return `/${relative}`;
|
|
1294
|
+
};
|
|
1295
|
+
const compat = await getAssetExtensions().staticAssetModule({ sourcePath, bytes, emit });
|
|
1296
|
+
const source = compat ?? coreStaticAssetModule(sourcePath, bytes, emit);
|
|
1297
|
+
for (const relative of emitted) {
|
|
1298
|
+
const target = path.join(config.outPath, 'public', ...relative.split('/'));
|
|
1299
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
1300
|
+
if (!existsSync(target)) await copyFile(sourcePath, target);
|
|
1301
|
+
}
|
|
1302
|
+
return source;
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
// Core's generic static-asset module (no compat override): emit under a hashed
|
|
1306
|
+
// `/_next/static/media` URL and export the URL string as default. Mirrors the
|
|
1307
|
+
// identically named helper in runtime/server.ts + dev/imports.ts.
|
|
1308
|
+
function coreStaticAssetModule(
|
|
1309
|
+
sourcePath: string,
|
|
1310
|
+
bytes: Uint8Array,
|
|
1311
|
+
emit: (relative: string) => string,
|
|
1312
|
+
): string {
|
|
1313
|
+
const ext = path.extname(sourcePath).toLowerCase() || '.bin';
|
|
1314
|
+
const hash = createHash('sha256').update(bytes).digest('hex').slice(0, 8);
|
|
1315
|
+
const base = path.basename(sourcePath, path.extname(sourcePath)).replace(/[^A-Za-z0-9_-]+/g, '-');
|
|
1316
|
+
const relative = path.posix.join('_next', 'static', 'media', `${base}.${hash}${ext}`);
|
|
1317
|
+
const src = emit(relative);
|
|
1318
|
+
return `const src = ${JSON.stringify(src)};\nexport default src;\nexport { src };\n`;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
/**
|
|
1322
|
+
* The single onLoad owner of first-party client source: it reads each file ONCE and runs every
|
|
1323
|
+
* rewrite that file needs - compat's registered transform chain, core's literal `dynamic()` rewrite
|
|
1324
|
+
* and the React Compiler pass - then hands esbuild the result with the right loader.
|
|
1325
|
+
*
|
|
1326
|
+
* Merging matters for correctness as much as for cost: esbuild gives a file to the FIRST plugin
|
|
1327
|
+
* whose onLoad filter matches, so a chain of independent loaders means whichever registers first
|
|
1328
|
+
* silently shadows the rest. That is what switched the React Compiler off for next-compat apps.
|
|
1329
|
+
*
|
|
1330
|
+
* App-convention .js/.mjs may contain JSX (jsxImportSource is preact), so those parse with the jsx
|
|
1331
|
+
* loader; scoped to the source roots, so third-party node_modules .js keeps esbuild's default loader.
|
|
1332
|
+
*/
|
|
1333
|
+
function createClientSourcePipeline(config: ResolvedConfig, dev = false) {
|
|
1334
|
+
const sourceRewrite = nextCompatEnabled(config);
|
|
1335
|
+
const compiler = getCompatModeExtensions().reactCompilerOptions(config);
|
|
1336
|
+
const asyncPre = hasClientSourceAsyncPreTransforms();
|
|
1337
|
+
// Under a source root, and under a root only first-party source plus transpiled packages - said in
|
|
1338
|
+
// the FILTER, not the callback: a dependency's files are the bulk of a real graph, so a callback
|
|
1339
|
+
// would decline most of them one at a time.
|
|
1340
|
+
const roots = clientSourceRoots(config).map(root => escapeRegex(root)).join('|');
|
|
1341
|
+
const sep = escapeRegex(path.sep);
|
|
1342
|
+
const filter = new RegExp(
|
|
1343
|
+
`^(?:${roots})(?:${sep}${nonSegmentPattern('node_modules', sep)})*${sep}[^${sep}]*\\.(?:[cm]?[jt]sx?)$`,
|
|
1344
|
+
);
|
|
1345
|
+
const transpiled = sourceRewrite ? transpiledPackageFilter(roots) : undefined;
|
|
1346
|
+
// One in-flight pipeline per file for the whole build: `warm()` and the
|
|
1347
|
+
// onLoad hook share it, so a warmed file is already done (or in flight on
|
|
1348
|
+
// oxc's threadpool) by the time esbuild asks for it, and a component shared
|
|
1349
|
+
// by twenty routes is prepared once.
|
|
1350
|
+
const prepared = new Map<string, Promise<OnLoadOutput>>();
|
|
1351
|
+
// Deferred dynamic refs THIS pipeline rewrote — persisted as the out-dir sidecar.
|
|
1352
|
+
const deferredRefs = new Map<string, DeferredDynamicRef>();
|
|
1353
|
+
// The text esbuild actually parsed, kept so the prebuilt seam can read a
|
|
1354
|
+
// file's framework imports off the POST-transform source — a compat rewrite
|
|
1355
|
+
// can add or rename one, and the seam must offer exactly what is there.
|
|
1356
|
+
const loaded = new Map<string, string>();
|
|
1357
|
+
|
|
1358
|
+
function prepare(resolved: string): Promise<OnLoadOutput> {
|
|
1359
|
+
const existing = prepared.get(resolved);
|
|
1360
|
+
if (existing) return existing;
|
|
1361
|
+
const pending = runPipeline(resolved);
|
|
1362
|
+
prepared.set(resolved, pending);
|
|
1363
|
+
return pending;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
async function runPipeline(resolved: string): Promise<OnLoadOutput> {
|
|
1367
|
+
const inNodeModules = resolved.split(path.sep).includes('node_modules');
|
|
1368
|
+
// A nested node_modules under a source root is a dependency, not app
|
|
1369
|
+
// source: only an explicitly transpiled package opts into the rewrites,
|
|
1370
|
+
// and none of them are ever React-Compiled.
|
|
1371
|
+
if (inNodeModules && (!sourceRewrite || !isTranspiledPackageFile(resolved))) return undefined;
|
|
1372
|
+
clientProfile.count('loadFiles');
|
|
1373
|
+
// Build-scoped cache: the route-fact walk has already read most of these,
|
|
1374
|
+
// so this is a map lookup rather than a second read of the same file.
|
|
1375
|
+
const original = await clientProfile.timeAsync('read', () => readSourceText(resolved));
|
|
1376
|
+
let contents = original;
|
|
1377
|
+
// Worker bundling runs first and awaits: it consumes `import.meta.url`,
|
|
1378
|
+
// which the sync chain inlines.
|
|
1379
|
+
if (asyncPre) {
|
|
1380
|
+
contents = await applyClientSourceAsyncPreTransforms(contents, resolved, config.root);
|
|
1381
|
+
}
|
|
1382
|
+
contents = clientProfile.time('chain', () =>
|
|
1383
|
+
applyClientSourceTransforms(contents, resolved, config.root),
|
|
1384
|
+
);
|
|
1385
|
+
contents = clientProfile.time('dynamic', () => rewriteLiteralDynamicCalls(contents, resolved));
|
|
1386
|
+
if (dev && devDynamicSplitEnabled()) {
|
|
1387
|
+
contents = rewriteDeferredDynamicImports(
|
|
1388
|
+
contents,
|
|
1389
|
+
resolved,
|
|
1390
|
+
specifier => resolveImport(rootFromFile(resolved), resolved, specifier),
|
|
1391
|
+
target => {
|
|
1392
|
+
const id = clientReferenceId(target.file, target.exportName);
|
|
1393
|
+
registerDeferredDynamicRef(id, target);
|
|
1394
|
+
deferredRefs.set(id, target);
|
|
1395
|
+
return deferredDynamicChunkHref({ id });
|
|
1396
|
+
},
|
|
1397
|
+
);
|
|
1398
|
+
}
|
|
1399
|
+
if (compiler && !inNodeModules && shouldReactCompile(contents, resolved)) {
|
|
1400
|
+
contents = await clientProfile.timeAsync('reactCompiler', () =>
|
|
1401
|
+
reactCompiled(resolved, contents, compiler),
|
|
1402
|
+
);
|
|
1403
|
+
}
|
|
1404
|
+
// Unchanged sources go back to esbuild's native loader; only .js/.mjs
|
|
1405
|
+
// must be claimed to force the jsx parse.
|
|
1406
|
+
loaded.set(resolved, contents);
|
|
1407
|
+
if (contents === original && !/\.m?js$/.test(resolved)) return undefined;
|
|
1408
|
+
return { contents, loader: clientSourceLoader(resolved) };
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
return {
|
|
1412
|
+
/** The parsed text of a file this build has loaded, if it has. */
|
|
1413
|
+
sourceOf(file: string) {
|
|
1414
|
+
return loaded.get(file);
|
|
1415
|
+
},
|
|
1416
|
+
/** Deferred dynamic refs rewritten during this build (dev split). */
|
|
1417
|
+
deferredRefs() {
|
|
1418
|
+
return deferredRefs;
|
|
1419
|
+
},
|
|
1420
|
+
/**
|
|
1421
|
+
* Start the pipeline for sources the route table already names, before esbuild runs. Without it
|
|
1422
|
+
* every file waits to be discovered by the bundler's graph walk and its React Compiler pass
|
|
1423
|
+
* queues behind that walk.
|
|
1424
|
+
*/
|
|
1425
|
+
/** `warm` for whole routes - every client source the route table names. */
|
|
1426
|
+
warmRoutes(routes: RouteManifestEntry[]) {
|
|
1427
|
+
this.warm(routeClientSources(routes));
|
|
1428
|
+
},
|
|
1429
|
+
warm(files: Iterable<string>) {
|
|
1430
|
+
for (const file of files) {
|
|
1431
|
+
const resolved = path.resolve(file);
|
|
1432
|
+
if (!filter.test(resolved) && !transpiled?.test(resolved)) continue;
|
|
1433
|
+
// Errors surface at the onLoad await, where esbuild attributes them to
|
|
1434
|
+
// the importing module; nothing must reject on the warm path.
|
|
1435
|
+
prepare(resolved).catch(() => undefined);
|
|
1436
|
+
}
|
|
1437
|
+
},
|
|
1438
|
+
/**
|
|
1439
|
+
* The single onLoad owner of first-party client source: it reads each file ONCE and runs every
|
|
1440
|
+
* rewrite that file needs, then hands esbuild the result with the right loader. Merging matters
|
|
1441
|
+
* for correctness as much as for cost: esbuild gives a file to the FIRST plugin whose onLoad
|
|
1442
|
+
* filter matches, so independent loaders mean whichever registers first silently shadows the rest.
|
|
1443
|
+
*/
|
|
1444
|
+
plugin(): Plugin {
|
|
1445
|
+
return {
|
|
1446
|
+
name: 'pnext-client-source',
|
|
1447
|
+
setup(build) {
|
|
1448
|
+
// namespace: 'file' — an onLoad without a namespace matches EVERY
|
|
1449
|
+
// namespace, which would hijack virtual modules (e.g. the server-action
|
|
1450
|
+
// client stub) whose paths end in .js and feed back the original source.
|
|
1451
|
+
build.onLoad({ filter, namespace: 'file' }, args => prepare(path.resolve(args.path)));
|
|
1452
|
+
// Second hook, not a wider first one: `transpilePackages` opts a
|
|
1453
|
+
// dependency's own files into the same pipeline, and they are the only
|
|
1454
|
+
// node_modules paths that ever produce contents.
|
|
1455
|
+
if (transpiled) {
|
|
1456
|
+
build.onLoad({ filter: transpiled, namespace: 'file' }, args =>
|
|
1457
|
+
prepare(path.resolve(args.path)),
|
|
1458
|
+
);
|
|
1459
|
+
}
|
|
1460
|
+
},
|
|
1461
|
+
};
|
|
1462
|
+
},
|
|
1463
|
+
};
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
type OnLoadOutput = { contents: string; loader: 'js' | 'ts' | 'tsx' | 'jsx' } | undefined;
|
|
1467
|
+
|
|
1468
|
+
export type ClientSourcePipeline = ReturnType<typeof createClientSourcePipeline>;
|
|
1469
|
+
|
|
1470
|
+
/**
|
|
1471
|
+
* Open the client-source pipeline early so the build can feed it routes as the fact scan resolves
|
|
1472
|
+
* them - the transforms and the React Compiler then run on oxc's threadpool underneath the rest of
|
|
1473
|
+
* the build. Hand the result to `buildClientEntries`; it is the same object the stage would
|
|
1474
|
+
* otherwise create for itself.
|
|
1475
|
+
*/
|
|
1476
|
+
export function startClientSources(config: ResolvedConfig): ClientSourcePipeline {
|
|
1477
|
+
conventionCache.clear();
|
|
1478
|
+
return createClientSourcePipeline(config);
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
// The compiled-source cache is keyed on the file and a hash of its post-rewrite
|
|
1482
|
+
// input, so a rebuild of an unchanged file skips the oxc pass. It holds the
|
|
1483
|
+
// PROMISE, so two concurrent loads of the same source (two routes sharing a
|
|
1484
|
+
// component) share one compile instead of racing two.
|
|
1485
|
+
const reactCompiledCache = new Map<string, { hash: bigint; code: Promise<string> }>();
|
|
1486
|
+
|
|
1487
|
+
function reactCompiled(
|
|
1488
|
+
file: string,
|
|
1489
|
+
source: string,
|
|
1490
|
+
options: NonNullable<ReturnType<CompatModeExtensions['reactCompilerOptions']>>,
|
|
1491
|
+
) {
|
|
1492
|
+
const hash = Bun.hash.wyhash(`${options.target}\0${source}`);
|
|
1493
|
+
const cached = reactCompiledCache.get(file);
|
|
1494
|
+
if (cached?.hash === hash) return cached.code;
|
|
1495
|
+
const code = transformReactCompiler(source, file, options);
|
|
1496
|
+
reactCompiledCache.set(file, { hash, code });
|
|
1497
|
+
return code;
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
// config.root can sit outside the workspace root (and vice versa), so both
|
|
1501
|
+
// anchor the filter — as does each one's realpath, since esbuild reports
|
|
1502
|
+
// resolved paths through the OS realpath.
|
|
1503
|
+
function clientSourceRoots(config: ResolvedConfig) {
|
|
1504
|
+
const roots = new Set<string>();
|
|
1505
|
+
for (const root of [config.root, config.workspaceRoot]) {
|
|
1506
|
+
const resolved = path.resolve(root);
|
|
1507
|
+
roots.add(resolved);
|
|
1508
|
+
try {
|
|
1509
|
+
roots.add(realpathSync.native(resolved));
|
|
1510
|
+
} catch {
|
|
1511
|
+
// A root that cannot be realpath'd is already covered by its literal form.
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
return [...roots];
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
// `.cjs` keeps the plain js loader: CommonJS never carries JSX, and parsing a
|
|
1518
|
+
// `a < b` comparison as JSX would fail the build.
|
|
1519
|
+
function clientSourceLoader(file: string): 'js' | 'ts' | 'tsx' | 'jsx' {
|
|
1520
|
+
if (file.endsWith('.tsx')) return 'tsx';
|
|
1521
|
+
if (/\.[cm]?ts$/.test(file)) return 'ts';
|
|
1522
|
+
return file.endsWith('.cjs') ? 'js' : 'jsx';
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
// A path segment that is NOT `word`, spelled positively: esbuild's Go regexp has
|
|
1526
|
+
// no lookahead, so the negation enumerates "differs at some position / ends
|
|
1527
|
+
// early / runs longer".
|
|
1528
|
+
function nonSegmentPattern(word: string, sep: string) {
|
|
1529
|
+
const parts = [`${word}[^${sep}]+`];
|
|
1530
|
+
for (let index = 0; index < word.length; index += 1) {
|
|
1531
|
+
const prefix = word.slice(0, index);
|
|
1532
|
+
parts.push(prefix, `${prefix}[^${sep}${word[index]}][^${sep}]*`);
|
|
1533
|
+
}
|
|
1534
|
+
return `(?:${parts.join('|')})`;
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
/**
|
|
1538
|
+
* Filter for the node_modules files the pipeline still owns - the packages `transpilePackages` names.
|
|
1539
|
+
* A policy that publishes no list cannot be narrowed safely, so that case keeps every node_modules
|
|
1540
|
+
* path on the hook.
|
|
1541
|
+
*/
|
|
1542
|
+
function transpiledPackageFilter(roots: string): RegExp | undefined {
|
|
1543
|
+
const sep = escapeRegex(path.sep);
|
|
1544
|
+
const names = getExternalPackagePolicy().transpiled?.();
|
|
1545
|
+
if (names?.length === 0) return undefined;
|
|
1546
|
+
const packages = names
|
|
1547
|
+
? `(?:${names.map(name => escapeRegex(name).split('/').join(sep)).join('|')})${sep}`
|
|
1548
|
+
: '';
|
|
1549
|
+
return new RegExp(`^(?:${roots}).*${sep}node_modules${sep}${packages}.*\\.(?:[cm]?[jt]sx?)$`);
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
function isTranspiledPackageFile(file: string) {
|
|
1553
|
+
const parts = file.split(path.sep);
|
|
1554
|
+
const nodeModules = parts.lastIndexOf('node_modules');
|
|
1555
|
+
if (nodeModules === -1) return false;
|
|
1556
|
+
const first = parts[nodeModules + 1];
|
|
1557
|
+
if (!first) return false;
|
|
1558
|
+
const name = first.startsWith('@') ? `${first}/${parts[nodeModules + 2] ?? ''}` : first;
|
|
1559
|
+
return getExternalPackagePolicy().transpile(name);
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
function virtualEntryPath(routeId: string) {
|
|
1563
|
+
return `${virtualEntryNamespace}:${routeId}`;
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
function virtualEntryPlugin(entries: { route: RouteManifestEntry; source: string }[]): Plugin {
|
|
1567
|
+
const sources = new Map(entries.map(entry => [entry.route.id, entry.source]));
|
|
1568
|
+
return {
|
|
1569
|
+
name: 'pnext-virtual-client-entry',
|
|
1570
|
+
setup(build) {
|
|
1571
|
+
build.onResolve({ filter: new RegExp(`^${escapeRegex(virtualEntryNamespace)}:`) }, args => ({
|
|
1572
|
+
path: args.path.slice(virtualEntryNamespace.length + 1),
|
|
1573
|
+
namespace: virtualEntryNamespace,
|
|
1574
|
+
}));
|
|
1575
|
+
build.onLoad({ filter: /.*/, namespace: virtualEntryNamespace }, args => {
|
|
1576
|
+
const contents = sources.get(args.path);
|
|
1577
|
+
if (contents === undefined) return undefined;
|
|
1578
|
+
return { contents, loader: 'ts', resolveDir: process.cwd() };
|
|
1579
|
+
});
|
|
1580
|
+
},
|
|
1581
|
+
};
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
// The shared route runtime: one virtual module per build, imported by every
|
|
1585
|
+
// generated entry stub. With `splitting` on, esbuild hoists it into a single
|
|
1586
|
+
// chunk instead of inlining ~19 KB of identical glue in each route entry.
|
|
1587
|
+
function clientRuntimePlugin(facts: ClientRuntimeFacts): Plugin {
|
|
1588
|
+
return {
|
|
1589
|
+
name: 'pnext-client-runtime',
|
|
1590
|
+
setup(build) {
|
|
1591
|
+
build.onResolve({ filter: new RegExp(`^${escapeRegex(CLIENT_RUNTIME_MODULE)}$`) }, () => ({
|
|
1592
|
+
path: CLIENT_RUNTIME_MODULE,
|
|
1593
|
+
namespace: CLIENT_RUNTIME_MODULE,
|
|
1594
|
+
}));
|
|
1595
|
+
build.onLoad({ filter: /.*/, namespace: CLIENT_RUNTIME_MODULE }, () => ({
|
|
1596
|
+
contents: clientRuntimeSource(facts),
|
|
1597
|
+
loader: 'ts',
|
|
1598
|
+
resolveDir: process.cwd(),
|
|
1599
|
+
}));
|
|
1600
|
+
},
|
|
1601
|
+
};
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
async function profileClientBuild<T>(
|
|
1605
|
+
route: RouteManifestEntry,
|
|
1606
|
+
dev: boolean | undefined,
|
|
1607
|
+
task: () => Promise<T>,
|
|
1608
|
+
) {
|
|
1609
|
+
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
1610
|
+
if (!process.env.PNEXT_DEV_PROFILE) return task();
|
|
1611
|
+
const start = performance.now();
|
|
1612
|
+
try {
|
|
1613
|
+
return await task();
|
|
1614
|
+
} finally {
|
|
1615
|
+
const mode = dev ? 'dev' : 'prod';
|
|
1616
|
+
console.log(
|
|
1617
|
+
`dev-profile client build ${mode} ${route.route} in ${formatDuration(performance.now() - start)}`,
|
|
1618
|
+
);
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
function formatDuration(durationMs: number) {
|
|
1623
|
+
const ms = Math.max(0, durationMs);
|
|
1624
|
+
if (ms < 10) return `${ms.toFixed(1)}ms`;
|
|
1625
|
+
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
1626
|
+
return `${(ms / 1000).toFixed(ms < 10000 ? 2 : 1)}s`;
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
function serverOnlyClientImportPlugin(): Plugin {
|
|
1630
|
+
return {
|
|
1631
|
+
name: 'pnext-server-only-client-import',
|
|
1632
|
+
setup(build) {
|
|
1633
|
+
build.onResolve(
|
|
1634
|
+
{ filter: /^(next|next\/headers|next\/font|next\/font\/google|next\/font\/local)$/ },
|
|
1635
|
+
args => ({
|
|
1636
|
+
errors: [
|
|
1637
|
+
{
|
|
1638
|
+
text: `${args.path} is server-only and cannot be imported from Client Components.`,
|
|
1639
|
+
},
|
|
1640
|
+
],
|
|
1641
|
+
}),
|
|
1642
|
+
);
|
|
1643
|
+
},
|
|
1644
|
+
};
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
interface EsbuildLikeError extends Error {
|
|
1648
|
+
errors?: {
|
|
1649
|
+
text?: string;
|
|
1650
|
+
location?: {
|
|
1651
|
+
file?: string;
|
|
1652
|
+
};
|
|
1653
|
+
}[];
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
interface TraceParent {
|
|
1657
|
+
file: string;
|
|
1658
|
+
specifier: string;
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
const traceSourceExtensions = ['.tsx', '.ts', '.jsx', '.js', '.mts', '.cts', '.module.css', '.css'];
|
|
1662
|
+
|
|
1663
|
+
function withClientImportTrace(
|
|
1664
|
+
error: unknown,
|
|
1665
|
+
config: ResolvedConfig,
|
|
1666
|
+
route: RouteManifestEntry,
|
|
1667
|
+
entrySource: string,
|
|
1668
|
+
) {
|
|
1669
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1670
|
+
const trace = clientImportTrace(config, route, entrySource, error);
|
|
1671
|
+
if (!trace) return error;
|
|
1672
|
+
const next = new Error(`${message}\n\n${trace}`);
|
|
1673
|
+
if (error instanceof Error) {
|
|
1674
|
+
next.name = error.name;
|
|
1675
|
+
next.stack = error.stack ? `${error.stack}\n\n${trace}` : next.stack;
|
|
1676
|
+
Object.assign(next, error);
|
|
1677
|
+
}
|
|
1678
|
+
return next;
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
function clientImportTrace(
|
|
1682
|
+
config: ResolvedConfig,
|
|
1683
|
+
route: RouteManifestEntry,
|
|
1684
|
+
entrySource: string,
|
|
1685
|
+
error: unknown,
|
|
1686
|
+
) {
|
|
1687
|
+
const unresolved = unresolvedImportFromError(error);
|
|
1688
|
+
if (!unresolved) return undefined;
|
|
1689
|
+
|
|
1690
|
+
const entryFile = `${route.id}.ts`;
|
|
1691
|
+
const failedFile = unresolved.file ? resolveTraceFile(unresolved.file) : undefined;
|
|
1692
|
+
const parents = new Map<string, TraceParent>();
|
|
1693
|
+
const visited = new Set<string>();
|
|
1694
|
+
const queue: { file: string; source: string }[] = [{ file: entryFile, source: entrySource }];
|
|
1695
|
+
let unresolvedImporter: string | undefined;
|
|
1696
|
+
|
|
1697
|
+
while (queue.length > 0) {
|
|
1698
|
+
const current = queue.shift();
|
|
1699
|
+
if (!current || visited.has(current.file)) continue;
|
|
1700
|
+
visited.add(current.file);
|
|
1701
|
+
if (visited.size > 2000) break;
|
|
1702
|
+
|
|
1703
|
+
for (const specifier of localImports(current.file, current.source)) {
|
|
1704
|
+
const resolved = resolveTraceImport(config.root, current.file, specifier);
|
|
1705
|
+
if (!resolved) {
|
|
1706
|
+
if (
|
|
1707
|
+
specifier === unresolved.specifier &&
|
|
1708
|
+
(!failedFile || sameFile(current.file, failedFile))
|
|
1709
|
+
) {
|
|
1710
|
+
unresolvedImporter = current.file;
|
|
1711
|
+
queue.length = 0;
|
|
1712
|
+
break;
|
|
1713
|
+
}
|
|
1714
|
+
continue;
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
if (!parents.has(resolved)) parents.set(resolved, { file: current.file, specifier });
|
|
1718
|
+
if (failedFile && sameFile(resolved, failedFile)) {
|
|
1719
|
+
unresolvedImporter = resolved;
|
|
1720
|
+
queue.length = 0;
|
|
1721
|
+
break;
|
|
1722
|
+
}
|
|
1723
|
+
if (!visited.has(resolved) && !isAssetLike(resolved)) {
|
|
1724
|
+
queue.push({ file: resolved, source: readTextSyncSafe(resolved) });
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
if (!unresolvedImporter) return undefined;
|
|
1730
|
+
const trace = tracePath(entryFile, unresolvedImporter, parents);
|
|
1731
|
+
if (trace.length === 0) return undefined;
|
|
1732
|
+
|
|
1733
|
+
return [
|
|
1734
|
+
`pnext client import trace for route ${route.route}:`,
|
|
1735
|
+
...trace.map(
|
|
1736
|
+
(file, index) => `${index === 0 ? ' ' : ' -> '}${displayTraceFile(config, file)}`,
|
|
1737
|
+
),
|
|
1738
|
+
` -> ${unresolved.specifier} (unresolved in browser client bundle)`,
|
|
1739
|
+
].join('\n');
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
/** True when the build failed because an import could not be resolved. */
|
|
1743
|
+
function hasUnresolvedImportError(error: unknown): boolean {
|
|
1744
|
+
const errors = (error as EsbuildLikeError).errors;
|
|
1745
|
+
if (Array.isArray(errors)) {
|
|
1746
|
+
return errors.some(item => item.text?.startsWith('Could not resolve '));
|
|
1747
|
+
}
|
|
1748
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1749
|
+
return message.includes('Could not resolve ');
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
function unresolvedImportFromError(error: unknown) {
|
|
1753
|
+
const buildError = error as EsbuildLikeError;
|
|
1754
|
+
const diagnostic = buildError.errors?.find(item => item.text?.startsWith('Could not resolve '));
|
|
1755
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1756
|
+
const text = diagnostic?.text ?? message;
|
|
1757
|
+
const specifier = /Could not resolve "([^"]+)"/.exec(text)?.[1];
|
|
1758
|
+
if (!specifier) return undefined;
|
|
1759
|
+
return {
|
|
1760
|
+
specifier,
|
|
1761
|
+
file: diagnostic?.location?.file ?? errorFileFromMessage(message),
|
|
1762
|
+
};
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
function errorFileFromMessage(message: string) {
|
|
1766
|
+
const match = /\n\s+([^:\n]+):\d+:\d+:\s+ERROR:\s+Could not resolve /.exec(message);
|
|
1767
|
+
return match?.[1];
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
function resolveTraceImport(root: string, fromFile: string, specifier: string) {
|
|
1771
|
+
if (path.isAbsolute(specifier)) return resolveTraceCandidate(specifier);
|
|
1772
|
+
return resolveImport(root, fromFile, specifier);
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
function resolveTraceFile(file: string) {
|
|
1776
|
+
return canonicalTraceFile(
|
|
1777
|
+
path.isAbsolute(file) ? path.resolve(file) : path.resolve(process.cwd(), file),
|
|
1778
|
+
);
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
function resolveTraceCandidate(base: string) {
|
|
1782
|
+
const ext = path.extname(base);
|
|
1783
|
+
const candidates = ext
|
|
1784
|
+
? [base]
|
|
1785
|
+
: [
|
|
1786
|
+
...traceSourceExtensions.map(extension => `${base}${extension}`),
|
|
1787
|
+
...traceSourceExtensions.map(extension => path.join(base, `index${extension}`)),
|
|
1788
|
+
base,
|
|
1789
|
+
];
|
|
1790
|
+
const resolved = candidates.find(isTraceFile);
|
|
1791
|
+
return resolved ? canonicalTraceFile(resolved) : undefined;
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
function isTraceFile(file: string) {
|
|
1795
|
+
try {
|
|
1796
|
+
return statSync(file).isFile();
|
|
1797
|
+
} catch {
|
|
1798
|
+
return false;
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
function readTextSyncSafe(file: string) {
|
|
1803
|
+
try {
|
|
1804
|
+
return existsSync(file) ? readFileSync(file, 'utf8') : '';
|
|
1805
|
+
} catch {
|
|
1806
|
+
return '';
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
function tracePath(entryFile: string, targetFile: string, parents: Map<string, TraceParent>) {
|
|
1811
|
+
const pathItems = [targetFile];
|
|
1812
|
+
let current = targetFile;
|
|
1813
|
+
|
|
1814
|
+
while (current !== entryFile) {
|
|
1815
|
+
const parent = parents.get(current);
|
|
1816
|
+
if (!parent) return [];
|
|
1817
|
+
current = parent.file;
|
|
1818
|
+
pathItems.push(current);
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
return pathItems.reverse();
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
function sameFile(a: string, b: string) {
|
|
1825
|
+
return canonicalTraceFile(a) === canonicalTraceFile(b);
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
function displayTraceFile(config: ResolvedConfig, file: string) {
|
|
1829
|
+
if (!path.isAbsolute(file)) return `${file} (generated client entry)`;
|
|
1830
|
+
const root = canonicalTraceFile(config.root);
|
|
1831
|
+
const workspaceRoot = canonicalTraceFile(config.workspaceRoot);
|
|
1832
|
+
const resolvedFile = canonicalTraceFile(file);
|
|
1833
|
+
const rootRelative = path.relative(root, resolvedFile);
|
|
1834
|
+
if (!rootRelative.startsWith('..') && !path.isAbsolute(rootRelative)) return rootRelative;
|
|
1835
|
+
const workspaceRelative = path.relative(workspaceRoot, resolvedFile);
|
|
1836
|
+
if (!workspaceRelative.startsWith('..') && !path.isAbsolute(workspaceRelative))
|
|
1837
|
+
return workspaceRelative;
|
|
1838
|
+
return resolvedFile;
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
function canonicalTraceFile(file: string) {
|
|
1842
|
+
try {
|
|
1843
|
+
return realpathSync.native(file);
|
|
1844
|
+
} catch {
|
|
1845
|
+
return path.resolve(file);
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
/** One walked client source, with the stat that lets a later boot skip reading it. */
|
|
1850
|
+
export interface ClientCacheSource {
|
|
1851
|
+
file: string;
|
|
1852
|
+
mtimeMs: number;
|
|
1853
|
+
size: number;
|
|
1854
|
+
hash: string;
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
export interface ClientCacheKeyParts {
|
|
1858
|
+
key: string;
|
|
1859
|
+
staticHash: string;
|
|
1860
|
+
sources: ClientCacheSource[];
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
/**
|
|
1864
|
+
* The half of the key that is not a walked source: the client build pipeline's own sources, the
|
|
1865
|
+
* generated entry, and the route's reference list. A handful of small reads, so it is cheap to
|
|
1866
|
+
* recompute on every lookup - which is what lets the expensive source walk be validated from a
|
|
1867
|
+
* persisted index instead (see dev/client-key-cache.ts).
|
|
1868
|
+
*/
|
|
1869
|
+
export async function clientCacheStaticHash(
|
|
1870
|
+
route: RouteManifestEntry,
|
|
1871
|
+
nextCompat?: boolean,
|
|
1872
|
+
config?: ResolvedConfig,
|
|
1873
|
+
) {
|
|
1874
|
+
// The key must observe convention files added since the last build, so it
|
|
1875
|
+
// never reads a memo an earlier call left behind.
|
|
1876
|
+
conventionCache.clear();
|
|
1877
|
+
const hash = createHash('sha256');
|
|
1878
|
+
for (const file of clientBuildPipelineFiles()) {
|
|
1879
|
+
hash.update(file);
|
|
1880
|
+
hash.update('\0');
|
|
1881
|
+
hash.update(await readText(file));
|
|
1882
|
+
hash.update('\0');
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
hash.update(
|
|
1886
|
+
clientEntrySource({
|
|
1887
|
+
// Dev-only callers (client-key-cache): the key must move with the split
|
|
1888
|
+
// flag or a toggled PNEXT_DYNAMIC_SPLIT would serve the other arm's entry.
|
|
1889
|
+
deferredDynamicHref: devDeferredDynamicHref(true),
|
|
1890
|
+
pageFile: route.client ? route.file : undefined,
|
|
1891
|
+
clientReferences: route.clientReferences,
|
|
1892
|
+
nextCompat,
|
|
1893
|
+
errorFile: nextCompat && config ? routeErrorFile(config, route) : undefined,
|
|
1894
|
+
globalErrorFile: nextCompat && config ? globalErrorFile(config) : undefined,
|
|
1895
|
+
notFoundFile: nextCompat && config ? routeNotFoundFile(config, route) : undefined,
|
|
1896
|
+
controlFlow: nextCompat ? routeThrowsClientControlFlow(route) : false,
|
|
1897
|
+
router: Boolean(route.needsRouterEntry),
|
|
1898
|
+
clientRootLayout: config ? hasClientRootLayout(config, route) : false,
|
|
1899
|
+
shellLayoutOrder: config ? shellLayoutOrder(config, route) : [],
|
|
1900
|
+
}),
|
|
1901
|
+
);
|
|
1902
|
+
hash.update('\0');
|
|
1903
|
+
hash.update(route.client ? route.file : '');
|
|
1904
|
+
hash.update(JSON.stringify(route.clientReferences));
|
|
1905
|
+
return hash.digest('hex');
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
/** The out-dir name: the static half plus every walked source's content hash. */
|
|
1909
|
+
export function clientCacheKeyFrom(
|
|
1910
|
+
staticHash: string,
|
|
1911
|
+
sources: readonly { file: string; hash: string }[],
|
|
1912
|
+
) {
|
|
1913
|
+
const hash = createHash('sha256').update(staticHash).update('\0');
|
|
1914
|
+
for (const source of sources) {
|
|
1915
|
+
hash.update(source.file);
|
|
1916
|
+
hash.update('\0');
|
|
1917
|
+
hash.update(source.hash);
|
|
1918
|
+
hash.update('\0');
|
|
1919
|
+
}
|
|
1920
|
+
return hash.digest('hex').slice(0, 16);
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
export function clientSourceHash(source: string) {
|
|
1924
|
+
return Bun.hash(source).toString(36);
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
/**
|
|
1928
|
+
* Read one walked source. Stat first: a file that changes between the two
|
|
1929
|
+
* records the older stat, so the next boot re-hashes it rather than trusting a
|
|
1930
|
+
* hash that stat never described.
|
|
1931
|
+
*/
|
|
1932
|
+
export async function readClientCacheSource(file: string): Promise<ClientCacheSource> {
|
|
1933
|
+
const stats = await stat(file).catch(() => undefined);
|
|
1934
|
+
return {
|
|
1935
|
+
file,
|
|
1936
|
+
mtimeMs: stats?.mtimeMs ?? 0,
|
|
1937
|
+
size: stats?.size ?? 0,
|
|
1938
|
+
hash: clientSourceHash(await readText(file)),
|
|
1939
|
+
};
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
export async function clientCacheKeyParts(
|
|
1943
|
+
route: RouteManifestEntry,
|
|
1944
|
+
nextCompat?: boolean,
|
|
1945
|
+
config?: ResolvedConfig,
|
|
1946
|
+
staticHash?: string,
|
|
1947
|
+
): Promise<ClientCacheKeyParts> {
|
|
1948
|
+
const staticDigest = staticHash ?? (await clientCacheStaticHash(route, nextCompat, config));
|
|
1949
|
+
const sources = await Promise.all(
|
|
1950
|
+
(await clientSourceFiles(route)).map(file => readClientCacheSource(file)),
|
|
1951
|
+
);
|
|
1952
|
+
return { key: clientCacheKeyFrom(staticDigest, sources), staticHash: staticDigest, sources };
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
export async function clientCacheKey(
|
|
1956
|
+
route: RouteManifestEntry,
|
|
1957
|
+
nextCompat?: boolean,
|
|
1958
|
+
config?: ResolvedConfig,
|
|
1959
|
+
) {
|
|
1960
|
+
return (await clientCacheKeyParts(route, nextCompat, config)).key;
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
function clientBuildPipelineFiles() {
|
|
1964
|
+
return [
|
|
1965
|
+
path.join(import.meta.dirname, 'entry.ts'),
|
|
1966
|
+
path.resolve(import.meta.dirname, '..', 'api', 'router.ts'),
|
|
1967
|
+
path.resolve(import.meta.dirname, '..', 'api', 'client-navigation.ts'),
|
|
1968
|
+
path.join(import.meta.dirname, 'react-compiler.ts'),
|
|
1969
|
+
path.join(import.meta.dirname, 'build.ts'),
|
|
1970
|
+
path.resolve(import.meta.dirname, '..', 'compat', 'react', 'client.ts'),
|
|
1971
|
+
path.resolve(import.meta.dirname, '..', 'compat', 'react', 'preact.ts'),
|
|
1972
|
+
].filter(file => existsSync(file));
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
async function clientSourceFiles(route: RouteManifestEntry) {
|
|
1976
|
+
const entryFiles = [
|
|
1977
|
+
...(route.client ? [route.file] : []),
|
|
1978
|
+
...route.clientReferences.map(reference => reference.file),
|
|
1979
|
+
];
|
|
1980
|
+
const files = new Set<string>();
|
|
1981
|
+
|
|
1982
|
+
await Promise.all(entryFiles.map(file => collectClientSourceFile(file, files)));
|
|
1983
|
+
|
|
1984
|
+
return [...files].sort();
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
// The walk fans out: every file joins `files` before its own read starts, so
|
|
1988
|
+
// concurrent branches still visit each file once. Serially this was the whole
|
|
1989
|
+
// cost of a route's client cache key (~1.8 s on a 300-module route).
|
|
1990
|
+
async function collectClientSourceFile(file: string, files: Set<string>) {
|
|
1991
|
+
if (files.has(file) || !existsSync(file)) return;
|
|
1992
|
+
files.add(file);
|
|
1993
|
+
if (isAssetLike(file)) return;
|
|
1994
|
+
|
|
1995
|
+
const source = rewriteLiteralDynamicCalls(await readText(file), file);
|
|
1996
|
+
await Promise.all(
|
|
1997
|
+
[...localImports(file, source)].map(specifier => {
|
|
1998
|
+
const resolved = resolveImport(rootFromFile(file), file, specifier);
|
|
1999
|
+
return resolved ? collectClientSourceFile(resolved, files) : Promise.resolve();
|
|
2000
|
+
}),
|
|
2001
|
+
);
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
// Value imports, re-exports and literal `import()` of one module, off the
|
|
2005
|
+
// memoized parse (PERF-REWRITES #21) — type-only imports bind nothing and are
|
|
2006
|
+
// already excluded there.
|
|
2007
|
+
export function localImports(file: string, source: string) {
|
|
2008
|
+
const facts = scanFacts(file, source);
|
|
2009
|
+
const imports = new Set<string>();
|
|
2010
|
+
for (const edge of facts.imports) imports.add(edge.specifier);
|
|
2011
|
+
for (const edge of facts.dynamicImports) imports.add(edge.specifier);
|
|
2012
|
+
return imports;
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
function isAssetLike(file: string) {
|
|
2016
|
+
return /\.(css|svg|png|jpe?g|gif|webp|ico|woff2?)$/.test(file);
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
function rootFromFile(file: string) {
|
|
2020
|
+
const parts = file.split(path.sep);
|
|
2021
|
+
const srcApp = parts.lastIndexOf('src');
|
|
2022
|
+
if (srcApp !== -1 && parts[srcApp + 1] === 'app')
|
|
2023
|
+
return parts.slice(0, srcApp).join(path.sep) || path.sep;
|
|
2024
|
+
const app = parts.lastIndexOf('app');
|
|
2025
|
+
if (app !== -1) return parts.slice(0, app).join(path.sep) || path.sep;
|
|
2026
|
+
return path.dirname(file);
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
// esbuild names every shared chunk "chunk-<hash>", which makes bundle reports
|
|
2030
|
+
// unreadable. Rename each one after the module that dominates it by size in
|
|
2031
|
+
// the metafile: the package name for node_modules code, the file stem for app
|
|
2032
|
+
// code. Falls back to "shared" when a build ran without a metafile.
|
|
2033
|
+
async function nameSharedClientChunks(outDir: string, metafile?: Metafile) {
|
|
2034
|
+
const chunksDir = path.join(outDir, 'chunks');
|
|
2035
|
+
const files = (await listFiles(chunksDir)).filter(file => file.endsWith('.js'));
|
|
2036
|
+
const labels = metafile ? chunkLabelsFromMetafile(metafile) : new Map<string, string>();
|
|
2037
|
+
const renames = new Map<string, string>();
|
|
2038
|
+
|
|
2039
|
+
for (const file of files) {
|
|
2040
|
+
const basename = path.basename(file);
|
|
2041
|
+
if (!basename.startsWith('chunk-')) continue;
|
|
2042
|
+
renames.set(basename, `${labels.get(basename) ?? 'shared'}-${hashSuffix(basename)}.js`);
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
if (renames.size === 0) return renames;
|
|
2046
|
+
|
|
2047
|
+
await Promise.all(
|
|
2048
|
+
[...renames].map(async ([from, to]) => {
|
|
2049
|
+
const fromPath = path.join(chunksDir, from);
|
|
2050
|
+
const toPath = path.join(chunksDir, to);
|
|
2051
|
+
if (from === to || !existsSync(fromPath)) return;
|
|
2052
|
+
try {
|
|
2053
|
+
await rename(fromPath, toPath);
|
|
2054
|
+
} catch (error) {
|
|
2055
|
+
// Tolerate a concurrent build having already moved this chunk (ENOENT);
|
|
2056
|
+
// the reference rewrite below still points at the renamed file.
|
|
2057
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
|
2058
|
+
}
|
|
2059
|
+
// Move the external sourcemap alongside its chunk. The `sourceMappingURL`
|
|
2060
|
+
// comment in the renamed chunk is rewritten below (replaceChunkNames matches
|
|
2061
|
+
// the `chunk-<hash>.js` prefix inside `chunk-<hash>.js.map`), so the map file
|
|
2062
|
+
// must adopt the new name to keep the link resolvable.
|
|
2063
|
+
const fromMap = `${fromPath}.map`;
|
|
2064
|
+
if (!existsSync(fromMap)) return;
|
|
2065
|
+
try {
|
|
2066
|
+
await rename(fromMap, `${toPath}.map`);
|
|
2067
|
+
} catch (error) {
|
|
2068
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
|
2069
|
+
}
|
|
2070
|
+
}),
|
|
2071
|
+
);
|
|
2072
|
+
|
|
2073
|
+
// One read+rewrite per output file. An app with a route per page has hundreds
|
|
2074
|
+
// of them, so they go out concurrently rather than one await at a time.
|
|
2075
|
+
const outputs = (await listFiles(outDir)).filter(item => item.endsWith('.js'));
|
|
2076
|
+
await Promise.all(
|
|
2077
|
+
outputs.map(async file => {
|
|
2078
|
+
const source = await readText(file);
|
|
2079
|
+
const next = replaceChunkNames(source, renames);
|
|
2080
|
+
if (next !== source) await writeText(file, next);
|
|
2081
|
+
}),
|
|
2082
|
+
);
|
|
2083
|
+
|
|
2084
|
+
return renames;
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
function hashSuffix(file: string) {
|
|
2088
|
+
return path.basename(file, '.js').split('-').at(-1) ?? 'chunk';
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
function chunkLabelsFromMetafile(metafile: Metafile) {
|
|
2092
|
+
const labels = new Map<string, string>();
|
|
2093
|
+
for (const [outputPath, output] of Object.entries(metafile.outputs)) {
|
|
2094
|
+
const basename = path.basename(outputPath);
|
|
2095
|
+
if (!basename.startsWith('chunk-') || !basename.endsWith('.js')) continue;
|
|
2096
|
+
const weights = new Map<string, number>();
|
|
2097
|
+
for (const [input, { bytesInOutput }] of Object.entries(output.inputs)) {
|
|
2098
|
+
const label = chunkInputLabel(input);
|
|
2099
|
+
if (!label) continue;
|
|
2100
|
+
weights.set(label, (weights.get(label) ?? 0) + bytesInOutput);
|
|
2101
|
+
}
|
|
2102
|
+
const top = [...weights.entries()].sort((a, b) => b[1] - a[1])[0];
|
|
2103
|
+
if (top) labels.set(basename, top[0]);
|
|
2104
|
+
}
|
|
2105
|
+
return labels;
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
function chunkInputLabel(input: string) {
|
|
2109
|
+
const posix = input.split(path.sep).join('/');
|
|
2110
|
+
const packageIndex = posix.lastIndexOf('node_modules/');
|
|
2111
|
+
if (packageIndex !== -1) {
|
|
2112
|
+
const parts = posix.slice(packageIndex + 'node_modules/'.length).split('/');
|
|
2113
|
+
const name = parts[0]?.startsWith('@') ? `${parts[0]}-${parts[1] ?? ''}` : parts[0];
|
|
2114
|
+
return sanitizeChunkLabel(name ?? '');
|
|
2115
|
+
}
|
|
2116
|
+
return sanitizeChunkLabel(
|
|
2117
|
+
posix
|
|
2118
|
+
.split('/')
|
|
2119
|
+
.at(-1)
|
|
2120
|
+
?.replace(/\.[^.]+$/, '') ?? '',
|
|
2121
|
+
);
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
function sanitizeChunkLabel(label: string) {
|
|
2125
|
+
const clean = label
|
|
2126
|
+
.replace(/^@/, '')
|
|
2127
|
+
.replace(/[^a-zA-Z0-9._-]+/g, '-')
|
|
2128
|
+
.replace(/^[-.]+|[-.]+$/g, '')
|
|
2129
|
+
.slice(0, 40);
|
|
2130
|
+
return clean || undefined;
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
function replaceChunkNames(source: string, renames: Map<string, string>) {
|
|
2134
|
+
const pattern = new RegExp([...renames.keys()].map(escapeRegex).join('|'), 'g');
|
|
2135
|
+
return source.replace(pattern, match => renames.get(match) ?? match);
|
|
2136
|
+
}
|