@wular/pnext 0.0.18 → 0.0.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +1 -1
- package/reference/compat.md +2 -2
- package/reference/getting-started.md +2 -2
- package/src/cli/adapters/vercel.ts +27 -0
- package/src/cli/analyze.ts +8 -7
- package/src/cli/boot/trace.ts +9 -3
- package/src/cli/build.ts +23 -15
- package/src/cli/dev.ts +23 -0
- package/src/cli/migrate/report.ts +1 -1
- package/src/cli/migrate/run.ts +2 -2
- package/src/cli/migrate/scan.ts +128 -1
- package/src/cli/serve/pipeline.ts +42 -42
- package/src/cli/start.ts +4 -2
- package/src/client/build.ts +51 -25
- package/src/client/entry.ts +27 -4
- package/src/client/router/page-slot.ts +81 -0
- package/src/client/router/runtime.ts +107 -112
- package/src/compat/bundler/cjs-exports.ts +26 -20
- package/src/compat/client/css-order.ts +1 -1
- package/src/compat/client/navigation-scroll.ts +36 -40
- package/src/compat/css/modules.ts +6 -2
- package/src/compat/next/font/cache.ts +10 -22
- package/src/compat/next/font/runtime.ts +20 -0
- package/src/compat/next/legacy-request-apis.ts +41 -5
- package/src/compat/next/script.tsx +16 -24
- package/src/compat/react/client-lite.ts +1 -0
- package/src/compat/react/client.ts +11 -0
- package/src/compat/react/dom-client.ts +11 -2
- package/src/compat/react/dom-react-server.ts +4 -0
- package/src/compat/react/dom.ts +11 -2
- package/src/compat/react/internals.ts +65 -0
- package/src/compat/react/parity.ts +47 -0
- package/src/compat/react/preact.ts +31 -1
- package/src/compat/react/react-server.ts +7 -0
- package/src/compat/react/server.ts +16 -2
- package/src/compat/react/style-values.ts +68 -0
- package/src/compat/register/render.ts +14 -3
- package/src/compat/register/routing.ts +57 -7
- package/src/compat/tsconfig-defaults.ts +4 -0
- package/src/compat/typecheck/check.ts +26 -71
- package/src/compat/typecheck/worker.ts +3 -2
- package/src/compat-bootstrap.ts +4 -0
- package/src/config.ts +21 -12
- package/src/css/build.ts +153 -14
- package/src/css/postcss.ts +32 -3
- package/src/dev/restart/client-key.ts +1 -1
- package/src/dev/restart/global-css.ts +12 -94
- package/src/dev/restart/record.ts +106 -0
- package/src/dev/restart/route-facts.ts +11 -90
- package/src/dev/server.ts +208 -41
- package/src/extensions.ts +69 -3
- package/src/render/hooks.ts +1 -1
- package/src/render/metadata.ts +89 -17
- package/src/render/renderer.ts +322 -136
- package/src/render/resource-hints.ts +62 -9
- package/src/render/slots.tsx +2 -1
- package/src/resolve/scan-facts.ts +13 -5
- package/src/routing/forwarded.ts +20 -7
- package/src/routing/handler.ts +6 -2
- package/src/routing/href.ts +11 -1
- package/src/routing/metadata-files.ts +16 -3
- package/src/routing/proxy.ts +6 -3
- package/src/routing/routes.ts +253 -34
- package/src/runtime/fetch-host.ts +42 -0
- package/src/runtime/fingerprint.ts +132 -0
- package/src/runtime/loader.ts +7 -5
- package/src/runtime/module-cache.ts +3 -36
- package/src/runtime/modules.ts +333 -77
- package/src/runtime/vendor-build.ts +34 -5
- package/src/runtime/vendor.ts +1 -18
- package/src/types.ts +6 -0
- package/src/utils/asset-hash.ts +22 -0
- package/src/utils/code.ts +41 -0
- package/src/utils/html.ts +4 -1
- package/src/utils/serialize.ts +66 -7
package/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
|
|
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[]
|
package/src/cli/migrate/run.ts
CHANGED
|
@@ -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',
|
package/src/cli/migrate/scan.ts
CHANGED
|
@@ -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)
|
|
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'
|
|
542
|
+
setWorkUnitRoute('static-asset')
|
|
527
543
|
return applyProxyResponse(
|
|
528
544
|
new Response('Not Found', {
|
|
529
545
|
status: 404,
|
|
530
|
-
headers: {
|
|
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
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
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/
|
|
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(/^\/+/, '')))
|