@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/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
|
-
|
|
227
|
-
|
|
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?:
|
|
306
|
+
config?: AssetHrefConfig,
|
|
233
307
|
): string | string[] | undefined {
|
|
234
|
-
|
|
235
|
-
if (
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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: [
|
|
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
|
-
|
|
323
|
-
|
|
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
|
|
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() {
|
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
|
+
}
|