@wular/pnext 0.0.19 → 0.0.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/reference/compat.md +2 -2
  4. package/reference/getting-started.md +2 -2
  5. package/src/cli/adapters/vercel.ts +27 -0
  6. package/src/cli/analyze.ts +8 -7
  7. package/src/cli/boot/trace.ts +9 -3
  8. package/src/cli/build/cache.ts +324 -0
  9. package/src/cli/build.ts +42 -18
  10. package/src/cli/create.ts +4 -4
  11. package/src/cli/dev.ts +23 -0
  12. package/src/cli/migrate/package-json.ts +3 -2
  13. package/src/cli/migrate/report.ts +1 -1
  14. package/src/cli/migrate/run.ts +2 -2
  15. package/src/cli/migrate/scan.ts +128 -1
  16. package/src/cli/serve/pipeline.ts +42 -42
  17. package/src/cli/start.ts +20 -20
  18. package/src/client/build.ts +64 -35
  19. package/src/client/entry.ts +228 -16
  20. package/src/client/router/page-slot.ts +81 -0
  21. package/src/client/router/runtime.ts +107 -112
  22. package/src/compat/bundler/cjs-exports.ts +26 -20
  23. package/src/compat/client/css-order.ts +1 -1
  24. package/src/compat/client/navigation-scroll.ts +36 -40
  25. package/src/compat/css/modules.ts +6 -2
  26. package/src/compat/metadata.ts +0 -9
  27. package/src/compat/next/font/cache.ts +10 -22
  28. package/src/compat/next/font/runtime.ts +20 -0
  29. package/src/compat/next/legacy-request-apis.ts +41 -5
  30. package/src/compat/next/script.tsx +16 -24
  31. package/src/compat/react/client-lite.ts +1 -0
  32. package/src/compat/react/client.ts +11 -0
  33. package/src/compat/react/dom-client.ts +11 -2
  34. package/src/compat/react/dom-react-server.ts +4 -0
  35. package/src/compat/react/dom.ts +11 -2
  36. package/src/compat/react/internals.ts +65 -0
  37. package/src/compat/react/parity.ts +47 -0
  38. package/src/compat/react/preact.ts +31 -1
  39. package/src/compat/react/react-server.ts +7 -0
  40. package/src/compat/react/server.ts +16 -2
  41. package/src/compat/react/style-values.ts +68 -0
  42. package/src/compat/register/proxy.ts +3 -1
  43. package/src/compat/register/render.ts +14 -3
  44. package/src/compat/register/routing.ts +57 -7
  45. package/src/compat/register/typed-routes.ts +0 -2
  46. package/src/compat/tsconfig-defaults.ts +4 -0
  47. package/src/compat/typecheck/check.ts +26 -71
  48. package/src/compat/typecheck/worker.ts +3 -2
  49. package/src/compat/validation/validate.ts +2 -0
  50. package/src/compat-bootstrap.ts +4 -0
  51. package/src/css/build.ts +153 -14
  52. package/src/css/postcss.ts +32 -3
  53. package/src/dev/restart/client-key.ts +1 -1
  54. package/src/dev/restart/global-css.ts +12 -94
  55. package/src/dev/restart/record.ts +106 -0
  56. package/src/dev/restart/route-facts.ts +11 -90
  57. package/src/dev/server.ts +208 -41
  58. package/src/extensions.ts +69 -3
  59. package/src/render/hooks.ts +1 -1
  60. package/src/render/metadata.ts +89 -17
  61. package/src/render/renderer.ts +325 -138
  62. package/src/render/resource-hints.ts +62 -9
  63. package/src/render/slots.tsx +2 -1
  64. package/src/resolve/scan-facts.ts +13 -5
  65. package/src/routing/forwarded.ts +20 -7
  66. package/src/routing/handler.ts +6 -2
  67. package/src/routing/href.ts +11 -1
  68. package/src/routing/metadata-files.ts +16 -3
  69. package/src/routing/proxy.ts +6 -3
  70. package/src/routing/routes.ts +253 -34
  71. package/src/runtime/fetch-host.ts +42 -0
  72. package/src/runtime/fingerprint.ts +132 -0
  73. package/src/runtime/loader.ts +7 -5
  74. package/src/runtime/module-cache.ts +3 -36
  75. package/src/runtime/modules.ts +333 -77
  76. package/src/runtime/vendor-build.ts +34 -5
  77. package/src/runtime/vendor.ts +1 -18
  78. package/src/types.ts +6 -0
  79. package/src/utils/asset-hash.ts +22 -0
  80. package/src/utils/code.ts +41 -0
  81. package/src/utils/html.ts +4 -1
  82. package/src/utils/registry.ts +70 -0
  83. package/src/utils/serialize.ts +66 -7
package/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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wular/pnext",
3
- "version": "0.0.19",
3
+ "version": "0.0.21",
4
4
  "description": "A fast little framework for server-first React apps, fully compatible with Next.js",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 []
@@ -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
- addIfExists(initial, sizes, 'assets/global.css')
176
- // Mirrors the renderer's stylesheet list: compat cssChunking splits a route's
177
- // CSS into `<id>-<n>.css` and records them in cssAssets; `<id>.css` is emitted
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
@@ -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 { existsSync, readdirSync } from 'node:fs'
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 ${outDir}`,
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
- // Load Tailwind on the CSS worker while the steps below run, so the first
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
  }