4.8 KiB
4.8 KiB
routes/progress-sse — Server-Sent Events progress push
(no submodules)
Purpose
Exposes GET /imports/:id/progress returning a long-lived
text/event-stream so clients can subscribe to a job's real-time
progress without polling. The handler is driven entirely off the
existing ImportService.bus — three subscriptions (import.progress,
import.completed, import.failed) are wired into a
ReadableStream<Uint8Array> and detached on terminal events or client
abort. This module was deferred from the original
add-api-plugin-import migration's Excluded list and ships
independently via the add-import-sse-progress change.
Features
- Three SSE event kinds —
progress(perimport.progresstick),complete(terminal, onimport.completed),failed(terminal, onimport.failed). - Initial frame snapshot from
service.getJobso clients see current state before the next bus tick. - Owner-scoped — foreign-owner / missing-job lookups return HTTP 404
RFC 7807 problem+json (
IMPORT.NOT_FOUND); anonymous requests return HTTP 401 via the existingrequireAuth()middleware. - Per-job filtering on every subscription callback so a long-lived connection cannot leak progress for other jobs.
AbortSignallifecycle — client disconnect detaches every listener and emitsimport.sse.disconnected{reason: "client"}.- OTel observability —
import.sse.connected,import.sse.events_sent{kind},import.sse.disconnected{reason}. - Pure stream — no polling timer, no background interval, no heartbeat (job runs are short-lived so proxy-keepalive concerns are handled by upstream LB tuning, not by the handler).
Usage: Basic Example
import { createImportRoutes, ImportService } from "@suaveplan/api-plugin-import";
import { Hono } from "hono";
const service = new ImportService();
const host = new Hono();
host.use("*", async (c, next) => {
c.set("user", { id: "alice" });
await next();
});
host.route("/imports", createImportRoutes({ service }));
// Client side:
const es = new EventSource("/imports/job-1/progress");
es.addEventListener("progress", (e) => console.log(JSON.parse(e.data)));
es.addEventListener("complete", () => es.close());
es.addEventListener("failed", () => es.close());
Usage: Advanced Example
import {
createProgressSseHandler,
ImportService,
} from "@suaveplan/api-plugin-import";
import { Hono } from "hono";
const service = new ImportService();
const app = new Hono();
app.use("*", async (c, next) => {
c.set("user", { id: "alice" });
await next();
});
// Mount stand-alone (without the rest of `createImportRoutes`).
app.get("/jobs/:id/progress", createProgressSseHandler({ service }));
// Drive the service from a worker; every emitted progress event is
// fanned to every connected client whose `:id` matches.
const controller = new AbortController();
const res = await app.request(
new Request("http://t/jobs/job-1/progress", { signal: controller.signal })
);
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buf = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const frames = buf.split("\n\n");
buf = frames.pop() ?? "";
for (const frame of frames) console.log(frame);
}
API Reference
createProgressSseHandler(deps: ProgressSseDeps): (c: Context) => ResponseformatProgressFrame(kind: ProgressSseEventKind, seq: number, payload: ImportProgress): stringinterface ProgressSseDeps { service: ImportService }type ProgressSseEventKind = "progress" | "complete" | "failed"
Implementation Notes
- The handler is registered via
app.get("/:id/progress", ...)rather thanapp.openapi(...)becausec.body(ReadableStream, ...)does not conform to a static OpenAPI response schema — the parent router'sapp.use("/*", requireAuth())andapp.onError(...)still apply. - Subscriptions are filtered by
payload.jobId === jobIdso the bus cannot leak foreign-job progress to a connection. The router's authoritative ownership check happens once viaservice.getJobat connection-open time; subsequent leak prevention is structural. - The terminal
complete/failedbranch instarthandles the edge case where a job reached terminal state betweengetJoband the bus subscription — without it, a client would be stuck on the initialprogressframe forever. - The OTel counters are read through
getImportInstruments()rather than cached at module scope soresetImportInstruments()(used by test setup) rebinds correctly. - The response headers (
Cache-Control: no-cache, no-transform,X-Accel-Buffering: no,Connection: keep-alive) match the@suaveplan/api-plugin-ssehandler so reverse proxies (nginx, cloudflare) do not buffer or coalesce the stream.