@wular/pnext 0.0.19 → 0.0.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/reference/compat.md +2 -2
  4. package/reference/getting-started.md +2 -2
  5. package/src/cli/adapters/vercel.ts +27 -0
  6. package/src/cli/analyze.ts +8 -7
  7. package/src/cli/boot/trace.ts +9 -3
  8. package/src/cli/build/cache.ts +324 -0
  9. package/src/cli/build.ts +42 -18
  10. package/src/cli/create.ts +4 -4
  11. package/src/cli/dev.ts +23 -0
  12. package/src/cli/migrate/package-json.ts +3 -2
  13. package/src/cli/migrate/report.ts +1 -1
  14. package/src/cli/migrate/run.ts +2 -2
  15. package/src/cli/migrate/scan.ts +128 -1
  16. package/src/cli/serve/pipeline.ts +42 -42
  17. package/src/cli/start.ts +20 -20
  18. package/src/client/build.ts +64 -35
  19. package/src/client/entry.ts +228 -16
  20. package/src/client/router/page-slot.ts +81 -0
  21. package/src/client/router/runtime.ts +107 -112
  22. package/src/compat/bundler/cjs-exports.ts +26 -20
  23. package/src/compat/client/css-order.ts +1 -1
  24. package/src/compat/client/navigation-scroll.ts +36 -40
  25. package/src/compat/css/modules.ts +6 -2
  26. package/src/compat/metadata.ts +0 -9
  27. package/src/compat/next/font/cache.ts +10 -22
  28. package/src/compat/next/font/runtime.ts +20 -0
  29. package/src/compat/next/legacy-request-apis.ts +41 -5
  30. package/src/compat/next/script.tsx +16 -24
  31. package/src/compat/react/client-lite.ts +1 -0
  32. package/src/compat/react/client.ts +11 -0
  33. package/src/compat/react/dom-client.ts +11 -2
  34. package/src/compat/react/dom-react-server.ts +4 -0
  35. package/src/compat/react/dom.ts +11 -2
  36. package/src/compat/react/internals.ts +65 -0
  37. package/src/compat/react/parity.ts +47 -0
  38. package/src/compat/react/preact.ts +31 -1
  39. package/src/compat/react/react-server.ts +7 -0
  40. package/src/compat/react/server.ts +16 -2
  41. package/src/compat/react/style-values.ts +68 -0
  42. package/src/compat/register/proxy.ts +3 -1
  43. package/src/compat/register/render.ts +14 -3
  44. package/src/compat/register/routing.ts +57 -7
  45. package/src/compat/register/typed-routes.ts +0 -2
  46. package/src/compat/tsconfig-defaults.ts +4 -0
  47. package/src/compat/typecheck/check.ts +26 -71
  48. package/src/compat/typecheck/worker.ts +3 -2
  49. package/src/compat/validation/validate.ts +2 -0
  50. package/src/compat-bootstrap.ts +4 -0
  51. package/src/css/build.ts +153 -14
  52. package/src/css/postcss.ts +32 -3
  53. package/src/dev/restart/client-key.ts +1 -1
  54. package/src/dev/restart/global-css.ts +12 -94
  55. package/src/dev/restart/record.ts +106 -0
  56. package/src/dev/restart/route-facts.ts +11 -90
  57. package/src/dev/server.ts +208 -41
  58. package/src/extensions.ts +69 -3
  59. package/src/render/hooks.ts +1 -1
  60. package/src/render/metadata.ts +89 -17
  61. package/src/render/renderer.ts +325 -138
  62. package/src/render/resource-hints.ts +62 -9
  63. package/src/render/slots.tsx +2 -1
  64. package/src/resolve/scan-facts.ts +13 -5
  65. package/src/routing/forwarded.ts +20 -7
  66. package/src/routing/handler.ts +6 -2
  67. package/src/routing/href.ts +11 -1
  68. package/src/routing/metadata-files.ts +16 -3
  69. package/src/routing/proxy.ts +6 -3
  70. package/src/routing/routes.ts +253 -34
  71. package/src/runtime/fetch-host.ts +42 -0
  72. package/src/runtime/fingerprint.ts +132 -0
  73. package/src/runtime/loader.ts +7 -5
  74. package/src/runtime/module-cache.ts +3 -36
  75. package/src/runtime/modules.ts +333 -77
  76. package/src/runtime/vendor-build.ts +34 -5
  77. package/src/runtime/vendor.ts +1 -18
  78. package/src/types.ts +6 -0
  79. package/src/utils/asset-hash.ts +22 -0
  80. package/src/utils/code.ts +41 -0
  81. package/src/utils/html.ts +4 -1
  82. package/src/utils/registry.ts +70 -0
  83. package/src/utils/serialize.ts +66 -7
@@ -1,15 +1,62 @@
1
1
  import type { ResourceHint } from '../types'
2
2
 
3
- declare global {
4
- var __PNEXT_RESOURCE_HINTS__: ResourceHint[] | undefined
5
- }
6
-
7
3
  // Optional server-side observer for every registered hint (compat's Link response-header emission).
8
4
  // Anchored on globalThis because react-dom's preload()/preinit() shims are compiled into app bundles as
9
5
  // separate module instances - only a global slot lets the runtime-registered listener see the
10
6
  // bundle-registered hints. No-op for pure-core apps.
11
7
  const HINT_LISTENER = Symbol.for('pnext.resourceHintListener')
12
8
 
9
+ // The buffers a render fills and the renderer drains at document assembly: resource hints, the
10
+ // beforeInteractive head scripts and the deferred script runtime. One global array per kind is not
11
+ // enough - two renders alive at once in one process (a build prerendering a route beside the
12
+ // not-found document, or two concurrent requests) then share it, and whichever assembles first
13
+ // drains the other's preloads and head scripts. So a render gets a FRAME, and the buffers live in
14
+ // it; outside a render they stay on globalThis, where the next render start drops them.
15
+ //
16
+ // The frame store is created by the renderer (server-only, it owns node:async_hooks) and read here
17
+ // through the same globalThis anchor as the work unit: this module is compiled into client bundles
18
+ // and into every compat bundle's own copy, and only a shared slot lets those copies write into the
19
+ // frame the renderer reads.
20
+ export const RENDER_BUFFER_SCOPE = Symbol.for('pnext.renderBufferScope')
21
+ const RENDER_BUFFER_STRAYS = Symbol.for('pnext.renderBufferStrays')
22
+
23
+ export type RenderBufferKind = 'hints' | 'head' | 'deferred'
24
+ export type RenderBufferFrame = Record<RenderBufferKind, unknown[]>
25
+
26
+ export const newRenderBufferFrame = (): RenderBufferFrame => ({ hints: [], head: [], deferred: [] })
27
+
28
+ const globals = globalThis as Record<PropertyKey, unknown>
29
+
30
+ /** Duck-typed store read, so this module never pulls node:async_hooks into a client bundle. */
31
+ function frame(): RenderBufferFrame {
32
+ const scope = globals[RENDER_BUFFER_SCOPE] as
33
+ { getStore(): RenderBufferFrame | undefined } | undefined
34
+ return (
35
+ scope?.getStore() ??
36
+ ((globals[RENDER_BUFFER_STRAYS] ??= newRenderBufferFrame()) as RenderBufferFrame)
37
+ )
38
+ }
39
+
40
+ /** The current render's buffer, or the process-wide stray one outside a render. */
41
+ export function renderBuffer<T>(kind: RenderBufferKind): T[] {
42
+ return frame()[kind] as T[]
43
+ }
44
+
45
+ /** Hand the buffer to the document being assembled and leave a fresh one behind. */
46
+ export function takeRenderBuffer<T>(kind: RenderBufferKind): T[] {
47
+ const active = frame()
48
+ const buffer = active[kind]
49
+ active[kind] = []
50
+ return buffer as T[]
51
+ }
52
+
53
+ /** Drop the frame's buffer AND the stray one, so out-of-render writes never accumulate. */
54
+ export function clearRenderBuffer(kind: RenderBufferKind): void {
55
+ frame()[kind] = []
56
+ const strays = globals[RENDER_BUFFER_STRAYS] as RenderBufferFrame | undefined
57
+ if (strays) strays[kind] = []
58
+ }
59
+
13
60
  export function setResourceHintListener(listener: ((hint: ResourceHint) => void) | undefined) {
14
61
  ;(globalThis as Record<PropertyKey, unknown>)[HINT_LISTENER] = listener
15
62
  }
@@ -22,22 +69,28 @@ export function registerResourceHint(hint: ResourceHint): void {
22
69
  const listener = (globalThis as Record<PropertyKey, unknown>)[HINT_LISTENER] as
23
70
  ((hint: ResourceHint) => void) | undefined
24
71
  listener?.(hint)
25
- const hints = (globalThis.__PNEXT_RESOURCE_HINTS__ ??= [])
72
+ const hints = renderBuffer<ResourceHint>('hints')
26
73
  if (hints.some(existing => resourceHintKey(existing) === resourceHintKey(hint))) return
27
74
  hints.push(hint)
28
75
  }
29
76
 
30
77
  export function clearResourceHints(): void {
31
- globalThis.__PNEXT_RESOURCE_HINTS__ = []
78
+ clearRenderBuffer('hints')
32
79
  }
33
80
 
34
81
  export function takeResourceHints(): ResourceHint[] {
35
- const hints = globalThis.__PNEXT_RESOURCE_HINTS__ ?? []
36
- globalThis.__PNEXT_RESOURCE_HINTS__ = []
37
- return hints
82
+ return takeRenderBuffer<ResourceHint>('hints')
38
83
  }
39
84
 
40
85
  function resourceHintKey(hint: ResourceHint): string {
86
+ // React Float dedupes image preloads by the image URL, not the full attribute
87
+ // set. next/image may register the same optimized candidate more than once
88
+ // while a shared layout renders, and the first registration owns its shape.
89
+ if (hint.rel === 'preload' && hint.as === 'image') {
90
+ const srcSet = hint.attributes?.imagesrcset
91
+ const first = typeof srcSet === 'string' ? srcSet.split(/[\s,]+/)[0] : undefined
92
+ return `image:${first || hint.url || ''}`
93
+ }
41
94
  const attributes = Object.entries(hint.attributes ?? {}).sort(([a], [b]) => a.localeCompare(b))
42
95
  return JSON.stringify({ ...hint, attributes })
43
96
  }
@@ -18,6 +18,7 @@ import {
18
18
  } from '../routing/slots'
19
19
  import { toPosixPath } from '../utils/fs'
20
20
  import { PROMISE_MARKER_KEY, revivePromiseMarkers } from '../utils/serialize'
21
+ import { getRenderExtensions } from '../extensions'
21
22
  import type { PageProps, RouteManifestEntry, RouteParamValue, ServerComponent } from '../types'
22
23
 
23
24
  interface SlotRenderOptions {
@@ -423,7 +424,7 @@ export function promisePlaceholderJson(kind: 'params' | 'searchParams'): string
423
424
  * entry runs, so a nested marker is a promise on both sides rather than a bare object on one.
424
425
  */
425
426
  export function revivePromiseProps(props: Record<string, unknown>) {
426
- return revivePromiseMarkers({ ...props })
427
+ return revivePromiseMarkers({ ...props }, getRenderExtensions().legacySyncProps)
427
428
  }
428
429
 
429
430
  function slotPageProps<Options extends SlotRenderOptions>(
@@ -115,6 +115,8 @@ export interface ScanFacts {
115
115
  exportStars: string[]
116
116
  /** A real `'use client'` directive — prologue position only, never a comment. */
117
117
  useClient: boolean
118
+ /** A real module-level `'use server'` directive, on the same terms. */
119
+ useServer: boolean
118
120
  /** Loader-shaped calls (see `DynamicCallFact`); only sources that can bind `dynamic` are walked. */
119
121
  dynamicCalls: DynamicCallFact[]
120
122
  }
@@ -128,6 +130,7 @@ const emptyFacts: ScanFacts = {
128
130
  exportNames: [],
129
131
  exportStars: [],
130
132
  useClient: false,
133
+ useServer: false,
131
134
  dynamicCalls: [],
132
135
  }
133
136
 
@@ -378,7 +381,8 @@ function parseFacts(file: string, source: string, lang: ParserLang) {
378
381
  requires,
379
382
  exportNames,
380
383
  exportStars,
381
- useClient: hasUseClient(source, result),
384
+ useClient: moduleDirective(source, result, 'use client'),
385
+ useServer: moduleDirective(source, result, 'use server'),
382
386
  // `result.program` deserializes the whole AST: walk it only for sources
383
387
  // that can bind `dynamic()` at all (same cheap gate as resolve/dynamic.ts).
384
388
  dynamicCalls:
@@ -400,12 +404,16 @@ function parseFacts(file: string, source: string, lang: ParserLang) {
400
404
  * was its only consumer. The raw-source scanner answers it without the AST; the AST is still
401
405
  * consulted for the prologue shapes the scanner will not commit to.
402
406
  */
403
- function hasUseClient(source: string, result: { program: { body: readonly Directive[] } }) {
404
- const sniffed = sniffDirective(source, 'use client')
407
+ function moduleDirective(
408
+ source: string,
409
+ result: { program: { body: readonly Directive[] } },
410
+ directive: string,
411
+ ) {
412
+ const sniffed = sniffDirective(source, directive)
405
413
  if (sniffed !== undefined) return sniffed
406
414
  // Uncertain, but a directive is its raw text: no occurrence, no directive.
407
- if (!source.includes('use client')) return false
408
- return hasDirective(result.program.body, 'use client')
415
+ if (!source.includes(directive)) return false
416
+ return hasDirective(result.program.body, directive)
409
417
  }
410
418
 
411
419
  interface Directive {
@@ -1,26 +1,34 @@
1
1
  // x-forwarded-* request header injection (CORE HTTP correctness).
2
2
  //
3
3
  // Before route matching / render, the server records the observed host, port,
4
- // and protocol on the incoming request headers so headers() during render and
5
- // any request-header reader (middleware, route handlers) see them. Client-sent
4
+ // protocol, and peer address on the incoming request headers so headers() during
5
+ // render and any request-header reader (middleware, route handlers) see them. Client-sent
6
6
  // x-forwarded-* values are never overridden (a trusted proxy already set them),
7
7
  // including multi-value forms like "https, https".
8
8
 
9
9
  const FORWARDED_HOST = 'x-forwarded-host'
10
10
  const FORWARDED_PORT = 'x-forwarded-port'
11
11
  const FORWARDED_PROTO = 'x-forwarded-proto'
12
+ const FORWARDED_FOR = 'x-forwarded-for'
13
+
14
+ /** The serving runtime, narrowed to the peer-address lookup (Bun's `Server`). */
15
+ export interface PeerAddressSource {
16
+ requestIP(request: Request): { address: string } | null
17
+ }
12
18
 
13
19
  /**
14
- * Return the request with x-forwarded-host/port/proto injected from the request
15
- * URL + Host header, preserving any client-sent values. Returns the same
16
- * request when every value was already present (no allocation on the hot path).
20
+ * Return the request with x-forwarded-host/port/proto/for injected from the
21
+ * request URL + Host header + peer address, preserving any client-sent values.
22
+ * Returns the same request when every value was already present (no allocation
23
+ * on the hot path).
17
24
  */
18
- export function withForwardedHeaders(request: Request): Request {
25
+ export function withForwardedHeaders(request: Request, peer?: PeerAddressSource): Request {
19
26
  const headers = request.headers
20
27
  const hasHost = headers.has(FORWARDED_HOST)
21
28
  const hasPort = headers.has(FORWARDED_PORT)
22
29
  const hasProto = headers.has(FORWARDED_PROTO)
23
- if (hasHost && hasPort && hasProto) return request
30
+ const hasFor = headers.has(FORWARDED_FOR)
31
+ if (hasHost && hasPort && hasProto && (hasFor || !peer)) return request
24
32
 
25
33
  let url: URL
26
34
  try {
@@ -37,5 +45,10 @@ export function withForwardedHeaders(request: Request): Request {
37
45
  if (!hasHost) next.set(FORWARDED_HOST, host)
38
46
  if (!hasPort) next.set(FORWARDED_PORT, port)
39
47
  if (!hasProto) next.set(FORWARDED_PROTO, proto)
48
+ // Next sets it from the socket's remoteAddress and never appends to a client-sent value.
49
+ if (!hasFor && peer) {
50
+ const address = peer.requestIP(request)?.address
51
+ if (address) next.set(FORWARDED_FOR, address)
52
+ }
40
53
  return new Request(request, { headers: next })
41
54
  }
@@ -1,7 +1,7 @@
1
1
  import { isNotFoundError, isRedirectError } from '../api/navigation'
2
2
  import { getWorkUnit, runWithRequest } from '../request/context'
3
3
  import { nextResponseMeta, toNextRequest } from '../api/server'
4
- import { reportRequestError } from '../extensions'
4
+ import { getRenderExtensions, reportRequestError } from '../extensions'
5
5
  import type { NextRequest, RouteManifestEntry, RouteParamValue } from '../types'
6
6
  import { safeDecode } from '../utils/decode'
7
7
 
@@ -156,7 +156,11 @@ function callHandler(
156
156
  // Static routes (no dynamic segments) get no `params` in context — Next
157
157
  // omits it, so `context.params` is `undefined` rather than a promise of `{}`.
158
158
  const context: RouteHandlerContext =
159
- Object.keys(params).length > 0 ? { params: Promise.resolve(params) } : {}
159
+ Object.keys(params).length > 0
160
+ ? {
161
+ params: getRenderExtensions().legacySyncProps(Promise.resolve(params), 'params', params),
162
+ }
163
+ : {}
160
164
  return runWithRequest(nextRequest, () => handler(nextRequest, context), params, {
161
165
  responseHeaders,
162
166
  })
@@ -9,6 +9,16 @@ export interface HrefParts {
9
9
  hash?: string
10
10
  }
11
11
 
12
+ /**
13
+ * A request for pnext's own build output: `/assets/*`, and next-compat's `/_next/static/*` - the
14
+ * same output under the path Next serves it from (see assetPathname). Every bypass that must not
15
+ * treat build output as a page (proxy, trailing-slash canonicalization) asks HERE, so moving the
16
+ * emitted prefix can never leave a bypass knowing only the old one.
17
+ */
18
+ export function isBuildAssetPathname(pathname: string) {
19
+ return pathname.startsWith('/assets/') || pathname.startsWith('/_next/static/')
20
+ }
21
+
12
22
  export function routeHref(route: string, options: HrefParts = {}) {
13
23
  const path = applyTrailingSlash(fillParams(route, options.params ?? {}))
14
24
  const search = searchString(options.search)
@@ -209,7 +219,7 @@ export function trailingSlashRedirect(
209
219
  if (method !== 'GET' && method !== 'HEAD') return null
210
220
  const pathname = url.pathname
211
221
  if (pathname === '/' || pathname === '') return null
212
- if (pathname.startsWith('/__pnext') || pathname.startsWith('/assets/')) return null
222
+ if (pathname.startsWith('/__pnext') || isBuildAssetPathname(pathname)) return null
213
223
  const target = canonicalTrailingSlashPath(pathname, Boolean(config.trailingSlash))
214
224
  if (target === pathname) return null
215
225
  return new Response(null, {
@@ -1,3 +1,4 @@
1
+ import { createHash } from 'node:crypto'
1
2
  import { existsSync, readdirSync, readFileSync } from 'node:fs'
2
3
  import path from 'node:path'
3
4
  import type {
@@ -144,8 +145,12 @@ export async function withDynamicMetadataRoutes(
144
145
  pathname: string,
145
146
  loadModule: MetadataModuleLoader,
146
147
  ): Promise<StaticMetadataForPath> {
148
+ // Every render with no prebuilt metadata reaches here, so an app with no
149
+ // manifest/icon/sitemap modules pays one memoized lookup and no copy.
150
+ const discovered = discoverDynamicMetadataFiles(appPath)
151
+ if (discovered.length === 0) return metadata
147
152
  const routeDir = toPosixPath(path.relative(appPath, path.dirname(routeFile)))
148
- const files = discoverDynamicMetadataFiles(appPath)
153
+ const files = discovered
149
154
  .map(file => matchDynamicMetadataFile(file, pathname, routeDir))
150
155
  .filter((file): file is MatchedDynamicMetadataFile => file !== undefined)
151
156
 
@@ -241,6 +246,7 @@ function staticMetadataFile(appPath: string, file: string): StaticMetadataFile |
241
246
  outputPath: toPosixPath(path.join(outputDir, outputName)),
242
247
  routeSegments,
243
248
  contentType: contentTypeForFile(name),
249
+ ...(kind === 'manifest' ? {} : { cacheIdentity: metadataFileIdentity(file) }),
244
250
  ...(info?.width ? { width: info.width } : {}),
245
251
  ...(info?.height ? { height: info.height } : {}),
246
252
  ...(info?.sizes ? { sizes: info.sizes } : {}),
@@ -296,8 +302,15 @@ function metadataImage(file: StaticMetadataFile): MetadataImage {
296
302
  }
297
303
 
298
304
  function staticMetadataUrl(file: StaticMetadataFile) {
299
- const suffix = file.kind === 'favicon' ? 'favicon' : 'metadata'
300
- return `/${file.outputPath}?${suffix}`
305
+ // File-convention images carry their content identity in the query so an
306
+ // image replacement cannot be served from an old browser cache. Manifests
307
+ // are the one non-image convention represented by this type.
308
+ if (file.kind === 'manifest') return `/${file.outputPath}`
309
+ return `/${file.outputPath}?${file.kind}.${file.cacheIdentity}.${path.extname(file.outputPath).slice(1)}`
310
+ }
311
+
312
+ function metadataFileIdentity(file: string) {
313
+ return createHash('sha256').update(readFileSync(file)).digest('base64url').slice(0, 16)
301
314
  }
302
315
 
303
316
  function nearestFile(files: StaticMetadataFile[], kind: StaticMetadataKind) {
@@ -21,7 +21,7 @@ import {
21
21
  } from '../api/server'
22
22
  import type { ExternalLoadTarget } from '../resolve/imports'
23
23
  import { getProxyExtensions, withRouteRuntime } from '../extensions'
24
- import { canonicalTrailingSlashPath } from './href'
24
+ import { canonicalTrailingSlashPath, isBuildAssetPathname } from './href'
25
25
 
26
26
  export interface ProxyModule {
27
27
  default?: ProxyHandler
@@ -126,7 +126,7 @@ export async function runProxy(
126
126
  options: ProxyRunOptions = {},
127
127
  ): Promise<ProxyNextResult | Response | undefined> {
128
128
  const url = new URL(request.url)
129
- if (url.pathname.startsWith('/__pnext/') || url.pathname.startsWith('/assets/')) {
129
+ if (url.pathname.startsWith('/__pnext/') || isBuildAssetPathname(url.pathname)) {
130
130
  return undefined
131
131
  }
132
132
 
@@ -165,7 +165,10 @@ export function createProxyRunner(
165
165
 
166
166
  const runner: ProxyRunner = async (request, options = {}) => {
167
167
  const url = new URL(request.url)
168
- if (url.pathname.startsWith('/__pnext/') || url.pathname.startsWith('/assets/')) {
168
+ if (
169
+ url.pathname.startsWith('/__pnext/') ||
170
+ (!config.compat?.next && isBuildAssetPathname(url.pathname))
171
+ ) {
169
172
  return undefined
170
173
  }
171
174
  if (!file) return undefined