Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .changeset/plugin-route-cache-control.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"emdash": minor
"@emdash-cms/plugin-types": minor
---

Adds a `cacheControl` option for public plugin routes: successful GET responses carry the configured `Cache-Control` header, enabling CDN and browser caching for public plugin endpoints. Works for native, standard, and marketplace plugin formats. Private routes and errors keep the `private, no-store` default.
16 changes: 16 additions & 0 deletions docs/src/content/docs/plugins/creating-plugins/api-routes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,22 @@ routes: {
Public routes skip authentication, scope, and CSRF entirely. Anyone on the internet can call them. Use `public: true` only for endpoints that need to accept external traffic — webhooks, public-facing search endpoints — and validate the input carefully.
</Aside>

### Caching public responses

API responses default to `Cache-Control: private, no-store`. For public routes that serve the same data to everyone — a product catalog, a public search index — that means every page view pays a full round-trip to the origin. Public routes can opt in to CDN/browser caching with `cacheControl`:

```typescript
routes: {
catalog: {
public: true,
cacheControl: "public, max-age=60, stale-while-revalidate=300",
handler: async (ctx) => listProducts(ctx),
},
},
```

The header is applied only to **successful GET responses of public routes**. Errors are never cached, other methods keep the default, and setting `cacheControl` on a private route has no effect — authenticated responses always stay `private, no-store`.

## Input validation

`input` accepts a Zod schema. The dispatcher parses the request body (POST/PUT/PATCH) or query string (GET/DELETE), validates it, and passes the typed result to your handler as `routeCtx.input`. Invalid input returns a 400 before your handler runs.
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/astro/public-plugin-api-routes.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { RouteMeta } from "../plugins/routes.js";
import type { HandlerResponse } from "./types.js";

export type PublicPluginApiRouteHandler = (
Expand All @@ -8,7 +9,7 @@ export type PublicPluginApiRouteHandler = (
) => Promise<HandlerResponse>;

interface PublicPluginApiRouteRuntime {
getPluginRouteMeta(pluginId: string, path: string): { public: boolean } | null;
getPluginRouteMeta(pluginId: string, path: string): RouteMeta | null;
handlePluginApiRoute(
pluginId: string,
method: string,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,15 @@ const handleRequest: APIRoute = async ({ params, request, locals }) => {
return apiError(code, message, status);
}

return apiSuccess(result.data);
const response = apiSuccess(result.data);
// Public routes may opt in to CDN/browser caching for GET responses.
// getRouteMeta only ever exposes cacheControl on public routes, and errors
// above keep the default private, no-store. Astro serves HEAD via this GET
// export, which is fine: same headers, no body.
if (routeMeta.cacheControl && (method === "GET" || method === "HEAD")) {
response.headers.set("Cache-Control", routeMeta.cacheControl);
}
return response;
};

// Export handlers for all HTTP methods
Expand Down
6 changes: 4 additions & 2 deletions packages/core/src/astro/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
import type { Element } from "@emdash-cms/blocks";
import type { Kysely } from "kysely";

import type { RouteMeta } from "../plugins/routes.js";

// Re-export core types
export type {
ContentItem,
Expand Down Expand Up @@ -410,8 +412,8 @@ export interface EmDashHandlers {
request: Request,
) => Promise<HandlerResponse>;

// Plugin route metadata (for auth decisions before dispatch)
getPluginRouteMeta: (pluginId: string, path: string) => { public: boolean } | null;
// Plugin route metadata (for auth/caching decisions before dispatch)
getPluginRouteMeta: (pluginId: string, path: string) => RouteMeta | null;

// Media provider handlers
getMediaProvider: (providerId: string) => import("../media/types.js").MediaProvider | undefined;
Expand Down
11 changes: 10 additions & 1 deletion packages/core/src/cli/commands/bundle-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type {
ResolvedPlugin,
HookName,
ManifestHookEntry,
ManifestRouteEntry,
} from "../../plugins/types.js";

// ── Constants ────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -157,7 +158,15 @@ export function extractManifest(plugin: ResolvedPlugin): PluginManifest {
allowedHosts: plugin.allowedHosts,
storage: plugin.storage,
hooks,
routes: Object.keys(plugin.routes),
// Emit structured entries when a route carries metadata the host needs
// for auth/caching decisions (public, cacheControl); plain names otherwise.
routes: Object.entries(plugin.routes).map(([name, route]) => {
if (!route.public && !route.cacheControl) return name;
const entry: ManifestRouteEntry = { name };
if (route.public) entry.public = true;
if (route.cacheControl) entry.cacheControl = route.cacheControl;
return entry;
}),
admin: {
// Omit entry (it's a module specifier for the host, not relevant in bundles)
settingsSchema: plugin.admin.settingsSchema,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/cli/commands/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,7 @@ export const bundleCommand = defineCommand({
(resolvedPlugin.routes as Record<string, unknown>)[name] = {
handler: routeObj.handler,
public: routeObj.public,
cacheControl: routeObj.cacheControl,
};
}
}
Expand Down
12 changes: 6 additions & 6 deletions packages/core/src/emdash-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ import {
} from "./plugins/hooks.js";
import { normalizeManifestRoute } from "./plugins/manifest-schema.js";
import { extractRequestMeta, sanitizeHeadersForSandbox } from "./plugins/request-meta.js";
import { PluginRouteRegistry, type RouteMeta } from "./plugins/routes.js";
import { buildRouteMeta, PluginRouteRegistry, type RouteMeta } from "./plugins/routes.js";
import type { CronScheduler } from "./plugins/scheduler/types.js";
import { PluginStateRepository } from "./plugins/state.js";
import { normalizeRegistryConfig } from "./registry/config.js";
Expand Down Expand Up @@ -877,7 +877,7 @@ export class EmDashRuntime {
const routeMetaMap = new Map<string, RouteMeta>();
for (const entry of bundle.manifest.routes) {
const normalized = normalizeManifestRoute(entry);
routeMetaMap.set(normalized.name, { public: normalized.public === true });
routeMetaMap.set(normalized.name, buildRouteMeta(normalized));
}
sandboxedRouteMetaCache.set(pluginId, routeMetaMap);
} else {
Expand Down Expand Up @@ -989,7 +989,7 @@ export class EmDashRuntime {
const routeMetaMap = new Map<string, RouteMeta>();
for (const entry of bundle.manifest.routes) {
const normalized = normalizeManifestRoute(entry);
routeMetaMap.set(normalized.name, { public: normalized.public === true });
routeMetaMap.set(normalized.name, buildRouteMeta(normalized));
}
sandboxedRouteMetaCache.set(pluginId, routeMetaMap);
} else {
Expand Down Expand Up @@ -2010,7 +2010,7 @@ export class EmDashRuntime {
const routeMeta = new Map<string, RouteMeta>();
for (const entry of bundle.manifest.routes) {
const normalized = normalizeManifestRoute(entry);
routeMeta.set(normalized.name, { public: normalized.public === true });
routeMeta.set(normalized.name, buildRouteMeta(normalized));
}
sandboxedRouteMetaCache.set(plugin.pluginId, routeMeta);
}
Expand Down Expand Up @@ -2079,7 +2079,7 @@ export class EmDashRuntime {
const routeMeta = new Map<string, RouteMeta>();
for (const entry of bundle.manifest.routes) {
const normalized = normalizeManifestRoute(entry);
routeMeta.set(normalized.name, { public: normalized.public === true });
routeMeta.set(normalized.name, buildRouteMeta(normalized));
}
sandboxedRouteMetaCache.set(plugin.pluginId, routeMeta);
}
Expand Down Expand Up @@ -3286,7 +3286,7 @@ export class EmDashRuntime {
if (trustedPlugin) {
const route = trustedPlugin.routes[routeKey];
if (!route) return null;
return { public: route.public === true };
return buildRouteMeta(route);
}

// Check sandboxed plugin route metadata cache
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/plugin-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,12 @@ export type RouteEntry =
| {
handler: RouteHandler;
public?: boolean;
/**
* Cache-Control value for successful GET responses. Only honored on
* routes that are also `public: true` — authenticated responses
* always keep `private, no-store`.
*/
cacheControl?: string;
input?: unknown;
};

Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/plugins/adapt-sandbox-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ function resolveSandboxedHook(entry: AnyHookEntry, pluginId: string): ResolvedHo
function normalizeRouteEntry(entry: RouteEntry): {
handler: RouteHandler;
public?: boolean;
cacheControl?: string;
input?: PluginRoute["input"];
} {
if (typeof entry === "function") {
Expand All @@ -116,6 +117,7 @@ function normalizeRouteEntry(entry: RouteEntry): {
return {
handler: entry.handler,
public: entry.public,
cacheControl: entry.cacheControl,
// eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- RouteEntry.input is intentionally `unknown` (sandboxed plugins) and validated by the runtime at invocation time
input: entry.input as PluginRoute["input"],
};
Expand Down Expand Up @@ -196,10 +198,11 @@ export function adaptSandboxEntry(
if (definition.routes) {
for (const [routeName, rawEntry] of Object.entries(definition.routes)) {
const normalized = normalizeRouteEntry(rawEntry);
const { handler, public: publicFlag, input: inputSchema } = normalized;
const { handler, public: publicFlag, cacheControl, input: inputSchema } = normalized;
resolvedRoutes[routeName] = {
input: inputSchema,
public: publicFlag,
cacheControl,
handler: async (ctx) => {
// `ctx.request` is a real WHATWG `Request` (this is the
// in-process adapter; the worker-sandbox adapter handles
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/plugins/manifest-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ const routeNamePattern = /^[a-zA-Z0-9][a-zA-Z0-9_\-/]*$/;
const manifestRouteEntrySchema = z.object({
name: z.string().min(1).regex(routeNamePattern, "Route name must be a safe path segment"),
public: z.boolean().optional(),
cacheControl: z.string().min(1).optional(),
});

// ── Sub-schemas ─────────────────────────────────────────────────
Expand Down Expand Up @@ -349,9 +350,12 @@ export function normalizeManifestHook(
/**
* Normalize a manifest route entry — plain strings become `{ name }` objects.
*/
export function normalizeManifestRoute(entry: string | { name: string; public?: boolean }): {
export function normalizeManifestRoute(
entry: string | { name: string; public?: boolean; cacheControl?: string },
): {
name: string;
public?: boolean;
cacheControl?: string;
} {
if (typeof entry === "string") {
return { name: entry };
Expand Down
22 changes: 21 additions & 1 deletion packages/core/src/plugins/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,26 @@ function guardConsumedRequestBody(request: Request): Request {
*/
export interface RouteMeta {
public: boolean;
/**
* Cache-Control value for successful GET responses. Only ever set for
* public routes — authenticated responses must stay `private, no-store`.
*/
cacheControl?: string;
}

/**
* Build RouteMeta from a route's `public`/`cacheControl` flags. Single source
* of truth for the "cacheControl is only ever exposed on public routes"
* invariant — used for trusted routes and manifest-declared sandboxed routes.
*/
export function buildRouteMeta(route: { public?: boolean; cacheControl?: string }): RouteMeta {
const meta: RouteMeta = { public: route.public === true };
// Private responses are per-user and must never become cacheable, even if
// a route sets both flags.
if (meta.public && typeof route.cacheControl === "string" && route.cacheControl.length > 0) {
meta.cacheControl = route.cacheControl;
}
return meta;
}

/**
Expand Down Expand Up @@ -199,7 +219,7 @@ export class PluginRouteHandler {
getRouteMeta(name: string): RouteMeta | null {
const route: PluginRoute | undefined = this.plugin.routes[name];
if (!route) return null;
return { public: route.public === true };
return buildRouteMeta(route);
}
}

Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/plugins/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1173,6 +1173,13 @@ export interface PluginRoute<TInput = unknown> {
* Public routes skip session/token auth and CSRF checks.
*/
public?: boolean;
/**
* `Cache-Control` header value for successful GET responses, e.g.
* `"public, max-age=60, stale-while-revalidate=300"`. Only honored on
* routes that are also `public: true` — authenticated responses always
* keep the default `private, no-store`. Errors are never cached.
*/
cacheControl?: string;
/** Route handler */
handler: (ctx: RouteContext<TInput>) => Promise<unknown>;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/**
* End-to-end wiring for plugin-route `cacheControl`.
*
* The route-layer unit tests mock `getPluginRouteMeta`; this test exercises
* the real path — a `ResolvedPlugin` with `cacheControl` registered on
* `EmDashRuntime`, metadata resolved via `runtime.getPluginRouteMeta`, and the
* catch-all handler applying the header — so a runtime that drops the field
* (the original review finding) fails here.
*/

import { randomUUID } from "node:crypto";

import Database from "better-sqlite3";
import { SqliteDialect } from "kysely";
import { afterAll, beforeAll, describe, expect, it } from "vitest";

import { GET, POST } from "../../../src/astro/routes/api/plugins/[pluginId]/[...path].js";
import { EmDashRuntime } from "../../../src/emdash-runtime.js";
import type { RuntimeDependencies } from "../../../src/emdash-runtime.js";
import { definePlugin } from "../../../src/plugins/define-plugin.js";

const CACHE_VALUE = "public, max-age=60, stale-while-revalidate=300";

function createDeps(): RuntimeDependencies {
const entrypoint = `test-plugin-cache-control-${randomUUID()}`;
return {
config: {
database: { entrypoint, config: {}, type: "sqlite" },
storage: { entrypoint, config: {} },
},
plugins: [
definePlugin({
id: "cache-demo",
version: "1.0.0",
capabilities: [],
routes: {
catalog: {
public: true,
cacheControl: CACHE_VALUE,
handler: async () => ({ items: [] }),
},
uncached: {
public: true,
handler: async () => ({ items: [] }),
},
// Misconfigured on purpose: cacheControl on a private route
// must never surface.
admin: {
cacheControl: CACHE_VALUE,
handler: async () => ({ secret: true }),
},
},
}),
],
createDialect: () => new SqliteDialect({ database: new Database(":memory:") }),
createStorage: null,
sandboxEnabled: false,
sandboxedPluginEntries: [],
createSandboxRunner: null,
};
}

function invokeCatchAll(runtime: EmDashRuntime, routeName: string, method: string) {
const request = new Request(`http://test.local/_emdash/api/plugins/cache-demo/${routeName}`, {
method,
});
const handler = method === "POST" ? POST : GET;
return handler({
params: { pluginId: "cache-demo", path: routeName },
request,
locals: { user: null, emdash: runtime },
} as never);
}

describe("plugin route cacheControl — runtime wiring", () => {
let runtime: EmDashRuntime;

beforeAll(async () => {
runtime = await EmDashRuntime.create(createDeps());
});

afterAll(async () => {
await runtime.stopCron();
});

it("runtime.getPluginRouteMeta carries cacheControl for public trusted routes", () => {
expect(runtime.getPluginRouteMeta("cache-demo", "/catalog")).toEqual({
public: true,
cacheControl: CACHE_VALUE,
});
});

it("runtime.getPluginRouteMeta never exposes cacheControl for private routes", () => {
expect(runtime.getPluginRouteMeta("cache-demo", "/admin")).toEqual({ public: false });
});

it("applies the header end-to-end on a public GET through the catch-all", async () => {
const res = await invokeCatchAll(runtime, "catalog", "GET");
expect(res.status).toBe(200);
expect(res.headers.get("Cache-Control")).toBe(CACHE_VALUE);
});

it("keeps the private, no-store default without the option", async () => {
const res = await invokeCatchAll(runtime, "uncached", "GET");
expect(res.status).toBe(200);
expect(res.headers.get("Cache-Control")).toBe("private, no-store");
});

it("keeps the default on POST to a cached route", async () => {
const res = await invokeCatchAll(runtime, "catalog", "POST");
expect(res.status).toBe(200);
expect(res.headers.get("Cache-Control")).toBe("private, no-store");
});
});
Loading
Loading