@cloudflare/workers-response-store 0.0.0 → 0.1.0-beta.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Cloudflare, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,92 @@
1
+ # Workers Response Store
2
+
3
+ `@cloudflare/workers-response-store` is an R2-backed programmatic response store for Cloudflare Workers. The core package has no vinext dependency; the optional vinext adapter lives in `@vinext/cloudflare`.
4
+
5
+ ## Single-Worker mode
6
+
7
+ A user Worker can define a Response Store once, export its generated entrypoints, and call its methods directly. All of those entrypoints and bindings are deployed together from one Wrangler configuration.
8
+
9
+ There is no second cache Worker, reverse service binding, circular deployment, or bootstrap deployment. `ctx.exports` supplies both directions inside the same Worker version: the user handler calls the cache-enabled `ResponseStoreBinding`, and that entrypoint calls the user's `ResponseStoreRevalidator` when regeneration is required.
10
+
11
+ ```text
12
+ request ──► user Worker default entrypoint
13
+
14
+ └── ctx.exports.ResponseStoreBinding({ props })
15
+
16
+ ├── Workers Cache ──► cached Response
17
+ └── cache miss / RPC
18
+ ├── response bodies ──► R2
19
+ ├── metadata/indexes ─► SQLite DO
20
+ └── same-Worker ResponseStoreRevalidator.regenerate()
21
+ ```
22
+
23
+ The fixture in `example/worker.ts` demonstrates the integration:
24
+
25
+ ```ts
26
+ import { createWorkersResponseStore } from "@cloudflare/workers-response-store";
27
+
28
+ const responseStore = createWorkersResponseStore<Env>({
29
+ regenerate(input, { env, ctx }) {
30
+ return renderer.fetch(input.request, env, ctx);
31
+ },
32
+ });
33
+
34
+ export const { CacheMetadata, ResponseStoreRevalidator, ResponseStoreBinding } =
35
+ responseStore.entrypoints;
36
+
37
+ export default {
38
+ fetch(request) {
39
+ return responseStore.fetch(request);
40
+ },
41
+ } satisfies ExportedHandler<Env>;
42
+ ```
43
+
44
+ The Worker configuration enables Workers Cache only for `ResponseStoreBinding`, binds R2 and the SQLite Durable Object to the same Worker, and includes a version-metadata binding so backing data is automatically scoped to the deployed Worker version. `wrangler.jsonc` is the complete fixture configuration.
45
+
46
+ ## Service-binding mode
47
+
48
+ The library can instead run R2, SQLite, and Workers Cache in a reusable cache Worker. Point that Worker's Wrangler `main` directly at the installed implementation:
49
+
50
+ ```jsonc
51
+ {
52
+ "main": "./node_modules/@cloudflare/workers-response-store/dist/service.js",
53
+ }
54
+ ```
55
+
56
+ The user Worker has one `RESPONSE_STORE` service binding to the uncached `ResponseStoreService` gateway and a `CF_VERSION_METADATA` version-metadata binding. It still calls the same `fetch`, `put`, `refresh`, and `purge` API. The gateway forwards each operation to its cache-enabled local `ResponseStoreBinding` entrypoint. No custom service-binding props are required. These conventional binding names work directly with the `Env` generated by `wrangler types`.
57
+
58
+ The user Worker passes its version-pinned `ctx.exports.ResponseStoreRevalidator` binding over RPC with each operation. The cache Worker places that binding in the local cache entrypoint's `ctx.props`, allowing misses, expiry, manual refresh, and background SWR to call the user Worker without a configured reverse service binding. Consequently there is no circular deployment or bootstrap: deploy the cache Worker once, then deploy or update the user Worker once.
59
+
60
+ ```ts
61
+ const responseStore = createWorkersResponseStoreClient<Env>({
62
+ regenerate(input, { env, ctx }) {
63
+ return renderer.fetch(input.request, env, ctx);
64
+ },
65
+ });
66
+
67
+ export const { ResponseStoreRevalidator, ResponseStoreClient } = responseStore.entrypoints;
68
+ ```
69
+
70
+ The complete pair lives in `example/service-binding`: `wrangler.cache.jsonc` owns the storage and cache entrypoints, while `user-worker.ts` consumes it through `wrangler.user.jsonc`.
71
+
72
+ ## API and storage model
73
+
74
+ The library exposes `fetch`, `put`, `refresh({ tags, pathPrefixes })`, and `purge({ tags, pathPrefixes, purgeEverything })`. Cache identity is the request pathname plus query string. Response bodies live only in revision-specific R2 objects; SQLite stores metadata, freshness, revalidator descriptors, and tag invalidation timestamps. Explicit refresh and purge operations scan stored entry metadata instead of maintaining a write-heavy tag index.
75
+
76
+ Framework soft-tag checks query their expiration only after a candidate cache hit and memoize each distinct tag set within the current request. Misses and repeated lookups therefore add no metadata DO request, while every check still reads the authoritative invalidation state directly.
77
+
78
+ SQLite assigns monotonically increasing revisions and conditionally publishes metadata, so slow writes cannot replace newer writes or resurrect purged entries. User RPC, R2, and purge I/O happen outside SQLite transactions. Stale R2 responses within their SWR window return immediately while `ctx.waitUntil()` runs one claimed regeneration; hard-expired responses are never returned.
79
+
80
+ The immediate stale return is deliberate. Workers Cache can invoke the cache Worker to fill an empty edge location or update a stale entry, but the Worker does not currently receive the reason for that invocation. The binding therefore cannot wait for regeneration only during an update without also blocking an empty-location read backed by stale R2 data. It returns stale R2 immediately in both cases, regenerates and conditionally publishes the complete response to R2, and allows a later Workers Cache request to promote that revision. This preserves non-blocking SWR but can produce one extra stale response. An invocation-status signal would remove that gap.
81
+
82
+ R2 custom metadata contains only the response status, creation time, and initial age. The metadata remains tiny relative to R2's 8 KiB object-metadata limit. RPC-transferred response streams are currently materialized in the binding entrypoint before writing them to R2 because the transferred stream lacks the fixed-length marker required by R2's single-part put API.
83
+
84
+ ## Known behavior
85
+
86
+ - Workers Cache hits bypass the Response Store Worker, R2, and SQLite entirely.
87
+ - A stale R2 entry may be returned once more while its replacement is generated because Workers Cache does not expose whether the invocation is an SWR update or an empty-location fill.
88
+ - Hard-expired entries are never returned; the request waits for regeneration when a revalidator is available.
89
+ - Response bodies are stored only in R2. SQLite stores metadata, tag invalidations, and pending-object cleanup records.
90
+ - Pending R2 objects are retained for one hour before an alarm-driven cleanup pass. Failed deletion is retried without making the active response unavailable.
91
+ - Service-binding loopbacks stay pinned to the user Worker version that supplied the revalidator capability.
92
+ - The store buffers RPC-transferred bodies before R2 writes. Consumers should account for Worker memory limits when choosing maximum response sizes.
@@ -0,0 +1,115 @@
1
+ import { WorkerEntrypoint } from "cloudflare:workers";
2
+ //#region src/binding.d.ts
3
+ type RevalidatorDescriptor = {
4
+ id: string;
5
+ args: SerializableValue[];
6
+ };
7
+ export type SerializableValue = null | boolean | number | string | SerializableValue[] | {
8
+ [key: string]: SerializableValue;
9
+ };
10
+ export type ResponseStorePutOptions = {
11
+ /** @internal Collapse overlapping framework writes for the same cache key. */
12
+ coalesce?: boolean;
13
+ revalidator?: RevalidatorDescriptor;
14
+ purgeExisting?: boolean;
15
+ };
16
+ export type ResponseStoreRefreshOptions = {
17
+ tags?: string[];
18
+ pathPrefixes?: string[];
19
+ };
20
+ export type ResponseStorePurgeOptions = ResponseStoreRefreshOptions & {
21
+ purgeEverything?: boolean;
22
+ };
23
+ export type ResponseStoreMutationResult = {
24
+ backingStoreUpdated: boolean;
25
+ edgePurgeAccepted: boolean;
26
+ };
27
+ export type RevalidationReason = "swr" | "expired" | "missing" | "manual";
28
+ export type RevalidationInput = {
29
+ request: Request;
30
+ id: string;
31
+ args: SerializableValue[];
32
+ reason: RevalidationReason;
33
+ };
34
+ export type WorkersResponseStore = {
35
+ fetch(request: Request): Promise<Response>;
36
+ /** @internal Return the latest purge timestamp for framework-managed cache tags. */
37
+ getTagExpiration(tags: string[]): Promise<number>;
38
+ put(request: Request, response: Response, options?: ResponseStorePutOptions): Promise<ResponseStoreMutationResult>;
39
+ refresh(options: ResponseStoreRefreshOptions): Promise<ResponseStoreMutationResult>;
40
+ purge(options: ResponseStorePurgeOptions): Promise<ResponseStoreMutationResult>;
41
+ };
42
+ type EntryMetadata = {
43
+ objectKey: string;
44
+ statusText: string;
45
+ responseHeaders: [string, string][];
46
+ freshUntil: number;
47
+ swrUntil: number;
48
+ revalidator: RevalidatorDescriptor | null;
49
+ cacheTags: string[];
50
+ };
51
+ export type CandidateMetadata = EntryMetadata & {
52
+ fenceTags: string[];
53
+ };
54
+ export type StoredEntry = EntryMetadata & {
55
+ keyHash: string;
56
+ cacheKey: string;
57
+ activeRevision: number;
58
+ latestRevision: number;
59
+ };
60
+ export type PurgedEntry = {
61
+ keyHash: string;
62
+ cacheKey: string;
63
+ objectKey: string;
64
+ };
65
+ export type RevalidationService = {
66
+ regenerate(input: RevalidationInput): Promise<Response>;
67
+ };
68
+ export type WorkersResponseStoreEnv = {
69
+ CACHE_BODIES: R2Bucket;
70
+ CACHE_METADATA: DurableObjectNamespace<undefined>;
71
+ CF_VERSION_METADATA?: WorkerVersionMetadata;
72
+ };
73
+ export type WorkersResponseStoreProps = {
74
+ versionId?: string;
75
+ locationHint?: DurableObjectLocationHint;
76
+ revalidator?: RevalidationService;
77
+ };
78
+ export type ResponseStoreServiceProps = Pick<WorkersResponseStoreProps, "locationHint">;
79
+ export type ResponseStoreServiceInvocation = {
80
+ versionId: string;
81
+ revalidator: RevalidationService;
82
+ };
83
+ export type ResponseStoreExecutionContext = Pick<ExecutionContext, "exports">;
84
+ export declare function getWorkersResponseStore(ctx: ResponseStoreExecutionContext, props?: WorkersResponseStoreProps): WorkersResponseStore;
85
+ export declare class ResponseStoreService extends WorkerEntrypoint<WorkersResponseStoreEnv, ResponseStoreServiceProps> {
86
+ private getStore;
87
+ read(request: Request, invocation: ResponseStoreServiceInvocation): Promise<Response>;
88
+ getTagExpiration(tags: string[], invocation: ResponseStoreServiceInvocation): Promise<number>;
89
+ put(request: Request, response: Response, options: ResponseStorePutOptions, invocation: ResponseStoreServiceInvocation): Promise<ResponseStoreMutationResult>;
90
+ refresh(options: ResponseStoreRefreshOptions, invocation: ResponseStoreServiceInvocation): Promise<ResponseStoreMutationResult>;
91
+ purge(options: ResponseStorePurgeOptions, invocation: ResponseStoreServiceInvocation): Promise<ResponseStoreMutationResult>;
92
+ }
93
+ export type ResponseStoreServiceBinding = Pick<ResponseStoreService, "read" | "getTagExpiration" | "put" | "refresh" | "purge">;
94
+ export declare class ResponseStoreBinding extends WorkerEntrypoint<WorkersResponseStoreEnv, WorkersResponseStoreProps> {
95
+ private getVersionId;
96
+ private getMetadata;
97
+ private deriveCacheKey;
98
+ private purgeEdgeCache;
99
+ private purgeEdgeCacheByTags;
100
+ private objectKeyRoot;
101
+ private objectKeyPrefix;
102
+ private reserveWrite;
103
+ private logCleanupFailure;
104
+ private releaseFailedWrite;
105
+ private readStoredResponse;
106
+ private storeResponse;
107
+ private regenerateEntry;
108
+ private revalidateEntryInBackground;
109
+ fetch(request: Request): Promise<Response>;
110
+ getTagExpiration(tags: string[]): Promise<number>;
111
+ put(request: Request, response: Response, options?: ResponseStorePutOptions): Promise<ResponseStoreMutationResult>;
112
+ refresh(options: ResponseStoreRefreshOptions): Promise<ResponseStoreMutationResult>;
113
+ purge(options: ResponseStorePurgeOptions): Promise<ResponseStoreMutationResult>;
114
+ }
115
+ //#endregion
@@ -0,0 +1,369 @@
1
+ import { deriveCachePolicy, edgeCacheControl, representationAge } from "./cache-policy.js";
2
+ import { WorkerEntrypoint } from "cloudflare:workers";
3
+ //#region src/binding.ts
4
+ function getWorkersResponseStore(ctx, props = {}) {
5
+ const binding = Reflect.get(ctx.exports, "ResponseStoreBinding");
6
+ if (typeof binding !== "function") throw new Error("The ResponseStoreBinding entrypoint is not exported");
7
+ return binding({ props });
8
+ }
9
+ var ResponseStoreService = class extends WorkerEntrypoint {
10
+ getStore(invocation) {
11
+ return getWorkersResponseStore(this.ctx, {
12
+ ...this.ctx.props,
13
+ ...invocation
14
+ });
15
+ }
16
+ read(request, invocation) {
17
+ return this.getStore(invocation).fetch(request);
18
+ }
19
+ getTagExpiration(tags, invocation) {
20
+ return this.getStore(invocation).getTagExpiration(tags);
21
+ }
22
+ put(request, response, options, invocation) {
23
+ return this.getStore(invocation).put(request, response, options);
24
+ }
25
+ refresh(options, invocation) {
26
+ return this.getStore(invocation).refresh(options);
27
+ }
28
+ purge(options, invocation) {
29
+ return this.getStore(invocation).purge(options);
30
+ }
31
+ };
32
+ const MISS_HEADERS = {
33
+ "Cache-Control": "no-store",
34
+ "Content-Type": "text/plain; charset=utf-8",
35
+ "X-Workers-Response-Store": "MISS"
36
+ };
37
+ const BACKGROUND_REVALIDATION_LEASE_MS = 3e4;
38
+ const CACHE_PURGE_BATCH_SIZE = 100;
39
+ const MAX_CACHE_TAG_HEADER_BYTES = 16384;
40
+ const NULL_BODY_STATUSES = /* @__PURE__ */ new Set([
41
+ 204,
42
+ 205,
43
+ 304
44
+ ]);
45
+ const AGE_BASIS_HEADER = "X-Workers-Response-Store-Age-Basis";
46
+ const pendingPuts = /* @__PURE__ */ new Map();
47
+ function* batches(values, size) {
48
+ for (let offset = 0; offset < values.length; offset += size) yield values.slice(offset, offset + size);
49
+ }
50
+ function metadataInteger(value) {
51
+ if (!value || !/^\d+$/.test(value)) return;
52
+ const parsed = Number(value);
53
+ return Number.isSafeInteger(parsed) ? parsed : void 0;
54
+ }
55
+ function purgeTagForEntry(entry) {
56
+ return `runtime-cache-${entry.keyHash}`;
57
+ }
58
+ function cacheTagHeader(entry) {
59
+ const requiredTag = purgeTagForEntry(entry);
60
+ const tags = [requiredTag];
61
+ const seen = /* @__PURE__ */ new Set([requiredTag.toLowerCase()]);
62
+ let headerLength = requiredTag.length;
63
+ for (const tag of entry.cacheTags) {
64
+ const normalized = tag.toLowerCase();
65
+ if (!/^[!-~]+$/.test(tag) || tag.includes(",") || seen.has(normalized)) continue;
66
+ const addedLength = tag.length + 1;
67
+ if (headerLength + addedLength > MAX_CACHE_TAG_HEADER_BYTES) continue;
68
+ tags.push(tag);
69
+ seen.add(normalized);
70
+ headerLength += addedLength;
71
+ }
72
+ return tags.join(",");
73
+ }
74
+ function cacheTagsFromResponse(response) {
75
+ return [...new Set((response.headers.get("Cache-Tag") ?? "").split(",").map((tag) => tag.trim()).filter(Boolean))];
76
+ }
77
+ var ResponseStoreBinding = class extends WorkerEntrypoint {
78
+ getVersionId() {
79
+ const versionId = this.ctx.props?.versionId ?? this.env.CF_VERSION_METADATA?.id;
80
+ if (!versionId) throw new Error("Workers Response Store requires a version_metadata binding");
81
+ return versionId;
82
+ }
83
+ getMetadata() {
84
+ const locationHint = this.ctx.props?.locationHint;
85
+ return this.env.CACHE_METADATA.getByName(this.getVersionId(), locationHint ? { locationHint } : void 0);
86
+ }
87
+ async deriveCacheKey(request) {
88
+ if (request.method !== "GET") throw new TypeError("Workers Response Store keys must be GET requests");
89
+ const url = new URL(request.url);
90
+ const cacheKey = `${url.pathname}${url.search}`;
91
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(cacheKey));
92
+ return {
93
+ cacheKey,
94
+ keyHash: [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("")
95
+ };
96
+ }
97
+ async purgeEdgeCache(options) {
98
+ if (!this.ctx.cache) {
99
+ console.error(JSON.stringify({
100
+ message: "Workers Response Store cache purge is unavailable",
101
+ reason: "ctx.cache is absent"
102
+ }));
103
+ return false;
104
+ }
105
+ try {
106
+ const result = await this.ctx.cache.purge(options);
107
+ if (!result.success) throw new Error(result.errors.map(({ code, message }) => `${code}: ${message}`).join(", ") || "Workers Response Store cache purge was rejected");
108
+ return true;
109
+ } catch (error) {
110
+ console.error(JSON.stringify({
111
+ message: "Workers Response Store cache purge failed",
112
+ error: error instanceof Error ? error.message : String(error)
113
+ }));
114
+ throw error;
115
+ }
116
+ }
117
+ async purgeEdgeCacheByTags(tags) {
118
+ let accepted = true;
119
+ for (const batch of batches(tags, CACHE_PURGE_BATCH_SIZE)) if (!await this.purgeEdgeCache({ tags: batch })) accepted = false;
120
+ return accepted;
121
+ }
122
+ objectKeyRoot() {
123
+ return ["runtime-cache", this.getVersionId()].join("/");
124
+ }
125
+ objectKeyPrefix(keyHash) {
126
+ return `${this.objectKeyRoot()}/${keyHash}`;
127
+ }
128
+ async reserveWrite(metadata, keyHash, cacheKey, cacheTags) {
129
+ return {
130
+ cacheKey,
131
+ fenceTags: cacheTags,
132
+ keyHash,
133
+ ...await metadata.reserveWrite(keyHash, cacheKey, this.objectKeyPrefix(keyHash), Date.now())
134
+ };
135
+ }
136
+ logCleanupFailure(objectKey, error) {
137
+ console.error(JSON.stringify({
138
+ message: "Workers Response Store R2 cleanup failed",
139
+ objectKey,
140
+ error: error instanceof Error ? error.message : String(error)
141
+ }));
142
+ }
143
+ async releaseFailedWrite(metadata, write) {
144
+ await metadata.releaseWrite(write.keyHash, write.objectKey, write.claimId).catch((error) => this.logCleanupFailure(write.objectKey, error));
145
+ }
146
+ async readStoredResponse(entry, now = Date.now()) {
147
+ const object = await this.env.CACHE_BODIES.get(entry.objectKey);
148
+ if (!object) return null;
149
+ const status = metadataInteger(object.customMetadata?.status);
150
+ const createdAt = metadataInteger(object.customMetadata?.createdAt);
151
+ const initialAge = metadataInteger(object.customMetadata?.initialAge);
152
+ if (status === void 0 || status < 200 || status > 599 || createdAt === void 0 || initialAge === void 0) {
153
+ await object.body.cancel();
154
+ return null;
155
+ }
156
+ const headers = new Headers(entry.responseHeaders);
157
+ headers.set(AGE_BASIS_HEADER, `${createdAt}:${initialAge}`);
158
+ headers.set("Age", String(representationAge(createdAt, initialAge, now)));
159
+ headers.set("Cloudflare-CDN-Cache-Control", edgeCacheControl(entry.freshUntil, entry.swrUntil, now));
160
+ headers.set("Cache-Tag", cacheTagHeader(entry));
161
+ headers.set("X-Workers-Response-Store", now < entry.freshUntil ? "BLOB-FRESH" : "BLOB-STALE");
162
+ headers.set("X-Workers-Response-Store-Revision", String(entry.activeRevision));
163
+ headers.set("X-Workers-Response-Store-Binding-Invocation", crypto.randomUUID());
164
+ const body = NULL_BODY_STATUSES.has(status) ? null : object.body;
165
+ if (!body) await object.body.cancel();
166
+ return new Response(body, {
167
+ status,
168
+ statusText: entry.statusText,
169
+ headers
170
+ });
171
+ }
172
+ async storeResponse(metadata, request, response, revalidator, reservation, cacheTags = cacheTagsFromResponse(response)) {
173
+ const cacheKey = reservation ?? await this.deriveCacheKey(request);
174
+ const write = reservation ?? await this.reserveWrite(metadata, cacheKey.keyHash, cacheKey.cacheKey, cacheTags);
175
+ const { keyHash, objectKey, revision } = write;
176
+ let publication;
177
+ try {
178
+ const now = Date.now();
179
+ const policy = deriveCachePolicy(response.headers, now);
180
+ const responseHeaders = [...response.headers].filter(([name]) => {
181
+ const lower = name.toLowerCase();
182
+ return lower !== "age" && lower !== "cf-cache-status" && lower !== "content-length";
183
+ });
184
+ const candidate = {
185
+ fenceTags: [.../* @__PURE__ */ new Set([...write.fenceTags, ...cacheTags])],
186
+ objectKey,
187
+ statusText: response.statusText,
188
+ responseHeaders,
189
+ freshUntil: policy.freshUntil,
190
+ swrUntil: policy.swrUntil,
191
+ revalidator: revalidator ?? null,
192
+ cacheTags
193
+ };
194
+ const body = response.body ? await response.arrayBuffer() : /* @__PURE__ */ new ArrayBuffer(0);
195
+ await this.env.CACHE_BODIES.put(objectKey, body, { customMetadata: {
196
+ status: String(response.status),
197
+ createdAt: String(policy.createdAt),
198
+ initialAge: String(policy.initialAge)
199
+ } });
200
+ publication = await metadata.publish(keyHash, revision, candidate, write.claimId);
201
+ } catch (error) {
202
+ await this.releaseFailedWrite(metadata, write);
203
+ throw error;
204
+ }
205
+ if (!publication.published) return {
206
+ published: false,
207
+ entry: publication.entry
208
+ };
209
+ return {
210
+ published: true,
211
+ entry: publication.entry
212
+ };
213
+ }
214
+ async regenerateEntry(metadata, entry, reason, reservation) {
215
+ if (!entry.revalidator) throw new Error("Cache entry has no configured revalidator");
216
+ const cacheRequest = new Request(`https://runtime-cache.invalid${entry.cacheKey}`);
217
+ const writeReservation = reservation ?? await this.reserveWrite(metadata, entry.keyHash, entry.cacheKey, entry.cacheTags);
218
+ let response;
219
+ try {
220
+ const origin = this.ctx.props?.revalidator ?? Reflect.get(this.ctx.exports, "ResponseStoreRevalidator");
221
+ if (typeof origin?.regenerate !== "function") throw new Error("The ResponseStoreRevalidator entrypoint is unavailable");
222
+ response = await origin.regenerate({
223
+ request: cacheRequest,
224
+ id: entry.revalidator.id,
225
+ args: entry.revalidator.args,
226
+ reason
227
+ });
228
+ } catch (error) {
229
+ await this.releaseFailedWrite(metadata, writeReservation);
230
+ throw error;
231
+ }
232
+ return this.storeResponse(metadata, cacheRequest, response, entry.revalidator, writeReservation);
233
+ }
234
+ async revalidateEntryInBackground(metadata, entry) {
235
+ if (!entry.revalidator) return;
236
+ const claim = await metadata.claimRevalidation(entry.keyHash, entry.activeRevision, entry.cacheKey, this.objectKeyPrefix(entry.keyHash), Date.now(), BACKGROUND_REVALIDATION_LEASE_MS);
237
+ if (!claim) return;
238
+ try {
239
+ await this.regenerateEntry(metadata, entry, "swr", {
240
+ cacheKey: entry.cacheKey,
241
+ claimId: claim.claimId,
242
+ fenceTags: entry.cacheTags,
243
+ keyHash: entry.keyHash,
244
+ objectKey: claim.objectKey,
245
+ revision: claim.revision
246
+ });
247
+ } catch (error) {
248
+ console.error(JSON.stringify({
249
+ message: "Workers Response Store SWR regeneration failed",
250
+ cacheKey: entry.cacheKey,
251
+ error: error instanceof Error ? error.message : String(error)
252
+ }));
253
+ }
254
+ }
255
+ async fetch(request) {
256
+ const { keyHash } = await this.deriveCacheKey(request);
257
+ const metadata = this.getMetadata();
258
+ const entry = await metadata.getEntry(keyHash);
259
+ if (!entry) return new Response("Workers Response Store miss", {
260
+ status: 404,
261
+ headers: MISS_HEADERS
262
+ });
263
+ const now = Date.now();
264
+ if (now < entry.swrUntil) {
265
+ const stored = await this.readStoredResponse(entry, now);
266
+ if (stored) {
267
+ if (now < entry.freshUntil) return stored;
268
+ this.ctx.waitUntil(this.revalidateEntryInBackground(metadata, entry));
269
+ return stored;
270
+ }
271
+ }
272
+ const regenerated = await this.regenerateEntry(metadata, entry, now >= entry.swrUntil ? "expired" : "missing");
273
+ if (!regenerated.entry) throw new Error("Regeneration was superseded and no active entry remains");
274
+ const response = await this.readStoredResponse(regenerated.entry);
275
+ if (!response) throw new Error("The committed cache body is unavailable");
276
+ return response;
277
+ }
278
+ getTagExpiration(tags) {
279
+ return this.getMetadata().getTagExpiration(tags);
280
+ }
281
+ async put(request, response, options = {}) {
282
+ const metadata = this.getMetadata();
283
+ const { cacheKey, keyHash } = await this.deriveCacheKey(request);
284
+ const cacheTags = cacheTagsFromResponse(response);
285
+ const pendingPutKey = `${this.getVersionId()}:${keyHash}:${Boolean(options.purgeExisting)}`;
286
+ let reservation;
287
+ if (options.coalesce) for (;;) {
288
+ const pending = pendingPuts.get(pendingPutKey);
289
+ if (!pending) break;
290
+ reservation ??= await this.reserveWrite(metadata, keyHash, cacheKey, cacheTags);
291
+ let result;
292
+ try {
293
+ result = await pending;
294
+ } catch {
295
+ if (pendingPuts.get(pendingPutKey) === pending) pendingPuts.delete(pendingPutKey);
296
+ continue;
297
+ }
298
+ if (result.published && result.entry) {
299
+ const objectKey = reservation.objectKey;
300
+ await metadata.finishPendingObjects([objectKey]).catch((error) => this.logCleanupFailure(objectKey, error));
301
+ response.body?.cancel().catch(() => {});
302
+ return {
303
+ backingStoreUpdated: true,
304
+ edgePurgeAccepted: options.purgeExisting ? await this.purgeEdgeCacheByTags([purgeTagForEntry(result.entry)]) : true
305
+ };
306
+ }
307
+ if (pendingPuts.get(pendingPutKey) === pending) pendingPuts.delete(pendingPutKey);
308
+ }
309
+ const write = (async () => {
310
+ reservation ??= await this.reserveWrite(metadata, keyHash, cacheKey, cacheTags);
311
+ return this.storeResponse(metadata, request, response, options.revalidator, reservation, cacheTags);
312
+ })();
313
+ if (options.coalesce) pendingPuts.set(pendingPutKey, write);
314
+ try {
315
+ const result = await write;
316
+ if (!result.published || !result.entry) return {
317
+ backingStoreUpdated: false,
318
+ edgePurgeAccepted: false
319
+ };
320
+ return {
321
+ backingStoreUpdated: true,
322
+ edgePurgeAccepted: options.purgeExisting ? await this.purgeEdgeCacheByTags([purgeTagForEntry(result.entry)]) : true
323
+ };
324
+ } finally {
325
+ if (options.coalesce && pendingPuts.get(pendingPutKey) === write) pendingPuts.delete(pendingPutKey);
326
+ }
327
+ }
328
+ async refresh(options) {
329
+ if (!options.tags?.length && !options.pathPrefixes?.length) throw new TypeError("refresh() requires tags or pathPrefixes");
330
+ const metadata = this.getMetadata();
331
+ const candidates = await metadata.reserveRefresh(options, this.objectKeyRoot(), Date.now());
332
+ if (candidates.length === 0) return {
333
+ backingStoreUpdated: false,
334
+ edgePurgeAccepted: false
335
+ };
336
+ const settled = await Promise.allSettled(candidates.map(async ({ entry, reservation }) => {
337
+ const result = await this.regenerateEntry(metadata, entry, "manual", reservation ? {
338
+ cacheKey: entry.cacheKey,
339
+ fenceTags: entry.cacheTags,
340
+ keyHash: entry.keyHash,
341
+ ...reservation
342
+ } : void 0);
343
+ return result.published ? result.entry : null;
344
+ }));
345
+ const refreshed = [];
346
+ const failures = [];
347
+ for (const result of settled) if (result.status === "rejected") failures.push(result.reason);
348
+ else if (result.value) refreshed.push(result.value);
349
+ const edgePurgeAccepted = refreshed.length ? await this.purgeEdgeCacheByTags(refreshed.map((entry) => purgeTagForEntry(entry))) : false;
350
+ if (failures.length) throw new AggregateError(failures, "One or more cache entries failed to refresh");
351
+ return {
352
+ backingStoreUpdated: refreshed.length === candidates.length,
353
+ edgePurgeAccepted
354
+ };
355
+ }
356
+ async purge(options) {
357
+ if (!options.purgeEverything && !options.tags?.length && !options.pathPrefixes?.length) throw new TypeError("purge() requires tags, pathPrefixes, or purgeEverything");
358
+ const purged = await this.getMetadata().purgeMatching(options);
359
+ let edgePurgeAccepted = true;
360
+ if (options.purgeEverything) edgePurgeAccepted = await this.purgeEdgeCache({ purgeEverything: true });
361
+ else if (purged.length) edgePurgeAccepted = await this.purgeEdgeCacheByTags(purged.map((entry) => purgeTagForEntry(entry)));
362
+ return {
363
+ backingStoreUpdated: true,
364
+ edgePurgeAccepted
365
+ };
366
+ }
367
+ };
368
+ //#endregion
369
+ export { ResponseStoreBinding, ResponseStoreService, getWorkersResponseStore };
@@ -0,0 +1,11 @@
1
+ //#region src/cache-policy.d.ts
2
+ export type CachePolicy = {
3
+ createdAt: number;
4
+ initialAge: number;
5
+ freshUntil: number;
6
+ swrUntil: number;
7
+ };
8
+ export declare function deriveCachePolicy(headers: Headers, now?: number): CachePolicy;
9
+ export declare function representationAge(createdAt: number, initialAge: number, now?: number): number;
10
+ export declare function edgeCacheControl(freshUntil: number, swrUntil: number, now?: number): string;
11
+ //#endregion
@@ -0,0 +1,37 @@
1
+ //#region src/cache-policy.ts
2
+ function parseSeconds(value) {
3
+ if (value === void 0) return;
4
+ const match = /^(?:"(\d+)"|(\d+))$/.exec(value);
5
+ if (!match) return;
6
+ const parsed = Number(match[1] ?? match[2]);
7
+ return Number.isSafeInteger(parsed) ? parsed : void 0;
8
+ }
9
+ function deriveCachePolicy(headers, now = Date.now()) {
10
+ const cacheControl = headers.get("Cloudflare-CDN-Cache-Control") ?? headers.get("CDN-Cache-Control") ?? headers.get("Cache-Control");
11
+ const directives = /* @__PURE__ */ new Map();
12
+ for (const part of cacheControl?.split(",") ?? []) {
13
+ const [rawName, ...rawValue] = part.trim().split("=");
14
+ if (!rawName) continue;
15
+ directives.set(rawName.toLowerCase(), rawValue.length ? rawValue.join("=").trim() : void 0);
16
+ }
17
+ const cacheStorageForbidden = directives.has("no-store") || directives.has("private");
18
+ const staleServingForbidden = directives.has("s-maxage") || directives.has("must-revalidate") || directives.has("proxy-revalidate");
19
+ const maxAge = cacheStorageForbidden || directives.has("no-cache") ? 0 : parseSeconds(directives.get("s-maxage")) ?? parseSeconds(directives.get("max-age")) ?? 0;
20
+ const initialAge = parseSeconds(headers.get("Age") ?? void 0) ?? 0;
21
+ const freshUntil = now + Math.max(0, maxAge - initialAge) * 1e3;
22
+ return {
23
+ createdAt: now,
24
+ initialAge,
25
+ freshUntil,
26
+ swrUntil: freshUntil + (cacheStorageForbidden || staleServingForbidden ? 0 : parseSeconds(directives.get("stale-while-revalidate")) ?? 0) * 1e3
27
+ };
28
+ }
29
+ function representationAge(createdAt, initialAge, now = Date.now()) {
30
+ return initialAge + Math.max(0, Math.floor((now - createdAt) / 1e3));
31
+ }
32
+ function edgeCacheControl(freshUntil, swrUntil, now = Date.now()) {
33
+ if (now < freshUntil) return `max-age=${Math.max(0, Math.ceil((freshUntil - now) / 1e3))}, stale-while-revalidate=${Math.max(0, Math.ceil((swrUntil - freshUntil) / 1e3))}`;
34
+ return `max-age=0, stale-while-revalidate=${Math.max(0, Math.ceil((swrUntil - now) / 1e3))}`;
35
+ }
36
+ //#endregion
37
+ export { deriveCachePolicy, edgeCacheControl, representationAge };