From 9ae4a162e93d2afd78db8905a0e81c8da4427d1a Mon Sep 17 00:00:00 2001 From: Dipak Date: Tue, 21 Jul 2026 10:46:17 +0530 Subject: [PATCH 1/2] feat(plugins): let route handlers return a Response for non-JSON content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (#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 #2110 --- .../plugin-route-response-passthrough.md | 5 ++ .../api/plugins/[pluginId]/[...path].ts | 7 +++ packages/core/src/plugins/routes.ts | 18 ++++++ packages/core/src/plugins/types.ts | 8 ++- .../core/tests/unit/plugins/routes.test.ts | 63 +++++++++++++++++++ 5 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 .changeset/plugin-route-response-passthrough.md diff --git a/.changeset/plugin-route-response-passthrough.md b/.changeset/plugin-route-response-passthrough.md new file mode 100644 index 0000000000..d77210244f --- /dev/null +++ b/.changeset/plugin-route-response-passthrough.md @@ -0,0 +1,5 @@ +--- +"emdash": patch +--- + +Plugin API route handlers can now return a `Response` directly to serve non-JSON content — an image, a file, or anything with a custom content type. A returned `Response` is sent to the caller verbatim (status and headers included) instead of being wrapped in the standard `{ success, data }` JSON envelope; ordinary return values keep the envelope. Applies to trusted (configured) plugins; sandboxed plugin routes stay JSON-only because their results cross a serialization boundary. diff --git a/packages/core/src/astro/routes/api/plugins/[pluginId]/[...path].ts b/packages/core/src/astro/routes/api/plugins/[pluginId]/[...path].ts index bdd852aa82..dfa344b940 100644 --- a/packages/core/src/astro/routes/api/plugins/[pluginId]/[...path].ts +++ b/packages/core/src/astro/routes/api/plugins/[pluginId]/[...path].ts @@ -83,6 +83,13 @@ const handleRequest: APIRoute = async ({ params, request, locals }) => { return apiError(code, message, status); } + // Route handlers may return a Response directly (image, file, custom + // content type) — send it verbatim instead of JSON-wrapping it (#2110). + const passthrough = (result as { response?: unknown }).response; + if (passthrough instanceof Response) { + return passthrough; + } + return apiSuccess(result.data); }; diff --git a/packages/core/src/plugins/routes.ts b/packages/core/src/plugins/routes.ts index 24b02a58df..f6f0b104f2 100644 --- a/packages/core/src/plugins/routes.ts +++ b/packages/core/src/plugins/routes.ts @@ -62,6 +62,14 @@ export interface RouteMeta { export interface RouteResult { success: boolean; data?: T; + /** + * Raw Response passthrough. Set when the route handler returned a `Response` + * directly (e.g. an image or file with its own content type); the HTTP layer + * must send it verbatim instead of wrapping it in the JSON envelope. + * Trusted (configured) plugins only — a Response cannot cross the sandbox + * serialization boundary. + */ + response?: Response; error?: { code: string; message: string; @@ -146,6 +154,16 @@ export class PluginRouteHandler { // Execute handler try { const result = await route.handler(routeContext); + // A handler may return a Response directly to serve non-JSON content + // (images, files, custom content types) — pass it through untouched + // rather than JSON-wrapping it (#2110). + if (result instanceof Response) { + return { + success: true, + response: result, + status: result.status, + }; + } return { success: true, data: result, diff --git a/packages/core/src/plugins/types.ts b/packages/core/src/plugins/types.ts index 2ce3bbaefe..f4760d3588 100644 --- a/packages/core/src/plugins/types.ts +++ b/packages/core/src/plugins/types.ts @@ -1175,7 +1175,13 @@ export interface PluginRoute { * Public routes skip session/token auth and CSRF checks. */ public?: boolean; - /** Route handler */ + /** + * Route handler. Return a JSON-serializable value to send the standard + * `{ success, data }` envelope, or return a `Response` directly to serve + * non-JSON content (an image, a file, a custom content type) verbatim. + * Response passthrough applies to trusted (configured) plugins; sandboxed + * plugin results cross a serialization boundary and stay JSON-only. + */ handler: (ctx: RouteContext) => Promise; } diff --git a/packages/core/tests/unit/plugins/routes.test.ts b/packages/core/tests/unit/plugins/routes.test.ts index 1187cd54ef..4e26526a89 100644 --- a/packages/core/tests/unit/plugins/routes.test.ts +++ b/packages/core/tests/unit/plugins/routes.test.ts @@ -570,3 +570,66 @@ describe("createRouteRegistry helper", () => { expect(registry).toBeInstanceOf(PluginRouteRegistry); }); }); + +describe("Response passthrough (#2110)", () => { + it("returns a handler's Response verbatim instead of JSON-wrapping it", async () => { + const pngBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); + const plugin = createTestPlugin({ + routes: { + image: { + handler: async () => + new Response(pngBytes, { headers: { "Content-Type": "image/png" } }), + }, + }, + }); + const handler = new PluginRouteHandler(plugin, createMockFactoryOptions()); + + const result = await handler.invoke("image", { + request: new Request("http://test.com"), + }); + + expect(result.success).toBe(true); + expect(result.data).toBeUndefined(); + expect(result.response).toBeInstanceOf(Response); + expect(result.status).toBe(200); + expect(result.response!.headers.get("Content-Type")).toBe("image/png"); + expect(new Uint8Array(await result.response!.arrayBuffer())).toEqual(pngBytes); + }); + + it("carries a non-200 Response status to the result", async () => { + const plugin = createTestPlugin({ + routes: { + redirect: { + handler: async () => + new Response(null, { status: 302, headers: { Location: "/elsewhere" } }), + }, + }, + }); + const handler = new PluginRouteHandler(plugin, createMockFactoryOptions()); + + const result = await handler.invoke("redirect", { + request: new Request("http://test.com"), + }); + + expect(result.success).toBe(true); + expect(result.status).toBe(302); + expect(result.response!.headers.get("Location")).toBe("/elsewhere"); + }); + + it("still JSON-wraps ordinary return values", async () => { + const plugin = createTestPlugin({ + routes: { + json: { handler: async () => ({ hello: "world" }) }, + }, + }); + const handler = new PluginRouteHandler(plugin, createMockFactoryOptions()); + + const result = await handler.invoke("json", { + request: new Request("http://test.com"), + }); + + expect(result.success).toBe(true); + expect(result.response).toBeUndefined(); + expect(result.data).toEqual({ hello: "world" }); + }); +}); From 8854d3e123f9baff256b5a1af12c3285921be879 Mon Sep 17 00:00:00 2001 From: "emdashbot[bot]" Date: Tue, 21 Jul 2026 13:55:00 +0000 Subject: [PATCH 2/2] style: format --- packages/core/tests/unit/plugins/routes.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/core/tests/unit/plugins/routes.test.ts b/packages/core/tests/unit/plugins/routes.test.ts index 91b5eef225..f4f69b5384 100644 --- a/packages/core/tests/unit/plugins/routes.test.ts +++ b/packages/core/tests/unit/plugins/routes.test.ts @@ -604,8 +604,7 @@ describe("Response passthrough (#2110)", () => { const plugin = createTestPlugin({ routes: { image: { - handler: async () => - new Response(pngBytes, { headers: { "Content-Type": "image/png" } }), + handler: async () => new Response(pngBytes, { headers: { "Content-Type": "image/png" } }), }, }, });