@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.
Files changed (83) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/reference/compat.md +2 -2
  4. package/reference/getting-started.md +2 -2
  5. package/src/cli/adapters/vercel.ts +27 -0
  6. package/src/cli/analyze.ts +8 -7
  7. package/src/cli/boot/trace.ts +9 -3
  8. package/src/cli/build/cache.ts +324 -0
  9. package/src/cli/build.ts +42 -18
  10. package/src/cli/create.ts +4 -4
  11. package/src/cli/dev.ts +23 -0
  12. package/src/cli/migrate/package-json.ts +3 -2
  13. package/src/cli/migrate/report.ts +1 -1
  14. package/src/cli/migrate/run.ts +2 -2
  15. package/src/cli/migrate/scan.ts +128 -1
  16. package/src/cli/serve/pipeline.ts +42 -42
  17. package/src/cli/start.ts +20 -20
  18. package/src/client/build.ts +64 -35
  19. package/src/client/entry.ts +228 -16
  20. package/src/client/router/page-slot.ts +81 -0
  21. package/src/client/router/runtime.ts +107 -112
  22. package/src/compat/bundler/cjs-exports.ts +26 -20
  23. package/src/compat/client/css-order.ts +1 -1
  24. package/src/compat/client/navigation-scroll.ts +36 -40
  25. package/src/compat/css/modules.ts +6 -2
  26. package/src/compat/metadata.ts +0 -9
  27. package/src/compat/next/font/cache.ts +10 -22
  28. package/src/compat/next/font/runtime.ts +20 -0
  29. package/src/compat/next/legacy-request-apis.ts +41 -5
  30. package/src/compat/next/script.tsx +16 -24
  31. package/src/compat/react/client-lite.ts +1 -0
  32. package/src/compat/react/client.ts +11 -0
  33. package/src/compat/react/dom-client.ts +11 -2
  34. package/src/compat/react/dom-react-server.ts +4 -0
  35. package/src/compat/react/dom.ts +11 -2
  36. package/src/compat/react/internals.ts +65 -0
  37. package/src/compat/react/parity.ts +47 -0
  38. package/src/compat/react/preact.ts +31 -1
  39. package/src/compat/react/react-server.ts +7 -0
  40. package/src/compat/react/server.ts +16 -2
  41. package/src/compat/react/style-values.ts +68 -0
  42. package/src/compat/register/proxy.ts +3 -1
  43. package/src/compat/register/render.ts +14 -3
  44. package/src/compat/register/routing.ts +57 -7
  45. package/src/compat/register/typed-routes.ts +0 -2
  46. package/src/compat/tsconfig-defaults.ts +4 -0
  47. package/src/compat/typecheck/check.ts +26 -71
  48. package/src/compat/typecheck/worker.ts +3 -2
  49. package/src/compat/validation/validate.ts +2 -0
  50. package/src/compat-bootstrap.ts +4 -0
  51. package/src/css/build.ts +153 -14
  52. package/src/css/postcss.ts +32 -3
  53. package/src/dev/restart/client-key.ts +1 -1
  54. package/src/dev/restart/global-css.ts +12 -94
  55. package/src/dev/restart/record.ts +106 -0
  56. package/src/dev/restart/route-facts.ts +11 -90
  57. package/src/dev/server.ts +208 -41
  58. package/src/extensions.ts +69 -3
  59. package/src/render/hooks.ts +1 -1
  60. package/src/render/metadata.ts +89 -17
  61. package/src/render/renderer.ts +325 -138
  62. package/src/render/resource-hints.ts +62 -9
  63. package/src/render/slots.tsx +2 -1
  64. package/src/resolve/scan-facts.ts +13 -5
  65. package/src/routing/forwarded.ts +20 -7
  66. package/src/routing/handler.ts +6 -2
  67. package/src/routing/href.ts +11 -1
  68. package/src/routing/metadata-files.ts +16 -3
  69. package/src/routing/proxy.ts +6 -3
  70. package/src/routing/routes.ts +253 -34
  71. package/src/runtime/fetch-host.ts +42 -0
  72. package/src/runtime/fingerprint.ts +132 -0
  73. package/src/runtime/loader.ts +7 -5
  74. package/src/runtime/module-cache.ts +3 -36
  75. package/src/runtime/modules.ts +333 -77
  76. package/src/runtime/vendor-build.ts +34 -5
  77. package/src/runtime/vendor.ts +1 -18
  78. package/src/types.ts +6 -0
  79. package/src/utils/asset-hash.ts +22 -0
  80. package/src/utils/code.ts +41 -0
  81. package/src/utils/html.ts +4 -1
  82. package/src/utils/registry.ts +70 -0
  83. package/src/utils/serialize.ts +66 -7
package/src/css/build.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  import { existsSync, readFileSync } from 'node:fs'
2
- import { writeFile } from 'node:fs/promises'
2
+ import { readFile, rename, writeFile } from 'node:fs/promises'
3
3
  import path from 'node:path'
4
4
  import type { Plugin } from 'esbuild'
5
+ import { hashedAssetName } from '../utils/asset-hash'
5
6
  import { build } from '../utils/esbuild'
6
7
  import { ensureDir, readText } from '../utils/fs'
7
8
  import { postcssConfigFile, runPostcss } from './postcss'
8
9
  import { extraPageExtensions, getCssExtensions } from '../extensions'
10
+ import { nextCompatEnabled } from '../render/hooks'
9
11
  import { resolveImport } from '../resolve/imports'
10
12
  import { createVerboseLogger } from '../utils/verbose'
11
13
  import type { ClientReference } from '../client/reference'
@@ -222,20 +224,116 @@ function rootLayoutFile(appPath: string, missing?: Set<string>) {
222
224
  }
223
225
 
224
226
  export function globalCssHref(config: ResolvedConfig) {
225
- return globalCssSources(config).length > 0
226
- ? withAssetPrefix(config, '/assets/global.css')
227
- : undefined
227
+ return globalCssSources(config).length > 0 ? assetHref(config, 'global.css') : undefined
228
+ }
229
+
230
+ export type AssetHrefConfig = Pick<ResolvedConfig, 'assetPrefix' | 'compat' | 'outPath'>
231
+
232
+ /**
233
+ * Logical build-asset name -> the content-hashed name the build actually emitted
234
+ * (`global.css` -> `global-1f4a9c2b3d5e6f70.css`). A production asset name must
235
+ * carry its content, or the URL cannot honestly be served `immutable`: the same
236
+ * name would answer different bytes after a deploy.
237
+ *
238
+ * Keyed by outPath, never process-global: one process builds several apps (the
239
+ * test suite does it constantly) and their names must not cross. Empty in dev,
240
+ * where names stay flat and every asset is served `no-cache` anyway.
241
+ */
242
+ const emittedAssets = new Map<string, Map<string, string>>()
243
+
244
+ export function recordEmittedAsset(outPath: string, logical: string, emitted: string): void {
245
+ const names = emittedAssets.get(outPath) ?? new Map<string, string>()
246
+ names.set(logical, emitted)
247
+ emittedAssets.set(outPath, names)
248
+ }
249
+
250
+ /** Re-publish the build's name map at server boot (see BuildManifest.assetNames). */
251
+ export function publishEmittedAssets(
252
+ outPath: string,
253
+ names: Record<string, string> | undefined,
254
+ ): void {
255
+ if (!names) return
256
+ for (const [logical, emitted] of Object.entries(names))
257
+ recordEmittedAsset(outPath, logical, emitted)
258
+ }
259
+
260
+ export function emittedAssetNames(outPath: string): Record<string, string> {
261
+ return Object.fromEntries(emittedAssets.get(outPath) ?? [])
262
+ }
263
+
264
+ export function clearEmittedAssets(outPath: string): void {
265
+ emittedAssets.delete(outPath)
266
+ }
267
+
268
+ /** The file name a logical asset name resolves to. Identity until a build records one. */
269
+ export function emittedAssetName(
270
+ config: Pick<ResolvedConfig, 'outPath'> | undefined,
271
+ name: string,
272
+ ): string {
273
+ if (!config?.outPath) return name
274
+ return emittedAssets.get(config.outPath)?.get(name) ?? name
275
+ }
276
+
277
+ // Where a built asset lives in the URL space. next-compat serves the build output under Next's
278
+ // static path, so a compat app's document references its CSS/JS exactly as Next does; core keeps
279
+ // `/assets/`. Every document-emitted build-asset URL goes through here (and the dev/prod servers
280
+ // accept both spellings), so an emitted href can never name a path the server will not serve —
281
+ // including the content hash the production name carries.
282
+ export function assetPathname(
283
+ config: Pick<ResolvedConfig, 'compat' | 'outPath'> | undefined,
284
+ name: string,
285
+ ) {
286
+ const emitted = emittedAssetName(config, name)
287
+ return nextCompatEnabled(config ?? {}) ? `/_next/static/${emitted}` : `/assets/${emitted}`
288
+ }
289
+
290
+ export function assetHref(config: AssetHrefConfig | undefined, name: string) {
291
+ return withAssetPrefix(config, assetPathname(config, name))
292
+ }
293
+
294
+ // A route's built CSS filenames - the one rule behind the document's links, the
295
+ // inline-CSS path and analyze, so no consumer can disagree about whether a
296
+ // route has CSS. compat cssChunking records split names in cssAssets.
297
+ export function routeCssAssetNames(
298
+ route: Pick<RouteManifestEntry, 'id' | 'cssImports' | 'cssAssets'>,
299
+ ): string[] {
300
+ if (route.cssImports.length === 0) return []
301
+ return route.cssAssets?.length ? route.cssAssets : [`${route.id}.css`]
228
302
  }
229
303
 
230
304
  export function routeCssHref(
231
305
  route: RouteManifestEntry,
232
- config?: Pick<ResolvedConfig, 'assetPrefix'>,
306
+ config?: AssetHrefConfig,
233
307
  ): string | string[] | undefined {
234
- if (route.cssImports.length === 0) return undefined
235
- if (route.cssAssets?.length) {
236
- return route.cssAssets.map(asset => withAssetPrefix(config, `/assets/${asset}`))
237
- }
238
- return withAssetPrefix(config, `/assets/${route.id}.css`)
308
+ const assets = routeCssAssetNames(route)
309
+ if (assets.length === 0) return undefined
310
+ return assets.map(asset => assetHref(config, asset))
311
+ }
312
+
313
+ /**
314
+ * Put a render's next/font rules in the first stylesheet chunk, matching Next's
315
+ * linked CSS delivery without changing the rest of the route's chunk order.
316
+ */
317
+ export async function emitFontCssStylesheet(
318
+ config: ResolvedConfig,
319
+ routeId: string,
320
+ fontCss: string,
321
+ baseAsset: string | undefined,
322
+ options: { dev: boolean },
323
+ ) {
324
+ const outDir = path.join(config.outPath, options.dev ? 'cache' : 'public', 'assets')
325
+ await ensureDir(outDir)
326
+ const baseFile = baseAsset ? path.join(outDir, baseAsset) : undefined
327
+ const baseCss = baseFile && existsSync(baseFile) ? await readFile(baseFile, 'utf8') : ''
328
+ const contents = [fontCss, baseCss].filter(Boolean).join('\n')
329
+ const logicalName = baseAsset
330
+ ? baseAsset.replace(/-[0-9a-f]{16}(?=\.css$)/, '')
331
+ : `${routeId}.css`
332
+ const name = hashedAssetName(logicalName, contents)
333
+ const file = path.join(outDir, name)
334
+ if (!existsSync(file) || (await readFile(file, 'utf8')) !== contents)
335
+ await writeFile(file, contents)
336
+ return name
239
337
  }
240
338
 
241
339
  // Prepend the configured assetPrefix (a CDN origin or path) to an app-absolute
@@ -296,7 +394,11 @@ export async function buildGlobalCss(config: ResolvedConfig, options: CssBuildOp
296
394
  minify: !options.dev,
297
395
  conditions: ['style', 'browser'],
298
396
  assetNames: 'files/[name]-[hash]',
299
- plugins: [...getCssExtensions().cssChunkPlugins(), ...cssDirectiveOrderPlugins(postcss)],
397
+ plugins: [
398
+ cssPublicUrlPlugin(),
399
+ ...getCssExtensions().cssChunkPlugins(),
400
+ ...cssDirectiveOrderPlugins(postcss),
401
+ ],
300
402
  loader: {
301
403
  '.css': 'css',
302
404
  '.module.css': 'css',
@@ -319,8 +421,30 @@ export async function buildGlobalCss(config: ResolvedConfig, options: CssBuildOp
319
421
  )
320
422
  }
321
423
 
322
- log.log(`global css → ${path.relative(config.root, outfile)}`)
323
- return outfile
424
+ const emitted = await fingerprintAsset(config, outDir, 'global.css', options)
425
+ log.log(`global css → ${path.relative(config.root, emitted)}`)
426
+ return emitted
427
+ }
428
+
429
+ /**
430
+ * Rename a freshly built asset to carry its content hash, and record the
431
+ * logical -> emitted mapping every href/file consumer resolves through.
432
+ * Dev keeps flat names: nothing there is served immutable, and the dev server
433
+ * addresses these files by their logical name.
434
+ */
435
+ async function fingerprintAsset(
436
+ config: Pick<ResolvedConfig, 'outPath'>,
437
+ outDir: string,
438
+ assetName: string,
439
+ options: CssBuildOptions,
440
+ ) {
441
+ const outfile = path.join(outDir, assetName)
442
+ if (options.dev) return outfile
443
+ const emittedName = hashedAssetName(assetName, await readFile(outfile))
444
+ const emitted = path.join(outDir, emittedName)
445
+ if (emitted !== outfile) await rename(outfile, emitted)
446
+ recordEmittedAsset(config.outPath, assetName, emittedName)
447
+ return emitted
324
448
  }
325
449
 
326
450
  function moduleSpecifiers(source: string) {
@@ -491,6 +615,7 @@ async function bundleCssChunk(
491
615
  assetNames: 'files/[name]-[hash]',
492
616
  // Lazy registry read: compat (sass) plugins register at bootstrap.
493
617
  plugins: [
618
+ cssPublicUrlPlugin(),
494
619
  ...getCssExtensions().cssChunkPlugins(),
495
620
  cssModuleBuildPlugin(),
496
621
  ...cssDirectiveOrderPlugins(postcss),
@@ -515,7 +640,21 @@ async function bundleCssChunk(
515
640
  await log.step(`${label}: postcss`, () => runPostcssOffThread(config, outfile, options))
516
641
  }
517
642
 
518
- return outfile
643
+ return fingerprintAsset(config, outDir, assetName, options)
644
+ }
645
+
646
+ // A leading slash in a CSS url() is a public URL, not an app-root filesystem
647
+ // path. Keep it verbatim so the production bundle matches the dev server and
648
+ // Next's public/ asset contract; relative URLs still use esbuild's file loader.
649
+ function cssPublicUrlPlugin(): Plugin {
650
+ return {
651
+ name: 'pnext-css-public-url',
652
+ setup(plugin) {
653
+ plugin.onResolve({ filter: /^\// }, args =>
654
+ args.kind === 'url-token' ? { path: args.path, external: true } : undefined,
655
+ )
656
+ },
657
+ }
519
658
  }
520
659
 
521
660
  export function registerCssRuntime() {
@@ -75,9 +75,10 @@ export async function runPostcss(
75
75
  // `DEBUG=tailwindcss` marker keys off `result.opts.from`). We still write the
76
76
  // processed output back to the bundle outfile via `to`.
77
77
  const from = fromSource ?? cssFile
78
+ const css = await readText(cssFile)
78
79
  // The scan directives go last so they don't shift the line numbers plugins
79
80
  // report against the real source; `@source` is order-independent to Tailwind.
80
- const result = await loaded.processor.process((await readText(cssFile)) + loaded.sources, {
81
+ const result = await loaded.processor.process(css + keptExclusions(loaded.sources, css, from), {
81
82
  from,
82
83
  to: cssFile,
83
84
  })
@@ -112,7 +113,35 @@ async function createPostcssProcessor(root: string, options: PostcssOptions) {
112
113
  * Excluding these three by name restores that behaviour without narrowing what a correctly-ignored
113
114
  * checkout already scans. Apps that really do keep Tailwind-classed sources in one of them set
114
115
  * `PNEXT_TAILWIND_SCAN_ALL`.
116
+ *
117
+ * A pure function of (root, outDir) — deliberately NOT of what is on disk. The processor is built
118
+ * once and cached, and `warmCssPipeline` builds it before the build has written its output, so an
119
+ * `existsSync` here made the exclusion set depend on which of the two got there first: the same
120
+ * stylesheet compiled with or without utilities minted out of pnext's own emitted JS.
115
121
  */
122
+ /**
123
+ * An appended `@source not <dir>` silences every explicit `@source` glob the stylesheet points
124
+ * under that dir - Tailwind gives `not` precedence - so an app opting INTO scanning a package
125
+ * (`@source '../../node_modules/x/dist/...'`) lost those utilities entirely. Keep only the
126
+ * exclusions that no explicit `@source` in this stylesheet reaches into.
127
+ */
128
+ export function keptExclusions(exclusions: string, css: string, fromFile: string): string {
129
+ if (!exclusions) return exclusions
130
+ const dir = path.dirname(fromFile)
131
+ const globs = [...css.matchAll(/@source\s+(?!not\s)['"]([^'"]+)['"]/g)].map(m =>
132
+ path.resolve(dir, m[1]!.replace(/[*{[].*$/, '')),
133
+ )
134
+ if (globs.length === 0) return exclusions
135
+ const kept = exclusions
136
+ .split('\n')
137
+ .filter(line => {
138
+ const m = /@source not "([^"]+)"/.exec(line)
139
+ return !m || !globs.some(glob => glob === m[1] || glob.startsWith(m[1] + '/'))
140
+ })
141
+ .join('\n')
142
+ return kept.includes('@source not') ? kept : ''
143
+ }
144
+
116
145
  function scanExclusions(root: string, appRequire: NodeJS.Require, options: PostcssOptions) {
117
146
  // eslint-disable-next-line turbo/no-undeclared-env-vars
118
147
  if (process.env.PNEXT_TAILWIND_SCAN_ALL) return ''
@@ -120,8 +149,8 @@ function scanExclusions(root: string, appRequire: NodeJS.Require, options: Postc
120
149
  const [major, minor] = packageVersion(appRequire, '@tailwindcss/postcss')
121
150
  if (major < 4 || (major === 4 && minor < 1)) return ''
122
151
  const dirs = ['node_modules', '.next', options.outDir ?? '.pnext']
123
- const excluded = [...new Set(dirs.map(dir => path.resolve(root, dir)))].filter(
124
- dir => dir.startsWith(root + path.sep) && existsSync(dir),
152
+ const excluded = [...new Set(dirs.map(dir => path.resolve(root, dir)))].filter(dir =>
153
+ dir.startsWith(root + path.sep),
125
154
  )
126
155
  if (excluded.length === 0) return ''
127
156
  // Leading and trailing newlines keep the stylesheet's own first and last
@@ -83,7 +83,7 @@ export async function devClientCacheKey(
83
83
  ): Promise<string> {
84
84
  if (!restartCacheEnabled()) return (await clientCacheKeyParts(route, nextCompat, config)).key
85
85
  const cache = keyCache(config)
86
- const staticHash = await clientCacheStaticHash(route, nextCompat, config)
86
+ const staticHash = clientCacheStaticHash(route, nextCompat, config)
87
87
  const entry = cache.routes.get(route.id)
88
88
  if (entry?.static === staticHash) {
89
89
  const revalidated = await revalidate(entry.files)
@@ -10,19 +10,17 @@
10
10
  // content differs -> discard, walk again, record the new answer
11
11
  // Paths the walk found ABSENT are re-checked too: a stylesheet restored while the server was not
12
12
  // running adds an edge that no importer's bytes record.
13
- import { mkdirSync, existsSync, readFileSync, statSync, writeFileSync } from 'node:fs'
13
+ import { mkdirSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
14
14
  import path from 'node:path'
15
15
  import type { ResolvedConfig } from '../../config'
16
16
  import { setGlobalCssSourceStore, type GlobalCssSourceStore } from '../../css/build'
17
17
  import { traceEnabled } from '../../utils/trace-flags'
18
18
  import { restartCacheEnabled } from './enabled'
19
-
20
- /** `[file, mtimeMs, size, contentHash]` — the shape the other restart caches use. */
21
- type SourceEntry = [string, number, number, string]
19
+ import { hash, lazyRecordCacheKey, revalidate, sourceEntries, type SourceEntry } from './record'
22
20
 
23
21
  interface RecordFile {
24
22
  version: number
25
- /** Everything the answer depends on that is not a walked source (see cacheKey). */
23
+ /** Everything the answer depends on that is not a walked source (see restart/record.ts). */
26
24
  key: string
27
25
  files: SourceEntry[]
28
26
  missing: string[]
@@ -32,47 +30,15 @@ interface RecordFile {
32
30
  /** Bumped whenever a record's shape changes; an older record is simply ignored. */
33
31
  const RECORD_VERSION = 1
34
32
 
35
- const hash = (value: string) => Bun.hash(value).toString(36)
36
-
37
- /**
38
- * The walk's answer can change without any app file moving, when the thing that
39
- * *interprets* the graph does: the resolver's config (aliases, conditions, page
40
- * extensions) or pnext itself. Both go in the key, so either one moving makes
41
- * the record read as a miss instead of half-validating it.
42
- */
43
- function cacheKey(config: ResolvedConfig): string | undefined {
44
- try {
45
- const shape = JSON.stringify(config, (_key, value: unknown) =>
46
- typeof value === 'function' ? '[fn]' : value,
47
- )
48
- return hash(`${RECORD_VERSION}\n${pnextBuildStamp()}\n${shape}`)
49
- } catch {
50
- return undefined // a config that will not serialize simply opts out
51
- }
52
- }
53
-
54
- /** mtime+size of the walk this process is running (see restart/route-facts.ts). */
55
- function pnextBuildStamp() {
56
- try {
57
- const self = Bun.fileURLToPath(import.meta.url)
58
- const walk = path.join(path.dirname(path.dirname(self)), 'css', 'build.ts')
59
- const info = statSync(walk, { throwIfNoEntry: false })
60
- return info ? `${info.mtimeMs}:${info.size}` : self
61
- } catch {
62
- return 'unknown'
63
- }
64
- }
65
-
66
33
  export function installGlobalCssCache(config: ResolvedConfig) {
67
34
  if (!restartCacheEnabled()) return
68
35
  // eslint-disable-next-line turbo/no-undeclared-env-vars
69
36
  if (process.env.PNEXT_DEV_GLOBAL_CSS_CACHE === '0') return
70
- const key = cacheKey(config)
71
- if (!key) return
72
- setGlobalCssSourceStore(createStore(path.join(config.outPath, 'cache', 'global-css'), key))
37
+ const dir = path.join(config.outPath, 'cache', 'global-css')
38
+ setGlobalCssSourceStore(createStore(dir, lazyRecordCacheKey(config, RECORD_VERSION)))
73
39
  }
74
40
 
75
- function createStore(dir: string, key: string): GlobalCssSourceStore {
41
+ function createStore(dir: string, cacheKey: () => string | undefined): GlobalCssSourceStore {
76
42
  // Last payload written per walk, so a save that re-runs the walk with an
77
43
  // unchanged answer costs no write, and one with a changed answer does.
78
44
  const written = new Map<string, string>()
@@ -80,8 +46,10 @@ function createStore(dir: string, key: string): GlobalCssSourceStore {
80
46
 
81
47
  return {
82
48
  load(walkKey) {
83
- const record = readRecord(recordFile(walkKey), key)
84
- if (!record) {
49
+ // A config that will not serialize has no key, so it simply always walks.
50
+ const key = cacheKey()
51
+ const record = key ? readRecord(recordFile(walkKey), key) : undefined
52
+ if (!key || !record) {
85
53
  stats.misses += 1
86
54
  return undefined
87
55
  }
@@ -111,6 +79,8 @@ function createStore(dir: string, key: string): GlobalCssSourceStore {
111
79
  return record.sources
112
80
  },
113
81
  save(walkKey, sources, visited, missing) {
82
+ const key = cacheKey()
83
+ if (!key) return
114
84
  const files = sourceEntries(visited)
115
85
  if (!files) return
116
86
  write(recordFile(walkKey), key, written, walkKey, files, missing, sources)
@@ -161,58 +131,6 @@ function write(
161
131
  }
162
132
  }
163
133
 
164
- function sourceEntries(files: string[]): SourceEntry[] | undefined {
165
- const entries: SourceEntry[] = []
166
- for (const file of files) {
167
- const entry = sourceEntry(file)
168
- if (!entry) return undefined
169
- entries.push(entry)
170
- }
171
- return entries
172
- }
173
-
174
- function sourceEntry(file: string): SourceEntry | undefined {
175
- try {
176
- const source = readFileSync(file, 'utf8')
177
- // Stat AFTER the read: a file that changes between the two records the
178
- // older stat, so the next boot re-hashes rather than trusting a stale one.
179
- const info = statSync(file, { throwIfNoEntry: false })
180
- if (!info) return undefined
181
- return [file, info.mtimeMs, info.size, hash(source)]
182
- } catch {
183
- return undefined
184
- }
185
- }
186
-
187
- /**
188
- * The recorded sources, re-stated: `files` itself when nothing moved, a fresh list when only stats moved,
189
- * `undefined` when any content actually differs - or a source is gone, which is the same thing, since the
190
- * walk has to run again.
191
- */
192
- function revalidate(files: SourceEntry[]): SourceEntry[] | undefined {
193
- let moved = false
194
- const checked: SourceEntry[] = []
195
- for (const entry of files) {
196
- const [file, mtimeMs, size, contentHash] = entry
197
- const info = statSync(file, { throwIfNoEntry: false })
198
- if (!info) return undefined
199
- if (info.mtimeMs === mtimeMs && info.size === size) {
200
- checked.push(entry)
201
- continue
202
- }
203
- let source: string
204
- try {
205
- source = readFileSync(file, 'utf8')
206
- } catch {
207
- return undefined
208
- }
209
- if (hash(source) !== contentHash) return undefined
210
- moved = true
211
- checked.push([file, info.mtimeMs, info.size, contentHash])
212
- }
213
- return moved ? checked : files
214
- }
215
-
216
134
  /** Bisect seam: `PNEXT_TRACE=server` prints the hit/miss split at exit. */
217
135
  const stats = { hits: 0, misses: 0 }
218
136
  if (traceEnabled('server')) {
@@ -0,0 +1,106 @@
1
+ // Shared spine of the persisted restart records (global-css, route-facts): the key everything a
2
+ // record depends on that is NOT a walked source folds into, and the stat->hash ladder that
3
+ // revalidates the sources themselves. Both halves were duplicated per record; a key rule that lives
4
+ // in two copies is a key rule that gets fixed in one of them.
5
+ import { readFileSync, statSync } from 'node:fs'
6
+ import type { ResolvedConfig } from '../../config'
7
+ import { frameworkFingerprint } from '../../runtime/fingerprint'
8
+ import { cacheRoot } from '../../runtime/module-cache'
9
+
10
+ /** `[file, mtimeMs, size, contentHash]` — the shape every restart cache records. */
11
+ export type SourceEntry = [string, number, number, string]
12
+
13
+ export const hash = (value: string) => Bun.hash(value).toString(36)
14
+
15
+ /**
16
+ * A record's answer can change without any app file moving, when the thing that *interprets* the
17
+ * graph does: the resolver's config (aliases, conditions, page extensions) or pnext itself.
18
+ *
19
+ * The framework half is the CONTENT of pnext's whole shipped tree, never the mtime+size of one
20
+ * hand-picked walker file. A record derived by reading many framework files cannot be keyed on one
21
+ * of them - the stamp is blind to an edit in any of the others - and mtime+size is not content: a
22
+ * same-length edit landing in the same mtime tick moved nothing the stamp could see. The tree
23
+ * fingerprint is already computed at boot for the module cache and memoized per process, so this
24
+ * costs a memo read.
25
+ */
26
+ export function recordCacheKey(config: ResolvedConfig, version: number): string | undefined {
27
+ try {
28
+ const shape = JSON.stringify(config, (_key, value: unknown) =>
29
+ typeof value === 'function' ? '[fn]' : value,
30
+ )
31
+ return hash(`${version}\n${frameworkFingerprint(cacheRoot(config.outPath))}\n${shape}`)
32
+ } catch {
33
+ // A config that will not serialize (cycles, exotic values) simply opts out.
34
+ return undefined
35
+ }
36
+ }
37
+
38
+ /**
39
+ * The same key, on first USE rather than at install. The stores are installed during boot but first
40
+ * read on the first page, and the fingerprint the key folds in is paid by the module cache on that
41
+ * same page - so computing it eagerly does not save the work, it just moves it in front of the
42
+ * server being ready to serve.
43
+ */
44
+ export function lazyRecordCacheKey(config: ResolvedConfig, version: number) {
45
+ let key: string | undefined
46
+ let computed = false
47
+ return () => {
48
+ if (!computed) {
49
+ computed = true
50
+ key = recordCacheKey(config, version)
51
+ }
52
+ return key
53
+ }
54
+ }
55
+
56
+ export function sourceEntries(files: string[]): SourceEntry[] | undefined {
57
+ const entries: SourceEntry[] = []
58
+ for (const file of files) {
59
+ const entry = sourceEntry(file)
60
+ if (!entry) return undefined
61
+ entries.push(entry)
62
+ }
63
+ return entries
64
+ }
65
+
66
+ export function sourceEntry(file: string): SourceEntry | undefined {
67
+ try {
68
+ const source = readFileSync(file, 'utf8')
69
+ // Stat AFTER the read: a file that changes between the two records the
70
+ // older stat, so the next boot re-hashes rather than trusting a stale one.
71
+ const info = statSync(file, { throwIfNoEntry: false })
72
+ if (!info) return undefined
73
+ return [file, info.mtimeMs, info.size, hash(source)]
74
+ } catch {
75
+ return undefined
76
+ }
77
+ }
78
+
79
+ /**
80
+ * The recorded sources, re-stated: `files` itself when nothing moved, a fresh list when only stats moved,
81
+ * `undefined` when any content actually differs - or a source is gone, which is the same thing, since the
82
+ * walk has to run again.
83
+ */
84
+ export function revalidate(files: SourceEntry[]): SourceEntry[] | undefined {
85
+ let moved = false
86
+ const checked: SourceEntry[] = []
87
+ for (const entry of files) {
88
+ const [file, mtimeMs, size, contentHash] = entry
89
+ const info = statSync(file, { throwIfNoEntry: false })
90
+ if (!info) return undefined
91
+ if (info.mtimeMs === mtimeMs && info.size === size) {
92
+ checked.push(entry)
93
+ continue
94
+ }
95
+ let source: string
96
+ try {
97
+ source = readFileSync(file, 'utf8')
98
+ } catch {
99
+ return undefined
100
+ }
101
+ if (hash(source) !== contentHash) return undefined
102
+ moved = true
103
+ checked.push([file, info.mtimeMs, info.size, contentHash])
104
+ }
105
+ return moved ? checked : files
106
+ }