@wular/pnext 0.0.19 → 0.0.21
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 +2 -2
- package/package.json +1 -1
- package/reference/compat.md +2 -2
- package/reference/getting-started.md +2 -2
- package/src/cli/adapters/vercel.ts +27 -0
- package/src/cli/analyze.ts +8 -7
- package/src/cli/boot/trace.ts +9 -3
- package/src/cli/build/cache.ts +324 -0
- package/src/cli/build.ts +42 -18
- package/src/cli/create.ts +4 -4
- package/src/cli/dev.ts +23 -0
- package/src/cli/migrate/package-json.ts +3 -2
- package/src/cli/migrate/report.ts +1 -1
- package/src/cli/migrate/run.ts +2 -2
- package/src/cli/migrate/scan.ts +128 -1
- package/src/cli/serve/pipeline.ts +42 -42
- package/src/cli/start.ts +20 -20
- package/src/client/build.ts +64 -35
- package/src/client/entry.ts +228 -16
- package/src/client/router/page-slot.ts +81 -0
- package/src/client/router/runtime.ts +107 -112
- package/src/compat/bundler/cjs-exports.ts +26 -20
- package/src/compat/client/css-order.ts +1 -1
- package/src/compat/client/navigation-scroll.ts +36 -40
- package/src/compat/css/modules.ts +6 -2
- package/src/compat/metadata.ts +0 -9
- package/src/compat/next/font/cache.ts +10 -22
- package/src/compat/next/font/runtime.ts +20 -0
- package/src/compat/next/legacy-request-apis.ts +41 -5
- package/src/compat/next/script.tsx +16 -24
- package/src/compat/react/client-lite.ts +1 -0
- package/src/compat/react/client.ts +11 -0
- package/src/compat/react/dom-client.ts +11 -2
- package/src/compat/react/dom-react-server.ts +4 -0
- package/src/compat/react/dom.ts +11 -2
- package/src/compat/react/internals.ts +65 -0
- package/src/compat/react/parity.ts +47 -0
- package/src/compat/react/preact.ts +31 -1
- package/src/compat/react/react-server.ts +7 -0
- package/src/compat/react/server.ts +16 -2
- package/src/compat/react/style-values.ts +68 -0
- package/src/compat/register/proxy.ts +3 -1
- package/src/compat/register/render.ts +14 -3
- package/src/compat/register/routing.ts +57 -7
- package/src/compat/register/typed-routes.ts +0 -2
- package/src/compat/tsconfig-defaults.ts +4 -0
- package/src/compat/typecheck/check.ts +26 -71
- package/src/compat/typecheck/worker.ts +3 -2
- package/src/compat/validation/validate.ts +2 -0
- package/src/compat-bootstrap.ts +4 -0
- package/src/css/build.ts +153 -14
- package/src/css/postcss.ts +32 -3
- package/src/dev/restart/client-key.ts +1 -1
- package/src/dev/restart/global-css.ts +12 -94
- package/src/dev/restart/record.ts +106 -0
- package/src/dev/restart/route-facts.ts +11 -90
- package/src/dev/server.ts +208 -41
- package/src/extensions.ts +69 -3
- package/src/render/hooks.ts +1 -1
- package/src/render/metadata.ts +89 -17
- package/src/render/renderer.ts +325 -138
- package/src/render/resource-hints.ts +62 -9
- package/src/render/slots.tsx +2 -1
- package/src/resolve/scan-facts.ts +13 -5
- package/src/routing/forwarded.ts +20 -7
- package/src/routing/handler.ts +6 -2
- package/src/routing/href.ts +11 -1
- package/src/routing/metadata-files.ts +16 -3
- package/src/routing/proxy.ts +6 -3
- package/src/routing/routes.ts +253 -34
- package/src/runtime/fetch-host.ts +42 -0
- package/src/runtime/fingerprint.ts +132 -0
- package/src/runtime/loader.ts +7 -5
- package/src/runtime/module-cache.ts +3 -36
- package/src/runtime/modules.ts +333 -77
- package/src/runtime/vendor-build.ts +34 -5
- package/src/runtime/vendor.ts +1 -18
- package/src/types.ts +6 -0
- package/src/utils/asset-hash.ts +22 -0
- package/src/utils/code.ts +41 -0
- package/src/utils/html.ts +4 -1
- package/src/utils/registry.ts +70 -0
- package/src/utils/serialize.ts +66 -7
package/src/routing/routes.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
|
-
import { existsSync
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
3
|
import { listFiles, listFilesSync, readDirListing, toPosixPath, withDirCache } from '../utils/fs'
|
|
4
4
|
import { safeDecode } from '../utils/decode'
|
|
5
5
|
import {
|
|
@@ -7,14 +7,7 @@ import {
|
|
|
7
7
|
pnextDynamicImportNames,
|
|
8
8
|
rewriteLiteralDynamicCalls,
|
|
9
9
|
} from '../resolve/dynamic'
|
|
10
|
-
import {
|
|
11
|
-
commonJsModuleHasDefaultExport,
|
|
12
|
-
packageNameForFile,
|
|
13
|
-
packageNameOfSpecifier,
|
|
14
|
-
resolveImport,
|
|
15
|
-
resolveModuleAlias,
|
|
16
|
-
resolvePackageSpecifier,
|
|
17
|
-
} from '../resolve/imports'
|
|
10
|
+
import { resolveImport, resolveModuleAlias, resolvePackageSpecifier } from '../resolve/imports'
|
|
18
11
|
import { dynamicCallFacts, scanFacts, type DynamicCallFact } from '../resolve/scan-facts'
|
|
19
12
|
import { readSourceSync } from '../resolve/source-text'
|
|
20
13
|
import { globalCssSourcesForPaths } from '../css/build'
|
|
@@ -251,6 +244,7 @@ export interface RouteFacts {
|
|
|
251
244
|
clientReferences: ClientReference[]
|
|
252
245
|
cssImports: string[]
|
|
253
246
|
sourceFiles: string[]
|
|
247
|
+
serverActionFile?: string
|
|
254
248
|
stream?: RouteManifestEntry['stream']
|
|
255
249
|
maxDuration?: number
|
|
256
250
|
ppr?: boolean
|
|
@@ -269,6 +263,7 @@ const deferredFields = [
|
|
|
269
263
|
'clientReferences',
|
|
270
264
|
'cssImports',
|
|
271
265
|
'sourceFiles',
|
|
266
|
+
'serverActionFile',
|
|
272
267
|
'stream',
|
|
273
268
|
'maxDuration',
|
|
274
269
|
'ppr',
|
|
@@ -678,6 +673,7 @@ function pageOrHandlerFacts(
|
|
|
678
673
|
clientReferences,
|
|
679
674
|
cssImports,
|
|
680
675
|
sourceFiles,
|
|
676
|
+
...(kind === 'page' ? serverActionFileFact(context, sourceFiles) : {}),
|
|
681
677
|
...(stream ? { stream } : {}),
|
|
682
678
|
maxDuration: maxDurationFromSources([source, ...layoutSources]),
|
|
683
679
|
ppr: interception ? false : ppr,
|
|
@@ -1744,6 +1740,84 @@ function hasUseClientDirective(file: string, source: string) {
|
|
|
1744
1740
|
return scanFacts(file, source).useClient
|
|
1745
1741
|
}
|
|
1746
1742
|
|
|
1743
|
+
/**
|
|
1744
|
+
* The first `'use server'` module REACHABLE from this PAGE's graph - `sourceFiles`
|
|
1745
|
+
* is the transitive import closure of the page, its layouts and everything they
|
|
1746
|
+
* render, so a directive file that nothing imports is not one of these. That
|
|
1747
|
+
* scoping is the contract: existence never refuses an app, reachability does.
|
|
1748
|
+
*
|
|
1749
|
+
* Pages only, by the caller. An action is dangerous when it can reach a CLIENT
|
|
1750
|
+
* boundary, and a route handler's graph cannot - it returns a Response, never
|
|
1751
|
+
* markup and never an island - so a directive reachable only from a handler is
|
|
1752
|
+
* inert. Refusing it would break an app that works, for no user-visible reason.
|
|
1753
|
+
*
|
|
1754
|
+
* Computed regardless of compat (the parse is already memoized); only core
|
|
1755
|
+
* CONSUMES it - see serverActionsUnsupportedMessage.
|
|
1756
|
+
*/
|
|
1757
|
+
function serverActionFileFact(context: ScanContext, sourceFiles: string[]) {
|
|
1758
|
+
for (const file of sourceFiles) {
|
|
1759
|
+
if (!fileExists(context, file) || isAssetFile(file)) continue
|
|
1760
|
+
if (scanFacts(file, readSource(context, file)).useServer) return { serverActionFile: file }
|
|
1761
|
+
}
|
|
1762
|
+
return {}
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
/**
|
|
1766
|
+
* Core has no action registry and no dispatch endpoint: `'use server'` is a
|
|
1767
|
+
* next-compat feature. Refusing by name beats the alternative core used to have -
|
|
1768
|
+
* building clean and rendering a form whose `action` was silently dropped.
|
|
1769
|
+
*
|
|
1770
|
+
* Read once, act once: the file, what pnext will not do, and both remedies. No
|
|
1771
|
+
* stack - the CLI prints `error.message` alone (cli/index.ts).
|
|
1772
|
+
*/
|
|
1773
|
+
export function serverActionsUnsupportedMessage(offender: {
|
|
1774
|
+
file: string
|
|
1775
|
+
route?: string
|
|
1776
|
+
root?: string
|
|
1777
|
+
}) {
|
|
1778
|
+
const file = offender.root
|
|
1779
|
+
? toPosixPath(path.relative(offender.root, offender.file))
|
|
1780
|
+
: offender.file
|
|
1781
|
+
const reach = offender.route ? `, reachable from the route ${offender.route}` : ''
|
|
1782
|
+
return [
|
|
1783
|
+
`Server actions need next compat, and this app is pure core.`,
|
|
1784
|
+
``,
|
|
1785
|
+
` ${file} has a 'use server' directive${reach}.`,
|
|
1786
|
+
``,
|
|
1787
|
+
`pnext will not build this app: core has no server-action registry and no`,
|
|
1788
|
+
`dispatch endpoint, so the action could only render as a form that submits`,
|
|
1789
|
+
`nowhere.`,
|
|
1790
|
+
``,
|
|
1791
|
+
`Fix it one of two ways:`,
|
|
1792
|
+
` 1. Enable compat in pnext.config.ts:`,
|
|
1793
|
+
` export default { compat: { next: true } }`,
|
|
1794
|
+
` 2. Or delete the 'use server' directive from ${file}, if those exports are`,
|
|
1795
|
+
` only ever called on the server (in core the directive does nothing).`,
|
|
1796
|
+
].join('\n')
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
/**
|
|
1800
|
+
* Fail the build / the dev request rather than render a form whose action goes
|
|
1801
|
+
* nowhere. Only page routes carry the fact (see serverActionFileFact), so a
|
|
1802
|
+
* handler-only directive never reaches here.
|
|
1803
|
+
*/
|
|
1804
|
+
export function assertNoServerActionsWithoutCompat(
|
|
1805
|
+
routes: RouteManifestEntry[],
|
|
1806
|
+
compatEnabled: boolean,
|
|
1807
|
+
root?: string,
|
|
1808
|
+
) {
|
|
1809
|
+
if (compatEnabled) return
|
|
1810
|
+
const offender = routes.find(route => route.serverActionFile)
|
|
1811
|
+
if (!offender) return
|
|
1812
|
+
throw new Error(
|
|
1813
|
+
serverActionsUnsupportedMessage({
|
|
1814
|
+
file: offender.serverActionFile!,
|
|
1815
|
+
route: offender.route,
|
|
1816
|
+
root,
|
|
1817
|
+
}),
|
|
1818
|
+
)
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1747
1821
|
function isClientModule(context: ScanContext, file: string) {
|
|
1748
1822
|
const key = path.resolve(file)
|
|
1749
1823
|
let client = context.useClient.get(key)
|
|
@@ -1832,15 +1906,21 @@ function collectFromFile(
|
|
|
1832
1906
|
file: string,
|
|
1833
1907
|
references: Map<string, ClientReference>,
|
|
1834
1908
|
visited: Set<string>,
|
|
1909
|
+
withinClientBoundary = false,
|
|
1835
1910
|
) {
|
|
1836
|
-
|
|
1837
|
-
visited.
|
|
1911
|
+
const visitKey = `${file}\0${withinClientBoundary ? 'client' : 'server'}`
|
|
1912
|
+
if (visited.has(visitKey) || !fileExists(context, file)) return
|
|
1913
|
+
visited.add(visitKey)
|
|
1838
1914
|
|
|
1839
|
-
|
|
1915
|
+
const clientFile = isClientModule(context, file)
|
|
1916
|
+
if (clientFile) {
|
|
1840
1917
|
addClientReference(references, clientReference(file, 'default'))
|
|
1841
1918
|
}
|
|
1842
1919
|
|
|
1843
|
-
|
|
1920
|
+
const nestedClientBoundary = withinClientBoundary || clientFile
|
|
1921
|
+
const imports = [...moduleEdges(context, file)]
|
|
1922
|
+
if (!nestedClientBoundary) imports.push(...publishedClientImports(context, file))
|
|
1923
|
+
for (const imported of imports) {
|
|
1844
1924
|
if (isClientModule(context, imported.file)) {
|
|
1845
1925
|
// `export * from './client'` re-exports the target's named exports (never
|
|
1846
1926
|
// its default), so each of them is a client reference of this module.
|
|
@@ -1884,7 +1964,7 @@ function collectFromFile(
|
|
|
1884
1964
|
)
|
|
1885
1965
|
}
|
|
1886
1966
|
|
|
1887
|
-
collectFromFile(context, imported.file, references, visited)
|
|
1967
|
+
collectFromFile(context, imported.file, references, visited, nestedClientBoundary)
|
|
1888
1968
|
}
|
|
1889
1969
|
}
|
|
1890
1970
|
|
|
@@ -2182,7 +2262,7 @@ function moduleEdges(context: ScanContext, file: string) {
|
|
|
2182
2262
|
const key = path.resolve(file)
|
|
2183
2263
|
let edges = context.edges.get(key)
|
|
2184
2264
|
if (!edges) {
|
|
2185
|
-
edges = moduleEdgesFromSource(context
|
|
2265
|
+
edges = moduleEdgesFromSource(context, file, readSource(context, file))
|
|
2186
2266
|
context.edges.set(key, edges)
|
|
2187
2267
|
}
|
|
2188
2268
|
return edges
|
|
@@ -2199,19 +2279,93 @@ function resolveModuleEdge(root: string, file: string, specifier: string) {
|
|
|
2199
2279
|
resolveModuleAlias(specifier) ??
|
|
2200
2280
|
resolveImport(root, file, specifier) ??
|
|
2201
2281
|
getBundlerExtensions().resolveRouteDependency(root, file, specifier) ??
|
|
2202
|
-
resolveScopedClientBoundaryPackage(root, file, specifier) ??
|
|
2203
2282
|
getCssExtensions().resolveCssDependency(root, file, specifier)
|
|
2204
2283
|
)
|
|
2205
2284
|
}
|
|
2206
2285
|
|
|
2207
|
-
|
|
2286
|
+
/** Rendered Client Component exports hidden behind a package's dist barrels. */
|
|
2287
|
+
function publishedClientImports(context: ScanContext, file: string) {
|
|
2288
|
+
const source = readSource(context, file)
|
|
2289
|
+
const found: ModuleEdge[] = []
|
|
2290
|
+
for (const edge of scanFacts(file, source).imports) {
|
|
2291
|
+
if (resolveModuleEdge(context.root, file, edge.specifier)) continue
|
|
2292
|
+
found.push(...publishedClientEdges(context, file, edge.specifier, edge.exports))
|
|
2293
|
+
}
|
|
2294
|
+
return found
|
|
2295
|
+
}
|
|
2208
2296
|
|
|
2209
|
-
function
|
|
2210
|
-
|
|
2211
|
-
|
|
2297
|
+
function publishedClientEdges(
|
|
2298
|
+
context: ScanContext,
|
|
2299
|
+
file: string,
|
|
2300
|
+
specifier: string,
|
|
2301
|
+
requested: string[],
|
|
2302
|
+
) {
|
|
2303
|
+
const cacheKey = `${file}\0${specifier}\0${requested.join(',')}`
|
|
2304
|
+
const cached = context.edges.get(cacheKey)
|
|
2305
|
+
if (cached) return cached
|
|
2306
|
+
const entry = resolvePublishedEdge(context, file, specifier, 'import')
|
|
2307
|
+
const edges = entry ? publishedClientExports(context, entry, requested, new Set()) : []
|
|
2308
|
+
context.edges.set(cacheKey, edges)
|
|
2309
|
+
return edges
|
|
2212
2310
|
}
|
|
2213
2311
|
|
|
2214
|
-
function
|
|
2312
|
+
function resolvePublishedEdge(
|
|
2313
|
+
context: ScanContext,
|
|
2314
|
+
file: string,
|
|
2315
|
+
specifier: string,
|
|
2316
|
+
kind: 'import' | 'require',
|
|
2317
|
+
) {
|
|
2318
|
+
return (
|
|
2319
|
+
resolveModuleEdge(context.root, file, specifier) ??
|
|
2320
|
+
resolvePackageSpecifier(context.root, file, specifier, [kind, 'default'])
|
|
2321
|
+
)
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2324
|
+
function publishedClientExports(
|
|
2325
|
+
context: ScanContext,
|
|
2326
|
+
file: string,
|
|
2327
|
+
requested: string[],
|
|
2328
|
+
visiting: Set<string>,
|
|
2329
|
+
): ModuleEdge[] {
|
|
2330
|
+
const key = path.resolve(file)
|
|
2331
|
+
if (visiting.has(key) || !fileExists(context, key)) return []
|
|
2332
|
+
visiting.add(key)
|
|
2333
|
+
const source = readSource(context, key)
|
|
2334
|
+
if (hasUseClientDirective(key, source)) {
|
|
2335
|
+
visiting.delete(key)
|
|
2336
|
+
const names = [...scanFacts(key, source).exportNames, ...commonJsRouteExportNames(source)]
|
|
2337
|
+
const exports = requested.filter(name => names.includes(name))
|
|
2338
|
+
return exports.length > 0 ? [{ file: key, exports }] : []
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
const facts = scanFacts(key, source)
|
|
2342
|
+
const found: ModuleEdge[] = []
|
|
2343
|
+
for (const edge of facts.imports) {
|
|
2344
|
+
if (!edge.reexport) continue
|
|
2345
|
+
const map = esmReexportMap(source, edge.specifier)
|
|
2346
|
+
const next = edge.star
|
|
2347
|
+
? requested.filter(name => name !== 'default')
|
|
2348
|
+
: requested.map(name => map[name]).filter((name): name is string => Boolean(name))
|
|
2349
|
+
const target = resolvePublishedEdge(context, key, edge.specifier, 'import')
|
|
2350
|
+
if (target && next.length > 0) {
|
|
2351
|
+
found.push(...publishedClientExports(context, target, next, visiting))
|
|
2352
|
+
}
|
|
2353
|
+
}
|
|
2354
|
+
for (const edge of facts.requires) {
|
|
2355
|
+
const map = commonJsRequireReexportMap(source, edge.specifier)
|
|
2356
|
+
const next = map.star
|
|
2357
|
+
? requested
|
|
2358
|
+
: requested.map(name => map.names[name]).filter((name): name is string => Boolean(name))
|
|
2359
|
+
const target = resolvePublishedEdge(context, key, edge.specifier, 'require')
|
|
2360
|
+
if (target && next.length > 0) {
|
|
2361
|
+
found.push(...publishedClientExports(context, target, next, visiting))
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
visiting.delete(key)
|
|
2365
|
+
return found
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2368
|
+
function moduleEdgesFromSource(context: ScanContext, file: string, source: string) {
|
|
2215
2369
|
const dynamicNames = pnextDynamicImportNames(source, file)
|
|
2216
2370
|
assertSupportedDynamicCalls(file, source, dynamicNames)
|
|
2217
2371
|
// Edges carry their byte offset so the final list follows true source order:
|
|
@@ -2222,7 +2376,7 @@ function moduleEdgesFromSource(root: string, file: string, source: string) {
|
|
|
2222
2376
|
const facts = scanFacts(file, source)
|
|
2223
2377
|
|
|
2224
2378
|
for (const edge of facts.imports) {
|
|
2225
|
-
const resolved = resolveModuleEdge(root, file, edge.specifier)
|
|
2379
|
+
const resolved = resolveModuleEdge(context.root, file, edge.specifier)
|
|
2226
2380
|
if (!resolved) continue
|
|
2227
2381
|
ordered.push({
|
|
2228
2382
|
file: resolved,
|
|
@@ -2232,26 +2386,91 @@ function moduleEdgesFromSource(root: string, file: string, source: string) {
|
|
|
2232
2386
|
})
|
|
2233
2387
|
}
|
|
2234
2388
|
|
|
2235
|
-
if (scopedClientBoundaryPackages.has(packageNameForFile(file) ?? '')) {
|
|
2236
|
-
for (const call of facts.requires) {
|
|
2237
|
-
const resolved = resolveModuleEdge(root, file, call.specifier)
|
|
2238
|
-
if (!resolved) continue
|
|
2239
|
-
const exports = commonJsModuleHasDefaultExport(readFileSync(resolved, 'utf8'), resolved)
|
|
2240
|
-
? ['default']
|
|
2241
|
-
: []
|
|
2242
|
-
ordered.push({ file: resolved, exports, index: call.index })
|
|
2243
|
-
}
|
|
2244
|
-
}
|
|
2245
|
-
|
|
2246
2389
|
ordered.sort((a, b) => a.index - b.index)
|
|
2247
2390
|
const imports: ModuleEdge[] = ordered.map(({ index: _index, ...edge }) => edge)
|
|
2248
2391
|
|
|
2249
|
-
for (const imported of dynamicImportEdgesFromSource(root, file, source, dynamicNames))
|
|
2392
|
+
for (const imported of dynamicImportEdgesFromSource(context.root, file, source, dynamicNames))
|
|
2250
2393
|
imports.push(imported)
|
|
2251
2394
|
|
|
2252
2395
|
return imports
|
|
2253
2396
|
}
|
|
2254
2397
|
|
|
2398
|
+
function esmReexportMap(source: string, specifier: string) {
|
|
2399
|
+
const escaped = escapeRegex(specifier)
|
|
2400
|
+
const names: Record<string, string> = {}
|
|
2401
|
+
const pattern = new RegExp(`\\bexport\\s*\\{([^}]*)\\}\\s*from\\s*["']${escaped}["']`, 'g')
|
|
2402
|
+
for (const clause of source.matchAll(pattern)) {
|
|
2403
|
+
for (const item of (clause[1] ?? '').split(',')) {
|
|
2404
|
+
const parts = item.trim().split(/\s+as\s+/)
|
|
2405
|
+
const imported = parts[0]?.trim()
|
|
2406
|
+
const exported = (parts[1] ?? parts[0])?.trim()
|
|
2407
|
+
if (imported && exported && /^[$\w]+$/.test(imported) && /^[$\w]+$/.test(exported)) {
|
|
2408
|
+
names[exported] = imported
|
|
2409
|
+
}
|
|
2410
|
+
}
|
|
2411
|
+
}
|
|
2412
|
+
return names
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2415
|
+
function commonJsRequireReexportMap(source: string, specifier: string) {
|
|
2416
|
+
const escaped = escapeRegex(specifier)
|
|
2417
|
+
const names: Record<string, string> = {}
|
|
2418
|
+
const direct = new RegExp(
|
|
2419
|
+
`exports\\.([A-Za-z_$][\\w$]*)\\s*=\\s*require\\(\\s*["']${escaped}["']\\s*\\)\\.([A-Za-z_$][\\w$]*)`,
|
|
2420
|
+
'g',
|
|
2421
|
+
)
|
|
2422
|
+
for (const match of source.matchAll(direct)) {
|
|
2423
|
+
names[match[1]!] = match[2]!
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2426
|
+
const binding = new RegExp(
|
|
2427
|
+
`(?:const|let|var)\\s+([A-Za-z_$][\\w$]*)\\s*=\\s*require\\(\\s*["']${escaped}["']\\s*\\)`,
|
|
2428
|
+
).exec(source)?.[1]
|
|
2429
|
+
if (binding) {
|
|
2430
|
+
const local = escapeRegex(binding)
|
|
2431
|
+
const assignment = new RegExp(
|
|
2432
|
+
`exports\\.([A-Za-z_$][\\w$]*)\\s*=\\s*${local}\\.([A-Za-z_$][\\w$]*)`,
|
|
2433
|
+
'g',
|
|
2434
|
+
)
|
|
2435
|
+
for (const match of source.matchAll(assignment)) {
|
|
2436
|
+
names[match[1]!] = match[2]!
|
|
2437
|
+
}
|
|
2438
|
+
const getter = new RegExp(
|
|
2439
|
+
`Object\\.defineProperty\\(\\s*exports\\s*,\\s*["']([A-Za-z_$][\\w$]*)["'](?:(?!Object\\.defineProperty)[\\s\\S]){0,300}?return\\s+${local}\\.([A-Za-z_$][\\w$]*)`,
|
|
2440
|
+
'g',
|
|
2441
|
+
)
|
|
2442
|
+
for (const match of source.matchAll(getter)) {
|
|
2443
|
+
names[match[1]!] = match[2]!
|
|
2444
|
+
}
|
|
2445
|
+
const helper = new RegExp(
|
|
2446
|
+
`(?:^|[,\\{])\\s*([A-Za-z_$][\\w$]*)\\s*:\\s*\\(\\s*\\)\\s*=>\\s*${local}\\.([A-Za-z_$][\\w$]*)`,
|
|
2447
|
+
'g',
|
|
2448
|
+
)
|
|
2449
|
+
for (const match of source.matchAll(helper)) {
|
|
2450
|
+
names[match[1]!] = match[2]!
|
|
2451
|
+
}
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
const star = new RegExp(
|
|
2455
|
+
`module\\.exports\\s*=\\s*(?:require\\(\\s*["']${escaped}["']\\s*\\)|${binding ?? '(?!)'})(?=\\s*(?:;|$))`,
|
|
2456
|
+
'm',
|
|
2457
|
+
).test(source)
|
|
2458
|
+
return { names, star }
|
|
2459
|
+
}
|
|
2460
|
+
|
|
2461
|
+
function commonJsRouteExportNames(source: string) {
|
|
2462
|
+
return [
|
|
2463
|
+
...new Set([
|
|
2464
|
+
...[...source.matchAll(/\bexports\.([A-Za-z_$][\w$]*)\s*=/g)].map(match => match[1]!),
|
|
2465
|
+
...[
|
|
2466
|
+
...source.matchAll(
|
|
2467
|
+
/\bObject\.defineProperty\(\s*exports\s*,\s*["']([A-Za-z_$][\w$]*)["']/g,
|
|
2468
|
+
),
|
|
2469
|
+
].map(match => match[1]!),
|
|
2470
|
+
]),
|
|
2471
|
+
].filter(name => name !== '__esModule')
|
|
2472
|
+
}
|
|
2473
|
+
|
|
2255
2474
|
function assertSupportedDynamicCalls(file: string, source: string, dynamicNames: Set<string>) {
|
|
2256
2475
|
for (const call of dynamicCallsFromSource(source, dynamicNames)) {
|
|
2257
2476
|
if (!/\bimport\s*\(/.test(call.source)) {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bun's fetch sends a caller-supplied `host` header verbatim; undici (Node, and therefore Next)
|
|
3
|
+
* always derives Host from the request URL and drops any supplied one. An app that forwards its
|
|
4
|
+
* inbound request headers to an upstream - every proxy/BFF route handler - would otherwise send
|
|
5
|
+
* the local Host upstream, an RFC 9110 7.2 violation that real upstreams reject. Match undici.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
let installed = false
|
|
9
|
+
|
|
10
|
+
function isHostKey(key: unknown): boolean {
|
|
11
|
+
return typeof key === 'string' && key.length === 4 && key.toLowerCase() === 'host'
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Allocation-free probe: the overwhelmingly common case is no `host` at all. */
|
|
15
|
+
function hasHost(headers: HeadersInit): boolean {
|
|
16
|
+
if (headers instanceof Headers) return headers.has('host')
|
|
17
|
+
if (Array.isArray(headers)) {
|
|
18
|
+
for (const entry of headers) if (isHostKey(entry?.[0])) return true
|
|
19
|
+
return false
|
|
20
|
+
}
|
|
21
|
+
for (const key in headers) if (isHostKey(key)) return true
|
|
22
|
+
return false
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Only ever runs when a `host` was actually supplied, so the copy stays off the hot path. */
|
|
26
|
+
function withoutHost(headers: HeadersInit): Headers {
|
|
27
|
+
const next = new Headers(headers)
|
|
28
|
+
next.delete('host')
|
|
29
|
+
return next
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Idempotent; safe to call from every server and build entry point. */
|
|
33
|
+
export function installFetchHostNormalization(): void {
|
|
34
|
+
if (installed) return
|
|
35
|
+
installed = true
|
|
36
|
+
const original = globalThis.fetch.bind(globalThis)
|
|
37
|
+
globalThis.fetch = function fetch(input: RequestInfo | URL, init?: RequestInit) {
|
|
38
|
+
const source = init?.headers ?? (input instanceof Request ? input.headers : undefined)
|
|
39
|
+
if (source === undefined || !hasHost(source)) return original(input, init)
|
|
40
|
+
return original(input, { ...init, headers: withoutHost(source) })
|
|
41
|
+
} as typeof globalThis.fetch
|
|
42
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// pnext's own sources shape every artifact it emits, so a framework edit must invalidate an app's
|
|
2
|
+
// compiled caches exactly the way an app edit does. ONE fingerprint over pnext's whole shipped `src`
|
|
3
|
+
// tree does that - never a hand-picked directory list, which is how a compat-shim edit came to leave
|
|
4
|
+
// a stale vendor bundle serving a bug that had already been fixed.
|
|
5
|
+
import { createHash } from 'node:crypto'
|
|
6
|
+
import { mkdirSync, readFileSync, readdirSync, statSync, writeFileSync, type Dirent } from 'node:fs'
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
import { fileURLToPath } from 'node:url'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Exactly what the package publishes and the vercel adapter ships: source extensions only, never
|
|
12
|
+
* declarations (both drop them) and never machine-local files like `.DS_Store`. The same tree must
|
|
13
|
+
* fingerprint identically in a workspace, in an npm install and inside a deployed function, or the
|
|
14
|
+
* function distrusts the cache its own build wrote and re-vendors onto a read-only filesystem.
|
|
15
|
+
*/
|
|
16
|
+
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.cjs', '.mjs'])
|
|
17
|
+
const DECLARATION = /\.d\.[cm]?ts$/
|
|
18
|
+
|
|
19
|
+
const frameworkSrc = fileURLToPath(new URL('..', import.meta.url))
|
|
20
|
+
|
|
21
|
+
/** Every shipped source under `dir`, sorted, so the fold order is stable. */
|
|
22
|
+
function sourceFiles(dir: string, found: string[] = []) {
|
|
23
|
+
let entries: Dirent[]
|
|
24
|
+
try {
|
|
25
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
26
|
+
} catch {
|
|
27
|
+
return found
|
|
28
|
+
}
|
|
29
|
+
for (const entry of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
|
|
30
|
+
const full = path.join(dir, entry.name)
|
|
31
|
+
if (entry.isDirectory()) sourceFiles(full, found)
|
|
32
|
+
else if (SOURCE_EXTENSIONS.has(path.extname(entry.name)) && !DECLARATION.test(entry.name))
|
|
33
|
+
found.push(full)
|
|
34
|
+
}
|
|
35
|
+
return found
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function contentFingerprint(files: string[], dir: string) {
|
|
39
|
+
const hash = createHash('sha256')
|
|
40
|
+
for (const file of files) {
|
|
41
|
+
hash.update(path.relative(dir, file))
|
|
42
|
+
try {
|
|
43
|
+
hash.update(Bun.hash(readFileSync(file)).toString(36))
|
|
44
|
+
} catch {
|
|
45
|
+
// A file that vanished mid-walk simply contributes its name.
|
|
46
|
+
}
|
|
47
|
+
hash.update('\0')
|
|
48
|
+
}
|
|
49
|
+
return hash.digest('hex').slice(0, 16)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Content identity of a framework source tree. Pure — no memo, no record. */
|
|
53
|
+
export function computeFrameworkFingerprint(dir = frameworkSrc) {
|
|
54
|
+
return contentFingerprint(sourceFiles(dir), dir)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Reading 4.4 MB of source costs ~13 ms; one stat per file costs ~3 ms. So the same three-step
|
|
58
|
+
// ladder the module index and the client key use: the record maps a stat signature to the content
|
|
59
|
+
// fingerprint it produced, a matching signature reuses it, and anything else re-reads. A touch that
|
|
60
|
+
// changed nothing (a reinstall, a checkout, a staged copy) re-reads once and records the same
|
|
61
|
+
// fingerprint, so it never costs a rebuild - only fingerprinting mtimes directly would.
|
|
62
|
+
const RECORD_VERSION = 1
|
|
63
|
+
|
|
64
|
+
interface Record {
|
|
65
|
+
version: number
|
|
66
|
+
stats: string
|
|
67
|
+
fingerprint: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function statSignature(files: string[], dir: string) {
|
|
71
|
+
const hash = createHash('sha256')
|
|
72
|
+
for (const file of files) {
|
|
73
|
+
const stats = statSync(file, { throwIfNoEntry: false })
|
|
74
|
+
hash.update(`${path.relative(dir, file)}:${stats?.size ?? -1}:${stats?.mtimeMs ?? -1}\0`)
|
|
75
|
+
}
|
|
76
|
+
return hash.digest('hex').slice(0, 16)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function recordFile(dir: string) {
|
|
80
|
+
return path.join(dir, 'framework.json')
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function readRecord(dir: string) {
|
|
84
|
+
try {
|
|
85
|
+
const parsed = JSON.parse(readFileSync(recordFile(dir), 'utf8')) as Record
|
|
86
|
+
return parsed.version === RECORD_VERSION && parsed.stats && parsed.fingerprint
|
|
87
|
+
? parsed
|
|
88
|
+
: undefined
|
|
89
|
+
} catch {
|
|
90
|
+
return undefined
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function writeRecord(dir: string, record: Record) {
|
|
95
|
+
try {
|
|
96
|
+
mkdirSync(dir, { recursive: true })
|
|
97
|
+
writeFileSync(recordFile(dir), JSON.stringify(record))
|
|
98
|
+
} catch {
|
|
99
|
+
// Non-fatal: an unwritable record only costs the next boot a re-read.
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let fingerprint: string | undefined
|
|
104
|
+
const recorded = new Set<string>()
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* This pnext's generation. Mixed into every compiled-artifact cache key, so an artifact built by one
|
|
108
|
+
* framework generation is never handed to another.
|
|
109
|
+
*
|
|
110
|
+
* `recordDir` (a cache root) buys the O(1) stat path across restarts; without it the tree is read.
|
|
111
|
+
* `PNEXT_FRAMEWORK_FINGERPRINT` pins the value outright — the bisect seam, and how a deployment can
|
|
112
|
+
* replay its build's generation without walking the tree at all.
|
|
113
|
+
*/
|
|
114
|
+
export function frameworkFingerprint(recordDir?: string) {
|
|
115
|
+
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
116
|
+
const pinned = process.env.PNEXT_FRAMEWORK_FINGERPRINT
|
|
117
|
+
if (pinned) return pinned
|
|
118
|
+
// Memoized, but a second app in the same process still gets its own record - without one its next
|
|
119
|
+
// boot pays the full read for a fingerprint this process already knows.
|
|
120
|
+
if (fingerprint !== undefined && (!recordDir || recorded.has(recordDir))) return fingerprint
|
|
121
|
+
const files = sourceFiles(frameworkSrc)
|
|
122
|
+
const stats = statSignature(files, frameworkSrc)
|
|
123
|
+
const previous = recordDir ? readRecord(recordDir) : undefined
|
|
124
|
+
fingerprint =
|
|
125
|
+
previous?.stats === stats ? previous.fingerprint : contentFingerprint(files, frameworkSrc)
|
|
126
|
+
if (recordDir) {
|
|
127
|
+
recorded.add(recordDir)
|
|
128
|
+
if (previous?.stats !== stats)
|
|
129
|
+
writeRecord(recordDir, { version: RECORD_VERSION, stats, fingerprint })
|
|
130
|
+
}
|
|
131
|
+
return fingerprint
|
|
132
|
+
}
|
package/src/runtime/loader.ts
CHANGED
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
sourceHasDynamicImport,
|
|
41
41
|
} from '../resolve/dynamic'
|
|
42
42
|
import { cacheRoot } from './module-cache'
|
|
43
|
+
import { frameworkFingerprint } from './fingerprint'
|
|
43
44
|
import { readNodeModuleBundle, writeNodeModuleBundle } from '../dev/restart/node-modules'
|
|
44
45
|
import { setBasePathPrefix, setDefaultPrefetchMode, setTrailingSlashUrls } from '../routing/href'
|
|
45
46
|
import { resolveExternalLoadTarget, resolveImport, workspacePackageRoots } from '../resolve/imports'
|
|
@@ -481,15 +482,16 @@ const materializedSegment = `${path.sep}cache${path.sep}server${path.sep}`
|
|
|
481
482
|
* Where a transform of `file` is remembered across restarts. Only for the
|
|
482
483
|
* materialized `cache/server` modules: they are hash-named build output under
|
|
483
484
|
* our own cache root, so the key (path + mtime + size + the define/extension
|
|
484
|
-
* inputs of the transform
|
|
485
|
-
* a build or a config change
|
|
485
|
+
* inputs of the transform, and pnext's generation, which shapes the rewrite) is
|
|
486
|
+
* complete, and the whole tree is discarded when a build or a config change
|
|
487
|
+
* rewrites it.
|
|
486
488
|
*/
|
|
487
|
-
function transformCacheFile(file: string, fileStat: { mtimeMs: number; size: number }) {
|
|
489
|
+
export function transformCacheFile(file: string, fileStat: { mtimeMs: number; size: number }) {
|
|
488
490
|
if (transformDiskCacheDisabled) return undefined
|
|
489
491
|
const at = file.lastIndexOf(materializedSegment)
|
|
490
492
|
if (at === -1) return undefined
|
|
491
493
|
const cacheRoot = file.slice(0, at + materializedSegment.length)
|
|
492
|
-
const key = `${file}\0${fileStat.mtimeMs}\0${fileStat.size}\0${currentLoadExtras().join(',')}\0${JSON.stringify(serverDefineOptions())}`
|
|
494
|
+
const key = `${file}\0${fileStat.mtimeMs}\0${fileStat.size}\0${currentLoadExtras().join(',')}\0${JSON.stringify(serverDefineOptions())}\0${frameworkFingerprint()}`
|
|
493
495
|
return path.join(cacheRoot, 'transform', `${hashBundleSpecifier(key)}.js`)
|
|
494
496
|
}
|
|
495
497
|
|
|
@@ -628,7 +630,7 @@ function nodeModuleBundleRecordFile(file: string, fileStat: { mtimeMs: number; s
|
|
|
628
630
|
if (transformDiskCacheDisabled) return undefined
|
|
629
631
|
const outPath = (runtimeConfigForFile(file) ?? firstRuntimeConfig())?.resolved.outPath
|
|
630
632
|
if (!outPath) return undefined
|
|
631
|
-
const key = `${file}\0${fileStat.mtimeMs}\0${fileStat.size}\0${currentLoadExtras().join(',')}\0${JSON.stringify(serverDefineOptions())}`
|
|
633
|
+
const key = `${file}\0${fileStat.mtimeMs}\0${fileStat.size}\0${currentLoadExtras().join(',')}\0${JSON.stringify(serverDefineOptions())}\0${frameworkFingerprint()}`
|
|
632
634
|
return path.join(cacheRoot(outPath), 'node-module-bundle', `${hashBundleSpecifier(key)}.json`)
|
|
633
635
|
}
|
|
634
636
|
|
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
//
|
|
8
8
|
// The graph the hash walks is read from a persisted index keyed by (mtime, size, content hash), so a
|
|
9
9
|
// restart re-derives it with one stat per source instead of re-scanning and re-resolving every import.
|
|
10
|
-
import { mkdirSync, readFileSync,
|
|
10
|
+
import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
|
11
11
|
import { cachedExistsSync } from '../utils/fs-cache'
|
|
12
|
+
import { frameworkFingerprint } from './fingerprint'
|
|
12
13
|
import { mkdir, readFile, stat } from 'node:fs/promises'
|
|
13
14
|
import { createHash, randomUUID } from 'node:crypto'
|
|
14
15
|
import path from 'node:path'
|
|
@@ -220,7 +221,7 @@ function createDevModuleCache(
|
|
|
220
221
|
const generated = (file: string) =>
|
|
221
222
|
file.startsWith(outRoot) || file.includes(`${path.sep}pnext-pages-compat${path.sep}`)
|
|
222
223
|
const key = createHash('sha256')
|
|
223
|
-
.update(`${options.compileKey}\0${frameworkFingerprint()}`)
|
|
224
|
+
.update(`${options.compileKey}\0${frameworkFingerprint(root)}`)
|
|
224
225
|
.digest('hex')
|
|
225
226
|
.slice(0, 16)
|
|
226
227
|
establishCacheTrust(root)
|
|
@@ -747,37 +748,3 @@ function serializeIndex(key: string, records: Map<string, SourceRecord>, workspa
|
|
|
747
748
|
}
|
|
748
749
|
return JSON.stringify({ version: INDEX_VERSION, key, files } satisfies IndexFile)
|
|
749
750
|
}
|
|
750
|
-
|
|
751
|
-
// pnext's own compiler sources shape every artifact, so editing the framework must invalidate the cache the
|
|
752
|
-
// same way editing app code does - but ONLY an edit. Fingerprinting mtimes threw the whole cache away
|
|
753
|
-
// whenever a file was merely touched (a reinstall, a checkout, a staged copy, a no-op save), so this reads
|
|
754
|
-
// the files that actually emit compiled output. A few dozen small files, once per process, off the boot
|
|
755
|
-
// path - the first compile pays it.
|
|
756
|
-
let fingerprint: string | undefined
|
|
757
|
-
|
|
758
|
-
function frameworkFingerprint() {
|
|
759
|
-
if (fingerprint !== undefined) return fingerprint
|
|
760
|
-
const hash = createHash('sha256')
|
|
761
|
-
for (const dir of ['.', '..', '../runtime', '../compat/bundler', '../resolve']) {
|
|
762
|
-
const root = new URL(`${dir}/`, import.meta.url).pathname
|
|
763
|
-
let entries: string[]
|
|
764
|
-
try {
|
|
765
|
-
entries = readdirSync(root).sort()
|
|
766
|
-
} catch {
|
|
767
|
-
continue
|
|
768
|
-
}
|
|
769
|
-
for (const entry of entries) {
|
|
770
|
-
// Declarations emit nothing, and a deployed build ships without them
|
|
771
|
-
// (the vercel adapter drops `.d.ts`) — hashing one would key the cache to
|
|
772
|
-
// a file that is provably absent where the artifacts are replayed.
|
|
773
|
-
if (!entry.endsWith('.ts') || /\.d\.[cm]?ts$/.test(entry)) continue
|
|
774
|
-
try {
|
|
775
|
-
hash.update(`${entry}:${Bun.hash(readFileSync(path.join(root, entry))).toString(36)}\0`)
|
|
776
|
-
} catch {
|
|
777
|
-
// ignore unreadable entries
|
|
778
|
-
}
|
|
779
|
-
}
|
|
780
|
-
}
|
|
781
|
-
fingerprint = hash.digest('hex').slice(0, 16)
|
|
782
|
-
return fingerprint
|
|
783
|
-
}
|