@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 +114 -12
- package/dist/inspect.d.ts +12 -5
- package/dist/inspect.js +29 -29
- package/package.json +7 -7
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
289
|
-
const runtimeExecute = wireApp?.runtime?.execute;
|
|
340
|
+
const runtime = wireApp?.runtime;
|
|
290
341
|
let result;
|
|
291
342
|
try {
|
|
292
|
-
if (
|
|
293
|
-
// Unified
|
|
294
|
-
//
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
|
|
343
|
+
if (runtime?.receive) {
|
|
344
|
+
// Unified inbound — the 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
|
|
6
|
-
* GET /_nwire/wires
|
|
7
|
-
* GET /_nwire/runtime
|
|
8
|
-
* GET /_nwire/
|
|
9
|
-
*
|
|
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
|
|
6
|
-
* GET /_nwire/wires
|
|
7
|
-
* GET /_nwire/runtime
|
|
8
|
-
* GET /_nwire/
|
|
9
|
-
*
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
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" &&
|
|
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
|
|
194
|
-
const records = buf.recent(limit);
|
|
197
|
+
const records = streams.events.recent(limit);
|
|
195
198
|
kctx.type = "application/json";
|
|
196
|
-
kctx.body = JSON.stringify(
|
|
199
|
+
kctx.body = JSON.stringify(records.map((rec, i) => toBufferedEvent(rec, i)));
|
|
197
200
|
return;
|
|
198
201
|
}
|
|
199
|
-
if (kctx.method === "GET" &&
|
|
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 =
|
|
211
|
+
const backfill = streams.events.recent(50);
|
|
210
212
|
let seq = 0;
|
|
211
213
|
for (const rec of backfill) {
|
|
212
|
-
|
|
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
|
-
|
|
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.
|
|
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/
|
|
37
|
-
"@nwire/
|
|
38
|
-
"@nwire/
|
|
39
|
-
"@nwire/wires": "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.
|
|
51
|
-
"@nwire/handler": "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"
|