Problem
Plugin route handlers registered via PluginRouteHandler cannot return non-JSON content. Every return value is wrapped in the standard API envelope, which blocks plugins from serving files at conventional paths with specific content-types.
Ground truth (from reading emdash@0.1.0 source)
PluginRouteHandler.invoke() wraps every return value as { success, data, status: 200 }.
src/astro/routes/api/plugins/[pluginId]/[...path].ts unconditionally calls apiSuccess(result.data) — the JSON envelope is non-negotiable.
- No middleware hook in
PluginHooks exists to intercept the response.
- Net result: plugin route handlers are typed
Promise<unknown> but the HTTP response is always application/json with the envelope.
Motivating use cases
While building @devondragon/emdash-plugin-seo-aeo-geo-toolkit, we hit this immediately. Real-world use cases that need raw responses:
sitemap.xml — must be served as application/xml at a well-known path for search engines.
llms.txt — text/plain at site root, the emerging convention for AI crawler guidance.
- Others in the same family:
robots.txt, RSS/Atom feeds, iCal (.ics) exports, OpenSearch descriptors, WebFinger/JRD, any plugin wanting to publish a file at a conventional path.
None of these can live behind a JSON envelope — consumers (Googlebot, RSS readers, calendar apps) expect the raw bytes with the right Content-Type.
Proposed API shapes (not prescriptive — just options to consider)
Option A: return a Response directly. If the handler returns an object that is instanceof Response, the route file returns it as-is and skips apiSuccess(). Minimal, Web-standards-native, zero new types.
registerRoute('/sitemap.xml', async () => {
const xml = await buildSitemap(ctx);
return new Response(xml, { headers: { 'content-type': 'application/xml' } });
});
Option B: structured raw-response shape. Handler returns { body, contentType, status?, headers? }, and the envelope is skipped when that shape is detected.
registerRoute('/sitemap.xml', async () => ({
body: await buildSitemap(ctx),
contentType: 'application/xml',
}));
Either works; Option A is probably less surface area and aligns with how Astro/modern runtimes think about responses.
Current workaround
Our plugin exports pure builder functions; consuming sites add ~5-line Astro route files that import the builder and return new Response(xml, { headers: { 'content-type': 'application/xml' } }). It works, so this isn't a blocker — just boilerplate that every consumer has to copy for what should be plug-and-play.
Why file this
With a first-class raw-response path, a whole category of SEO/discovery/syndication plugins becomes drop-in installable. Happy to contribute a PR if the maintainers have a preferred direction.
Thanks for the great plugin system so far.
Problem
Plugin route handlers registered via
PluginRouteHandlercannot return non-JSON content. Every return value is wrapped in the standard API envelope, which blocks plugins from serving files at conventional paths with specific content-types.Ground truth (from reading
emdash@0.1.0source)PluginRouteHandler.invoke()wraps every return value as{ success, data, status: 200 }.src/astro/routes/api/plugins/[pluginId]/[...path].tsunconditionally callsapiSuccess(result.data)— the JSON envelope is non-negotiable.PluginHooksexists to intercept the response.Promise<unknown>but the HTTP response is alwaysapplication/jsonwith the envelope.Motivating use cases
While building
@devondragon/emdash-plugin-seo-aeo-geo-toolkit, we hit this immediately. Real-world use cases that need raw responses:sitemap.xml— must be served asapplication/xmlat a well-known path for search engines.llms.txt—text/plainat site root, the emerging convention for AI crawler guidance.robots.txt, RSS/Atom feeds, iCal (.ics) exports, OpenSearch descriptors, WebFinger/JRD, any plugin wanting to publish a file at a conventional path.None of these can live behind a JSON envelope — consumers (Googlebot, RSS readers, calendar apps) expect the raw bytes with the right
Content-Type.Proposed API shapes (not prescriptive — just options to consider)
Option A: return a
Responsedirectly. If the handler returns an object that isinstanceof Response, the route file returns it as-is and skipsapiSuccess(). Minimal, Web-standards-native, zero new types.Option B: structured raw-response shape. Handler returns
{ body, contentType, status?, headers? }, and the envelope is skipped when that shape is detected.Either works; Option A is probably less surface area and aligns with how Astro/modern runtimes think about responses.
Current workaround
Our plugin exports pure builder functions; consuming sites add ~5-line Astro route files that import the builder and return
new Response(xml, { headers: { 'content-type': 'application/xml' } }). It works, so this isn't a blocker — just boilerplate that every consumer has to copy for what should be plug-and-play.Why file this
With a first-class raw-response path, a whole category of SEO/discovery/syndication plugins becomes drop-in installable. Happy to contribute a PR if the maintainers have a preferred direction.
Thanks for the great plugin system so far.