# `routes/download` — `GET /:id/download` handler _(no submodules)_ ## Purpose Implements the `GET /:id/download` route on `@suaveplan/api-plugin-export`. The handler serves the rendered file body for a previously completed export job. The endpoint closes the deferral noted in `archive/2026-05-19-add-api-plugin-export/proposal.md` §"Out of scope" — the `POST /exports` response already returned `downloadUrl: "/{jobId}/download"` for forward compatibility, and this module finally resolves that URL to a real response. The handler is decoupled from Hono via the discriminated `DownloadOutcome` union: the route registration call site in `routes/exports/exports.ts` calls `runDownloadExport(...)`, branches on `outcome.kind`, and either streams the body or passes the carried `ExportError` to the per-package RFC 7807 renderer. Every failure branch raises one of the existing `EXPORT_*` codes so the spec-conformance pin (`api-plugin-export.spec-conformance.test.ts`) keeps the registry fully exercised from a production path. ## Features - Reads the job from the persistent `ExportJobStore` slot when wired (`SurrealExportStore` / `MemoryExportJobStore`) or falls back to the in-memory `Map` shipped on `ExportRoutesDeps.jobs`. - Validates the job is in `status: "completed"` — any other status raises `EXPORT.JOB_NOT_READY` (HTTP `409`). - Re-fetches the source rows through the registered `DataProvider`, passing `job.filters` exactly as supplied to the original `POST /exports` call so the export is idempotent against the current provider state. - Re-renders through the existing `getExporter(format)` writer — no new format work, no body caching. The `Content-Type` is taken from the writer's `mimeType` field. - Emits `Content-Disposition: attachment; filename=""` with the filename sanitised per RFC 6266 §4.1 (quotes / backslashes escaped, control characters replaced with `_`). - OTel: increments `export.download.served{format}` on success and `export.download.failed{reason}` (`not-found` / `not-ready` / `provider-not-found` / `failed`) on every failure branch. ## Usage: Basic The function is consumed by the route registration in `routes/exports/exports.ts`: ```ts import { runDownloadExport } from "@suaveplan/api-plugin-export/routes/download/download"; app.get("/:id/download", async (c) => { const { id } = c.req.param(); const outcome = await runDownloadExport(deps, id); if (outcome.kind === "ok") { c.header("content-type", outcome.contentType); c.header( "content-disposition", buildContentDisposition(outcome.filename) ); return c.body(outcome.body, 200); } return respondProblemJson(c, outcome.error); }); ``` ## Usage: Advanced The handler can be driven directly outside Hono — e.g. from a CLI or worker entrypoint — because the `DownloadDeps` interface only requires three plain fields: ```ts import { runDownloadExport } from "@suaveplan/api-plugin-export/routes/download/download"; import { MemoryExportJobStore } from "@suaveplan/api-plugin-export"; const out = await runDownloadExport( { jobStore: new MemoryExportJobStore([[jobId, job]]), jobs: new Map(), providers: new Map([ [ "users", { resource: "users", fetch: async () => [{ id: 1 }], }, ], ]), }, jobId ); if (out.kind === "ok") { await Bun.write("out.csv", out.body); } ``` When `jobStore` is omitted, the handler reads the job from the in-memory `deps.jobs` map — the existing slot wired on `ExportRoutesDeps` since the initial plugin ship. ## API Reference | Symbol | Kind | Notes | |---|---|---| | `DownloadDeps` | `interface` | `{ jobStore?; jobs; providers }` | | `DownloadOutcome` | discriminated union | `ok` / `not-found` / `not-ready` / `provider-not-found` / `failed` | | `runDownloadExport(deps, id)` | async function | Returns `Promise` | | `buildContentDisposition(filename)` | function | RFC 6266 §4.1-compliant string | ## Implementation Notes - The handler never throws — every failure becomes an `ExportError` carried on the `DownloadOutcome.error` field. This decouples the response shape from the discriminated outcome and keeps the route layer free of `try { … } catch { … }` blocks. - `context.statusCode` is set on every `ExportError` so the per-package problem-details renderer can map directly to the right HTTP code without a separate code-to-status table. - `inferColumns(data)` is used unconditionally on download. The original column projection is not persisted on the `ExportJob` shape — re-inferring keeps the download deterministic against the current provider response without bloating the persisted job. - The handler is intentionally idempotent against the current provider state: a fresh download after the source rows change reflects the change. This matches RFC 7231 §4.2.2 "idempotent methods" for `GET` and avoids the cache-invalidation problem a body-caching scheme would introduce. - `buildContentDisposition` strips `[0x00-0x1f]` plus `0x7f` rather than emitting them literally — these are forbidden in HTTP header values per RFC 7230 §3.2.6.