@astilba/cache 0.0.0-stage → 0.1.0

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.
@@ -0,0 +1,92 @@
1
+ import { Ct as TelemetrySink, O as RequestContext, _t as SinkErrorHook, t as Cache, x as L3Budget, yt as TELEMETRY_EVENTS } from "../../cache-YWn0VZpn.js";
2
+ import { MiddlewareFunction, RouterContext } from "react-router";
3
+ //#region src/adapters/react-router/middleware.d.ts
4
+ /**
5
+ * The RR-v8 middleware argument object (the first parameter RR passes a `MiddlewareFunction`:
6
+ * `{ request, url, params, context }`, `context` being the request's `RouterContextProvider`).
7
+ * Re-exported off `MiddlewareFunction` so the `request` derivation option below is typed without
8
+ * naming react-router's private `DataFunctionArgs`.
9
+ */
10
+ type CacheMiddlewareArgs = Parameters<MiddlewareFunction>[0];
11
+ /** The single telemetry event a shared-cache-INELIGIBLE response emits — exactly
12
+ * one per response, naming the reason CLASS in its `reason` field and nothing else. No tag names
13
+ * ride the payload: which dependency did it is a question for `explain()`, and an operator
14
+ * watching this counter only needs to see THAT a route stopped being CDN-cacheable. */
15
+ declare const L3_INELIGIBLE: typeof TELEMETRY_EVENTS.l3_ineligible;
16
+ /**
17
+ * The shared-cache header budget: 16 KB aggregate, ~1,000 tags. (The 1,024-char cap is a
18
+ * purge-API limit, not a header one — header tags carry no per-tag cap.) Overridable per
19
+ * middleware via `CacheMiddlewareOptions.l3Budget`.
20
+ */
21
+ declare const L3_BUDGET_DEFAULT: L3Budget;
22
+ /**
23
+ * `cacheMiddleware` configuration. `cache` is the already-built instance the middleware provides
24
+ * into the request lifecycle (a `createWorkersCache(...)` / `createCache(...)` result). `request`
25
+ * is the OPTIONAL principal derivation: it maps the RR request to the identity fields the kernel
26
+ * reads (`userId`/`tenant`) — the app owns that mapping because identity is app-domain.
27
+ * Omit it and the request carries no derivable principal (the shared `pub` class, 4).
28
+ *
29
+ * `waitUntil` is the Workers `ExecutionContext.waitUntil` the poll tick rides on — pass
30
+ * `context.cloudflare.ctx.waitUntil` (RR's Cloudflare load context) or `waitUntil` from
31
+ * `cloudflare:workers`, bound. It is OPTIONAL and never load-bearing for correctness: without it the
32
+ * tick still fires (unawaited, best-effort) and simply is not kept alive past the response. It is
33
+ * never awaited on the request path either way — the tick races the loaders, it does not gate them.
34
+ *
35
+ * `telemetry` receives the driver's swallowed-tick counter (`poll_tick_failed`) and the single
36
+ * `l3_ineligible` event a demoted response emits. Every delivery to it goes through
37
+ * `safeEmit`, so a throwing/rejecting sink is swallowed; `onSinkError` is the OPTIONAL injectable
38
+ * hook `safeEmit` calls when it swallows such a failure — a sink for reporting the reporter.
39
+ *
40
+ * `l3Budget` overrides the header budget (`L3_BUDGET_DEFAULT`).
41
+ */
42
+ interface CacheMiddlewareOptions {
43
+ readonly cache: Cache;
44
+ readonly request?: (args: CacheMiddlewareArgs) => RequestContext;
45
+ readonly waitUntil?: (promise: Promise<unknown>) => void;
46
+ readonly telemetry?: TelemetrySink;
47
+ readonly onSinkError?: SinkErrorHook;
48
+ readonly l3Budget?: L3Budget;
49
+ }
50
+ /**
51
+ * The `RouterContext<Cache>` key the middleware writes the cache under. A loader/action reads the
52
+ * request's cache with `context.get(cacheContext)`. Created with no default value, so a `get` in a
53
+ * request the middleware never ran on throws (an honest misconfiguration signal, not a silent miss).
54
+ */
55
+ declare const cacheContext: RouterContext<Cache>;
56
+ /**
57
+ * The current request's principal-bearing `RequestContext`, or `undefined` outside a middleware
58
+ * frame. Pass it straight through to `getOrSet`'s `request` option so the kernel derives the
59
+ * request's scope: `cache.getOrSet({ key, request: currentRequest(), factory })`.
60
+ */
61
+ declare function currentRequest(): RequestContext | undefined;
62
+ /**
63
+ * Build the RR-v8 root middleware. Always-on in v8 (the `unstable_`/`v8_middleware` flag
64
+ * was removed at 8.0.0): register the returned function in a route module's `middleware` array. It
65
+ * provides the cache into `context`, opens the request + collector frames, fires the replication
66
+ * poll tick (unawaited), `await`s `next()`, commits the collector, and applies the
67
+ * header algorithm.
68
+ */
69
+ declare function cacheMiddleware(options: CacheMiddlewareOptions): MiddlewareFunction;
70
+ //#endregion
71
+ //#region src/adapters/react-router/poll-driver.d.ts
72
+ /**
73
+ * Minimum ms between two piggybacked ticks on ONE cache instance. Set to the ladder's fastest rung
74
+ * (~1s): ticking faster can never make the poller act sooner (every path it drives is gated by its
75
+ * own due-time), so anything below this is pure waste under load.
76
+ */
77
+ declare const TICK_MIN_INTERVAL_MS = 1000;
78
+ /**
79
+ * The counter a swallowed tick failure increments. The tick runs OUT-OF-BAND from the response,
80
+ * so its failure must never surface to the caller — but a silently failing replication poll is
81
+ * exactly the kind of degradation an operator has to be able to see, so the swallow is paired with
82
+ * this event. Deliberately payload-free apart from the error's NAME (a code, never free-form detail
83
+ * or a key/tag reference), so a plain sink can be pointed at it safely.
84
+ *
85
+ * NOTE: `registry_degraded` / `state_stale` — the richer telemetry names this event may
86
+ * eventually be folded into — are now ENROLLED in the const-union catalog (`TELEMETRY_EVENTS`) as
87
+ * RESERVED members, but their emit condition is unruled: `poll_tick_failed` keeps emitting as-is.
88
+ * (FOR FABLE: whether to enrich/rename this counter into a degraded-mode signal is an open question.)
89
+ */
90
+ declare const POLL_TICK_FAILED: typeof TELEMETRY_EVENTS.poll_tick_failed;
91
+ //#endregion
92
+ export { type CacheMiddlewareArgs, type CacheMiddlewareOptions, L3_BUDGET_DEFAULT, L3_INELIGIBLE, POLL_TICK_FAILED, TICK_MIN_INTERVAL_MS, cacheContext, cacheMiddleware, currentRequest };
@@ -0,0 +1,272 @@
1
+ import { a as replicationPollerOf, i as managedDependenciesOf, m as safeEmit, n as bindRenderCollector, o as resolveCacheIdentity, p as TELEMETRY_EVENTS } from "../../cache-Z4cJzPmm.js";
2
+ import { AsyncLocalStorage } from "node:async_hooks";
3
+ import { createContext } from "react-router";
4
+ //#region src/adapters/react-router/poll-driver.ts
5
+ /**
6
+ * Minimum ms between two piggybacked ticks on ONE cache instance. Set to the ladder's fastest rung
7
+ * (~1s): ticking faster can never make the poller act sooner (every path it drives is gated by its
8
+ * own due-time), so anything below this is pure waste under load.
9
+ */
10
+ const TICK_MIN_INTERVAL_MS = 1e3;
11
+ /**
12
+ * The counter a swallowed tick failure increments. The tick runs OUT-OF-BAND from the response,
13
+ * so its failure must never surface to the caller — but a silently failing replication poll is
14
+ * exactly the kind of degradation an operator has to be able to see, so the swallow is paired with
15
+ * this event. Deliberately payload-free apart from the error's NAME (a code, never free-form detail
16
+ * or a key/tag reference), so a plain sink can be pointed at it safely.
17
+ *
18
+ * NOTE: `registry_degraded` / `state_stale` — the richer telemetry names this event may
19
+ * eventually be folded into — are now ENROLLED in the const-union catalog (`TELEMETRY_EVENTS`) as
20
+ * RESERVED members, but their emit condition is unruled: `poll_tick_failed` keeps emitting as-is.
21
+ * (FOR FABLE: whether to enrich/rename this counter into a degraded-mode signal is an open question.)
22
+ */
23
+ const POLL_TICK_FAILED = TELEMETRY_EVENTS.poll_tick_failed;
24
+ /** Last piggybacked tick per cache instance — the staleness gate's cursor. Per-isolate by nature
25
+ * (an isolate holds one `Cache`), and keyed by the instance so two caches in one isolate never
26
+ * starve each other's ticks.
27
+ *
28
+ * Keyed by RESOLVED identity (the third instance-keyed seam, and the one that does not live beside
29
+ * the other two). A facade and the instance it fronts drive the SAME poller, so they must share the
30
+ * SAME gate: two gates over one poller would let an app holding both objects tick it twice per
31
+ * window, which is exactly the hammering the gate exists to stop. */
32
+ const lastTickMs = /* @__PURE__ */ new WeakMap();
33
+ /**
34
+ * THE SWALLOW WRAP (adapter-side; the guard itself now lives in the shared `safeEmit`).
35
+ * `ctx.waitUntil(tick())` on a rejecting promise surfaces as an unhandled rejection in the request's
36
+ * lifecycle — a failing L2 read would then be able to reach the response of a request that never
37
+ * needed that read. So the tick is wrapped: catch → count → swallow. Adapter code by ruling, not
38
+ * kernel: the kernel's `resync` is entitled to reject, and the driver that chose to run it
39
+ * out-of-band is what owes the caller its silence.
40
+ *
41
+ * The returned promise NEVER rejects — and the guarantee is AIRTIGHT over the whole failure surface,
42
+ * INCLUDING the reporting channel itself: the telemetry emit is routed through `safeEmit`, so
43
+ * neither a throwing sink nor a rejecting `async` sink can turn the swallow into an escape, and the
44
+ * injected `onSinkError` hook observes a swallowed sink failure. (The default `waitUntil` drops
45
+ * the promise on the floor; a rejection here would therefore become an unhandled rejection —
46
+ * process-fatal on the Node/RR lane, an uncaught request error on workerd. A background freshness
47
+ * probe must never be able to kill a user's request.)
48
+ */
49
+ async function runTick(poller, nowMs, telemetry, onSinkError) {
50
+ try {
51
+ await poller.onPollTick(nowMs);
52
+ } catch (error) {
53
+ safeEmit(telemetry, {
54
+ error: error instanceof Error ? error.name : "unknown",
55
+ type: POLL_TICK_FAILED
56
+ }, onSinkError);
57
+ }
58
+ }
59
+ /**
60
+ * Fire one replication poll tick for `cache` IF the last one is stale (see `TICK_MIN_INTERVAL_MS`).
61
+ *
62
+ * Returns the in-flight tick — hand it straight to `ctx.waitUntil` so it outlives the response — or
63
+ * `undefined` when nothing is due (the gate held, or the cache is registry-absent and has no reader
64
+ * to drive). The returned promise NEVER rejects: a store/resync failure inside the tick is caught,
65
+ * counted through `telemetry` (`poll_tick_failed`), and swallowed, because a background freshness
66
+ * probe has no business failing a user's response. The `telemetry` sink is NOT trusted to keep that
67
+ * promise: it is app code on the failure path, so a sink that throws — or an `async` sink that
68
+ * rejects — is neutralized too, and floats nothing.
69
+ */
70
+ function tickIfStale(cache, nowMs, telemetry, onSinkError) {
71
+ const poller = replicationPollerOf(cache);
72
+ if (poller === void 0) return;
73
+ const gateKey = resolveCacheIdentity(cache);
74
+ const last = lastTickMs.get(gateKey);
75
+ if (last !== void 0 && nowMs - last < 1e3) return;
76
+ lastTickMs.set(gateKey, nowMs);
77
+ return runTick(poller, nowMs, telemetry, onSinkError);
78
+ }
79
+ //#endregion
80
+ //#region src/adapters/react-router/middleware.ts
81
+ /** The single telemetry event a shared-cache-INELIGIBLE response emits — exactly
82
+ * one per response, naming the reason CLASS in its `reason` field and nothing else. No tag names
83
+ * ride the payload: which dependency did it is a question for `explain()`, and an operator
84
+ * watching this counter only needs to see THAT a route stopped being CDN-cacheable. */
85
+ const L3_INELIGIBLE = TELEMETRY_EVENTS.l3_ineligible;
86
+ /**
87
+ * The shared-cache header budget: 16 KB aggregate, ~1,000 tags. (The 1,024-char cap is a
88
+ * purge-API limit, not a header one — header tags carry no per-tag cap.) Overridable per
89
+ * middleware via `CacheMiddlewareOptions.l3Budget`.
90
+ */
91
+ const L3_BUDGET_DEFAULT = {
92
+ maxBytes: 16384,
93
+ maxTags: 1e3
94
+ };
95
+ /**
96
+ * The reason CLASS a telemetry consumer sees, mapped from the kernel's `L3Ineligibility` union.
97
+ * TWO VOCABULARIES, ONE MAPPING POINT — deliberately: the kernel's union is kebab (its own existing
98
+ * convention, `late-tag`), while the event vocabulary is snake (`poll_tick_failed`,
99
+ * `singleflight_option_mismatch`), and the design fixes `scope_unreadable` as the literal class name. This
100
+ * function is the ONLY place the two meet; nothing downstream re-spells either.
101
+ */
102
+ const REASON_CLASS = {
103
+ budget: "budget",
104
+ "late-tag": "late_tag",
105
+ scope: "scope",
106
+ "scope-unreadable": "scope_unreadable"
107
+ };
108
+ /** The two response headers this adapter governs. `Cache-Control` carries the shared-cache
109
+ * posture; `Cache-Tag` is the CDN purge handle keyed off the render's dependency tags. */
110
+ const CACHE_CONTROL = "Cache-Control";
111
+ const CACHE_TAG = "Cache-Tag";
112
+ /** The one directive the adapter ever WRITES to `Cache-Control`. It never writes
113
+ * `public` or an `s-maxage` — opting into shared caching is the application's call, and the
114
+ * adapter's job is to emit the purge tags when that is safe and to demote when it is not. */
115
+ const PRIVATE = "private";
116
+ /**
117
+ * The `RouterContext<Cache>` key the middleware writes the cache under. A loader/action reads the
118
+ * request's cache with `context.get(cacheContext)`. Created with no default value, so a `get` in a
119
+ * request the middleware never ran on throws (an honest misconfiguration signal, not a silent miss).
120
+ */
121
+ const cacheContext = createContext();
122
+ /** The empty principal-bearing frame used when no `request` derivation is configured — a request
123
+ * with no adapter-visible identity: `derivePrincipal` finds none ⇒ the `pub` class. */
124
+ const EMPTY_REQUEST_CONTEXT = Object.freeze({});
125
+ const requestFrame = new AsyncLocalStorage();
126
+ const collectorFrame = new AsyncLocalStorage();
127
+ /**
128
+ * The current request's principal-bearing `RequestContext`, or `undefined` outside a middleware
129
+ * frame. Pass it straight through to `getOrSet`'s `request` option so the kernel derives the
130
+ * request's scope: `cache.getOrSet({ key, request: currentRequest(), factory })`.
131
+ */
132
+ function currentRequest() {
133
+ return requestFrame.getStore();
134
+ }
135
+ /** The `waitUntil` used when the app configures none: the tick still runs, it is simply not held
136
+ * alive past the response (best-effort, and safe — the driver's promise never rejects). */
137
+ function floatingWaitUntil(promise) {}
138
+ /** Nothing to write — the app's headers stand exactly as authored. */
139
+ const NO_EDIT = Object.freeze({});
140
+ /**
141
+ * Decide the posture for the response, given the render's emission and how many
142
+ * dependencies that render consumed (`managed` — hits, fills, and explicit `dependsOn`
143
+ * declarations, read off the kernel's in-package seam; it is deliberately NOT a field of the
144
+ * spec-verbatim public `L3Emission`, which stays exactly the shape names).
145
+ *
146
+ * THREE postures, in precedence order:
147
+ *
148
+ * 1. NO MANAGED DEPENDENCY (the jurisdiction floor). The render consumed nothing this library
149
+ * manages, so the adapter asserts nothing: it fills in `Cache-Control: private` ONLY where the
150
+ * application set no cache header at all, and it leaves an app-set `Cache-Tag` alone. A
151
+ * default-fill, never an override — the adapter has observed nothing that licenses an opinion.
152
+ * This is also what separates the floor from an ELIGIBLE render that happens to emit no tag:
153
+ * the emission alone cannot tell those apart, and they get different postures.
154
+ *
155
+ * 2. INELIGIBLE (scope-poisoned, or late-tag/budget). `Cache-Control: private` is FORCED — it
156
+ * overwrites whatever the application set, because the application's own header is what has to
157
+ * lose here: the whole point of the branch is a route that asked to be publicly cached while
158
+ * its render read per-principal content. Any `Cache-Tag` is DELETED, including one the adapter
159
+ * never assembled: removing it, rather than declining to add one, is what the branch is for —
160
+ * a downstream cache parses the header off the wire whether or not it stores the response.
161
+ *
162
+ * 3. ELIGIBLE. The adapter's own assembled `Cache-Tag` replaces whatever was there — DEDUPLICATED
163
+ * here, at the join. One entry read by several loaders in a render reaches the collector once
164
+ * per consumption, so its tag arrives once per consumption; a header that repeats the same
165
+ * purge handle buys nothing and costs bytes. The dedup lives HERE and NOT in the collector on
166
+ * purpose: `commitHeaders`'s BUDGET counts every occurrence (that is the measure invariant
167
+ * 13 pins — collapsing duplicates there would make an over-budget tag list come out eligible),
168
+ * and emitting fewer bytes than were measured is always safe, while measuring fewer bytes than
169
+ * are emitted is not. The adapter does NOT fabricate public caching — no `public`, no
170
+ * `s-maxage`, ever. The application opts into shared caching with its own `Cache-Control`; the
171
+ * adapter's job is to emit the purge tags when that is safe and to demote when it is not. So
172
+ * the safe `private` default still fills in where the app wrote no cache header.
173
+ */
174
+ function decidePosture(emission, managed, appSetCacheControl) {
175
+ const safeDefault = appSetCacheControl ? NO_EDIT : { cacheControl: PRIVATE };
176
+ if (managed === 0) return safeDefault;
177
+ if (!emission.eligible) return {
178
+ cacheControl: PRIVATE,
179
+ cacheTag: null
180
+ };
181
+ const emitted = [...new Set(emission.cacheTags)];
182
+ return {
183
+ ...safeDefault,
184
+ cacheTag: emitted.length > 0 ? emitted.join(",") : null
185
+ };
186
+ }
187
+ /** Write one decided posture onto a mutable `Headers`. Throws iff the guard is immutable — which is
188
+ * exactly the signal `applyHeaders` acts on, and never a partial write: an immutable guard rejects
189
+ * the FIRST mutation, so either the whole posture lands or none of it does. */
190
+ function writePosture(headers, posture) {
191
+ if (posture.cacheControl !== void 0) headers.set(CACHE_CONTROL, posture.cacheControl);
192
+ if (posture.cacheTag === null) headers.delete(CACHE_TAG);
193
+ else if (posture.cacheTag !== void 0) headers.set(CACHE_TAG, posture.cacheTag);
194
+ }
195
+ /**
196
+ * Rebuild `response` with a MUTABLE header guard and the posture written onto it, preserving status,
197
+ * statusText, body, and every header the original carried (the `Response(body, response)` init form
198
+ * copies all three). `undefined` when the platform refuses to reconstruct the response at all — see
199
+ * `applyHeaders` for why that residue is safe.
200
+ */
201
+ function rebuiltWithPosture(response, posture) {
202
+ try {
203
+ const rebuilt = new Response(response.body, response);
204
+ writePosture(rebuilt.headers, posture);
205
+ return rebuilt;
206
+ } catch {
207
+ return;
208
+ }
209
+ }
210
+ /**
211
+ * Apply the posture to the response and return the response to send.
212
+ *
213
+ * NOT ALL RESPONSES ARE WRITABLE. A `Response` obtained from `Response.redirect(...)`, from a
214
+ * `fetch()` passthrough, or from `caches.match(...)` carries an IMMUTABLE header guard: every
215
+ * `set`/`delete` on it throws `TypeError`. A middleware that mutates headers unconditionally
216
+ * therefore rejects its own promise on such a response and turns it into a 500 — a route that
217
+ * merely redirects would break. So the write is attempted, and on refusal the SAME decided posture
218
+ * is written onto a rebuilt copy (status, statusText, body and headers all preserved) which is
219
+ * returned in the original's place.
220
+ *
221
+ * THE RESIDUE, and why it is safe: a response the platform will not let us reconstruct either
222
+ * (`Response.error()` and the other sub-200 network-error forms) is returned untouched. Such a
223
+ * response carries NO readable or writable headers at all — there is no app-set `Cache-Control` to
224
+ * override and no `Cache-Tag` for a shared cache to read off it — so declining to write a posture
225
+ * onto it withholds nothing and leaks nothing. The one thing that must never happen — a throw out
226
+ * of the middleware — cannot.
227
+ *
228
+ * The telemetry event an ineligible response owes is emitted exactly once, off the
229
+ * DECISION, so it is independent of which of the three write paths the response takes: one event per
230
+ * response, naming the reason CLASS, carrying no tag names.
231
+ */
232
+ function applyHeaders(response, emission, telemetry, onSinkError) {
233
+ const managed = managedDependenciesOf(emission);
234
+ const posture = decidePosture(emission, managed, response.headers.has(CACHE_CONTROL));
235
+ if (managed > 0 && !emission.eligible && emission.ineligibleReason !== void 0) safeEmit(telemetry, {
236
+ reason: REASON_CLASS[emission.ineligibleReason],
237
+ type: L3_INELIGIBLE
238
+ }, onSinkError);
239
+ if (posture === NO_EDIT) return response;
240
+ try {
241
+ writePosture(response.headers, posture);
242
+ return response;
243
+ } catch {}
244
+ return rebuiltWithPosture(response, posture) ?? response;
245
+ }
246
+ /**
247
+ * Build the RR-v8 root middleware. Always-on in v8 (the `unstable_`/`v8_middleware` flag
248
+ * was removed at 8.0.0): register the returned function in a route module's `middleware` array. It
249
+ * provides the cache into `context`, opens the request + collector frames, fires the replication
250
+ * poll tick (unawaited), `await`s `next()`, commits the collector, and applies the
251
+ * header algorithm.
252
+ */
253
+ function cacheMiddleware(options) {
254
+ const { cache, l3Budget = L3_BUDGET_DEFAULT, onSinkError, request: deriveRequest, telemetry, waitUntil = floatingWaitUntil } = options;
255
+ bindRenderCollector(cache, () => collectorFrame.getStore());
256
+ return async (args, next) => {
257
+ const requestContext = deriveRequest?.(args) ?? EMPTY_REQUEST_CONTEXT;
258
+ const collector = cache.collect();
259
+ return await requestFrame.run(requestContext, async () => {
260
+ args.context.set(cacheContext, cache);
261
+ const tick = tickIfStale(cache, Date.now(), telemetry, onSinkError);
262
+ if (tick !== void 0) waitUntil(tick);
263
+ return await collectorFrame.run(collector, async () => {
264
+ const response = await next();
265
+ if (response instanceof Response) return applyHeaders(response, collector.commitHeaders(l3Budget), telemetry, onSinkError);
266
+ return response;
267
+ });
268
+ });
269
+ };
270
+ }
271
+ //#endregion
272
+ export { L3_BUDGET_DEFAULT, L3_INELIGIBLE, POLL_TICK_FAILED, TICK_MIN_INTERVAL_MS, cacheContext, cacheMiddleware, currentRequest };