@thomas.f/prnext 0.1.17-alpha → 0.1.19-alpha

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 (202) hide show
  1. package/README.md +27 -868
  2. package/build/actions.mjs +27 -15
  3. package/build/analyze.mjs +180 -24
  4. package/build/app-config.mjs +13 -0
  5. package/build/app-static.mjs +34 -12
  6. package/build/app-styles.mjs +79 -0
  7. package/build/app.mjs +122 -46
  8. package/build/bundle-runtime.mjs +22 -9
  9. package/build/cache-components.mjs +1 -1
  10. package/build/cache-handlers.mjs +1 -1
  11. package/build/compact-css.mjs +53 -0
  12. package/build/compilation-cache.mjs +269 -0
  13. package/build/compilation-store.mjs +142 -0
  14. package/build/compiler.mjs +34 -10
  15. package/build/config.mjs +34 -3
  16. package/build/create.mjs +239 -0
  17. package/build/css-modules.mjs +56 -12
  18. package/build/deployment-assets.mjs +89 -0
  19. package/build/deployment-config.mjs +18 -0
  20. package/build/dev-css-assets.mjs +173 -0
  21. package/build/dev-css-update.mjs +18 -14
  22. package/build/dev-dependencies.mjs +1 -1
  23. package/build/dev-discovery.mjs +3 -3
  24. package/build/dev-lazy-build.mjs +77 -0
  25. package/build/dev.mjs +15 -8
  26. package/build/dynamic.mjs +14 -7
  27. package/build/edge.mjs +3 -3
  28. package/build/fonts.mjs +1 -1
  29. package/build/framework-imports.mjs +15 -5
  30. package/build/html-only-config.mjs +53 -0
  31. package/build/html-only-graph.mjs +67 -0
  32. package/build/incremental.mjs +112 -18
  33. package/build/index.mjs +90 -28
  34. package/build/instrumentation.mjs +30 -0
  35. package/build/lazy-dynamic-imports.mjs +137 -0
  36. package/build/metadata-routes.mjs +1 -1
  37. package/build/migrate.mjs +4 -4
  38. package/build/migration-audit.mjs +19 -31
  39. package/build/migration-imports.mjs +245 -0
  40. package/build/migration-scenarios-runner.mjs +73 -0
  41. package/build/migration-scenarios.mjs +72 -0
  42. package/build/native-discovery.mjs +65 -5
  43. package/build/native-packages.mjs +2 -0
  44. package/build/native-type-config.mjs +37 -0
  45. package/build/npm-packages.mjs +1 -0
  46. package/build/persistent-cache.mjs +18 -3
  47. package/build/precompress.mjs +1 -1
  48. package/build/prerender-cache.mjs +70 -0
  49. package/build/prerender-config.mjs +29 -0
  50. package/build/prerender-pages.mjs +19 -9
  51. package/build/prerender-pool.mjs +165 -0
  52. package/build/prerender-process-worker.mjs +58 -0
  53. package/build/prerender-worker.mjs +5 -5
  54. package/build/production-builder.mjs +59 -10
  55. package/build/react-compiler-report.mjs +225 -0
  56. package/build/react-compiler.mjs +139 -0
  57. package/build/runtime-contract.mjs +2 -2
  58. package/build/standalone.mjs +21 -3
  59. package/build/static-export.mjs +4 -4
  60. package/build/styles.mjs +6 -4
  61. package/build/transform.mjs +1 -1
  62. package/build/typed-routes.mjs +4 -0
  63. package/build/typescript-dev.mjs +35 -8
  64. package/build/typescript-native-worker.mjs +55 -0
  65. package/build/typescript-native.mjs +18 -0
  66. package/build/typescript-toolchain.mjs +19 -0
  67. package/build/typescript.mjs +6 -4
  68. package/build/webpack-graph.mjs +19 -6
  69. package/build/worker-memory.mjs +9 -0
  70. package/cli.mjs +70 -5
  71. package/compat/activity-cache.cjs +26 -0
  72. package/compat/after.cjs +6 -4
  73. package/compat/app-dynamic.cjs +12 -1
  74. package/compat/app-layout-context.cjs +52 -2
  75. package/compat/base64.cjs +10 -0
  76. package/compat/cache-handlers.cjs +15 -3
  77. package/compat/cache-wire.cjs +28 -0
  78. package/compat/cache.cjs +2 -1
  79. package/compat/cache.d.cts +4 -0
  80. package/compat/cookies.cjs +21 -5
  81. package/compat/data-cache.cjs +12 -8
  82. package/compat/flight-delta.cjs +152 -0
  83. package/compat/headers.cjs +4 -2
  84. package/compat/incremental-cache.cjs +20 -7
  85. package/compat/index.d.ts +69 -4
  86. package/compat/link.cjs +13 -10
  87. package/compat/navigation-authorization.cjs +9 -0
  88. package/compat/navigation-stages.cjs +19 -0
  89. package/compat/offline-store.cjs +11 -0
  90. package/compat/offline.cjs +5 -0
  91. package/compat/offline.d.cts +2 -0
  92. package/compat/redis-cache.cjs +201 -0
  93. package/compat/redis-cache.d.cts +37 -0
  94. package/compat/server-primitives.cjs +31 -11
  95. package/compat/server.d.cts +15 -2
  96. package/compat/static-generation.cjs +7 -0
  97. package/compat/use-cache.cjs +3 -0
  98. package/docs/ai/compatibility.md +248 -2
  99. package/docs/ai/data.md +165 -5
  100. package/docs/ai/deployment.md +158 -0
  101. package/docs/ai/quickstart.md +29 -4
  102. package/docs/ai/routing.md +93 -0
  103. package/docs/ai/troubleshooting.md +116 -2
  104. package/package.json +40 -9
  105. package/runtime/action-client.mjs +10 -4
  106. package/runtime/action-server.mjs +2 -0
  107. package/runtime/app-client.mjs +316 -92
  108. package/runtime/app-content.mjs +4 -3
  109. package/runtime/app-partial-client.mjs +3 -0
  110. package/runtime/app-partial.mjs +1 -1
  111. package/runtime/app-prefetch-constants.mjs +4 -0
  112. package/runtime/app-prefetch.mjs +2 -3
  113. package/runtime/app-render-key.mjs +5 -0
  114. package/runtime/app-render.mjs +88 -28
  115. package/runtime/app-routing.mjs +22 -9
  116. package/runtime/app-static.mjs +18 -8
  117. package/runtime/app-stream-html.mjs +10 -53
  118. package/runtime/app-style-selection.mjs +34 -0
  119. package/runtime/app-styles.mjs +154 -0
  120. package/runtime/atomic-file.mjs +27 -0
  121. package/runtime/cache-components-rsc.mjs +7 -2
  122. package/runtime/cache-request.mjs +2 -1
  123. package/runtime/deployment-client.mjs +19 -0
  124. package/runtime/dev-client.mjs +13 -3
  125. package/runtime/dev-compiler-broker.mjs +42 -0
  126. package/runtime/dev-file-changes.mjs +123 -8
  127. package/runtime/dev-indicator.mjs +8 -3
  128. package/runtime/dev-lazy-imports.mjs +121 -0
  129. package/runtime/dev-lazy-staging.mjs +14 -0
  130. package/runtime/dev-lazy-tool.mjs +16 -0
  131. package/runtime/dev-style-retention.mjs +15 -0
  132. package/runtime/development.mjs +83 -25
  133. package/runtime/document.mjs +1 -1
  134. package/runtime/edge.mjs +6 -5
  135. package/runtime/fetch-cache.mjs +10 -8
  136. package/runtime/flight-delta.mjs +74 -0
  137. package/runtime/flight-tap.mjs +28 -0
  138. package/runtime/fs.mjs +15 -8
  139. package/runtime/html-only.mjs +27 -0
  140. package/runtime/http.mjs +2 -0
  141. package/runtime/incremental-cache.mjs +25 -4
  142. package/runtime/instrumentation.mjs +49 -0
  143. package/runtime/lazy-import-client.mjs +38 -0
  144. package/runtime/navigation-recovery.mjs +36 -0
  145. package/runtime/navigation.mjs +7 -0
  146. package/runtime/node-html-stream.mjs +47 -0
  147. package/runtime/pages-client.mjs +14 -7
  148. package/runtime/pages-navigation.mjs +48 -63
  149. package/runtime/prefetch-scheduler.mjs +110 -0
  150. package/runtime/profile.mjs +14 -0
  151. package/runtime/profiles.json +2 -2
  152. package/runtime/render.mjs +3 -0
  153. package/runtime/route-pattern.mjs +44 -0
  154. package/runtime/rsc-worker.mjs +34 -12
  155. package/runtime/trace.mjs +78 -0
  156. package/runtime/watch-project.mjs +19 -15
  157. package/runtime/worker.mjs +70 -26
  158. package/vendor/nft/LICENSE +7 -0
  159. package/vendor/nft/README.md +16 -0
  160. package/vendor/nft/out/analyze.d.ts +8 -0
  161. package/vendor/nft/out/analyze.js +1216 -0
  162. package/vendor/nft/out/analyze.js.map +1 -0
  163. package/vendor/nft/out/cli.d.ts +2 -0
  164. package/vendor/nft/out/cli.js +94 -0
  165. package/vendor/nft/out/cli.js.map +1 -0
  166. package/vendor/nft/out/fs.d.ts +22 -0
  167. package/vendor/nft/out/fs.js +106 -0
  168. package/vendor/nft/out/fs.js.map +1 -0
  169. package/vendor/nft/out/index.d.ts +4 -0
  170. package/vendor/nft/out/index.js +25 -0
  171. package/vendor/nft/out/index.js.map +1 -0
  172. package/vendor/nft/out/node-file-trace.d.ts +41 -0
  173. package/vendor/nft/out/node-file-trace.js +380 -0
  174. package/vendor/nft/out/node-file-trace.js.map +1 -0
  175. package/vendor/nft/out/resolve-dependency.d.ts +6 -0
  176. package/vendor/nft/out/resolve-dependency.js +427 -0
  177. package/vendor/nft/out/resolve-dependency.js.map +1 -0
  178. package/vendor/nft/out/types.d.ts +66 -0
  179. package/vendor/nft/out/types.js +2 -0
  180. package/vendor/nft/out/types.js.map +1 -0
  181. package/vendor/nft/out/utils/ast-helpers.js +49 -0
  182. package/vendor/nft/out/utils/ast-helpers.js.map +1 -0
  183. package/vendor/nft/out/utils/binary-locators.js +88 -0
  184. package/vendor/nft/out/utils/binary-locators.js.map +1 -0
  185. package/vendor/nft/out/utils/get-package-base.js +30 -0
  186. package/vendor/nft/out/utils/get-package-base.js.map +1 -0
  187. package/vendor/nft/out/utils/interop-require.js +24 -0
  188. package/vendor/nft/out/utils/interop-require.js.map +1 -0
  189. package/vendor/nft/out/utils/node-version.js +10 -0
  190. package/vendor/nft/out/utils/node-version.js.map +1 -0
  191. package/vendor/nft/out/utils/sharedlib-emit.js +33 -0
  192. package/vendor/nft/out/utils/sharedlib-emit.js.map +1 -0
  193. package/vendor/nft/out/utils/special-cases.js +347 -0
  194. package/vendor/nft/out/utils/special-cases.js.map +1 -0
  195. package/vendor/nft/out/utils/static-eval.js +579 -0
  196. package/vendor/nft/out/utils/static-eval.js.map +1 -0
  197. package/vendor/nft/out/utils/types.js +2 -0
  198. package/vendor/nft/out/utils/types.js.map +1 -0
  199. package/vendor/nft/out/utils/wrappers.js +659 -0
  200. package/vendor/nft/out/utils/wrappers.js.map +1 -0
  201. package/vendor/nft/package.json +4 -0
  202. package/vendor/nft/upstream.json +51 -0
@@ -1,8 +1,53 @@
1
1
  'use client';
2
2
  'use strict';
3
3
  const React = require('react');
4
+ const { AppRouterContext } = require('./app-context.cjs');
4
5
  const LayoutContext = React.createContext({ children: [] });
5
6
  const LayoutCacheContext = React.createContext(null);
7
+ const ActivityCacheContext = React.createContext(null);
8
+
9
+ function ActivitySlot({ identity, children, authorizationKey }) {
10
+ const retention = React.useContext(ActivityCacheContext);
11
+ authorizationKey ??= retention?.authorizationKey;
12
+ const router = React.useContext(AppRouterContext);
13
+ const epoch = retention?.epoch ?? 0;
14
+ const authorizationEpoch = retention?.authorizationEpoch ?? 0;
15
+ const registrations = React.useRef(new Set());
16
+ const serial = React.useRef(0);
17
+ const [saved, setSaved] = React.useState(() => ({ epoch, authorizationEpoch, active: identity, entries: new Map() }));
18
+ let entries = saved.entries;
19
+ const current = entries.get(identity);
20
+ if (saved.epoch !== epoch || saved.authorizationEpoch !== authorizationEpoch || current?.children !== children || current?.router !== router || current?.authorizationKey !== authorizationKey) {
21
+ const sameAuthorization = saved.authorizationEpoch === authorizationEpoch && !(current?.authorizationKey && authorizationKey && current.authorizationKey !== authorizationKey);
22
+ entries = saved.epoch === epoch && sameAuthorization ? new Map(entries) : new Map();
23
+ const preserve = current && saved.authorizationEpoch === authorizationEpoch && (sameAuthorization && (saved.epoch === epoch || saved.active === identity)
24
+ || !retention?.strictAuthorization && saved.active === identity);
25
+ entries.set(identity, { children, router, authorizationKey, token: preserve ? current.token : {}, reactKey: preserve ? current.reactKey : ++serial.current });
26
+ setSaved({ epoch, authorizationEpoch, active: identity, entries });
27
+ }
28
+ React.useLayoutEffect(() => {
29
+ const cache = retention?.cache;
30
+ if (!cache) return;
31
+ const inactive = new Set([...entries].filter(([key]) => key !== identity).map(([, entry]) => entry.token));
32
+ for (const token of registrations.current) if (!inactive.has(token)) cache.release(token);
33
+ for (const [key, entry] of entries) {
34
+ if (key === identity) cache.release(entry.token);
35
+ else cache.retain(entry.token, () => setSaved(previous => {
36
+ if (!previous.entries.has(key)) return previous;
37
+ const next = new Map(previous.entries); next.delete(key); return { ...previous, entries: next };
38
+ }));
39
+ }
40
+ registrations.current = inactive;
41
+ }, [entries, identity, retention?.cache]);
42
+ React.useLayoutEffect(() => () => {
43
+ for (const token of registrations.current) retention?.cache.release(token);
44
+ registrations.current.clear();
45
+ }, [retention?.cache]);
46
+ // Identical boundary structure during SSR and hydration. Hidden branches get
47
+ // their original router context and their effects are disconnected by React.
48
+ return [...entries].map(([key, entry]) => React.createElement(React.Activity, { key: entry.reactKey, mode: key === identity ? 'visible' : 'hidden' },
49
+ React.createElement(AppRouterContext.Provider, { value: key === identity ? router : entry.router }, entry.children)));
50
+ }
6
51
 
7
52
  function LayoutProvider({ id, segments, children }) {
8
53
  const cache = React.useContext(LayoutCacheContext);
@@ -13,15 +58,20 @@ function LayoutProvider({ id, segments, children }) {
13
58
  return React.createElement(LayoutContext.Provider, { value: current }, children);
14
59
  }
15
60
 
16
- function LayoutSlot({ id, preserve, children }) {
61
+ function LayoutSlot({ id, preserve, children, activityKey, activityPreserve, authorizationKey }) {
17
62
  const cache = React.useContext(LayoutCacheContext);
18
63
  const previous = React.useRef(children);
19
64
  const current = preserve && cache && Object.hasOwn(cache.slots, id) ? cache.slots[id] : preserve ? previous.current : children;
20
65
  React.useLayoutEffect(() => { previous.current = current; if (cache && id) cache.slots[id] = current; }, [cache, id, current]);
21
- return current ?? null;
66
+ const previousKey = React.useRef(activityKey);
67
+ const identity = preserve || activityPreserve ? previousKey.current : activityKey;
68
+ React.useLayoutEffect(() => { previousKey.current = identity; }, [identity]);
69
+ return activityKey === undefined ? current ?? null : React.createElement(ActivitySlot, { identity, authorizationKey }, current);
22
70
  }
23
71
 
24
72
  module.exports.LayoutContext = LayoutContext;
25
73
  module.exports.LayoutCacheContext = LayoutCacheContext;
26
74
  module.exports.LayoutProvider = LayoutProvider;
27
75
  module.exports.LayoutSlot = LayoutSlot;
76
+ module.exports.ActivitySlot = ActivitySlot;
77
+ module.exports.ActivityCacheContext = ActivityCacheContext;
@@ -0,0 +1,10 @@
1
+ 'use strict';
2
+ // Native decoding avoids a JavaScript callback for each Flight byte. The loop
3
+ // remains available on browsers that do not implement Uint8Array.fromBase64.
4
+ function decodeBase64(value) {
5
+ if(typeof Uint8Array.fromBase64==='function')return Uint8Array.fromBase64(value);
6
+ const binary=atob(value),bytes=new Uint8Array(binary.length);
7
+ for(let n=0;n<binary.length;n++)bytes[n]=binary.charCodeAt(n);
8
+ return bytes;
9
+ }
10
+ module.exports={decodeBase64};
@@ -80,7 +80,9 @@ async function read(entry, context) {
80
80
  if (size > MAX_BYTES) throw new Error('CacheEntry exceeds 2 MiB');
81
81
  chunks.push(Buffer.from(value));
82
82
  }
83
- return Buffer.concat(chunks, size);
83
+ // Buffer.from above owns each chunk even when a custom producer reuses its
84
+ // Uint8Array on the next pull. Keep that copy, but do not copy it twice.
85
+ return chunks.length===1?chunks[0]:Buffer.concat(chunks, size);
84
86
  } finally {
85
87
  if (!complete) void reader.cancel().catch(() => {});
86
88
  reader.releaseLock();
@@ -139,10 +141,19 @@ async function customCachedValue(key, kind, producer, options) {
139
141
  const timestamp = Date.now();
140
142
  const record = { revision, timestamp, work: undefined, metadata: undefined };
141
143
  work = (async () => {
144
+ let lease,canStore=true;
145
+ try {
146
+ if(typeof handler.acquire==='function'){
147
+ try{
148
+ lease=await call(handler,'acquire',[key,{signal:context.signal}],context);
149
+ const shared=await call(handler,'get',[key,tags],context);
150
+ if(shared){const sharedInfo=metadata(shared);if(Date.now()-sharedInfo.timestamp<Math.min(sharedInfo.expire,sharedInfo.revalidate)*1000){record.metadata=sharedInfo;options.onReadMetadata?.(sharedInfo);return await read(shared,context);}void shared.value.cancel().catch(()=>{});}
151
+ }catch{context.signal?.throwIfAborted();canStore=false;}
152
+ }
142
153
  const result = await producer();
143
154
  const info = resolveMetadata();
144
155
  record.metadata = info;
145
- if (result.byteLength <= MAX_BYTES && revision === (revisions.get(handler) || 0)) {
156
+ if (canStore && result.byteLength <= MAX_BYTES && revision === (revisions.get(handler) || 0)) {
146
157
  try {
147
158
  const changed = await expiration(handler, [...new Set([...tags, ...info.tags])], context);
148
159
  // Many backends record Date.now() millisecond timestamps. Treat the
@@ -150,12 +161,13 @@ async function customCachedValue(key, kind, producer, options) {
150
161
  // hide an invalidation which actually happened after the read started.
151
162
  if ((changed === Infinity || changed < Math.floor(timestamp)) && revision === (revisions.get(handler) || 0)) {
152
163
  const value = stream(result);
153
- try { await call(handler, 'set', [key, Promise.resolve({ value, tags: info.tags, stale: info.stale, revalidate: info.revalidate, expire: info.expire, timestamp })], context); }
164
+ try { await call(handler, 'set', [key, Promise.resolve({ value, tags: info.tags, stale: info.stale, revalidate: info.revalidate, expire: info.expire, timestamp }),{lease,softTags:tags}], context); }
154
165
  catch { if (!value.locked) void value.cancel().catch(() => {}); }
155
166
  }
156
167
  } catch { context.signal?.throwIfAborted(); }
157
168
  }
158
169
  return result;
170
+ }finally{try{await lease?.release();}catch{/* A failed cache cleanup does not replay application work. */}}
159
171
  })();
160
172
  record.work = work;
161
173
  if (pendingCount < 64) {
@@ -0,0 +1,28 @@
1
+ 'use strict';
2
+ // Versioned private framing: bounded JSON metadata followed by uninterpreted bytes.
3
+ const TYPE = 'application/vnd.prnext.cache-v2';
4
+ const MAX_VALUE = 2 * 1024 * 1024;
5
+ const MAX_META = 2 * 1024 * 1024;
6
+ function encode(operation) {
7
+ const { value, ...metadata } = operation;
8
+ if (value !== undefined && !Buffer.isBuffer(value) && !(value instanceof Uint8Array)) throw new TypeError('Cache wire values must be bytes');
9
+ const bytes = value === undefined ? undefined : Buffer.isBuffer(value) ? value : Buffer.from(value.buffer, value.byteOffset, value.byteLength);
10
+ if (bytes && (metadata.op !== 'commit' || bytes.length > MAX_VALUE)) throw new Error('Invalid cache wire value');
11
+ if (metadata.op === 'commit' && !bytes) throw new Error('Cache commit requires bytes');
12
+ const json = Buffer.from(JSON.stringify(metadata));
13
+ if (json.length > MAX_META) throw new Error('Cache wire metadata exceeds limit');
14
+ const prefix = Buffer.allocUnsafe(4); prefix.writeUInt32BE(json.length);
15
+ return bytes ? [prefix, json, bytes] : [prefix, json];
16
+ }
17
+ function decode(bytes) {
18
+ if (bytes.length < 4) throw new Error('Truncated cache wire frame');
19
+ const length = bytes.readUInt32BE(0);
20
+ if (length > MAX_META || length > bytes.length - 4) throw new Error('Invalid cache wire metadata length');
21
+ const value = JSON.parse(bytes.subarray(4, 4 + length).toString('utf8'));
22
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid cache wire metadata');
23
+ const raw = bytes.subarray(4 + length);
24
+ if (raw.length > MAX_VALUE || (value.value !== true && raw.length) || (value.value !== undefined && value.value !== true)) throw new Error('Invalid cache wire payload');
25
+ if (value.value === true) value.value = raw;
26
+ return value;
27
+ }
28
+ module.exports = { TYPE, MAX_VALUE, MAX_META, encode, decode };
package/compat/cache.cjs CHANGED
@@ -96,4 +96,5 @@ function unstable_noStore() {
96
96
  }
97
97
  function refresh() { cacheState(mutationContext('refresh', true)).refresh = true; }
98
98
 
99
- module.exports = { unstable_cache, revalidateTag, updateTag, revalidatePath, unstable_noStore, refresh, cacheTag, cacheLife };
99
+ const { navigation, prefetch } = require('./navigation-stages.cjs');
100
+ module.exports = { unstable_cache, revalidateTag, updateTag, revalidatePath, unstable_noStore, refresh, cacheTag, cacheLife, navigation, prefetch };
@@ -34,3 +34,7 @@ export function updateTag(tag: string): void;
34
34
  export function revalidatePath(path: string, type?: 'page' | 'layout'): void;
35
35
  export function unstable_noStore(): void;
36
36
  export function refresh(): void;
37
+ /** Defer runtime work until navigation, while allowing static prerenders. */
38
+ export function navigation(): Promise<void>;
39
+ /** Defer runtime work until a per-link prefetch or navigation. */
40
+ export function prefetch(): Promise<void>;
@@ -1,13 +1,13 @@
1
1
  'use strict';
2
2
 
3
- function parseCookieHeader(value = '') {
3
+ function parseCookieHeader(value = '', { ignoreMalformed = false } = {}) {
4
4
  const values = new Map();
5
5
  for (const part of String(value).split(';')) {
6
6
  const separator = part.indexOf('=');
7
7
  if (separator < 1) continue;
8
8
  const name = part.slice(0, separator).trim();
9
9
  let value = part.slice(separator + 1).trim();
10
- try { value = decodeURIComponent(value); } catch { /* Preserve malformed user input. */ }
10
+ try { value = decodeURIComponent(value); } catch { if (ignoreMalformed) continue; }
11
11
  values.set(name, { name, value });
12
12
  }
13
13
  return values;
@@ -50,7 +50,7 @@ function serializeCookie(cookie) {
50
50
 
51
51
  function parseResponseCookie(header) {
52
52
  const [pair, ...attributes] = header.split(';');
53
- const cookie = parseCookieHeader(pair).values().next().value;
53
+ const cookie = parseCookieHeader(pair, { ignoreMalformed: true }).values().next().value;
54
54
  if (!cookie) return;
55
55
  for (const attribute of attributes) {
56
56
  const separator = attribute.indexOf('=');
@@ -71,7 +71,7 @@ function parseResponseCookie(header) {
71
71
 
72
72
  class CookieStore {
73
73
  constructor(header, { mutable = false, onChange } = {}) {
74
- this._cookies = parseCookieHeader(header);
74
+ this._cookies = parseCookieHeader(header, { ignoreMalformed: true });
75
75
  this._mutable = mutable;
76
76
  this._onChange = onChange;
77
77
  }
@@ -99,6 +99,22 @@ class CookieStore {
99
99
  *[Symbol.iterator]() { for (const [name, cookie] of this._cookies) yield [name, { ...cookie }]; }
100
100
  }
101
101
 
102
+ class RequestCookieStore extends CookieStore {
103
+ constructor(headers) {
104
+ super(headers.get('cookie') || '', { mutable: true, onChange: () => headers.set('cookie', this.toString()) });
105
+ }
106
+ set(name, value) {
107
+ const cookie = typeof name === 'object' ? { name: name.name, value: name.value } : { name, value };
108
+ return super.set(cookie);
109
+ }
110
+ delete(names) {
111
+ const result = Array.isArray(names) ? names.map(name => this._cookies.delete(name)) : this._cookies.delete(names);
112
+ this._onChange?.();
113
+ return result;
114
+ }
115
+ clear() { this.delete([...this._cookies.keys()]); return this; }
116
+ }
117
+
102
118
  class ResponseCookieStore extends CookieStore {
103
119
  constructor(headers) {
104
120
  super('', { mutable: true });
@@ -126,4 +142,4 @@ class ResponseCookieStore extends CookieStore {
126
142
  toString() { return [...this._cookies.values()].map(serializeCookie).join('; '); }
127
143
  }
128
144
 
129
- module.exports = { CookieStore, ResponseCookieStore, parseCookieHeader, serializeCookie };
145
+ module.exports = { CookieStore, RequestCookieStore, ResponseCookieStore, parseCookieHeader, serializeCookie };
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
  const { AsyncLocalStorage, AsyncResource } = require('node:async_hooks');
3
3
  const http = require('node:http');
4
+ const cacheWire = require('./cache-wire.cjs');
4
5
  const { setTimeout: delay } = require('node:timers/promises');
5
6
  const { trackStaticDependency } = require('./static-generation.cjs');
6
7
  const diagnostics = require('./diagnostics.cjs');
@@ -21,7 +22,8 @@ const rpcScope = new AsyncResource('prnext-cache-transport');
21
22
  // idle sockets churns ephemeral ports under concurrent PPR traffic. Idle
22
23
  // connections expire after 30 seconds and retain no request context.
23
24
  const rpcAgent = new http.Agent({ keepAlive: true, maxSockets: 32, maxTotalSockets: 32, maxFreeSockets: 32, timeout: 30_000 });
24
- function localRpc(target, body, signal) {
25
+ function localRpc(target, parts, signal, binary) {
26
+ const length = parts.reduce((sum,part)=>sum+Buffer.byteLength(part),0);
25
27
  return rpcScope.runInAsyncScope(() => new Promise((resolve, reject) => {
26
28
  let settled = false;
27
29
  const finish = (error, value) => {
@@ -31,7 +33,7 @@ function localRpc(target, body, signal) {
31
33
  if (error) reject(error); else resolve(value);
32
34
  };
33
35
  const request = http.request(target.url, { method: 'POST', agent: rpcAgent,
34
- headers: { 'content-type': 'application/json', authorization: `Bearer ${target.token}`, 'content-length': Buffer.byteLength(body) },
36
+ headers: { 'content-type': binary ? cacheWire.TYPE : 'application/json', authorization: `Bearer ${target.token}`, 'content-length': length },
35
37
  }, response => {
36
38
  let size = 0;
37
39
  const chunks = [];
@@ -43,14 +45,14 @@ function localRpc(target, body, signal) {
43
45
  });
44
46
  response.on('end', () => {
45
47
  const ok = response.statusCode >= 200 && response.statusCode < 300;
46
- try { finish(null, { ok, status: response.statusCode, value: ok ? JSON.parse(Buffer.concat(chunks, size).toString()) : undefined }); }
48
+ try { finish(null, { ok, status: response.statusCode, value: ok ? (binary ? (response.headers['content-type'] === cacheWire.TYPE ? cacheWire.decode(Buffer.concat(chunks,size)) : (()=>{throw new Error('Cache protocol mismatch')})()) : JSON.parse(Buffer.concat(chunks, size).toString())) : undefined }); }
47
49
  catch (error) { finish(error); }
48
50
  });
49
51
  });
50
52
  const abort = () => request.destroy(signal.reason instanceof Error ? signal.reason : new Error('Cache RPC aborted'));
51
53
  request.on('error', error => finish(error));
52
54
  signal?.addEventListener('abort', abort, { once: true });
53
- if (signal?.aborted) abort(); else request.end(body);
55
+ if (signal?.aborted) abort(); else { request.cork(); for(const part of parts) request.write(part); request.end(); request.uncork(); }
54
56
  }));
55
57
  }
56
58
 
@@ -149,9 +151,10 @@ async function acquireExternalCacheLease(key, signal) {
149
151
  throw new Error('Timed out waiting for a native incremental cache lease');
150
152
  }
151
153
  async function rpc(target, operation, signal) {
152
- const body = JSON.stringify(operation);
153
154
  const url = new URL(target.url);
154
155
  const local = url.protocol === 'http:' && ['127.0.0.1', '[::1]', 'localhost'].includes(url.hostname);
156
+ const binary = local && process.env.PRNEXT_CACHE_PROTOCOL === '2';
157
+ const body = binary ? cacheWire.encode(operation) : [JSON.stringify(operation.value === undefined ? operation : { ...operation, value: bufferValue(operation.value).toString('base64') })];
155
158
  const controller = new AbortController();
156
159
  const timer = setTimeout(() => controller.abort(new DOMException('The operation was aborted due to timeout', 'TimeoutError')), 5000);
157
160
  timer.unref();
@@ -159,10 +162,10 @@ async function rpc(target, operation, signal) {
159
162
  const operationSignal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
160
163
  let retryMs = 10;
161
164
  while (true) {
162
- const response = local ? await localRpc(target, body, operationSignal) : await nativeFetch(target.url, {
165
+ const response = local ? await localRpc(target, body, operationSignal, binary) : await nativeFetch(target.url, {
163
166
  method: 'POST',
164
167
  headers: { 'content-type': 'application/json', authorization: `Bearer ${target.token}` },
165
- body,
168
+ body: body[0],
166
169
  signal: operationSignal,
167
170
  });
168
171
  // The deadline covers the JSON body as well as response headers. A
@@ -198,6 +201,7 @@ function bufferValue(value) {
198
201
  return Buffer.isBuffer(value) ? value : Buffer.from(value.buffer, value.byteOffset, value.byteLength);
199
202
  }
200
203
  function decodeValue(value) {
204
+ if (Buffer.isBuffer(value)) { if(value.length > MAX_CACHE_VALUE_BYTES) throw new Error('Invalid PRNext data cache value'); return value; }
201
205
  if (typeof value !== 'string' || value.length > Math.ceil(MAX_CACHE_VALUE_BYTES / 3) * 4) throw new Error('Invalid PRNext data cache value');
202
206
  const bytes = Buffer.from(value, 'base64');
203
207
  if (bytes.length > MAX_CACHE_VALUE_BYTES) throw new Error('Invalid PRNext data cache value');
@@ -247,7 +251,7 @@ async function compute(target, key, lease, producer, options) {
247
251
  try {
248
252
  const metadata = options.resolveMetadata?.();
249
253
  if (metadata?.externalCache) { await release(target, key, lease); return; }
250
- const result = await rpc(target, { op: 'commit', key, lease, value: bytes.toString('base64'), revalidate: options.revalidate,
254
+ const result = await rpc(target, { op: 'commit', key, lease, value: bytes, revalidate: options.revalidate,
251
255
  ...(metadata ? { tags: validateTags(metadata.tags), paths: metadata.paths || [], revalidate: validateRevalidate(metadata.revalidate, { allowZero: true }), expire: metadata.expire } : {}) }, signal);
252
256
  if (!result.stored) await release(target, key, lease);
253
257
  } catch {
@@ -0,0 +1,152 @@
1
+ 'use strict';
2
+
3
+ const WINDOW = 32, MAX_VALUE = 2 * 1024 * 1024, MAX_RESPONSE = 16 * 1024 * 1024;
4
+ const TYPE = 'application/x-prnext-flight-delta';
5
+ const MAGIC = new Uint8Array([80,82,68,49]);
6
+ const BLOCK_BYTES = 32 * 1024, MAX_COMMANDS_PER_CHUNK = 256, MAX_COMMANDS = 2048;
7
+ let POWER = 1; for(let n=1;n<WINDOW;n++) POWER = Math.imul(POWER,31) >>> 0;
8
+ function hash(bytes,offset) { let result=0; for(let n=0;n<WINDOW;n++) result=(Math.imul(result,31)+bytes[offset+n])>>>0; return result; }
9
+ function createIndex(base) {
10
+ const index=new Map();
11
+ for(let offset=0;offset+WINDOW<=base.length;offset+=WINDOW) {
12
+ const key=hash(base,offset), candidates=index.get(key)||[];
13
+ if(candidates.length<4)candidates.push(offset); index.set(key,candidates);
14
+ }
15
+ return index;
16
+ }
17
+ function frame(tag,a,b) { const value=new Uint8Array(tag===1?9:5), view=new DataView(value.buffer); value[0]=tag;view.setUint32(1,a);if(tag===1)view.setUint32(5,b);return value; }
18
+ function encodeChunk(bytes,base,index) {
19
+ const output=[];let cursor=0,literal=0,key=bytes.length>=WINDOW?hash(bytes,0):0,work=0,commands=0;
20
+ const budget=bytes.length*8+32768;
21
+ const emit=end=>{if(end>literal){commands++;output.push(frame(0,end-literal),bytes.subarray(literal,end));}};
22
+ while(cursor+WINDOW<=bytes.length) {
23
+ if(work>budget||commands>=MAX_COMMANDS_PER_CHUNK)return null;
24
+ let match=-1;
25
+ for(const offset of index.get(key)||[]) {let n=0;while(n<WINDOW&&bytes[cursor+n]===base[offset+n]){n++;work++;}if(n===WINDOW){match=offset;break;}}
26
+ if(match!==-1) {
27
+ emit(cursor);let length=WINDOW;
28
+ while(cursor+length<bytes.length&&match+length<base.length&&bytes[cursor+length]===base[match+length]){length++;work++;}
29
+ commands++;output.push(frame(1,match,length));cursor+=length;literal=cursor;
30
+ if(cursor+WINDOW<=bytes.length)key=hash(bytes,cursor);
31
+ } else {
32
+ key=(Math.imul((key-Math.imul(bytes[cursor],POWER))>>>0,31)+bytes[cursor+WINDOW])>>>0;cursor++;
33
+ }
34
+ }
35
+ emit(bytes.length);if(commands>MAX_COMMANDS_PER_CHUNK)return null;output.commands=commands;return output;
36
+ }
37
+ function join(parts,size) { const result=new Uint8Array(size);let offset=0;for(const part of parts){result.set(part,offset);offset+=part.length;}return result; }
38
+ // Commands are a wire format, not scheduling units. Thousands of tiny writes
39
+ // defeat gzip and multiply worker IPC/socket work even when the raw delta saves
40
+ // bytes. Bound each outgoing block without reading ahead into a delayed chunk.
41
+ function blocks(parts) {
42
+ const output=[];let pending=[],size=0;
43
+ const flush=()=>{if(size)output.push(pending.length===1?pending[0]:join(pending,size));pending=[];size=0;};
44
+ for(const part of parts)for(let offset=0;offset<part.length;){const n=Math.min(BLOCK_BYTES-size,part.length-offset);pending.push(part.subarray(offset,offset+n));size+=n;offset+=n;if(size===BLOCK_BYTES)flush();}
45
+ flush();return output;
46
+ }
47
+ function collectStream(source,{onComplete,onCancel,onFinally,capture=true}={}) {
48
+ const reader=source.getReader(), parts=[];let size=0,complete=false,finished=false;
49
+ const finish=()=>{if(!finished){finished=true;onFinally?.();}};
50
+ return new ReadableStream({
51
+ async pull(controller) {try{const next=await reader.read();if(next.done){complete=true;if(capture&&size<=MAX_VALUE)onComplete?.(join(parts,size));parts.length=0;finish();controller.close();return;}if(!(next.value instanceof Uint8Array))throw new Error('Invalid Flight bytes');size+=next.value.length;if(size>MAX_RESPONSE)throw new Error('Flight exceeds transport budget');if(capture&&size<=MAX_VALUE)parts.push(next.value.slice());else parts.length=0;controller.enqueue(next.value);}catch(error){parts.length=0;finish();controller.error(error);await reader.cancel(error).catch(()=>{});}},
52
+ async cancel(reason){parts.length=0;finish();if(!complete)onCancel?.();await reader.cancel(reason);},
53
+ },{highWaterMark:0});
54
+ }
55
+ async function deltaStream(source,base,{assess}={}) {
56
+ const reader=source.getReader(), first=await reader.read();
57
+ if(first.done)return {delta:false,body:new ReadableStream({start(c){c.close();}})};
58
+ // A short first chunk always selects ordinary Flight. Do not build a base
59
+ // index that this response cannot use, or wait for a later streaming chunk.
60
+ let index=first.value.length>=WINDOW&&base instanceof Uint8Array&&base.length<=MAX_VALUE?createIndex(base):null;
61
+ const profitable=(raw,frames,initial)=>frames&&frames.reduce((n,b)=>n+b.length,0)+(initial?5:1)<raw.length*0.9&&(!assess||assess(raw,join(initial?[MAGIC,...frames,new Uint8Array([2])]:frames,frames.reduce((n,b)=>n+b.length,0)+(initial?5:0)))===true);
62
+ const firstFrames=index&&first.value.length>=WINDOW?encodeChunk(first.value,base,index):null;
63
+ let useDelta;
64
+ try{useDelta=!!profitable(first.value,firstFrames,true);}catch(error){await reader.cancel(error).catch(()=>{});throw error;}
65
+ let pending=useDelta?blocks([MAGIC,...firstFrames]):[first.value], pendingAt=0,ended=false,tail=false,commands=firstFrames?.commands||0;
66
+ if(!useDelta){base=undefined;index=undefined;}
67
+ return {delta:!!useDelta,body:new ReadableStream({
68
+ async pull(controller){try{
69
+ if(pendingAt===pending.length&&!ended){pending=[];pendingAt=0;const next=await reader.read();
70
+ if(next.done){ended=true;if(useDelta&&!tail)pending=[new Uint8Array([2])];}
71
+ else if(useDelta&&!tail){const encoded=encodeChunk(next.value,base,index);commands+=encoded?.commands||0;
72
+ if(commands>MAX_COMMANDS||!profitable(next.value,encoded,false)){tail=true;index=undefined;base=undefined;pending=blocks([new Uint8Array([3]),next.value]);}
73
+ else pending=blocks(encoded);
74
+ }else pending=[next.value];
75
+ }
76
+ if(pendingAt<pending.length)controller.enqueue(pending[pendingAt++]);else if(ended)controller.close();
77
+ }catch(error){pending=[];base=undefined;index=undefined;controller.error(error);await reader.cancel(error).catch(()=>{});}},
78
+ cancel(reason){pending=[];base=undefined;index=undefined;ended=true;return reader.cancel(reason);},
79
+ },{highWaterMark:0})};
80
+ }
81
+ function decodeStream(source,base) {
82
+ const reader=source.getReader();let chunks=[],head=0,available=0,offset=0,total=0,wireTotal=0,started=false,ended=false,tail=false,commands=0;
83
+ function consume(size){
84
+ const first=chunks[head];
85
+ if(first&&first.length-offset>=size){const result=first.subarray(offset,offset+size);offset+=size;available-=size;if(offset===first.length){head++;offset=0;}return result;}
86
+ const result=new Uint8Array(size);let written=0;
87
+ while(written<size){const value=chunks[head],count=Math.min(size-written,value.length-offset);result.set(value.subarray(offset,offset+count),written);written+=count;offset+=count;available-=count;if(offset===value.length){head++;offset=0;}}
88
+ return result;
89
+ }
90
+ async function take(size) {
91
+ if(size>MAX_RESPONSE)throw new Error('Invalid Flight delta frame size');
92
+ while(available<size){const next=await reader.read();if(next.done)throw new Error('Truncated Flight delta');if(!(next.value instanceof Uint8Array))throw new Error('Invalid Flight delta bytes');wireTotal+=next.value.length;if(wireTotal>MAX_RESPONSE+1024*1024)throw new Error('Flight delta wire budget exceeded');if(next.value.length){if(head){chunks=chunks.slice(head);head=0;}chunks.push(next.value);available+=next.value.length;}}
93
+ return consume(size);
94
+ }
95
+ function peek(at){let index=head,position=offset+at;while(index<chunks.length){if(position<chunks[index].length)return chunks[index][position];position-=chunks[index++].length;}}
96
+ function bufferedCommand(){
97
+ if(!available)return false;
98
+ const tag=peek(0);if(tag!==0&&tag!==1)return true;
99
+ if(available<(tag===1?9:5))return false;
100
+ const length=tag===1?0:((peek(1)*0x1000000)+(peek(2)<<16)+(peek(3)<<8)+peek(4));
101
+ return length>MAX_RESPONSE||available>=5+length;
102
+ }
103
+ async function pullTail(controller){
104
+ const next=available?{value:await take(available),done:false}:await reader.read();
105
+ if(next.done){ended=true;controller.close();return;}
106
+ if(!(next.value instanceof Uint8Array))throw new Error('Invalid Flight delta bytes');
107
+ total+=next.value.length;if(total>MAX_RESPONSE)throw new Error('Decoded Flight exceeds transport budget');controller.enqueue(next.value);
108
+ }
109
+ return new ReadableStream({
110
+ async pull(controller){try{
111
+ if(tail)return await pullTail(controller);
112
+ if(!started){const magic=await take(4);if(!magic.every((value,n)=>value===MAGIC[n]))throw new Error('Unsupported Flight delta version');started=true;}
113
+ if(ended)return controller.close();
114
+ const output=[];let size=0;
115
+ do {
116
+ // Publish decoded data before waiting for another source chunk or the
117
+ // final EOF. This preserves Suspense flushes and downstream backpressure.
118
+ if(output.length&&(!bufferedCommand()||peek(0)===2||peek(0)===3))break;
119
+ if(++commands>65536)throw new Error('Flight delta command budget exceeded');
120
+ const [tag]=await take(1);let value;
121
+ if(tag===3){tail=true;return await pullTail(controller);}
122
+ if(tag===2){ended=true;if(available)throw new Error('Trailing Flight delta data');const rest=await reader.read();if(!rest.done)throw new Error('Trailing Flight delta data');controller.close();return;}
123
+ if(tag===0){const header=await take(4),length=new DataView(header.buffer,header.byteOffset,header.byteLength).getUint32(0);if(!length)throw new Error('Empty Flight delta literal');value=await take(length);}
124
+ else if(tag===1){const header=await take(8),view=new DataView(header.buffer,header.byteOffset,header.byteLength),start=view.getUint32(0),length=view.getUint32(4);if(!length||start+length>base.length)throw new Error('Invalid Flight delta reference');value=base.subarray(start,start+length);}
125
+ else throw new Error('Invalid Flight delta command');
126
+ total+=value.length;if(total>MAX_RESPONSE)throw new Error('Decoded Flight exceeds transport budget');output.push(value);size+=value.length;
127
+ }while(size<BLOCK_BYTES);
128
+ controller.enqueue(output.length===1?output[0]:join(output,size));
129
+ }catch(error){chunks=[];controller.error(error);await reader.cancel(error).catch(()=>{});}},
130
+ cancel:reason=>{chunks=[];return reader.cancel(reason);},
131
+ },{highWaterMark:0});
132
+ }
133
+ function createFlightTransport() {
134
+ let current,serial=0;
135
+ return {
136
+ snapshot(){return current;},
137
+ async response(response,base) {
138
+ const version=++serial;
139
+ const token=response.headers.get('x-prnext-flight-token'), headers=new Headers(response.headers);
140
+ let source=response.body;
141
+ if(!source)return response;
142
+ if(headers.get('content-type')?.startsWith(TYPE)) {
143
+ if(!base||headers.get('x-prnext-flight-base')!==base.token){await source.cancel();throw new Error('Missing Flight delta base');}
144
+ source=decodeStream(source,base.bytes);
145
+ }
146
+ headers.set('content-type','text/x-component; charset=utf-8');headers.delete('content-length');headers.delete('content-encoding');
147
+ return new Response(collectStream(source,{capture:!!token&&/^[a-f0-9]{48}$/.test(token),onComplete:bytes=>{if(version===serial&&token&&/^[a-f0-9]{48}$/.test(token))current={token,bytes};}}),{status:response.status,statusText:response.statusText,headers});
148
+ },
149
+ clear(){serial++;current=undefined;},
150
+ };
151
+ }
152
+ module.exports={TYPE,MAX_VALUE,createIndex,encodeChunk,deltaStream,decodeStream,collectStream,createFlightTransport};
@@ -63,12 +63,14 @@ function runRequestContext(input, callback) {
63
63
  return requestStorage.run(context, callback);
64
64
  }
65
65
 
66
+ function assertAfterRequestData(name) { const context = currentRequest(); if (context.phase === 'after' && context.afterRestricted) throw new Error(`${name}() cannot be called inside after() registered by a Server Component; read it before registering the callback`); }
66
67
  function assertUncached(name) {
67
68
  if (inCacheScope()) throw new Error(`${name}() cannot be accessed inside unstable_cache; read it outside and pass the required values as arguments`);
68
69
  }
69
- async function headers() { if (currentRequest().instantValidation && currentRequest().instantSample) { assertUncached('headers'); return sampleMethods(currentRequest(), 'headers', currentRequest().headers); } if (currentCacheScope()?.kind !== 'private') assertUncached('headers'); dynamicUsage('headers()'); return currentRequest().headers; }
70
- async function cookies() { if (currentRequest().instantValidation && currentRequest().instantSample) { assertUncached('cookies'); return sampleMethods(currentRequest(), 'cookies', currentRequest().cookies); } if (currentCacheScope()?.kind !== 'private') assertUncached('cookies'); dynamicUsage('cookies()'); return currentRequest().cookies; }
70
+ async function headers() { assertAfterRequestData('headers'); if (currentRequest().instantValidation && currentRequest().instantSample) { assertUncached('headers'); return sampleMethods(currentRequest(), 'headers', currentRequest().headers); } if (currentCacheScope()?.kind !== 'private') assertUncached('headers'); dynamicUsage('headers()'); return currentRequest().headers; }
71
+ async function cookies() { assertAfterRequestData('cookies'); if (currentRequest().instantValidation && currentRequest().instantSample) { assertUncached('cookies'); return sampleMethods(currentRequest(), 'cookies', currentRequest().cookies); } if (currentCacheScope()?.kind !== 'private') assertUncached('cookies'); dynamicUsage('cookies()'); return currentRequest().cookies; }
71
72
  async function draftMode() {
73
+ assertAfterRequestData('draftMode');
72
74
  const context = currentRequest();
73
75
  if (!context.draftProvider) context.draftProvider = {
74
76
  get isEnabled() { return context.draftMode; },
@@ -78,6 +78,7 @@ async function invalidate(context, operation) {
78
78
  await invoke(handler, 'revalidateTag', [[...new Set(tags)], durations], context);
79
79
  }
80
80
  function fresh(entry, revalidate) {
81
+ if (entry?.isStale === true) return false;
81
82
  const timestamp = entry?.lastModified;
82
83
  if (!Number.isFinite(timestamp) || timestamp < 0 || timestamp > Date.now() + 60_000) return false;
83
84
  const stored = entry.value?.revalidate;
@@ -124,14 +125,18 @@ async function cachedIncrementalValue(key, producer, options) {
124
125
  let complete;
125
126
  const owner = { revision, done: new Promise(resolve => { complete = resolve; }) };
126
127
  if (ownersCount < 64) { record.owners.set(key, owner); ownersCount++; }
127
- let lease;
128
+ let lease, sharedLease;
128
129
  const cleanup = async () => {
129
130
  if (owner.finished) return;
130
131
  owner.finished = true;
131
- try { await lease?.release(); } finally {
132
- if (record.owners.get(key) === owner) record.owners.delete(key);
133
- if (owner.tracked) ownersCount--;
134
- complete();
132
+ try {
133
+ if (sharedLease && handler.prnextLeaseVersion === 1 && typeof handler.release === 'function') await invoke(handler, 'release', [key, sharedLease], { ...context, signal: undefined });
134
+ } finally {
135
+ try { await lease?.release(); } finally {
136
+ if (record.owners.get(key) === owner) record.owners.delete(key);
137
+ if (owner.tracked) ownersCount--;
138
+ complete();
139
+ }
135
140
  }
136
141
  };
137
142
  owner.tracked = record.owners.get(key) === owner;
@@ -140,7 +145,7 @@ async function cachedIncrementalValue(key, producer, options) {
140
145
  if (!(value instanceof Uint8Array) || value.byteLength > MAX_BYTES) return;
141
146
  if (lease && !await lease.valid()) return;
142
147
  const data = incremental.encode(Buffer.from(value));
143
- const setContext = { ...getContext, tags: [...new Set([...tags, ...implicit])], cacheControl: { revalidate } };
148
+ const setContext = { ...getContext, tags: [...new Set([...tags, ...implicit])], cacheControl: { revalidate }, prnextLease: sharedLease };
144
149
  try {
145
150
  await invoke(handler, 'set', [key, { kind: 'FETCH', data, revalidate: revalidate === false ? 31536000 : revalidate }, setContext], context);
146
151
  // A mutation in any local worker can revoke the native lease while the
@@ -152,7 +157,15 @@ async function cachedIncrementalValue(key, producer, options) {
152
157
  const work = (async () => {
153
158
  try {
154
159
  lease = await cache.acquireExternalCacheLease(key, signal);
155
- if (lease) {
160
+ if (handler.prnextLeaseVersion === 1 && typeof handler.acquire === 'function') {
161
+ const until = Date.now() + 25000;
162
+ for (;;) {
163
+ signal?.throwIfAborted();
164
+ try { sharedLease = await invoke(handler, 'acquire', [key, { signal }], context); break; }
165
+ catch (error) { if (error.code !== 'PRNEXT_REDIS_BUSY' || Date.now() >= until) throw error; }
166
+ }
167
+ }
168
+ if (lease || sharedLease) {
156
169
  // Even an immediately granted lease may follow a producer that finished
157
170
  // between our first backend read and this native admission.
158
171
  let shared;