Skip to content

Plugin routes should support raw responses (custom content-type, headers) #417

@devondragon

Description

@devondragon

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:

  1. sitemap.xml — must be served as application/xml at a well-known path for search engines.
  2. llms.txttext/plain at site root, the emerging convention for AI crawler guidance.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions