Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/plugin-route-response-passthrough.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions packages/core/src/plugins/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,14 @@ export function buildRouteMeta(route: {
export interface RouteResult<T = unknown> {
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;
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 7 additions & 1 deletion packages/core/src/plugins/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1197,7 +1197,13 @@ export interface PluginRoute<TInput = unknown> {
* 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<TInput>) => Promise<unknown>;
}

Expand Down
62 changes: 62 additions & 0 deletions packages/core/tests/unit/plugins/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
});
});
Loading