@wular/pnext 0.0.19 → 0.0.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/reference/compat.md +2 -2
  4. package/reference/getting-started.md +2 -2
  5. package/src/cli/adapters/vercel.ts +27 -0
  6. package/src/cli/analyze.ts +8 -7
  7. package/src/cli/boot/trace.ts +9 -3
  8. package/src/cli/build.ts +23 -15
  9. package/src/cli/dev.ts +23 -0
  10. package/src/cli/migrate/report.ts +1 -1
  11. package/src/cli/migrate/run.ts +2 -2
  12. package/src/cli/migrate/scan.ts +128 -1
  13. package/src/cli/serve/pipeline.ts +42 -42
  14. package/src/cli/start.ts +4 -2
  15. package/src/client/build.ts +51 -25
  16. package/src/client/entry.ts +27 -4
  17. package/src/client/router/page-slot.ts +81 -0
  18. package/src/client/router/runtime.ts +107 -112
  19. package/src/compat/bundler/cjs-exports.ts +26 -20
  20. package/src/compat/client/css-order.ts +1 -1
  21. package/src/compat/client/navigation-scroll.ts +36 -40
  22. package/src/compat/css/modules.ts +6 -2
  23. package/src/compat/next/font/cache.ts +10 -22
  24. package/src/compat/next/font/runtime.ts +20 -0
  25. package/src/compat/next/legacy-request-apis.ts +41 -5
  26. package/src/compat/next/script.tsx +16 -24
  27. package/src/compat/react/client-lite.ts +1 -0
  28. package/src/compat/react/client.ts +11 -0
  29. package/src/compat/react/dom-client.ts +11 -2
  30. package/src/compat/react/dom-react-server.ts +4 -0
  31. package/src/compat/react/dom.ts +11 -2
  32. package/src/compat/react/internals.ts +65 -0
  33. package/src/compat/react/parity.ts +47 -0
  34. package/src/compat/react/preact.ts +31 -1
  35. package/src/compat/react/react-server.ts +7 -0
  36. package/src/compat/react/server.ts +16 -2
  37. package/src/compat/react/style-values.ts +68 -0
  38. package/src/compat/register/render.ts +14 -3
  39. package/src/compat/register/routing.ts +57 -7
  40. package/src/compat/tsconfig-defaults.ts +4 -0
  41. package/src/compat/typecheck/check.ts +26 -71
  42. package/src/compat/typecheck/worker.ts +3 -2
  43. package/src/compat-bootstrap.ts +4 -0
  44. package/src/css/build.ts +153 -14
  45. package/src/css/postcss.ts +32 -3
  46. package/src/dev/restart/client-key.ts +1 -1
  47. package/src/dev/restart/global-css.ts +12 -94
  48. package/src/dev/restart/record.ts +106 -0
  49. package/src/dev/restart/route-facts.ts +11 -90
  50. package/src/dev/server.ts +208 -41
  51. package/src/extensions.ts +69 -3
  52. package/src/render/hooks.ts +1 -1
  53. package/src/render/metadata.ts +89 -17
  54. package/src/render/renderer.ts +322 -136
  55. package/src/render/resource-hints.ts +62 -9
  56. package/src/render/slots.tsx +2 -1
  57. package/src/resolve/scan-facts.ts +13 -5
  58. package/src/routing/forwarded.ts +20 -7
  59. package/src/routing/handler.ts +6 -2
  60. package/src/routing/href.ts +11 -1
  61. package/src/routing/metadata-files.ts +16 -3
  62. package/src/routing/proxy.ts +6 -3
  63. package/src/routing/routes.ts +253 -34
  64. package/src/runtime/fetch-host.ts +42 -0
  65. package/src/runtime/fingerprint.ts +132 -0
  66. package/src/runtime/loader.ts +7 -5
  67. package/src/runtime/module-cache.ts +3 -36
  68. package/src/runtime/modules.ts +333 -77
  69. package/src/runtime/vendor-build.ts +34 -5
  70. package/src/runtime/vendor.ts +1 -18
  71. package/src/types.ts +6 -0
  72. package/src/utils/asset-hash.ts +22 -0
  73. package/src/utils/code.ts +41 -0
  74. package/src/utils/html.ts +4 -1
  75. package/src/utils/serialize.ts +66 -7
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.20",
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
 
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,6 +77,7 @@ 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,
@@ -270,6 +272,9 @@ async function runBuild(root: string | undefined, options: BuildOptions) {
270
272
  // eslint-disable-next-line @typescript-eslint/require-await
271
273
  async () => materializeRouteFacts(routes, route => clientSources.warmRoutes([route])),
272
274
  )
275
+ // Before anything renders: core cannot dispatch a server action, so an app with
276
+ // one fails the build by name instead of shipping forms that go nowhere.
277
+ assertNoServerActionsWithoutCompat(routes, nextCompatEnabled(config), config.root)
273
278
  // Render-time extensions read the route table from the request runtime (e.g.
274
279
  // compat's static-sibling route state); publish it for the prerender pass the
275
280
  // same way the serve handlers do.
@@ -358,19 +363,6 @@ async function runBuild(root: string | undefined, options: BuildOptions) {
358
363
  (route.client || route.clientReferences.length > 0 || route.needsRouterEntry),
359
364
  )
360
365
  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
366
  const clientBundles = log.step(
375
367
  `client bundles (${clientRoutes.length} route${clientRoutes.length === 1 ? '' : 's'})`,
376
368
  () =>
@@ -384,6 +376,21 @@ async function runBuild(root: string | undefined, options: BuildOptions) {
384
376
  }),
385
377
  )
386
378
  clientBundles.catch(() => undefined)
379
+ // The 404 document links the same content-hashed entries and stylesheets every
380
+ // other document does, and those names are only final once the client stage has
381
+ // fingerprinted them — so this render follows the bundle rather than racing it.
382
+ // Result is consumed after the prerender pass, where it always was.
383
+ const notFoundDocuments = clientBundles.then(() =>
384
+ renderNotFoundDocuments({
385
+ config,
386
+ log,
387
+ skip: options.buildMode === 'compile',
388
+ documentCss,
389
+ staticMetadataFiles,
390
+ staticModuleMetadata,
391
+ }),
392
+ )
393
+ notFoundDocuments.catch(() => undefined)
387
394
  // The manifest-writing steps and typegen depend on nothing the client stage produces, so they run
388
395
  // under it instead of ahead of it. Awaited first so a step failure still reports before a
389
396
  // bundling error.
@@ -698,7 +705,6 @@ async function runBuild(root: string | undefined, options: BuildOptions) {
698
705
  ...(route.usesRequest ? { request: new Request(url) } : {}),
699
706
  staticMetadataFiles,
700
707
  staticModuleMetadata,
701
- resolveDynamicMetadataRoutes: true,
702
708
  }),
703
709
  ),
704
710
  ),
@@ -881,6 +887,9 @@ async function runBuild(root: string | undefined, options: BuildOptions) {
881
887
  outDir: config.outPath,
882
888
  routes,
883
889
  staticFiles,
890
+ // The serving process re-publishes these so a render resolves the same
891
+ // content-hashed names the build emitted.
892
+ assetNames: emittedAssetNames(config.outPath),
884
893
  staticMetadataFiles,
885
894
  ...(Object.keys(staticModuleMetadata).length > 0 ? { staticModuleMetadata } : {}),
886
895
  ...(!config.compat?.next
@@ -2156,7 +2165,6 @@ async function renderNotFoundDocuments({
2156
2165
  url: new URL('http://pnext.local/_not-found'),
2157
2166
  staticMetadataFiles,
2158
2167
  staticModuleMetadata,
2159
- resolveDynamicMetadataRoutes: true,
2160
2168
  })
2161
2169
  return response.text()
2162
2170
  }
package/src/cli/dev.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { createRequire } from 'node:module'
2
+ import path from 'node:path'
1
3
  import { dim } from '../utils/ansi'
2
4
  import { moduleGenerationStats } from '../runtime/module-generations'
3
5
  import { markBoot, printBootTrace } from './boot/trace'
@@ -40,6 +42,7 @@ export async function dev(options: DevOptions = {}) {
40
42
  markBoot('dev:imports')
41
43
  const config = await loadConfig(options.root, { dev: true })
42
44
  markBoot('dev:config')
45
+ warnDuplicateSass(config.root, Boolean(config.compat?.next))
43
46
  const port = options.port ?? 3000
44
47
  const hostname = options.hostname ?? 'localhost'
45
48
  // A memory-triggered restart can briefly race the previous listener's
@@ -76,6 +79,26 @@ export async function dev(options: DevOptions = {}) {
76
79
  return server
77
80
  }
78
81
 
82
+ /** Next's dev-only preflight warning for projects carrying both Sass implementations. */
83
+ function warnDuplicateSass(root: string, nextCompat: boolean): void {
84
+ if (!nextCompat) return
85
+ const appRequire = createRequire(path.join(root, 'package.json'))
86
+ const installed = (name: string) => {
87
+ try {
88
+ appRequire.resolve(name)
89
+ return true
90
+ } catch {
91
+ return false
92
+ }
93
+ }
94
+ if (!installed('sass') || !installed('node-sass')) return
95
+ console.warn(
96
+ 'Your project has both `sass` and `node-sass` installed as dependencies, but should only use one or the other. ' +
97
+ 'Please remove the `node-sass` dependency from your project. ' +
98
+ ' Read more: https://nextjs.org/docs/messages/duplicate-sass',
99
+ )
100
+ }
101
+
79
102
  // Every dev reload imports changed modules under fresh URLs, and the ES module
80
103
  // registry never evicts, so a long session pins every stale graph in memory.
81
104
  // Once RSS crosses the limit, trade a seconds-long restart (vendor bundles are
@@ -25,7 +25,7 @@ export interface ReportItem {
25
25
  export interface MigrationResult {
26
26
  edits: Edit[]
27
27
  reports: ReportItem[]
28
- /** Files reading cookies()/headers()/draftMode() synchronously (Next 14 style). */
28
+ /** Files reading cookies()/headers()/draftMode() or params/searchParams synchronously (Next 14 style). */
29
29
  syncRequestApis: string[]
30
30
  /** Files importing next/font/google, which needs the `next-font` metadata package. */
31
31
  googleFontImports: string[]
@@ -66,7 +66,7 @@ async function createPnextConfig(root: string, result: MigrationResult, dryRun:
66
66
  legacy
67
67
  ? 'export default {\n' +
68
68
  ' compat: {\n' +
69
- ' // Sync cookies()/headers() still work; migrate to `await` and drop this.\n' +
69
+ ' // Sync cookies()/headers()/params still work; migrate to `await` and drop this.\n' +
70
70
  ' next: { legacyRequestAPIs: true },\n' +
71
71
  ' },\n' +
72
72
  '};\n'
@@ -84,7 +84,7 @@ async function createPnextConfig(root: string, result: MigrationResult, dryRun:
84
84
 
85
85
  function reportSyncRequestApis(result: MigrationResult, existingConfig?: string) {
86
86
  result.reports.push({
87
- title: 'Sync cookies()/headers() detected',
87
+ title: 'Sync cookies()/headers()/params detected',
88
88
  detail: existingConfig
89
89
  ? `Set compat: { next: { legacyRequestAPIs: true } } in ${existingConfig} to keep these working, then migrate to \`await\` and remove it. See https://pnext.dev/docs/compat`
90
90
  : 'Enabled compat.next.legacyRequestAPIs; migrate to `await` and remove it. See https://pnext.dev/docs/compat',
@@ -23,6 +23,15 @@ const NEXT_HEADERS_IMPORT = /import\s*\{([^}]*)\}\s*from\s*['"]next\/headers['"]
23
23
  const REQUEST_APIS = new Set(['cookies', 'headers', 'draftMode'])
24
24
  const PROMISE_METHODS = new Set(['then', 'catch', 'finally'])
25
25
  const SPECIAL_PAGES = ['_app', '_document', '_error']
26
+ // App-router segment files, the only place `params`/`searchParams` arrive as props.
27
+ const SEGMENT_FILES = new Set(['page', 'layout', 'route', 'template', 'default'])
28
+ // Every head whose match ENDS at the parameter list's `(` — default exports and generateMetadata.
29
+ const SEGMENT_HEADS =
30
+ /export\s+default\s+(?:async\s+)?function\s*[\w$]*\s*\(|export\s+default\s+(?:async\s+)?\(|export\s+(?:async\s+)?function\s+generateMetadata\s*\(|export\s+const\s+generateMetadata\s*(?::[^=]*)?=\s*(?:async\s*)?\(/g
31
+ // Route-handler methods take `{ params }` as their SECOND parameter (the context object).
32
+ const ROUTE_HANDLER_HEADS =
33
+ /export\s+(?:async\s+)?function\s+(?:GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS)\s*\(|export\s+const\s+(?:GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS)\s*(?::[^=]*)?=\s*(?:async\s*)?\(/g
34
+ const SYNC_PROPS = ['params', 'searchParams'] as const
26
35
 
27
36
  export async function scanSources(root: string, result: MigrationResult) {
28
37
  const { SHIMMED_NEXT_DIST_PATHS } = await import('../../compat/aliases')
@@ -57,7 +66,9 @@ export async function scanSources(root: string, result: MigrationResult) {
57
66
  // package, so only it earns the dependency.
58
67
  if (specifier === 'next/font/google') result.googleFontImports.push(relative)
59
68
  }
60
- if (readsRequestApiSynchronously(text)) result.syncRequestApis.push(relative)
69
+ if (readsRequestApiSynchronously(text) || readsSyncSegmentProps(file, text)) {
70
+ result.syncRequestApis.push(relative)
71
+ }
61
72
  }
62
73
 
63
74
  if (deepImports.length > 0) {
@@ -111,6 +122,122 @@ function readsRequestApiSynchronously(text: string): boolean {
111
122
  return false
112
123
  }
113
124
 
125
+ /**
126
+ * A segment file whose default export (or generateMetadata) takes `params`/`searchParams` the Next 14
127
+ * way — annotated as a plain object rather than a Promise, or destructured and read without `await`.
128
+ * Precision over recall: an unresolvable annotation, an awaited read, or a `params` key anywhere other
129
+ * than the parameter list (an axios `{ params: { page } }` option bag) never counts.
130
+ */
131
+ export function readsSyncSegmentProps(file: string, text: string): boolean {
132
+ const extension = path.extname(file)
133
+ if (!SEGMENT_FILES.has(path.basename(file, extension))) return false
134
+ return readsHeadProps(text, SEGMENT_HEADS, 0) || readsHeadProps(text, ROUTE_HANDLER_HEADS, 1)
135
+ }
136
+
137
+ function readsHeadProps(text: string, heads: RegExp, index: number): boolean {
138
+ for (const head of text.matchAll(heads)) {
139
+ const list = balanced(text, head.index + head[0].length - 1, '(', ')')
140
+ const parameter = (splitTopLevel(list)[index] ?? '').trim()
141
+ if (parameter && readsPropsSynchronously(parameter, text)) return true
142
+ }
143
+ return false
144
+ }
145
+
146
+ function readsPropsSynchronously(parameter: string, text: string): boolean {
147
+ const colon = topLevelIndex(parameter, ':')
148
+ const binding = (colon < 0 ? parameter : parameter.slice(0, colon)).trim()
149
+ const annotation = colon < 0 ? '' : parameter.slice(colon + 1).trim()
150
+ const destructured = binding.startsWith('{') ? destructuredKeys(binding) : null
151
+ for (const prop of SYNC_PROPS) {
152
+ if (destructured && !destructured.has(prop)) continue
153
+ if (annotation) {
154
+ const declared = propertyType(annotation, text, prop)
155
+ if (declared && !/^Promise\s*</.test(declared)) return true
156
+ continue
157
+ }
158
+ // Untyped JS: a destructured binding read without `await`, or `props.params` read without one.
159
+ if (destructured) {
160
+ if (!new RegExp(`await\\s+${prop}\\b`).test(text)) return true
161
+ } else if (/^[\w$]+$/.test(binding)) {
162
+ const reads = text.matchAll(new RegExp(`(await\\s+)?\\b${binding}\\.${prop}\\b`, 'g'))
163
+ if ([...reads].some(read => !read[1])) return true
164
+ }
165
+ }
166
+ return false
167
+ }
168
+
169
+ /** The declared type of `prop` inside an inline object annotation or a locally declared type/interface. */
170
+ function propertyType(annotation: string, text: string, prop: string): string | undefined {
171
+ let body: string | undefined
172
+ if (annotation.startsWith('{')) {
173
+ body = balanced(annotation, 0, '{', '}')
174
+ } else {
175
+ const name = /^[\w$]+/.exec(annotation)?.[0]
176
+ body = name ? localTypeBody(name, text) : undefined
177
+ }
178
+ if (!body) return undefined
179
+ return new RegExp(`(?:^|[{;,\\n])\\s*${prop}\\s*\\??\\s*:\\s*([^;,\\n]+)`).exec(body)?.[1]?.trim()
180
+ }
181
+
182
+ function localTypeBody(name: string, text: string): string | undefined {
183
+ const declaration = new RegExp(`\\b(?:type|interface)\\s+${name}\\b`).exec(text)
184
+ if (!declaration) return undefined
185
+ const open = text.indexOf('{', declaration.index)
186
+ // An alias to something else (`type Props = PageProps<'/x'>`) has no object body of its own.
187
+ if (open < 0 || /[;)]/.test(text.slice(declaration.index, open))) return undefined
188
+ return balanced(text, open, '{', '}')
189
+ }
190
+
191
+ /** The keys of a destructuring pattern, renames included (`{ params: p }` → `params`). */
192
+ function destructuredKeys(binding: string): Set<string> {
193
+ const keys = new Set<string>()
194
+ for (const part of splitTopLevel(balanced(binding, 0, '{', '}'))) {
195
+ const key = /^\s*(?:\.\.\.)?([\w$]+)/.exec(part)?.[1]
196
+ if (key) keys.add(key)
197
+ }
198
+ return keys
199
+ }
200
+
201
+ /** Text between `open` at `from` and its matching close, exclusive. */
202
+ function balanced(text: string, from: number, open: string, close: string): string {
203
+ let depth = 0
204
+ for (let i = from; i < text.length; i++) {
205
+ if (text[i] === open) depth++
206
+ else if (text[i] === close && --depth === 0) return text.slice(from + 1, i)
207
+ }
208
+ return ''
209
+ }
210
+
211
+ function splitTopLevel(list: string): string[] {
212
+ const parts: string[] = []
213
+ let depth = 0
214
+ let start = 0
215
+ for (let i = 0; i < list.length; i++) {
216
+ const char = list[i]!
217
+ if (char === '=' && list[i + 1] === '>') i++
218
+ else if ('({[<'.includes(char)) depth++
219
+ else if (')}]>'.includes(char)) depth--
220
+ else if (char === ',' && depth === 0) {
221
+ parts.push(list.slice(start, i))
222
+ start = i + 1
223
+ }
224
+ }
225
+ parts.push(list.slice(start))
226
+ return parts
227
+ }
228
+
229
+ /** Index of `char` outside any bracket pair, or -1. */
230
+ function topLevelIndex(text: string, char: string): number {
231
+ let depth = 0
232
+ for (let i = 0; i < text.length; i++) {
233
+ const current = text[i]!
234
+ if ('({[<'.includes(current)) depth++
235
+ else if (')}]>'.includes(current)) depth--
236
+ else if (current === char && depth === 0) return i
237
+ }
238
+ return -1
239
+ }
240
+
114
241
  function scanSpecialPages(root: string, result: MigrationResult) {
115
242
  const found: string[] = []
116
243
  for (const base of ['pages', path.join('src', 'pages')]) {
@@ -32,7 +32,7 @@ import {
32
32
  malformedUrlResponse,
33
33
  trailingSlashRedirect,
34
34
  } from '../../routing/href'
35
- import { withForwardedHeaders } from '../../routing/forwarded'
35
+ import { type PeerAddressSource, withForwardedHeaders } from '../../routing/forwarded'
36
36
  import { normalizePathname, parseNavState, selectRouteForRequest } from '../../routing/routes'
37
37
  import { runWithCacheScope } from '../../request/cache'
38
38
  import { metadataRouteHandlerModule } from '../../routing/metadata-files'
@@ -57,6 +57,7 @@ import {
57
57
  } from '../../request/context'
58
58
  import { setRequestRuntime } from '../../routing/request-environment'
59
59
  import { getRenderSpanExtensions } from '../../render/hooks'
60
+ import { publishEmittedAssets } from '../../css/build'
60
61
  import { contentType } from '../../utils/content-type'
61
62
  import { stopEsbuildService } from '../../utils/esbuild'
62
63
  import { markErrorLogged } from '../../utils/error-log'
@@ -107,6 +108,9 @@ export async function createRequestHandler(
107
108
  // so runtime renders observe force-cache / revalidate TTLs / tags exactly
108
109
  // like build prerenders. No-op for pure-core apps.
109
110
  runInitHooks(config)
111
+ // Content-hashed asset names, as the build emitted them: a render in THIS
112
+ // process must link the same files, not the logical `global.css` spelling.
113
+ publishEmittedAssets(config.outPath, manifest.assetNames)
110
114
  // Publish the live routing state the compat request interceptors (action
111
115
  // dispatch, rewrites) read; prod loads the route table once.
112
116
  setRequestRuntime({ config, routes: manifest.routes, dev: false })
@@ -266,13 +270,13 @@ export async function createRequestHandler(
266
270
  // resident esbuild service child (~10+ MB RSS) — it respawns if ever needed.
267
271
  stopEsbuildService()
268
272
 
269
- return function handleRequest(request: Request): Promise<Response> {
273
+ return function handleRequest(request: Request, server?: PeerAddressSource): Promise<Response> {
270
274
  // One work unit spans the whole request; its after-queue flushes once the
271
275
  // response fully closes (stream end, redirect, notFound, error, abort).
272
276
  return runWithWorkUnit('render', async () => {
273
277
  const unit = getWorkUnit()
274
278
  try {
275
- const raw = await handle(request)
279
+ const raw = await handle(request, server)
276
280
  const finalized = await finalizeResponse(
277
281
  raw,
278
282
  { method: request.method, url: new URL(request.url), headers: request.headers },
@@ -304,10 +308,10 @@ export async function createRequestHandler(
304
308
  })
305
309
  }
306
310
 
307
- async function handle(request: Request): Promise<Response> {
311
+ async function handle(request: Request, server?: PeerAddressSource): Promise<Response> {
308
312
  const badRequest = malformedUrlResponse(request)
309
313
  if (badRequest) return badRequest
310
- request = withForwardedHeaders(request)
314
+ request = withForwardedHeaders(request, server)
311
315
  const requestedUrl = new URL(request.url)
312
316
  // assetPrefix is independent from basePath. Strip a path-style asset
313
317
  // prefix first so `/cdn/_next/static/*` remains servable even when the app
@@ -502,6 +506,18 @@ export async function createRequestHandler(
502
506
  ? { revalidateSeconds: servedBuilt.metadata.revalidateSeconds }
503
507
  : undefined,
504
508
  )
509
+ // A prebuilt DOCUMENT's cache-control belongs to the response finalizer,
510
+ // which owns the one rule (Next's static / ISR / dynamic values) for
511
+ // rendered and prebuilt pages alike. maybeBuiltFile's `no-cache` is its
512
+ // asset default, not a claim; a build-captured header is a claim and stays.
513
+ if (
514
+ isHtmlResponse(staticFile) &&
515
+ !(servedBuilt.metadata?.headers ?? []).some(
516
+ ([key]) => key.toLowerCase() === 'cache-control',
517
+ )
518
+ ) {
519
+ staticFile.headers.delete('cache-control')
520
+ }
505
521
  if (servedBuilt.metadata) {
506
522
  const servedTtl = servedBuilt.metadata.revalidateSeconds
507
523
  const expired =
@@ -523,11 +539,14 @@ export async function createRequestHandler(
523
539
  }
524
540
  }
525
541
  if (url.pathname.startsWith('/_next/static/') && !built) {
526
- setWorkUnitRoute('static-asset', 'static')
542
+ setWorkUnitRoute('static-asset')
527
543
  return applyProxyResponse(
528
544
  new Response('Not Found', {
529
545
  status: 404,
530
- headers: { 'content-type': 'text/plain' },
546
+ headers: {
547
+ 'cache-control': 'private, no-cache, no-store, max-age=0, must-revalidate',
548
+ 'content-type': 'text/plain;charset=utf-8',
549
+ },
531
550
  }),
532
551
  proxyResponse,
533
552
  )
@@ -537,20 +556,7 @@ export async function createRequestHandler(
537
556
 
538
557
  const matched = selection
539
558
  if (!matched) {
540
- return applyProxyResponse(
541
- compressResponse(
542
- await renderGlobalNotFoundResponse({
543
- config,
544
- url,
545
- request: canonicalRequest,
546
- staticMetadataFiles: manifest.staticMetadataFiles,
547
- staticModuleMetadata: manifest.staticModuleMetadata,
548
- staticRouteMetadata: manifest.staticRouteMetadata,
549
- }),
550
- request,
551
- ),
552
- proxyResponse,
553
- )
559
+ return applyProxyResponse(await renderNotFound(canonicalRequest), proxyResponse)
554
560
  }
555
561
  if (matched.route.kind === 'handler') {
556
562
  setWorkUnitRoute(
@@ -648,20 +654,7 @@ export async function createRequestHandler(
648
654
  // prerendered param set; anything else is a 404. (Prebuilt paths were
649
655
  // already served from the static output above.)
650
656
  if (!dynamicParamsAllowed(matched.route, matched.params)) {
651
- return applyProxyResponse(
652
- compressResponse(
653
- await renderGlobalNotFoundResponse({
654
- config,
655
- url,
656
- request: canonicalRequest,
657
- staticMetadataFiles: manifest.staticMetadataFiles,
658
- staticModuleMetadata: manifest.staticModuleMetadata,
659
- staticRouteMetadata: manifest.staticRouteMetadata,
660
- }),
661
- request,
662
- ),
663
- proxyResponse,
664
- )
657
+ return applyProxyResponse(await renderNotFound(canonicalRequest), proxyResponse)
665
658
  }
666
659
 
667
660
  // force-static routes render with an empty request and no search params
@@ -1324,14 +1317,21 @@ function cachedGzip(key: string, body: Buffer) {
1324
1317
 
1325
1318
  export const immutableCacheControl = 'public, max-age=31536000, immutable'
1326
1319
 
1327
- // Chunk and font filenames are content-hashed, so their bytes can never change under a given URL;
1328
- // entries, css and html keep stable names and must revalidate (no-cache still allows storing, so
1329
- // every revisit is a cheap 304). Compat-registered static-asset prefixes are content-hashed the same
1330
- // way, so they qualify too.
1320
+ /**
1321
+ * Everything the build emits under `assets/` (served at `/_next/static/*` in
1322
+ * compat) is immutable, which is the promise Next makes for that whole
1323
+ * namespace. It holds because every one of those names carries a content hash:
1324
+ * esbuild's for chunks and fonts, assetContentHash for the route entries and the
1325
+ * stylesheets (see fingerprintClientEntries / fingerprintAsset), and a build id
1326
+ * for `_next/static/<id>/_*Manifest.js`. An UNHASHED name must never reach here —
1327
+ * the same URL would answer different bytes after a deploy, and every browser
1328
+ * that saw the old ones would keep them for a year.
1329
+ *
1330
+ * Route outputs (prerendered html, handler bodies) live outside both prefixes
1331
+ * and stay revalidating; the public/ tree the app ships is likewise untouched.
1332
+ */
1331
1333
  export function immutableAssetPath(relativePath: string) {
1332
- if (relativePath.startsWith('assets/chunks/') || relativePath.startsWith('assets/fonts/')) {
1333
- return true
1334
- }
1334
+ if (relativePath.startsWith('assets/') || relativePath.startsWith('_next/static/')) return true
1335
1335
  return getAssetExtensions()
1336
1336
  .staticAssetPublicPrefixes()
1337
1337
  .some(prefix => relativePath.startsWith(prefix.replace(/^\/+/, '')))