@wular/pnext 0.0.19 → 0.0.20

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 (75) 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.ts +23 -15
  9. package/src/cli/dev.ts +23 -0
  10. package/src/cli/migrate/report.ts +1 -1
  11. package/src/cli/migrate/run.ts +2 -2
  12. package/src/cli/migrate/scan.ts +128 -1
  13. package/src/cli/serve/pipeline.ts +42 -42
  14. package/src/cli/start.ts +4 -2
  15. package/src/client/build.ts +51 -25
  16. package/src/client/entry.ts +27 -4
  17. package/src/client/router/page-slot.ts +81 -0
  18. package/src/client/router/runtime.ts +107 -112
  19. package/src/compat/bundler/cjs-exports.ts +26 -20
  20. package/src/compat/client/css-order.ts +1 -1
  21. package/src/compat/client/navigation-scroll.ts +36 -40
  22. package/src/compat/css/modules.ts +6 -2
  23. package/src/compat/next/font/cache.ts +10 -22
  24. package/src/compat/next/font/runtime.ts +20 -0
  25. package/src/compat/next/legacy-request-apis.ts +41 -5
  26. package/src/compat/next/script.tsx +16 -24
  27. package/src/compat/react/client-lite.ts +1 -0
  28. package/src/compat/react/client.ts +11 -0
  29. package/src/compat/react/dom-client.ts +11 -2
  30. package/src/compat/react/dom-react-server.ts +4 -0
  31. package/src/compat/react/dom.ts +11 -2
  32. package/src/compat/react/internals.ts +65 -0
  33. package/src/compat/react/parity.ts +47 -0
  34. package/src/compat/react/preact.ts +31 -1
  35. package/src/compat/react/react-server.ts +7 -0
  36. package/src/compat/react/server.ts +16 -2
  37. package/src/compat/react/style-values.ts +68 -0
  38. package/src/compat/register/render.ts +14 -3
  39. package/src/compat/register/routing.ts +57 -7
  40. package/src/compat/tsconfig-defaults.ts +4 -0
  41. package/src/compat/typecheck/check.ts +26 -71
  42. package/src/compat/typecheck/worker.ts +3 -2
  43. package/src/compat-bootstrap.ts +4 -0
  44. package/src/css/build.ts +153 -14
  45. package/src/css/postcss.ts +32 -3
  46. package/src/dev/restart/client-key.ts +1 -1
  47. package/src/dev/restart/global-css.ts +12 -94
  48. package/src/dev/restart/record.ts +106 -0
  49. package/src/dev/restart/route-facts.ts +11 -90
  50. package/src/dev/server.ts +208 -41
  51. package/src/extensions.ts +69 -3
  52. package/src/render/hooks.ts +1 -1
  53. package/src/render/metadata.ts +89 -17
  54. package/src/render/renderer.ts +322 -136
  55. package/src/render/resource-hints.ts +62 -9
  56. package/src/render/slots.tsx +2 -1
  57. package/src/resolve/scan-facts.ts +13 -5
  58. package/src/routing/forwarded.ts +20 -7
  59. package/src/routing/handler.ts +6 -2
  60. package/src/routing/href.ts +11 -1
  61. package/src/routing/metadata-files.ts +16 -3
  62. package/src/routing/proxy.ts +6 -3
  63. package/src/routing/routes.ts +253 -34
  64. package/src/runtime/fetch-host.ts +42 -0
  65. package/src/runtime/fingerprint.ts +132 -0
  66. package/src/runtime/loader.ts +7 -5
  67. package/src/runtime/module-cache.ts +3 -36
  68. package/src/runtime/modules.ts +333 -77
  69. package/src/runtime/vendor-build.ts +34 -5
  70. package/src/runtime/vendor.ts +1 -18
  71. package/src/types.ts +6 -0
  72. package/src/utils/asset-hash.ts +22 -0
  73. package/src/utils/code.ts +41 -0
  74. package/src/utils/html.ts +4 -1
  75. package/src/utils/serialize.ts +66 -7
@@ -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
+ }
@@ -13,19 +13,17 @@
13
13
  // One file PER ROUTE, not one index: the whole table is large and the first page only ever asks
14
14
  // about the route it is serving, so as a single index its JSON.parse landed, in full, on that
15
15
  // request and gave most of the walk back.
16
- import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
16
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
17
17
  import path from 'node:path'
18
18
  import type { ResolvedConfig } from '../../config'
19
19
  import { setRouteFactsStore, type RouteFacts, type RouteFactsStore } from '../../routing/routes'
20
20
  import { traceEnabled } from '../../utils/trace-flags'
21
21
  import { restartCacheEnabled } from './enabled'
22
-
23
- /** `[file, mtimeMs, size, contentHash]` — the same record shape the key cache uses. */
24
- type SourceEntry = [string, number, number, string]
22
+ import { hash, lazyRecordCacheKey, revalidate, sourceEntry, type SourceEntry } from './record'
25
23
 
26
24
  interface RecordFile {
27
25
  version: number
28
- /** Everything the facts depend on that is not a walked source (see cacheKey). */
26
+ /** Everything the facts depend on that is not a walked source (see restart/record.ts). */
29
27
  key: string
30
28
  files: SourceEntry[]
31
29
  facts: RouteFacts
@@ -34,45 +32,10 @@ interface RecordFile {
34
32
  /** Bumped whenever a record's shape changes; an older record is simply ignored. */
35
33
  const RECORD_VERSION = 1
36
34
 
37
- const hash = (value: string) => Bun.hash(value).toString(36)
38
-
39
- /**
40
- * The facts of a route whose own sources did not move can still change when the
41
- * thing that *interprets* them does: the resolved config (compat on/off, route
42
- * extensions, aliases) or pnext itself. Both go in the record's key, so either
43
- * one moving makes every record read as a miss instead of half-validating it.
44
- */
45
- function cacheKey(config: ResolvedConfig): string | undefined {
46
- try {
47
- const shape = JSON.stringify(config, (_key, value: unknown) =>
48
- typeof value === 'function' ? '[fn]' : value,
49
- )
50
- return hash(`${RECORD_VERSION}\n${pnextBuildStamp()}\n${shape}`)
51
- } catch {
52
- // A config that will not serialize (cycles, exotic values) simply opts out.
53
- return undefined
54
- }
55
- }
56
-
57
- /**
58
- * mtime+size of the route scanner this process is running. The facts are the framework's own reading of
59
- * the app's sources, so a pnext that changed since the record was written must re-derive them rather than
60
- * inherit the old answers - which is what keeps this safe while pnext itself is being edited.
61
- */
62
- function pnextBuildStamp() {
63
- try {
64
- const self = Bun.fileURLToPath(import.meta.url)
65
- const scanner = path.join(path.dirname(path.dirname(self)), 'routing', 'routes.ts')
66
- const info = statSync(scanner, { throwIfNoEntry: false })
67
- return info ? `${info.mtimeMs}:${info.size}` : self
68
- } catch {
69
- return 'unknown'
70
- }
71
- }
72
-
73
35
  interface FactsCache {
74
36
  dir: string
75
- key: string
37
+ /** Resolved on first use, not at install; see restart/record.ts. */
38
+ key: () => string | undefined
76
39
  /** Records written this process, so a repeat save does not re-serialize. */
77
40
  written: Set<string>
78
41
  }
@@ -81,11 +44,9 @@ export function installRouteFactsCache(config: ResolvedConfig) {
81
44
  if (!restartCacheEnabled()) return
82
45
  // eslint-disable-next-line turbo/no-undeclared-env-vars
83
46
  if (process.env.PNEXT_DEV_FACTS_CACHE === '0') return
84
- const key = cacheKey(config)
85
- if (!key) return
86
47
  const cache: FactsCache = {
87
48
  dir: path.join(config.outPath, 'cache', 'route-facts'),
88
- key,
49
+ key: lazyRecordCacheKey(config, RECORD_VERSION),
89
50
  written: new Set(),
90
51
  }
91
52
  setRouteFactsStore(createStore(cache))
@@ -132,7 +93,7 @@ function readRecord(cache: FactsCache, routeId: string): RecordFile | undefined
132
93
  } catch {
133
94
  return undefined
134
95
  }
135
- if (parsed?.version !== RECORD_VERSION || parsed.key !== cache.key) return undefined
96
+ if (parsed?.version !== RECORD_VERSION || parsed.key !== cache.key()) return undefined
136
97
  if (!parsed.facts || !Array.isArray(parsed.files)) return undefined
137
98
  return parsed
138
99
  }
@@ -143,11 +104,13 @@ function readRecord(cache: FactsCache, routeId: string): RecordFile | undefined
143
104
  * once per process per route.
144
105
  */
145
106
  function write(cache: FactsCache, routeId: string, files: SourceEntry[], facts: RouteFacts) {
146
- if (cache.written.has(routeId)) return
107
+ const key = cache.key()
108
+ // A config that will not serialize has no key, so nothing is recorded for it.
109
+ if (!key || cache.written.has(routeId)) return
147
110
  cache.written.add(routeId)
148
111
  const contents = JSON.stringify({
149
112
  version: RECORD_VERSION,
150
- key: cache.key,
113
+ key,
151
114
  files,
152
115
  facts,
153
116
  } satisfies RecordFile)
@@ -174,48 +137,6 @@ function factsSources(facts: RouteFacts): SourceEntry[] | undefined {
174
137
  return entries
175
138
  }
176
139
 
177
- function sourceEntry(file: string): SourceEntry | undefined {
178
- try {
179
- const source = readFileSync(file, 'utf8')
180
- // Stat AFTER the read: a file that changes between the two records the
181
- // older stat, so the next boot re-hashes rather than trusting a stale one.
182
- const info = statSync(file, { throwIfNoEntry: false })
183
- if (!info) return undefined
184
- return [file, info.mtimeMs, info.size, hash(source)]
185
- } catch {
186
- return undefined
187
- }
188
- }
189
-
190
- /**
191
- * The recorded sources, re-stated: `files` itself when nothing moved, a fresh list when only stats moved,
192
- * `undefined` when any content actually differs - or a source is gone, which is the same thing, since the
193
- * walk has to run again.
194
- */
195
- function revalidate(files: SourceEntry[]): SourceEntry[] | undefined {
196
- let moved = false
197
- const checked: SourceEntry[] = []
198
- for (const entry of files) {
199
- const [file, mtimeMs, size, contentHash] = entry
200
- const info = statSync(file, { throwIfNoEntry: false })
201
- if (!info) return undefined
202
- if (info.mtimeMs === mtimeMs && info.size === size) {
203
- checked.push(entry)
204
- continue
205
- }
206
- let source: string
207
- try {
208
- source = readFileSync(file, 'utf8')
209
- } catch {
210
- return undefined
211
- }
212
- if (hash(source) !== contentHash) return undefined
213
- moved = true
214
- checked.push([file, info.mtimeMs, info.size, contentHash])
215
- }
216
- return moved ? checked : files
217
- }
218
-
219
140
  /** Bisect seam: `PNEXT_TRACE=server` prints the hit/miss split at exit. */
220
141
  const stats = { hits: 0, misses: 0 }
221
142
  if (traceEnabled('server')) {