@rsc-kit/core 0.30.43 → 0.30.45

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.
@@ -1,3 +1,4 @@
1
+ import type { CacheStore } from "./cache.js";
1
2
  /** What a request for a `revalidate` page carries from the moment it arrives. */
2
3
  export interface PageCaching {
3
4
  /** Seconds a shared cache may keep the page. */
@@ -56,3 +57,29 @@ export declare function finishCacheable(request: Request, response: Response, en
56
57
  * it - but a not-found one is kept no longer than a 404.
57
58
  */
58
59
  saidNotFound?: () => boolean): Promise<Response>;
60
+ /**
61
+ * Pages kept in this process, by the size of what they hold: a page is tens
62
+ * or hundreds of KB, so a count says nothing about memory. Least recently
63
+ * used goes first.
64
+ */
65
+ export declare function pageMemoryStore(maxBytes?: number): CacheStore;
66
+ /** A kept answer, and whether it is past its time: served, while one render replaces it. */
67
+ export interface KeptAnswer {
68
+ response: Response;
69
+ stale: boolean;
70
+ }
71
+ export interface PageCache {
72
+ /** The kept answer for this request, or null to render it now. */
73
+ lookup(key: string, request: Request): Promise<KeptAnswer | null>;
74
+ /** Whether a render for this key is running in this process. */
75
+ busy(key: string): boolean;
76
+ /** Keep the answer, when finishCacheable() sent it public. */
77
+ keep(key: string, answer: Response): Promise<void>;
78
+ /** Run one render per key at a time: a burst for a page nobody has kept waits for the first. */
79
+ once<T>(key: string, render: () => Promise<T>): Promise<{
80
+ led: boolean;
81
+ value?: T;
82
+ }>;
83
+ }
84
+ /** The engine's page cache, over a store: the app's, or one of its own in memory. */
85
+ export declare function createPageCache(store: CacheStore): PageCache;
package/dist/pageCache.js CHANGED
@@ -29,7 +29,8 @@
29
29
  // by the backend on every call does not make every page private.
30
30
  import { HEADER, PER_CLIENT } from "./headers.js";
31
31
  import { PAYLOAD_PARAM, payloadKey, withoutPayloadKey } from "./js/payloadKey.js";
32
- import { cacheTagsRead, renderFailed, visitorReads } from "./request.js";
32
+ import { cacheTagsRead, cacheTagVersionsRead, renderFailed, visitorReads } from "./request.js";
33
+ import { versionSource } from "./changed.js";
33
34
  /** A 404 is kept no longer than this, however long its page may be. */
34
35
  const NOT_FOUND_LIMIT = 60;
35
36
  /** How long a cache may serve a stale page while one request refreshes it, or while the origin is failing. */
@@ -196,10 +197,167 @@ saidNotFound = () => false) {
196
197
  const tags = cacheTagHeader(cacheTagsRead());
197
198
  if (tags)
198
199
  headers.set("Cache-Tag", tags);
200
+ const kept = { status: response.status, body, versions: cacheTagVersionsRead(), revalidate: entry.revalidate, notFound: saidNotFound() };
199
201
  if (response.status === 200 && matches(request.headers.get("If-None-Match"), etag)) {
200
202
  headers.delete("Content-Length");
201
- return new Response(null, { status: 304, headers });
203
+ const answer = new Response(null, { status: 304, headers });
204
+ keepable.set(answer, kept);
205
+ return answer;
202
206
  }
203
- return new Response(request.method === "HEAD" ? null : body, { status: response.status, statusText: response.statusText, headers });
207
+ const answer = new Response(request.method === "HEAD" ? null : body, { status: response.status, statusText: response.statusText, headers });
208
+ keepable.set(answer, kept);
209
+ return answer;
210
+ }
211
+ const keepable = new WeakMap();
212
+ /** Headers that belong to one answer, never to the kept page. */
213
+ const NOT_KEPT = new Set(["date", "set-cookie", "content-length", "x-rsc-kit", "x-powered-by", "age"]);
214
+ const utf8 = new TextDecoder("utf-8", { fatal: true });
215
+ /**
216
+ * Pages kept in this process, by the size of what they hold: a page is tens
217
+ * or hundreds of KB, so a count says nothing about memory. Least recently
218
+ * used goes first.
219
+ */
220
+ export function pageMemoryStore(maxBytes = 64 * 1024 * 1024) {
221
+ const entries = new Map();
222
+ let bytes = 0;
223
+ const drop = (key) => {
224
+ const entry = entries.get(key);
225
+ if (!entry)
226
+ return;
227
+ entries.delete(key);
228
+ bytes -= entry.value.length;
229
+ };
230
+ return {
231
+ get(key) {
232
+ const entry = entries.get(key);
233
+ if (!entry)
234
+ return undefined;
235
+ if (entry.until <= Date.now()) {
236
+ drop(key);
237
+ return undefined;
238
+ }
239
+ // Re-inserted, so the Map's order is least recently used first.
240
+ entries.delete(key);
241
+ entries.set(key, entry);
242
+ return entry.value;
243
+ },
244
+ set(key, value, ttl) {
245
+ drop(key);
246
+ if (value.length > maxBytes)
247
+ return;
248
+ entries.set(key, { value, until: Date.now() + ttl * 1000 });
249
+ bytes += value.length;
250
+ while (bytes > maxBytes)
251
+ drop(entries.keys().next().value);
252
+ },
253
+ };
254
+ }
255
+ let warnedPageStore = false;
256
+ /** A store that fails is a render, not a broken page: said once. */
257
+ function pageStoreFailed(error) {
258
+ if (warnedPageStore)
259
+ return;
260
+ warnedPageStore = true;
261
+ console.warn("[rsc-kit] the page cache's store failed, so pages are being rendered each time instead:", error);
262
+ }
263
+ /** The engine's page cache, over a store: the app's, or one of its own in memory. */
264
+ export function createPageCache(store) {
265
+ const inFlight = new Map();
266
+ return {
267
+ async lookup(key, request) {
268
+ let raw;
269
+ try {
270
+ raw = await store.get(`rsc-page|${key}`);
271
+ }
272
+ catch (error) {
273
+ pageStoreFailed(error);
274
+ return null;
275
+ }
276
+ if (!raw)
277
+ return null;
278
+ let kept;
279
+ try {
280
+ kept = JSON.parse(raw);
281
+ }
282
+ catch {
283
+ return null;
284
+ }
285
+ const now = Date.now();
286
+ const age = now - kept.at;
287
+ // The store may keep it longer than asked; the age is checked here.
288
+ // Past its time it is still served, for a while, as the stale copy a
289
+ // render in the background replaces: no visitor waits on an expiry.
290
+ if (age >= (kept.r + (kept.w ?? 0)) * 1000)
291
+ return null;
292
+ // A tag that moved is never served stale: whoever changed it is
293
+ // looking for the change, so this request renders.
294
+ if (Object.keys(kept.v).length > 0) {
295
+ try {
296
+ const moved = await versionSource().changed(kept.v, 0);
297
+ if (Object.keys(moved).some((tag) => moved[tag] !== kept.v[tag]))
298
+ return null;
299
+ }
300
+ catch {
301
+ // Not knowing whether it changed is not knowing it did not.
302
+ return null;
303
+ }
304
+ }
305
+ const headers = new Headers(kept.h);
306
+ headers.set("Age", String(Math.floor((now - kept.at) / 1000)));
307
+ const etag = headers.get("ETag");
308
+ const stale = age >= kept.r * 1000;
309
+ if (kept.s === 200 && etag && matches(request.headers.get("If-None-Match"), etag)) {
310
+ return { response: new Response(null, { status: 304, headers }), stale };
311
+ }
312
+ return { response: new Response(request.method === "HEAD" ? null : kept.b, { status: kept.s, headers }), stale };
313
+ },
314
+ busy(key) {
315
+ return inFlight.has(key);
316
+ },
317
+ async keep(key, answer) {
318
+ const kept = keepable.get(answer);
319
+ if (!kept)
320
+ return;
321
+ let body;
322
+ try {
323
+ body = utf8.decode(kept.body);
324
+ }
325
+ catch {
326
+ // Not text: sent, never kept.
327
+ return;
328
+ }
329
+ const headers = [];
330
+ answer.headers.forEach((value, name) => {
331
+ if (!NOT_KEPT.has(name.toLowerCase()))
332
+ headers.push([name, value]);
333
+ });
334
+ const notFound = kept.status === 404 || kept.notFound;
335
+ const revalidate = notFound ? Math.min(kept.revalidate, NOT_FOUND_LIMIT) : kept.revalidate;
336
+ // The same windows the answer's Cache-Control gives a CDN.
337
+ const window = notFound ? NOT_FOUND_LIMIT : STALE_WINDOW;
338
+ const page = { s: kept.status, h: headers, b: body, v: kept.versions, at: Date.now(), r: revalidate, w: window };
339
+ try {
340
+ await store.set(`rsc-page|${key}`, JSON.stringify(page), Math.ceil(revalidate + window));
341
+ }
342
+ catch (error) {
343
+ pageStoreFailed(error);
344
+ }
345
+ },
346
+ async once(key, render) {
347
+ const pending = inFlight.get(key);
348
+ if (pending) {
349
+ await pending.catch(() => { });
350
+ return { led: false };
351
+ }
352
+ const work = render();
353
+ inFlight.set(key, work);
354
+ try {
355
+ return { led: true, value: await work };
356
+ }
357
+ finally {
358
+ inFlight.delete(key);
359
+ }
360
+ },
361
+ };
204
362
  }
205
363
  //# sourceMappingURL=pageCache.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"pageCache.js","sourceRoot":"","sources":["../src/pageCache.ts"],"names":[],"mappings":"AAAA,iEAAiE;AACjE,EAAE;AACF,8EAA8E;AAC9E,4EAA4E;AAC5E,4EAA4E;AAC5E,6EAA6E;AAC7E,4EAA4E;AAC5E,2BAA2B;AAC3B,EAAE;AACF,6CAA6C;AAC7C,EAAE;AACF,gGAAgG;AAChG,8EAA8E;AAC9E,4EAA4E;AAC5E,+DAA+D;AAC/D,uEAAuE;AACvE,qEAAqE;AACrE,mDAAmD;AACnD,2EAA2E;AAC3E,0CAA0C;AAC1C,2EAA2E;AAC3E,8EAA8E;AAC9E,2EAA2E;AAC3E,EAAE;AACF,4EAA4E;AAC5E,qEAAqE;AACrE,EAAE;AACF,6EAA6E;AAC7E,iEAAiE;AAEjE,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAClD,OAAO,EAAE,aAAa,EAAE,UAAU,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAClF,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAYzE,uEAAuE;AACvE,MAAM,eAAe,GAAG,EAAE,CAAC;AAE3B,8GAA8G;AAC9G,MAAM,YAAY,GAAG,MAAM,CAAC;AAE5B,MAAM,OAAO,GAAG,IAAI,OAAO,EAAwB,CAAC;AAEpD;;;;;;GAMG;AACH,MAAM,UAAU,UAAU,CAAC,OAAgB,EAAE,UAA8B;IACzE,wDAAwD;IACxD,IAAI,CAAC,UAAU,IAAI,UAAU,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,MAAM,KAAK,KAAK,IAAI,OAAO,CAAC,MAAM,KAAK,MAAM,CAAC;QAAE,OAAO,IAAI,CAAC;IAE3G,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;IACjC,MAAM,KAAK,GAAgB,EAAE,UAAU,EAAE,IAAI,EAAE,GAAG,CAAC,QAAQ,EAAE,OAAO,EAAE,UAAU,CAAC,OAAO,EAAE,GAAG,CAAC,EAAE,CAAC;IAEjG,OAAO,CAAC,GAAG,CAAC,iBAAiB,CAAC,OAAO,CAAC,EAAE,KAAK,CAAC,CAAC;IAE/C,OAAO,KAAK,CAAC;AACf,CAAC;AAED,+DAA+D;AAC/D,MAAM,UAAU,SAAS,CAAC,OAAgB;IACxC,OAAO,OAAO,CAAC,GAAG,CAAC,iBAAiB,CAAC,OAAO,CAAC,CAAC,IAAI,IAAI,CAAC;AACzD,CAAC;AAED;;;;GAIG;AACH,SAAS,UAAU,CAAC,OAAgB,EAAE,GAAQ;IAC5C,MAAM,GAAG,GAAG,GAAG,CAAC,YAAY,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC;IAChD,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,KAAK,IAAI,CAAC;IAEzD,IAAI,OAAO,CAAC,CAAC,CAAC,GAAG,KAAK,UAAU,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,GAAG,KAAK,IAAI;QAAE,OAAO,aAAa,CAAC;IAEvF,OAAO,IAAI,CAAC;AACd,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,cAAc,CAAC,KAAkB;IAC/C,KAAK,CAAC,OAAO,KAAK,WAAW,EAAE,CAAC;AAClC,CAAC;AAED,SAAS,WAAW;IAClB,IAAI,YAAY,EAAE;QAAE,OAAO,eAAe,CAAC;IAE3C,MAAM,IAAI,GAAG,YAAY,EAAE,CAAC,CAAC,CAAC,CAAC;IAE/B,OAAO,IAAI,IAAI,IAAI,CAAC;AACtB,CAAC;AAED,qFAAqF;AACrF,SAAS,QAAQ,CAAC,OAAe;IAC/B,OAAO,OAAO,CAAC,OAAO,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC,OAAO,CAAC,WAAW,EAAE,GAAG,CAAC,CAAC;AAChE,CAAC;AAED,MAAM,MAAM,GAAG,IAAI,GAAG,EAAU,CAAC;AAEjC,MAAM,OAAO,GAA2B;IACtC,aAAa,EACX,6HAA6H;IAC/H,iBAAiB,EAAE,0EAA0E;IAC7F,YAAY,EAAE,0BAA0B;IACxC,KAAK,EAAE,+EAA+E;IACtF,MAAM,EAAE,8BAA8B;IACtC,eAAe,EAAE,8CAA8C;CAChE,CAAC;AAEF,8EAA8E;AAC9E,SAAS,UAAU,CAAC,KAAkB,EAAE,OAAe;IACrD,MAAM,GAAG,GAAG,GAAG,KAAK,CAAC,IAAI,KAAK,OAAO,EAAE,CAAC;IAExC,IAAI,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,MAAM,CAAC,IAAI,GAAG,GAAG;QAAE,OAAO;IAEjD,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;IAEhB,4DAA4D;IAC5D,MAAM,GAAG,GAAG,OAAO,CAAC,OAAO,CAAC,IAAI,GAAG,OAAO,sBAAsB,CAAC;IAEjE,OAAO,CAAC,IAAI,CAAC,aAAa,KAAK,CAAC,IAAI,8CAA8C,GAAG,EAAE,CAAC,CAAC;AAC3F,CAAC;AAED,SAAS,MAAM,CAAC,QAAkB,EAAE,IAAqB,EAAE,KAAkB,EAAE,OAAe;IAC5F,MAAM,OAAO,GAAG,IAAI,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC;IAE9C,OAAO,CAAC,GAAG,CAAC,eAAe,EAAE,UAAU,CAAC,CAAC;IACzC,OAAO,CAAC,GAAG,CAAC,cAAc,EAAE,+BAA+B,QAAQ,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC;IAChF,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;IACvB,UAAU,CAAC,KAAK,EAAE,OAAO,CAAC,CAAC;IAE3B,OAAO,IAAI,QAAQ,CAAC,IAAI,EAAE,EAAE,MAAM,EAAE,QAAQ,CAAC,MAAM,EAAE,UAAU,EAAE,QAAQ,CAAC,UAAU,EAAE,OAAO,EAAE,CAAC,CAAC;AACnG,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,kBAAkB,CAAC,UAAkB,EAAE,MAAc;IACnE,OAAO,MAAM,KAAK,GAAG;QACnB,CAAC,CAAC,+BAA+B,IAAI,CAAC,GAAG,CAAC,UAAU,EAAE,eAAe,CAAC,4BAA4B,eAAe,oBAAoB,eAAe,EAAE;QACtJ,CAAC,CAAC,+BAA+B,UAAU,4BAA4B,YAAY,oBAAoB,YAAY,EAAE,CAAC;AAC1H,CAAC;AAED,uFAAuF;AACvF,MAAM,GAAG,GAAG,yBAAyB,CAAC;AAEtC,IAAI,SAAS,GAAG,KAAK,CAAC;AAEtB;;;;;GAKG;AACH,MAAM,UAAU,cAAc,CAAC,IAAuB;IACpD,MAAM,IAAI,GAAG,IAAI,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;IAEjD,IAAI,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC,MAAM,IAAI,CAAC,SAAS,EAAE,CAAC;QAC5C,SAAS,GAAG,IAAI,CAAC;QACjB,MAAM,GAAG,GAAG,IAAI,CAAC,IAAI,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;QAC/C,OAAO,CAAC,IAAI,CACV,yBAAyB,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,yGAAyG,CACtJ,CAAC;IACJ,CAAC;IAED,OAAO,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;AAC7C,CAAC;AAED,KAAK,UAAU,MAAM,CAAC,IAAiB;IACrC,MAAM,MAAM,GAAG,IAAI,UAAU,CAAC,MAAM,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,SAAS,EAAE,IAAI,CAAC,CAAC,CAAC;IAC3E,IAAI,GAAG,GAAG,EAAE,CAAC;IAEb,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC,EAAE;QAAE,GAAG,IAAI,MAAM,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC;IAE5E,OAAO,IAAI,GAAG,GAAG,CAAC;AACpB,CAAC;AAED,SAAS,OAAO,CAAC,WAA0B,EAAE,IAAY;IACvD,IAAI,CAAC,WAAW;QAAE,OAAO,KAAK,CAAC;IAE/B,OAAO,WAAW;SACf,KAAK,CAAC,GAAG,CAAC;SACV,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;SAC5C,IAAI,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,KAAK,IAAI,IAAI,GAAG,KAAK,GAAG,CAAC,CAAC;AAChD,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,CAAC,KAAK,UAAU,eAAe,CACnC,OAAgB,EAChB,QAAkB,EAClB,KAAkB;AAClB;;;;;GAKG;AACH,YAAY,GAAkB,GAAG,EAAE,CAAC,KAAK;IAEzC,IAAI,KAAK,CAAC,OAAO;QAAE,OAAO,MAAM,CAAC,QAAQ,EAAE,QAAQ,CAAC,IAAI,EAAE,KAAK,EAAE,KAAK,CAAC,OAAO,CAAC,CAAC;IAEhF,IAAI,QAAQ,CAAC,MAAM,KAAK,GAAG,IAAI,QAAQ,CAAC,MAAM,KAAK,GAAG;QAAE,OAAO,MAAM,CAAC,QAAQ,EAAE,QAAQ,CAAC,IAAI,EAAE,KAAK,EAAE,QAAQ,CAAC,CAAC;IAEhH,MAAM,IAAI,GAAG,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,cAAc,CAAC,IAAI,EAAE,CAAC;IAExD,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,WAAW,CAAC,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,kBAAkB,CAAC,EAAE,CAAC;QAC1E,OAAO,MAAM,CAAC,QAAQ,EAAE,QAAQ,CAAC,IAAI,EAAE,KAAK,EAAE,QAAQ,CAAC,CAAC;IAC1D,CAAC;IAED,MAAM,IAAI,GAAG,MAAM,QAAQ,CAAC,WAAW,EAAE,CAAC;IAC1C,MAAM,OAAO,GACX,WAAW,EAAE;QACb,CAAC,QAAQ,CAAC,OAAO,CAAC,YAAY,EAAE,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,IAAI,CAAC;QAClE,CAAC,SAAS,CAAC,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,yBAAyB,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC;IAE3F,IAAI,OAAO;QAAE,OAAO,MAAM,CAAC,QAAQ,EAAE,IAAI,EAAE,KAAK,EAAE,OAAO,CAAC,CAAC;IAE3D,MAAM,OAAO,GAAG,IAAI,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC;IAC9C,MAAM,IAAI,GAAG,MAAM,MAAM,CAAC,IAAI,CAAC,CAAC;IAEhC,OAAO,CAAC,GAAG,CAAC,eAAe,EAAE,kBAAkB,CAAC,KAAK,CAAC,UAAU,EAAE,YAAY,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC;IAC3G,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC;IAE1B,MAAM,IAAI,GAAG,cAAc,CAAC,aAAa,EAAE,CAAC,CAAC;IAE7C,IAAI,IAAI;QAAE,OAAO,CAAC,GAAG,CAAC,WAAW,EAAE,IAAI,CAAC,CAAC;IAEzC,IAAI,QAAQ,CAAC,MAAM,KAAK,GAAG,IAAI,OAAO,CAAC,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,eAAe,CAAC,EAAE,IAAI,CAAC,EAAE,CAAC;QACnF,OAAO,CAAC,MAAM,CAAC,gBAAgB,CAAC,CAAC;QAEjC,OAAO,IAAI,QAAQ,CAAC,IAAI,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,EAAE,CAAC,CAAC;IACtD,CAAC;IAED,OAAO,IAAI,QAAQ,CAAC,OAAO,CAAC,MAAM,KAAK,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,EAAE,EAAE,MAAM,EAAE,QAAQ,CAAC,MAAM,EAAE,UAAU,EAAE,QAAQ,CAAC,UAAU,EAAE,OAAO,EAAE,CAAC,CAAC;AACtI,CAAC","sourcesContent":["// A page a shared cache may keep: `export const revalidate = N`.\n//\n// The page is rendered whole, judged, then sent. Public only when every check\n// passed for that render; otherwise `private, no-store`, exactly as before,\n// with `Cache-Status: rsc-kit; fwd=bypass; detail=<check>` saying which one\n// failed and a line in the log saying where. Cache-Control is the contract a\n// host caches by; Cache-Status is for counting and for the developer asking\n// why a page was not kept.\n//\n// The checks, in the order they can be made:\n//\n// 1. payload-url - a GET or HEAD, and a payload asked for at its own `?_rsc=<key>` url, and a\n// document never at one: a cache that ignores Vary keys on\n// the url alone, so either the other way round hands one\n// kind of answer to a request for the other\n// 2. the render read nothing that belongs to one visitor: cookies(),\n// headers(), request(), connection() - in the page, a layout, a\n// Suspense'd child, metadata or middleware.ts\n// 3. host-middleware - Laravel route middleware runs against the session\n// 4. set-cookie - nothing set a cookie\n// 5. nonce - no CSP nonce, which a shared copy would give everyone\n// 6. status - 200 or 404; a redirect, a 5xx or a render React reported\n// as failed inside the payload (failed-render) never is\n//\n// A public answer carries `Cache-Tag`: the tags of every cache() answer the\n// render used, so a CDN can purge the copy when one of them changes.\n//\n// Backends render as nobody - see renderAsNobody() - so a session cookie set\n// by the backend on every call does not make every page private.\n\nimport { HEADER, PER_CLIENT } from \"./headers.js\";\nimport { PAYLOAD_PARAM, payloadKey, withoutPayloadKey } from \"./js/payloadKey.js\";\nimport { cacheTagsRead, renderFailed, visitorReads } from \"./request.js\";\n\n/** What a request for a `revalidate` page carries from the moment it arrives. */\nexport interface PageCaching {\n /** Seconds a shared cache may keep the page. */\n revalidate: number;\n /** The url path, for the log line. */\n path: string;\n /** A check that failed before anything rendered, or after the page said notFound(). */\n refused: string | null;\n}\n\n/** A 404 is kept no longer than this, however long its page may be. */\nconst NOT_FOUND_LIMIT = 60;\n\n/** How long a cache may serve a stale page while one request refreshes it, or while the origin is failing. */\nconst STALE_WINDOW = 86_400;\n\nconst caching = new WeakMap<Request, PageCaching>();\n\n/**\n * Decide, for a request as it arrived, whether its answer may be judged at all.\n *\n * Null for anything but a GET or HEAD of a page that exports revalidate - the\n * answer is then what it always was. Keyed by the request the app sees, so the\n * entry in front, which renders a 404, finds it again.\n */\nexport function cachingFor(arrived: Request, revalidate: number | undefined): PageCaching | null {\n // A post is never kept, and its answer already says so.\n if (!revalidate || revalidate <= 0 || (arrived.method !== \"GET\" && arrived.method !== \"HEAD\")) return null;\n\n const url = new URL(arrived.url);\n const entry: PageCaching = { revalidate, path: url.pathname, refused: urlRefusal(arrived, url) };\n\n caching.set(withoutPayloadKey(arrived), entry);\n\n return entry;\n}\n\n/** What cachingFor() decided for this request, if anything. */\nexport function cachingOf(request: Request): PageCaching | null {\n return caching.get(withoutPayloadKey(request)) ?? null;\n}\n\n/**\n * Check 1. A payload answers at `?_rsc=<key>`, with the key its headers give;\n * a document never answers there. Either way round, a cache keying on the url\n * alone would hand a request one kind of answer for the other.\n */\nfunction urlRefusal(arrived: Request, url: URL): string | null {\n const key = url.searchParams.get(PAYLOAD_PARAM);\n const payload = arrived.headers.get(HEADER.rsc) !== null;\n\n if (payload ? key !== payloadKey(arrived.headers) : key !== null) return \"payload-url\";\n\n return null;\n}\n\n/**\n * Note what the render has read so far, for an answer that will be finished\n * somewhere else: the page said notFound(), and the entry in front renders\n * not-found.tsx in a scope of its own, where these reads are gone.\n */\nexport function noteReadsSoFar(entry: PageCaching): void {\n entry.refused ??= readRefusal();\n}\n\nfunction readRefusal(): string | null {\n if (renderFailed()) return \"failed-render\";\n\n const read = visitorReads()[0];\n\n return read ?? null;\n}\n\n/** The detail token a read is reported under: `cookies() in Header` is `cookies`. */\nfunction detailOf(refused: string): string {\n return refused.replace(/\\(.*$/, \"\").replace(/[^a-z-]/gi, \"-\");\n}\n\nconst logged = new Set<string>();\n\nconst REASONS: Record<string, string> = {\n \"payload-url\":\n \"a payload was asked for without its ?_rsc= key, or a document with one. The router always adds it; this was another client.\",\n \"host-middleware\": \"the route has host middleware, which runs against the visitor's session.\",\n \"set-cookie\": \"the answer set a cookie.\",\n nonce: \"the answer carries a CSP nonce, which a shared copy would give every visitor.\",\n status: \"only a 200 or a 404 is kept.\",\n \"failed-render\": \"React reported a failure inside the payload.\",\n};\n\n/** Once per page and reason: a page refused on every request says so once. */\nfunction logRefusal(entry: PageCaching, refused: string): void {\n const key = `${entry.path}\\n${refused}`;\n\n if (logged.has(key) || logged.size > 500) return;\n\n logged.add(key);\n\n // A read names what read, and where: `cookies() in Header`.\n const why = REASONS[refused] ?? `${refused} reads this visitor.`;\n\n console.warn(`[rsc-kit] ${entry.path} exports revalidate, and was sent private: ${why}`);\n}\n\nfunction bypass(response: Response, body: BodyInit | null, entry: PageCaching, refused: string): Response {\n const headers = new Headers(response.headers);\n\n headers.set(\"Cache-Control\", PER_CLIENT);\n headers.set(\"Cache-Status\", `rsc-kit; fwd=bypass; detail=${detailOf(refused)}`);\n headers.delete(\"ETag\");\n logRefusal(entry, refused);\n\n return new Response(body, { status: response.status, statusText: response.statusText, headers });\n}\n\n/**\n * What a public answer says: kept by shared caches for N seconds, refreshed\n * in the background for a day after that, never by the browser. A 404 for at\n * most a minute, so a scanner walking random urls cannot hold pages out.\n *\n * stale-if-error says the same window outright for an origin that is down,\n * erroring or mid-deploy: a cache left to choose may serve the error instead,\n * or a copy older than the page would ever have been kept for.\n */\nexport function publicCacheControl(revalidate: number, status: number): string {\n return status === 404\n ? `public, max-age=0, s-maxage=${Math.min(revalidate, NOT_FOUND_LIMIT)}, stale-while-revalidate=${NOT_FOUND_LIMIT}, stale-if-error=${NOT_FOUND_LIMIT}`\n : `public, max-age=0, s-maxage=${revalidate}, stale-while-revalidate=${STALE_WINDOW}, stale-if-error=${STALE_WINDOW}`;\n}\n\n/** A tag a Cache-Tag list can carry: visible ASCII, no comma, which separates them. */\nconst TAG = /^[\\x21-\\x2b\\x2d-\\x7e]+$/;\n\nlet warnedTag = false;\n\n/**\n * The tags of every cache() answer a kept page used, as a CDN reads them:\n * `Cache-Tag: pages,post:1`. A purge by any of them drops the copy. A tag the\n * header cannot carry is left out, and said once: the copy then goes stale on\n * its own time, not on that change.\n */\nexport function cacheTagHeader(tags: readonly string[]): string | null {\n const kept = tags.filter((tag) => TAG.test(tag));\n\n if (kept.length < tags.length && !warnedTag) {\n warnedTag = true;\n const bad = tags.find((tag) => !TAG.test(tag));\n console.warn(\n `[rsc-kit] cache() tag ${JSON.stringify(bad)} cannot go in a Cache-Tag header (a comma, a space or a non-ASCII letter), so a CDN cannot purge by it.`,\n );\n }\n\n return kept.length ? kept.join(\",\") : null;\n}\n\nasync function etagOf(body: ArrayBuffer): Promise<string> {\n const digest = new Uint8Array(await crypto.subtle.digest(\"SHA-256\", body));\n let hex = \"\";\n\n for (let i = 0; i < 12; i++) hex += digest[i].toString(16).padStart(2, \"0\");\n\n return `\"${hex}\"`;\n}\n\nfunction matches(ifNoneMatch: string | null, etag: string): boolean {\n if (!ifNoneMatch) return false;\n\n return ifNoneMatch\n .split(\",\")\n .map((tag) => tag.trim().replace(/^W\\//, \"\"))\n .some((tag) => tag === etag || tag === \"*\");\n}\n\n/**\n * Finish the answer to a `revalidate` page: rendered whole, then judged.\n *\n * Called inside the request's scope, after every header the request will get\n * is on the answer - cookies included - so what it judges is what is sent.\n * Reading the body to its end is what waits for the render: a Suspense'd\n * child that reads cookies() is still running when the headers exist.\n */\nexport async function finishCacheable(\n request: Request,\n response: Response,\n entry: PageCaching,\n /**\n * Whether the answer turned out to be not-found.tsx, asked once the body is\n * read: a page can say notFound() in the middle of its stream, after a 200\n * went out. A payload is a 200 either way - the router reads the page from\n * it - but a not-found one is kept no longer than a 404.\n */\n saidNotFound: () => boolean = () => false,\n): Promise<Response> {\n if (entry.refused) return bypass(response, response.body, entry, entry.refused);\n\n if (response.status !== 200 && response.status !== 404) return bypass(response, response.body, entry, \"status\");\n\n const type = response.headers.get(\"Content-Type\") ?? \"\";\n\n if (!type.startsWith(\"text/html\") && !type.startsWith(\"text/x-component\")) {\n return bypass(response, response.body, entry, \"status\");\n }\n\n const body = await response.arrayBuffer();\n const refused =\n readRefusal() ??\n (response.headers.getSetCookie().length > 0 ? \"set-cookie\" : null) ??\n (/'nonce-/.test(response.headers.get(\"Content-Security-Policy\") ?? \"\") ? \"nonce\" : null);\n\n if (refused) return bypass(response, body, entry, refused);\n\n const headers = new Headers(response.headers);\n const etag = await etagOf(body);\n\n headers.set(\"Cache-Control\", publicCacheControl(entry.revalidate, saidNotFound() ? 404 : response.status));\n headers.set(\"ETag\", etag);\n\n const tags = cacheTagHeader(cacheTagsRead());\n\n if (tags) headers.set(\"Cache-Tag\", tags);\n\n if (response.status === 200 && matches(request.headers.get(\"If-None-Match\"), etag)) {\n headers.delete(\"Content-Length\");\n\n return new Response(null, { status: 304, headers });\n }\n\n return new Response(request.method === \"HEAD\" ? null : body, { status: response.status, statusText: response.statusText, headers });\n}\n"]}
1
+ {"version":3,"file":"pageCache.js","sourceRoot":"","sources":["../src/pageCache.ts"],"names":[],"mappings":"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","sourcesContent":["// A page a shared cache may keep: `export const revalidate = N`.\n//\n// The page is rendered whole, judged, then sent. Public only when every check\n// passed for that render; otherwise `private, no-store`, exactly as before,\n// with `Cache-Status: rsc-kit; fwd=bypass; detail=<check>` saying which one\n// failed and a line in the log saying where. Cache-Control is the contract a\n// host caches by; Cache-Status is for counting and for the developer asking\n// why a page was not kept.\n//\n// The checks, in the order they can be made:\n//\n// 1. payload-url - a GET or HEAD, and a payload asked for at its own `?_rsc=<key>` url, and a\n// document never at one: a cache that ignores Vary keys on\n// the url alone, so either the other way round hands one\n// kind of answer to a request for the other\n// 2. the render read nothing that belongs to one visitor: cookies(),\n// headers(), request(), connection() - in the page, a layout, a\n// Suspense'd child, metadata or middleware.ts\n// 3. host-middleware - Laravel route middleware runs against the session\n// 4. set-cookie - nothing set a cookie\n// 5. nonce - no CSP nonce, which a shared copy would give everyone\n// 6. status - 200 or 404; a redirect, a 5xx or a render React reported\n// as failed inside the payload (failed-render) never is\n//\n// A public answer carries `Cache-Tag`: the tags of every cache() answer the\n// render used, so a CDN can purge the copy when one of them changes.\n//\n// Backends render as nobody - see renderAsNobody() - so a session cookie set\n// by the backend on every call does not make every page private.\n\nimport { HEADER, PER_CLIENT } from \"./headers.js\";\nimport { PAYLOAD_PARAM, payloadKey, withoutPayloadKey } from \"./js/payloadKey.js\";\nimport { cacheTagsRead, cacheTagVersionsRead, renderFailed, visitorReads } from \"./request.js\";\nimport type { CacheStore } from \"./cache.js\";\nimport { versionSource } from \"./changed.js\";\n\n/** What a request for a `revalidate` page carries from the moment it arrives. */\nexport interface PageCaching {\n /** Seconds a shared cache may keep the page. */\n revalidate: number;\n /** The url path, for the log line. */\n path: string;\n /** A check that failed before anything rendered, or after the page said notFound(). */\n refused: string | null;\n}\n\n/** A 404 is kept no longer than this, however long its page may be. */\nconst NOT_FOUND_LIMIT = 60;\n\n/** How long a cache may serve a stale page while one request refreshes it, or while the origin is failing. */\nconst STALE_WINDOW = 86_400;\n\nconst caching = new WeakMap<Request, PageCaching>();\n\n/**\n * Decide, for a request as it arrived, whether its answer may be judged at all.\n *\n * Null for anything but a GET or HEAD of a page that exports revalidate - the\n * answer is then what it always was. Keyed by the request the app sees, so the\n * entry in front, which renders a 404, finds it again.\n */\nexport function cachingFor(arrived: Request, revalidate: number | undefined): PageCaching | null {\n // A post is never kept, and its answer already says so.\n if (!revalidate || revalidate <= 0 || (arrived.method !== \"GET\" && arrived.method !== \"HEAD\")) return null;\n\n const url = new URL(arrived.url);\n const entry: PageCaching = { revalidate, path: url.pathname, refused: urlRefusal(arrived, url) };\n\n caching.set(withoutPayloadKey(arrived), entry);\n\n return entry;\n}\n\n/** What cachingFor() decided for this request, if anything. */\nexport function cachingOf(request: Request): PageCaching | null {\n return caching.get(withoutPayloadKey(request)) ?? null;\n}\n\n/**\n * Check 1. A payload answers at `?_rsc=<key>`, with the key its headers give;\n * a document never answers there. Either way round, a cache keying on the url\n * alone would hand a request one kind of answer for the other.\n */\nfunction urlRefusal(arrived: Request, url: URL): string | null {\n const key = url.searchParams.get(PAYLOAD_PARAM);\n const payload = arrived.headers.get(HEADER.rsc) !== null;\n\n if (payload ? key !== payloadKey(arrived.headers) : key !== null) return \"payload-url\";\n\n return null;\n}\n\n/**\n * Note what the render has read so far, for an answer that will be finished\n * somewhere else: the page said notFound(), and the entry in front renders\n * not-found.tsx in a scope of its own, where these reads are gone.\n */\nexport function noteReadsSoFar(entry: PageCaching): void {\n entry.refused ??= readRefusal();\n}\n\nfunction readRefusal(): string | null {\n if (renderFailed()) return \"failed-render\";\n\n const read = visitorReads()[0];\n\n return read ?? null;\n}\n\n/** The detail token a read is reported under: `cookies() in Header` is `cookies`. */\nfunction detailOf(refused: string): string {\n return refused.replace(/\\(.*$/, \"\").replace(/[^a-z-]/gi, \"-\");\n}\n\nconst logged = new Set<string>();\n\nconst REASONS: Record<string, string> = {\n \"payload-url\":\n \"a payload was asked for without its ?_rsc= key, or a document with one. The router always adds it; this was another client.\",\n \"host-middleware\": \"the route has host middleware, which runs against the visitor's session.\",\n \"set-cookie\": \"the answer set a cookie.\",\n nonce: \"the answer carries a CSP nonce, which a shared copy would give every visitor.\",\n status: \"only a 200 or a 404 is kept.\",\n \"failed-render\": \"React reported a failure inside the payload.\",\n};\n\n/** Once per page and reason: a page refused on every request says so once. */\nfunction logRefusal(entry: PageCaching, refused: string): void {\n const key = `${entry.path}\\n${refused}`;\n\n if (logged.has(key) || logged.size > 500) return;\n\n logged.add(key);\n\n // A read names what read, and where: `cookies() in Header`.\n const why = REASONS[refused] ?? `${refused} reads this visitor.`;\n\n console.warn(`[rsc-kit] ${entry.path} exports revalidate, and was sent private: ${why}`);\n}\n\nfunction bypass(response: Response, body: BodyInit | null, entry: PageCaching, refused: string): Response {\n const headers = new Headers(response.headers);\n\n headers.set(\"Cache-Control\", PER_CLIENT);\n headers.set(\"Cache-Status\", `rsc-kit; fwd=bypass; detail=${detailOf(refused)}`);\n headers.delete(\"ETag\");\n logRefusal(entry, refused);\n\n return new Response(body, { status: response.status, statusText: response.statusText, headers });\n}\n\n/**\n * What a public answer says: kept by shared caches for N seconds, refreshed\n * in the background for a day after that, never by the browser. A 404 for at\n * most a minute, so a scanner walking random urls cannot hold pages out.\n *\n * stale-if-error says the same window outright for an origin that is down,\n * erroring or mid-deploy: a cache left to choose may serve the error instead,\n * or a copy older than the page would ever have been kept for.\n */\nexport function publicCacheControl(revalidate: number, status: number): string {\n return status === 404\n ? `public, max-age=0, s-maxage=${Math.min(revalidate, NOT_FOUND_LIMIT)}, stale-while-revalidate=${NOT_FOUND_LIMIT}, stale-if-error=${NOT_FOUND_LIMIT}`\n : `public, max-age=0, s-maxage=${revalidate}, stale-while-revalidate=${STALE_WINDOW}, stale-if-error=${STALE_WINDOW}`;\n}\n\n/** A tag a Cache-Tag list can carry: visible ASCII, no comma, which separates them. */\nconst TAG = /^[\\x21-\\x2b\\x2d-\\x7e]+$/;\n\nlet warnedTag = false;\n\n/**\n * The tags of every cache() answer a kept page used, as a CDN reads them:\n * `Cache-Tag: pages,post:1`. A purge by any of them drops the copy. A tag the\n * header cannot carry is left out, and said once: the copy then goes stale on\n * its own time, not on that change.\n */\nexport function cacheTagHeader(tags: readonly string[]): string | null {\n const kept = tags.filter((tag) => TAG.test(tag));\n\n if (kept.length < tags.length && !warnedTag) {\n warnedTag = true;\n const bad = tags.find((tag) => !TAG.test(tag));\n console.warn(\n `[rsc-kit] cache() tag ${JSON.stringify(bad)} cannot go in a Cache-Tag header (a comma, a space or a non-ASCII letter), so a CDN cannot purge by it.`,\n );\n }\n\n return kept.length ? kept.join(\",\") : null;\n}\n\nasync function etagOf(body: ArrayBuffer): Promise<string> {\n const digest = new Uint8Array(await crypto.subtle.digest(\"SHA-256\", body));\n let hex = \"\";\n\n for (let i = 0; i < 12; i++) hex += digest[i].toString(16).padStart(2, \"0\");\n\n return `\"${hex}\"`;\n}\n\nfunction matches(ifNoneMatch: string | null, etag: string): boolean {\n if (!ifNoneMatch) return false;\n\n return ifNoneMatch\n .split(\",\")\n .map((tag) => tag.trim().replace(/^W\\//, \"\"))\n .some((tag) => tag === etag || tag === \"*\");\n}\n\n/**\n * Finish the answer to a `revalidate` page: rendered whole, then judged.\n *\n * Called inside the request's scope, after every header the request will get\n * is on the answer - cookies included - so what it judges is what is sent.\n * Reading the body to its end is what waits for the render: a Suspense'd\n * child that reads cookies() is still running when the headers exist.\n */\nexport async function finishCacheable(\n request: Request,\n response: Response,\n entry: PageCaching,\n /**\n * Whether the answer turned out to be not-found.tsx, asked once the body is\n * read: a page can say notFound() in the middle of its stream, after a 200\n * went out. A payload is a 200 either way - the router reads the page from\n * it - but a not-found one is kept no longer than a 404.\n */\n saidNotFound: () => boolean = () => false,\n): Promise<Response> {\n if (entry.refused) return bypass(response, response.body, entry, entry.refused);\n\n if (response.status !== 200 && response.status !== 404) return bypass(response, response.body, entry, \"status\");\n\n const type = response.headers.get(\"Content-Type\") ?? \"\";\n\n if (!type.startsWith(\"text/html\") && !type.startsWith(\"text/x-component\")) {\n return bypass(response, response.body, entry, \"status\");\n }\n\n const body = await response.arrayBuffer();\n const refused =\n readRefusal() ??\n (response.headers.getSetCookie().length > 0 ? \"set-cookie\" : null) ??\n (/'nonce-/.test(response.headers.get(\"Content-Security-Policy\") ?? \"\") ? \"nonce\" : null);\n\n if (refused) return bypass(response, body, entry, refused);\n\n const headers = new Headers(response.headers);\n const etag = await etagOf(body);\n\n headers.set(\"Cache-Control\", publicCacheControl(entry.revalidate, saidNotFound() ? 404 : response.status));\n headers.set(\"ETag\", etag);\n\n const tags = cacheTagHeader(cacheTagsRead());\n\n if (tags) headers.set(\"Cache-Tag\", tags);\n\n const kept: Keepable = { status: response.status, body, versions: cacheTagVersionsRead(), revalidate: entry.revalidate, notFound: saidNotFound() };\n\n if (response.status === 200 && matches(request.headers.get(\"If-None-Match\"), etag)) {\n headers.delete(\"Content-Length\");\n\n const answer = new Response(null, { status: 304, headers });\n\n keepable.set(answer, kept);\n\n return answer;\n }\n\n const answer = new Response(request.method === \"HEAD\" ? null : body, { status: response.status, statusText: response.statusText, headers });\n\n keepable.set(answer, kept);\n\n return answer;\n}\n\n// The engine's own copy of a public page: ISR on every host.\n//\n// A kept page is versioned, not purged. Its entry carries the version of\n// every tag its cache() answers were read under, and a lookup asks the\n// version source whether any moved since. changed(tag) bumps a version every\n// instance reads, so every pod misses at its next request with nothing sent\n// to it - and a render that was running when the change landed is stored\n// under the older version, so it is never served. Correctness needs only a\n// version source every instance shares; the store decides how often a page\n// is rendered, never whether a stale one is served.\n\n/** What finishCacheable() sent public, kept beside the answer for the page cache to store. */\ninterface Keepable {\n status: number;\n body: ArrayBuffer;\n versions: Record<string, number>;\n revalidate: number;\n /** A not-found page, kept no longer than a 404. */\n notFound: boolean;\n}\n\nconst keepable = new WeakMap<Response, Keepable>();\n\n/** A kept page, as the store holds it. */\ninterface KeptPage {\n /** Status. */\n s: number;\n /** Headers, as name and value pairs. */\n h: [string, string][];\n /** Body, as text: HTML and Flight both are. */\n b: string;\n /** Tag versions it was rendered under. */\n v: Record<string, number>;\n /** When it was rendered, in ms. */\n at: number;\n /** Seconds it is fresh for. */\n r: number;\n /** Seconds past that it may still be served while one render replaces it. */\n w: number;\n}\n\n/** Headers that belong to one answer, never to the kept page. */\nconst NOT_KEPT = new Set([\"date\", \"set-cookie\", \"content-length\", \"x-rsc-kit\", \"x-powered-by\", \"age\"]);\n\nconst utf8 = new TextDecoder(\"utf-8\", { fatal: true });\n\n/**\n * Pages kept in this process, by the size of what they hold: a page is tens\n * or hundreds of KB, so a count says nothing about memory. Least recently\n * used goes first.\n */\nexport function pageMemoryStore(maxBytes = 64 * 1024 * 1024): CacheStore {\n const entries = new Map<string, { value: string; until: number }>();\n let bytes = 0;\n\n const drop = (key: string) => {\n const entry = entries.get(key);\n\n if (!entry) return;\n\n entries.delete(key);\n bytes -= entry.value.length;\n };\n\n return {\n get(key) {\n const entry = entries.get(key);\n\n if (!entry) return undefined;\n\n if (entry.until <= Date.now()) {\n drop(key);\n\n return undefined;\n }\n\n // Re-inserted, so the Map's order is least recently used first.\n entries.delete(key);\n entries.set(key, entry);\n\n return entry.value;\n },\n set(key, value, ttl) {\n drop(key);\n\n if (value.length > maxBytes) return;\n\n entries.set(key, { value, until: Date.now() + ttl * 1000 });\n bytes += value.length;\n\n while (bytes > maxBytes) drop(entries.keys().next().value!);\n },\n };\n}\n\n/** A kept answer, and whether it is past its time: served, while one render replaces it. */\nexport interface KeptAnswer {\n response: Response;\n stale: boolean;\n}\n\nexport interface PageCache {\n /** The kept answer for this request, or null to render it now. */\n lookup(key: string, request: Request): Promise<KeptAnswer | null>;\n /** Whether a render for this key is running in this process. */\n busy(key: string): boolean;\n /** Keep the answer, when finishCacheable() sent it public. */\n keep(key: string, answer: Response): Promise<void>;\n /** Run one render per key at a time: a burst for a page nobody has kept waits for the first. */\n once<T>(key: string, render: () => Promise<T>): Promise<{ led: boolean; value?: T }>;\n}\n\nlet warnedPageStore = false;\n\n/** A store that fails is a render, not a broken page: said once. */\nfunction pageStoreFailed(error: unknown): void {\n if (warnedPageStore) return;\n warnedPageStore = true;\n console.warn(\"[rsc-kit] the page cache's store failed, so pages are being rendered each time instead:\", error);\n}\n\n/** The engine's page cache, over a store: the app's, or one of its own in memory. */\nexport function createPageCache(store: CacheStore): PageCache {\n const inFlight = new Map<string, Promise<unknown>>();\n\n return {\n async lookup(key, request) {\n let raw: string | null | undefined;\n\n try {\n raw = await store.get(`rsc-page|${key}`);\n } catch (error) {\n pageStoreFailed(error);\n\n return null;\n }\n\n if (!raw) return null;\n\n let kept: KeptPage;\n\n try {\n kept = JSON.parse(raw);\n } catch {\n return null;\n }\n\n const now = Date.now();\n const age = now - kept.at;\n\n // The store may keep it longer than asked; the age is checked here.\n // Past its time it is still served, for a while, as the stale copy a\n // render in the background replaces: no visitor waits on an expiry.\n if (age >= (kept.r + (kept.w ?? 0)) * 1000) return null;\n\n // A tag that moved is never served stale: whoever changed it is\n // looking for the change, so this request renders.\n if (Object.keys(kept.v).length > 0) {\n try {\n const moved = await versionSource().changed(kept.v, 0);\n\n if (Object.keys(moved).some((tag) => moved[tag] !== kept.v[tag])) return null;\n } catch {\n // Not knowing whether it changed is not knowing it did not.\n return null;\n }\n }\n\n const headers = new Headers(kept.h);\n\n headers.set(\"Age\", String(Math.floor((now - kept.at) / 1000)));\n\n const etag = headers.get(\"ETag\");\n\n const stale = age >= kept.r * 1000;\n\n if (kept.s === 200 && etag && matches(request.headers.get(\"If-None-Match\"), etag)) {\n return { response: new Response(null, { status: 304, headers }), stale };\n }\n\n return { response: new Response(request.method === \"HEAD\" ? null : kept.b, { status: kept.s, headers }), stale };\n },\n\n busy(key) {\n return inFlight.has(key);\n },\n\n async keep(key, answer) {\n const kept = keepable.get(answer);\n\n if (!kept) return;\n\n let body: string;\n\n try {\n body = utf8.decode(kept.body);\n } catch {\n // Not text: sent, never kept.\n return;\n }\n\n const headers: [string, string][] = [];\n\n answer.headers.forEach((value, name) => {\n if (!NOT_KEPT.has(name.toLowerCase())) headers.push([name, value]);\n });\n\n const notFound = kept.status === 404 || kept.notFound;\n const revalidate = notFound ? Math.min(kept.revalidate, NOT_FOUND_LIMIT) : kept.revalidate;\n // The same windows the answer's Cache-Control gives a CDN.\n const window = notFound ? NOT_FOUND_LIMIT : STALE_WINDOW;\n const page: KeptPage = { s: kept.status, h: headers, b: body, v: kept.versions, at: Date.now(), r: revalidate, w: window };\n\n try {\n await store.set(`rsc-page|${key}`, JSON.stringify(page), Math.ceil(revalidate + window));\n } catch (error) {\n pageStoreFailed(error);\n }\n },\n\n async once(key, render) {\n const pending = inFlight.get(key);\n\n if (pending) {\n await pending.catch(() => {});\n\n return { led: false };\n }\n\n const work = render();\n\n inFlight.set(key, work);\n\n try {\n return { led: true, value: await work };\n } finally {\n inFlight.delete(key);\n }\n },\n };\n}\n"]}
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Telling a CDN that kept pages changed.
3
+ *
4
+ * A `revalidate` page goes out with `Cache-Tag`, and a CDN that purges by tag
5
+ * can drop it the moment one of those tags changes, rather than waiting out
6
+ * `s-maxage`. What calls the purge is the app's: every CDN has its own API, so
7
+ * the engine has one hook, `installPurge()`, and `changed(tag)` calls it with
8
+ * the names that changed - after the answer has gone, through `after()`, so a
9
+ * slow CDN never holds up the action that made the change.
10
+ *
11
+ * Homeport is the one installed for you: with `HOMEPORT_PURGE_URL` set, the
12
+ * hook is `homeportPurge()` unless the app installed another.
13
+ *
14
+ * A Laravel or Go backend changes its names itself (`Rsc::changed()`,
15
+ * `rsckit.Changed()`), and the engine never hears of it, so each backend
16
+ * purges for its own changes. This hook covers `changed()` called here.
17
+ */
18
+ /** What a purge drops: pages carrying any of these tags, pages at these paths, or everything. */
19
+ export interface Purge {
20
+ tags?: readonly string[];
21
+ paths?: readonly string[];
22
+ all?: boolean;
23
+ }
24
+ /** Sends a purge to a CDN. A rejection is logged; it never fails the change. */
25
+ export type Purger = (purge: Purge) => Promise<void> | void;
26
+ /** Install the app's purge. `null` takes it out, and Homeport's default with it. */
27
+ export declare function installPurge(purger: Purger | null): void;
28
+ /** The purge in use: the app's, else Homeport's when its url is set, else none. */
29
+ export declare function installedPurge(): Purger | null;
30
+ /**
31
+ * @internal For changed(): purge these tags once the answer has gone, or at
32
+ * once outside a request. Does nothing when no purge is installed.
33
+ */
34
+ export declare function purgeLater(tags: readonly string[]): void;
35
+ /**
36
+ * Send a purge now, through the installed purge. For an app's own use - a
37
+ * deploy step, a webhook - as well as changed()'s. Never throws: the change
38
+ * it follows is already made, and a CDN that missed it still drops the copy
39
+ * at `s-maxage`. The first failure is logged.
40
+ */
41
+ export declare function purge(what: Purge): Promise<void>;
42
+ /** Homeport takes at most this many tags, and this many paths, per call. */
43
+ export declare const HOMEPORT_PURGE_LIMIT = 100;
44
+ export interface HomeportPurgeOptions {
45
+ /** Where to post. Default `HOMEPORT_PURGE_URL`, which Homeport sets. */
46
+ url?: string;
47
+ /** For tests. */
48
+ fetch?: typeof fetch;
49
+ }
50
+ /**
51
+ * Homeport's purge: `POST {tags, paths, all}` to the url Homeport gives the
52
+ * app, no token - it is reachable only from inside the app's own network.
53
+ * Split into calls of 100 tags and 100 paths. A 429 is tried once more after
54
+ * its `Retry-After` (at most five seconds); anything else that is not a 2xx
55
+ * rejects.
56
+ */
57
+ export declare function homeportPurge(options?: HomeportPurgeOptions): Purger;
package/dist/purge.js ADDED
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Telling a CDN that kept pages changed.
3
+ *
4
+ * A `revalidate` page goes out with `Cache-Tag`, and a CDN that purges by tag
5
+ * can drop it the moment one of those tags changes, rather than waiting out
6
+ * `s-maxage`. What calls the purge is the app's: every CDN has its own API, so
7
+ * the engine has one hook, `installPurge()`, and `changed(tag)` calls it with
8
+ * the names that changed - after the answer has gone, through `after()`, so a
9
+ * slow CDN never holds up the action that made the change.
10
+ *
11
+ * Homeport is the one installed for you: with `HOMEPORT_PURGE_URL` set, the
12
+ * hook is `homeportPurge()` unless the app installed another.
13
+ *
14
+ * A Laravel or Go backend changes its names itself (`Rsc::changed()`,
15
+ * `rsckit.Changed()`), and the engine never hears of it, so each backend
16
+ * purges for its own changes. This hook covers `changed()` called here.
17
+ */
18
+ function hooks() {
19
+ return (globalThis[Symbol.for("rsc-kit.after")] ?? {
20
+ after: (work) => void Promise.resolve().then(work),
21
+ requestKey: () => null,
22
+ });
23
+ }
24
+ // On the global, like the cache store: an app installs through its own copy
25
+ // of the package, and the engine's copy is the one that calls it.
26
+ const PURGER = Symbol.for("rsc-kit.purger");
27
+ const globals = globalThis;
28
+ /** Install the app's purge. `null` takes it out, and Homeport's default with it. */
29
+ export function installPurge(purger) {
30
+ globals[PURGER] = purger;
31
+ }
32
+ /** The purge in use: the app's, else Homeport's when its url is set, else none. */
33
+ export function installedPurge() {
34
+ const installed = globals[PURGER];
35
+ if (installed !== undefined)
36
+ return installed;
37
+ const url = homeportPurgeUrl();
38
+ return url ? homeportPurge({ url }) : null;
39
+ }
40
+ function homeportPurgeUrl() {
41
+ return globalThis.process?.env?.HOMEPORT_PURGE_URL || undefined;
42
+ }
43
+ // The names an action changed go out as one purge when its answer has gone:
44
+ // three changed() calls send one request, not three. Gathered per request,
45
+ // so a request whose after() work never ran cannot hold back another's.
46
+ const pendingFor = new WeakMap();
47
+ /**
48
+ * @internal For changed(): purge these tags once the answer has gone, or at
49
+ * once outside a request. Does nothing when no purge is installed.
50
+ */
51
+ export function purgeLater(tags) {
52
+ if (tags.length === 0 || !installedPurge())
53
+ return;
54
+ const { after, requestKey } = hooks();
55
+ const key = requestKey();
56
+ if (!key) {
57
+ after(() => purge({ tags }));
58
+ return;
59
+ }
60
+ let pending = pendingFor.get(key);
61
+ if (pending) {
62
+ for (const tag of tags)
63
+ pending.add(tag);
64
+ return;
65
+ }
66
+ pending = new Set(tags);
67
+ pendingFor.set(key, pending);
68
+ const gathered = pending;
69
+ after(() => {
70
+ pendingFor.delete(key);
71
+ return purge({ tags: [...gathered] });
72
+ });
73
+ }
74
+ let warnedPurge = false;
75
+ /**
76
+ * Send a purge now, through the installed purge. For an app's own use - a
77
+ * deploy step, a webhook - as well as changed()'s. Never throws: the change
78
+ * it follows is already made, and a CDN that missed it still drops the copy
79
+ * at `s-maxage`. The first failure is logged.
80
+ */
81
+ export async function purge(what) {
82
+ const purger = installedPurge();
83
+ if (!purger)
84
+ return;
85
+ try {
86
+ await purger(what);
87
+ }
88
+ catch (error) {
89
+ if (!warnedPurge) {
90
+ warnedPurge = true;
91
+ console.warn("[rsc-kit] purging the CDN failed; its copy is dropped at s-maxage instead: " +
92
+ (error instanceof Error ? error.message : String(error)));
93
+ }
94
+ }
95
+ }
96
+ /** Homeport takes at most this many tags, and this many paths, per call. */
97
+ export const HOMEPORT_PURGE_LIMIT = 100;
98
+ /**
99
+ * Homeport's purge: `POST {tags, paths, all}` to the url Homeport gives the
100
+ * app, no token - it is reachable only from inside the app's own network.
101
+ * Split into calls of 100 tags and 100 paths. A 429 is tried once more after
102
+ * its `Retry-After` (at most five seconds); anything else that is not a 2xx
103
+ * rejects.
104
+ */
105
+ export function homeportPurge(options = {}) {
106
+ return async (what) => {
107
+ const url = options.url ?? homeportPurgeUrl();
108
+ if (!url)
109
+ throw new Error("homeportPurge() has no url: HOMEPORT_PURGE_URL is not set.");
110
+ const send = options.fetch ?? fetch;
111
+ const tags = [...(what.tags ?? [])];
112
+ const paths = [...(what.paths ?? [])];
113
+ const bodies = [];
114
+ if (what.all)
115
+ bodies.push({ all: true });
116
+ while (tags.length > 0 || paths.length > 0) {
117
+ const body = {};
118
+ const someTags = tags.splice(0, HOMEPORT_PURGE_LIMIT);
119
+ const somePaths = paths.splice(0, HOMEPORT_PURGE_LIMIT);
120
+ if (someTags.length > 0)
121
+ body.tags = someTags;
122
+ if (somePaths.length > 0)
123
+ body.paths = somePaths;
124
+ bodies.push(body);
125
+ }
126
+ for (const body of bodies) {
127
+ await post(send, url, body);
128
+ }
129
+ };
130
+ }
131
+ async function post(send, url, body, retried = false) {
132
+ const response = await send(url, {
133
+ method: "POST",
134
+ headers: { "Content-Type": "application/json" },
135
+ body: JSON.stringify(body),
136
+ });
137
+ await response.body?.cancel();
138
+ if (response.ok)
139
+ return;
140
+ if (response.status === 429 && !retried) {
141
+ const seconds = Math.min(Number(response.headers.get("Retry-After")) || 1, 5);
142
+ await new Promise((resolve) => setTimeout(resolve, seconds * 1000));
143
+ return post(send, url, body, true);
144
+ }
145
+ throw new Error(`Homeport answered ${response.status} to a purge of ${JSON.stringify(body)}.`);
146
+ }
147
+ //# sourceMappingURL=purge.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"purge.js","sourceRoot":"","sources":["../src/purge.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Telling a CDN that kept pages changed.\n *\n * A `revalidate` page goes out with `Cache-Tag`, and a CDN that purges by tag\n * can drop it the moment one of those tags changes, rather than waiting out\n * `s-maxage`. What calls the purge is the app's: every CDN has its own API, so\n * the engine has one hook, `installPurge()`, and `changed(tag)` calls it with\n * the names that changed - after the answer has gone, through `after()`, so a\n * slow CDN never holds up the action that made the change.\n *\n * Homeport is the one installed for you: with `HOMEPORT_PURGE_URL` set, the\n * hook is `homeportPurge()` unless the app installed another.\n *\n * A Laravel or Go backend changes its names itself (`Rsc::changed()`,\n * `rsckit.Changed()`), and the engine never hears of it, so each backend\n * purges for its own changes. This hook covers `changed()` called here.\n */\n\n// Not imported: @rsc-kit/core/changed reaches this file and a client component\n// may import that, so request.ts - server-only - is found through the global\n// it sets when the server loads it. Without it, there is no request to wait\n// for, and a purge goes out at once.\ntype AfterHooks = { after: (work: () => unknown) => void; requestKey: () => object | null };\n\nfunction hooks(): AfterHooks {\n return (\n ((globalThis as Record<symbol, unknown>)[Symbol.for(\"rsc-kit.after\")] as AfterHooks | undefined) ?? {\n after: (work) => void Promise.resolve().then(work),\n requestKey: () => null,\n }\n );\n}\n\n/** What a purge drops: pages carrying any of these tags, pages at these paths, or everything. */\nexport interface Purge {\n tags?: readonly string[];\n paths?: readonly string[];\n all?: boolean;\n}\n\n/** Sends a purge to a CDN. A rejection is logged; it never fails the change. */\nexport type Purger = (purge: Purge) => Promise<void> | void;\n\n// On the global, like the cache store: an app installs through its own copy\n// of the package, and the engine's copy is the one that calls it.\nconst PURGER = Symbol.for(\"rsc-kit.purger\");\nconst globals = globalThis as unknown as Record<symbol, Purger | null | undefined>;\n\n/** Install the app's purge. `null` takes it out, and Homeport's default with it. */\nexport function installPurge(purger: Purger | null): void {\n globals[PURGER] = purger;\n}\n\n/** The purge in use: the app's, else Homeport's when its url is set, else none. */\nexport function installedPurge(): Purger | null {\n const installed = globals[PURGER];\n\n if (installed !== undefined) return installed;\n\n const url = homeportPurgeUrl();\n\n return url ? homeportPurge({ url }) : null;\n}\n\nfunction homeportPurgeUrl(): string | undefined {\n return (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env?.HOMEPORT_PURGE_URL || undefined;\n}\n\n// The names an action changed go out as one purge when its answer has gone:\n// three changed() calls send one request, not three. Gathered per request,\n// so a request whose after() work never ran cannot hold back another's.\nconst pendingFor = new WeakMap<object, Set<string>>();\n\n/**\n * @internal For changed(): purge these tags once the answer has gone, or at\n * once outside a request. Does nothing when no purge is installed.\n */\nexport function purgeLater(tags: readonly string[]): void {\n if (tags.length === 0 || !installedPurge()) return;\n\n const { after, requestKey } = hooks();\n const key = requestKey();\n\n if (!key) {\n after(() => purge({ tags }));\n\n return;\n }\n\n let pending = pendingFor.get(key);\n\n if (pending) {\n for (const tag of tags) pending.add(tag);\n\n return;\n }\n\n pending = new Set(tags);\n pendingFor.set(key, pending);\n\n const gathered = pending;\n\n after(() => {\n pendingFor.delete(key);\n\n return purge({ tags: [...gathered] });\n });\n}\n\nlet warnedPurge = false;\n\n/**\n * Send a purge now, through the installed purge. For an app's own use - a\n * deploy step, a webhook - as well as changed()'s. Never throws: the change\n * it follows is already made, and a CDN that missed it still drops the copy\n * at `s-maxage`. The first failure is logged.\n */\nexport async function purge(what: Purge): Promise<void> {\n const purger = installedPurge();\n\n if (!purger) return;\n\n try {\n await purger(what);\n } catch (error) {\n if (!warnedPurge) {\n warnedPurge = true;\n console.warn(\n \"[rsc-kit] purging the CDN failed; its copy is dropped at s-maxage instead: \" +\n (error instanceof Error ? error.message : String(error)),\n );\n }\n }\n}\n\n/** Homeport takes at most this many tags, and this many paths, per call. */\nexport const HOMEPORT_PURGE_LIMIT = 100;\n\nexport interface HomeportPurgeOptions {\n /** Where to post. Default `HOMEPORT_PURGE_URL`, which Homeport sets. */\n url?: string;\n /** For tests. */\n fetch?: typeof fetch;\n}\n\n/**\n * Homeport's purge: `POST {tags, paths, all}` to the url Homeport gives the\n * app, no token - it is reachable only from inside the app's own network.\n * Split into calls of 100 tags and 100 paths. A 429 is tried once more after\n * its `Retry-After` (at most five seconds); anything else that is not a 2xx\n * rejects.\n */\nexport function homeportPurge(options: HomeportPurgeOptions = {}): Purger {\n return async (what) => {\n const url = options.url ?? homeportPurgeUrl();\n\n if (!url) throw new Error(\"homeportPurge() has no url: HOMEPORT_PURGE_URL is not set.\");\n\n const send = options.fetch ?? fetch;\n const tags = [...(what.tags ?? [])];\n const paths = [...(what.paths ?? [])];\n const bodies: Purge[] = [];\n\n if (what.all) bodies.push({ all: true });\n\n while (tags.length > 0 || paths.length > 0) {\n const body: Purge = {};\n const someTags = tags.splice(0, HOMEPORT_PURGE_LIMIT);\n const somePaths = paths.splice(0, HOMEPORT_PURGE_LIMIT);\n\n if (someTags.length > 0) body.tags = someTags;\n if (somePaths.length > 0) body.paths = somePaths;\n bodies.push(body);\n }\n\n for (const body of bodies) {\n await post(send, url, body);\n }\n };\n}\n\nasync function post(send: typeof fetch, url: string, body: Purge, retried = false): Promise<void> {\n const response = await send(url, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify(body),\n });\n\n await response.body?.cancel();\n\n if (response.ok) return;\n\n if (response.status === 429 && !retried) {\n const seconds = Math.min(Number(response.headers.get(\"Retry-After\")) || 1, 5);\n\n await new Promise((resolve) => setTimeout(resolve, seconds * 1000));\n\n return post(send, url, body, true);\n }\n\n throw new Error(`Homeport answered ${response.status} to a purge of ${JSON.stringify(body)}.`);\n}\n"]}
package/dist/request.d.ts CHANGED
@@ -216,7 +216,9 @@ export declare function renderingAsNobody(): boolean;
216
216
  * everyone: the kept copy is stale when any of them changes, and its Cache-Tag
217
217
  * lets a CDN purge it then. A no-op anywhere else.
218
218
  */
219
- export declare function noteCacheTags(tags: readonly string[]): void;
219
+ export declare function noteCacheTags(tags: readonly string[], versions?: Record<string, number>): void;
220
+ /** The version of each tag noteCacheTags() collected, as it was first read. */
221
+ export declare function cacheTagVersionsRead(): Record<string, number>;
220
222
  /** The tags noteCacheTags() collected in the render that is open, in order. */
221
223
  export declare function cacheTagsRead(): string[];
222
224
  /** What read the visitor in a render renderAsNobody() marked, in order. */
@@ -268,6 +270,12 @@ export declare function noteFallback(text: string): void;
268
270
  * gone. Outside a request - a build - the work runs at once.
269
271
  */
270
272
  export declare function after(work: () => unknown): void;
273
+ /**
274
+ * @internal The request being answered, as an object to key per-request
275
+ * state on - null outside one. For work that gathers itself into one after():
276
+ * a purge of every name an action changed.
277
+ */
278
+ export declare function requestKey(): object | null;
271
279
  /**
272
280
  * @internal For the host: the work `after()` collected for this request, as
273
281
  * one promise that never rejects. Taken once; a second call has nothing.
package/dist/request.js CHANGED
@@ -632,14 +632,24 @@ export function renderingAsNobody() {
632
632
  * everyone: the kept copy is stale when any of them changes, and its Cache-Tag
633
633
  * lets a CDN purge it then. A no-op anywhere else.
634
634
  */
635
- export function noteCacheTags(tags) {
635
+ export function noteCacheTags(tags, versions = {}) {
636
636
  const store = scope()?.getStore();
637
637
  if (!store?.request || !store.asNobody || tags.length === 0)
638
638
  return;
639
639
  const list = (store.tags ??= []);
640
- for (const tag of tags)
640
+ const seen = (store.tagVersions ??= {});
641
+ for (const tag of tags) {
641
642
  if (!list.includes(tag))
642
643
  list.push(tag);
644
+ // The first read wins: a tag that moved mid-render leaves the older
645
+ // version on the kept page, so the next lookup sees the move and misses.
646
+ if (!(tag in seen))
647
+ seen[tag] = versions[tag] ?? 0;
648
+ }
649
+ }
650
+ /** The version of each tag noteCacheTags() collected, as it was first read. */
651
+ export function cacheTagVersionsRead() {
652
+ return { ...(scope()?.getStore()?.tagVersions ?? {}) };
643
653
  }
644
654
  /** The tags noteCacheTags() collected in the render that is open, in order. */
645
655
  export function cacheTagsRead() {
@@ -732,6 +742,18 @@ export function after(work) {
732
742
  }
733
743
  store.after.push(work);
734
744
  }
745
+ /**
746
+ * @internal The request being answered, as an object to key per-request
747
+ * state on - null outside one. For work that gathers itself into one after():
748
+ * a purge of every name an action changed.
749
+ */
750
+ export function requestKey() {
751
+ const store = scope()?.getStore();
752
+ return store?.request ? store : null;
753
+ }
754
+ // For purge.ts, which @rsc-kit/core/changed brings into client code and so
755
+ // cannot import this file: the two calls it needs, on the global.
756
+ globalThis[Symbol.for("rsc-kit.after")] = { after, requestKey };
735
757
  function reportAfter(error) {
736
758
  console.error("[rsc-kit] after() work failed:", error);
737
759
  }