@wular/pnext 0.0.19 → 0.0.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +1 -1
- package/reference/compat.md +2 -2
- package/reference/getting-started.md +2 -2
- package/src/cli/adapters/vercel.ts +27 -0
- package/src/cli/analyze.ts +8 -7
- package/src/cli/boot/trace.ts +9 -3
- package/src/cli/build/cache.ts +324 -0
- package/src/cli/build.ts +42 -18
- package/src/cli/create.ts +4 -4
- package/src/cli/dev.ts +23 -0
- package/src/cli/migrate/package-json.ts +3 -2
- package/src/cli/migrate/report.ts +1 -1
- package/src/cli/migrate/run.ts +2 -2
- package/src/cli/migrate/scan.ts +128 -1
- package/src/cli/serve/pipeline.ts +42 -42
- package/src/cli/start.ts +20 -20
- package/src/client/build.ts +64 -35
- package/src/client/entry.ts +228 -16
- package/src/client/router/page-slot.ts +81 -0
- package/src/client/router/runtime.ts +107 -112
- package/src/compat/bundler/cjs-exports.ts +26 -20
- package/src/compat/client/css-order.ts +1 -1
- package/src/compat/client/navigation-scroll.ts +36 -40
- package/src/compat/css/modules.ts +6 -2
- package/src/compat/metadata.ts +0 -9
- package/src/compat/next/font/cache.ts +10 -22
- package/src/compat/next/font/runtime.ts +20 -0
- package/src/compat/next/legacy-request-apis.ts +41 -5
- package/src/compat/next/script.tsx +16 -24
- package/src/compat/react/client-lite.ts +1 -0
- package/src/compat/react/client.ts +11 -0
- package/src/compat/react/dom-client.ts +11 -2
- package/src/compat/react/dom-react-server.ts +4 -0
- package/src/compat/react/dom.ts +11 -2
- package/src/compat/react/internals.ts +65 -0
- package/src/compat/react/parity.ts +47 -0
- package/src/compat/react/preact.ts +31 -1
- package/src/compat/react/react-server.ts +7 -0
- package/src/compat/react/server.ts +16 -2
- package/src/compat/react/style-values.ts +68 -0
- package/src/compat/register/proxy.ts +3 -1
- package/src/compat/register/render.ts +14 -3
- package/src/compat/register/routing.ts +57 -7
- package/src/compat/register/typed-routes.ts +0 -2
- package/src/compat/tsconfig-defaults.ts +4 -0
- package/src/compat/typecheck/check.ts +26 -71
- package/src/compat/typecheck/worker.ts +3 -2
- package/src/compat/validation/validate.ts +2 -0
- package/src/compat-bootstrap.ts +4 -0
- package/src/css/build.ts +153 -14
- package/src/css/postcss.ts +32 -3
- package/src/dev/restart/client-key.ts +1 -1
- package/src/dev/restart/global-css.ts +12 -94
- package/src/dev/restart/record.ts +106 -0
- package/src/dev/restart/route-facts.ts +11 -90
- package/src/dev/server.ts +208 -41
- package/src/extensions.ts +69 -3
- package/src/render/hooks.ts +1 -1
- package/src/render/metadata.ts +89 -17
- package/src/render/renderer.ts +325 -138
- package/src/render/resource-hints.ts +62 -9
- package/src/render/slots.tsx +2 -1
- package/src/resolve/scan-facts.ts +13 -5
- package/src/routing/forwarded.ts +20 -7
- package/src/routing/handler.ts +6 -2
- package/src/routing/href.ts +11 -1
- package/src/routing/metadata-files.ts +16 -3
- package/src/routing/proxy.ts +6 -3
- package/src/routing/routes.ts +253 -34
- package/src/runtime/fetch-host.ts +42 -0
- package/src/runtime/fingerprint.ts +132 -0
- package/src/runtime/loader.ts +7 -5
- package/src/runtime/module-cache.ts +3 -36
- package/src/runtime/modules.ts +333 -77
- package/src/runtime/vendor-build.ts +34 -5
- package/src/runtime/vendor.ts +1 -18
- package/src/types.ts +6 -0
- package/src/utils/asset-hash.ts +22 -0
- package/src/utils/code.ts +41 -0
- package/src/utils/html.ts +4 -1
- package/src/utils/registry.ts +70 -0
- package/src/utils/serialize.ts +66 -7
package/src/cli/create.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { existsSync } from 'node:fs'
|
|
|
2
2
|
import { mkdir, readdir } from 'node:fs/promises'
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import { bold, cyan, dim, green } from '../utils/ansi'
|
|
5
|
-
import {
|
|
5
|
+
import { latestPnextVersionRange } from '../utils/registry'
|
|
6
6
|
import { formatDuration } from '../utils/verbose'
|
|
7
7
|
|
|
8
8
|
export interface CreateAppOptions {
|
|
@@ -27,7 +27,7 @@ export async function createApp(dir: string | undefined, options: CreateAppOptio
|
|
|
27
27
|
const name = sanitizePackageName(path.basename(target))
|
|
28
28
|
console.log(`${cyan('▲')} ${bold('pnext create')} ${dim(`— scaffolding ${name}`)}\n`)
|
|
29
29
|
|
|
30
|
-
const files = scaffoldFiles(name)
|
|
30
|
+
const files = scaffoldFiles(name, await latestPnextVersionRange('create'))
|
|
31
31
|
await Promise.all(
|
|
32
32
|
Object.entries(files).map(([file, source]) => Bun.write(path.join(target, file), source)),
|
|
33
33
|
)
|
|
@@ -112,7 +112,7 @@ function printNextSteps(dir: string, installed: boolean) {
|
|
|
112
112
|
console.log(dim(`└${'─'.repeat(width + 2)}┘`))
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
function scaffoldFiles(name: string): Record<string, string> {
|
|
115
|
+
function scaffoldFiles(name: string, pnextRange: string): Record<string, string> {
|
|
116
116
|
return {
|
|
117
117
|
'package.json': `${JSON.stringify(
|
|
118
118
|
{
|
|
@@ -126,7 +126,7 @@ function scaffoldFiles(name: string): Record<string, string> {
|
|
|
126
126
|
},
|
|
127
127
|
dependencies: { preact: '^10' },
|
|
128
128
|
devDependencies: {
|
|
129
|
-
'@wular/pnext':
|
|
129
|
+
'@wular/pnext': pnextRange,
|
|
130
130
|
typescript: '^5',
|
|
131
131
|
'@types/bun': '^1',
|
|
132
132
|
},
|
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
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
|
|
4
4
|
import { readFile, writeFile } from 'node:fs/promises'
|
|
5
5
|
import path from 'node:path'
|
|
6
|
-
import { pnextOptionalDependencyRange
|
|
6
|
+
import { pnextOptionalDependencyRange } from '../../utils/fs'
|
|
7
|
+
import { latestPnextVersionRange } from '../../utils/registry'
|
|
7
8
|
import type { MigrationResult } from './report'
|
|
8
9
|
|
|
9
10
|
const SEPARATORS = new Set(['&&', '||', ';', '|', '&'])
|
|
@@ -124,7 +125,7 @@ export async function migratePackageJson(
|
|
|
124
125
|
}
|
|
125
126
|
}
|
|
126
127
|
if (!('@wular/pnext' in devDependencies) && !('@wular/pnext' in dependencies)) {
|
|
127
|
-
const versionRange =
|
|
128
|
+
const versionRange = await latestPnextVersionRange('migrate')
|
|
128
129
|
devDependencies['@wular/pnext'] = versionRange
|
|
129
130
|
result.edits.push({
|
|
130
131
|
file: 'package.json',
|
|
@@ -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(/^\/+/, '')))
|
package/src/cli/start.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { markErrorLogged } from '../utils/error-log'
|
|
|
15
15
|
import { isPostpone } from '../render/postpone'
|
|
16
16
|
import { browserHost, ensurePortFree, openBrowser, printServerReady } from './serve/ui'
|
|
17
17
|
import type { BuildManifest } from '../types'
|
|
18
|
+
import type { PeerAddressSource } from '../routing/forwarded'
|
|
18
19
|
|
|
19
20
|
interface StartOptions {
|
|
20
21
|
root?: string
|
|
@@ -22,7 +23,7 @@ interface StartOptions {
|
|
|
22
23
|
hostname?: string
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
type RequestHandler = (request: Request) => Promise<Response>
|
|
26
|
+
type RequestHandler = (request: Request, server?: PeerAddressSource) => Promise<Response>
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
29
|
* The production request handler `pnext start` serves with. Exposed on its own
|
|
@@ -47,14 +48,9 @@ export async function start(options: StartOptions = {}) {
|
|
|
47
48
|
const startedAt = Date.now()
|
|
48
49
|
const port = options.port ?? 3000
|
|
49
50
|
const hostname = options.hostname ?? 'localhost'
|
|
50
|
-
// Export builds are not served
|
|
51
|
-
// Next only WARNS, pointing at the standalone launcher, and the launcher re-execs this path with
|
|
52
|
-
// `__NEXT_PRIVATE_STANDALONE_CONFIG` set to suppress that warning. Handle both before binding a
|
|
53
|
-
// port, matching Next's messages.
|
|
51
|
+
// Export builds are not served; pnext serves its standalone output directly.
|
|
54
52
|
const config = await guardUnservableOutputMode(options.root)
|
|
55
|
-
const manifest =
|
|
56
|
-
await readFile(path.join(config.outPath, 'manifest.json'), 'utf8'),
|
|
57
|
-
) as BuildManifest
|
|
53
|
+
const manifest = await readProductionManifest(config)
|
|
58
54
|
await ensurePortFree(port, hostname)
|
|
59
55
|
|
|
60
56
|
// The pipeline import starts right after listen; a request that beats it
|
|
@@ -70,7 +66,8 @@ export async function start(options: StartOptions = {}) {
|
|
|
70
66
|
const server = Bun.serve({
|
|
71
67
|
hostname,
|
|
72
68
|
port,
|
|
73
|
-
fetch:
|
|
69
|
+
fetch: (request, server) =>
|
|
70
|
+
handler ? handler(request, server) : ensureHandler().then(ready => ready(request, server)),
|
|
74
71
|
})
|
|
75
72
|
|
|
76
73
|
printServerReady({
|
|
@@ -108,10 +105,20 @@ function installProcessErrorHandlers(): void {
|
|
|
108
105
|
process.on('uncaughtException', report)
|
|
109
106
|
}
|
|
110
107
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
108
|
+
export async function readProductionManifest(config: ResolvedConfig): Promise<BuildManifest> {
|
|
109
|
+
try {
|
|
110
|
+
return JSON.parse(
|
|
111
|
+
await readFile(path.join(config.outPath, 'manifest.json'), 'utf8'),
|
|
112
|
+
) as BuildManifest
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
115
|
+
throw new Error("No production build found. Run 'pnext build' first.")
|
|
116
|
+
}
|
|
117
|
+
throw error
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Export output is unservable; pnext production output remains serveable for standalone apps.
|
|
115
122
|
async function guardUnservableOutputMode(root?: string): Promise<ResolvedConfig> {
|
|
116
123
|
const config = await loadConfig(root, { serve: true })
|
|
117
124
|
await bootstrapCompat(config)
|
|
@@ -123,13 +130,6 @@ async function guardUnservableOutputMode(root?: string): Promise<ResolvedConfig>
|
|
|
123
130
|
)
|
|
124
131
|
process.exit(1)
|
|
125
132
|
}
|
|
126
|
-
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
127
|
-
if (mode === 'standalone' && !process.env.__NEXT_PRIVATE_STANDALONE_CONFIG) {
|
|
128
|
-
console.warn(
|
|
129
|
-
'"next start" does not work with "output: standalone" configuration. ' +
|
|
130
|
-
'Use "node .next/standalone/server.js" instead.',
|
|
131
|
-
)
|
|
132
|
-
}
|
|
133
133
|
return config
|
|
134
134
|
}
|
|
135
135
|
|