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 d902d536b3..77078ccdf8 100644 --- a/packages/core/src/astro/routes/api/plugins/[pluginId]/[...path].ts +++ b/packages/core/src/astro/routes/api/plugins/[pluginId]/[...path].ts @@ -89,6 +89,15 @@ 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). + // A raw Response owns its own headers, so it bypasses the envelope and the + // cache-control below. + const passthrough = (result as { response?: unknown }).response; + if (passthrough instanceof Response) { + return passthrough; + } + 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 diff --git a/packages/core/src/plugins/routes.ts b/packages/core/src/plugins/routes.ts index ffe7e7643f..848bdae40c 100644 --- a/packages/core/src/plugins/routes.ts +++ b/packages/core/src/plugins/routes.ts @@ -88,6 +88,14 @@ export function buildRouteMeta(route: { 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; @@ -172,6 +180,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 7e3ad2bd9f..0e7c1f90e1 100644 --- a/packages/core/src/plugins/types.ts +++ b/packages/core/src/plugins/types.ts @@ -1197,7 +1197,13 @@ export interface PluginRoute { * keep the default `private, no-store`. Errors are never cached. */ cacheControl?: string; - /** 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 13b6d83946..f4f69b5384 100644 --- a/packages/core/tests/unit/plugins/routes.test.ts +++ b/packages/core/tests/unit/plugins/routes.test.ts @@ -597,3 +597,65 @@ 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" }); + }); +});