@nwire/koa 0.10.0 → 0.11.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/README.md +20 -24
- package/dist/http-koa.d.ts +27 -9
- package/dist/http-koa.js +110 -37
- package/dist/inspect.js +157 -3
- package/dist/openapi.js +1 -3
- package/package.json +7 -7
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
|
-
|
|
22
|
-
|
|
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,
|
|
34
|
+
port: 3000, // 0 = ephemeral; .port() returns the bound port after boot
|
|
36
35
|
host: "0.0.0.0",
|
|
37
|
-
prefix: "/api",
|
|
38
|
-
helmet: true,
|
|
39
|
-
cors: { origin: "*" },
|
|
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],
|
|
42
|
-
logger: myLogger,
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
93
|
-
|
|
|
94
|
-
| `{ $kind: "response-instance", status, body }`
|
|
95
|
-
| `{ $status, body }` (legacy envelope)
|
|
96
|
-
| anything else
|
|
97
|
-
| validation throws
|
|
98
|
-
| handler throws `defineError`-shaped
|
|
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
|
|
package/dist/http-koa.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
27
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
27
|
-
*
|
|
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;
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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.
|
|
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
|
-
|
|
149
|
-
|
|
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
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
//
|
|
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
|
|
277
|
+
tenant: resolveTenant(kctx),
|
|
222
278
|
userId: kctx.state.userId ?? undefined,
|
|
223
|
-
|
|
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
|
|
252
|
-
//
|
|
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
|
-
|
|
256
|
-
error: {
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
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()
|
|
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.
|
|
3
|
+
"version": "0.11.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/logger": "0.
|
|
39
|
-
"@nwire/
|
|
36
|
+
"@nwire/container": "0.11.0",
|
|
37
|
+
"@nwire/endpoint": "0.11.0",
|
|
38
|
+
"@nwire/logger": "0.11.0",
|
|
39
|
+
"@nwire/wires": "0.11.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/
|
|
51
|
-
"@nwire/
|
|
50
|
+
"@nwire/handler": "0.11.0",
|
|
51
|
+
"@nwire/app": "0.11.0"
|
|
52
52
|
},
|
|
53
53
|
"peerDependencies": {
|
|
54
54
|
"@asteasolutions/zod-to-openapi": "^8.0.0"
|