@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,1160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The vendor pipeline: demand scheduling (dedup, package grouping, bounded parallelism), the
|
|
3
|
+
* browser-ready-ESM fast path, and CommonJS export recovery.
|
|
4
|
+
*
|
|
5
|
+
* SCHEDULING. Every vendor demand - a module compile's import scan, an esbuild resolve callback, a
|
|
6
|
+
* transitive dependency of another vendor bundle - enters through `vendorBundle`, so the three
|
|
7
|
+
* duplication sources are handled once:
|
|
8
|
+
*
|
|
9
|
+
* - IDENTICAL demands share one build. The dedup key is the ARTIFACT key (resolved entry file x
|
|
10
|
+
* target x conditions), never the importer's directory: the same package demanded from seven
|
|
11
|
+
* importers resolves to one entry and must compile once.
|
|
12
|
+
* - SIBLING SUBPATHS of one package share one build. Same-package specifiers are inlined rather
|
|
13
|
+
* than externalized (externalizing a CJS entry loses its named exports), so one build per subpath
|
|
14
|
+
* would re-parse the package's whole graph per subpath. Concurrently demanded subpaths become
|
|
15
|
+
* entries of ONE `splitting: true` build instead. DEMANDED subpaths only - see `joinVendorGroup`.
|
|
16
|
+
* - PARALLELISM is capped. Only demands from outside a running build queue: a demand raised by a
|
|
17
|
+
* build's own resolve callback would deadlock behind the slot its parent still holds.
|
|
18
|
+
*
|
|
19
|
+
* Bypassing the queue is not enough on its own, because dedup and grouping both let a nested
|
|
20
|
+
* demand land on work SOMEONE ELSE already queued - the nested caller then waits for the queue
|
|
21
|
+
* while its own parent holds a slot, and once every slot is held that way nothing can move again.
|
|
22
|
+
* So queued work carries a ticket, and a nested demand that lands on one PROMOTES it out of the
|
|
23
|
+
* queue instead of waiting.
|
|
24
|
+
*
|
|
25
|
+
* COMMONJS EXPORT RECOVERY. Externalizing a CommonJS entry loses its named exports (esbuild emits
|
|
26
|
+
* only `default`), which is why every CJS dependency is otherwise inlined into each importer's
|
|
27
|
+
* vendor bundle. A facade restores them: it re-publishes the entry's names off that default object,
|
|
28
|
+
* so the dependency becomes a normal external and is parsed once for the whole app.
|
|
29
|
+
*
|
|
30
|
+
* The name set has to be COMPLETE or the facade is worse than inlining, and a static scan alone is
|
|
31
|
+
* not a safe gate. So the static set is completed by importing the built bundle and reading its
|
|
32
|
+
* keys, and the result is gated by re-bundling the facade - esbuild fails both on a missing file and
|
|
33
|
+
* on "No matching export in X for import Y", so a clean re-bundle proves the facade resolves and
|
|
34
|
+
* exports everything it claims. Packages left with no names (default-only) stay inlined.
|
|
35
|
+
*/
|
|
36
|
+
import { appendFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
37
|
+
import { copyFile, mkdir, readFile } from 'node:fs/promises';
|
|
38
|
+
import path from 'node:path';
|
|
39
|
+
import { fileURLToPath } from 'node:url';
|
|
40
|
+
import { build } from 'esbuild';
|
|
41
|
+
import { isCommonJsModuleSource } from '../resolve/imports';
|
|
42
|
+
import { outputSpecifiers } from '../dev/module-transform';
|
|
43
|
+
import { escapeRegex, isIdentifier, uniqueIdentifier } from '../utils/source';
|
|
44
|
+
import { cachedExistsSync } from '../utils/fs-cache';
|
|
45
|
+
import { writeFileAtomic } from '../utils/fs';
|
|
46
|
+
|
|
47
|
+
// --------------------------------------------------------------------------
|
|
48
|
+
// demand scheduling
|
|
49
|
+
// --------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
export interface VendorGroupMember {
|
|
52
|
+
specifier: string;
|
|
53
|
+
/** Resolved entry file for this subpath. */
|
|
54
|
+
entry: string;
|
|
55
|
+
/** Artifact path, identical to the one a solo build of this subpath writes. */
|
|
56
|
+
file: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface VendorGroupPlan {
|
|
60
|
+
key: string;
|
|
61
|
+
/** The demanded subpath — the only thing a group is ever allowed to compile. */
|
|
62
|
+
member: VendorGroupMember;
|
|
63
|
+
/** `external` are siblings already on disk: referenced, never re-parsed. */
|
|
64
|
+
build: (
|
|
65
|
+
members: VendorGroupMember[],
|
|
66
|
+
external: readonly VendorGroupMember[],
|
|
67
|
+
nested: boolean,
|
|
68
|
+
) => Promise<void>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface VendorBuildPlan {
|
|
72
|
+
/** Artifact key — the ONLY dedup identity. Importer directories never enter it. */
|
|
73
|
+
key: string;
|
|
74
|
+
file: string;
|
|
75
|
+
/** Disk cache / no-compile fast paths; a returned path settles the demand. */
|
|
76
|
+
prepare: () => Promise<string | undefined>;
|
|
77
|
+
/** `nested` marks a build raised from inside another: it may never wait. */
|
|
78
|
+
buildOne: (nested: boolean) => Promise<string>;
|
|
79
|
+
group?: VendorGroupPlan;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Something a demand can be waiting on that has not started yet. A nested demand that lands on one MUST be
|
|
84
|
+
* able to pull it out of the queue - see `promoteVendorWork`.
|
|
85
|
+
*/
|
|
86
|
+
interface VendorPromotable {
|
|
87
|
+
/** Set only while the work sits in the queue; cleared once it runs. */
|
|
88
|
+
promote?: () => void;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface PendingBundle extends VendorPromotable {
|
|
92
|
+
promise: Promise<string>;
|
|
93
|
+
result?: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
interface VendorGroupState {
|
|
97
|
+
/** Demanded, not yet compiled. */
|
|
98
|
+
pending: Map<string, VendorGroupMember>;
|
|
99
|
+
/** Compiled by this group. */
|
|
100
|
+
produced: Map<string, VendorGroupMember>;
|
|
101
|
+
/** Handed back to the single-bundle path — a round with nothing to share. */
|
|
102
|
+
solo: Map<string, VendorGroupMember>;
|
|
103
|
+
round?: Promise<void>;
|
|
104
|
+
/** The current round's queue ticket, while it is still waiting for a slot. */
|
|
105
|
+
ticket?: VendorPromotable;
|
|
106
|
+
rounds: number;
|
|
107
|
+
/** Once closed (failed, or out of rounds) every member builds alone. */
|
|
108
|
+
closed: boolean;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const pendingBundles = new Map<string, PendingBundle>();
|
|
112
|
+
const vendorGroups = new Map<string, VendorGroupState>();
|
|
113
|
+
|
|
114
|
+
// §4e worker sweep (1/2/4/6/7/8/12 → 3.96/2.03/1.68/1.62/1.55/1.94/1.98 s):
|
|
115
|
+
// 6-7 concurrent builds is the optimum and going past core count regresses.
|
|
116
|
+
const VENDOR_CONCURRENCY = Math.max(
|
|
117
|
+
1,
|
|
118
|
+
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
119
|
+
Number(process.env.PNEXT_VENDOR_CONCURRENCY) || 7,
|
|
120
|
+
);
|
|
121
|
+
/**
|
|
122
|
+
* Ceiling on builds that have STARTED and not finished, which is a different resource from the
|
|
123
|
+
* compute slots: a build waiting on a dependency gives its slot back (see `outsideVendorSlot`) but
|
|
124
|
+
* keeps its bundler open, and open bundlers are what peak RSS is made of. Wall time keeps improving
|
|
125
|
+
* as this rises and RSS grows with it, so the value is a choice, not an optimum - but the ceiling is
|
|
126
|
+
* absolute, so memory stays bounded however big the app gets.
|
|
127
|
+
*/
|
|
128
|
+
const VENDOR_OPEN_LIMIT = Math.max(
|
|
129
|
+
VENDOR_CONCURRENCY,
|
|
130
|
+
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
131
|
+
Number(process.env.PNEXT_VENDOR_OPEN_LIMIT) || VENDOR_CONCURRENCY * 8,
|
|
132
|
+
);
|
|
133
|
+
let vendorActive = 0;
|
|
134
|
+
let vendorOpen = 0;
|
|
135
|
+
/** Queued starts; each returns whether it actually took the slot. */
|
|
136
|
+
const vendorQueue: (() => boolean)[] = [];
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Run a vendor build under the concurrency cap. Nested builds - raised by a running build's own resolve
|
|
140
|
+
* callback - bypass the queue: their parent holds a slot until they finish, so making them wait for one
|
|
141
|
+
* deadlocks. They still count as active, which is what backs the outer demands off.
|
|
142
|
+
*
|
|
143
|
+
* `ticket` is how work that DID queue can still be pulled forward: a nested demand that dedups onto it would
|
|
144
|
+
* otherwise wait behind the cap while its own parent holds a slot - the deadlock this seam exists to prevent.
|
|
145
|
+
*/
|
|
146
|
+
function withVendorSlot<T>(
|
|
147
|
+
nested: boolean,
|
|
148
|
+
label: string,
|
|
149
|
+
task: () => Promise<T>,
|
|
150
|
+
ticket?: VendorPromotable,
|
|
151
|
+
): Promise<T> {
|
|
152
|
+
return watchVendorWait(nested ? 'slot-nested' : 'slot', label, new Promise<T>((resolve, reject) => {
|
|
153
|
+
let started = false;
|
|
154
|
+
const run = () => {
|
|
155
|
+
if (started) return false;
|
|
156
|
+
started = true;
|
|
157
|
+
if (ticket) ticket.promote = undefined;
|
|
158
|
+
vendorActive += 1;
|
|
159
|
+
vendorOpen += 1;
|
|
160
|
+
const span = openVendorSpan(label, nested);
|
|
161
|
+
task()
|
|
162
|
+
.then(resolve, reject)
|
|
163
|
+
.finally(() => {
|
|
164
|
+
vendorActive -= 1;
|
|
165
|
+
vendorOpen -= 1;
|
|
166
|
+
closeVendorSpan(span);
|
|
167
|
+
drainVendorQueue();
|
|
168
|
+
});
|
|
169
|
+
return true;
|
|
170
|
+
};
|
|
171
|
+
if (nested || vendorActive < VENDOR_CONCURRENCY) run();
|
|
172
|
+
else {
|
|
173
|
+
if (ticket) ticket.promote = run;
|
|
174
|
+
vendorQueue.push(run);
|
|
175
|
+
}
|
|
176
|
+
}));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Give the slot back for the duration of `task`. A build that is waiting - on a nested dependency's compile,
|
|
181
|
+
* or on another build to finish discovering their shared package's shape - is holding a worker while burning
|
|
182
|
+
* nothing, which caps real parallelism at the depth of the dependency chain rather than at the worker count.
|
|
183
|
+
*/
|
|
184
|
+
export async function outsideVendorSlot<T>(task: () => Promise<T>): Promise<T> {
|
|
185
|
+
// At the open ceiling, keeping the slot IS the back-pressure: yielding it
|
|
186
|
+
// would only admit one more bundler to sit open alongside this one.
|
|
187
|
+
if (vendorOpen >= VENDOR_OPEN_LIMIT) return task();
|
|
188
|
+
vendorActive -= 1;
|
|
189
|
+
drainVendorQueue();
|
|
190
|
+
try {
|
|
191
|
+
return await task();
|
|
192
|
+
} finally {
|
|
193
|
+
vendorActive += 1;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Hand the freed slot to the first queued build that has not been promoted. */
|
|
198
|
+
function drainVendorQueue() {
|
|
199
|
+
while (vendorActive < VENDOR_CONCURRENCY) {
|
|
200
|
+
const next = vendorQueue.shift();
|
|
201
|
+
if (!next) return;
|
|
202
|
+
if (next()) return;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* A nested demand landed on work that is still queued: run it now. Waiting is not an option - the caller's
|
|
208
|
+
* own build holds a slot until this resolves, so every slot can end up held by a build waiting for the queue
|
|
209
|
+
* to move.
|
|
210
|
+
*/
|
|
211
|
+
function promoteVendorWork(work: VendorPromotable | undefined) {
|
|
212
|
+
work?.promote?.();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// --------------------------------------------------------------------------
|
|
216
|
+
// saturation trace (PNEXT_VENDOR_TRACE=1)
|
|
217
|
+
// --------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
/** One build's occupancy of a worker slot — the active-builds-over-time input. */
|
|
220
|
+
export interface VendorBuildSpan {
|
|
221
|
+
label: string;
|
|
222
|
+
nested: boolean;
|
|
223
|
+
startMs: number;
|
|
224
|
+
endMs?: number;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const vendorSpans: VendorBuildSpan[] = [];
|
|
228
|
+
|
|
229
|
+
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
230
|
+
const vendorTracing = () => process.env.PNEXT_VENDOR_TRACE === '1';
|
|
231
|
+
|
|
232
|
+
function openVendorSpan(label: string, nested: boolean) {
|
|
233
|
+
if (!vendorTracing()) return undefined;
|
|
234
|
+
const span: VendorBuildSpan = { label, nested, startMs: performance.now() };
|
|
235
|
+
vendorSpans.push(span);
|
|
236
|
+
return span;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function closeVendorSpan(span: VendorBuildSpan | undefined) {
|
|
240
|
+
if (span) span.endMs = performance.now();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// --------------------------------------------------------------------------
|
|
244
|
+
// TEMPORARY JSONL workload trace (PNEXT_VENDOR_TRACE=<path>)
|
|
245
|
+
// --------------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
// A PATH-valued PNEXT_VENDOR_TRACE appends build/request/edge rows for
|
|
248
|
+
// bench/tools/vendor-analyze.ts; `=1` still means the in-memory spans only.
|
|
249
|
+
const vendorTraceFile = (() => {
|
|
250
|
+
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
251
|
+
const value = process.env.PNEXT_VENDOR_TRACE;
|
|
252
|
+
if (!value || !(value.includes('/') || value.endsWith('.jsonl'))) return undefined;
|
|
253
|
+
return path.resolve(value);
|
|
254
|
+
})();
|
|
255
|
+
|
|
256
|
+
let vendorTraceDirReady = false;
|
|
257
|
+
let vendorTraceSeq = 0;
|
|
258
|
+
|
|
259
|
+
export const vendorTraceEnabled = () => vendorTraceFile !== undefined;
|
|
260
|
+
|
|
261
|
+
export const nextVendorTraceSeq = () => vendorTraceSeq++;
|
|
262
|
+
|
|
263
|
+
/** Append one JSONL row, stamped with the wall clock the analyzer unions on. */
|
|
264
|
+
export function vendorTraceRow(row: Record<string, unknown>) {
|
|
265
|
+
if (!vendorTraceFile) return;
|
|
266
|
+
try {
|
|
267
|
+
if (!vendorTraceDirReady) {
|
|
268
|
+
mkdirSync(path.dirname(vendorTraceFile), { recursive: true });
|
|
269
|
+
vendorTraceDirReady = true;
|
|
270
|
+
}
|
|
271
|
+
appendFileSync(vendorTraceFile, `${JSON.stringify({ ...row, w: Date.now() })}\n`);
|
|
272
|
+
} catch {
|
|
273
|
+
// Tracing must never fail a build.
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Whether this artifact key already has an in-flight or settled build. */
|
|
278
|
+
export function vendorBundleMemHit(key: string) {
|
|
279
|
+
return pendingBundles.has(key);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// --------------------------------------------------------------------------
|
|
283
|
+
// stall watchdog
|
|
284
|
+
// --------------------------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Every vendor await this file performs, so a stalled pipeline can name what it is parked on -
|
|
288
|
+
* without it, a promise that never settles is indistinguishable from slow work. Entries are removed
|
|
289
|
+
* on settle, so a healthy pipeline holds none.
|
|
290
|
+
*/
|
|
291
|
+
const vendorWaits = new Map<number, { label: string; kind: string; startMs: number }>();
|
|
292
|
+
let vendorWaitSeq = 0;
|
|
293
|
+
|
|
294
|
+
const VENDOR_STALL_MS = Math.max(
|
|
295
|
+
0,
|
|
296
|
+
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
297
|
+
Number(process.env.PNEXT_VENDOR_STALL_MS ?? 20_000),
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
/** Waits older than the threshold, oldest first — the stall report's payload. */
|
|
301
|
+
function stalledVendorWaits(now: number) {
|
|
302
|
+
return [...vendorWaits.values()]
|
|
303
|
+
.filter(wait => now - wait.startMs >= VENDOR_STALL_MS)
|
|
304
|
+
.sort((a, b) => a.startMs - b.startMs);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
let stallTimer: ReturnType<typeof setInterval> | undefined;
|
|
308
|
+
|
|
309
|
+
function reportVendorStall() {
|
|
310
|
+
const now = performance.now();
|
|
311
|
+
const stalled = stalledVendorWaits(now);
|
|
312
|
+
if (stalled.length === 0) return;
|
|
313
|
+
const trace = vendorPipelineTrace();
|
|
314
|
+
console.error(
|
|
315
|
+
[
|
|
316
|
+
`PNext vendor pipeline stalled: ${stalled.length} wait(s) over ${Math.round(VENDOR_STALL_MS)}ms.`,
|
|
317
|
+
` slots active=${trace.active}/${trace.concurrency} open=${trace.open}/${trace.openLimit} queued=${trace.queued}`,
|
|
318
|
+
` groups=${vendorGroups.size} unsettled bundles=${trace.unsettled.length}`,
|
|
319
|
+
...stalled.slice(0, 20).map(w => ` [${w.kind}] ${w.label} waiting ${Math.round(now - w.startMs)}ms`),
|
|
320
|
+
...(trace.unsettled.length ? [` unsettled: ${trace.unsettled.slice(0, 20).join(', ')}`] : []),
|
|
321
|
+
` set PNEXT_VENDOR_STALL_MS=0 to silence, PNEXT_VENDOR_GROUP=0 to bisect grouping.`,
|
|
322
|
+
].join('\n'),
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Register `promise` as a scheduler wait. Purely observational — it never
|
|
328
|
+
* changes what resolves — so arming it cannot itself introduce a stall.
|
|
329
|
+
*/
|
|
330
|
+
function watchVendorWait<T>(kind: string, label: string, promise: Promise<T>): Promise<T> {
|
|
331
|
+
if (VENDOR_STALL_MS <= 0) return promise;
|
|
332
|
+
const id = vendorWaitSeq++;
|
|
333
|
+
vendorWaits.set(id, { kind, label, startMs: performance.now() });
|
|
334
|
+
if (!stallTimer) {
|
|
335
|
+
stallTimer = setInterval(reportVendorStall, Math.max(1000, VENDOR_STALL_MS));
|
|
336
|
+
// Never keep the process alive for the watchdog alone.
|
|
337
|
+
stallTimer.unref?.();
|
|
338
|
+
}
|
|
339
|
+
return promise.finally(() => {
|
|
340
|
+
vendorWaits.delete(id);
|
|
341
|
+
if (vendorWaits.size === 0 && stallTimer) {
|
|
342
|
+
clearInterval(stallTimer);
|
|
343
|
+
stallTimer = undefined;
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Scheduler waits currently outstanding — for tests and external diagnostics. */
|
|
349
|
+
export function vendorWaitReport() {
|
|
350
|
+
const now = performance.now();
|
|
351
|
+
return [...vendorWaits.values()].map(w => ({
|
|
352
|
+
kind: w.kind,
|
|
353
|
+
label: w.label,
|
|
354
|
+
waitedMs: now - w.startMs,
|
|
355
|
+
}));
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Scheduler state and every traced build span. Spans are what the worker-
|
|
360
|
+
* saturation trace is computed from; the unsettled list is what a stuck
|
|
361
|
+
* pipeline is diagnosed from.
|
|
362
|
+
*/
|
|
363
|
+
export function vendorPipelineTrace() {
|
|
364
|
+
return {
|
|
365
|
+
concurrency: VENDOR_CONCURRENCY,
|
|
366
|
+
openLimit: VENDOR_OPEN_LIMIT,
|
|
367
|
+
active: vendorActive,
|
|
368
|
+
open: vendorOpen,
|
|
369
|
+
queued: vendorQueue.length,
|
|
370
|
+
spans: vendorSpans,
|
|
371
|
+
unsettled: [...pendingBundles]
|
|
372
|
+
.filter(([, pending]) => pending.result === undefined)
|
|
373
|
+
.map(([key]) => key),
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** The single funnel every vendor demand goes through. */
|
|
378
|
+
export function vendorBundle(plan: VendorBuildPlan, nested = false): Promise<string> {
|
|
379
|
+
const existing = pendingBundles.get(plan.key);
|
|
380
|
+
if (existing) {
|
|
381
|
+
// Dedup is what makes this a deadlock risk: the demand we join may still be
|
|
382
|
+
// queued, and a nested caller cannot afford to wait for the queue to move.
|
|
383
|
+
if (nested) promoteVendorWork(existing);
|
|
384
|
+
return watchVendorWait(nested ? 'dedup-nested' : 'dedup', plan.key, existing.promise);
|
|
385
|
+
}
|
|
386
|
+
const pending: PendingBundle = { promise: undefined as unknown as Promise<string> };
|
|
387
|
+
pending.promise = runVendorDemand(plan, nested, pending).then(
|
|
388
|
+
file => {
|
|
389
|
+
pending.result = file;
|
|
390
|
+
return file;
|
|
391
|
+
},
|
|
392
|
+
error => {
|
|
393
|
+
// A write racing a concurrent `.pnext` wipe rejects; evict so the next
|
|
394
|
+
// demand retries instead of re-awaiting a permanently failed promise.
|
|
395
|
+
pendingBundles.delete(plan.key);
|
|
396
|
+
throw error;
|
|
397
|
+
},
|
|
398
|
+
);
|
|
399
|
+
pendingBundles.set(plan.key, pending);
|
|
400
|
+
return pending.promise;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async function runVendorDemand(plan: VendorBuildPlan, nested: boolean, ticket: VendorPromotable) {
|
|
404
|
+
const ready = await plan.prepare();
|
|
405
|
+
if (ready !== undefined) return ready;
|
|
406
|
+
if (plan.group && (await joinVendorGroup(plan.group, nested, ticket))) return plan.file;
|
|
407
|
+
return withVendorSlot(nested, plan.key, () => plan.buildOne(nested), ticket);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* A round waits this long before it snapshots the group's demands. Sibling subpaths arrive in bursts - one
|
|
412
|
+
* module's import scan, one bundle's resolve callbacks - and the window is what turns a burst into a single
|
|
413
|
+
* build instead of one growth rebuild per straggler.
|
|
414
|
+
*
|
|
415
|
+
* Every demand pays it before it can even learn it has no siblings, and it is a timer on the one JS thread
|
|
416
|
+
* the concurrent bundlers already saturate with plugin IPC, so the real wait is bounded below by this rather
|
|
417
|
+
* than by it. `PNEXT_VENDOR_GROUP_WINDOW_MS` is the bisect seam.
|
|
418
|
+
*/
|
|
419
|
+
const VENDOR_GROUP_WINDOW_MS = (() => {
|
|
420
|
+
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
421
|
+
const override = Number(process.env.PNEXT_VENDOR_GROUP_WINDOW_MS);
|
|
422
|
+
return Number.isFinite(override) && override >= 0 ? override : 5;
|
|
423
|
+
})();
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* A group re-parses the package graph once per round, so an unbounded trickle
|
|
427
|
+
* of stragglers must not turn into an unbounded chain of rebuilds. Past the
|
|
428
|
+
* bound the package goes back to a build per subpath.
|
|
429
|
+
*/
|
|
430
|
+
const MAX_VENDOR_GROUP_ROUNDS = 4;
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Escape hatch: `PNEXT_VENDOR_GROUP=0` puts every subpath back on its own build. Grouping is the one part of
|
|
434
|
+
* the pipeline that changes what a single demand compiles, so it is also the one worth being able to switch
|
|
435
|
+
* off while bisecting a slow or stuck compile.
|
|
436
|
+
*/
|
|
437
|
+
function vendorGroupingEnabled() {
|
|
438
|
+
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
439
|
+
return process.env.PNEXT_VENDOR_GROUP !== '0';
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Attach the demand to its package's group and return whether the group compiled its artifact.
|
|
444
|
+
*
|
|
445
|
+
* Entries are the subpaths ACTUALLY DEMANDED, never the ones the package publishes: hub packages
|
|
446
|
+
* export per-framework adapter subpaths whose entries import frameworks the app does not use (and
|
|
447
|
+
* may not even have installed), so compiling the published set fans one demand out into hundreds of
|
|
448
|
+
* foreign builds. A later sibling therefore grows the entry set and triggers another round;
|
|
449
|
+
* artifacts from earlier rounds stay valid and are referenced as externals, so a growth round costs
|
|
450
|
+
* what that sibling's own build would have cost.
|
|
451
|
+
*/
|
|
452
|
+
async function joinVendorGroup(
|
|
453
|
+
group: VendorGroupPlan,
|
|
454
|
+
nested: boolean,
|
|
455
|
+
ticket: VendorPromotable,
|
|
456
|
+
) {
|
|
457
|
+
if (!vendorGroupingEnabled()) return false;
|
|
458
|
+
let state = vendorGroups.get(group.key);
|
|
459
|
+
if (!state) {
|
|
460
|
+
state = {
|
|
461
|
+
pending: new Map(),
|
|
462
|
+
produced: new Map(),
|
|
463
|
+
solo: new Map(),
|
|
464
|
+
rounds: 0,
|
|
465
|
+
closed: false,
|
|
466
|
+
};
|
|
467
|
+
vendorGroups.set(group.key, state);
|
|
468
|
+
}
|
|
469
|
+
const specifier = group.member.specifier;
|
|
470
|
+
if (state.closed || state.solo.has(specifier)) return false;
|
|
471
|
+
// A demand raised from INSIDE a running round cannot wait for that round:
|
|
472
|
+
// the round is waiting on this demand. Build it alone, and leave it on file
|
|
473
|
+
// so the group externalizes the artifact instead of re-parsing it.
|
|
474
|
+
if (nested && state.round && !state.pending.has(specifier)) {
|
|
475
|
+
state.solo.set(specifier, group.member);
|
|
476
|
+
return false;
|
|
477
|
+
}
|
|
478
|
+
state.pending.set(specifier, group.member);
|
|
479
|
+
// While this demand waits on a shared round, promoting IT has to promote the
|
|
480
|
+
// round — that is the only work its result depends on.
|
|
481
|
+
const groupState = state;
|
|
482
|
+
ticket.promote = () => promoteVendorWork(groupState.ticket);
|
|
483
|
+
try {
|
|
484
|
+
while (!state.produced.has(specifier)) {
|
|
485
|
+
if (state.closed || state.solo.has(specifier)) return false;
|
|
486
|
+
if (nested) promoteVendorWork(state.ticket);
|
|
487
|
+
await watchVendorWait(
|
|
488
|
+
nested ? 'group-round-nested' : 'group-round',
|
|
489
|
+
`${group.key} :: ${specifier}`,
|
|
490
|
+
runVendorGroupRound(group, state, nested),
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
} finally {
|
|
494
|
+
ticket.promote = undefined;
|
|
495
|
+
}
|
|
496
|
+
return true;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function runVendorGroupRound(
|
|
500
|
+
group: VendorGroupPlan,
|
|
501
|
+
state: VendorGroupState,
|
|
502
|
+
nested: boolean,
|
|
503
|
+
): Promise<void> {
|
|
504
|
+
if (!state.round) {
|
|
505
|
+
state.round = vendorGroupRound(group, state, nested).finally(() => {
|
|
506
|
+
state.round = undefined;
|
|
507
|
+
state.ticket = undefined;
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
return state.round;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
async function vendorGroupRound(
|
|
514
|
+
group: VendorGroupPlan,
|
|
515
|
+
state: VendorGroupState,
|
|
516
|
+
nested: boolean,
|
|
517
|
+
) {
|
|
518
|
+
await new Promise(resolve => setTimeout(resolve, VENDOR_GROUP_WINDOW_MS));
|
|
519
|
+
const members = [...state.pending.values()];
|
|
520
|
+
if (members.length === 0) return;
|
|
521
|
+
// A lone subpath with no sibling to share a graph with is the single-bundle
|
|
522
|
+
// path plus chunk plumbing. Hand it back — and keep it on file, so a sibling
|
|
523
|
+
// demanded later externalizes its artifact instead of re-parsing it.
|
|
524
|
+
if (members.length === 1 && state.produced.size === 0) {
|
|
525
|
+
const only = members[0]!;
|
|
526
|
+
state.pending.delete(only.specifier);
|
|
527
|
+
state.solo.set(only.specifier, only);
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
state.rounds += 1;
|
|
531
|
+
const external = [...state.produced.values(), ...state.solo.values()];
|
|
532
|
+
const ticket: VendorPromotable = {};
|
|
533
|
+
state.ticket = ticket;
|
|
534
|
+
try {
|
|
535
|
+
await withVendorSlot(nested, group.key, () => group.build(members, external, nested), ticket);
|
|
536
|
+
for (const member of members) {
|
|
537
|
+
state.pending.delete(member.specifier);
|
|
538
|
+
state.produced.set(member.specifier, member);
|
|
539
|
+
}
|
|
540
|
+
} catch {
|
|
541
|
+
// Grouping is an optimization: one bad sibling must not fail the demand,
|
|
542
|
+
// so the group gives up and every member falls back to its own build.
|
|
543
|
+
state.closed = true;
|
|
544
|
+
}
|
|
545
|
+
if (state.rounds >= MAX_VENDOR_GROUP_ROUNDS) state.closed = true;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// --------------------------------------------------------------------------
|
|
549
|
+
// pre-planned builds: enqueued without an awaiter, drained at route-module eval
|
|
550
|
+
// --------------------------------------------------------------------------
|
|
551
|
+
|
|
552
|
+
const preplanBuilds = new Set<Promise<unknown>>();
|
|
553
|
+
let preplanBuildError: unknown;
|
|
554
|
+
|
|
555
|
+
export function trackPreplanBuild(promise: Promise<unknown>) {
|
|
556
|
+
const tracked = promise.catch(error => {
|
|
557
|
+
preplanBuildError ??= error;
|
|
558
|
+
});
|
|
559
|
+
preplanBuilds.add(tracked);
|
|
560
|
+
void tracked.finally(() => preplanBuilds.delete(tracked));
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** The one join point: nothing evaluates until every enqueued build settles. */
|
|
564
|
+
export async function drainPreplanBuilds() {
|
|
565
|
+
while (preplanBuilds.size > 0) {
|
|
566
|
+
await watchVendorWait('preplan-drain', 'preplan', Promise.allSettled([...preplanBuilds]));
|
|
567
|
+
}
|
|
568
|
+
if (preplanBuildError !== undefined) {
|
|
569
|
+
const error = preplanBuildError;
|
|
570
|
+
preplanBuildError = undefined;
|
|
571
|
+
// eslint-disable-next-line @typescript-eslint/only-throw-error
|
|
572
|
+
throw error;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Forget a bundle whose artifact turned out to be gone (a `pnext build` wiping
|
|
578
|
+
* `.pnext` under a running server). Only the caller holding the stale result
|
|
579
|
+
* evicts, so concurrent demands join the one rebuild instead of racing.
|
|
580
|
+
*/
|
|
581
|
+
export function dropVendorBundle(key: string, stale: string) {
|
|
582
|
+
const pending = pendingBundles.get(key);
|
|
583
|
+
if (pending?.result === stale) pendingBundles.delete(key);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** Forget a group whose artifacts were wiped, so the retry rebuilds them. */
|
|
587
|
+
export function dropVendorGroup(key: string) {
|
|
588
|
+
vendorGroups.delete(key);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
export function clearVendorPipeline() {
|
|
592
|
+
pendingBundles.clear();
|
|
593
|
+
vendorGroups.clear();
|
|
594
|
+
preplanBuilds.clear();
|
|
595
|
+
preplanBuildError = undefined;
|
|
596
|
+
esmDistFiles.clear();
|
|
597
|
+
vendorContentIds.clear();
|
|
598
|
+
vendorSpans.length = 0;
|
|
599
|
+
// In-flight waits belong to the pipeline that was just dropped; leaving them
|
|
600
|
+
// registered would have the watchdog report a stall that no longer exists.
|
|
601
|
+
vendorWaits.clear();
|
|
602
|
+
if (stallTimer) {
|
|
603
|
+
clearInterval(stallTimer);
|
|
604
|
+
stallTimer = undefined;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// --------------------------------------------------------------------------
|
|
609
|
+
// content-addressed artifacts
|
|
610
|
+
// --------------------------------------------------------------------------
|
|
611
|
+
|
|
612
|
+
/** Artifact (or facade) file -> content id of the bytes it resolves to. */
|
|
613
|
+
const vendorContentIds = new Map<string, string>();
|
|
614
|
+
|
|
615
|
+
export function vendorContentId(file: string) {
|
|
616
|
+
return vendorContentIds.get(file);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
export function noteVendorContentId(file: string, id: string) {
|
|
620
|
+
vendorContentIds.set(file, id);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Vendor hrefs replaced by the content id they point at. An href with no id yet
|
|
625
|
+
* keeps its path, which keeps the artifact layer-specific — never the reverse.
|
|
626
|
+
*/
|
|
627
|
+
export function canonicalVendorCode(code: string) {
|
|
628
|
+
return rewriteEmittedRefs(code, ref => {
|
|
629
|
+
if (!ref.specifier.startsWith('file:')) return undefined;
|
|
630
|
+
const id = vendorContentIds.get(fileURLToPath(ref.specifier));
|
|
631
|
+
return id ? `pnext-content:${id}` : undefined;
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// --------------------------------------------------------------------------
|
|
636
|
+
// plugin-free builds: emitted-code surgery
|
|
637
|
+
// --------------------------------------------------------------------------
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* A specifier as it appears in EMITTED code. The plugin-free vendor build lets esbuild externalize by pattern
|
|
641
|
+
* and settles what each reference means here instead - the resolve callbacks were what serialized the bundler
|
|
642
|
+
* and cost a JS bridge hop per file.
|
|
643
|
+
*/
|
|
644
|
+
export interface EmittedRef {
|
|
645
|
+
specifier: string;
|
|
646
|
+
/** esbuild emits an external `require()` as `__require("x")`. */
|
|
647
|
+
require: boolean;
|
|
648
|
+
/** Offsets of the specifier text itself, quotes excluded. */
|
|
649
|
+
start: number;
|
|
650
|
+
end: number;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// Import/export statements, dynamic imports, and (esbuild's) require calls.
|
|
654
|
+
const EMITTED_IMPORT_REF =
|
|
655
|
+
/(?:^|[\s;}])(?:import|export)\s*(?:[\w$*{}\s,]*?\bfrom\s*)?(["'])([^"'\n]*)\1/g;
|
|
656
|
+
const EMITTED_CALL_REF = /(?:^|[^\w$.])(?:(__)?require|import)\s*\(\s*(["'])([^"'\n]*)\2\s*\)/g;
|
|
657
|
+
|
|
658
|
+
/** Every bare-specifier reference the emitted code still carries. */
|
|
659
|
+
export function emittedRefs(code: string): EmittedRef[] {
|
|
660
|
+
const refs: EmittedRef[] = [];
|
|
661
|
+
const seen = new Set<number>();
|
|
662
|
+
for (const match of code.matchAll(EMITTED_IMPORT_REF)) {
|
|
663
|
+
const start = match.index + match[0].length - match[2]!.length - 1;
|
|
664
|
+
if (seen.has(start)) continue;
|
|
665
|
+
seen.add(start);
|
|
666
|
+
refs.push({ specifier: match[2]!, require: false, start, end: start + match[2]!.length });
|
|
667
|
+
}
|
|
668
|
+
for (const match of code.matchAll(EMITTED_CALL_REF)) {
|
|
669
|
+
const value = match[3]!;
|
|
670
|
+
const start = match.index + match[0].lastIndexOf(value);
|
|
671
|
+
if (seen.has(start)) continue;
|
|
672
|
+
seen.add(start);
|
|
673
|
+
refs.push({
|
|
674
|
+
specifier: value,
|
|
675
|
+
require: match[0].includes('require'),
|
|
676
|
+
start,
|
|
677
|
+
end: start + value.length,
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
return refs.sort((a, b) => a.start - b.start);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Repoint emitted references. `replace` returns the new specifier, or undefined
|
|
685
|
+
* to leave one alone — a bare package name the runtime resolves itself.
|
|
686
|
+
*/
|
|
687
|
+
export function rewriteEmittedRefs(
|
|
688
|
+
code: string,
|
|
689
|
+
replace: (ref: EmittedRef) => string | undefined,
|
|
690
|
+
) {
|
|
691
|
+
let out = '';
|
|
692
|
+
let cursor = 0;
|
|
693
|
+
for (const ref of emittedRefs(code)) {
|
|
694
|
+
const next = replace(ref);
|
|
695
|
+
if (next === undefined || next === ref.specifier) continue;
|
|
696
|
+
out += code.slice(cursor, ref.start) + next.replace(/\\/g, '\\\\');
|
|
697
|
+
cursor = ref.end;
|
|
698
|
+
}
|
|
699
|
+
return cursor === 0 ? code : out + code.slice(cursor);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Export names an emitted module publishes, or undefined when the set cannot be
|
|
704
|
+
* proven (a star re-export). Used to check that a repointed reference can
|
|
705
|
+
* actually bind what its importer asks for.
|
|
706
|
+
*/
|
|
707
|
+
export function emittedExportNames(code: string): Set<string> | undefined {
|
|
708
|
+
const names = new Set<string>();
|
|
709
|
+
if (/(^|\n)\s*export\s*\*\s*from/.test(code)) return undefined;
|
|
710
|
+
for (const match of code.matchAll(/(^|[\s;}])export\s*\{([^}]*)\}/g)) {
|
|
711
|
+
for (const entry of match[2]!.split(',')) {
|
|
712
|
+
const name = entry.trim().split(/\s+as\s+/).pop()?.trim();
|
|
713
|
+
if (name) names.add(name);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
for (const match of code.matchAll(
|
|
717
|
+
/(^|[\s;}])export\s+(?:declare\s+)?(?:async\s+)?(?:const|let|var|function\*?|class)\s+([A-Za-z_$][\w$]*)/g,
|
|
718
|
+
)) {
|
|
719
|
+
names.add(match[2]!);
|
|
720
|
+
}
|
|
721
|
+
if (/(^|[\s;}])export\s+default[\s({[]/.test(code)) names.add('default');
|
|
722
|
+
return names;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Republish a bundled CommonJS entry's named exports off its default export.
|
|
727
|
+
* Applied to the bundle itself (not a separate module) for the inlined case.
|
|
728
|
+
*/
|
|
729
|
+
export function addCommonJsNamedExports(code: string) {
|
|
730
|
+
if (hasNonDefaultExport(code)) return code;
|
|
731
|
+
const names = [...new Set([...commonJsExportNames(code), ...esbuildEntryExportNames(code)])]
|
|
732
|
+
.filter(name => name !== 'default' && name !== '__esModule')
|
|
733
|
+
.sort();
|
|
734
|
+
if (names.length === 0) return code;
|
|
735
|
+
const defaultExport = /(^|\n)export default ([^;\n]+);/.exec(code);
|
|
736
|
+
if (!defaultExport?.[2]) return code;
|
|
737
|
+
const defaultName = uniqueIdentifier(code, '__pnext_cjs_default');
|
|
738
|
+
const usedNames = [defaultName];
|
|
739
|
+
const bindings = names.map(name => {
|
|
740
|
+
const localName = uniqueIdentifier(code, `__pnext_cjs_export_${name}`, ...usedNames);
|
|
741
|
+
usedNames.push(localName);
|
|
742
|
+
return { name, localName };
|
|
743
|
+
});
|
|
744
|
+
const namedBindings = bindings
|
|
745
|
+
.map(({ name, localName }) => `const ${localName} = ${defaultName}[${JSON.stringify(name)}];`)
|
|
746
|
+
.join('\n');
|
|
747
|
+
const namedExports = bindings.map(({ name, localName }) => `${localName} as ${name}`).join(', ');
|
|
748
|
+
return code.replace(
|
|
749
|
+
defaultExport[0],
|
|
750
|
+
`${defaultExport[1]}const ${defaultName} = ${defaultExport[2]};\nexport default ${defaultName};\n${namedBindings}\nexport { ${namedExports} };\n`,
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function hasNonDefaultExport(code: string) {
|
|
755
|
+
return /(^|\n)export\s+(?:\*|\{[^}]*\b(?!default\b)[A-Za-z_$][\w$]*\b|(?:const|let|var|function|class)\s+)/.test(
|
|
756
|
+
code,
|
|
757
|
+
);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
export function commonJsExportNames(code: string) {
|
|
761
|
+
return [
|
|
762
|
+
...new Set([
|
|
763
|
+
...[...code.matchAll(/\bexports\.([A-Za-z_$][\w$]*)\s*=/g)].map(match => match[1]),
|
|
764
|
+
...[
|
|
765
|
+
...code.matchAll(/\bObject\.defineProperty\(\s*exports\s*,\s*["']([A-Za-z_$][\w$]*)["']/g),
|
|
766
|
+
].map(match => match[1]),
|
|
767
|
+
]),
|
|
768
|
+
]
|
|
769
|
+
.filter((name): name is string => Boolean(name && name !== '__esModule' && isIdentifier(name)))
|
|
770
|
+
.sort();
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// Recover named exports from esbuild's `__export(entry, {...}); module.exports =
|
|
774
|
+
// __toCommonJS(entry)` shape. The entry is emitted last, so the final __toCommonJS.
|
|
775
|
+
function esbuildEntryExportNames(code: string) {
|
|
776
|
+
const toCommonJs = [
|
|
777
|
+
...code.matchAll(/module\.exports\s*=\s*__toCommonJS\w*\(\s*([A-Za-z_$][\w$]*)\s*\)/g),
|
|
778
|
+
];
|
|
779
|
+
const entryVar = toCommonJs.at(-1)?.[1];
|
|
780
|
+
if (!entryVar) return [];
|
|
781
|
+
const exportCall = new RegExp(
|
|
782
|
+
`__export\\w*\\(\\s*${escapeRegex(entryVar)}\\s*,\\s*\\{([\\s\\S]*?)\\}\\s*\\)`,
|
|
783
|
+
).exec(code);
|
|
784
|
+
if (!exportCall?.[1]) return [];
|
|
785
|
+
return [...exportCall[1].matchAll(/(?:^|,)\s*([A-Za-z_$][\w$]*)\s*:/g)]
|
|
786
|
+
.map(match => match[1])
|
|
787
|
+
.filter((name): name is string => Boolean(name && isIdentifier(name)));
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* The gate every plugin-free artifact has to pass before it is published: esbuild re-bundles the emitted
|
|
792
|
+
* entry, so its own chunk graph has to resolve and its syntax has to be real, and every reference the
|
|
793
|
+
* post-pass repointed has to exist on disk. `PNEXT_VENDOR_VERIFY=deep` additionally pulls those references
|
|
794
|
+
* INTO the re-bundle, which is what makes esbuild check that each one exports the names its importer binds -
|
|
795
|
+
* the audit configuration. Returns a failure reason, or undefined when the artifact is clean.
|
|
796
|
+
*/
|
|
797
|
+
export async function verifyVendorArtifact(
|
|
798
|
+
file: string,
|
|
799
|
+
code: string,
|
|
800
|
+
repointed: ReadonlyMap<string, string>,
|
|
801
|
+
/** Shared chunks of the same build, which are not on disk yet. */
|
|
802
|
+
chunks: ReadonlyMap<string, string> = new Map(),
|
|
803
|
+
) {
|
|
804
|
+
for (const [specifier, target] of repointed) {
|
|
805
|
+
if (!existsSync(target)) return `repointed ${specifier} -> missing ${target}`;
|
|
806
|
+
}
|
|
807
|
+
// Nothing was rewritten and nothing was split out: this is esbuild's own
|
|
808
|
+
// output, unedited, and there is no claim left for a re-bundle to check.
|
|
809
|
+
if (repointed.size === 0 && chunks.size === 0) return undefined;
|
|
810
|
+
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
811
|
+
const mode = process.env.PNEXT_VENDOR_VERIFY;
|
|
812
|
+
if (mode === 'off') return undefined;
|
|
813
|
+
const deep = mode === 'deep';
|
|
814
|
+
const outDir = path.dirname(file);
|
|
815
|
+
try {
|
|
816
|
+
await build({
|
|
817
|
+
stdin: { contents: code, resolveDir: outDir, sourcefile: file, loader: 'js' },
|
|
818
|
+
bundle: true,
|
|
819
|
+
write: false,
|
|
820
|
+
format: 'esm',
|
|
821
|
+
platform: 'neutral',
|
|
822
|
+
target: 'es2022',
|
|
823
|
+
packages: 'external',
|
|
824
|
+
logLevel: 'silent',
|
|
825
|
+
plugins: [
|
|
826
|
+
{
|
|
827
|
+
name: 'pnext-vendor-verify',
|
|
828
|
+
setup(verify) {
|
|
829
|
+
// The entry's own chunks are still in memory — verification runs
|
|
830
|
+
// before anything is published, so nothing half-written is ever
|
|
831
|
+
// readable by a concurrent demand.
|
|
832
|
+
verify.onResolve({ filter: /^\.\.?\// }, args => {
|
|
833
|
+
const resolved = path.resolve(args.resolveDir, args.path);
|
|
834
|
+
return chunks.has(resolved)
|
|
835
|
+
? { path: resolved, namespace: 'pnext-vendor-chunk' }
|
|
836
|
+
: undefined;
|
|
837
|
+
});
|
|
838
|
+
verify.onLoad({ filter: /.*/, namespace: 'pnext-vendor-chunk' }, args => ({
|
|
839
|
+
contents: chunks.get(args.path),
|
|
840
|
+
loader: 'js',
|
|
841
|
+
resolveDir: path.dirname(args.path),
|
|
842
|
+
}));
|
|
843
|
+
verify.onResolve({ filter: /^file:/ }, args => {
|
|
844
|
+
const resolved = fileURLToPath(args.path);
|
|
845
|
+
// Deep: one level in (esbuild then checks the export names), and
|
|
846
|
+
// only from the artifact itself — its dependencies' own externals
|
|
847
|
+
// stay external, so the closure cannot run away.
|
|
848
|
+
const inside = args.importer === file || args.importer.startsWith(outDir);
|
|
849
|
+
return { path: resolved, external: !deep || !inside };
|
|
850
|
+
});
|
|
851
|
+
},
|
|
852
|
+
},
|
|
853
|
+
],
|
|
854
|
+
});
|
|
855
|
+
} catch (error) {
|
|
856
|
+
return (error instanceof Error ? error.message : String(error)).replace(/\s+/g, ' ').slice(0, 300);
|
|
857
|
+
}
|
|
858
|
+
return undefined;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// --------------------------------------------------------------------------
|
|
862
|
+
// browser-ready ESM dist fast path
|
|
863
|
+
// --------------------------------------------------------------------------
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* Some packages already ship exactly what the vendor pass would produce: an ESM dist whose modules only
|
|
867
|
+
* import each other by relative path. Compiling those is pure waste - nothing in the build would change them,
|
|
868
|
+
* because none of our plugins have anything to claim. Copy the closure instead, preserving the relative
|
|
869
|
+
* layout so its specifiers keep resolving, and skip esbuild entirely.
|
|
870
|
+
*
|
|
871
|
+
* The predicate is deliberately strict: ONE bare specifier, one asset import, or one CommonJS shape anywhere
|
|
872
|
+
* in the closure means a plugin could have fired, so the package goes back to the bundler. A false negative
|
|
873
|
+
* costs a compile; a false positive ships wrong code.
|
|
874
|
+
*/
|
|
875
|
+
const MAX_ESM_DIST_FILES = 4096;
|
|
876
|
+
const ESM_DIST_ENTRY_MARKER = 'entry';
|
|
877
|
+
|
|
878
|
+
const esmDists = new Map<string, Promise<string | undefined>>();
|
|
879
|
+
|
|
880
|
+
export function copyBrowserReadyEsmDist(entry: string, distDir: string) {
|
|
881
|
+
// One copy per destination: a second copier would rewrite files the first has
|
|
882
|
+
// already published, and copyFile is not atomic against a concurrent import.
|
|
883
|
+
let dist = esmDists.get(distDir);
|
|
884
|
+
if (!dist) {
|
|
885
|
+
dist = copyBrowserReadyEsmDistUncached(entry, distDir);
|
|
886
|
+
esmDists.set(distDir, dist);
|
|
887
|
+
}
|
|
888
|
+
return dist;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
async function copyBrowserReadyEsmDistUncached(entry: string, distDir: string) {
|
|
892
|
+
const existing = await readFile(path.join(distDir, ESM_DIST_ENTRY_MARKER), 'utf8').catch(
|
|
893
|
+
() => undefined,
|
|
894
|
+
);
|
|
895
|
+
if (existing !== undefined) return path.join(distDir, existing);
|
|
896
|
+
|
|
897
|
+
const closure = await collectBrowserReadyEsmClosure(entry);
|
|
898
|
+
if (!closure) return undefined;
|
|
899
|
+
// Copies keep their offsets relative to the shallowest directory the closure
|
|
900
|
+
// touches, which is what makes every `../` in it still land on its target.
|
|
901
|
+
const base = commonAncestorDir([...closure]);
|
|
902
|
+
const relative = (file: string) => path.relative(base, file);
|
|
903
|
+
await Promise.all(
|
|
904
|
+
[...closure].map(async file => {
|
|
905
|
+
const target = path.join(distDir, relative(file));
|
|
906
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
907
|
+
await copyFile(file, target);
|
|
908
|
+
}),
|
|
909
|
+
);
|
|
910
|
+
await writeFileAtomic(path.join(distDir, ESM_DIST_ENTRY_MARKER), relative(entry));
|
|
911
|
+
return path.join(distDir, relative(entry));
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
const ESM_DIST_EXTENSIONS = new Set(['.js', '.mjs']);
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* Per-FILE verdict, memoized: the predicate runs once per demanded artifact and
|
|
918
|
+
* a package's subpaths share almost all of their closure, so the same few
|
|
919
|
+
* thousand files were re-read, re-regexed and re-`existsSync`ed per subpath.
|
|
920
|
+
* `undefined` means "not browser-ready", which fails the whole closure.
|
|
921
|
+
*/
|
|
922
|
+
const esmDistFiles = new Map<string, Promise<string[] | undefined>>();
|
|
923
|
+
|
|
924
|
+
function browserReadyEsmImports(file: string) {
|
|
925
|
+
let entry = esmDistFiles.get(file);
|
|
926
|
+
if (!entry) {
|
|
927
|
+
entry = browserReadyEsmImportsUncached(file);
|
|
928
|
+
esmDistFiles.set(file, entry);
|
|
929
|
+
}
|
|
930
|
+
return entry;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
async function browserReadyEsmImportsUncached(file: string) {
|
|
934
|
+
if (!ESM_DIST_EXTENSIONS.has(path.extname(file))) return undefined;
|
|
935
|
+
const source = await readFile(file, 'utf8').catch(() => undefined);
|
|
936
|
+
if (source === undefined) return undefined;
|
|
937
|
+
// `use cache` would need the post-bundle source transforms this path skips.
|
|
938
|
+
if (
|
|
939
|
+
isCommonJsModuleSource(source, file) ||
|
|
940
|
+
/\brequire\s*\(|\bmodule\.exports\b/.test(source) ||
|
|
941
|
+
source.includes('use cache')
|
|
942
|
+
) {
|
|
943
|
+
return undefined;
|
|
944
|
+
}
|
|
945
|
+
const imports: string[] = [];
|
|
946
|
+
for (const found of outputSpecifiers(source)) {
|
|
947
|
+
const specifier = found.value;
|
|
948
|
+
if (!specifier.startsWith('.')) return undefined;
|
|
949
|
+
const resolved = resolveEsmDistImport(path.dirname(file), specifier);
|
|
950
|
+
if (!resolved) return undefined;
|
|
951
|
+
imports.push(resolved);
|
|
952
|
+
}
|
|
953
|
+
return imports;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
async function collectBrowserReadyEsmClosure(entry: string) {
|
|
957
|
+
const closure = new Set<string>();
|
|
958
|
+
const queue = [entry];
|
|
959
|
+
while (queue.length > 0) {
|
|
960
|
+
const file = queue.pop()!;
|
|
961
|
+
if (closure.has(file)) continue;
|
|
962
|
+
if (closure.size >= MAX_ESM_DIST_FILES) return undefined;
|
|
963
|
+
closure.add(file);
|
|
964
|
+
const imports = await browserReadyEsmImports(file);
|
|
965
|
+
if (!imports) return undefined;
|
|
966
|
+
queue.push(...imports);
|
|
967
|
+
}
|
|
968
|
+
// A single-file "closure" is a re-export shim or a stub; bundling it is
|
|
969
|
+
// already free, and the marker directory would cost more than it saves.
|
|
970
|
+
return closure.size > 1 ? closure : undefined;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/** Relative resolution limited to what a browser would do: exact file first. */
|
|
974
|
+
function resolveEsmDistImport(fromDir: string, specifier: string) {
|
|
975
|
+
const base = path.resolve(fromDir, specifier);
|
|
976
|
+
for (const candidate of [base, `${base}.js`, `${base}.mjs`, path.join(base, 'index.js')]) {
|
|
977
|
+
if (ESM_DIST_EXTENSIONS.has(path.extname(candidate)) && cachedExistsSync(candidate)) {
|
|
978
|
+
return candidate;
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
return undefined;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
function commonAncestorDir(files: string[]) {
|
|
985
|
+
const segmentLists = files.map(file => path.dirname(file).split(path.sep));
|
|
986
|
+
const shortest = Math.min(...segmentLists.map(segments => segments.length));
|
|
987
|
+
const common: string[] = [];
|
|
988
|
+
for (let index = 0; index < shortest; index += 1) {
|
|
989
|
+
const segment = segmentLists[0]![index]!;
|
|
990
|
+
if (segmentLists.some(segments => segments[index] !== segment)) break;
|
|
991
|
+
common.push(segment);
|
|
992
|
+
}
|
|
993
|
+
return common.join(path.sep) || path.sep;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// --------------------------------------------------------------------------
|
|
997
|
+
// facades
|
|
998
|
+
// --------------------------------------------------------------------------
|
|
999
|
+
|
|
1000
|
+
const REEXPORT_CALL = /\bmodule\.exports\s*=\s*require\(\s*(['"])([^'"]+)\1\s*\)/;
|
|
1001
|
+
|
|
1002
|
+
/**
|
|
1003
|
+
* Cheap pre-filter: can this CommonJS entry plausibly carry named exports? A
|
|
1004
|
+
* package whose entry publishes nothing but `module.exports = fn` is
|
|
1005
|
+
* default-only and never worth building separately just to discard the facade.
|
|
1006
|
+
* Re-exporting entries (`module.exports = require('./lib')`) are followed one
|
|
1007
|
+
* level, which is where most packages' real export list lives.
|
|
1008
|
+
*/
|
|
1009
|
+
export async function cjsEntryMayHaveNamedExports(entry: string) {
|
|
1010
|
+
const source = await readFile(entry, 'utf8').catch(() => undefined);
|
|
1011
|
+
if (source === undefined) return false;
|
|
1012
|
+
if (commonJsExportNames(source).length > 0) return true;
|
|
1013
|
+
const reexport = REEXPORT_CALL.exec(source);
|
|
1014
|
+
const target = reexport?.[2];
|
|
1015
|
+
if (!target?.startsWith('.')) return false;
|
|
1016
|
+
for (const candidate of [target, `${target}.js`, path.join(target, 'index.js')]) {
|
|
1017
|
+
const file = path.resolve(path.dirname(entry), candidate);
|
|
1018
|
+
if (!existsSync(file) || !file.endsWith('.js')) continue;
|
|
1019
|
+
const inner = await readFile(file, 'utf8').catch(() => undefined);
|
|
1020
|
+
if (inner && commonJsExportNames(inner).length > 0) return true;
|
|
1021
|
+
}
|
|
1022
|
+
return false;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/**
|
|
1026
|
+
* Facade shape, bumped whenever this file changes what it emits. The artifact is content-keyed to the
|
|
1027
|
+
* BUNDLE, so without it a pnext release that fixes the facade keeps reusing the one the previous release
|
|
1028
|
+
* wrote - and deleting the stale files by hand dangles every importer that baked the name in, which only a
|
|
1029
|
+
* full cache wipe recovers from. A new key sidesteps both: the old facade stays on disk for whoever still
|
|
1030
|
+
* imports it, and every new compile names the new one.
|
|
1031
|
+
*/
|
|
1032
|
+
const FACADE_GENERATOR = 'v2';
|
|
1033
|
+
|
|
1034
|
+
/** Marker written beside a bundle whose export set turned out to be default-only. */
|
|
1035
|
+
const NO_FACADE_SUFFIX = `.no-facade-${FACADE_GENERATOR}`;
|
|
1036
|
+
|
|
1037
|
+
/** The facade this generator writes for `bundleFile`. */
|
|
1038
|
+
export function cjsFacadePath(bundleFile: string) {
|
|
1039
|
+
return `${bundleFile.replace(/\.mjs$/, '')}.facade-${FACADE_GENERATOR}.mjs`;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
/** Where this generator records "no named exports" for `bundleFile`. */
|
|
1043
|
+
export function cjsNoFacadeMarker(bundleFile: string) {
|
|
1044
|
+
return `${bundleFile}${NO_FACADE_SUFFIX}`;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
const facades = new Map<string, Promise<string | undefined>>();
|
|
1048
|
+
|
|
1049
|
+
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
1050
|
+
const heavyProfFile = process.env.PNEXT_HEAVY_PROF;
|
|
1051
|
+
export function heavyProfRow(row: Record<string, unknown>) {
|
|
1052
|
+
if (!heavyProfFile) return;
|
|
1053
|
+
try { appendFileSync(heavyProfFile, `${JSON.stringify(row)}\n`); } catch { /* prof only */ }
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/**
|
|
1057
|
+
* Emit (once, persisted) a named-export facade for a bundled CommonJS package and return its path, or
|
|
1058
|
+
* undefined when the package has no named exports or the facade fails verification - in both cases the caller
|
|
1059
|
+
* inlines instead.
|
|
1060
|
+
*/
|
|
1061
|
+
export function cjsNamedExportFacade(bundleFile: string, importHref: string) {
|
|
1062
|
+
let facade = facades.get(bundleFile);
|
|
1063
|
+
if (!facade) {
|
|
1064
|
+
facade = writeCjsNamedExportFacade(bundleFile, importHref).catch(() => undefined);
|
|
1065
|
+
facades.set(bundleFile, facade);
|
|
1066
|
+
}
|
|
1067
|
+
return facade;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
async function writeCjsNamedExportFacade(bundleFile: string, importHref: string) {
|
|
1071
|
+
const file = cjsFacadePath(bundleFile);
|
|
1072
|
+
// The facade's identity follows its bundle's, so an importer of one canonical-
|
|
1073
|
+
// izes the same way an importer of the other does.
|
|
1074
|
+
const bundleId = vendorContentIds.get(bundleFile);
|
|
1075
|
+
if (bundleId) vendorContentIds.set(file, `${bundleId}:facade`);
|
|
1076
|
+
// Both outcomes persist: the facade is content-keyed with the bundle it wraps,
|
|
1077
|
+
// so a restart never re-executes the package to re-derive its names.
|
|
1078
|
+
if (existsSync(file)) return file;
|
|
1079
|
+
if (existsSync(cjsNoFacadeMarker(bundleFile))) return undefined;
|
|
1080
|
+
|
|
1081
|
+
const t0 = performance.now();
|
|
1082
|
+
const code = await readFile(bundleFile, 'utf8').catch(() => undefined);
|
|
1083
|
+
if (code === undefined) return undefined;
|
|
1084
|
+
const tRead = performance.now();
|
|
1085
|
+
// The exec pass is REQUIRED, not a supplement: a statically-scanned set is routinely short, and an
|
|
1086
|
+
// incomplete facade is worse than inlining - it breaks an importer's named binding at build time.
|
|
1087
|
+
// A bundle that cannot be executed here simply gets no facade.
|
|
1088
|
+
const executed = await executedExportNames(importHref);
|
|
1089
|
+
const tExec = performance.now();
|
|
1090
|
+
const names =
|
|
1091
|
+
executed &&
|
|
1092
|
+
[...new Set([...commonJsExportNames(code), ...executed])]
|
|
1093
|
+
.filter(name => name !== 'default' && name !== '__esModule' && isIdentifier(name))
|
|
1094
|
+
.sort();
|
|
1095
|
+
if (!names || names.length === 0) {
|
|
1096
|
+
heavyProfRow({ k: 'facade', f: path.basename(bundleFile), readMs: tRead - t0, execMs: tExec - tRead, verifyMs: 0, out: 'no-names' });
|
|
1097
|
+
await writeFileAtomic(cjsNoFacadeMarker(bundleFile), '');
|
|
1098
|
+
return undefined;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
const binding = '__pnext_cjs';
|
|
1102
|
+
const namespace = '__pnext_cjs_ns';
|
|
1103
|
+
const holder = '__pnext_cjs_names';
|
|
1104
|
+
// A CommonJS entry marked `__esModule` but carrying no `default` (e.g.
|
|
1105
|
+
// bind-event-listener) leaves the default binding undefined, so reading names
|
|
1106
|
+
// off it threw at facade eval. Its names live on the bundle's own exports
|
|
1107
|
+
// instead — the same precedence `executedExportNames` derived them with.
|
|
1108
|
+
const source =
|
|
1109
|
+
`import ${binding}, * as ${namespace} from ${JSON.stringify(`./${path.basename(bundleFile)}`)};\n` +
|
|
1110
|
+
`export default ${binding};\n` +
|
|
1111
|
+
`const ${holder} = ${binding} ?? ${namespace};\n` +
|
|
1112
|
+
names.map(name => `export const ${name} = ${holder}[${JSON.stringify(name)}];`).join('\n') +
|
|
1113
|
+
'\n';
|
|
1114
|
+
const ok = await facadeRebundles(file, source);
|
|
1115
|
+
heavyProfRow({ k: 'facade', f: path.basename(bundleFile), readMs: tRead - t0, execMs: tExec - tRead, verifyMs: performance.now() - tExec, out: ok ? 'facade' : 'verify-fail' });
|
|
1116
|
+
if (!ok) {
|
|
1117
|
+
await writeFileAtomic(cjsNoFacadeMarker(bundleFile), '');
|
|
1118
|
+
return undefined;
|
|
1119
|
+
}
|
|
1120
|
+
await writeFileAtomic(file, source);
|
|
1121
|
+
return file;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
/**
|
|
1125
|
+
* Ground truth for the export set: import the bundle and read the keys off its default export - the same
|
|
1126
|
+
* object an importer would have seen inlined. This evaluates the package's module scope, so it returns
|
|
1127
|
+
* undefined (no facade) for a bundle that cannot run in this process, e.g. browser-only globals.
|
|
1128
|
+
*/
|
|
1129
|
+
async function executedExportNames(importHref: string) {
|
|
1130
|
+
try {
|
|
1131
|
+
const module_ = (await import(importHref)) as Record<string, unknown>;
|
|
1132
|
+
const source = (module_.default as Record<string, unknown> | undefined) ?? module_;
|
|
1133
|
+
if (!source || (typeof source !== 'object' && typeof source !== 'function')) return undefined;
|
|
1134
|
+
return Object.keys(source);
|
|
1135
|
+
} catch {
|
|
1136
|
+
return undefined;
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
/**
|
|
1141
|
+
* The gate: esbuild reports a missing file AND a missing named export, so a
|
|
1142
|
+
* facade that re-bundles cleanly is one every importer can safely bind against.
|
|
1143
|
+
*/
|
|
1144
|
+
async function facadeRebundles(file: string, source: string) {
|
|
1145
|
+
try {
|
|
1146
|
+
await build({
|
|
1147
|
+
stdin: { contents: source, resolveDir: path.dirname(file), sourcefile: file, loader: 'js' },
|
|
1148
|
+
bundle: true,
|
|
1149
|
+
write: false,
|
|
1150
|
+
format: 'esm',
|
|
1151
|
+
platform: 'neutral',
|
|
1152
|
+
target: 'es2022',
|
|
1153
|
+
packages: 'external',
|
|
1154
|
+
logLevel: 'silent',
|
|
1155
|
+
});
|
|
1156
|
+
return true;
|
|
1157
|
+
} catch {
|
|
1158
|
+
return false;
|
|
1159
|
+
}
|
|
1160
|
+
}
|