Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion examples/workers-cache/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Workers Response Store adapter POC

This example uses one `responseStoreAdapter()` from `@vinext/cloudflare` in place of both `cdnAdapter()` and `kvDataAdapter()`. The application Worker keeps Workers Cache disabled. Its `RESPONSE_STORE` service binding calls a separately deployed cache Worker that owns Workers Cache, R2 response bodies, SQLite Durable Object metadata, tag indexes, and SWR regeneration.
This example uses one `responseStoreAdapter()` from `@vinext/cloudflare` in place of both `cdnAdapter()` and `kvDataAdapter()`. The application Worker keeps Workers Cache disabled. Its `RESPONSE_STORE` service binding calls a separately deployed cache Worker that owns Workers Cache, R2 response bodies, SQLite Durable Object metadata and tag invalidation timestamps, and SWR regeneration.

The cache Worker is shared infrastructure, not a second deployment of the application. Each application version has one ordinary build and deploy. Cached entries retain a loopback to that application version's vinext response-stage entrypoint. Route and fetch-cache entries can replay that stage, while a transformed public `"use cache"` entry records its encrypted arguments and server-reference identity so regeneration invokes only that function. If its arguments cannot be safely recorded, the adapter falls back to replaying the cacheable route.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,7 @@ export default {
return;
}
await responseStore.put(key, admitted, {
coalesce: true,
revalidator: { id: ROUTE_REVALIDATOR_ID, args: [invocation] },
});
})
Expand All @@ -426,6 +427,7 @@ export default {
const [foreground, cacheBody] = rendered.body ? rendered.body.tee() : [null, null];
const cacheResponse = new Response(cacheBody, rendered);
await responseStore.put(key, cacheResponse, {
coalesce: true,
revalidator: { id: ROUTE_REVALIDATOR_ID, args: [invocation] },
});

Expand All @@ -446,7 +448,10 @@ export default {
headers: rscHeaders,
status: 200,
}),
{ revalidator: { id: ROUTE_REVALIDATOR_ID, args: [rscInvocation] } },
{
coalesce: true,
revalidator: { id: ROUTE_REVALIDATOR_ID, args: [rscInvocation] },
},
);
}
return publicResponse(new Response(foreground, rendered), "MISS");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,7 @@ export class WorkersResponseStoreCacheHandler implements CacheHandler {

await this.store.put(await cacheRequest(key), response, {
...(revalidator ? { revalidator } : {}),
coalesce: true,
purgeExisting: true,
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,7 @@ describe("Cloudflare Workers Response Store adapter", () => {
assert.doesNotMatch(serialized, /first-secret|second-secret/);
});

test("keeps concurrent cold renders successful when a cache write loses CAS", async () => {
test("keeps concurrent cold renders successful", async () => {
const responses = await Promise.all(
Array.from({ length: 8 }, () => request("/cached/concurrent")),
);
Expand Down
3 changes: 2 additions & 1 deletion packages/cloudflare/tests/response-store-data.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ test("only attaches loopback regeneration to replayable requests", async () => {
handler.set("get", null, { cacheControl: { revalidate: 1, expire: 2 } }),
);
expect(store.options).toMatchObject({
coalesce: true,
purgeExisting: true,
revalidator: { id: "vinext:data", args: ["get", "safe-get"] },
});
Expand All @@ -78,7 +79,7 @@ test("only attaches loopback regeneration to replayable requests", async () => {
await runWithResponseStoreInvocation("unsafe-post", false, () =>
handler.set("post", null, { cacheControl: { revalidate: 1, expire: 2 } }),
);
expect(store.options).toEqual({ purgeExisting: true });
expect(store.options).toEqual({ coalesce: true, purgeExisting: true });
expect(store.response?.headers.get("X-Vinext-Response-Store-Replayable")).toBeNull();
expect(store.response?.headers.get("Cache-Control")).toBe("public, max-age=315360000");
});
Expand Down
4 changes: 3 additions & 1 deletion packages/workers-response-store/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,9 @@ The complete pair lives in `example/service-binding`: `cache-worker.ts` owns the

## API and storage model

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, tag invalidation timestamps, and the reverse tag-to-entry index needed by refresh and purge.
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.

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.

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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,11 @@ export default {
return json(await responseStore.purge((await request.json()) as ResponseStorePurgeOptions));
}

if (request.method === "POST" && url.pathname === "/admin/tag-expiration") {
const { tags } = (await request.json()) as { tags: string[] };
return json({ expiration: await responseStore.getTagExpiration(tags) });
}

return new Response("Not found", { status: 404 });
} catch (error) {
return json({ error: error instanceof Error ? error.message : String(error) }, 500);
Expand Down
18 changes: 18 additions & 0 deletions packages/workers-response-store/example/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,13 @@ async function handlePut(request: Request, store: WorkersResponseStore): Promise
if (cdnCacheControl) headers.set("CDN-Cache-Control", cdnCacheControl);

let body = request.body;
if (request.headers.get("X-Body-Failure") === "1") {
body = new ReadableStream({
start(controller) {
controller.error(new Error("Fixture body failure"));
},
});
}
if (body && bodyDelayMs > 0) {
const reader = body.getReader();
let delayed = false;
Expand All @@ -82,6 +89,10 @@ async function handlePut(request: Request, store: WorkersResponseStore): Promise
},
});
}
let teeSibling: ReadableStream | undefined;
if (body && request.headers.get("X-Tee-Body") === "1") {
[body, teeSibling] = body.tee();
}

const response = new Response(NULL_BODY_STATUSES.has(status) ? null : body, {
status,
Expand All @@ -99,9 +110,11 @@ async function handlePut(request: Request, store: WorkersResponseStore): Promise
};

const result = await store.put(target, response, {
coalesce: request.headers.get("X-Coalesce") === "1",
revalidator,
purgeExisting: request.headers.get("X-Purge-Existing") === "1",
});
await new Response(teeSibling).arrayBuffer();

return json(result);
}
Expand Down Expand Up @@ -188,6 +201,11 @@ export default {
return json(await responseStore.purge(options));
}

if (request.method === "POST" && url.pathname === "/admin/tag-expiration") {
const { tags } = (await request.json()) as { tags: string[] };
return json({ expiration: await responseStore.getTagExpiration(tags) });
}

if (request.method === "GET" && url.pathname === "/admin/stats") {
return json({ regenerationCount });
}
Expand Down
Loading
Loading