@nwire/koa 0.12.0 → 0.13.0

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/dist/http-koa.js CHANGED
@@ -35,6 +35,24 @@ import bodyParser from "koa-bodyparser";
35
35
  import cors from "@koa/cors";
36
36
  import helmet from "koa-helmet";
37
37
  import KoaRouter from "@koa/router";
38
+ /** Zod throws a `ZodError` (name + `issues[]`) on a failed `.parse()`. */
39
+ function isZodError(e) {
40
+ return (typeof e === "object" &&
41
+ e !== null &&
42
+ e.name === "ZodError" &&
43
+ Array.isArray(e.issues));
44
+ }
45
+ /**
46
+ * A human-readable summary of the first validation issue. Safe to expose:
47
+ * it describes the caller's own input, not internal state.
48
+ */
49
+ function zodSummary(e) {
50
+ const first = e.issues[0];
51
+ if (!first)
52
+ return "validation failed";
53
+ const path = (first.path ?? []).join(".") || "(body)";
54
+ return `${path}: ${first.message}`;
55
+ }
38
56
  function isHttpBinding(b) {
39
57
  return (typeof b === "object" &&
40
58
  b !== null &&
@@ -45,6 +63,9 @@ function isHttpBinding(b) {
45
63
  export function httpKoa(config = {}) {
46
64
  let server;
47
65
  let koaInstance;
66
+ // Set during boot — answers "does the wire own this URL path?" for the
67
+ // one-Vite dev host. Undefined before boot.
68
+ let ownsPredicate;
48
69
  const logger = config.logger ?? new ConsoleLogger();
49
70
  return {
50
71
  $kind: "adapter",
@@ -58,6 +79,12 @@ export function httpKoa(config = {}) {
58
79
  koa() {
59
80
  return koaInstance;
60
81
  },
82
+ handler() {
83
+ return koaInstance?.callback();
84
+ },
85
+ owns(pathname) {
86
+ return ownsPredicate?.(pathname) ?? false;
87
+ },
61
88
  async boot(ctx) {
62
89
  const koa = new Koa();
63
90
  koaInstance = koa;
@@ -77,12 +104,32 @@ export function httpKoa(config = {}) {
77
104
  catch (err) {
78
105
  const e = err;
79
106
  const isNwireError = e?.$kind === "error";
80
- kctx.status = typeof e.status === "number" ? e.status : 500;
107
+ if (isZodError(err)) {
108
+ // A schema `.parse()` that escaped the per-route catch (e.g. an
109
+ // action's own input schema). Validation failures are the
110
+ // caller's fault → 400 with a readable summary, never an opaque
111
+ // 500. The message is about their input, so it's safe to expose.
112
+ kctx.status = 400;
113
+ kctx.body = { error: { code: "validation_failed", summary: zodSummary(err) } };
114
+ return;
115
+ }
116
+ const status = typeof e.status === "number" ? e.status : 500;
117
+ kctx.status = status;
81
118
  if (isNwireError) {
82
119
  kctx.body = {
83
120
  error: { code: e.code ?? "internal_error", summary: e.summary ?? "Internal error" },
84
121
  };
85
122
  }
123
+ else if (status >= 400 && status < 500) {
124
+ // A client error raised by middleware before any handler ran —
125
+ // most commonly the body parser rejecting malformed JSON. The
126
+ // status is already the caller's fault, and the message describes
127
+ // their own request, so surface it as a clean client error rather
128
+ // than an opaque "internal_error".
129
+ kctx.body = {
130
+ error: { code: e.code ?? "bad_request", summary: e.message ?? "Bad request" },
131
+ };
132
+ }
86
133
  else {
87
134
  logger.error?.(`[http-koa] unhandled error: ${e?.message ?? e}`, undefined);
88
135
  kctx.body = {
@@ -249,7 +296,7 @@ export function httpKoa(config = {}) {
249
296
  kctx.body = {
250
297
  error: {
251
298
  code: "validation_failed",
252
- summary: err.message,
299
+ summary: isZodError(err) ? zodSummary(err) : err.message,
253
300
  },
254
301
  };
255
302
  return;
@@ -274,7 +321,12 @@ export function httpKoa(config = {}) {
274
321
  : (kc.request.headers["x-tenant"] ?? undefined));
275
322
  const envelopePartial = {
276
323
  tenant: resolveTenant(kctx),
277
- userId: kctx.state.userId ?? undefined,
324
+ // Prefer an explicit `state.userId`, but fall back to the
325
+ // user object's id so handlers reading `envelope.userId` work
326
+ // whenever auth middleware populated `state.user`.
327
+ userId: kctx.state.userId ??
328
+ kctx.state.user?.id ??
329
+ undefined,
278
330
  // Pass the full user object through when adopter auth middleware
279
331
  // populated it. Without this, transport-agnostic RBAC has to
280
332
  // reach into `ctx.koa.state.user` and couples the handler to
@@ -285,17 +337,25 @@ export function httpKoa(config = {}) {
285
337
  };
286
338
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
287
339
  const wireApp = wire.app;
288
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
289
- const runtimeExecute = wireApp?.runtime?.execute;
340
+ const runtime = wireApp?.runtime;
290
341
  let result;
291
342
  try {
292
- if (runtimeExecute) {
293
- // Unified dispatchruntime.execute builds the canonical
294
- // ctx (input + envelope + execute + emit + enqueue + resolve
295
- // + scope) for both HandlerDefinition and plain `(input, ctx)`
296
- // function shapes, threads transport extras (logger, koa)
297
- // onto the same ctx, and runs the hook chain when present.
298
- result = await runtimeExecute.call(wireApp.runtime, wire.handler, input, envelopePartial, { logger: handlerCtx.logger, koa: handlerCtx.koa });
343
+ if (runtime?.receive) {
344
+ // Unified inboundthe HTTP request becomes a command on the
345
+ // runtime's source chain. The terminal router lands it on
346
+ // runtime.execute, which builds the canonical ctx (input +
347
+ // envelope + execute + emit + enqueue + resolve + scope) for
348
+ // both action/handler and plain `(input, ctx)` shapes and
349
+ // threads transport extras (logger, koa) onto that ctx.
350
+ result = await runtime.receive({
351
+ kind: "command",
352
+ name: wire.handler?.name ?? "handler",
353
+ input,
354
+ target: wire.handler,
355
+ }, {
356
+ envelope: envelopePartial,
357
+ extras: { logger: handlerCtx.logger, koa: handlerCtx.koa },
358
+ });
299
359
  }
300
360
  else {
301
361
  // No-runtime fallback — standalone interface without an App.
@@ -395,6 +455,43 @@ export function httpKoa(config = {}) {
395
455
  }
396
456
  }
397
457
  koa.use(router.routes()).use(router.allowedMethods());
458
+ // Ownership predicate for the one-Vite dev host. The wire owns its route
459
+ // prefix subtree (so `/api/*` always reaches koa — and 404s/401s there,
460
+ // not in Studio) plus the well-known surfaces it mounts outside the
461
+ // router: `/openapi.json`, `/docs`, and the `/_nwire/*` inspect tree.
462
+ // Everything else (`/`, Studio assets + deep links) is not ours, so the
463
+ // host serves Studio. When no prefix is configured the wire's routes sit
464
+ // at the root, so we fall back to a method-agnostic route match to avoid
465
+ // claiming `/`.
466
+ const owned = new Set();
467
+ if (config.openapi?.spec || config.openapi?.generator || config.openapi?.auto) {
468
+ owned.add(openapiPath);
469
+ }
470
+ if (docsConfig !== null)
471
+ owned.add(docsPath);
472
+ const inspectPrefix = inspectEnabled
473
+ ? ((typeof inspectEnabled === "object" ? inspectEnabled.prefix : undefined) ?? "/_nwire")
474
+ : undefined;
475
+ const routePrefix = config.prefix;
476
+ ownsPredicate = (pathname) => {
477
+ if (owned.has(pathname))
478
+ return true;
479
+ if (inspectPrefix &&
480
+ (pathname === inspectPrefix || pathname.startsWith(inspectPrefix + "/"))) {
481
+ return true;
482
+ }
483
+ if (routePrefix) {
484
+ return pathname === routePrefix || pathname.startsWith(routePrefix + "/");
485
+ }
486
+ // No prefix — match real route paths so `/` and unknown paths fall
487
+ // through to Studio. `.match` is method-agnostic via the `path` layers.
488
+ try {
489
+ return router.match(pathname, "GET").path.length > 0;
490
+ }
491
+ catch {
492
+ return false;
493
+ }
494
+ };
398
495
  // Listen if a port is available: adopter-level config.port wins,
399
496
  // otherwise fall back to the endpoint-level port hint from boot ctx.
400
497
  const listenPort = config.port ?? ctx.port;
@@ -412,6 +509,11 @@ export function httpKoa(config = {}) {
412
509
  },
413
510
  async shutdown() {
414
511
  if (server) {
512
+ // `close()` stops listening but waits on in-flight connections;
513
+ // idle HTTP keep-alive sockets would otherwise hold the server (and
514
+ // a test runner's worker) open indefinitely. Drop them so shutdown
515
+ // is prompt and the listening handle is actually released.
516
+ server.closeAllConnections?.();
415
517
  await new Promise((resolve) => server.close(() => {
416
518
  resolve();
417
519
  }));
package/dist/inspect.d.ts CHANGED
@@ -2,17 +2,24 @@
2
2
  * `/_nwire/*` — read + dispatch surface Studio (and MCP tooling) reads
3
3
  * to render the live state of a running app.
4
4
  *
5
- * GET /_nwire/manifest — app name + adopter info + wire summary
6
- * GET /_nwire/wires — every wire's binding kind + key fields
7
- * GET /_nwire/runtime telemetry tap + framework-event log shape
8
- * GET /_nwire/actors/:t actor instances of type `t` from the store
9
- * POST /_nwire/dispatch invoke a registered handler by name
5
+ * GET /_nwire/manifest — app name + adopter info + wire summary
6
+ * GET /_nwire/wires — every wire's binding kind + key fields
7
+ * GET /_nwire/runtime — framework-event log shape + lifecycle
8
+ * GET /_nwire/events/recent recent wire events from the ring buffer
9
+ * GET /_nwire/events/stream SSE stream of live wire events
10
+ * GET /_nwire/actors/:t — actor instances of type `t` from the store
11
+ * POST /_nwire/dispatch — invoke a registered handler by name
10
12
  *
11
13
  * `inspect: true` (or `inspect: { ... }`) on `httpKoa({ ... })` mounts
12
14
  * these before per-route middleware so they don't sit behind auth gates.
13
15
  *
14
16
  * Production deployments should add an admin guard via
15
17
  * `inspect: { middleware: [requireAdmin] }`.
18
+ *
19
+ * Telemetry is now a sink — records flow to TelemetryReporter instances
20
+ * (file, OTLP, …) configured at the app level. The former
21
+ * `/_nwire/telemetry/recent` and `/_nwire/telemetry/stream` endpoints
22
+ * have been removed; use the reporter output directly.
16
23
  */
17
24
  import type Koa from "koa";
18
25
  import type { Wire } from "@nwire/wires";
package/dist/inspect.js CHANGED
@@ -2,17 +2,24 @@
2
2
  * `/_nwire/*` — read + dispatch surface Studio (and MCP tooling) reads
3
3
  * to render the live state of a running app.
4
4
  *
5
- * GET /_nwire/manifest — app name + adopter info + wire summary
6
- * GET /_nwire/wires — every wire's binding kind + key fields
7
- * GET /_nwire/runtime telemetry tap + framework-event log shape
8
- * GET /_nwire/actors/:t actor instances of type `t` from the store
9
- * POST /_nwire/dispatch invoke a registered handler by name
5
+ * GET /_nwire/manifest — app name + adopter info + wire summary
6
+ * GET /_nwire/wires — every wire's binding kind + key fields
7
+ * GET /_nwire/runtime — framework-event log shape + lifecycle
8
+ * GET /_nwire/events/recent recent wire events from the ring buffer
9
+ * GET /_nwire/events/stream SSE stream of live wire events
10
+ * GET /_nwire/actors/:t — actor instances of type `t` from the store
11
+ * POST /_nwire/dispatch — invoke a registered handler by name
10
12
  *
11
13
  * `inspect: true` (or `inspect: { ... }`) on `httpKoa({ ... })` mounts
12
14
  * these before per-route middleware so they don't sit behind auth gates.
13
15
  *
14
16
  * Production deployments should add an admin guard via
15
17
  * `inspect: { middleware: [requireAdmin] }`.
18
+ *
19
+ * Telemetry is now a sink — records flow to TelemetryReporter instances
20
+ * (file, OTLP, …) configured at the app level. The former
21
+ * `/_nwire/telemetry/recent` and `/_nwire/telemetry/stream` endpoints
22
+ * have been removed; use the reporter output directly.
16
23
  */
17
24
  function summarizeWire(wire) {
18
25
  const b = wire.binding;
@@ -65,29 +72,26 @@ class RingBuffer {
65
72
  }
66
73
  function attachStreams(opts) {
67
74
  const events = new RingBuffer(500);
68
- const telemetry = new RingBuffer(500);
69
75
  const listeners = new Set();
70
76
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
71
77
  const rt = opts.app?.runtime;
72
78
  if (typeof rt?.onTelemetry === "function") {
73
79
  rt.onTelemetry((rec) => {
74
- telemetry.push(rec);
75
80
  if (rec.kind === "event.published" || rec.kind === "event.deduped") {
76
81
  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.
82
+ for (const fn of listeners) {
83
+ try {
84
+ fn(rec);
85
+ }
86
+ catch {
87
+ // Listener crashed; drop without taking the runtime with it.
88
+ }
84
89
  }
85
90
  }
86
91
  });
87
92
  }
88
93
  return {
89
94
  events,
90
- telemetry,
91
95
  subscribe(fn) {
92
96
  listeners.add(fn);
93
97
  return () => listeners.delete(fn);
@@ -107,7 +111,7 @@ export function mountInspect(koa, opts) {
107
111
  const prefix = opts.config.prefix ?? "/_nwire";
108
112
  const chain = opts.config.middleware ?? [];
109
113
  const isProd = process.env.NODE_ENV === "production";
110
- // Eagerly attach the telemetry tap on the App's runtime so we don't
114
+ // Eagerly attach the event tap on the App's runtime so we don't
111
115
  // miss records emitted before the first /_nwire/* request.
112
116
  streamsFor(opts);
113
117
  // Production guardrail: `/dispatch` is remote-handler invocation. If the
@@ -187,18 +191,16 @@ async function dispatchInspect(kctx, opts) {
187
191
  });
188
192
  return;
189
193
  }
190
- if (kctx.method === "GET" && (sub === "/events/recent" || sub === "/telemetry/recent")) {
194
+ if (kctx.method === "GET" && sub === "/events/recent") {
191
195
  const streams = streamsFor(opts);
192
196
  const limit = Number(kctx.query.limit ?? "200") || 200;
193
- const buf = sub === "/events/recent" ? streams.events : streams.telemetry;
194
- const records = buf.recent(limit);
197
+ const records = streams.events.recent(limit);
195
198
  kctx.type = "application/json";
196
- kctx.body = JSON.stringify(sub === "/events/recent" ? records.map((rec, i) => toBufferedEvent(rec, i)) : records);
199
+ kctx.body = JSON.stringify(records.map((rec, i) => toBufferedEvent(rec, i)));
197
200
  return;
198
201
  }
199
- if (kctx.method === "GET" && (sub === "/events/stream" || sub === "/telemetry/stream")) {
202
+ if (kctx.method === "GET" && sub === "/events/stream") {
200
203
  const streams = streamsFor(opts);
201
- const onlyEvents = sub === "/events/stream";
202
204
  kctx.respond = false;
203
205
  const res = kctx.res;
204
206
  res.statusCode = 200;
@@ -206,18 +208,14 @@ async function dispatchInspect(kctx, opts) {
206
208
  res.setHeader("Cache-Control", "no-cache, no-transform");
207
209
  res.setHeader("Connection", "keep-alive");
208
210
  // Backfill from the ring buffer.
209
- const backfill = onlyEvents ? streams.events.recent(50) : streams.telemetry.recent(50);
211
+ const backfill = streams.events.recent(50);
210
212
  let seq = 0;
211
213
  for (const rec of backfill) {
212
- const payload = onlyEvents ? toBufferedEvent(rec, seq++) : rec;
213
- res.write(`data: ${JSON.stringify(payload)}\n\n`);
214
+ res.write(`data: ${JSON.stringify(toBufferedEvent(rec, seq++))}\n\n`);
214
215
  }
215
216
  const unsub = streams.subscribe((rec) => {
216
- if (onlyEvents && rec.kind !== "event.published" && rec.kind !== "event.deduped")
217
- return;
218
217
  try {
219
- const payload = onlyEvents ? toBufferedEvent(rec, seq++) : rec;
220
- res.write(`data: ${JSON.stringify(payload)}\n\n`);
218
+ res.write(`data: ${JSON.stringify(toBufferedEvent(rec, seq++))}\n\n`);
221
219
  }
222
220
  catch {
223
221
  // socket gone
@@ -231,6 +229,8 @@ async function dispatchInspect(kctx, opts) {
231
229
  // socket gone
232
230
  }
233
231
  }, 25_000);
232
+ // An idle SSE keepalive must never keep the process alive on its own.
233
+ keepalive.unref();
234
234
  kctx.req.on("close", () => {
235
235
  unsub();
236
236
  clearInterval(keepalive);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nwire/koa",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
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/container": "0.12.0",
37
- "@nwire/endpoint": "0.12.0",
38
- "@nwire/logger": "0.12.0",
39
- "@nwire/wires": "0.12.0"
36
+ "@nwire/endpoint": "0.13.0",
37
+ "@nwire/logger": "0.13.0",
38
+ "@nwire/container": "0.13.0",
39
+ "@nwire/wires": "0.13.0"
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.12.0",
51
- "@nwire/handler": "0.12.0"
50
+ "@nwire/app": "0.13.0",
51
+ "@nwire/handler": "0.13.0"
52
52
  },
53
53
  "peerDependencies": {
54
54
  "@asteasolutions/zod-to-openapi": "^8.0.0"