@wular/pnext 0.0.19 → 0.0.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/reference/compat.md +2 -2
  4. package/reference/getting-started.md +2 -2
  5. package/src/cli/adapters/vercel.ts +27 -0
  6. package/src/cli/analyze.ts +8 -7
  7. package/src/cli/boot/trace.ts +9 -3
  8. package/src/cli/build.ts +23 -15
  9. package/src/cli/dev.ts +23 -0
  10. package/src/cli/migrate/report.ts +1 -1
  11. package/src/cli/migrate/run.ts +2 -2
  12. package/src/cli/migrate/scan.ts +128 -1
  13. package/src/cli/serve/pipeline.ts +42 -42
  14. package/src/cli/start.ts +4 -2
  15. package/src/client/build.ts +51 -25
  16. package/src/client/entry.ts +27 -4
  17. package/src/client/router/page-slot.ts +81 -0
  18. package/src/client/router/runtime.ts +107 -112
  19. package/src/compat/bundler/cjs-exports.ts +26 -20
  20. package/src/compat/client/css-order.ts +1 -1
  21. package/src/compat/client/navigation-scroll.ts +36 -40
  22. package/src/compat/css/modules.ts +6 -2
  23. package/src/compat/next/font/cache.ts +10 -22
  24. package/src/compat/next/font/runtime.ts +20 -0
  25. package/src/compat/next/legacy-request-apis.ts +41 -5
  26. package/src/compat/next/script.tsx +16 -24
  27. package/src/compat/react/client-lite.ts +1 -0
  28. package/src/compat/react/client.ts +11 -0
  29. package/src/compat/react/dom-client.ts +11 -2
  30. package/src/compat/react/dom-react-server.ts +4 -0
  31. package/src/compat/react/dom.ts +11 -2
  32. package/src/compat/react/internals.ts +65 -0
  33. package/src/compat/react/parity.ts +47 -0
  34. package/src/compat/react/preact.ts +31 -1
  35. package/src/compat/react/react-server.ts +7 -0
  36. package/src/compat/react/server.ts +16 -2
  37. package/src/compat/react/style-values.ts +68 -0
  38. package/src/compat/register/render.ts +14 -3
  39. package/src/compat/register/routing.ts +57 -7
  40. package/src/compat/tsconfig-defaults.ts +4 -0
  41. package/src/compat/typecheck/check.ts +26 -71
  42. package/src/compat/typecheck/worker.ts +3 -2
  43. package/src/compat-bootstrap.ts +4 -0
  44. package/src/css/build.ts +153 -14
  45. package/src/css/postcss.ts +32 -3
  46. package/src/dev/restart/client-key.ts +1 -1
  47. package/src/dev/restart/global-css.ts +12 -94
  48. package/src/dev/restart/record.ts +106 -0
  49. package/src/dev/restart/route-facts.ts +11 -90
  50. package/src/dev/server.ts +208 -41
  51. package/src/extensions.ts +69 -3
  52. package/src/render/hooks.ts +1 -1
  53. package/src/render/metadata.ts +89 -17
  54. package/src/render/renderer.ts +322 -136
  55. package/src/render/resource-hints.ts +62 -9
  56. package/src/render/slots.tsx +2 -1
  57. package/src/resolve/scan-facts.ts +13 -5
  58. package/src/routing/forwarded.ts +20 -7
  59. package/src/routing/handler.ts +6 -2
  60. package/src/routing/href.ts +11 -1
  61. package/src/routing/metadata-files.ts +16 -3
  62. package/src/routing/proxy.ts +6 -3
  63. package/src/routing/routes.ts +253 -34
  64. package/src/runtime/fetch-host.ts +42 -0
  65. package/src/runtime/fingerprint.ts +132 -0
  66. package/src/runtime/loader.ts +7 -5
  67. package/src/runtime/module-cache.ts +3 -36
  68. package/src/runtime/modules.ts +333 -77
  69. package/src/runtime/vendor-build.ts +34 -5
  70. package/src/runtime/vendor.ts +1 -18
  71. package/src/types.ts +6 -0
  72. package/src/utils/asset-hash.ts +22 -0
  73. package/src/utils/code.ts +41 -0
  74. package/src/utils/html.ts +4 -1
  75. package/src/utils/serialize.ts +66 -7
package/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
@@ -70,7 +71,8 @@ export async function start(options: StartOptions = {}) {
70
71
  const server = Bun.serve({
71
72
  hostname,
72
73
  port,
73
- fetch: request => (handler ? handler(request) : ensureHandler().then(ready => ready(request))),
74
+ fetch: (request, server) =>
75
+ handler ? handler(request, server) : ensureHandler().then(ready => ready(request, server)),
74
76
  })
75
77
 
76
78
  printServerReady({
@@ -1,6 +1,6 @@
1
1
  import { createHash } from 'node:crypto'
2
2
  import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'
3
- import { copyFile, mkdir, readFile, rename, stat } from 'node:fs/promises'
3
+ import { copyFile, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
4
4
  import path from 'node:path'
5
5
  import {
6
6
  build,
@@ -15,6 +15,7 @@ import {
15
15
  } from 'esbuild'
16
16
  import { foldInitialChunks } from './chunk-fold'
17
17
  import { clientEntryName } from './chunk-name'
18
+ import { assetContentHash } from '../utils/asset-hash'
18
19
  import { clientProfile } from './profile'
19
20
  import { frameworkRuntimeAliasEntries, publicEnvDefines, type ResolvedConfig } from '../config'
20
21
  import { cssModuleClientPlugin } from '../css/build'
@@ -57,6 +58,7 @@ import { shouldReactCompile, transformReactCompiler } from './react-compiler'
57
58
  import { createVerboseLogger, formatDuration } from '../utils/verbose'
58
59
  import { dim as dimText } from '../utils/ansi'
59
60
  import { traceEnabled } from '../utils/trace-flags'
61
+ import { frameworkFingerprint } from '../runtime/fingerprint'
60
62
  import type { RouteManifestEntry } from '../types'
61
63
  import {
62
64
  applyClientSourceAsyncPreTransforms,
@@ -654,9 +656,41 @@ export async function buildClientEntries({
654
656
  }
655
657
  }
656
658
  })
659
+ await clientProfile.timeAsync('fingerprint', () => fingerprintClientEntries(outDir, entries))
657
660
  clientProfile.report('client stage')
658
661
  }
659
662
 
663
+ /**
664
+ * Give every route entry a CONTENT-hashed name, replacing the route-identity hash
665
+ * clientEntryName picks for the build. These are served from /_next/static, which
666
+ * is immutable: a name that outlives its bytes pins a stale runtime in every
667
+ * browser that saw the previous deploy. Two routes whose entries are byte
668
+ * identical collapse onto one file — a URL they can honestly share.
669
+ */
670
+ async function fingerprintClientEntries(outDir: string, entries: ClientEntry[]) {
671
+ await Promise.all(
672
+ entries.map(async entry => {
673
+ const from = path.join(outDir, `${entry.entryName}.js`)
674
+ const bytes = await readFile(from).catch(() => undefined)
675
+ if (!bytes) return
676
+ const name = `pnext-client-${assetContentHash(bytes)}.js`
677
+ entry.route.clientEntry = `assets/${name}`
678
+ const to = path.join(outDir, name)
679
+ if (to === from) return
680
+ const sourceMap = `${from}.map`
681
+ if (existsSync(sourceMap)) {
682
+ // The linked map moves with the entry, so the comment has to move too.
683
+ const source = bytes.toString('utf8').replaceAll(`${entry.entryName}.js.map`, `${name}.map`)
684
+ await writeFile(to, source)
685
+ await rm(from, { force: true })
686
+ await rename(sourceMap, `${to}.map`)
687
+ return
688
+ }
689
+ await rename(from, to)
690
+ }),
691
+ )
692
+ }
693
+
660
694
  interface ClientEntry {
661
695
  route: RouteManifestEntry
662
696
  entryName: string
@@ -1905,12 +1939,15 @@ export interface ClientCacheKeyParts {
1905
1939
  }
1906
1940
 
1907
1941
  /**
1908
- * The half of the key that is not a walked source: the client build pipeline's own sources, the
1909
- * generated entry, and the route's reference list. A handful of small reads, so it is cheap to
1910
- * recompute on every lookup - which is what lets the expensive source walk be validated from a
1911
- * persisted index instead (see dev/restart/client-key.ts).
1942
+ * The half of the key that is not a walked source: pnext's own generation, the generated entry, and
1943
+ * the route's reference list. Cheap to recompute on every lookup - which is what lets the expensive
1944
+ * source walk be validated from a persisted index instead (see dev/restart/client-key.ts).
1945
+ *
1946
+ * The generation, not a list of the pipeline's own files: that list named seven of them and missed
1947
+ * the router runtime the entry pulls in, so editing it left every route's out-dir named for the
1948
+ * bundle it no longer builds.
1912
1949
  */
1913
- export async function clientCacheStaticHash(
1950
+ export function clientCacheStaticHash(
1914
1951
  route: RouteManifestEntry,
1915
1952
  nextCompat?: boolean,
1916
1953
  config?: ResolvedConfig,
@@ -1919,12 +1956,8 @@ export async function clientCacheStaticHash(
1919
1956
  // never reads a memo an earlier call left behind.
1920
1957
  conventionCache.clear()
1921
1958
  const hash = createHash('sha256')
1922
- for (const file of clientBuildPipelineFiles()) {
1923
- hash.update(file)
1924
- hash.update('\0')
1925
- hash.update(await readText(file))
1926
- hash.update('\0')
1927
- }
1959
+ hash.update(frameworkFingerprint())
1960
+ hash.update('\0')
1928
1961
 
1929
1962
  hash.update(
1930
1963
  clientEntrySource({
@@ -1944,6 +1977,11 @@ export async function clientCacheStaticHash(
1944
1977
  }),
1945
1978
  )
1946
1979
  hash.update('\0')
1980
+ // The build is scoped to the route id (virtual entry, source pipeline, surface signature, deferred
1981
+ // dynamic href), so it belongs in the key: without it two routes with the same client closure share
1982
+ // an out-dir, and their entries either overwrite each other or serve one another's bytes.
1983
+ hash.update(route.id)
1984
+ hash.update('\0')
1947
1985
  hash.update(route.client ? route.file : '')
1948
1986
  hash.update(JSON.stringify(route.clientReferences))
1949
1987
  return hash.digest('hex')
@@ -1989,7 +2027,7 @@ export async function clientCacheKeyParts(
1989
2027
  config?: ResolvedConfig,
1990
2028
  staticHash?: string,
1991
2029
  ): Promise<ClientCacheKeyParts> {
1992
- const staticDigest = staticHash ?? (await clientCacheStaticHash(route, nextCompat, config))
2030
+ const staticDigest = staticHash ?? clientCacheStaticHash(route, nextCompat, config)
1993
2031
  const sources = await Promise.all(
1994
2032
  (await clientSourceFiles(route)).map(file => readClientCacheSource(file)),
1995
2033
  )
@@ -2004,18 +2042,6 @@ export async function clientCacheKey(
2004
2042
  return (await clientCacheKeyParts(route, nextCompat, config)).key
2005
2043
  }
2006
2044
 
2007
- function clientBuildPipelineFiles() {
2008
- return [
2009
- path.join(import.meta.dirname, 'entry.ts'),
2010
- path.resolve(import.meta.dirname, '..', 'api', 'router.ts'),
2011
- path.resolve(import.meta.dirname, '..', 'api', 'client-navigation.ts'),
2012
- path.join(import.meta.dirname, 'react-compiler.ts'),
2013
- path.join(import.meta.dirname, 'build.ts'),
2014
- path.resolve(import.meta.dirname, '..', 'compat', 'react', 'client.ts'),
2015
- path.resolve(import.meta.dirname, '..', 'compat', 'react', 'preact.ts'),
2016
- ].filter(file => existsSync(file))
2017
- }
2018
-
2019
2045
  async function clientSourceFiles(route: RouteManifestEntry) {
2020
2046
  const entryFiles = [
2021
2047
  ...(route.client ? [route.file] : []),
@@ -291,6 +291,22 @@ import { isActionError } from ${JSON.stringify(actionClientModulePath())};`,
291
291
  // The `$$pnextError` marker is written by the renderer's action-prop serializer, which returns props
292
292
  // untouched when next compat is off - so core and react entries import the ref+binary reviver and the
293
293
  // error branch never ships.
294
+ /**
295
+ * Both wire carriers - an island's `data-pnext-props` markers and the client page's
296
+ * `__PNEXT_PROPS__` - must rebuild the same legacy sync surface, so they share one rebuilder. The
297
+ * server stamps the flag only under compat.next.legacyRequestAPIs; a strict app reads params the
298
+ * async way and must not grow a surface its server render never had.
299
+ *
300
+ * Emitted under next compat only - the flag rides compat's document scripts, so a core/react entry
301
+ * could never see it and ships neither the import nor the call.
302
+ */
303
+ function syncPropsHelperSource() {
304
+ return `
305
+ import { rebuildSyncProps as __pnextRebuildSyncProps } from ${JSON.stringify(serializeModulePath())};
306
+ const __pnextSyncProps = () => (typeof window !== 'undefined' && window.__PNEXT_SYNC_PROPS__ ? __pnextRebuildSyncProps : undefined);
307
+ `
308
+ }
309
+
294
310
  function propsParserSource(nextCompat?: boolean) {
295
311
  const revive = nextCompat ? 'reviveSerializedErrorRefs' : 'reviveSerializedRefs'
296
312
  return `
@@ -322,7 +338,7 @@ function parseIslandProps(raw) {
322
338
  // nested in plain containers (a dehydrated react-query state's pending-query promise). Revive both
323
339
  // into pre-fulfilled promises - see revivePromiseMarkers. The deep walk is gated on a substring
324
340
  // test of the raw attribute, so an island with no promise props pays one indexOf.
325
- return __pnextHasPromiseProps(raw || '') ? __pnextRevivePromiseMarkers(revived) : revived;
341
+ return __pnextHasPromiseProps(raw || '') ? __pnextRevivePromiseMarkers(revived${nextCompat ? ', __pnextSyncProps()' : ''}) : revived;
326
342
  }
327
343
  `
328
344
  }
@@ -699,6 +715,7 @@ ${nextCompat ? "import { options as __pnextPreactOptions } from 'preact';" : ''}
699
715
  ${deferredIslands ? "import { useEffect, useState } from 'preact/hooks';" : ''}
700
716
  ${suspense ? "import { Suspense } from 'preact/compat';" : ''}
701
717
  ${revival ? layoutSegmentHelperSource(nextCompat) : ''}
718
+ ${nextCompat && (revival || page) ? syncPropsHelperSource() : ''}
702
719
  ${revival ? propsParserSource(nextCompat) : ''}
703
720
  ${revival ? islandHelpersSource(facts) : ''}
704
721
  ${islandCssHelperSource()}
@@ -918,6 +935,7 @@ const pnextMountedRoots = new Set();
918
935
  let pnextIslandMounts;
919
936
  let pnextRender;
920
937
 
938
+ ${nextCompat ? syncPropsHelperSource() : ''}
921
939
  ${propsParserSource(nextCompat)}
922
940
  ${compatBoundary}
923
941
  export function mountRoute() {
@@ -1195,16 +1213,21 @@ const rawProps = window.__PNEXT_PROPS__ ?? {};
1195
1213
  // Pre-fulfilled promises (status/value readable synchronously): use() must
1196
1214
  // not suspend during page hydration or preact appends fragment siblings
1197
1215
  // instead of reusing the server DOM.
1198
- const fulfilled = value => {
1216
+ // Under compat.next.legacyRequestAPIs the server also gave these promises the resolved object's
1217
+ // own keys (withSyncProps), so Next-14 code reads \`params.creator\` without await; rebuild that
1218
+ // same surface here or the hydrated page loses every param the SSR render had.
1219
+ ${nextCompat ? 'const legacy = __pnextSyncProps();' : ''}
1220
+ const fulfilled = (kind, value) => {
1199
1221
  const promise = Promise.resolve(value);
1200
1222
  promise.status = 'fulfilled';
1201
1223
  promise.value = value;
1202
- return promise;
1224
+ return ${nextCompat ? 'legacy ? legacy(promise, kind, value) : promise' : 'promise'};
1203
1225
  };
1204
1226
  return {
1205
1227
  ...rawProps,
1206
- params: fulfilled(rawProps.params ?? {}),
1228
+ params: fulfilled('params', rawProps.params ?? {}),
1207
1229
  searchParams: fulfilled(
1230
+ 'searchParams',
1208
1231
  rawProps.searchParams ?? Object.fromEntries(new URLSearchParams(location.search)),
1209
1232
  ),
1210
1233
  };
@@ -0,0 +1,81 @@
1
+ type PageSlotRange = [start: Element | Comment, end: Comment | undefined, nodes: ChildNode[]]
2
+
3
+ /** Resolve either wire form of a page slot inside a preserved server segment. */
4
+ export function pageSlotRange(segment: Element): PageSlotRange | null {
5
+ const element = segment.querySelector('#pnext-page')
6
+ if (element) return [element, undefined, [...element.childNodes]]
7
+ const walker = document.createTreeWalker(segment, 128 /* NodeFilter.SHOW_COMMENT */)
8
+ while (walker.nextNode()) {
9
+ const start = walker.currentNode as Comment
10
+ if (!start.data.startsWith('pnext-page:')) continue
11
+ const nodes: ChildNode[] = []
12
+ let end = start.nextSibling
13
+ while (end && !(end.nodeType === 8 && end.nodeValue === '/pnext-page')) {
14
+ nodes.push(end)
15
+ end = end.nextSibling
16
+ }
17
+ return end ? [start, end as Comment, nodes] : null
18
+ }
19
+ return null
20
+ }
21
+
22
+ /** Whether an element belongs to the page slot in either DOM form. */
23
+ export function elementInPageSlot(element: Element): boolean {
24
+ return !!(
25
+ element.closest('#pnext-page') ||
26
+ pageSlotRange(document.body)?.[2].some(node => node.contains(element))
27
+ )
28
+ }
29
+
30
+ /**
31
+ * Graft a destination page into a preserved layout without changing the destination's mount shape.
32
+ * Client pages need the real `#pnext-page` element; server/island-only pages need the marker range.
33
+ */
34
+ export function graftPageSlot(liveSegment: Element, incomingSegment: Element): boolean {
35
+ const live = pageSlotRange(liveSegment)
36
+ const incoming = pageSlotRange(incomingSegment)
37
+ if (!live || !incoming) return false
38
+ const nodes = reusePageNodes(live[2], incoming[2])
39
+ for (const stale of live[2]) stale.remove()
40
+ const end = incoming[1]
41
+ if (!end) (incoming[0] as Element).replaceChildren(...nodes)
42
+ else {
43
+ for (const stale of incoming[2]) stale.remove()
44
+ }
45
+ live[0].replaceWith(incoming[0])
46
+ if (end) incoming[0].after(...nodes, end)
47
+ live[1]?.remove()
48
+ return true
49
+ }
50
+
51
+ // Next re-renders a page in place, so unchanged DOM keeps its node identity.
52
+ function reusePageNodes(live: readonly ChildNode[], incoming: readonly Node[]): Node[] {
53
+ const candidates = live.filter(
54
+ (node): node is Element =>
55
+ node instanceof Element &&
56
+ !node.matches('script, pnext-client, #pnext-page') &&
57
+ !node.querySelector('pnext-client, #pnext-page, [data-pnext-preserve]'),
58
+ )
59
+ return incoming.map(node => {
60
+ if (!(node instanceof Element)) return node
61
+ const index = candidates.findIndex(candidate => candidate.outerHTML === node.outerHTML)
62
+ return index === -1 ? node : candidates.splice(index, 1)[0]!
63
+ })
64
+ }
65
+
66
+ export interface LoadingShellTarget {
67
+ container: HTMLElement
68
+ markerRange?: PageSlotRange
69
+ }
70
+
71
+ /** Resolve a safe loading-shell target without falling through a dissolved page to the body. */
72
+ export function loadingShellTarget(): LoadingShellTarget {
73
+ const page = document.getElementById('pnext-page')
74
+ if (page) return { container: page }
75
+ const markerRange = pageSlotRange(document.body)
76
+ if (markerRange?.[1]) {
77
+ return { container: markerRange[0].parentElement!, markerRange }
78
+ }
79
+ const root = document.querySelector<HTMLElement>('[data-pnext-root]')
80
+ return { container: root ?? document.body }
81
+ }