@wular/pnext 0.0.10 → 0.0.11

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 (57) hide show
  1. package/package.json +1 -1
  2. package/reference/dev.md +17 -4
  3. package/src/api/dynamic.tsx +1 -0
  4. package/src/api/link.tsx +1 -0
  5. package/src/cli/analyze.ts +1 -1
  6. package/src/cli/boot/trace.ts +40 -7
  7. package/src/cli/build.ts +2 -2
  8. package/src/cli/migrate/package-json.ts +1 -1
  9. package/src/cli/migrate/report.ts +5 -3
  10. package/src/cli/migrate/run.ts +30 -4
  11. package/src/cli/migrate/scan.ts +34 -7
  12. package/src/cli/typegen.ts +8 -1
  13. package/src/client/build.ts +4 -5
  14. package/src/client/profile.ts +4 -3
  15. package/src/client/router/runtime.ts +8 -0
  16. package/src/compat/actions/config.ts +2 -0
  17. package/src/compat/aliases.ts +2 -2
  18. package/src/compat/bundler/react-profiler.tsx +1 -0
  19. package/src/compat/cache-control.ts +49 -119
  20. package/src/compat/client/errors/global-error.ts +10 -5
  21. package/src/compat/client/errors/install.ts +30 -6
  22. package/src/compat/client/errors/lazy.ts +32 -2
  23. package/src/compat/next/client-script.tsx +1 -0
  24. package/src/compat/next/config-headers.ts +139 -0
  25. package/src/compat/next/config-loader.ts +32 -2
  26. package/src/compat/next/dynamic.tsx +1 -0
  27. package/src/compat/next/error.tsx +1 -0
  28. package/src/compat/next/form.tsx +1 -0
  29. package/src/compat/next/head.tsx +1 -0
  30. package/src/compat/next/headers.ts +12 -6
  31. package/src/compat/next/image/client.tsx +1 -0
  32. package/src/compat/next/image.tsx +1 -0
  33. package/src/compat/next/legacy-image.tsx +1 -0
  34. package/src/compat/next/legacy-request-apis.ts +62 -0
  35. package/src/compat/next/link.tsx +1 -0
  36. package/src/compat/next/rewrites.ts +7 -0
  37. package/src/compat/next/script.tsx +1 -0
  38. package/src/compat/register/bundler.ts +6 -1
  39. package/src/compat/segment/loading-boundary.ts +1 -5
  40. package/src/compat/validation/validate.ts +10 -2
  41. package/src/config.ts +12 -1
  42. package/src/css/build.ts +111 -4
  43. package/src/dev/restart/global-css.ts +3 -3
  44. package/src/dev/restart/route-facts.ts +3 -3
  45. package/src/dev/server.ts +2 -2
  46. package/src/render/renderer.ts +2 -2
  47. package/src/render/slots.tsx +1 -0
  48. package/src/request/context.ts +15 -0
  49. package/src/runtime/loader.ts +2 -2
  50. package/src/runtime/module-cache.ts +12 -5
  51. package/src/runtime/modules.ts +44 -31
  52. package/src/runtime/vendor-build.ts +5 -7
  53. package/src/runtime/vendor.ts +29 -13
  54. package/src/types.ts +10 -1
  55. package/src/utils/code.ts +5 -0
  56. package/src/utils/dev-profile.ts +1 -1
  57. package/src/utils/trace-flags.ts +66 -0
@@ -5,162 +5,92 @@ import type { ResolvedConfig } from '../config'
5
5
  import type { ResponseFinalizer, ResponseFinalizerContext } from '../extensions'
6
6
  import { getNextConfig } from './next/config-loader'
7
7
  import { escapeRegex } from '../utils/code'
8
+ import { canonicalUrlHref } from './next/canonical-url'
8
9
  import {
9
- evaluateConditions,
10
- parseConditions,
11
- substitute,
12
- type RewriteCondition,
13
- type RewriteRequestContext,
14
- } from './next/rewrites'
10
+ appPathname,
11
+ loadCompatHeaders,
12
+ resolveCompatHeaders,
13
+ type CompatHeaderPair,
14
+ type CompatHeaderRule,
15
+ } from './next/config-headers'
16
+ import type { RewriteRequestContext } from './next/rewrites'
15
17
 
16
18
  const DEFAULT_EXPIRE_TIME = 31536000
17
19
 
18
- interface HeaderRule {
19
- regex: RegExp
20
- paramNames: string[]
21
- headers: [string, string][]
22
- has: RewriteCondition[]
23
- missing: RewriteCondition[]
24
- }
25
-
26
- interface HeaderEntry {
27
- source?: string
28
- headers?: unknown
29
- has?: unknown
30
- missing?: unknown
31
- }
32
-
33
20
  interface RevalidateRule {
34
21
  regex: RegExp
35
22
  revalidateSeconds: number
36
23
  }
37
24
 
38
- interface ConfigHeader {
39
- key?: unknown
40
- value?: unknown
41
- }
42
-
43
25
  export function cacheControlFinalizers(config: ResolvedConfig): ResponseFinalizer[] {
44
26
  if (!nextCompatEnabled(config)) return []
45
27
 
46
- let rules: Promise<HeaderRule[]> | undefined
28
+ let rules: Promise<CompatHeaderRule[]> | undefined
47
29
  let pagesRevalidateRules: RevalidateRule[] | undefined
48
30
  const finalizer: ResponseFinalizer = async ctx => {
49
- rules ??= loadHeaderRules()
50
- applyConfigHeaders(ctx, await rules)
31
+ rules ??= loadCompatHeaders(config.root)
32
+ applyConfigHeaders(ctx, await rules, config.basePath)
33
+ applyPoweredByHeader(ctx)
51
34
  pagesRevalidateRules ??= loadPagesRevalidateRules(config.root)
52
35
  applyDefaultIsrHeader(ctx, pagesRevalidateRules)
53
36
  }
54
37
  return [finalizer]
55
38
  }
56
39
 
57
- async function loadHeaderRules(): Promise<HeaderRule[]> {
58
- const producer = getNextConfig().headers
59
- if (typeof producer !== 'function') return []
60
-
61
- let entries: unknown
62
- try {
63
- entries = await (producer as () => unknown)()
64
- } catch {
65
- return []
66
- }
67
- if (!Array.isArray(entries)) return []
68
-
69
- const rules: HeaderRule[] = []
70
- for (const entry of entries as HeaderEntry[]) {
71
- if (!entry?.source || !Array.isArray(entry.headers)) continue
72
- const compiled = compileHeaderSource(entry.source)
73
- if (!compiled) continue
74
- const has = parseConditions(entry.has)
75
- const missing = parseConditions(entry.missing)
76
- if (!has || !missing) continue
77
- const headers = headerPairs(entry.headers)
78
- if (headers.length === 0) continue
79
- rules.push({ ...compiled, headers, has, missing })
80
- }
81
- return rules
82
- }
83
-
84
40
  /**
85
- * Compile a next.config `headers`/`redirects` source pattern into a pathname regex. Mirrors the
86
- * rewrites source compiler for `:param` tokens but, unlike it, escapes literal regex-special characters
87
- * in plain segments (the `.` in `/favicon.ico`, say) instead of rejecting the rule - Next's
88
- * path-to-regexp treats those as literals. Raw regex bodies still only appear inside `:param(...)`.
41
+ * Next sends `X-Powered-By: Next.js` on HTML payloads (send-payload.ts gates on the HTML content
42
+ * type) and omits it entirely under `poweredByHeader: false`.
89
43
  */
90
- function compileHeaderSource(source: string): { regex: RegExp; paramNames: string[] } | null {
91
- const paramNames: string[] = []
92
- const parts: string[] = []
93
- for (const segment of source.split('/').filter(Boolean)) {
94
- const custom = /^:([A-Za-z_$][\w$]*)\((.*)\)$/.exec(segment)
95
- if (custom?.[1] !== undefined && custom[2] !== undefined) {
96
- paramNames.push(custom[1])
97
- parts.push(`(${custom[2]})`)
98
- continue
99
- }
100
- const catchAll = /^:([A-Za-z_$][\w$]*)[*+]$/.exec(segment)
101
- if (catchAll?.[1]) {
102
- paramNames.push(catchAll[1])
103
- parts.push('(.*)')
104
- continue
105
- }
106
- const optional = /^:([A-Za-z_$][\w$]*)\?$/.exec(segment)
107
- if (optional?.[1]) {
108
- paramNames.push(optional[1])
109
- parts.push('([^/]*)')
110
- continue
111
- }
112
- const param = /^:([A-Za-z_$][\w$]*)$/.exec(segment)
113
- if (param?.[1]) {
114
- paramNames.push(param[1])
115
- parts.push('([^/]+)')
116
- continue
117
- }
118
- // Plain literal segment: escape regex-special characters so `.` etc. match
119
- // literally rather than acting as regex metacharacters.
120
- parts.push(segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
121
- }
122
- return { regex: new RegExp(`^/${parts.join('/')}/?$`), paramNames }
44
+ function applyPoweredByHeader(ctx: ResponseFinalizerContext): void {
45
+ if (getNextConfig().poweredByHeader === false) return
46
+ if (!ctx.headers.get('content-type')?.startsWith('text/html')) return
47
+ ctx.headers.set('x-powered-by', 'Next.js')
123
48
  }
124
49
 
125
- function headerPairs(headers: unknown[]): [string, string][] {
126
- const pairs: [string, string][] = []
127
- for (const header of headers as ConfigHeader[]) {
128
- if (typeof header?.key !== 'string' || typeof header.value !== 'string') continue
129
- pairs.push([header.key, header.value])
130
- }
131
- return pairs
132
- }
133
-
134
- function applyConfigHeaders(ctx: ResponseFinalizerContext, rules: readonly HeaderRule[]): void {
50
+ function applyConfigHeaders(
51
+ ctx: ResponseFinalizerContext,
52
+ rules: readonly CompatHeaderRule[],
53
+ basePath: string,
54
+ ): void {
135
55
  if (rules.length === 0) return
136
- const url = ctx.request.url
56
+ // Header routes precede rewrites (and middleware) in Next's route list, so
57
+ // they match the URL the browser asked for, never a rewrite destination.
58
+ const url = new URL(canonicalUrlHref() ?? ctx.request.url.href)
137
59
  const requestCtx: RewriteRequestContext = {
138
60
  host: url.host,
139
61
  headers: ctx.request.headers,
140
62
  cookies: parseCookieHeader(ctx.request.headers.get('cookie')),
141
63
  query: url.searchParams,
142
64
  }
65
+ // `basePath: false` entries match the RAW path; every other entry's source is
66
+ // basePath-prefixed, i.e. matches the in-app path. The two passes are disjoint.
67
+ const inApp = appPathname(url.pathname, basePath)
68
+ const matched: CompatHeaderPair[] = [
69
+ ...(inApp === undefined
70
+ ? []
71
+ : resolveCompatHeaders(
72
+ rules.filter(rule => !rule.outsideBasePath),
73
+ inApp,
74
+ requestCtx,
75
+ )),
76
+ ...resolveCompatHeaders(
77
+ rules.filter(rule => rule.outsideBasePath),
78
+ url.pathname,
79
+ requestCtx,
80
+ ),
81
+ ]
143
82
 
144
83
  // Config `Set-Cookie` headers must be appended (each entry is its own header,
145
84
  // never overwriting) and must precede the response's own Set-Cookie headers —
146
85
  // Next applies config headers at the router layer before the route's own
147
86
  // cookies (set-cookies suite asserts [...nextConfigHeaders, ...cookies]).
148
87
  const configSetCookies: string[] = []
149
- for (const rule of rules) {
150
- const match = rule.regex.exec(url.pathname)
151
- if (!match) continue
152
- const params: Record<string, string> = {}
153
- rule.paramNames.forEach((name, index) => {
154
- params[name] = match[index + 1] ?? ''
155
- })
156
- const conditionParams = evaluateConditions(rule, requestCtx)
157
- if (conditionParams === null) continue
158
- Object.assign(params, conditionParams)
159
- for (const [key, value] of rule.headers) {
160
- const resolved = substitute(value, params).pathname
161
- if (key.toLowerCase() === 'set-cookie') configSetCookies.push(resolved)
162
- else ctx.headers.set(key, resolved)
163
- }
88
+ // A route handler's Response headers are user code running AFTER the router
89
+ // set these, so those keys stay the handler's (Next: res.setHeader last wins).
90
+ const pageOwnsHeaders = ctx.routeKind === 'route-handler'
91
+ for (const { key, value } of matched) {
92
+ if (key.toLowerCase() === 'set-cookie') configSetCookies.push(value)
93
+ else if (!pageOwnsHeaders || !ctx.headers.has(key)) ctx.headers.set(key, value)
164
94
  }
165
95
  if (configSetCookies.length > 0) {
166
96
  const existing =
@@ -16,7 +16,12 @@ import { softNavigate } from '../../../client/router'
16
16
 
17
17
  export type GlobalErrorComponent = ComponentType<{ error: unknown; reset: () => void }>
18
18
 
19
- let globalErrorMounted = false
19
+ // Mounted PER DOCUMENT, not per process: idempotence is a property of the document being
20
+ // replaced. Identical in a browser (one document per page lifetime); differs in multi-document
21
+ // hosts (jsdom embedders, the test process), where a process boolean wrongly suppresses mounts.
22
+ let globalErrorMountedIn: Document | undefined
23
+ const globalErrorMounted = () =>
24
+ typeof document !== 'undefined' && globalErrorMountedIn === document
20
25
 
21
26
  // The app-root global-error.js component, registered by installClientErrors so the in-tree
22
27
  // ClientErrorBoundary can escalate straight to it WITHOUT bouncing a re-throw through the window
@@ -75,15 +80,15 @@ function reportUncaught(error: Error): void {
75
80
  * top-level replacement). A soft refresh via reset() clears the flag.
76
81
  */
77
82
  export function mountGlobalError(error: unknown, component?: GlobalErrorComponent): void {
78
- if (typeof document === 'undefined' || globalErrorMounted) return
79
- globalErrorMounted = true
83
+ if (typeof document === 'undefined' || globalErrorMounted()) return
84
+ globalErrorMountedIn = document
80
85
 
81
86
  document.documentElement.id = '__next_error__'
82
87
  const root = document.createElement('div')
83
88
  document.body.replaceChildren(root)
84
89
 
85
90
  const reset = () => {
86
- globalErrorMounted = false
91
+ globalErrorMountedIn = undefined
87
92
  render(null, root)
88
93
  // Re-render the current URL from the server (recovers server + client errors).
89
94
  void softNavigate(location.href, { replace: true, scroll: false }).catch(() => {
@@ -98,7 +103,7 @@ export function mountGlobalError(error: unknown, component?: GlobalErrorComponen
98
103
  render(h(component, { error, reset }), root)
99
104
  return
100
105
  } catch {
101
- globalErrorMounted = true // keep mounted; render the fallback below.
106
+ globalErrorMountedIn = document // keep mounted; render the fallback below.
102
107
  }
103
108
  }
104
109
 
@@ -13,8 +13,12 @@
13
13
  // 1. CONTROL-FLOW errors that escape - driven to their navigation outcome, never shown.
14
14
  // 2. ACTION errors - left to the action-error overlay, ignored here.
15
15
  // 3. Errors ALREADY HANDLED by a boundary - ignored, so one failure is never rendered twice.
16
- // 4. Anything else - a genuinely UNCAUGHT client error with no route error.js - escapes the root and
17
- // the global-error UI replaces the document.
16
+ // 4. UNCAUGHT RENDER escapes (tagged at the preact boundary walk, see ./lazy) - the root render
17
+ // failed, so global-error replaces the document, exactly Next's root boundary.
18
+ // 5. Anything else - event-handler throws, third-party script noise, benign browser error events
19
+ // (ResizeObserver loops report with `error: null`) - stays uncaught and the page SURVIVES.
20
+ // Next mounts global-error only from its root render boundary, never from window events
21
+ // (its only production window listeners drive redirect control flow).
18
22
  //
19
23
  // Client-escaped errors carry NO digest, matching Next.
20
24
 
@@ -60,6 +64,24 @@ export interface InstallOptions {
60
64
 
61
65
  let installed = false
62
66
 
67
+ // Cross-chunk marker (./lazy tags before this chunk exists): a value the preact boundary walk
68
+ // rethrew is a ROOT RENDER failure, the one case the window channel mounts global-error for.
69
+ const RENDER_ESCAPE = Symbol.for('pnext.renderEscape')
70
+
71
+ export function tagRenderEscape(error: unknown): void {
72
+ if (error !== null && typeof error === 'object') {
73
+ try {
74
+ ;(error as Record<symbol, unknown>)[RENDER_ESCAPE] = true
75
+ } catch {
76
+ // frozen throw value: the post-install escalation still mounts synchronously
77
+ }
78
+ }
79
+ }
80
+
81
+ function isRenderEscape(error: unknown): boolean {
82
+ return error !== null && typeof error === 'object' && RENDER_ESCAPE in error
83
+ }
84
+
63
85
  // Set by installClientErrors so ./lazy can replay the errors that escaped
64
86
  // before this chunk landed (a throw during hydration or in a first-paint
65
87
  // effect) through exactly the same triage.
@@ -92,10 +114,11 @@ export function installClientErrors(options: InstallOptions = {}): void {
92
114
  // Action errors belong to the action-error overlay.
93
115
  if (isActionError?.(error)) return
94
116
 
95
- // A genuinely uncaught client error with no route error.js to catch it —
96
- // the global-error last resort replaces the document. Pass the raw value so
97
- // a non-Error throw renders unwrapped in global-error.js (`${error}`).
98
- mountGlobalError(error, globalErrorComponent)
117
+ // Only a RENDER escape (the preact boundary walk rethrew it — tagged in ./lazy before this
118
+ // chunk exists, escalated synchronously after install) replaces the document: that is Next's
119
+ // root render boundary. Every other uncaught value — event handlers, third-party scripts,
120
+ // browser noise — stays uncaught and the page survives, as it does under Next.
121
+ if (isRenderEscape(error)) mountGlobalError(error, globalErrorComponent)
99
122
  }
100
123
  escapedErrorHandler = onError
101
124
 
@@ -143,6 +166,7 @@ function installRenderEscapeEscalation(
143
166
  // have an owner — only a genuinely uncaught error escapes to global-error.
144
167
  // escalate (not mount): the error must also report as uncaught in THIS
145
168
  // task, not a task later via the natural unhandled rejection.
169
+ tagRenderEscape(error)
146
170
  if (!wasErrorHandled(error) && !isControlFlowError(error) && !isActionError?.(error)) {
147
171
  escalateToGlobalError(error)
148
172
  }
@@ -6,13 +6,43 @@
6
6
  // a first-paint effect fires long before the idle tier lands, and Next still shows global-error for
7
7
  // it. They buffer the value, pull ./install right away, and hand it over - so a healthy page still
8
8
  // never fetches the chunk.
9
+ import { options } from 'preact'
9
10
  import { registerDeferredInstall } from '../../../client/router'
10
11
  import type { InstallOptions } from './install'
11
12
 
13
+ // Same key ./install reads (Symbol.for crosses the chunk boundary): only values the preact
14
+ // boundary walk rethrew — a ROOT RENDER failure, Next's global-error case — may replace the
15
+ // document. Every other uncaught window value (event handlers, third-party noise, ResizeObserver
16
+ // loop events) is buffered for triage but the page survives, as it does under Next.
17
+ const RENDER_ESCAPE = Symbol.for('pnext.renderEscape')
18
+
19
+ /** Tag boundary-walk escapes before the deferred runtime exists (a hydration throw fires first). */
20
+ function tagRenderEscapes(): void {
21
+ const hooks = options as unknown as Record<string, unknown>
22
+ const key = (['_catchError', '__e'] as const).find(name => typeof hooks[name] === 'function')
23
+ if (!key) return
24
+ const walkBoundaries = hooks[key] as (...args: unknown[]) => unknown
25
+ hooks[key] = function taggedCatchError(this: unknown, ...args: unknown[]) {
26
+ try {
27
+ return walkBoundaries.apply(this, args)
28
+ } catch (error) {
29
+ if (error !== null && typeof error === 'object') {
30
+ try {
31
+ ;(error as Record<symbol, unknown>)[RENDER_ESCAPE] = true
32
+ } catch {
33
+ // frozen throw value: the runtime's own escalation still mounts post-install
34
+ }
35
+ }
36
+ throw error
37
+ }
38
+ }
39
+ }
40
+
12
41
  type ErrorRuntime = typeof import('./install')
13
42
 
14
- export function installClientErrors(options: InstallOptions = {}): void {
43
+ export function installClientErrors(installOptions: InstallOptions = {}): void {
15
44
  if (!process.browser && typeof window === 'undefined') return
45
+ tagRenderEscapes()
16
46
 
17
47
  let runtime: Promise<ErrorRuntime> | undefined
18
48
  const load = () =>
@@ -21,7 +51,7 @@ export function installClientErrors(options: InstallOptions = {}): void {
21
51
  // error is triaged once rather than by both paths.
22
52
  window.removeEventListener('error', onError)
23
53
  window.removeEventListener('unhandledrejection', onRejection)
24
- module.installClientErrors(options)
54
+ module.installClientErrors(installOptions)
25
55
  return module
26
56
  }))
27
57
 
@@ -1,3 +1,4 @@
1
+ /** @jsxImportSource preact */
1
2
  import type { JSX } from 'preact'
2
3
  import { useEffect } from 'preact/compat'
3
4
  import { registerResourceHint } from '../../render/resource-hints'
@@ -0,0 +1,139 @@
1
+ /**
2
+ * next.config `headers()` support (Next compat): every rule whose `source` matches the as-requested
3
+ * pathname contributes its `headers` to the response. Shares the source/condition/substitution
4
+ * machinery with rewrites and redirects (`:param`, `:param*`/`+`/`?`, `:param(regex)`, bare `(regex)`,
5
+ * has/missing of type host/header/query/cookie, `basePath: false`).
6
+ *
7
+ * Unlike redirects/rewrites, matching does NOT stop at the first hit: Next walks the whole header route
8
+ * list and accumulates, so a later rule overwrites an earlier one per key while `set-cookie` appends.
9
+ * Key AND value take `:param` substitution (Next's compileNonPath).
10
+ *
11
+ * The rules are applied by the cache-control finalizer (compat/cache-control.ts), which owns the
12
+ * response seam headers share with the ISR Cache-Control defaults - config headers land first, exactly
13
+ * as Next's router sets them on `res` before the render runs.
14
+ */
15
+ import { loadNextConfig, loadResolvedNextConfig } from '../actions/config'
16
+ import {
17
+ compileSource,
18
+ evaluateConditions,
19
+ parseConditions,
20
+ substitute,
21
+ type RewriteCondition,
22
+ type RewriteRequestContext,
23
+ } from './rewrites'
24
+
25
+ export interface CompatHeaderPair {
26
+ key: string
27
+ value: string
28
+ }
29
+
30
+ export interface CompatHeaderRule {
31
+ regex: RegExp
32
+ paramNames: string[]
33
+ headers: CompatHeaderPair[]
34
+ has: RewriteCondition[]
35
+ missing: RewriteCondition[]
36
+ /** `basePath: false`: the source matches the RAW path, outside the basePath. */
37
+ outsideBasePath: boolean
38
+ }
39
+
40
+ interface HeaderEntry {
41
+ source?: string
42
+ headers?: unknown
43
+ has?: unknown
44
+ missing?: unknown
45
+ basePath?: unknown
46
+ }
47
+
48
+ export async function loadCompatHeaders(root: string): Promise<CompatHeaderRule[]> {
49
+ let config = await loadResolvedNextConfig(root)
50
+ if (typeof config?.headers !== 'function') config = await loadNextConfig(root)
51
+ const producer = config?.headers
52
+ if (typeof producer !== 'function') return []
53
+ let value: unknown
54
+ try {
55
+ value = await producer()
56
+ } catch {
57
+ return []
58
+ }
59
+ if (!Array.isArray(value)) return []
60
+
61
+ const rules: CompatHeaderRule[] = []
62
+ for (const entry of value as HeaderEntry[]) {
63
+ if (!entry?.source || !Array.isArray(entry.headers)) continue
64
+ const headers = parseHeaderPairs(entry.headers)
65
+ if (headers.length === 0) continue
66
+ const has = parseConditions(entry.has)
67
+ const missing = parseConditions(entry.missing)
68
+ if (has === null || missing === null) continue // unsupported condition shape
69
+ const compiled = compileSource(entry.source)
70
+ if (!compiled) continue
71
+ rules.push({
72
+ ...compiled,
73
+ headers,
74
+ has,
75
+ missing,
76
+ outsideBasePath: entry.basePath === false,
77
+ })
78
+ }
79
+ return rules
80
+ }
81
+
82
+ function parseHeaderPairs(value: readonly unknown[]): CompatHeaderPair[] {
83
+ const pairs: CompatHeaderPair[] = []
84
+ for (const raw of value) {
85
+ if (!raw || typeof raw !== 'object') continue
86
+ const { key, value: headerValue } = raw as Record<string, unknown>
87
+ if (typeof key !== 'string' || typeof headerValue !== 'string') continue
88
+ pairs.push({ key, value: headerValue })
89
+ }
90
+ return pairs
91
+ }
92
+
93
+ /** Every matching rule's headers, in rule order (the caller resolves per-key precedence). */
94
+ export function resolveCompatHeaders(
95
+ rules: readonly CompatHeaderRule[],
96
+ pathname: string,
97
+ ctx?: RewriteRequestContext,
98
+ ): CompatHeaderPair[] {
99
+ const context: RewriteRequestContext = ctx ?? {
100
+ host: '',
101
+ headers: new Headers(),
102
+ cookies: {},
103
+ query: new URLSearchParams(),
104
+ }
105
+ const resolved: CompatHeaderPair[] = []
106
+ for (const rule of rules) {
107
+ const match = rule.regex.exec(pathname)
108
+ if (!match) continue
109
+
110
+ const params: Record<string, string> = {}
111
+ rule.paramNames.forEach((name, index) => {
112
+ params[name] = match[index + 1] ?? ''
113
+ })
114
+
115
+ const conditionParams = evaluateConditions(rule, context)
116
+ if (conditionParams === null) continue // has/missing gate failed
117
+ Object.assign(params, conditionParams)
118
+
119
+ const hasParams = Object.keys(params).length > 0
120
+ for (const pair of rule.headers) {
121
+ resolved.push(
122
+ hasParams
123
+ ? {
124
+ key: substitute(pair.key, params).pathname,
125
+ value: substitute(pair.value, params).pathname,
126
+ }
127
+ : pair,
128
+ )
129
+ }
130
+ }
131
+ return resolved
132
+ }
133
+
134
+ /** The in-app path for a raw request pathname, or undefined when it lies outside the basePath. */
135
+ export function appPathname(pathname: string, basePath: string): string | undefined {
136
+ if (!basePath) return pathname
137
+ if (pathname === basePath) return '/'
138
+ return pathname.startsWith(`${basePath}/`) ? pathname.slice(basePath.length) : undefined
139
+ }
@@ -393,27 +393,54 @@ const VALID_DEV_INDICATOR_POSITIONS = new Set([
393
393
 
394
394
  const KNOWN_TOP_LEVEL_CONFIG_KEYS = new Set([
395
395
  'adapterPath',
396
+ 'agentRules',
397
+ 'allowedDevOrigins',
396
398
  'assetPrefix',
397
399
  'basePath',
400
+ 'bundlePagesRouterDependencies',
398
401
  'cacheComponents',
399
402
  'cacheHandler',
400
403
  'cacheHandlers',
401
404
  'cacheLife',
405
+ 'cacheMaxMemorySize',
406
+ 'cleanDistDir',
402
407
  'compiler',
408
+ 'compress',
409
+ 'configFile',
410
+ 'configFileName',
411
+ 'configOrigin',
412
+ 'crossOrigin',
413
+ 'deploymentId',
403
414
  'devIndicators',
404
415
  'distDir',
416
+ 'enablePrerenderSourceMaps',
405
417
  'env',
418
+ 'excludeDefaultMomentLocales',
406
419
  'expireTime',
407
420
  'experimental',
421
+ 'exportPathMap',
422
+ 'generateBuildId',
423
+ 'generateEtags',
424
+ 'headers',
425
+ 'htmlLimitedBots',
426
+ 'httpAgentOptions',
408
427
  'images',
428
+ 'logging',
429
+ 'modularizeImports',
430
+ 'onDemandEntries',
409
431
  'output',
432
+ 'outputFileTracingExcludes',
433
+ 'outputFileTracingIncludes',
410
434
  'outputFileTracingRoot',
411
435
  'pageExtensions',
412
436
  'partialPrefetching',
437
+ 'poweredByHeader',
413
438
  'productionBrowserSourceMaps',
414
439
  'publicRuntimeConfig',
415
440
  'reactCompiler',
416
441
  'reactMaxHeadersLength',
442
+ 'reactProductionProfiling',
443
+ 'reactStrictMode',
417
444
  'resolveExtensions',
418
445
  'redirects',
419
446
  'rewrites',
@@ -422,6 +449,7 @@ const KNOWN_TOP_LEVEL_CONFIG_KEYS = new Set([
422
449
  'serverRuntimeConfig',
423
450
  'skipProxyUrlNormalize',
424
451
  'skipTrailingSlashRedirect',
452
+ 'staticPageGenerationTimeout',
425
453
  'i18n',
426
454
  'instrumentationClientInject',
427
455
  'trailingSlash',
@@ -430,6 +458,8 @@ const KNOWN_TOP_LEVEL_CONFIG_KEYS = new Set([
430
458
  'turbopack',
431
459
  'typedRoutes',
432
460
  'typescript',
461
+ 'useFileSystemPublicRoutes',
462
+ 'watchOptions',
433
463
  'webpack',
434
464
  ])
435
465
 
@@ -523,12 +553,12 @@ function warnWebpackCompat(config: NextConfigObject, configFileName: string): vo
523
553
  console.warn(
524
554
  `\`webpack\` in ${configFileName} is not executed by pnext — esbuild is the only bundler. ` +
525
555
  '`transpilePackages`, `experimental.optimizePackageImports`, and `turbopack.resolveAlias` are ' +
526
- 'supported natively; see reference/compat.md.',
556
+ 'supported natively; see https://pnext.dev/docs/compat.',
527
557
  )
528
558
  if (webpackReferencesSvgr(config)) {
529
559
  console.info(
530
560
  'pnext detected @svgr/webpack in `webpack()` and will compile `.svg` imports to inline Preact ' +
531
- 'components instead (see reference/compat.md).',
561
+ 'components instead (see https://pnext.dev/docs/compat).',
532
562
  )
533
563
  }
534
564
  }
@@ -1,3 +1,4 @@
1
+ /** @jsxImportSource preact */
1
2
  import { h, type ComponentType } from 'preact'
2
3
  import { lazy, Suspense } from 'preact/compat'
3
4
  import {
@@ -1,4 +1,5 @@
1
1
  'use client'
2
+ /** @jsxImportSource preact */
2
3
 
3
4
  import { Component, Fragment, h, type ComponentChildren, type ComponentType } from 'preact'
4
5
  import { isControlFlowError } from '../client/errors/control-flow'
@@ -1,4 +1,5 @@
1
1
  'use client'
2
+ /** @jsxImportSource preact */
2
3
 
3
4
  import { useEffect, useRef, type JSX, type ReactNode } from 'preact/compat'
4
5
  import { prefetchRoute, softNavigate } from '../../client/router'
@@ -1,3 +1,4 @@
1
+ /** @jsxImportSource preact */
1
2
  import type { ComponentChildren } from 'preact'
2
3
 
3
4
  /**