@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
@@ -40,7 +40,7 @@ import { fileURLToPath } from 'node:url'
40
40
  import { build } from '../utils/esbuild'
41
41
  import { isCommonJsModuleSource } from '../resolve/imports'
42
42
  import { outputSpecifiers } from './module-transform'
43
- import { escapeRegex, isIdentifier, uniqueIdentifier } from '../utils/code'
43
+ import { esbuildEntryExportNames, isIdentifier, uniqueIdentifier } from '../utils/code'
44
44
  import { cachedExistsSync } from '../utils/fs-cache'
45
45
  import { traceEnabled, traceValue } from '../utils/trace-flags'
46
46
  import { writeFileAtomic } from '../utils/fs'
@@ -771,23 +771,6 @@ export function commonJsExportNames(code: string) {
771
771
  .sort()
772
772
  }
773
773
 
774
- // Recover named exports from esbuild's `__export(entry, {...}); module.exports =
775
- // __toCommonJS(entry)` shape. The entry is emitted last, so the final __toCommonJS.
776
- function esbuildEntryExportNames(code: string) {
777
- const toCommonJs = [
778
- ...code.matchAll(/module\.exports\s*=\s*__toCommonJS\w*\(\s*([A-Za-z_$][\w$]*)\s*\)/g),
779
- ]
780
- const entryVar = toCommonJs.at(-1)?.[1]
781
- if (!entryVar) return []
782
- const exportCall = new RegExp(
783
- `__export\\w*\\(\\s*${escapeRegex(entryVar)}\\s*,\\s*\\{([\\s\\S]*?)\\}\\s*\\)`,
784
- ).exec(code)
785
- if (!exportCall?.[1]) return []
786
- return [...exportCall[1].matchAll(/(?:^|,)\s*([A-Za-z_$][\w$]*)\s*:/g)]
787
- .map(match => match[1])
788
- .filter((name): name is string => Boolean(name && isIdentifier(name)))
789
- }
790
-
791
774
  /**
792
775
  * The gate every plugin-free artifact has to pass before it is published: esbuild re-bundles the emitted
793
776
  * entry, so its own chunk graph has to resolve and its syntax has to be real, and every reference the
package/src/types.ts CHANGED
@@ -558,6 +558,8 @@ export interface RouteManifestEntry {
558
558
  childrenDefault?: string
559
559
  /** Intercepting-route metadata, when this route is an interceptor. */
560
560
  interception?: RouteInterception
561
+ /** First `'use server'` module in the route's closure — core refuses these. */
562
+ serverActionFile?: string
561
563
  /**
562
564
  * Ship the soft-navigation runtime even without client references. Derived:
563
565
  * true exactly when `clientEntryReasons` is non-empty. Kept as its own field
@@ -609,6 +611,8 @@ export interface StaticMetadataFileEntry {
609
611
  outputPath: string
610
612
  routeSegments: string[]
611
613
  contentType: string
614
+ /** Content-derived URL identity for cache-busted metadata image links. */
615
+ cacheIdentity?: string
612
616
  width?: number
613
617
  height?: number
614
618
  sizes?: string
@@ -642,6 +646,8 @@ export interface BuildManifest {
642
646
  outDir: string
643
647
  routes: RouteManifestEntry[]
644
648
  staticFiles?: Record<string, StaticFileMetadata>
649
+ /** Logical build-asset name -> the content-hashed name emitted (`global.css` -> `global-<hash>.css`). */
650
+ assetNames?: Record<string, string>
645
651
  staticMetadataFiles?: StaticMetadataFileEntry[]
646
652
  staticModuleMetadata?: Record<string, StaticModuleMetadata>
647
653
  staticRouteMetadata?: Record<string, StaticRouteMetadata>
@@ -0,0 +1,22 @@
1
+ import { createHash } from 'node:crypto'
2
+
3
+ /**
4
+ * The content fingerprint a build-output asset name carries so its URL can be
5
+ * served `immutable`. Lowercase hex keeps the emitted name inside the
6
+ * `<name>-<hash>.<ext>` shape the client chunks and Next's own assets use.
7
+ */
8
+ export function assetContentHash(bytes: Uint8Array | string): string {
9
+ return createHash('sha256').update(bytes).digest('hex').slice(0, 16)
10
+ }
11
+
12
+ /** `global.css` + bytes -> `global-1f4a9c2b3d5e6f70.css`. */
13
+ export function hashedAssetName(name: string, bytes: Uint8Array | string): string {
14
+ const hash = assetContentHash(bytes)
15
+ const dot = name.lastIndexOf('.')
16
+ return dot <= 0 ? `${name}-${hash}` : `${name.slice(0, dot)}-${hash}${name.slice(dot)}`
17
+ }
18
+
19
+ /** True for a name that already carries an `assetContentHash` fingerprint. */
20
+ export function isHashedAssetName(name: string): boolean {
21
+ return /-[0-9a-f]{16}(?:\.[^.]+)?$/.test(name)
22
+ }
package/src/utils/code.ts CHANGED
@@ -37,6 +37,47 @@ export function isIdentifier(value: string) {
37
37
  return /^[A-Za-z_$][\w$]*$/.test(value)
38
38
  }
39
39
 
40
+ /**
41
+ * Named exports of esbuild's `__export(entry, {...}); module.exports = __toCommonJS(entry)` shape.
42
+ * Both helpers are matched by POSITION, not by name: a dependency published with `--minify` (tsup's
43
+ * default, e.g. react-web-share) renames them to one letter, and a literal `__export`/`__toCommonJS`
44
+ * match left every such package default-only - a hard failure, since the importer's named binding is
45
+ * static. The `() =>` getter value is what pins the object down as an export map, not any other literal.
46
+ */
47
+ export function esbuildEntryExportNames(code: string): string[] {
48
+ const scope = entryCommonJsScope(code)
49
+ const assignments = [
50
+ ...scope.matchAll(
51
+ /module\.exports\s*=\s*(?:[A-Za-z_$][\w$]*\s*\(\s*)?([A-Za-z_$][\w$]*)\s*\)?\s*[;\n]/g,
52
+ ),
53
+ ]
54
+ const entryVar = assignments.at(-1)?.[1]
55
+ if (!entryVar) return []
56
+ const exportCall = new RegExp(
57
+ `[A-Za-z_$][\\w$]*\\(\\s*${escapeRegex(entryVar)}\\s*,\\s*\\{([\\s\\S]*?)\\}\\s*\\)`,
58
+ ).exec(scope)
59
+ if (!exportCall?.[1]) return []
60
+ return [
61
+ ...exportCall[1].matchAll(
62
+ /(?:^|,)\s*(?:["']([^"']+)["']|([A-Za-z_$][\w$]*))\s*:\s*\(\s*\)\s*=>/g,
63
+ ),
64
+ ]
65
+ .map(match => match[1] ?? match[2])
66
+ .filter((name): name is string => Boolean(name && isIdentifier(name)))
67
+ }
68
+
69
+ /**
70
+ * The entry CommonJS module's own text. esbuild emits a `__commonJS` wrapper per module, dependencies
71
+ * first and the entry's last, so slicing from that declaration keeps a dependency's `module.exports`
72
+ * from being read as the entry's - which would publish names the package does not have.
73
+ */
74
+ function entryCommonJsScope(code: string) {
75
+ const wrapper = /(^|\n)export default ([A-Za-z_$][\w$]*)\(\)/.exec(code)?.[2]
76
+ if (!wrapper) return code
77
+ const start = code.lastIndexOf(`var ${wrapper} = `)
78
+ return start === -1 ? code : code.slice(start)
79
+ }
80
+
40
81
  /** Source with comments removed; the `[^:]` guard keeps `://` inside string URLs intact. */
41
82
  export function stripComments(source: string): string {
42
83
  return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
package/src/utils/html.ts CHANGED
@@ -10,7 +10,10 @@ export function escapeHtml(value: unknown) {
10
10
  case '"':
11
11
  return '&quot;'
12
12
  case "'":
13
- return '&#39;'
13
+ // React/Next use the hexadecimal spelling in both text and attribute
14
+ // nodes. Keep our hand-written document/metadata serializers on the
15
+ // same wire format rather than relying on browser-equivalent entities.
16
+ return '&#x27;'
14
17
  default:
15
18
  return char
16
19
  }
@@ -0,0 +1,70 @@
1
+ // Which @wular/pnext a generated project depends on. `bunx @wular/pnext` reuses a cached copy of the
2
+ // package instead of re-resolving the dist-tag, so a stale laptop runs an old CLI — and an old CLI
3
+ // stamping its own version pins the new project to it. The range comes from the registry instead,
4
+ // with the running version as the offline fallback.
5
+
6
+ import { bold, cyan, dim } from './ansi'
7
+ import { pnextVersionRange } from './fs'
8
+
9
+ const REGISTRY_URL = 'https://registry.npmjs.org/@wular%2Fpnext'
10
+ // Short: create/migrate must not hang behind a slow or captive network.
11
+ const TIMEOUT_MS = 2000
12
+
13
+ async function fetchLatestPublishedVersion() {
14
+ const response = await fetch(REGISTRY_URL, {
15
+ signal: AbortSignal.timeout(TIMEOUT_MS),
16
+ // The abbreviated document — dist-tags plus versions, without the full metadata.
17
+ headers: { accept: 'application/vnd.npm.install-v1+json' },
18
+ })
19
+ if (!response.ok) return undefined
20
+ const body = (await response.json()) as { 'dist-tags'?: { latest?: string } }
21
+ return body['dist-tags']?.latest
22
+ }
23
+
24
+ // Test seam: the create/migrate suites assert the stamped range and must never hit the network.
25
+ let fetchLatestVersion: () => Promise<string | undefined> = fetchLatestPublishedVersion
26
+
27
+ /** @internal Test-only. Returns a restore function. */
28
+ export function setLatestVersionFetcher(fetcher: () => Promise<string | undefined>) {
29
+ const previous = fetchLatestVersion
30
+ fetchLatestVersion = fetcher
31
+ return () => {
32
+ fetchLatestVersion = previous
33
+ }
34
+ }
35
+
36
+ /**
37
+ * '^' + the latest published @wular/pnext, for a project this CLI generates. Never throws and never
38
+ * blocks for long — any failure falls back to the running version. Warns when the CLI itself is
39
+ * stale, which is the symptom the user actually has to fix.
40
+ */
41
+ export async function latestPnextVersionRange(command: 'create' | 'migrate') {
42
+ const running = pnextVersionRange().slice(1)
43
+ const latest = await fetchLatestVersion().catch(() => undefined)
44
+ if (!latest) return `^${running}`
45
+ if (isNewer(latest, running)) warnStaleCli(command, running, latest)
46
+ return `^${latest}`
47
+ }
48
+
49
+ function warnStaleCli(command: string, running: string, latest: string) {
50
+ console.error(
51
+ `\n${bold('⚠ Your pnext CLI is out of date')} — running ${running}, latest is ${latest}.\n` +
52
+ dim(
53
+ ` bunx reused a cached copy. This project depends on ^${latest}, but ${command} ran with ${running}.\n`,
54
+ ) +
55
+ ` Re-run with: ${cyan(`bunx @wular/pnext@latest ${command}`)}\n`,
56
+ )
57
+ }
58
+
59
+ /** Numeric major.minor.patch only; anything unparseable counts as "not newer". */
60
+ function isNewer(candidate: string, current: string) {
61
+ const parts = (value: string) => value.split('-')[0]!.split('.').map(Number)
62
+ const [a, b] = [parts(candidate), parts(current)]
63
+ for (let index = 0; index < 3; index += 1) {
64
+ const left = a[index] ?? 0
65
+ const right = b[index] ?? 0
66
+ if (!Number.isFinite(left) || !Number.isFinite(right)) return false
67
+ if (left !== right) return left > right
68
+ }
69
+ return false
70
+ }
@@ -109,6 +109,11 @@ function encodeContainer(value: object, ancestors: Map<object, string[]>, path:
109
109
  // Errors reach `error.tsx` as props and ride the same marker convention (the
110
110
  // encoder for this one lives on the render side, with the boundary props).
111
111
  const ERROR_MARKER = '$$pnextError'
112
+ // error.tsx's `reset`/`unstable_retry` are FUNCTIONS the framework supplies, so
113
+ // they cross as this marker (renderer serializeConventionProps) and come back as
114
+ // a re-run of the route. Compat's action runtime revives it first and identically;
115
+ // core has no action runtime, so the reviver below is the one that runs.
116
+ const ERROR_RESET_MARKER = '$$pnextErrorReset'
112
117
 
113
118
  /**
114
119
  * Revive the wire markers `serializeProps` wrote, IN PLACE:
@@ -132,7 +137,8 @@ export function reviveSerializedRefs<T>(
132
137
  const item = node[key]
133
138
  if (item === null || typeof item !== 'object' || ArrayBuffer.isView(item)) continue
134
139
  const child = item as Record<string, unknown>
135
- const revived = refTarget(root, child) ?? extra?.(child) ?? binaryTarget(child)
140
+ const revived =
141
+ refTarget(root, child) ?? extra?.(child) ?? binaryTarget(child) ?? errorResetTarget(child)
136
142
  if (revived !== undefined) {
137
143
  node[key] = revived
138
144
  continue
@@ -154,6 +160,14 @@ export function reviveSerializedErrorRefs<T>(root: T): T {
154
160
  return reviveSerializedRefs(root, errorTarget)
155
161
  }
156
162
 
163
+ function errorResetTarget(node: Record<string, unknown>): (() => void) | undefined {
164
+ return node[ERROR_RESET_MARKER] === true ? reloadRoute : undefined
165
+ }
166
+
167
+ function reloadRoute(): void {
168
+ location.reload()
169
+ }
170
+
157
171
  function errorTarget(node: Record<string, unknown>): Error | undefined {
158
172
  const marker = node[ERROR_MARKER]
159
173
  if (marker === null || typeof marker !== 'object') return undefined
@@ -217,28 +231,73 @@ export function hasPromiseProps(raw: string) {
217
231
  * Nested containers are rewritten in place (the client just parsed them; the server already
218
232
  * serialized the wire bytes before this runs), the root is not.
219
233
  */
220
- export function revivePromiseMarkers<T>(props: T): T {
221
- return reviveMarkers(props, new Set()) as T
234
+ export function revivePromiseMarkers<T>(props: T, sync?: SyncPropsRebuild): T {
235
+ return reviveMarkers(props, new Set(), sync) as T
222
236
  }
223
237
 
224
- function reviveMarkers(value: unknown, seen: Set<object>): unknown {
238
+ /**
239
+ * Rebuild the legacy sync surface (`withSyncProps`) on a revived promise. The wire carries only the
240
+ * resolved value, so a marker revives into a bare promise and every unawaited `params.creator` read
241
+ * a Next-14-era component makes returns undefined - on the island SSR pass as well as on the client.
242
+ */
243
+ export type SyncPropsRebuild = <T extends object>(
244
+ promise: Promise<T>,
245
+ kind: 'params' | 'searchParams',
246
+ value: T,
247
+ ) => Promise<T>
248
+
249
+ /** Exactly the props Next gives the transitional sync surface - the same scope as the server's. */
250
+ const SYNC_PROP_KEYS = new Set(['params', 'searchParams'])
251
+
252
+ function reviveMarkers(
253
+ value: unknown,
254
+ seen: Set<object>,
255
+ sync?: SyncPropsRebuild,
256
+ key?: string,
257
+ ): unknown {
225
258
  if (value === null || typeof value !== 'object' || seen.has(value)) return value
226
259
  if (PROMISE_MARKER_KEY in value) {
227
- return fulfilledPromise((value as Record<string, unknown>)[PROMISE_MARKER_KEY])
260
+ const resolved = (value as Record<string, unknown>)[PROMISE_MARKER_KEY]
261
+ const promise = fulfilledPromise(resolved)
262
+ if (!sync || !key || !SYNC_PROP_KEYS.has(key)) return promise
263
+ if (resolved === null || typeof resolved !== 'object') return promise
264
+ return sync(promise as Promise<object>, key as 'params' | 'searchParams', resolved)
228
265
  }
229
266
  seen.add(value)
230
267
  if (Array.isArray(value)) {
231
268
  for (let index = 0; index < value.length; index++) {
232
- value[index] = reviveMarkers(value[index], seen)
269
+ value[index] = reviveMarkers(value[index], seen, sync)
233
270
  }
234
271
  return value
235
272
  }
236
273
  if (Object.getPrototypeOf(value) !== Object.prototype) return value
237
274
  const target = value as Record<string, unknown>
238
- for (const key of Object.keys(target)) target[key] = reviveMarkers(target[key], seen)
275
+ for (const name of Object.keys(target)) {
276
+ target[name] = reviveMarkers(target[name], seen, sync, name)
277
+ }
239
278
  return value
240
279
  }
241
280
 
281
+ /**
282
+ * Client half of `withSyncProps`: give the revived promise the resolved object's own keys, skipping
283
+ * the ones the promise already answers for (then/status/value/...), exactly as the server does.
284
+ */
285
+ export function rebuildSyncProps<T extends object>(
286
+ promise: Promise<T>,
287
+ _kind: 'params' | 'searchParams',
288
+ value: T,
289
+ ): Promise<T> {
290
+ for (const key of Object.keys(value)) {
291
+ if (key in promise) continue
292
+ void Object.defineProperty(promise, key, {
293
+ get: () => (value as Record<string, unknown>)[key],
294
+ enumerable: true,
295
+ configurable: true,
296
+ })
297
+ }
298
+ return promise
299
+ }
300
+
242
301
  function fulfilledPromise(value: unknown) {
243
302
  const promise = Promise.resolve(value) as Promise<unknown> & { status: string; value: unknown }
244
303
  promise.status = 'fulfilled'