Skip to content

feat(plugins): opt-in Cache-Control for public plugin routes#1985

Open
swissky wants to merge 2 commits into
emdash-cms:mainfrom
swissky:feat/plugin-route-cache-control
Open

feat(plugins): opt-in Cache-Control for public plugin routes#1985
swissky wants to merge 2 commits into
emdash-cms:mainfrom
swissky:feat/plugin-route-cache-control

Conversation

@swissky

@swissky swissky commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Adds an opt-in cacheControl option for plugin routes. Public routes that set it have their successful GET/HEAD responses served with that Cache-Control value instead of the API default private, no-store — enabling CDN and browser caching for public endpoints that serve identical data to every visitor (catalog listings, public search, widget feeds).

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

Guardrails so this can't leak private data:

  • getRouteMeta only ever exposes cacheControl for routes that are also public: true — setting it on a private route has no effect, authenticated responses always keep private, no-store.
  • The header is applied only to successful GET/HEAD responses; errors and other methods keep the default.
  • Additive and default-off: existing routes are untouched.

Discussion: #1984

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm test passes (or targeted tests for my change)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (if applicable). Do not include messages.po changes except in translation PRs — a workflow extracts catalogs on merge to main. (n/a — no admin UI changes)
  • I have added a changeset (if this PR changes a published package)
  • New features link to an approved Discussion: Public plugin routes: opt-in Cache-Control for GET responses #1984

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Cursor + Claude. All changes reviewed and tested by me.

Screenshots / test output

routes.test.ts + plugin-api-route-cache.test.ts + plugin-api-route-auth.test.ts: 50 passed (50)

New tests cover: header set on public GET and HEAD, default kept without the flag / on POST / on errors, and getRouteMeta never exposing cacheControl for private routes.

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-bot

changeset-bot Bot commented Jul 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 08d848f

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 18 packages
Name Type
emdash Minor
@emdash-cms/plugin-types Minor
@emdash-cms/cloudflare Minor
@emdash-cms/sandbox-workerd Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/do-demo-site Patch
@emdash-cms/do-solo-demo-site Patch
@emdash-cms/admin Minor
@emdash-cms/plugin-cli Patch
@emdash-cms/auth Minor
@emdash-cms/blocks Minor
@emdash-cms/gutenberg-to-portable-text Minor
@emdash-cms/x402 Minor
create-emdash Minor
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

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

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-3289

    The catch-all route reads routeMeta.cacheControl from emdash.getPluginRouteMeta, but this implementation returns only { public: route.public === true } for trusted (native/configured) plugins and drops the route's cacheControl value 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.getRouteMeta and is the minimal fix to make the feature work for native plugins.

  • [needs fixing] packages/core/src/emdash-runtime.ts:870-2082

    The 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 drops cacheControl. Because normalizeManifestRoute currently strips the field from manifest route entries, fixing this needs the manifest schema/types updated first, then the cache construction should carry cacheControl through for public routes just like the trusted-plugin path above.

  • [needs fixing] packages/core/src/plugins/manifest-schema.ts:137-139

    The manifest route entry schema accepts only name and public, so sandboxed and marketplace plugins cannot declare cacheControl in their manifest. Add cacheControl: z.string().min(1).optional() and include it in normalizeManifestRoute so the runtime can honor it for the sandboxed route metadata cache.

  • [needs fixing] packages/plugin-types/src/index.ts:312-314

    ManifestRouteEntry is missing cacheControl, which prevents manifest-based plugins from opting into the feature. Add cacheControl?: string; alongside public?: boolean.

  • [suggestion] packages/core/src/plugin-types.ts:1

    The sandboxed-format RouteEntry ({ handler, public?, input? }) does not include cacheControl, so standard sandboxed plugins cannot declare it either. If the intent is native-only, document that clearly; otherwise add cacheControl?: string; and propagate it through adaptSandboxEntry so it reaches ResolvedPlugin.routes.

  • [needs fixing] packages/core/src/astro/public-plugin-api-routes.ts:11

    This interface still declares getPluginRouteMeta as returning { public: boolean } | null, but the runtime now returns RouteMeta | null containing cacheControl. Update the type and import RouteMeta from ../plugins/routes.js.

  • [suggestion] packages/core/src/astro/types.ts:414

    EmDashHandlers.getPluginRouteMeta is typed as (pluginId, path) => { public: boolean } | null. It should return RouteMeta | null to match the runtime and catch-all route.

  • [needs fixing] packages/core/tests/unit/astro/plugin-api-route-cache.test.ts:29-30

    The test mocks getPluginRouteMeta: () => ({ public: true, cacheControl }), which masks the runtime bug that EmDashRuntime.getPluginRouteMeta never returns cacheControl. Keep these route-layer unit tests, but add an integration test that wires a ResolvedPlugin with cacheControl through EmDashRuntime.getPluginRouteMeta and 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.
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review size/L and removed size/M review/needs-review No maintainer or bot review yet labels Jul 12, 2026
@swissky

swissky commented Jul 12, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review — the runtime wiring gap was real and is fixed in 08d848f, along with the full sandboxed/marketplace surface:

  • Runtime wiring: extracted buildRouteMeta() in plugins/routes.ts as the single source of the "cacheControl only on public routes" invariant. EmDashRuntime.getPluginRouteMeta now uses it for trusted plugins, and all four sandboxed route-meta cache sites use it too.
  • Manifest/types surface: manifestRouteEntrySchema and ManifestRouteEntry (plugin-types) accept cacheControl; normalizeManifestRoute passes it through; extractManifest now emits structured route entries when a route carries public/cacheControl; the bundle CLI probe preserves the field. The sandboxed RouteEntry config form accepts it and adaptSandboxEntry threads it into the resolved route — so native, standard, and marketplace formats all work.
  • Stale types: both getPluginRouteMeta declarations (astro/public-plugin-api-routes.ts, astro/types.ts) now return RouteMeta | null.
  • Test gap: added tests/integration/runtime/plugin-route-cache-control.test.ts which builds a real EmDashRuntime with a ResolvedPlugin and invokes the actual catch-all handler — it fails without the runtime fix. The mocked route-layer unit tests stay as-is for the header-application matrix.

Docs and changeset updated (the "native format only" caveat is gone; @emdash-cms/plugin-types gets a minor bump). 814 unit/integration tests green locally, typecheck clean.

The earlier Publish Preview failure was pkg-pr-new hitting a Cloudflare error page (HTML in the log) — transient, should clear on this push.

@pkg-pr-new

pkg-pr-new Bot commented Jul 12, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@1985

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@1985

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/@emdash-cms/auth-atproto@1985

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@1985

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@1985

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/contentful-to-portable-text@1985

emdash

npm i https://pkg.pr.new/emdash@1985

create-emdash

npm i https://pkg.pr.new/create-emdash@1985

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@1985

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/@emdash-cms/plugin-cli@1985

@emdash-cms/plugin-types

npm i https://pkg.pr.new/@emdash-cms/plugin-types@1985

@emdash-cms/registry-client

npm i https://pkg.pr.new/@emdash-cms/registry-client@1985

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/@emdash-cms/registry-lexicons@1985

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/@emdash-cms/sandbox-workerd@1985

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@1985

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@1985

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@1985

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@1985

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@1985

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@1985

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/@emdash-cms/plugin-field-kit@1985

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@1985

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@1985

commit: 08d848f

@swissky swissky added the bot:review Trigger an emdashbot code review on this PR label Jul 12, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.getPluginRouteMeta now uses buildRouteMeta(route) for trusted plugins, so cacheControl is 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, and PluginRoute all carry cacheControl.
  • The stale { public: boolean } | null return types in astro/public-plugin-api-routes.ts and astro/types.ts are updated to RouteMeta | null.
  • A new integration test wires a ResolvedPlugin through EmDashRuntime.getPluginRouteMeta and 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.

@github-actions

Copy link
Copy Markdown
Contributor

Overlapping PRs

This PR modifies files that are also changed by other open PRs:

This may cause merge conflicts or duplicated work. A maintainer will coordinate.

@github-actions github-actions Bot added review/approved Approved; no new commits since and removed review/needs-rereview Author pushed changes since the last review labels Jul 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/core area/docs bot:review Trigger an emdashbot code review on this PR overlap review/approved Approved; no new commits since size/L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant