Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
27 changes: 27 additions & 0 deletions .changeset/workers-cache-purge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
"emdash": minor
"@emdash-cms/cloudflare": minor
---

Add optional purging of Cloudflare Workers Caching on content and chrome writes.

Workers Caching (the platform cache enabled with `cache: { enabled: true }` in Wrangler) sits in front of the Worker and serves HITs without running it, so a long `Cache-Control` max-age on public pages would otherwise serve stale HTML until the TTL lapses. When configured, EmDash now purges that cache on writes, so edits appear without waiting for TTL.

Off by default. Enable it with the `edgeCache` adapter:

```ts
import { workersCache } from "@emdash-cms/cloudflare";

emdash({
database: d1({ binding: "DB" }),
edgeCache: workersCache(), // mode: "purgeEverything" (default)
});
```

and enable the platform cache in `wrangler.jsonc` (`"cache": { "enabled": true }`) with a cacheable `Cache-Control` on public responses.

v1 uses `purgeEverything`: any content or chrome write (content create/update/delete/publish/unpublish/schedule, settings, taxonomies, menus, bylines, slug-change redirects) triggers a single `cache.purge({ purgeEverything: true })`. Purges are deferred via `after()` (never block the write response) and coalesced (a bulk import collapses into one purge, respecting the zone purge rate limit). On non-Cloudflare runtimes or older runtimes without `cache.purge`, it's a safe no-op. Tag-based purging (purge only affected pages) is planned behind the same config.

This is independent of, and complements, the Astro route cache (`cloudflareCache()`) and the data/object cache: Workers Caching (HTML, in front) → Worker → data cache → DB.

New API: `invalidateEdgeCache()` and the `EdgeCache*` types (from `emdash`), and `workersCache()` (from `@emdash-cms/cloudflare`). Existing sites are unaffected until they opt in.
4 changes: 4 additions & 0 deletions packages/cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@
"./cache/config": {
"types": "./dist/cache/config.d.mts",
"default": "./dist/cache/config.mjs"
},
"./cache/edge": {
"types": "./dist/cache/edge.d.mts",
"default": "./dist/cache/edge.mjs"
}
},
"scripts": {
Expand Down
58 changes: 58 additions & 0 deletions packages/cloudflare/src/cache/edge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* Cloudflare Workers Caching purge backend — RUNTIME ENTRY
*
* Purges the platform cache that sits in front of the Worker, via
* `cache.purge()` from `cloudflare:workers`. EmDash calls this on content and
* chrome writes so edits appear on cached public pages without waiting for TTL.
*
* Entrypoint scoping (critical): Workers Caching purge is scoped to the
* entrypoint that calls it, and the cache is keyed by entrypoint + path +
* query + ctx.props. Public pages and EmDash's content-write API routes both
* run under the Worker's DEFAULT entrypoint, so this purge must run from the
* default entrypoint to hit the page cache. It must NOT be called from a named
* entrypoint (e.g. the PluginBridge export), which has a different cache.
* EmDash invokes it from the request/`after()` path of the default worker, so
* this holds.
*
* Distinct from `cloudflareCache()` (Astro route cache via the Cache API + zone
* REST purge): a zone/Cache-API purge does not affect Workers Caching.
*
* Do NOT import this at config time — use `workersCache()` from
* `@emdash-cms/cloudflare`.
*/

import * as cfWorkers from "cloudflare:workers";
import type { CreateEdgeCacheFn, EdgeCacheInvalidator } from "emdash";

/** Shape of the optional `cache` export on `cloudflare:workers`. */
interface WorkersCacheApi {
purge?: (options: { purgeEverything?: boolean; tags?: string[] }) => Promise<unknown>;
}

/**
* Feature-detect `cache.purge`. It exists only on a Cloudflare Worker with
* Workers Caching enabled (`cache: { enabled: true }`) on a recent runtime.
* Accessed via a namespace import so a missing `cache` export doesn't break
* module loading on older runtimes — it's simply `undefined`.
*/
function getPurge(): WorkersCacheApi["purge"] | undefined {
// eslint-disable-next-line typescript/no-unsafe-type-assertion -- optional newer export; undefined on older runtimes
const cache = (cfWorkers as { cache?: WorkersCacheApi }).cache;
return typeof cache?.purge === "function" ? cache.purge.bind(cache) : undefined;
}

export const createEdgeCache: CreateEdgeCacheFn = (_config): EdgeCacheInvalidator => {
return {
async purgeAll(): Promise<void> {
const purge = getPurge();
if (!purge) return; // No-op on Node/tests/older runtimes.
await purge({ purgeEverything: true });
},
async purgeTags(tags: string[]): Promise<void> {
if (tags.length === 0) return;
const purge = getPurge();
if (!purge) return;
await purge({ tags });
},
};
};
51 changes: 50 additions & 1 deletion packages/cloudflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,12 @@
* ```
*/

import type { AuthDescriptor, DatabaseDescriptor, StorageDescriptor } from "emdash";
import type {
AuthDescriptor,
DatabaseDescriptor,
EdgeCacheDescriptor,
StorageDescriptor,
} from "emdash";

import type { PreviewDOConfig } from "./db/do-types.js";

Expand Down Expand Up @@ -284,3 +289,47 @@ export { cloudflareStream, type CloudflareStreamConfig } from "./media/stream.js

// Re-export cache provider config helper (config-time)
export { cloudflareCache, type CloudflareCacheConfig } from "./cache/config.js";

/**
* Cloudflare Workers Caching purge configuration.
*/
export interface WorkersCacheConfig {
/**
* Invalidation strategy. Only `"purgeEverything"` is implemented today
* (purge the whole edge cache on any content/chrome write). Tag-based
* purging is planned. Defaults to `"purgeEverything"`.
*/
mode?: "purgeEverything";
}

/**
* Cloudflare Workers Caching invalidation adapter.
*
* Purges the platform cache that sits in front of the Worker on content and
* chrome writes, so edits appear on cached public pages without waiting for
* TTL expiry. Enable the platform cache itself in `wrangler.jsonc`:
*
* ```jsonc
* { "cache": { "enabled": true } }
* ```
*
* and set a cacheable `Cache-Control` on public responses (Workers Caching
* only stores responses with one; requests with `Authorization` and responses
* with `Set-Cookie` / `Cache-Control: private|no-store` auto-bypass).
*
* @example
* ```ts
* import { d1, workersCache } from "@emdash-cms/cloudflare";
*
* emdash({
* database: d1({ binding: "DB" }),
* edgeCache: workersCache(),
* })
* ```
*/
export function workersCache(config: WorkersCacheConfig = {}): EdgeCacheDescriptor {
return {
entrypoint: "@emdash-cms/cloudflare/cache/edge",
config: { mode: config.mode ?? "purgeEverything" },
};
}
56 changes: 56 additions & 0 deletions packages/cloudflare/tests/cache/edge-purge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { afterEach, describe, expect, it, vi } from "vitest";

// Control whether cloudflare:workers exposes `cache.purge`, per-test.
const state = vi.hoisted(() => ({
purge: undefined as undefined | ((opts: unknown) => Promise<unknown>),
}));

vi.mock("cloudflare:workers", () => ({
get cache() {
return state.purge ? { purge: state.purge } : undefined;
},
}));

import { createEdgeCache } from "../../src/cache/edge.js";

describe("workersCache edge purge backend", () => {
afterEach(() => {
state.purge = undefined;
});

it("calls cache.purge({ purgeEverything: true }) on purgeAll", async () => {
const purge = vi.fn(() => Promise.resolve());
state.purge = purge;

await createEdgeCache({ mode: "purgeEverything" }).purgeAll();

expect(purge).toHaveBeenCalledTimes(1);
expect(purge).toHaveBeenCalledWith({ purgeEverything: true });
});

it("calls cache.purge({ tags }) on purgeTags", async () => {
const purge = vi.fn(() => Promise.resolve());
state.purge = purge;

await createEdgeCache({}).purgeTags(["content:posts", "entry:posts:1"]);

expect(purge).toHaveBeenCalledWith({ tags: ["content:posts", "entry:posts:1"] });
});

it("no-ops purgeTags when given no tags", async () => {
const purge = vi.fn(() => Promise.resolve());
state.purge = purge;

await createEdgeCache({}).purgeTags([]);

expect(purge).not.toHaveBeenCalled();
});

it("no-ops gracefully when cache.purge is unavailable (older runtime / Node)", async () => {
state.purge = undefined; // cloudflare:workers exposes no `cache`
const backend = createEdgeCache({ mode: "purgeEverything" });

await expect(backend.purgeAll()).resolves.toBeUndefined();
await expect(backend.purgeTags(["x"])).resolves.toBeUndefined();
});
});
4 changes: 3 additions & 1 deletion packages/cloudflare/tsdown.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@ export default defineConfig({
// Media provider runtimes
"src/media/images-runtime.ts",
"src/media/stream-runtime.ts",
// Cache provider
// Cache provider (full-page response cache)
"src/cache/runtime.ts",
"src/cache/config.ts",
// Edge cache (Workers Caching) purge backend
"src/cache/edge.ts",
],
format: ["esm"],
dts: true,
Expand Down
25 changes: 25 additions & 0 deletions packages/core/src/astro/integration/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import type { AuthDescriptor, AuthProviderDescriptor } from "../../auth/types.js";
import type { DatabaseDescriptor } from "../../db/adapters.js";
import type { EdgeCacheDescriptor } from "../../edge-cache/types.js";
import type { MediaProviderDescriptor } from "../../media/types.js";
import type { ResolvedPlugin } from "../../plugins/types.js";
import type { ExperimentalConfig } from "../../registry/types.js";
Expand Down Expand Up @@ -151,6 +152,30 @@ export interface EmDashConfig {
* Storage configuration (for media)
*/
storage?: StorageDescriptor;

/**
* Optional platform edge-cache (Cloudflare Workers Caching) invalidation.
*
* Off by default. When configured, EmDash purges the platform cache that
* sits in front of the Worker on content and chrome writes, so edits appear
* on cached public pages without waiting for TTL expiry. Enable the
* platform cache itself in `wrangler.jsonc` (`"cache": { "enabled": true }`)
* and set a cacheable `Cache-Control` on public responses.
*
* Use a backend adapter:
* - `workersCache()` from `@emdash-cms/cloudflare`
*
* @example
* ```ts
* import { workersCache } from "@emdash-cms/cloudflare";
*
* emdash({
* database: d1({ binding: "DB" }),
* edgeCache: workersCache({ mode: "purgeEverything" }),
* })
* ```
*/
edgeCache?: EdgeCacheDescriptor;
/**
* Trusted plugins to load (run in main isolate)
*
Expand Down
28 changes: 28 additions & 0 deletions packages/core/src/astro/integration/virtual-modules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ export const RESOLVED_VIRTUAL_DIALECT_ID = "\0" + VIRTUAL_DIALECT_ID;
export const VIRTUAL_STORAGE_ID = "virtual:emdash/storage";
export const RESOLVED_VIRTUAL_STORAGE_ID = "\0" + VIRTUAL_STORAGE_ID;

export const VIRTUAL_EDGE_CACHE_ID = "virtual:emdash/edge-cache";
export const RESOLVED_VIRTUAL_EDGE_CACHE_ID = "\0" + VIRTUAL_EDGE_CACHE_ID;

export const VIRTUAL_ADMIN_REGISTRY_ID = "virtual:emdash/admin-registry";
export const RESOLVED_VIRTUAL_ADMIN_REGISTRY_ID = "\0" + VIRTUAL_ADMIN_REGISTRY_ID;

Expand Down Expand Up @@ -125,6 +128,31 @@ export const createStorage = _createStorage;
`;
}

/**
* Generates the edge-cache virtual module.
*
* Statically imports the configured edge-cache backend's `createEdgeCache`
* factory and embeds its serializable config. When no edge cache is
* configured, exports `undefined` so the runtime invalidator becomes a no-op
* (Workers Caching purge off by default).
*/
export function generateEdgeCacheModule(
entrypoint?: string,
config?: Record<string, unknown>,
): string {
if (!entrypoint) {
return [
`export const createEdgeCache = undefined;`,
`export const edgeCacheConfig = undefined;`,
].join("\n");
}
return `
import { createEdgeCache as _createEdgeCache } from "${entrypoint}";
export const createEdgeCache = _createEdgeCache;
export const edgeCacheConfig = ${JSON.stringify(config ?? {})};
`;
}

/**
* Generates the auth virtual module.
* Statically imports the configured auth provider.
Expand Down
14 changes: 14 additions & 0 deletions packages/core/src/astro/integration/vite-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import {
RESOLVED_VIRTUAL_DIALECT_ID,
VIRTUAL_STORAGE_ID,
RESOLVED_VIRTUAL_STORAGE_ID,
VIRTUAL_EDGE_CACHE_ID,
RESOLVED_VIRTUAL_EDGE_CACHE_ID,
VIRTUAL_ADMIN_REGISTRY_ID,
RESOLVED_VIRTUAL_ADMIN_REGISTRY_ID,
VIRTUAL_PLUGINS_ID,
Expand All @@ -47,6 +49,7 @@ import {
generateConfigModule,
generateDialectModule,
generateStorageModule,
generateEdgeCacheModule,
generateAuthModule,
generateAuthProvidersModule,
generatePluginsModule,
Expand Down Expand Up @@ -173,6 +176,9 @@ export function createVirtualModulesPlugin(options: VitePluginOptions): Plugin {
if (id === VIRTUAL_STORAGE_ID) {
return RESOLVED_VIRTUAL_STORAGE_ID;
}
if (id === VIRTUAL_EDGE_CACHE_ID) {
return RESOLVED_VIRTUAL_EDGE_CACHE_ID;
}
if (id === VIRTUAL_ADMIN_REGISTRY_ID) {
return RESOLVED_VIRTUAL_ADMIN_REGISTRY_ID;
}
Expand Down Expand Up @@ -221,6 +227,14 @@ export function createVirtualModulesPlugin(options: VitePluginOptions): Plugin {
if (id === RESOLVED_VIRTUAL_STORAGE_ID) {
return generateStorageModule(resolvedConfig.storage?.entrypoint);
}
// Generate the edge-cache module — statically imports the configured
// Workers Caching purge backend, or exports undefined (purge off).
if (id === RESOLVED_VIRTUAL_EDGE_CACHE_ID) {
return generateEdgeCacheModule(
resolvedConfig.edgeCache?.entrypoint,
resolvedConfig.edgeCache?.config,
);
}
// Generate plugins module that imports and instantiates all plugins
if (id === RESOLVED_VIRTUAL_PLUGINS_ID) {
return generatePluginsModule(pluginDescriptors);
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/database/repositories/byline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { sql, type Kysely, type Selectable } from "kysely";
import { ulid } from "ulidx";

import { getBylineFieldDefs } from "../../bylines/field-defs-cache.js";
import { invalidateEdgeCache } from "../../edge-cache/index.js";
import {
clearRequestCacheEntry,
peekRequestCache,
Expand Down Expand Up @@ -778,6 +779,7 @@ export class BylineRepository {
if (!byline) {
throw new Error("Failed to create byline");
}
invalidateEdgeCache();
return byline;
}

Expand Down Expand Up @@ -820,6 +822,7 @@ export class BylineRepository {
if (touchedGroupShared) {
clearRequestCacheEntry(`byline-field-group-values:${group}`);
}
invalidateEdgeCache();

return await this.findById(id);
}
Expand Down Expand Up @@ -908,6 +911,7 @@ export class BylineRepository {
}
});

invalidateEdgeCache();
return true;
}

Expand Down
Loading
Loading