Skip to content

feat(plugins): return a Response from a route handler to serve non-JSON content#2162

Open
dchaudhari7177 wants to merge 3 commits into
emdash-cms:mainfrom
dchaudhari7177:feat/plugin-route-response-passthrough
Open

feat(plugins): return a Response from a route handler to serve non-JSON content#2162
dchaudhari7177 wants to merge 3 commits into
emdash-cms:mainfrom
dchaudhari7177:feat/plugin-route-response-passthrough

Conversation

@dchaudhari7177

Copy link
Copy Markdown
Contributor

What does this PR do?

Plugin API route handlers could only return JSON — whatever a handler returned was wrapped in the standard { success, data } envelope, so there was no way to serve an image, a file, or anything with a custom content type from a plugin route. Returning a Response didn't help either; it was JSON-serialized.

A handler that returns a Response now has it passed through verbatim:

  • PluginRouteHandler.invoke detects result instanceof Response and carries it on RouteResult.response with the Response's own status
  • the plugin API endpoint (/_emdash/api/plugins/{pluginId}/{...path}) returns that Response directly instead of apiSuccess()-wrapping it
  • ordinary return values are unchanged — they still get the { success, data } envelope
  • passthrough is trusted (configured) plugins only; sandboxed plugin results cross a serialization boundary in handleSandboxedRoute, so they stay JSON-only (documented on the type and in the changeset)

The exact repro from the issue now works:

routes: {
  image: {
    handler: async () =>
      new Response(pngBytes, { headers: { "Content-Type": "image/png" } }),
  },
}

Closes #2110

Type of change

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

(Filed as a bug — an image route currently has no way to return its image. The change is additive and backwards-compatible; happy to re-file under a Discussion if you'd rather treat it as a feature.)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes (packages/core)
  • pnpm lint passes for the changed files (pnpm lint overall currently fails on a pre-existing error in packages/registry-verification/src/records.ts that reproduces on a clean main)
  • pnpm test passes (targeted: packages/core tests/unit/plugins/routes.test.ts, 39/39)
  • pnpm format has been run
  • I have added/updated tests for my changes
  • User-visible strings wrapped for translation (n/a — no UI strings)
  • I have added a changeset (emdash patch)
  • New features link to an approved Discussion (see note above)

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Opus 4.8 (Claude Code)

Tests

Three new cases in packages/core/tests/unit/plugins/routes.test.ts:

  • an image/png Response is returned verbatim (result.response is the Response, result.data undefined, body bytes and content-type intact)
  • a non-200 Response (302 + Location) carries its status through to the result
  • ordinary return values still JSON-wrap (result.data set, result.response undefined)

Plugin route handlers could only return JSON — whatever they returned
was wrapped in the standard { success, data } envelope, so there was no
way to serve an image, a file, or any custom content type from a plugin
route (emdash-cms#2110).

A handler that returns a Response now has it passed through verbatim:
the route registry carries it on RouteResult.response with the
Response's own status, and the plugin API endpoint returns it directly
instead of JSON-wrapping it. Ordinary return values keep the envelope.
Passthrough is trusted-plugin only; sandboxed results cross a
serialization boundary and stay JSON.

Fixes emdash-cms#2110
Copilot AI review requested due to automatic review settings July 21, 2026 05:16
@changeset-bot

changeset-bot Bot commented Jul 21, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8854d3e

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

This PR includes changesets to release 17 packages
Name Type
emdash Patch
@emdash-cms/cloudflare Patch
@emdash-cms/sandbox-workerd Patch
@emdash-cms/plugin-mcp-smoke 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 Patch
@emdash-cms/auth Patch
@emdash-cms/blocks Patch
@emdash-cms/gutenberg-to-portable-text Patch
@emdash-cms/x402 Patch
create-emdash Patch
@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

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@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: this is a real, additive bug fix that lets trusted plugin route handlers serve non-JSON content by returning a Response, without changing the envelope for ordinary return values. The implementation is minimal and the new unit tests in routes.test.ts cover the handler-level behavior well.

I found a few gaps to tighten before merge:

  1. Comment discipline. Both new inline comments reference issue #2110, which violates the AGENTS.md convention that comments should not point to issues/PRs/review threads. The JSDoc on RouteResult and PluginRoute already explains the behavior for future readers, so these inline comments can be removed.
  2. Missing API-route test. The unit tests verify PluginRouteHandler.invoke() returns the raw Response in result.response, but they don't cover the actual HTTP path in [...path].ts where that Response is returned to the client. Adding a focused test in plugin-api-route-auth.test.ts (mocking handlePluginApiRoute to return { success: true, response: ... } and asserting the route returns it verbatim) would close the loop on the reported bug.
  3. Stale HandlerResponse contract. locals.emdash.handlePluginApiRoute and handlePublicPluginApiRoute now return a result that may contain response: Response, but the HandlerResponse type in astro/types.ts doesn't expose it. Updating the interface keeps the locals contract honest for SSR/server callers.

Findings

  • [needs fixing] packages/core/src/plugins/routes.ts:158-160

    The inline comments reference issue #2110. AGENTS.md says comments should be evergreen for future readers and must not reference issues/PRs/review threads. The JSDoc on RouteResult already documents the passthrough behavior, so this block can be removed entirely.

    			const result = await route.handler(routeContext);
    			if (result instanceof Response) {
    				return {
    
  • [needs fixing] packages/core/src/astro/routes/api/plugins/[pluginId]/[...path].ts:86-88

    Same comment discipline issue: the inline comment references #2110. The surrounding code (passthrough instanceof Response) is self-explanating, and the JSDoc on RouteResult/PluginRoute covers the rationale.

    	const passthrough = (result as { response?: unknown }).response;
    	if (passthrough instanceof Response) {
    		return passthrough;
    	}
    
  • [needs fixing] packages/core/src/astro/routes/api/plugins/[pluginId]/[...path].ts:89

    This passthrough path is not exercised by any test. routes.test.ts verifies the lower-level PluginRouteHandler.invoke() shape, but the reported bug is about the HTTP endpoint returning the raw response. Add a case in packages/core/tests/unit/astro/plugin-api-route-auth.test.ts (or a sibling file) that mocks handlePluginApiRoute to return { success: true, response: new Response(...), status: 200 } and asserts the Astro route returns that exact Response to the client.

    	it("returns a handler Response verbatim instead of wrapping it", async () => {
    		const { locals, handlePluginApiRoute } = createLocals(null, true);
    		const png = new Response(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), {
    			headers: { "Content-Type": "image/png" },
    		});
    		handlePluginApiRoute.mockResolvedValue({ success: true, response: png, status: 200 });
    		const res = await invoke(GET, "GET", locals, { csrf: false });
    		expect(res).toBe(png);
    		expect(res.headers.get("Content-Type")).toBe("image/png");
    	});
    
  • [suggestion] packages/core/src/astro/types.ts:208

    handlePluginApiRoute and handlePublicPluginApiRoute can now return a result carrying response: Response for trusted plugin routes. The HandlerResponse interface should reflect that so callers of locals.emdash.handlePluginApiRoute / handlePublicPluginApiRoute see the full shape in TypeScript rather than discovering an untyped runtime field.

    export interface HandlerResponse<T = unknown> {
    	success: boolean;
    	data?: T;
    	response?: Response;
    	error?: {
    		code: string;
    		message: string;
    		details?: Record<string, unknown>;
    	};
    }
    

@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.

…ponse-passthrough

# Conflicts:
#	packages/core/src/astro/routes/api/plugins/[pluginId]/[...path].ts
#	packages/core/src/plugins/types.ts
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review and removed review/awaiting-author Reviewed; waiting on the author to respond labels Jul 21, 2026
@pkg-pr-new

pkg-pr-new Bot commented Jul 21, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

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

@emdash-cms/auth

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

@emdash-cms/auth-atproto

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

@emdash-cms/blocks

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

@emdash-cms/cloudflare

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

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

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

emdash

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

create-emdash

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

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

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

@emdash-cms/plugin-cli

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

@emdash-cms/plugin-types

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

@emdash-cms/registry-client

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

@emdash-cms/registry-lexicons

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

@emdash-cms/registry-verification

npm i https://pkg.pr.new/@emdash-cms/registry-verification@2162

@emdash-cms/sandbox-workerd

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

@emdash-cms/x402

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

@emdash-cms/plugin-ai-moderation

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

@emdash-cms/plugin-atproto

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

@emdash-cms/plugin-audit-log

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

@emdash-cms/plugin-color

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

@emdash-cms/plugin-embeds

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

@emdash-cms/plugin-field-kit

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

@emdash-cms/plugin-forms

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

@emdash-cms/plugin-webhook-notifier

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

commit: 8854d3e

@swissky swissky added the bot:review Trigger an emdashbot code review on this PR label Jul 21, 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.

The approach remains sound: this is a minimal, additive fix that lets trusted plugin route handlers serve non-JSON content by returning a Response, without changing the envelope for ordinary return values. The JSDoc on RouteResult and PluginRoute documents the sandbox limitation well, and the lower-level unit tests in routes.test.ts correctly cover the handler-level behavior.

However, my prior review pointed out three gaps, and none of them have been addressed in the current head:

  1. Comment discipline. Inline comments in packages/core/src/plugins/routes.ts and packages/core/src/astro/routes/api/plugins/[pluginId]/[...path].ts still reference issue #2110, which violates the AGENTS.md rule that comments must never point to issues/PRs/review threads.
  2. Missing HTTP-path test. routes.test.ts verifies PluginRouteHandler.invoke() returns the raw Response, but the actual Astro API route ([...path].ts) — where the passthrough is returned to the client — is not exercised by any test. A focused regression test there is still needed to close the loop on the reported bug.
  3. Stale HandlerResponse contract. locals.emdash.handlePluginApiRoute and handlePublicPluginApiRoute now return a RouteResult carrying response: Response (and the existing status), but the HandlerResponse interface in astro/types.ts still only exposes success, data, and error. Updating it keeps the locals contract honest for callers and lets the route file drop its unsafe casts.

No new security, data-integrity, or logged-out query concerns were introduced by this change. Once the comments are cleaned up, the HTTP path is tested, and the type contract is updated, this should be good to merge.


Findings

  • [needs fixing] packages/core/src/plugins/routes.ts:184-185

    The inline comment block references issue #2110. AGENTS.md explicitly forbids issue/PR/review-thread references in comments — that context belongs in the commit message and PR description. The RouteResult.response JSDoc already documents the passthrough behavior for future readers, so this comment can be removed.

    			const result = await route.handler(routeContext);
    			if (result instanceof Response) {
    				return {
    
  • [needs fixing] packages/core/src/astro/routes/api/plugins/[pluginId]/[...path].ts:92-93

    Same comment-discipline violation: the inline comment references #2110. The surrounding code (passthrough instanceof Response) is self-explanatory, and the JSDoc on RouteResult/PluginRoute covers the rationale and the sandbox limitation. Remove the issue reference.

    	const passthrough = (result as { response?: unknown }).response;
    	if (passthrough instanceof Response) {
    		return passthrough;
    	}
    
  • [needs fixing] packages/core/src/astro/routes/api/plugins/[pluginId]/[...path].ts:97

    This passthrough path is not exercised by any test. routes.test.ts verifies the lower-level PluginRouteHandler.invoke() shape, but the reported bug is about the HTTP endpoint returning the raw response. Add a focused test in packages/core/tests/unit/astro/plugin-api-route-auth.test.ts that mocks handlePluginApiRoute to return { success: true, response: ... } and asserts the Astro route returns that exact Response verbatim.

    	it("returns a handler Response verbatim instead of wrapping it", async () => {
    		const { locals, handlePluginApiRoute } = createLocals(null, true);
    		const png = new Response(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), {
    			headers: { "Content-Type": "image/png" },
    		});
    		handlePluginApiRoute.mockResolvedValue({ success: true, response: png, status: 200 });
    		const res = await invoke(GET, "GET", locals, { csrf: false });
    		expect(res).toBe(png);
    		expect(res.headers.get("Content-Type")).toBe("image/png");
    	});
    
  • [suggestion] packages/core/src/astro/types.ts:210

    handlePluginApiRoute and handlePublicPluginApiRoute now return a result that may carry response: Response for trusted plugin routes, and the route file already reads status from that result via an unsafe cast. Reflecting both fields in the public HandlerResponse interface keeps the locals.emdash contract honest and lets callers (and the route file) use typed fields instead of casting.

    export interface HandlerResponse<T = unknown> {
    	success: boolean;
    	data?: T;
    	response?: Response;
    	status?: number;
    	error?: {
    		code: string;
    		message: string;
    		details?: Record<string, unknown>;
    	};
    }
    

@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Jul 21, 2026
@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed review/needs-rereview Author pushed changes since the last review labels Jul 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Plugin API routes can't return non-JSON responses (e.g. images)

3 participants