@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.
- 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.ts +23 -15
- package/src/cli/dev.ts +23 -0
- 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 +4 -2
- package/src/client/build.ts +51 -25
- package/src/client/entry.ts +27 -4
- 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/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/render.ts +14 -3
- package/src/compat/register/routing.ts +57 -7
- 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-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 +322 -136
- 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/serialize.ts +66 -7
package/src/css/postcss.ts
CHANGED
|
@@ -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(
|
|
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
|
|
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 =
|
|
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,
|
|
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
|
|
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
|
|
71
|
-
|
|
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,
|
|
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
|
-
|
|
84
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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')) {
|