@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/README.md
CHANGED
|
@@ -13,13 +13,13 @@
|
|
|
13
13
|
A new app:
|
|
14
14
|
|
|
15
15
|
```sh
|
|
16
|
-
bunx @wular/pnext create my-app
|
|
16
|
+
bunx @wular/pnext@latest create my-app
|
|
17
17
|
```
|
|
18
18
|
|
|
19
19
|
Migrating a Next.js app? This rewrites scripts and config in place, scans your source, and reports anything that needs a look (it never edits your code):
|
|
20
20
|
|
|
21
21
|
```sh
|
|
22
|
-
bunx @wular/pnext migrate
|
|
22
|
+
bunx @wular/pnext@latest migrate
|
|
23
23
|
```
|
|
24
24
|
|
|
25
25
|
Or by hand: `bun add -d @wular/pnext`, then `pnext dev`.
|
package/package.json
CHANGED
package/reference/compat.md
CHANGED
|
@@ -19,8 +19,8 @@ To turn Next compatibility on without a config file, set `PNEXT_COMPAT=next`. Se
|
|
|
19
19
|
## Migrating a Next.js app
|
|
20
20
|
|
|
21
21
|
```sh
|
|
22
|
-
bunx @wular/pnext migrate --dry-run
|
|
23
|
-
bunx @wular/pnext migrate
|
|
22
|
+
bunx @wular/pnext@latest migrate --dry-run
|
|
23
|
+
bunx @wular/pnext@latest migrate
|
|
24
24
|
```
|
|
25
25
|
|
|
26
26
|
Migration rewrites `package.json` and `tsconfig.json`, and creates a `pnext.config.ts` with `compat.next` enabled. It reports on your app source but never edits it.
|
|
@@ -9,7 +9,7 @@ pnext is a Preact framework for file-routed apps. It renders on the server by de
|
|
|
9
9
|
## New app
|
|
10
10
|
|
|
11
11
|
```sh
|
|
12
|
-
bunx @wular/pnext create my-app
|
|
12
|
+
bunx @wular/pnext@latest create my-app
|
|
13
13
|
cd my-app
|
|
14
14
|
bun dev
|
|
15
15
|
```
|
|
@@ -23,7 +23,7 @@ The app is running at `http://localhost:3000`. Edit `app/page.tsx`; the browser
|
|
|
23
23
|
Run in your Next project:
|
|
24
24
|
|
|
25
25
|
```sh
|
|
26
|
-
bunx @wular/pnext migrate
|
|
26
|
+
bunx @wular/pnext@latest migrate
|
|
27
27
|
```
|
|
28
28
|
|
|
29
29
|
It converts the project to pnext with `compat.next` enabled, without editing app source. `--dry-run` previews.
|
|
@@ -7,6 +7,7 @@ import { DEFAULT_OUT_DIR, pathToFileHref } from '../../config'
|
|
|
7
7
|
import type { ResolvedConfig } from '../../config'
|
|
8
8
|
import { globalCssSources } from '../../css/build'
|
|
9
9
|
import { getImportAliasExtensions } from '../../extensions'
|
|
10
|
+
import { nextCompatEnabled } from '../../render/hooks'
|
|
10
11
|
import { importSpecifiers } from '../../resolve/scan-facts'
|
|
11
12
|
import { compiledSpecifiersManifestSuffix } from '../../runtime/modules'
|
|
12
13
|
import { resolveImport, workspacePackageRoots } from '../../resolve/imports'
|
|
@@ -199,6 +200,11 @@ export async function writeVercelOutput(
|
|
|
199
200
|
const overrides = await staticOverrides(path.join(config.outPath, 'public'), staticFiles)
|
|
200
201
|
|
|
201
202
|
const routes: NonNullable<VercelConfig['routes']> = [
|
|
203
|
+
// next-compat documents reference the build output under Next's static path (assetPathname),
|
|
204
|
+
// but it is copied to the CDN at `static/assets/*`. Rewrite before `handle: filesystem` so the
|
|
205
|
+
// CDN serves those bytes itself - without it every stylesheet and chunk fell through to the
|
|
206
|
+
// server function: served, but at function cost with no edge cache.
|
|
207
|
+
...compatStaticRewrite(config, outputPath),
|
|
202
208
|
// Chunk and font filenames are content-hashed; serve them immutable.
|
|
203
209
|
{
|
|
204
210
|
src: '^/assets/(chunks|fonts)/.*',
|
|
@@ -246,6 +252,27 @@ async function emptyDir(dir: string) {
|
|
|
246
252
|
}
|
|
247
253
|
}
|
|
248
254
|
|
|
255
|
+
/**
|
|
256
|
+
* The `/_next/static/*` -> `/assets/*` CDN rewrite for a next-compat build, restricted to the names
|
|
257
|
+
* actually copied under `assets/`. It has to be a NARROW alternation, not `(.*)`: `_next/static` is
|
|
258
|
+
* also a REAL output path (`media/*` from the static-media mirror, `pnext/_buildManifest.js`), and
|
|
259
|
+
* a blanket rewrite would point those at an `assets/` twin that does not exist.
|
|
260
|
+
*/
|
|
261
|
+
function compatStaticRewrite(config: ResolvedConfig, outputPath: string) {
|
|
262
|
+
if (!nextCompatEnabled(config)) return []
|
|
263
|
+
const staticDir = path.join(outputPath, 'static')
|
|
264
|
+
const names = readdirSyncSafe(path.join(staticDir, 'assets'))
|
|
265
|
+
// A name that is ALSO a real `_next/static/<name>` must keep resolving to itself.
|
|
266
|
+
.filter(name => !existsSync(path.join(staticDir, '_next', 'static', name)))
|
|
267
|
+
if (names.length === 0) return []
|
|
268
|
+
const alternation = names.map(name => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')
|
|
269
|
+
return [{ src: `^/_next/static/(${alternation})(/.*)?$`, dest: '/assets/$1$2', continue: true }]
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function readdirSyncSafe(dir: string) {
|
|
273
|
+
return existsSync(dir) ? readdirSync(dir) : []
|
|
274
|
+
}
|
|
275
|
+
|
|
249
276
|
async function proxyRoutes(config: ResolvedConfig) {
|
|
250
277
|
const proxyFile = findProxyFile(config)
|
|
251
278
|
if (!proxyFile) return []
|
package/src/cli/analyze.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises'
|
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import zlib from 'node:zlib'
|
|
5
5
|
import { loadConfig } from '../config'
|
|
6
|
+
import { emittedAssetName, publishEmittedAssets, routeCssAssetNames } from '../css/build'
|
|
6
7
|
import { matchRoute } from '../routing/routes'
|
|
7
8
|
import { bold, cyan, dim } from '../utils/ansi'
|
|
8
9
|
import { listFiles } from '../utils/fs'
|
|
@@ -112,6 +113,8 @@ async function analyzeRouteBundles(
|
|
|
112
113
|
if (!existsSync(manifestPath)) return []
|
|
113
114
|
|
|
114
115
|
const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as BuildManifest
|
|
116
|
+
// Resolve the logical CSS names through the build's content-hashed spellings.
|
|
117
|
+
publishEmittedAssets(outPath, manifest.assetNames)
|
|
115
118
|
const pages = manifest.routes.filter(route => route.kind === 'page')
|
|
116
119
|
const selected: AnalyzeRouteSelection[] = routeFilter
|
|
117
120
|
? filterAnalyzeRoutes(pages, routeFilter)
|
|
@@ -136,7 +139,7 @@ async function analyzeRouteBundles(
|
|
|
136
139
|
|
|
137
140
|
const bundles: AnalyzeRouteBundle[] = []
|
|
138
141
|
for (const { route, pathname } of selected) {
|
|
139
|
-
bundles.push(await analyzeRouteBundle(route, sizes, js, pathname))
|
|
142
|
+
bundles.push(await analyzeRouteBundle(outPath, route, sizes, js, pathname))
|
|
140
143
|
}
|
|
141
144
|
return bundles
|
|
142
145
|
}
|
|
@@ -165,6 +168,7 @@ function publicRoutePath(route: string) {
|
|
|
165
168
|
}
|
|
166
169
|
|
|
167
170
|
async function analyzeRouteBundle(
|
|
171
|
+
outPath: string,
|
|
168
172
|
route: RouteManifestEntry,
|
|
169
173
|
sizes: Map<string, AnalyzeFile>,
|
|
170
174
|
js: (path: string) => Promise<string>,
|
|
@@ -172,12 +176,9 @@ async function analyzeRouteBundle(
|
|
|
172
176
|
): Promise<AnalyzeRouteBundle> {
|
|
173
177
|
const initial = new Set<string>()
|
|
174
178
|
addIfExists(initial, sizes, routeHtmlPath(route, pathname))
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
// only when it is unset.
|
|
179
|
-
for (const asset of route.cssAssets ?? (route.cssImports.length ? [`${route.id}.css`] : []))
|
|
180
|
-
addIfExists(initial, sizes, `assets/${asset}`)
|
|
179
|
+
const assetFile = (name: string) => `assets/${emittedAssetName({ outPath }, name)}`
|
|
180
|
+
addIfExists(initial, sizes, assetFile('global.css'))
|
|
181
|
+
for (const asset of routeCssAssetNames(route)) addIfExists(initial, sizes, assetFile(asset))
|
|
181
182
|
// Every compat page loads this with a blocking <script>. The sibling
|
|
182
183
|
// `_ssgManifest.js` (router-fetched, never in the document) and the
|
|
183
184
|
// `polyfills-*.js` chunk (noModule: legacy browsers only) are deliberately
|
package/src/cli/boot/trace.ts
CHANGED
|
@@ -7,12 +7,19 @@
|
|
|
7
7
|
* one rss read; nothing is ever printed.
|
|
8
8
|
*/
|
|
9
9
|
import { loadavg, cpus, arch, platform } from 'node:os'
|
|
10
|
-
import {
|
|
10
|
+
import { readdirSync } from 'node:fs'
|
|
11
11
|
import path from 'node:path'
|
|
12
12
|
import { traceEnabled } from '../../utils/trace-flags'
|
|
13
13
|
|
|
14
14
|
const enabled = traceEnabled('boot')
|
|
15
15
|
const marks: [string, number, number][] = []
|
|
16
|
+
// Snapshotted at import: boot creates the cache directory long before the trace prints, so reading
|
|
17
|
+
// it at print time always reported "warm" - including on a run that had just wiped it.
|
|
18
|
+
const cacheAtStart = enabled ? cacheState() : 'cold'
|
|
19
|
+
|
|
20
|
+
function cacheState() {
|
|
21
|
+
return countTopLevel(path.join('.pnext', 'dev', 'cache')) > 0 ? 'warm' : 'cold'
|
|
22
|
+
}
|
|
16
23
|
|
|
17
24
|
// Bun (<=1.3.x) lacks the memoryUsage.rss fast path Node has.
|
|
18
25
|
const rss = () =>
|
|
@@ -31,10 +38,9 @@ function traceHeader(): string[] {
|
|
|
31
38
|
.map(value => value.toFixed(1))
|
|
32
39
|
.join(' ')
|
|
33
40
|
const packages = countTopLevel('node_modules')
|
|
34
|
-
const outDir = existsSync(path.resolve('.pnext', 'dev', 'cache')) ? 'warm' : 'cold'
|
|
35
41
|
return [
|
|
36
42
|
` runtime ${typeof Bun !== 'undefined' ? `bun ${Bun.version}` : `node ${process.version}`} · ${platform()} ${arch()} · ${cpus().length} cpus · load ${load}`,
|
|
37
|
-
` app ${packages} node_modules entries · .pnext/dev cache ${
|
|
43
|
+
` app ${packages} node_modules entries · .pnext/dev cache ${cacheAtStart}`,
|
|
38
44
|
]
|
|
39
45
|
}
|
|
40
46
|
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import { lstat, mkdir, readFile, readdir, readlink } from 'node:fs/promises'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import type { ResolvedConfig } from '../../config'
|
|
6
|
+
import { frameworkFingerprint } from '../../runtime/fingerprint'
|
|
7
|
+
import type { BuildManifest } from '../../types'
|
|
8
|
+
import { writeFileAtomic } from '../../utils/fs'
|
|
9
|
+
|
|
10
|
+
const CACHE_VERSION = 2
|
|
11
|
+
const CACHE_SEGMENTS = ['cache', 'build'] as const
|
|
12
|
+
const SKIP_INPUT_DIRS = new Set([
|
|
13
|
+
'.claude',
|
|
14
|
+
'.git',
|
|
15
|
+
'.husky',
|
|
16
|
+
'.next',
|
|
17
|
+
'.pnext',
|
|
18
|
+
'.tmp',
|
|
19
|
+
'.turbo',
|
|
20
|
+
'.vercel',
|
|
21
|
+
'.vscode',
|
|
22
|
+
'node_modules',
|
|
23
|
+
])
|
|
24
|
+
const SKIP_INPUT_FILE = /(?:^\.DS_Store$|\.(?:log|pid)$)/i
|
|
25
|
+
const LOCK_FILES = [
|
|
26
|
+
'bun.lock',
|
|
27
|
+
'bun.lockb',
|
|
28
|
+
'package-lock.json',
|
|
29
|
+
'npm-shrinkwrap.json',
|
|
30
|
+
'pnpm-lock.yaml',
|
|
31
|
+
'yarn.lock',
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
type InputRecord = [mtimeMs: number, size: number, hash: string]
|
|
35
|
+
|
|
36
|
+
interface CacheRecord {
|
|
37
|
+
version: number
|
|
38
|
+
key: string
|
|
39
|
+
inputs: Record<string, InputRecord>
|
|
40
|
+
outputHash: string
|
|
41
|
+
outputs: Record<string, string>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface BuildCacheOptions {
|
|
45
|
+
adapter?: 'vercel'
|
|
46
|
+
debugBuildPaths?: string
|
|
47
|
+
buildMode?: 'compile' | 'generate'
|
|
48
|
+
debugPrerender?: boolean
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface BuildCacheSession {
|
|
52
|
+
enabled: boolean
|
|
53
|
+
context?: string
|
|
54
|
+
inputs?: Record<string, InputRecord>
|
|
55
|
+
manifest?: BuildManifest
|
|
56
|
+
reason?: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Validate the standard production build before its output directory is touched. */
|
|
60
|
+
export async function lookupBuildCache(
|
|
61
|
+
config: ResolvedConfig,
|
|
62
|
+
options: BuildCacheOptions,
|
|
63
|
+
): Promise<BuildCacheSession> {
|
|
64
|
+
if (!cacheEnabled(options)) return { enabled: false }
|
|
65
|
+
const record = await readRecord(config.outPath)
|
|
66
|
+
const inputs = await inputRecords(config, record?.inputs)
|
|
67
|
+
const context = buildContext()
|
|
68
|
+
const key = buildKey(context, inputs)
|
|
69
|
+
if (record?.key !== key) return { enabled: true, context, inputs, reason: 'inputs' }
|
|
70
|
+
const output = await outputSnapshot(config.outPath)
|
|
71
|
+
if (output.hash !== record.outputHash) {
|
|
72
|
+
const changed = changedFiles(record.outputs, output.files).slice(0, 3).join(', ')
|
|
73
|
+
return { enabled: true, context, inputs, reason: `output (${changed || 'unknown'})` }
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
const manifest = JSON.parse(
|
|
77
|
+
await readFile(path.join(config.outPath, 'manifest.json'), 'utf8'),
|
|
78
|
+
) as BuildManifest
|
|
79
|
+
return { enabled: true, context, inputs, manifest, reason: 'hit' }
|
|
80
|
+
} catch {
|
|
81
|
+
return { enabled: true, context, inputs, reason: 'manifest' }
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Publish a successful build only after every production artifact has landed. */
|
|
86
|
+
export async function writeBuildCache(
|
|
87
|
+
config: ResolvedConfig,
|
|
88
|
+
options: BuildCacheOptions,
|
|
89
|
+
session: BuildCacheSession,
|
|
90
|
+
) {
|
|
91
|
+
if (!session.enabled || !session.context || !cacheEnabled(options)) return
|
|
92
|
+
const inputs = await inputRecords(config, session.inputs)
|
|
93
|
+
const output = await outputSnapshot(config.outPath)
|
|
94
|
+
const record: CacheRecord = {
|
|
95
|
+
version: CACHE_VERSION,
|
|
96
|
+
key: buildKey(session.context, inputs),
|
|
97
|
+
inputs,
|
|
98
|
+
outputHash: output.hash,
|
|
99
|
+
outputs: output.files,
|
|
100
|
+
}
|
|
101
|
+
await mkdir(cacheDir(config.outPath), { recursive: true })
|
|
102
|
+
await writeFileAtomic(recordFile(config.outPath), JSON.stringify(record))
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function cacheEnabled(options: BuildCacheOptions) {
|
|
106
|
+
// Opt-in until the no-change short-circuit is finished: the key currently hashes the whole
|
|
107
|
+
// environment, so cross-shell hits are rare and the cache adds bookkeeping without payoff.
|
|
108
|
+
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
109
|
+
if (process.env.PNEXT_BUILD_CACHE !== '1') return false
|
|
110
|
+
return (
|
|
111
|
+
options.adapter === undefined &&
|
|
112
|
+
options.debugBuildPaths === undefined &&
|
|
113
|
+
options.buildMode === undefined &&
|
|
114
|
+
options.debugPrerender !== true
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function cacheDir(outPath: string) {
|
|
119
|
+
return path.join(outPath, ...CACHE_SEGMENTS)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function recordFile(outPath: string) {
|
|
123
|
+
return path.join(cacheDir(outPath), 'result.json')
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function readRecord(outPath: string): Promise<CacheRecord | undefined> {
|
|
127
|
+
try {
|
|
128
|
+
const parsed = JSON.parse(await readFile(recordFile(outPath), 'utf8')) as CacheRecord
|
|
129
|
+
if (
|
|
130
|
+
parsed.version === CACHE_VERSION &&
|
|
131
|
+
parsed.key &&
|
|
132
|
+
parsed.inputs &&
|
|
133
|
+
parsed.outputHash &&
|
|
134
|
+
parsed.outputs
|
|
135
|
+
) {
|
|
136
|
+
return parsed
|
|
137
|
+
}
|
|
138
|
+
} catch {
|
|
139
|
+
// A partial or older entry is a miss, never a stale hit.
|
|
140
|
+
}
|
|
141
|
+
return undefined
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function buildContext() {
|
|
145
|
+
const hash = createHash('sha256')
|
|
146
|
+
hash.update(`pnext-build-cache:${CACHE_VERSION}\0`)
|
|
147
|
+
hash.update(frameworkFingerprint())
|
|
148
|
+
hash.update(`\0bun:${Bun.version}\0`)
|
|
149
|
+
for (const [name, value] of Object.entries(process.env).sort(([a], [b]) => a.localeCompare(b))) {
|
|
150
|
+
hash.update(`${name}=${value ?? ''}\0`)
|
|
151
|
+
}
|
|
152
|
+
return hash.digest('hex')
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function buildKey(context: string, inputs: Record<string, InputRecord>) {
|
|
156
|
+
const hash = createHash('sha256')
|
|
157
|
+
hash.update(context)
|
|
158
|
+
for (const [file, record] of Object.entries(inputs).sort(([a], [b]) => a.localeCompare(b))) {
|
|
159
|
+
hash.update(`${file}\0${record[2]}\0`)
|
|
160
|
+
}
|
|
161
|
+
return hash.digest('hex')
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function inputRecords(
|
|
165
|
+
config: ResolvedConfig,
|
|
166
|
+
previous: Record<string, InputRecord> | undefined,
|
|
167
|
+
) {
|
|
168
|
+
const files = new Map<string, string>()
|
|
169
|
+
await collectTree(config.root, 'root', files, config.outPath)
|
|
170
|
+
await collectModuleGraphInputs(config, files)
|
|
171
|
+
|
|
172
|
+
// Dependency trees are represented by package-manager content identities,
|
|
173
|
+
// while first-party modules above are keyed by their own bytes.
|
|
174
|
+
for (const [prefix, root] of [
|
|
175
|
+
['root-deps', config.root],
|
|
176
|
+
['workspace-deps', config.workspaceRoot],
|
|
177
|
+
] as const) {
|
|
178
|
+
for (const name of LOCK_FILES) addFile(files, `${prefix}:${name}`, path.join(root, name))
|
|
179
|
+
addFile(
|
|
180
|
+
files,
|
|
181
|
+
`${prefix}:node_modules/.package-lock.json`,
|
|
182
|
+
path.join(root, 'node_modules', '.package-lock.json'),
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const frameworkRoot = path.resolve(import.meta.dirname, '..', '..')
|
|
187
|
+
addFile(files, 'framework:package.json', path.join(frameworkRoot, 'package.json'))
|
|
188
|
+
for (const name of LOCK_FILES) {
|
|
189
|
+
addFile(files, `framework:${name}`, path.join(frameworkRoot, name))
|
|
190
|
+
addFile(files, `framework-workspace:${name}`, path.join(frameworkRoot, '..', name))
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const entries = await Promise.all(
|
|
194
|
+
[...files].map(async ([key, file]): Promise<[string, InputRecord] | undefined> => {
|
|
195
|
+
const stats = await lstat(file).catch(() => undefined)
|
|
196
|
+
if (!stats?.isFile() && !stats?.isSymbolicLink()) return undefined
|
|
197
|
+
const old = previous?.[key]
|
|
198
|
+
if (old?.[0] === stats.mtimeMs && old[1] === stats.size) return [key, old]
|
|
199
|
+
const contents = stats.isSymbolicLink()
|
|
200
|
+
? Buffer.from(`link:${await readlink(file)}`)
|
|
201
|
+
: await readFile(file)
|
|
202
|
+
return [key, [stats.mtimeMs, stats.size, contentHash(contents)]]
|
|
203
|
+
}),
|
|
204
|
+
)
|
|
205
|
+
return Object.fromEntries(
|
|
206
|
+
entries.filter((entry): entry is [string, InputRecord] => Boolean(entry)),
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
interface ModuleGraphIndex {
|
|
211
|
+
files?: Record<string, unknown>
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function collectModuleGraphInputs(config: ResolvedConfig, files: Map<string, string>) {
|
|
215
|
+
let parsed: ModuleGraphIndex
|
|
216
|
+
try {
|
|
217
|
+
parsed = JSON.parse(
|
|
218
|
+
await readFile(path.join(config.outPath, 'cache', 'server', 'graph.json'), 'utf8'),
|
|
219
|
+
) as ModuleGraphIndex
|
|
220
|
+
} catch {
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
const frameworkRoot = path.resolve(import.meta.dirname, '..', '..')
|
|
224
|
+
for (const source of Object.keys(parsed.files ?? {})) {
|
|
225
|
+
const value = source.slice(2).split('/').join(path.sep)
|
|
226
|
+
const file = source.startsWith('w:')
|
|
227
|
+
? path.resolve(config.workspaceRoot, value)
|
|
228
|
+
: source.startsWith('f:')
|
|
229
|
+
? path.resolve(frameworkRoot, value)
|
|
230
|
+
: source.startsWith('a:')
|
|
231
|
+
? value
|
|
232
|
+
: undefined
|
|
233
|
+
if (file) addFile(files, `module:${source}`, file)
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function collectTree(
|
|
238
|
+
root: string,
|
|
239
|
+
prefix: string,
|
|
240
|
+
files: Map<string, string>,
|
|
241
|
+
outPath: string,
|
|
242
|
+
dir = root,
|
|
243
|
+
): Promise<void> {
|
|
244
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => [])
|
|
245
|
+
await Promise.all(
|
|
246
|
+
entries.map(async entry => {
|
|
247
|
+
const file = path.join(dir, entry.name)
|
|
248
|
+
if (file === outPath || file.startsWith(`${outPath}${path.sep}`)) return
|
|
249
|
+
if (entry.isDirectory()) {
|
|
250
|
+
if (!SKIP_INPUT_DIRS.has(entry.name)) await collectTree(root, prefix, files, outPath, file)
|
|
251
|
+
return
|
|
252
|
+
}
|
|
253
|
+
if (!entry.isFile() && !entry.isSymbolicLink()) return
|
|
254
|
+
const relative = path.relative(root, file)
|
|
255
|
+
// Local server logs and top-level response captures are not compiler
|
|
256
|
+
// inputs; watching processes commonly rewrite them during a build.
|
|
257
|
+
if (
|
|
258
|
+
SKIP_INPUT_FILE.test(entry.name) ||
|
|
259
|
+
(!relative.includes(path.sep) && /\.html?$/i.test(entry.name))
|
|
260
|
+
) {
|
|
261
|
+
return
|
|
262
|
+
}
|
|
263
|
+
addFile(files, `${prefix}:${toPosix(relative)}`, file)
|
|
264
|
+
}),
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function addFile(files: Map<string, string>, key: string, file: string) {
|
|
269
|
+
if (existsSync(file)) files.set(key, file)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function outputSnapshot(outPath: string) {
|
|
273
|
+
const files = new Map<string, string>()
|
|
274
|
+
const entries = await readdir(outPath, { withFileTypes: true }).catch(() => [])
|
|
275
|
+
await Promise.all(
|
|
276
|
+
entries.map(async entry => {
|
|
277
|
+
if (entry.name === 'cache' || entry.name === 'dev') return
|
|
278
|
+
const file = path.join(outPath, entry.name)
|
|
279
|
+
if (entry.isDirectory()) await collectOutputTree(outPath, file, files)
|
|
280
|
+
else if (entry.isFile() || entry.isSymbolicLink()) {
|
|
281
|
+
files.set(toPosix(entry.name), file)
|
|
282
|
+
}
|
|
283
|
+
}),
|
|
284
|
+
)
|
|
285
|
+
const hash = createHash('sha256')
|
|
286
|
+
const hashes: Record<string, string> = {}
|
|
287
|
+
for (const [name, file] of [...files].sort(([a], [b]) => a.localeCompare(b))) {
|
|
288
|
+
const stats = await lstat(file)
|
|
289
|
+
const contents = stats.isSymbolicLink()
|
|
290
|
+
? Buffer.from(`link:${await readlink(file)}`)
|
|
291
|
+
: await readFile(file)
|
|
292
|
+
const contentsHash = contentHash(contents)
|
|
293
|
+
hashes[name] = contentsHash
|
|
294
|
+
hash.update(`${name}\0${contentsHash}\0`)
|
|
295
|
+
}
|
|
296
|
+
return { hash: hash.digest('hex'), files: hashes }
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function changedFiles(before: Record<string, string>, after: Record<string, string>) {
|
|
300
|
+
return [...new Set([...Object.keys(before), ...Object.keys(after)])].filter(
|
|
301
|
+
file => before[file] !== after[file],
|
|
302
|
+
)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function collectOutputTree(root: string, dir: string, files: Map<string, string>) {
|
|
306
|
+
const entries = await readdir(dir, { withFileTypes: true })
|
|
307
|
+
await Promise.all(
|
|
308
|
+
entries.map(async entry => {
|
|
309
|
+
const file = path.join(dir, entry.name)
|
|
310
|
+
if (entry.isDirectory()) await collectOutputTree(root, file, files)
|
|
311
|
+
else if (entry.isFile() || entry.isSymbolicLink()) {
|
|
312
|
+
files.set(toPosix(path.relative(root, file)), file)
|
|
313
|
+
}
|
|
314
|
+
}),
|
|
315
|
+
)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function contentHash(contents: Uint8Array) {
|
|
319
|
+
return createHash('sha256').update(contents).digest('hex')
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function toPosix(file: string) {
|
|
323
|
+
return path.sep === '/' ? file : file.split(path.sep).join('/')
|
|
324
|
+
}
|
package/src/cli/build.ts
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
buildGlobalCss,
|
|
31
31
|
buildNotFoundCss,
|
|
32
32
|
buildRouteCss,
|
|
33
|
+
emittedAssetNames,
|
|
33
34
|
prepareRouteCssChunks,
|
|
34
35
|
registerCssRuntime,
|
|
35
36
|
warmCssPipeline,
|
|
@@ -76,12 +77,14 @@ import { findProxyFile, proxyExternalLoadTarget, validateProxyFiles } from '../r
|
|
|
76
77
|
import { setRequestRuntime } from '../routing/request-environment'
|
|
77
78
|
import {
|
|
78
79
|
addClientEntryReason,
|
|
80
|
+
assertNoServerActionsWithoutCompat,
|
|
79
81
|
findLayouts,
|
|
80
82
|
materializeRouteFacts,
|
|
81
83
|
scanRoutes,
|
|
82
84
|
} from '../routing/routes'
|
|
83
85
|
import { interceptionMarkerLevels } from '../routing/slots'
|
|
84
86
|
import { writeTypegen } from './typegen'
|
|
87
|
+
import { lookupBuildCache, writeBuildCache } from './build/cache'
|
|
85
88
|
import { createVerboseLogger, type VerboseLogger } from '../utils/verbose'
|
|
86
89
|
import { bold, cyan, dim, green } from '../utils/ansi'
|
|
87
90
|
import type {
|
|
@@ -177,9 +180,7 @@ async function runBuild(root: string | undefined, options: BuildOptions) {
|
|
|
177
180
|
`${cyan('▲')} ${bold('pnext')} ${dim('— Creating an optimized production build ...\n')}`,
|
|
178
181
|
)
|
|
179
182
|
const config = await loadConfig(root)
|
|
180
|
-
|
|
181
|
-
// stylesheet doesn't pay its cold boot.
|
|
182
|
-
warmCssPipeline(config)
|
|
183
|
+
const buildCache = lookupBuildCache(config, options)
|
|
183
184
|
// Compat plugin loader: the single gated seam that populates the core
|
|
184
185
|
// extension registries when compat is enabled (no-op for pure-core apps).
|
|
185
186
|
// A build compiles immediately, so it takes both tiers up front.
|
|
@@ -190,6 +191,21 @@ async function runBuild(root: string | undefined, options: BuildOptions) {
|
|
|
190
191
|
// server-boot-only hooks out of the build - Next never runs register() during `next build`, so
|
|
191
192
|
// build prerenders must see a noop tracer.
|
|
192
193
|
runInitHooks(config, { build: true })
|
|
194
|
+
const cached = await buildCache
|
|
195
|
+
log.log(`build cache ${cached.reason ?? 'disabled'}`)
|
|
196
|
+
if (cached.manifest) {
|
|
197
|
+
printBuildSummary(
|
|
198
|
+
config,
|
|
199
|
+
cached.manifest.routes,
|
|
200
|
+
cached.manifest.staticFiles ?? {},
|
|
201
|
+
performance.now() - startedAt,
|
|
202
|
+
Boolean(cached.manifest.proxyModule),
|
|
203
|
+
)
|
|
204
|
+
return cached.manifest
|
|
205
|
+
}
|
|
206
|
+
// Load Tailwind on the CSS worker while the steps below run, so the first
|
|
207
|
+
// stylesheet doesn't pay its cold boot.
|
|
208
|
+
warmCssPipeline(config)
|
|
193
209
|
// The vercel adapter's warm pass runs in its own process; start it here so
|
|
194
210
|
// its boot overlaps the build and only the warming itself lands on the
|
|
195
211
|
// adapter step.
|
|
@@ -270,6 +286,9 @@ async function runBuild(root: string | undefined, options: BuildOptions) {
|
|
|
270
286
|
// eslint-disable-next-line @typescript-eslint/require-await
|
|
271
287
|
async () => materializeRouteFacts(routes, route => clientSources.warmRoutes([route])),
|
|
272
288
|
)
|
|
289
|
+
// Before anything renders: core cannot dispatch a server action, so an app with
|
|
290
|
+
// one fails the build by name instead of shipping forms that go nowhere.
|
|
291
|
+
assertNoServerActionsWithoutCompat(routes, nextCompatEnabled(config), config.root)
|
|
273
292
|
// Render-time extensions read the route table from the request runtime (e.g.
|
|
274
293
|
// compat's static-sibling route state); publish it for the prerender pass the
|
|
275
294
|
// same way the serve handlers do.
|
|
@@ -358,19 +377,6 @@ async function runBuild(root: string | undefined, options: BuildOptions) {
|
|
|
358
377
|
(route.client || route.clientReferences.length > 0 || route.needsRouterEntry),
|
|
359
378
|
)
|
|
360
379
|
for (const route of clientRoutes) route.clientEntry = `assets/${clientEntryName(route)}.js`
|
|
361
|
-
// A prerenderable 404 boots the server graph; the client bundles are esbuild.
|
|
362
|
-
// Both need only the stub set and the entry names decided just above, so run
|
|
363
|
-
// them side by side instead of paying the render after the bundle. Result is
|
|
364
|
-
// consumed after the prerender pass, where it always was.
|
|
365
|
-
const notFoundDocuments = renderNotFoundDocuments({
|
|
366
|
-
config,
|
|
367
|
-
log,
|
|
368
|
-
skip: options.buildMode === 'compile',
|
|
369
|
-
documentCss,
|
|
370
|
-
staticMetadataFiles,
|
|
371
|
-
staticModuleMetadata,
|
|
372
|
-
})
|
|
373
|
-
notFoundDocuments.catch(() => undefined)
|
|
374
380
|
const clientBundles = log.step(
|
|
375
381
|
`client bundles (${clientRoutes.length} route${clientRoutes.length === 1 ? '' : 's'})`,
|
|
376
382
|
() =>
|
|
@@ -384,6 +390,21 @@ async function runBuild(root: string | undefined, options: BuildOptions) {
|
|
|
384
390
|
}),
|
|
385
391
|
)
|
|
386
392
|
clientBundles.catch(() => undefined)
|
|
393
|
+
// The 404 document links the same content-hashed entries and stylesheets every
|
|
394
|
+
// other document does, and those names are only final once the client stage has
|
|
395
|
+
// fingerprinted them — so this render follows the bundle rather than racing it.
|
|
396
|
+
// Result is consumed after the prerender pass, where it always was.
|
|
397
|
+
const notFoundDocuments = clientBundles.then(() =>
|
|
398
|
+
renderNotFoundDocuments({
|
|
399
|
+
config,
|
|
400
|
+
log,
|
|
401
|
+
skip: options.buildMode === 'compile',
|
|
402
|
+
documentCss,
|
|
403
|
+
staticMetadataFiles,
|
|
404
|
+
staticModuleMetadata,
|
|
405
|
+
}),
|
|
406
|
+
)
|
|
407
|
+
notFoundDocuments.catch(() => undefined)
|
|
387
408
|
// The manifest-writing steps and typegen depend on nothing the client stage produces, so they run
|
|
388
409
|
// under it instead of ahead of it. Awaited first so a step failure still reports before a
|
|
389
410
|
// bundling error.
|
|
@@ -698,7 +719,6 @@ async function runBuild(root: string | undefined, options: BuildOptions) {
|
|
|
698
719
|
...(route.usesRequest ? { request: new Request(url) } : {}),
|
|
699
720
|
staticMetadataFiles,
|
|
700
721
|
staticModuleMetadata,
|
|
701
|
-
resolveDynamicMetadataRoutes: true,
|
|
702
722
|
}),
|
|
703
723
|
),
|
|
704
724
|
),
|
|
@@ -881,6 +901,9 @@ async function runBuild(root: string | undefined, options: BuildOptions) {
|
|
|
881
901
|
outDir: config.outPath,
|
|
882
902
|
routes,
|
|
883
903
|
staticFiles,
|
|
904
|
+
// The serving process re-publishes these so a render resolves the same
|
|
905
|
+
// content-hashed names the build emitted.
|
|
906
|
+
assetNames: emittedAssetNames(config.outPath),
|
|
884
907
|
staticMetadataFiles,
|
|
885
908
|
...(Object.keys(staticModuleMetadata).length > 0 ? { staticModuleMetadata } : {}),
|
|
886
909
|
...(!config.compat?.next
|
|
@@ -908,6 +931,8 @@ async function runBuild(root: string | undefined, options: BuildOptions) {
|
|
|
908
931
|
// (typecheck) are awaited next and reported on their own lines.
|
|
909
932
|
const buildDurationMs = performance.now() - startedAt
|
|
910
933
|
const phases = await settleBuildParallelPhases()
|
|
934
|
+
flushDevModuleCaches()
|
|
935
|
+
await writeBuildCache(config, options, cached)
|
|
911
936
|
|
|
912
937
|
printBuildSummary(
|
|
913
938
|
config,
|
|
@@ -2156,7 +2181,6 @@ async function renderNotFoundDocuments({
|
|
|
2156
2181
|
url: new URL('http://pnext.local/_not-found'),
|
|
2157
2182
|
staticMetadataFiles,
|
|
2158
2183
|
staticModuleMetadata,
|
|
2159
|
-
resolveDynamicMetadataRoutes: true,
|
|
2160
2184
|
})
|
|
2161
2185
|
return response.text()
|
|
2162
2186
|
}
|