@nwire/koa 0.12.1 → 0.13.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/dist/http-koa.js +80 -10
- package/dist/inspect.d.ts +12 -5
- package/dist/inspect.js +29 -29
- package/package.json +7 -7
package/dist/http-koa.js
CHANGED
|
@@ -63,6 +63,9 @@ function isHttpBinding(b) {
|
|
|
63
63
|
export function httpKoa(config = {}) {
|
|
64
64
|
let server;
|
|
65
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;
|
|
66
69
|
const logger = config.logger ?? new ConsoleLogger();
|
|
67
70
|
return {
|
|
68
71
|
$kind: "adapter",
|
|
@@ -76,6 +79,12 @@ export function httpKoa(config = {}) {
|
|
|
76
79
|
koa() {
|
|
77
80
|
return koaInstance;
|
|
78
81
|
},
|
|
82
|
+
handler() {
|
|
83
|
+
return koaInstance?.callback();
|
|
84
|
+
},
|
|
85
|
+
owns(pathname) {
|
|
86
|
+
return ownsPredicate?.(pathname) ?? false;
|
|
87
|
+
},
|
|
79
88
|
async boot(ctx) {
|
|
80
89
|
const koa = new Koa();
|
|
81
90
|
koaInstance = koa;
|
|
@@ -104,12 +113,23 @@ export function httpKoa(config = {}) {
|
|
|
104
113
|
kctx.body = { error: { code: "validation_failed", summary: zodSummary(err) } };
|
|
105
114
|
return;
|
|
106
115
|
}
|
|
107
|
-
|
|
116
|
+
const status = typeof e.status === "number" ? e.status : 500;
|
|
117
|
+
kctx.status = status;
|
|
108
118
|
if (isNwireError) {
|
|
109
119
|
kctx.body = {
|
|
110
120
|
error: { code: e.code ?? "internal_error", summary: e.summary ?? "Internal error" },
|
|
111
121
|
};
|
|
112
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
|
+
}
|
|
113
133
|
else {
|
|
114
134
|
logger.error?.(`[http-koa] unhandled error: ${e?.message ?? e}`, undefined);
|
|
115
135
|
kctx.body = {
|
|
@@ -317,17 +337,25 @@ export function httpKoa(config = {}) {
|
|
|
317
337
|
};
|
|
318
338
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
319
339
|
const wireApp = wire.app;
|
|
320
|
-
|
|
321
|
-
const runtimeExecute = wireApp?.runtime?.execute;
|
|
340
|
+
const runtime = wireApp?.runtime;
|
|
322
341
|
let result;
|
|
323
342
|
try {
|
|
324
|
-
if (
|
|
325
|
-
// Unified
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
|
|
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
|
+
});
|
|
331
359
|
}
|
|
332
360
|
else {
|
|
333
361
|
// No-runtime fallback — standalone interface without an App.
|
|
@@ -427,6 +455,43 @@ export function httpKoa(config = {}) {
|
|
|
427
455
|
}
|
|
428
456
|
}
|
|
429
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
|
+
};
|
|
430
495
|
// Listen if a port is available: adopter-level config.port wins,
|
|
431
496
|
// otherwise fall back to the endpoint-level port hint from boot ctx.
|
|
432
497
|
const listenPort = config.port ?? ctx.port;
|
|
@@ -444,6 +509,11 @@ export function httpKoa(config = {}) {
|
|
|
444
509
|
},
|
|
445
510
|
async shutdown() {
|
|
446
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?.();
|
|
447
517
|
await new Promise((resolve) => server.close(() => {
|
|
448
518
|
resolve();
|
|
449
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.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.
|
|
37
|
-
"@nwire/
|
|
38
|
-
"@nwire/
|
|
39
|
-
"@nwire/
|
|
36
|
+
"@nwire/endpoint": "0.13.1",
|
|
37
|
+
"@nwire/container": "0.13.1",
|
|
38
|
+
"@nwire/wires": "0.13.1",
|
|
39
|
+
"@nwire/logger": "0.13.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.
|
|
51
|
-
"@nwire/handler": "0.
|
|
50
|
+
"@nwire/app": "0.13.1",
|
|
51
|
+
"@nwire/handler": "0.13.1"
|
|
52
52
|
},
|
|
53
53
|
"peerDependencies": {
|
|
54
54
|
"@asteasolutions/zod-to-openapi": "^8.0.0"
|