Skip to content

Commit e886554

Browse files
feat: cache purge API (object cache + native Workers Caching) (#2275)
* feat: add object-cache purge API and cache:purge plugin capability Admins and sandboxed plugins can clear CMS object-cache namespaces (KV/memory) via GET/POST /_emdash/api/admin/cache/object and ctx.cache. Block Kit buttons gain optional disabled and title fields for clearer troubleshooting UI. * style: format * feat: add Workers Cache purge API alongside object cache Admins and plugins with cache:purge can clear edge-cached pages via GET/POST /_emdash/api/admin/cache/workers and ctx.cache.purgeWorkersCache() (Cloudflare purge_everything using CF_ZONE_ID + CF_CACHE_PURGE_TOKEN). * feat: purge Workers Cache via native cache.purge() Replace zone REST purge (CF_ZONE_ID + token) with cloudflare:workers cache.purge({ purgeEverything: true }). Status is configured when the native API is available — no secrets required. * fix(core): resolve Workers Cache purge via virtual module Dynamic import of cloudflare:workers from core failed under Vite. Expose cache through virtual:emdash/workers-cache (same pattern as env and waitUntil) so status/purge work on the Cloudflare adapter. * feat: Workers Cache path-prefix purge POST /admin/cache/workers and ctx.cache.purgeWorkersCache() accept optional pathPrefixes (paths or full URLs, normalized). Empty input still purges everything via cache.purge. * fix: lint workers-cache handlers and marketplace capability list Move URL regex to module scope, drop redundant unknown union, rename shadowed Tooltip render prop, and include cache:purge in CAPABILITY_LABELS contract test. --------- Co-authored-by: emdashbot[bot] <emdashbot[bot]@users.noreply.github.com>
1 parent ba0e9cb commit e886554

37 files changed

Lines changed: 1437 additions & 15 deletions
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"emdash": minor
3+
"@emdash-cms/cloudflare": minor
4+
"@emdash-cms/sandbox-workerd": minor
5+
"@emdash-cms/plugin-types": minor
6+
"@emdash-cms/plugin-cli": patch
7+
"@emdash-cms/blocks": minor
8+
---
9+
10+
Adds admin APIs and a `cache:purge` plugin capability for clearing CMS caches: object cache (`GET`/`POST /_emdash/api/admin/cache/object`, `ctx.cache.purgeObjectCache`) and native Workers Caching (`GET`/`POST /_emdash/api/admin/cache/workers`, `ctx.cache.purgeWorkersCache` via `cache.purge` — purge everything or path prefixes; no zone ID or API token). Block Kit buttons also support optional `disabled` and `title` (tooltip) fields.

packages/admin/src/lib/api/marketplace.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,7 @@ export const CAPABILITY_LABELS: Record<string, MessageDescriptor> = {
282282
"media:read": msg`Access your media library`,
283283
"media:write": msg`Upload and manage media`,
284284
"users:read": msg`Read user accounts`,
285+
"cache:purge": msg`Clear the CMS object cache and Workers Cache`,
285286
"network:request": msg`Make network requests`,
286287
"network:request:unrestricted": msg`Make network requests to any host (unrestricted)`,
287288
// Legacy aliases (still emitted by older installed manifests)

packages/admin/tests/lib/marketplace.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,7 @@ describe("CAPABILITY_LABELS", () => {
312312
"media:read",
313313
"media:write",
314314
"users:read",
315+
"cache:purge",
315316
"network:request",
316317
"network:request:unrestricted",
317318
// Legacy aliases

packages/blocks/src/builders.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,8 @@ function button(
246246
style?: "primary" | "danger" | "secondary";
247247
value?: unknown;
248248
confirm?: ConfirmDialog;
249+
disabled?: boolean;
250+
title?: string;
249251
},
250252
): ButtonElement {
251253
return {
@@ -255,6 +257,8 @@ function button(
255257
...(opts?.style !== undefined && { style: opts.style }),
256258
...(opts?.value !== undefined && { value: opts.value }),
257259
...(opts?.confirm !== undefined && { confirm: opts.confirm }),
260+
...(opts?.disabled !== undefined && { disabled: opts.disabled }),
261+
...(opts?.title !== undefined && { title: opts.title }),
258262
};
259263
}
260264

packages/blocks/src/elements/button.tsx

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Button, Dialog, DialogRoot } from "@cloudflare/kumo";
1+
import { Button, Dialog, DialogRoot, Tooltip, TooltipProvider } from "@cloudflare/kumo";
22
import { useCallback, useState } from "react";
33

44
import type { BlockInteraction, ButtonElement } from "../types.js";
@@ -11,22 +11,26 @@ export function ButtonElementComponent({
1111
onAction: (interaction: BlockInteraction) => void;
1212
}) {
1313
const [confirmOpen, setConfirmOpen] = useState(false);
14+
const isDisabled = element.disabled === true;
15+
const hasTitle = element.title !== undefined && element.title.length > 0;
1416

1517
const fireAction = useCallback(() => {
18+
if (isDisabled) return;
1619
onAction({
1720
type: "block_action",
1821
action_id: element.action_id,
1922
value: element.value,
2023
});
21-
}, [onAction, element.action_id, element.value]);
24+
}, [onAction, isDisabled, element.action_id, element.value]);
2225

2326
const handleClick = useCallback(() => {
27+
if (isDisabled) return;
2428
if (element.confirm) {
2529
setConfirmOpen(true);
2630
} else {
2731
fireAction();
2832
}
29-
}, [element.confirm, fireAction]);
33+
}, [isDisabled, element.confirm, fireAction]);
3034

3135
const handleConfirm = useCallback(() => {
3236
setConfirmOpen(false);
@@ -40,12 +44,35 @@ export function ButtonElementComponent({
4044
? ("destructive" as const)
4145
: ("secondary" as const);
4246

47+
// Don't pass `title` into Kumo Button when disabled — that attaches the
48+
// tooltip trigger to the disabled <button>, which never receives hover.
49+
// Instead wrap a span (always hoverable) as the Tooltip trigger.
50+
const button = (
51+
<Button variant={variant} onClick={handleClick} disabled={isDisabled}>
52+
{element.label}
53+
</Button>
54+
);
55+
56+
const withTooltip = hasTitle ? (
57+
<TooltipProvider>
58+
<Tooltip
59+
content={element.title}
60+
delay={200}
61+
closeDelay={0}
62+
// Span keeps pointer events when the inner button is disabled.
63+
render={<span className="inline-flex max-w-max" />}
64+
>
65+
{button}
66+
</Tooltip>
67+
</TooltipProvider>
68+
) : (
69+
button
70+
);
71+
4372
return (
4473
<>
45-
<Button variant={variant} onClick={handleClick}>
46-
{element.label}
47-
</Button>
48-
{element.confirm && (
74+
{withTooltip}
75+
{element.confirm && !isDisabled && (
4976
<DialogRoot open={confirmOpen} onOpenChange={setConfirmOpen}>
5077
<Dialog>
5178
<h3 className="text-lg font-semibold text-kumo-default">{element.confirm.title}</h3>

packages/blocks/src/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ export interface ButtonElement {
1717
style?: "primary" | "danger" | "secondary";
1818
value?: unknown;
1919
confirm?: ConfirmDialog;
20+
/** When true, the button does not fire actions. */
21+
disabled?: boolean;
22+
/** Native tooltip shown on hover (e.g. why the button is disabled). */
23+
title?: string;
2024
}
2125

2226
export interface TextInputElement {

packages/blocks/src/validation.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,18 @@ function validateElement(value: unknown, path: string, errors: ValidationError[]
176176
message: `Field 'style' must be one of: ${[...BUTTON_STYLES].join(", ")}`,
177177
});
178178
}
179+
if (value.disabled !== undefined && typeof value.disabled !== "boolean") {
180+
errors.push({
181+
path: `${path}.disabled`,
182+
message: "Field 'disabled' must be a boolean",
183+
});
184+
}
185+
if (value.title !== undefined && typeof value.title !== "string") {
186+
errors.push({
187+
path: `${path}.title`,
188+
message: "Field 'title' must be a string",
189+
});
190+
}
179191
if (value.confirm !== undefined) {
180192
validateConfirmDialog(value.confirm, `${path}.confirm`, errors);
181193
}

packages/blocks/tests/renderer.test.tsx

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,28 @@ const CollapsibleContext = React.createContext<{
1515
}>({});
1616

1717
vi.mock("@cloudflare/kumo", () => ({
18-
Button: ({ children, onClick, variant, type }: any) => (
19-
<button onClick={onClick} data-variant={variant} type={type || "button"}>
18+
Button: ({ children, onClick, variant, type, disabled, title }: any) => (
19+
<button
20+
onClick={onClick}
21+
data-variant={variant}
22+
type={type || "button"}
23+
disabled={disabled}
24+
title={typeof title === "string" ? title : undefined}
25+
>
2026
{children}
2127
</button>
2228
),
29+
TooltipProvider: ({ children }: any) => <>{children}</>,
30+
Tooltip: ({ content, children, render: triggerRender }: any) => {
31+
const trigger = triggerRender ?? <span />;
32+
return (
33+
<div data-testid="tooltip" data-content={content}>
34+
{React.isValidElement(trigger)
35+
? React.cloneElement(trigger as React.ReactElement<any>, {}, children)
36+
: children}
37+
</div>
38+
);
39+
},
2340
Badge: ({ children }: any) => <span data-testid="badge">{children}</span>,
2441
Input: ({ label, value, defaultValue, onChange, onBlur, placeholder, type, min, max }: any) => (
2542
<div>
@@ -393,6 +410,34 @@ describe("BlockRenderer", () => {
393410
expect(screen.getByText("Cancel")).toBeTruthy();
394411
});
395412

413+
it("disabled button with title wraps a tooltip and does not fire actions", () => {
414+
const onAction = vi.fn();
415+
renderBlocks(
416+
[
417+
{
418+
type: "actions",
419+
elements: [
420+
{
421+
type: "button",
422+
action_id: "clear",
423+
label: "Clear object cache",
424+
disabled: true,
425+
title: "Object Cache Not Configured",
426+
},
427+
],
428+
},
429+
],
430+
onAction,
431+
);
432+
const btn = screen.getByText("Clear object cache") as HTMLButtonElement;
433+
expect(btn.disabled).toBe(true);
434+
expect(screen.getByTestId("tooltip").getAttribute("data-content")).toBe(
435+
"Object Cache Not Configured",
436+
);
437+
fireEvent.click(btn);
438+
expect(onAction).not.toHaveBeenCalled();
439+
});
440+
396441
it("stats block renders stat cards with values", () => {
397442
renderBlocks([
398443
{

packages/cloudflare/src/sandbox/bridge.ts

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,14 @@
1010
import type { D1Database } from "@cloudflare/workers-types";
1111
import { WorkerEntrypoint } from "cloudflare:workers";
1212
import type { SandboxEmailSendCallback } from "emdash";
13-
import { ulid, PluginStorageRepository } from "emdash";
13+
import {
14+
handleObjectCachePurge,
15+
handleObjectCacheStatus,
16+
handleWorkersCachePurge,
17+
handleWorkersCacheStatus,
18+
ulid,
19+
PluginStorageRepository,
20+
} from "emdash";
1421
import { Kysely } from "kysely";
1522
import { D1Dialect } from "kysely-d1";
1623

@@ -1173,6 +1180,70 @@ export class PluginBridge extends WorkerEntrypoint<PluginBridgeEnv, PluginBridge
11731180
await emailSendCallback(message, pluginId);
11741181
}
11751182

1183+
// =========================================================================
1184+
// Object cache — capability-gated (cache:purge)
1185+
// =========================================================================
1186+
1187+
async getObjectCacheStatus(): Promise<{ configured: boolean }> {
1188+
const { capabilities } = this.ctx.props;
1189+
if (!capabilities.includes("cache:purge")) {
1190+
throw new Error("Missing capability: cache:purge");
1191+
}
1192+
const result = await handleObjectCacheStatus();
1193+
if (!result.success) {
1194+
throw new Error(result.error.message);
1195+
}
1196+
return result.data;
1197+
}
1198+
1199+
async purgeObjectCache(options?: {
1200+
namespaces?: string[];
1201+
}): Promise<{ configured: boolean; active: boolean; purged: string[] }> {
1202+
const { capabilities } = this.ctx.props;
1203+
if (!capabilities.includes("cache:purge")) {
1204+
throw new Error("Missing capability: cache:purge");
1205+
}
1206+
const db = new Kysely<unknown>({
1207+
dialect: new D1Dialect({ database: this.env.DB }),
1208+
});
1209+
// eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- D1 dialect matches core handler db shape
1210+
const result = await handleObjectCachePurge(db as never, {
1211+
namespaces: options?.namespaces,
1212+
});
1213+
if (!result.success) {
1214+
throw new Error(result.error.message);
1215+
}
1216+
return result.data;
1217+
}
1218+
1219+
async getWorkersCacheStatus(): Promise<{ configured: boolean }> {
1220+
const { capabilities } = this.ctx.props;
1221+
if (!capabilities.includes("cache:purge")) {
1222+
throw new Error("Missing capability: cache:purge");
1223+
}
1224+
const result = await handleWorkersCacheStatus();
1225+
if (!result.success) {
1226+
throw new Error(result.error.message);
1227+
}
1228+
return result.data;
1229+
}
1230+
1231+
async purgeWorkersCache(options?: {
1232+
pathPrefixes?: string[];
1233+
}): Promise<{ configured: boolean; purged: boolean; pathPrefixes?: string[] }> {
1234+
const { capabilities } = this.ctx.props;
1235+
if (!capabilities.includes("cache:purge")) {
1236+
throw new Error("Missing capability: cache:purge");
1237+
}
1238+
const result = await handleWorkersCachePurge({
1239+
pathPrefixes: options?.pathPrefixes,
1240+
});
1241+
if (!result.success) {
1242+
throw new Error(result.error.message);
1243+
}
1244+
return result.data;
1245+
}
1246+
11761247
// =========================================================================
11771248
// Logging
11781249
// =========================================================================

packages/cloudflare/src/sandbox/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,15 @@ export interface PluginBridgeBinding {
212212
): Promise<{ status: number; headers: Record<string, string>; text: string }>;
213213
// Email
214214
emailSend(message: { to: string; subject: string; text: string; html?: string }): Promise<void>;
215+
// Cache purge (gated on cache:purge)
216+
getObjectCacheStatus(): Promise<{ configured: boolean }>;
217+
purgeObjectCache(options?: {
218+
namespaces?: string[];
219+
}): Promise<{ configured: boolean; active: boolean; purged: string[] }>;
220+
getWorkersCacheStatus(): Promise<{ configured: boolean }>;
221+
purgeWorkersCache(options?: {
222+
pathPrefixes?: string[];
223+
}): Promise<{ configured: boolean; purged: boolean; pathPrefixes?: string[] }>;
215224
// Logging
216225
log(level: "debug" | "info" | "warn" | "error", msg: string, data?: unknown): void;
217226
}

0 commit comments

Comments
 (0)