feat(plugins): opt-in Cache-Control for public plugin routes#1985
feat(plugins): opt-in Cache-Control for public plugin routes#1985swissky wants to merge 2 commits into
Conversation
Public routes can set cacheControl to have successful GET/HEAD responses carry that Cache-Control value, enabling CDN/browser caching for endpoints that serve identical data to every visitor. Guardrails: route metadata only exposes cacheControl for public routes, so authenticated responses always keep private, no-store; errors and non-GET methods keep the default.
🦋 Changeset detectedLatest commit: 08d848f The changes in this PR will be included in the next version bump. This PR includes changesets to release 18 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
There was a problem hiding this comment.
The approach is sound: an opt-in, public-only cacheControl field on plugin routes is exactly the right shape for enabling CDN/browser caching without risking private response leakage. The guardrails described in the PR (public-only, GET/HEAD only, errors default to private, no-store) are good.
Unfortunately the implementation has a critical wiring gap: the runtime's getPluginRouteMeta never exposes cacheControl to the catch-all route, so the new header is never actually applied to native plugins. The field is correctly parsed by PluginRouteHandler.getRouteMeta and correctly applied by the catch-all handler, but the runtime bypasses PluginRouteHandler.getRouteMeta and rebuilds metadata itself, dropping cacheControl. The new route-level unit test hides this by mocking getPluginRouteMeta to return cacheControl even though the real runtime does not.
Sandboxed/marketplace plugins need additional work too: the manifest schema, shared ManifestRouteEntry type, and sandboxed-format RouteEntry do not carry cacheControl at all, so those plugin formats cannot use the feature even after the runtime is fixed.
I also found two stale return-type declarations for getPluginRouteMeta ({ public: boolean } | null instead of RouteMeta | null).
Changeset and docs look reasonable; I did not run tests/lint/typecheck (no shell/tooling available), so I cannot verify the reported test output. Addressing the runtime wiring and extending the type/schema surface for sandboxed routes should make this mergeable.
Findings
-
[needs fixing]
packages/core/src/emdash-runtime.ts:3279-3289The catch-all route reads
routeMeta.cacheControlfromemdash.getPluginRouteMeta, but this implementation returns only{ public: route.public === true }for trusted (native/configured) plugins and drops the route'scacheControlvalue entirely.const meta: RouteMeta = { public: route.public === true }; if ( meta.public && typeof route.cacheControl === "string" && route.cacheControl.length > 0 ) { meta.cacheControl = route.cacheControl; } return meta;This mirrors the logic already added to
PluginRouteHandler.getRouteMetaand is the minimal fix to make the feature work for native plugins. -
[needs fixing]
packages/core/src/emdash-runtime.ts:870-2082The sandboxed-plugin route-metadata cache is built in four places (e.g. lines 880, 992, 2013, 2082) with
{ public: normalized.public === true }, so it also dropscacheControl. BecausenormalizeManifestRoutecurrently strips the field from manifest route entries, fixing this needs the manifest schema/types updated first, then the cache construction should carrycacheControlthrough for public routes just like the trusted-plugin path above. -
[needs fixing]
packages/core/src/plugins/manifest-schema.ts:137-139The manifest route entry schema accepts only
nameandpublic, so sandboxed and marketplace plugins cannot declarecacheControlin their manifest. AddcacheControl: z.string().min(1).optional()and include it innormalizeManifestRouteso the runtime can honor it for the sandboxed route metadata cache. -
[needs fixing]
packages/plugin-types/src/index.ts:312-314ManifestRouteEntryis missingcacheControl, which prevents manifest-based plugins from opting into the feature. AddcacheControl?: string;alongsidepublic?: boolean. -
[suggestion]
packages/core/src/plugin-types.ts:1The sandboxed-format
RouteEntry({ handler, public?, input? }) does not includecacheControl, so standard sandboxed plugins cannot declare it either. If the intent is native-only, document that clearly; otherwise addcacheControl?: string;and propagate it throughadaptSandboxEntryso it reachesResolvedPlugin.routes. -
[needs fixing]
packages/core/src/astro/public-plugin-api-routes.ts:11This interface still declares
getPluginRouteMetaas returning{ public: boolean } | null, but the runtime now returnsRouteMeta | nullcontainingcacheControl. Update the type and importRouteMetafrom../plugins/routes.js. -
[suggestion]
packages/core/src/astro/types.ts:414EmDashHandlers.getPluginRouteMetais typed as(pluginId, path) => { public: boolean } | null. It should returnRouteMeta | nullto match the runtime and catch-all route. -
[needs fixing]
packages/core/tests/unit/astro/plugin-api-route-cache.test.ts:29-30The test mocks
getPluginRouteMeta: () => ({ public: true, cacheControl }), which masks the runtime bug thatEmDashRuntime.getPluginRouteMetanever returnscacheControl. Keep these route-layer unit tests, but add an integration test that wires aResolvedPluginwithcacheControlthroughEmDashRuntime.getPluginRouteMetaand the catch-all handler so the end-to-end path is actually exercised.
…ormats Review follow-up: EmDashRuntime.getPluginRouteMeta rebuilt metadata inline and dropped cacheControl, so the header was never applied. Extracted buildRouteMeta() as the single source of the public-only invariant and used it for trusted routes and all four sandboxed route-meta cache sites. Extends the surface to every plugin format: manifest schema + ManifestRouteEntry carry cacheControl, extractManifest emits structured entries, adaptSandboxEntry threads the field, and the bundle CLI probe preserves it. Fixes two stale getPluginRouteMeta return types. Adds an integration test exercising the real runtime path (ResolvedPlugin -> runtime.getPluginRouteMeta -> catch-all handler) that fails without the runtime fix.
|
Thanks for the thorough review — the runtime wiring gap was real and is fixed in 08d848f, along with the full sandboxed/marketplace surface:
Docs and changeset updated (the "native format only" caveat is gone; The earlier |
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/auth-atproto
@emdash-cms/blocks
@emdash-cms/cloudflare
@emdash-cms/contentful-to-portable-text
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/plugin-cli
@emdash-cms/plugin-types
@emdash-cms/registry-client
@emdash-cms/registry-lexicons
@emdash-cms/sandbox-workerd
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-field-kit
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
There was a problem hiding this comment.
Re-review of PR #1985.
The approach remains sound: an opt-in, public-only cacheControl field on plugin routes is the right shape for enabling CDN/browser caching without risking private-response leakage.
All blocking items from the prior review are addressed:
EmDashRuntime.getPluginRouteMetanow usesbuildRouteMeta(route)for trusted plugins, socacheControlis actually exposed to the catch-all route.- The four sandboxed/marketplace route-metadata cache construction sites now use
buildRouteMeta(normalized). - The manifest schema,
normalizeManifestRoute,ManifestRouteEntry,RouteEntry, andPluginRouteall carrycacheControl. - The stale
{ public: boolean } | nullreturn types inastro/public-plugin-api-routes.tsandastro/types.tsare updated toRouteMeta | null. - A new integration test wires a
ResolvedPluginthroughEmDashRuntime.getPluginRouteMetaand the catch-all handler, so a runtime that drops the field would fail.
The catch-all handler correctly applies the header only to successful GET/HEAD responses of public routes; errors and private-route responses keep the default private, no-store. Tests cover the public/private, GET/HEAD/POST, success/error, and default/no-cache cases.
I traced the native, standard-sandboxed, and marketplace/manifest paths and they all propagate the option. No remaining issues.
Overlapping PRsThis PR modifies files that are also changed by other open PRs:
This may cause merge conflicts or duplicated work. A maintainer will coordinate. |
What does this PR do?
Adds an opt-in
cacheControloption for plugin routes. Public routes that set it have their successful GET/HEAD responses served with thatCache-Controlvalue instead of the API defaultprivate, no-store— enabling CDN and browser caching for public endpoints that serve identical data to every visitor (catalog listings, public search, widget feeds).Guardrails so this can't leak private data:
getRouteMetaonly ever exposescacheControlfor routes that are alsopublic: true— setting it on a private route has no effect, authenticated responses always keepprivate, no-store.Discussion: #1984
Type of change
Checklist
pnpm typecheckpassespnpm lintpassespnpm testpasses (or targeted tests for my change)pnpm formathas been runmessages.pochanges except in translation PRs — a workflow extracts catalogs on merge tomain. (n/a — no admin UI changes)AI-generated code disclosure
Screenshots / test output
New tests cover: header set on public GET and HEAD, default kept without the flag / on POST / on errors, and
getRouteMetanever exposingcacheControlfor private routes.