diff --git a/.changeset/workers-cache-purge.md b/.changeset/workers-cache-purge.md new file mode 100644 index 000000000..0df9502f5 --- /dev/null +++ b/.changeset/workers-cache-purge.md @@ -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. diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index c6e6b4eef..f32cb51e6 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -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": { diff --git a/packages/cloudflare/src/cache/edge.ts b/packages/cloudflare/src/cache/edge.ts new file mode 100644 index 000000000..0b2a1cafc --- /dev/null +++ b/packages/cloudflare/src/cache/edge.ts @@ -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; +} + +/** + * 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 { + const purge = getPurge(); + if (!purge) return; // No-op on Node/tests/older runtimes. + await purge({ purgeEverything: true }); + }, + async purgeTags(tags: string[]): Promise { + if (tags.length === 0) return; + const purge = getPurge(); + if (!purge) return; + await purge({ tags }); + }, + }; +}; diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 5009ae379..2522aaafe 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -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"; @@ -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" }, + }; +} diff --git a/packages/cloudflare/tests/cache/edge-purge.test.ts b/packages/cloudflare/tests/cache/edge-purge.test.ts new file mode 100644 index 000000000..15d90fa6a --- /dev/null +++ b/packages/cloudflare/tests/cache/edge-purge.test.ts @@ -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), +})); + +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(); + }); +}); diff --git a/packages/cloudflare/tsdown.config.ts b/packages/cloudflare/tsdown.config.ts index 2e524c7b4..22484996e 100644 --- a/packages/cloudflare/tsdown.config.ts +++ b/packages/cloudflare/tsdown.config.ts @@ -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, diff --git a/packages/core/src/astro/integration/runtime.ts b/packages/core/src/astro/integration/runtime.ts index 61d504a9c..f9aab9fde 100644 --- a/packages/core/src/astro/integration/runtime.ts +++ b/packages/core/src/astro/integration/runtime.ts @@ -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"; @@ -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) * diff --git a/packages/core/src/astro/integration/virtual-modules.ts b/packages/core/src/astro/integration/virtual-modules.ts index f130c2309..2e8735413 100644 --- a/packages/core/src/astro/integration/virtual-modules.ts +++ b/packages/core/src/astro/integration/virtual-modules.ts @@ -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; @@ -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 { + 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. diff --git a/packages/core/src/astro/integration/vite-config.ts b/packages/core/src/astro/integration/vite-config.ts index 982877ada..0c9c1ec92 100644 --- a/packages/core/src/astro/integration/vite-config.ts +++ b/packages/core/src/astro/integration/vite-config.ts @@ -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, @@ -47,6 +49,7 @@ import { generateConfigModule, generateDialectModule, generateStorageModule, + generateEdgeCacheModule, generateAuthModule, generateAuthProvidersModule, generatePluginsModule, @@ -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; } @@ -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); diff --git a/packages/core/src/database/repositories/byline.ts b/packages/core/src/database/repositories/byline.ts index 37b782de1..89160327d 100644 --- a/packages/core/src/database/repositories/byline.ts +++ b/packages/core/src/database/repositories/byline.ts @@ -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, @@ -778,6 +779,7 @@ export class BylineRepository { if (!byline) { throw new Error("Failed to create byline"); } + invalidateEdgeCache(); return byline; } @@ -820,6 +822,7 @@ export class BylineRepository { if (touchedGroupShared) { clearRequestCacheEntry(`byline-field-group-values:${group}`); } + invalidateEdgeCache(); return await this.findById(id); } @@ -908,6 +911,7 @@ export class BylineRepository { } }); + invalidateEdgeCache(); return true; } diff --git a/packages/core/src/database/repositories/content.ts b/packages/core/src/database/repositories/content.ts index abb69af82..b78e00291 100644 --- a/packages/core/src/database/repositories/content.ts +++ b/packages/core/src/database/repositories/content.ts @@ -1,6 +1,7 @@ import { sql, type Kysely } from "kysely"; import { ulid } from "ulidx"; +import { invalidateEdgeCache } from "../../edge-cache/index.js"; import { slugify } from "../../utils/slugify.js"; import type { Database } from "../types.js"; import { validateIdentifier } from "../validate.js"; @@ -190,6 +191,8 @@ export class ContentRepository { `.execute(this.db); // Fetch and return the created item + invalidateEdgeCache(); + const item = await this.findById(type, id); if (!item) { throw new Error("Failed to create content"); @@ -602,6 +605,8 @@ export class ContentRepository { .where("deleted_at" as never, "is", null) .execute(); + invalidateEdgeCache(); + const updated = await this.findById(type, id); if (!updated) { throw new Error("Content not found"); @@ -624,6 +629,7 @@ export class ContentRepository { AND deleted_at IS NULL `.execute(this.db); + invalidateEdgeCache(); return (result.numAffectedRows ?? 0n) > 0n; } @@ -640,6 +646,7 @@ export class ContentRepository { AND deleted_at IS NOT NULL `.execute(this.db); + invalidateEdgeCache(); return (result.numAffectedRows ?? 0n) > 0n; } @@ -665,6 +672,7 @@ export class ContentRepository { AND deleted_at IS NOT NULL `.execute(this.db); + invalidateEdgeCache(); return (result.numAffectedRows ?? 0n) > 0n; } @@ -882,6 +890,8 @@ export class ContentRepository { AND deleted_at IS NULL `.execute(this.db); + invalidateEdgeCache(); + const updated = await this.findById(type, id); if (!updated) { throw new Error("Content not found"); @@ -919,6 +929,8 @@ export class ContentRepository { AND deleted_at IS NULL `.execute(this.db); + invalidateEdgeCache(); + const updated = await this.findById(type, id); if (!updated) { throw new Error("Content not found"); @@ -1047,6 +1059,8 @@ export class ContentRepository { `.execute(this.db); } + invalidateEdgeCache(); + const updated = await this.findById(type, id); if (!updated) { throw new Error("Content not found"); @@ -1099,6 +1113,8 @@ export class ContentRepository { AND deleted_at IS NULL `.execute(this.db); + invalidateEdgeCache(); + const updated = await this.findById(type, id); if (!updated) { throw new Error("Content not found"); @@ -1174,6 +1190,8 @@ export class ContentRepository { AND deleted_at IS NULL `.execute(this.db); + invalidateEdgeCache(); + const updated = await this.findById(type, id); if (!updated) { throw new Error("Content not found"); diff --git a/packages/core/src/database/repositories/menu.ts b/packages/core/src/database/repositories/menu.ts index 3dc2de463..06062ce11 100644 --- a/packages/core/src/database/repositories/menu.ts +++ b/packages/core/src/database/repositories/menu.ts @@ -16,6 +16,7 @@ import type { Kysely, Selectable } from "kysely"; import { ulid } from "ulidx"; +import { invalidateEdgeCache } from "../../edge-cache/index.js"; import { withTransaction } from "../transaction.js"; import type { Database, MenuItemTable, MenuTable } from "../types.js"; @@ -369,6 +370,7 @@ export class MenuRepository { } }); + invalidateEdgeCache(); const created = await this.findById(id); if (!created) throw new Error("Failed to create menu"); return created; @@ -383,6 +385,7 @@ export class MenuRepository { if (Object.keys(values).length > 0) { await this.db.updateTable("_emdash_menus").set(values).where("id", "=", id).execute(); + invalidateEdgeCache(); } return (await this.findById(id))!; @@ -404,6 +407,7 @@ export class MenuRepository { await trx.deleteFrom("_emdash_menu_items").where("menu_id", "=", id).execute(); await trx.deleteFrom("_emdash_menus").where("id", "=", id).execute(); }); + invalidateEdgeCache(); return true; } @@ -493,6 +497,7 @@ export class MenuRepository { .selectAll() .where("id", "=", id) .executeTakeFirstOrThrow(); + invalidateEdgeCache(); return rowToMenuItem(row); } @@ -536,6 +541,7 @@ export class MenuRepository { .selectAll() .where("id", "=", itemId) .executeTakeFirstOrThrow(); + invalidateEdgeCache(); return rowToMenuItem(row); } @@ -546,7 +552,9 @@ export class MenuRepository { .where("id", "=", itemId) .where("menu_id", "=", menuId) .execute(); - return result[0]?.numDeletedRows !== 0n; + const deleted = result[0]?.numDeletedRows !== 0n; + if (deleted) invalidateEdgeCache(); + return deleted; } /** @@ -614,6 +622,7 @@ export class MenuRepository { .execute(); }); + invalidateEdgeCache(); return { itemCount: items.length }; } @@ -622,6 +631,7 @@ export class MenuRepository { * malicious payload cannot move foreign items into this menu's siblings. */ async reorderItems(menuId: string, items: ReorderItem[]): Promise { + invalidateEdgeCache(); return withTransaction(this.db, async (trx) => { for (const item of items) { await trx diff --git a/packages/core/src/edge-cache/index.ts b/packages/core/src/edge-cache/index.ts new file mode 100644 index 000000000..298edde83 --- /dev/null +++ b/packages/core/src/edge-cache/index.ts @@ -0,0 +1,121 @@ +/** + * Edge cache invalidation — purge the platform Workers Cache on writes. + * + * Optional and off by default: when no `edgeCache` descriptor is configured, + * `virtual:emdash/edge-cache` exports `createEdgeCache = undefined`, the + * invalidator resolves to `null`, and {@link invalidateEdgeCache} is a no-op. + * Configure with `workersCache()` from `@emdash-cms/cloudflare`. + * + * Calls are coalesced and deferred: many writes within a tick collapse into a + * single purge run via `after()`, so writes never block on the purge and a + * bulk import doesn't fan out into one purge per row (respecting the shared + * zone purge rate limit). + * + * The singleton invalidator lives on `globalThis` behind a `Symbol.for` key so + * Vite SSR chunk duplication can't fork it (same pattern as request-context). + */ + +import { after } from "../after.js"; +import type { CreateEdgeCacheFn, EdgeCacheInvalidator, EdgeCacheRuntimeConfig } from "./types.js"; + +interface InvalidatorHolder { + initialized: boolean; + invalidator: EdgeCacheInvalidator | null; + initPromise: Promise | null; +} + +const HOLDER_KEY = Symbol.for("emdash:edge-cache:invalidator"); +const PENDING_KEY = Symbol.for("emdash:edge-cache:pending"); +const g = globalThis as Record; + +const holder: InvalidatorHolder = + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- globalThis singleton pattern (see request-context.ts) + (g[HOLDER_KEY] as InvalidatorHolder | undefined) ?? + (() => { + const h: InvalidatorHolder = { initialized: false, invalidator: null, initPromise: null }; + g[HOLDER_KEY] = h; + return h; + })(); + +/** + * Resolve (once per isolate) the configured edge-cache invalidator from the + * `virtual:emdash/edge-cache` module. Returns `null` when none is configured, + * or when the virtual module can't be imported (Node/tests). + */ +async function getInvalidator(): Promise { + if (holder.initialized) return holder.invalidator; + if (holder.initPromise) return holder.initPromise; + + holder.initPromise = (async () => { + try { + const mod: { + createEdgeCache?: CreateEdgeCacheFn; + edgeCacheConfig?: EdgeCacheRuntimeConfig; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - virtual module + } = await import("virtual:emdash/edge-cache"); + holder.invalidator = + typeof mod.createEdgeCache === "function" + ? mod.createEdgeCache(mod.edgeCacheConfig ?? {}) + : null; + } catch { + holder.invalidator = null; + } + holder.initialized = true; + holder.initPromise = null; + return holder.invalidator; + })(); + + return holder.initPromise; +} + +/** Whether an edge-cache purge run is already scheduled for this tick. */ +const pending = { + get value(): boolean { + return g[PENDING_KEY] === true; + }, + set value(v: boolean) { + g[PENDING_KEY] = v; + }, +}; + +/** + * Test-only override of the invalidator, bypassing the virtual module. + * @internal + */ +export function __setEdgeCacheInvalidatorForTests(invalidator: EdgeCacheInvalidator | null): void { + holder.initialized = true; + holder.initPromise = null; + holder.invalidator = invalidator; + pending.value = false; +} + +/** + * Invalidate the platform edge cache after a content or chrome write. + * + * Sync and non-blocking: the purge is deferred via `after()` and coalesced — + * repeated calls within a tick result in a single purge run. No-ops when no + * edge cache is configured, so it's safe to call unconditionally at write + * seams. + */ +export function invalidateEdgeCache(): void { + if (pending.value) return; + pending.value = true; + after(async () => { + pending.value = false; + try { + const invalidator = await getInvalidator(); + if (!invalidator) return; + await invalidator.purgeAll(); + } catch (error) { + console.error("[edge-cache] purge failed:", error); + } + }); +} + +export type { + EdgeCacheInvalidator, + EdgeCacheDescriptor, + EdgeCacheRuntimeConfig, + CreateEdgeCacheFn, +} from "./types.js"; diff --git a/packages/core/src/edge-cache/types.ts b/packages/core/src/edge-cache/types.ts new file mode 100644 index 000000000..1febd753f --- /dev/null +++ b/packages/core/src/edge-cache/types.ts @@ -0,0 +1,62 @@ +/** + * Edge cache (platform Workers Caching) invalidation types. + * + * "Workers Caching" is the Cloudflare platform cache enabled with + * `cache: { enabled: true }` in Wrangler. It sits *in front of* the Worker: + * on a HIT, Cloudflare returns the stored response without invoking the Worker + * at all — so the in-process and object/data caches never run. It only + * invalidates on TTL expiry or an explicit `cache.purge()`. + * + * This is the HTML-layer analogue of EmDash's on-write data-cache + * invalidation: when content or chrome changes, the platform cache must be + * purged so edits appear without waiting for TTL. The purge mechanism is + * Cloudflare-specific (`import { cache } from "cloudflare:workers"`), so the + * implementation lives in `@emdash-cms/cloudflare`; core only defines the + * interface and calls it at the write seams. + * + * Distinct from `cloudflareCache()` (the Astro route-cache provider using the + * Cache API + zone REST purge) and from the object/data cache — a + * zone/Cache-API purge does NOT affect Workers Caching, so this needs its own + * `cache.purge()`. + */ + +/** + * Invalidates the platform edge cache. Implementations must be safe to call + * when nothing is cached and must never throw on the response path (callers + * defer and swallow errors). + */ +export interface EdgeCacheInvalidator { + /** Purge the entire edge cache for the calling entrypoint. */ + purgeAll(): Promise; + /** + * Purge only entries tagged with the given `Cache-Tag` values. Used by the + * (future) precise tag mode; v1 uses {@link purgeAll}. + */ + purgeTags(tags: string[]): Promise; +} + +/** Serializable descriptor for an edge-cache backend (mirrors StorageDescriptor). */ +export interface EdgeCacheDescriptor { + /** Module path exporting a `createEdgeCache` function. */ + entrypoint: string; + /** Serializable config passed to `createEdgeCache` at runtime. */ + config: EdgeCacheRuntimeConfig; +} + +/** Runtime config shared by edge-cache backends. */ +export interface EdgeCacheRuntimeConfig { + /** + * Invalidation strategy. + * + * - `"purgeEverything"` (default): purge the whole edge cache on any + * content/chrome write. Coarse but correct; no response tagging needed. + * + * Tag-based purging (purge only the affected pages) is planned and will be + * enabled here once per-request `Cache-Tag` emission is in place. + */ + mode?: "purgeEverything"; + [key: string]: unknown; +} + +/** Factory exported as `createEdgeCache` from a backend entrypoint. */ +export type CreateEdgeCacheFn = (config: EdgeCacheRuntimeConfig) => EdgeCacheInvalidator; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a64261226..bacac43d3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -191,6 +191,15 @@ export type { } from "./storage/types.js"; export { EmDashStorageError } from "./storage/types.js"; +// Edge cache (platform Workers Caching) invalidation +export { invalidateEdgeCache } from "./edge-cache/index.js"; +export type { + EdgeCacheInvalidator, + EdgeCacheDescriptor, + EdgeCacheRuntimeConfig, + CreateEdgeCacheFn, +} from "./edge-cache/types.js"; + // Plugin system export { definePlugin, diff --git a/packages/core/src/redirects/cache.ts b/packages/core/src/redirects/cache.ts index d6c0e4dd1..746c0be57 100644 --- a/packages/core/src/redirects/cache.ts +++ b/packages/core/src/redirects/cache.ts @@ -14,6 +14,7 @@ */ import type { Redirect } from "../database/repositories/redirect.js"; +import { invalidateEdgeCache } from "../edge-cache/index.js"; import type { CompiledPattern } from "./patterns.js"; import { compilePattern, interpolateDestination, matchPattern } from "./patterns.js"; @@ -41,6 +42,9 @@ let cachedRedirects: CachedRedirects | null = null; */ export function invalidateRedirectCache(): void { cachedRedirects = null; + // Redirect rules (and content slug changes, which create auto-redirects) + // affect public navigation — purge the platform edge cache. + invalidateEdgeCache(); } /** diff --git a/packages/core/src/settings/index.ts b/packages/core/src/settings/index.ts index 92df2816b..fac98c831 100644 --- a/packages/core/src/settings/index.ts +++ b/packages/core/src/settings/index.ts @@ -10,6 +10,7 @@ import type { Kysely } from "kysely"; import { MediaRepository } from "../database/repositories/media.js"; import { OptionsRepository } from "../database/repositories/options.js"; import type { Database } from "../database/types.js"; +import { invalidateEdgeCache } from "../edge-cache/index.js"; import { getDb } from "../loader.js"; import { peekRequestCache, requestCached } from "../request-cache.js"; import type { Storage } from "../storage/types.js"; @@ -63,6 +64,9 @@ export function invalidateSiteSettingsCache(): void { holder.version++; holder.cached = null; holder.cachedVersion = -1; + // Site settings (title, logo, SEO defaults) render into public pages — + // purge the platform edge cache so the change shows without waiting for TTL. + invalidateEdgeCache(); } /** diff --git a/packages/core/src/taxonomies/index.ts b/packages/core/src/taxonomies/index.ts index 0571045f3..807021c77 100644 --- a/packages/core/src/taxonomies/index.ts +++ b/packages/core/src/taxonomies/index.ts @@ -11,6 +11,7 @@ * the right per-locale term. */ +import { invalidateEdgeCache } from "../edge-cache/index.js"; import { resolveLocale, resolveLocaleChain } from "../i18n/resolve.js"; import { getDb } from "../loader.js"; import { peekRequestCache, requestCached, setRequestCacheEntry } from "../request-cache.js"; @@ -23,10 +24,12 @@ export interface TaxonomyQueryOptions { } /** - * No-op — kept for API compatibility. + * Invalidate caches after a taxonomy write. Taxonomy terms render into public + * pages (term archives, tag lists, post categories), so purge the platform + * edge cache so the change appears without waiting for TTL. */ export function invalidateTermCache(): void { - // Intentionally empty. + invalidateEdgeCache(); } /** diff --git a/packages/core/src/virtual-modules.d.ts b/packages/core/src/virtual-modules.d.ts index fc1429668..148fca721 100644 --- a/packages/core/src/virtual-modules.d.ts +++ b/packages/core/src/virtual-modules.d.ts @@ -65,6 +65,14 @@ declare module "virtual:emdash/storage" { export const createStorage: ((config: Record) => Storage) | undefined; } +declare module "virtual:emdash/edge-cache" { + import type { CreateEdgeCacheFn, EdgeCacheRuntimeConfig } from "./edge-cache/types.js"; + + // Can be undefined if no edge cache is configured. + export const createEdgeCache: CreateEdgeCacheFn | undefined; + export const edgeCacheConfig: EdgeCacheRuntimeConfig | undefined; +} + declare module "virtual:emdash/auth" { import type { AuthResult } from "./auth/types.js"; diff --git a/packages/core/tests/unit/edge-cache.test.ts b/packages/core/tests/unit/edge-cache.test.ts new file mode 100644 index 000000000..0bfbfa77a --- /dev/null +++ b/packages/core/tests/unit/edge-cache.test.ts @@ -0,0 +1,115 @@ +import type { Kysely } from "kysely"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("virtual:emdash/wait-until", () => ({ waitUntil: undefined }), { virtual: true }); + +import { handleContentCreate } from "../../src/api/index.js"; +import type { Database } from "../../src/database/types.js"; +import { + __setEdgeCacheInvalidatorForTests, + invalidateEdgeCache, + type EdgeCacheInvalidator, +} from "../../src/edge-cache/index.js"; +import { runWithContext } from "../../src/request-context.js"; +import { invalidateSiteSettingsCache } from "../../src/settings/index.js"; +import { setupTestDatabaseWithCollections, teardownTestDatabase } from "../utils/test-db.js"; + +/** Flush the microtask + macrotask queue so deferred `after()` purges land. */ +async function flush(): Promise { + await new Promise((r) => setTimeout(r, 0)); +} + +function spyInvalidator(): EdgeCacheInvalidator & { purgeAll: ReturnType } { + return { + purgeAll: vi.fn(() => Promise.resolve()), + purgeTags: vi.fn(() => Promise.resolve()), + }; +} + +describe("invalidateEdgeCache", () => { + afterEach(() => { + __setEdgeCacheInvalidatorForTests(null); + }); + + it("purges once, deferred, when invoked", async () => { + const inv = spyInvalidator(); + __setEdgeCacheInvalidatorForTests(inv); + + invalidateEdgeCache(); + // Deferred — nothing synchronous. + expect(inv.purgeAll).not.toHaveBeenCalled(); + + await flush(); + expect(inv.purgeAll).toHaveBeenCalledTimes(1); + }); + + it("coalesces a burst of calls into a single purge", async () => { + const inv = spyInvalidator(); + __setEdgeCacheInvalidatorForTests(inv); + + for (let i = 0; i < 10; i++) invalidateEdgeCache(); + await flush(); + + expect(inv.purgeAll).toHaveBeenCalledTimes(1); + + // A later write (after the coalescing window) purges again. + invalidateEdgeCache(); + await flush(); + expect(inv.purgeAll).toHaveBeenCalledTimes(2); + }); + + it("is a no-op when no edge cache is configured", async () => { + __setEdgeCacheInvalidatorForTests(null); + expect(() => invalidateEdgeCache()).not.toThrow(); + await flush(); + // Nothing to assert beyond "did not throw" — there is no invalidator. + }); + + it("purges on a site-settings write (chrome seam)", async () => { + const inv = spyInvalidator(); + __setEdgeCacheInvalidatorForTests(inv); + + invalidateSiteSettingsCache(); + await flush(); + + expect(inv.purgeAll).toHaveBeenCalledTimes(1); + }); +}); + +describe("invalidateEdgeCache: content write seam", () => { + let db: Kysely; + + beforeEach(async () => { + db = await setupTestDatabaseWithCollections(); + }); + + afterEach(async () => { + __setEdgeCacheInvalidatorForTests(null); + await teardownTestDatabase(db); + }); + + it("purges after creating content", async () => { + const inv = spyInvalidator(); + __setEdgeCacheInvalidatorForTests(inv); + + const result = await runWithContext({ editMode: false, db }, () => + handleContentCreate(db, "post", { data: { title: "Hello" }, status: "published" }), + ); + expect(result.success).toBe(true); + + await flush(); + expect(inv.purgeAll).toHaveBeenCalledTimes(1); + }); + + it("does not purge when no edge cache is configured", async () => { + __setEdgeCacheInvalidatorForTests(null); + + await runWithContext({ editMode: false, db }, () => + handleContentCreate(db, "post", { data: { title: "Hello" }, status: "published" }), + ); + await flush(); + // No invalidator → no purge calls (and no throw). Nothing to assert + // beyond the absence of errors. + expect(true).toBe(true); + }); +});