@nwire/koa 0.10.1 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,10 +17,9 @@ import { z } from "zod";
17
17
 
18
18
  const app = createApp({ appName: "api" });
19
19
 
20
- app.wire(
21
- post("/hello", { body: z.object({ name: z.string().min(1) }) }),
22
- async (input) => ({ message: `Hello, ${input.name}!` }),
23
- );
20
+ app.wire(post("/hello", { body: z.object({ name: z.string().min(1) }) }), async (input) => ({
21
+ message: `Hello, ${input.name}!`,
22
+ }));
24
23
 
25
24
  await endpoint("api", { port: 3000 })
26
25
  .use(httpKoa({ prefix: "/api" }))
@@ -32,14 +31,14 @@ await endpoint("api", { port: 3000 })
32
31
 
33
32
  ```ts
34
33
  httpKoa({
35
- port: 3000, // 0 = ephemeral; .port() returns the bound port after boot
34
+ port: 3000, // 0 = ephemeral; .port() returns the bound port after boot
36
35
  host: "0.0.0.0",
37
- prefix: "/api", // mounted on every wired route
38
- helmet: true, // default-on; pass false to disable
39
- cors: { origin: "*" }, // pass an options object or true; false to disable
36
+ prefix: "/api", // mounted on every wired route
37
+ helmet: true, // default-on; pass false to disable
38
+ cors: { origin: "*" }, // pass an options object or true; false to disable
40
39
  bodyParser: { jsonLimit: "1mb" },
41
- middleware: [requireUser], // adopter-wide Koa middleware run before every wire
42
- logger: myLogger, // defaults to ConsoleLogger
40
+ middleware: [requireUser], // adopter-wide Koa middleware run before every wire
41
+ logger: myLogger, // defaults to ConsoleLogger
43
42
  });
44
43
  ```
45
44
 
@@ -66,13 +65,10 @@ Two shapes are supported and route to the same dispatch:
66
65
 
67
66
  ```ts
68
67
  // Foundation HTTP — plain (input, ctx)
69
- app.wire(
70
- post("/order", { body: OrderBody }),
71
- async (input, ctx) => {
72
- const repo = ctx.resolve<OrderRepo>("orders");
73
- return repo.create(input);
74
- },
75
- );
68
+ app.wire(post("/order", { body: OrderBody }), async (input, ctx) => {
69
+ const repo = ctx.resolve<OrderRepo>("orders");
70
+ return repo.create(input);
71
+ });
76
72
 
77
73
  // Forge HandlerDefinition — routed through runtime.execute
78
74
  const placeOrder = defineHandler(placeOrderAction, async (input, ctx) => {
@@ -89,13 +85,13 @@ take the direct path with a minimal ctx (`resolve`, `logger`, `koa`,
89
85
 
90
86
  ## Response shaping
91
87
 
92
- | Handler returns | HTTP response |
93
- | --------------------------------------------------- | ------------------------------ |
94
- | `{ $kind: "response-instance", status, body }` | `status`, JSON body |
95
- | `{ $status, body }` (legacy envelope) | `$status`, JSON body |
96
- | anything else | `200`, JSON serialized verbatim |
97
- | validation throws | `400`, structured error JSON |
98
- | handler throws `defineError`-shaped | `error.status`, structured JSON |
88
+ | Handler returns | HTTP response |
89
+ | ---------------------------------------------- | ------------------------------- |
90
+ | `{ $kind: "response-instance", status, body }` | `status`, JSON body |
91
+ | `{ $status, body }` (legacy envelope) | `$status`, JSON body |
92
+ | anything else | `200`, JSON serialized verbatim |
93
+ | validation throws | `400`, structured error JSON |
94
+ | handler throws `defineError`-shaped | `error.status`, structured JSON |
99
95
 
100
96
  ## Testing
101
97
 
@@ -16,20 +16,15 @@
16
16
  * — the handler returns a value that becomes ctx.body; status defaults
17
17
  * to 200; { $status, body } objects unwrap.
18
18
  *
19
- * 0.10 first-pass scope:
19
+ * Scope:
20
20
  * - helmet + cors + body parser layered when configured
21
21
  * - per-wire handler dispatch via Koa router
22
22
  * - per-request scoped container (parent = wire's source app)
23
23
  * - dispatched as Adapter so endpoint().use(httpKoa()).mount(app).run()
24
24
  * works end-to-end
25
- *
26
- * Deferred (lands in 0.10.x):
27
- * - OpenAPI document + /docs serving
28
- * - Scalar docs UI
29
- * - Inspect endpoints under /_nwire/*
30
- * - Per-route middleware (RouteBinding.middleware)
31
- * - Per-request envelope scope (forge integration)
32
- * - fromKoaApp / fromKoaMiddleware / fromKoa auto-wrapper
25
+ * - inspect surface at /_nwire/* (opt-in via `inspect` config or
26
+ * the NWIRE_INSPECT env var) — manifest, dispatch, event/telemetry
27
+ * ring buffers + SSE
33
28
  */
34
29
  import type { Adapter } from "@nwire/endpoint";
35
30
  import { type Logger } from "@nwire/logger";
@@ -54,6 +49,29 @@ export interface HttpKoaConfig {
54
49
  */
55
50
  readonly middleware?: readonly Koa.Middleware[];
56
51
  readonly logger?: Logger;
52
+ /**
53
+ * Correlation-id middleware. Default `true`. When enabled, every
54
+ * request gets `x-correlation-id`: if the client sent one (via
55
+ * `x-correlation-id` or `x-request-id`) it's reused; otherwise a fresh
56
+ * UUID is generated. The value lands on `envelope.correlationId` and
57
+ * is echoed back as a response header so distributed traces stitch.
58
+ *
59
+ * Set to `false` to opt out (e.g. if you have a custom request-id
60
+ * middleware running upstream).
61
+ */
62
+ readonly reqId?: boolean;
63
+ /**
64
+ * Tenant resolver. The dev/test default reads `x-tenant` from the
65
+ * request header — convenient for local multi-tenancy. Production
66
+ * MUST pass a resolver that derives tenancy from a verified source
67
+ * (a JWT claim, a session, a host header bound to a tenant config)
68
+ * — raw `x-tenant` from clients is trivially spoofable.
69
+ *
70
+ * When `NODE_ENV === "production"` and no resolver is provided,
71
+ * `envelope.tenant` is left undefined so the raw header can't be
72
+ * trusted.
73
+ */
74
+ readonly tenantResolver?: (kctx: Koa.ParameterizedContext) => string | undefined;
57
75
  /**
58
76
  * OpenAPI 3.1 doc — three forms of input:
59
77
  * - `auto: true` — generate the spec by walking the adopter's wires;
package/dist/http-koa.js CHANGED
@@ -16,22 +16,18 @@
16
16
  * — the handler returns a value that becomes ctx.body; status defaults
17
17
  * to 200; { $status, body } objects unwrap.
18
18
  *
19
- * 0.10 first-pass scope:
19
+ * Scope:
20
20
  * - helmet + cors + body parser layered when configured
21
21
  * - per-wire handler dispatch via Koa router
22
22
  * - per-request scoped container (parent = wire's source app)
23
23
  * - dispatched as Adapter so endpoint().use(httpKoa()).mount(app).run()
24
24
  * works end-to-end
25
- *
26
- * Deferred (lands in 0.10.x):
27
- * - OpenAPI document + /docs serving
28
- * - Scalar docs UI
29
- * - Inspect endpoints under /_nwire/*
30
- * - Per-route middleware (RouteBinding.middleware)
31
- * - Per-request envelope scope (forge integration)
32
- * - fromKoaApp / fromKoaMiddleware / fromKoa auto-wrapper
25
+ * - inspect surface at /_nwire/* (opt-in via `inspect` config or
26
+ * the NWIRE_INSPECT env var) — manifest, dispatch, event/telemetry
27
+ * ring buffers + SSE
33
28
  */
34
29
  import http from "node:http";
30
+ import { randomUUID } from "node:crypto";
35
31
  import { dummyContainer } from "@nwire/container";
36
32
  import { ConsoleLogger } from "@nwire/logger";
37
33
  import Koa from "koa";
@@ -65,24 +61,54 @@ export function httpKoa(config = {}) {
65
61
  async boot(ctx) {
66
62
  const koa = new Koa();
67
63
  koaInstance = koa;
64
+ const isProd = process.env.NODE_ENV === "production";
68
65
  // Top-level error mapper — catches throws from middleware and
69
66
  // unmatched routes. Renders defineError-shaped values with their
70
- // {code, status, summary} envelope; otherwise opaque 500.
67
+ // {code, status, summary} envelope; in production, generic
68
+ // (non-NwireError) throws return opaque "internal_error" so a
69
+ // stray `throw new Error("DB password X")` cannot leak the
70
+ // message to clients. The real error is always logged.
71
71
  koa.use(async (kctx, next) => {
72
72
  try {
73
73
  await next();
74
74
  }
75
75
  catch (err) {
76
76
  const e = err;
77
- kctx.status = typeof e.status === "number" ? e.status : 500;
78
- kctx.body = {
79
- error: {
80
- code: e.code ?? "internal_error",
81
- summary: e.summary ?? e.message ?? "Internal error",
82
- },
83
- };
77
+ const isNwireError = e?.$kind === "error";
78
+ const status = typeof e.status === "number" ? e.status : 500;
79
+ kctx.status = status;
80
+ if (!isNwireError && isProd) {
81
+ // Server-side: log the full error so SRE can see it. Client:
82
+ // opaque body — no error.message leak.
83
+ logger.error?.(`[http-koa] unhandled error: ${e?.message ?? e}`, undefined);
84
+ kctx.body = {
85
+ error: { code: "internal_error", summary: "Internal error" },
86
+ };
87
+ }
88
+ else {
89
+ kctx.body = {
90
+ error: {
91
+ code: e.code ?? "internal_error",
92
+ summary: e.summary ?? e.message ?? "Internal error",
93
+ },
94
+ };
95
+ }
84
96
  }
85
97
  });
98
+ // Correlation-id: opt-out (default on). Reuses an inbound
99
+ // `x-correlation-id` / `x-request-id`; mints a UUID otherwise.
100
+ // Stamps `kctx.state.correlationId` for downstream consumers and
101
+ // echoes the response header so traces stitch end-to-end.
102
+ if (config.reqId !== false) {
103
+ koa.use(async (kctx, next) => {
104
+ const incoming = kctx.request.headers["x-correlation-id"] ??
105
+ kctx.request.headers["x-request-id"];
106
+ const correlationId = incoming && incoming.length > 0 ? incoming : randomUUID();
107
+ kctx.state.correlationId = correlationId;
108
+ kctx.set("x-correlation-id", correlationId);
109
+ await next();
110
+ });
111
+ }
86
112
  if (config.helmet !== false) {
87
113
  koa.use(helmet());
88
114
  }
@@ -125,9 +151,27 @@ export function httpKoa(config = {}) {
125
151
  }
126
152
  if (docsConfig !== null) {
127
153
  koa.use(async (kctx, next) => {
128
- if (kctx.method !== "GET" || kctx.path !== docsPath)
154
+ if (kctx.path !== docsPath)
155
+ return next();
156
+ if (kctx.method !== "GET" && kctx.method !== "HEAD")
129
157
  return next();
158
+ // Scalar's bundle loads from cdn.jsdelivr.net; relax CSP just
159
+ // for this route so the docs render in a browser. Other routes
160
+ // keep helmet's strict defaults.
161
+ kctx.set("Content-Security-Policy", "default-src 'self'; " +
162
+ "script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " +
163
+ "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com; " +
164
+ "font-src 'self' https://fonts.gstatic.com data:; " +
165
+ "img-src 'self' data: https:; " +
166
+ "connect-src 'self'");
130
167
  kctx.type = "text/html";
168
+ if (kctx.method === "HEAD") {
169
+ // Force 200 + empty body. Koa otherwise rewrites the status
170
+ // when body is null/undefined.
171
+ kctx.status = 200;
172
+ kctx.body = "";
173
+ return;
174
+ }
131
175
  kctx.body = `<!doctype html>
132
176
  <html>
133
177
  <head>
@@ -145,8 +189,16 @@ export function httpKoa(config = {}) {
145
189
  // /_nwire/* introspection — mounted before adopter-wide auth so
146
190
  // Studio can read manifest/wires/runtime without an admin token.
147
191
  // Production consumers gate via inspect.middleware.
148
- if (config.inspect) {
149
- const inspectConfig = config.inspect === true ? {} : config.inspect;
192
+ //
193
+ // Auto-on when `NWIRE_INSPECT=1` is in the environment: Studio
194
+ // sets this when it spawns a dev process, so the live proxy "just
195
+ // works" without the app's main.ts having to opt in. Explicit
196
+ // `inspect:` config still wins — pass `inspect: false` to keep
197
+ // production deployments clean even if the env var leaks in.
198
+ const inspectEnvOn = process.env.NWIRE_INSPECT === "1" || process.env.NWIRE_INSPECT === "true";
199
+ const inspectEnabled = config.inspect ?? inspectEnvOn;
200
+ if (inspectEnabled) {
201
+ const inspectConfig = inspectEnabled === true ? {} : inspectEnabled;
150
202
  const { mountInspect } = await import("./inspect.js");
151
203
  // Find a representative source app — we mount inspect against the
152
204
  // first wire's owning app; the wire collection is what we report.
@@ -163,9 +215,7 @@ export function httpKoa(config = {}) {
163
215
  for (const mw of config.middleware ?? []) {
164
216
  koa.use(mw);
165
217
  }
166
- const router = config.prefix
167
- ? new KoaRouter({ prefix: config.prefix })
168
- : new KoaRouter();
218
+ const router = config.prefix ? new KoaRouter({ prefix: config.prefix }) : new KoaRouter();
169
219
  for (const wire of ctx.wires) {
170
220
  if (!isHttpBinding(wire.binding))
171
221
  continue;
@@ -213,14 +263,25 @@ export function httpKoa(config = {}) {
213
263
  logger,
214
264
  koa: kctx,
215
265
  };
216
- // Per-request envelope derived from request headers userId set
217
- // by adopter middleware lands on it; tenant flows from a header
218
- // for simple multi-tenancy; correlationId / causationId come
219
- // from upstream callers so distributed traces stitch in.
266
+ // Per-request envelope. correlationId is set by the req-id
267
+ // middleware above (or whatever the upstream caller sent).
268
+ // tenant goes through `tenantResolver` if provided; the dev/test
269
+ // default reads `x-tenant`, but in production we refuse to trust
270
+ // the raw header — a resolver must derive tenancy from a verified
271
+ // source (a JWT claim, a session, a host binding).
272
+ const resolveTenant = config.tenantResolver ??
273
+ ((kc) => isProd
274
+ ? undefined
275
+ : (kc.request.headers["x-tenant"] ?? undefined));
220
276
  const envelopePartial = {
221
- tenant: kctx.request.headers["x-tenant"] ?? undefined,
277
+ tenant: resolveTenant(kctx),
222
278
  userId: kctx.state.userId ?? undefined,
223
- correlationId: kctx.request.headers["x-correlation-id"] ?? undefined,
279
+ // Pass the full user object through when adopter auth middleware
280
+ // populated it. Without this, transport-agnostic RBAC has to
281
+ // reach into `ctx.koa.state.user` and couples the handler to
282
+ // the transport.
283
+ user: kctx.state.user ?? undefined,
284
+ correlationId: kctx.state.correlationId,
224
285
  causationId: kctx.request.headers["x-causation-id"] ?? undefined,
225
286
  };
226
287
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -248,16 +309,28 @@ export function httpKoa(config = {}) {
248
309
  }
249
310
  }
250
311
  catch (err) {
251
- // Generic error envelope; downstream phases hook in nwire-error
252
- // mapping + sanitised production responses.
312
+ // Generic error envelope. NwireError-shaped values (defineError)
313
+ // surface their {code, status, summary}. Other throws — under
314
+ // NODE_ENV=production — return opaque "internal_error" so a
315
+ // stray `throw new Error("DB password X")` cannot leak the
316
+ // message to clients. The real error is always logged.
253
317
  const e = err;
318
+ const isNwireError = e?.$kind === "error";
254
319
  kctx.status = typeof e.status === "number" ? e.status : 500;
255
- kctx.body = {
256
- error: {
257
- code: e.code ?? "internal_error",
258
- summary: e.summary ?? e.message ?? "Internal error",
259
- },
260
- };
320
+ if (!isNwireError && isProd) {
321
+ logger.error?.(`[http-koa] unhandled error: ${e?.message ?? e}`, undefined);
322
+ kctx.body = {
323
+ error: { code: "internal_error", summary: "Internal error" },
324
+ };
325
+ }
326
+ else {
327
+ kctx.body = {
328
+ error: {
329
+ code: e.code ?? "internal_error",
330
+ summary: e.summary ?? e.message ?? "Internal error",
331
+ },
332
+ };
333
+ }
261
334
  return;
262
335
  }
263
336
  // Response shaping:
package/dist/inspect.js CHANGED
@@ -30,16 +30,117 @@ function summarizeWire(wire) {
30
30
  source: b.source,
31
31
  };
32
32
  }
33
+ /**
34
+ * Studio's Trace page expects a flatter shape with `capturedAt` and a
35
+ * stable `seq` per stream. Telemetry records hold the same data under
36
+ * `event.{eventName,payload}` + `ts`, so we project at the API boundary
37
+ * rather than reshape every consumer of the ring buffer.
38
+ */
39
+ function toBufferedEvent(rec, seq) {
40
+ return {
41
+ seq,
42
+ eventName: rec.event?.eventName,
43
+ payload: rec.event?.payload,
44
+ envelope: rec.envelope ?? {},
45
+ source: rec.source ?? "in-process",
46
+ appName: rec.appName ?? "",
47
+ capturedAt: rec.ts ?? rec.envelope?.timestamp ?? new Date().toISOString(),
48
+ };
49
+ }
50
+ class RingBuffer {
51
+ cap;
52
+ items = [];
53
+ constructor(cap) {
54
+ this.cap = cap;
55
+ }
56
+ push(rec) {
57
+ this.items.push(rec);
58
+ if (this.items.length > this.cap)
59
+ this.items.shift();
60
+ }
61
+ recent(n) {
62
+ const limit = n ?? this.items.length;
63
+ return this.items.slice(-limit);
64
+ }
65
+ }
66
+ function attachStreams(opts) {
67
+ const events = new RingBuffer(500);
68
+ const telemetry = new RingBuffer(500);
69
+ const listeners = new Set();
70
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
71
+ const rt = opts.app?.runtime;
72
+ if (typeof rt?.onTelemetry === "function") {
73
+ rt.onTelemetry((rec) => {
74
+ telemetry.push(rec);
75
+ if (rec.kind === "event.published" || rec.kind === "event.deduped") {
76
+ events.push(rec);
77
+ }
78
+ for (const fn of listeners) {
79
+ try {
80
+ fn(rec);
81
+ }
82
+ catch {
83
+ // Listener crashed; drop without taking the runtime with it.
84
+ }
85
+ }
86
+ });
87
+ }
88
+ return {
89
+ events,
90
+ telemetry,
91
+ subscribe(fn) {
92
+ listeners.add(fn);
93
+ return () => listeners.delete(fn);
94
+ },
95
+ };
96
+ }
97
+ const streamsByMount = new WeakMap();
98
+ function streamsFor(opts) {
99
+ let s = streamsByMount.get(opts);
100
+ if (!s) {
101
+ s = attachStreams(opts);
102
+ streamsByMount.set(opts, s);
103
+ }
104
+ return s;
105
+ }
33
106
  export function mountInspect(koa, opts) {
34
107
  const prefix = opts.config.prefix ?? "/_nwire";
108
+ const chain = opts.config.middleware ?? [];
109
+ const isProd = process.env.NODE_ENV === "production";
110
+ // Eagerly attach the telemetry tap on the App's runtime so we don't
111
+ // miss records emitted before the first /_nwire/* request.
112
+ streamsFor(opts);
113
+ // Production guardrail: `/dispatch` is remote-handler invocation. If the
114
+ // caller didn't gate it with adopter middleware, refuse to expose it
115
+ // under NODE_ENV=production. The read-only `/manifest`, `/wires`,
116
+ // `/runtime`, `/actors`, `/queries` routes still serve (they only leak
117
+ // metadata) but POST `/dispatch` returns 403. Dev/test still mounts
118
+ // freely so Studio + tests work without extra setup.
119
+ const allowDispatch = !isProd || chain.length > 0;
120
+ if (isProd && !allowDispatch) {
121
+ // eslint-disable-next-line no-console
122
+ console.warn(`[http-koa] /_nwire/dispatch is disabled under NODE_ENV=production ` +
123
+ `because no inspect.middleware guard was configured. Pass ` +
124
+ `httpKoa({ inspect: { middleware: [requireAdmin] } }) to re-enable.`);
125
+ }
35
126
  const handle = async (kctx, next) => {
36
127
  if (!kctx.path.startsWith(prefix + "/"))
37
128
  return next();
129
+ const sub = kctx.path.slice(prefix.length);
130
+ if (kctx.method === "POST" && sub === "/dispatch" && !allowDispatch) {
131
+ kctx.status = 403;
132
+ kctx.body = {
133
+ error: {
134
+ code: "dispatch_disabled",
135
+ summary: "Inspect dispatch is disabled under NODE_ENV=production without explicit middleware guard.",
136
+ },
137
+ };
138
+ return;
139
+ }
38
140
  // Adopter-wide middleware first (if any). We model these as a chain;
39
141
  // Koa doesn't expose runtime chain composition for arbitrary inputs,
40
142
  // so we fold them with a manual chain.
41
143
  let i = 0;
42
- const chain = opts.config.middleware ?? [];
43
144
  const runNext = async () => {
44
145
  const mw = chain[i++];
45
146
  if (mw)
@@ -76,13 +177,66 @@ async function dispatchInspect(kctx, opts) {
76
177
  kctx.body = JSON.stringify({
77
178
  appName: opts.app?.appName ?? null,
78
179
  hasRuntime: typeof rt === "object" && rt !== null,
79
- lifecycle: rt?.lifecycle ?? "unknown",
180
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
181
+ lifecycle: opts.app?.state ?? rt?.lifecycle ?? "unknown",
80
182
  handlers: typeof rt?.listHandlers === "function"
81
- ? rt.listHandlers().map((h) => typeof h === "string" ? h : (h?.name ?? null)).filter(Boolean)
183
+ ? rt.listHandlers()
184
+ .map((h) => (typeof h === "string" ? h : (h?.name ?? null)))
185
+ .filter(Boolean)
82
186
  : [],
83
187
  });
84
188
  return;
85
189
  }
190
+ if (kctx.method === "GET" && (sub === "/events/recent" || sub === "/telemetry/recent")) {
191
+ const streams = streamsFor(opts);
192
+ const limit = Number(kctx.query.limit ?? "200") || 200;
193
+ const buf = sub === "/events/recent" ? streams.events : streams.telemetry;
194
+ const records = buf.recent(limit);
195
+ kctx.type = "application/json";
196
+ kctx.body = JSON.stringify(sub === "/events/recent" ? records.map((rec, i) => toBufferedEvent(rec, i)) : records);
197
+ return;
198
+ }
199
+ if (kctx.method === "GET" && (sub === "/events/stream" || sub === "/telemetry/stream")) {
200
+ const streams = streamsFor(opts);
201
+ const onlyEvents = sub === "/events/stream";
202
+ kctx.respond = false;
203
+ const res = kctx.res;
204
+ res.statusCode = 200;
205
+ res.setHeader("Content-Type", "text/event-stream");
206
+ res.setHeader("Cache-Control", "no-cache, no-transform");
207
+ res.setHeader("Connection", "keep-alive");
208
+ // Backfill from the ring buffer.
209
+ const backfill = onlyEvents ? streams.events.recent(50) : streams.telemetry.recent(50);
210
+ let seq = 0;
211
+ for (const rec of backfill) {
212
+ const payload = onlyEvents ? toBufferedEvent(rec, seq++) : rec;
213
+ res.write(`data: ${JSON.stringify(payload)}\n\n`);
214
+ }
215
+ const unsub = streams.subscribe((rec) => {
216
+ if (onlyEvents && rec.kind !== "event.published" && rec.kind !== "event.deduped")
217
+ return;
218
+ try {
219
+ const payload = onlyEvents ? toBufferedEvent(rec, seq++) : rec;
220
+ res.write(`data: ${JSON.stringify(payload)}\n\n`);
221
+ }
222
+ catch {
223
+ // socket gone
224
+ }
225
+ });
226
+ const keepalive = setInterval(() => {
227
+ try {
228
+ res.write(": keepalive\n\n");
229
+ }
230
+ catch {
231
+ // socket gone
232
+ }
233
+ }, 25_000);
234
+ kctx.req.on("close", () => {
235
+ unsub();
236
+ clearInterval(keepalive);
237
+ });
238
+ return;
239
+ }
86
240
  if (kctx.method === "GET" && sub.startsWith("/actors/")) {
87
241
  // Per-type actor listing requires an ActorStore binding. We try to
88
242
  // resolve it from the container; if absent, return 501.
package/dist/openapi.js CHANGED
@@ -82,9 +82,7 @@ export async function generateOpenApi(wires, options = {}) {
82
82
  request: {
83
83
  ...(b.params ? { params: b.params } : {}),
84
84
  ...(b.query ? { query: b.query } : {}),
85
- ...(b.body
86
- ? { body: { content: { "application/json": { schema: b.body } } } }
87
- : {}),
85
+ ...(b.body ? { body: { content: { "application/json": { schema: b.body } } } } : {}),
88
86
  },
89
87
  responses,
90
88
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nwire/koa",
3
- "version": "0.10.1",
3
+ "version": "0.11.1",
4
4
  "description": "Nwire — Koa-backed HTTP adopter. Consumes wires from @nwire/wires/http and serves them as Koa routes under endpoint lifecycle.",
5
5
  "keywords": [
6
6
  "adopter",
@@ -33,10 +33,10 @@
33
33
  "koa-bodyparser": "^4.4.1",
34
34
  "koa-helmet": "^8.0.1",
35
35
  "zod": "^4.0.0",
36
- "@nwire/endpoint": "0.10.1",
37
- "@nwire/container": "0.10.1",
38
- "@nwire/logger": "0.10.1",
39
- "@nwire/wires": "0.10.1"
36
+ "@nwire/container": "0.11.1",
37
+ "@nwire/endpoint": "0.11.1",
38
+ "@nwire/logger": "0.11.1",
39
+ "@nwire/wires": "0.11.1"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@asteasolutions/zod-to-openapi": "^8.0.0",
@@ -47,8 +47,8 @@
47
47
  "@types/node": "^22.19.9",
48
48
  "typescript": "^5.9.3",
49
49
  "vitest": "^4.0.18",
50
- "@nwire/app": "0.10.1",
51
- "@nwire/handler": "0.10.1"
50
+ "@nwire/app": "0.11.1",
51
+ "@nwire/handler": "0.11.1"
52
52
  },
53
53
  "peerDependencies": {
54
54
  "@asteasolutions/zod-to-openapi": "^8.0.0"