@nwire/koa 0.10.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alex Gefter / 200apps Ltd.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,123 @@
1
+ # @nwire/koa
2
+
3
+ > The canonical HTTP adopter — a Koa server that consumes Nwire wires.
4
+
5
+ ```bash
6
+ pnpm add @nwire/koa @nwire/app @nwire/endpoint @nwire/wires
7
+ ```
8
+
9
+ ## Quick start
10
+
11
+ ```ts
12
+ import { createApp } from "@nwire/app";
13
+ import { endpoint } from "@nwire/endpoint";
14
+ import { post } from "@nwire/wires/http";
15
+ import { httpKoa } from "@nwire/koa";
16
+ import { z } from "zod";
17
+
18
+ const app = createApp({ appName: "api" });
19
+
20
+ app.wire(
21
+ post("/hello", { body: z.object({ name: z.string().min(1) }) }),
22
+ async (input) => ({ message: `Hello, ${input.name}!` }),
23
+ );
24
+
25
+ await endpoint("api", { port: 3000 })
26
+ .use(httpKoa({ prefix: "/api" }))
27
+ .mount(app)
28
+ .run();
29
+ ```
30
+
31
+ ## Config
32
+
33
+ ```ts
34
+ httpKoa({
35
+ port: 3000, // 0 = ephemeral; .port() returns the bound port after boot
36
+ 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
40
+ bodyParser: { jsonLimit: "1mb" },
41
+ middleware: [requireUser], // adopter-wide Koa middleware run before every wire
42
+ logger: myLogger, // defaults to ConsoleLogger
43
+ });
44
+ ```
45
+
46
+ ## Per-route middleware
47
+
48
+ Wires can carry route-scoped middleware. `httpKoa` runs them after the
49
+ adopter-wide chain and before the handler.
50
+
51
+ ```ts
52
+ import { post } from "@nwire/wires/http";
53
+
54
+ app.wire(
55
+ post("/admin/users", {
56
+ body: UserBody,
57
+ middleware: [requireRole("admin")],
58
+ }),
59
+ createUser,
60
+ );
61
+ ```
62
+
63
+ ## Handler shape
64
+
65
+ Two shapes are supported and route to the same dispatch:
66
+
67
+ ```ts
68
+ // 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
+ );
76
+
77
+ // Forge HandlerDefinition — routed through runtime.execute
78
+ const placeOrder = defineHandler(placeOrderAction, async (input, ctx) => {
79
+ /* ... */
80
+ });
81
+ app.wire(post("/order", { body: OrderBody }), placeOrder);
82
+ ```
83
+
84
+ The adopter detects forge handlers (`.run` + `runtime.execute` present)
85
+ and routes them through the runtime so `action.before/after` hooks,
86
+ retries, telemetry, and per-action middleware all fire. Plain handlers
87
+ take the direct path with a minimal ctx (`resolve`, `logger`, `koa`,
88
+ `envelope`).
89
+
90
+ ## Response shaping
91
+
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 |
99
+
100
+ ## Testing
101
+
102
+ `adapter.koa()` exposes the underlying Koa app for in-process testing
103
+ via `supertest` or `simulateRequest` from `@nwire/test-kit`:
104
+
105
+ ```ts
106
+ const koa = httpKoa({ port: 0 });
107
+ const running = await endpoint("test", { ... }).use(koa).mount(app).run();
108
+
109
+ const res = await simulateRequest(koa.koa(), {
110
+ method: "POST",
111
+ path: "/hello",
112
+ body: { name: "Alice" },
113
+ });
114
+ ```
115
+
116
+ `adapter.port()` returns the bound port (useful when `port: 0`).
117
+
118
+ ## Related
119
+
120
+ - [`@nwire/endpoint`](../core-endpoint) — the lifecycle host
121
+ - [`@nwire/wires`](../core-wires) — `get`/`post`/`put`/`patch`/`del`
122
+ binding factories
123
+ - [`@nwire/express`](../nwire-express) — the Express-flavored adopter
@@ -0,0 +1,116 @@
1
+ /**
2
+ * `@nwire/koa` — Koa-backed HTTP adopter.
3
+ *
4
+ * import { httpKoa } from "@nwire/koa";
5
+ *
6
+ * await endpoint("api", { port: 3000 })
7
+ * .use(httpKoa({ helmet: true, cors: true }))
8
+ * .mount(app)
9
+ * .run();
10
+ *
11
+ * The adopter consumes wires whose `binding.$adapter === "http"`. For
12
+ * each wire it mounts a Koa route on the configured verb + path, parses
13
+ * params / body / query against the binding's zod schemas, builds a
14
+ * minimal handler ctx (input + resolve + logger), and invokes the
15
+ * wire's handler. Response shaping is intentionally loose at this stage
16
+ * — the handler returns a value that becomes ctx.body; status defaults
17
+ * to 200; { $status, body } objects unwrap.
18
+ *
19
+ * 0.10 first-pass scope:
20
+ * - helmet + cors + body parser layered when configured
21
+ * - per-wire handler dispatch via Koa router
22
+ * - per-request scoped container (parent = wire's source app)
23
+ * - dispatched as Adapter so endpoint().use(httpKoa()).mount(app).run()
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
33
+ */
34
+ import type { Adapter } from "@nwire/endpoint";
35
+ import { type Logger } from "@nwire/logger";
36
+ import Koa from "koa";
37
+ import cors from "@koa/cors";
38
+ export interface HttpKoaConfig {
39
+ /** Port to listen on. Falls through to endpoint's config.port if undefined. */
40
+ readonly port?: number;
41
+ readonly host?: string;
42
+ /** URL prefix mounted on the router (e.g. "/api"). */
43
+ readonly prefix?: string;
44
+ /** Enable koa-helmet. Default true. */
45
+ readonly helmet?: boolean;
46
+ /** Enable @koa/cors. Default false. Pass an object to forward CORS opts. */
47
+ readonly cors?: boolean | Parameters<typeof cors>[0];
48
+ /** Body parser options forwarded to koa-bodyparser. */
49
+ readonly bodyParser?: any;
50
+ /**
51
+ * Adopter-wide Koa middleware — runs before every wire's handler.
52
+ * Use for authn / authz / request-id / logging concerns shared across
53
+ * every route the adopter serves.
54
+ */
55
+ readonly middleware?: readonly Koa.Middleware[];
56
+ readonly logger?: Logger;
57
+ /**
58
+ * OpenAPI 3.1 doc — three forms of input:
59
+ * - `auto: true` — generate the spec by walking the adopter's wires;
60
+ * needs `@asteasolutions/zod-to-openapi` installed as a peer dep
61
+ * - `spec` — pass a pre-built object (skip codegen entirely)
62
+ * - `generator` — lazy generation per request, e.g. for cache-busting
63
+ *
64
+ * The spec is served at `path` (default `/openapi.json`). `title`,
65
+ * `version`, `description`, `serverUrl`, `prefix` are forwarded to the
66
+ * auto-generator if used.
67
+ */
68
+ readonly openapi?: {
69
+ readonly auto?: boolean;
70
+ readonly spec?: object;
71
+ readonly generator?: () => object | Promise<object>;
72
+ readonly path?: string;
73
+ readonly title?: string;
74
+ readonly version?: string;
75
+ readonly description?: string;
76
+ readonly serverUrl?: string;
77
+ };
78
+ /**
79
+ * Scalar UI for the OpenAPI spec. When `enabled`, mounts an HTML page
80
+ * at `path` (default `/docs`) that loads Scalar from the JSDelivr CDN
81
+ * and points at the configured `openapi.path`.
82
+ */
83
+ readonly docs?: boolean | {
84
+ readonly path?: string;
85
+ readonly title?: string;
86
+ };
87
+ /**
88
+ * `/_nwire/*` introspection surface (manifest, wires, runtime, actors,
89
+ * dispatch). `true` mounts with defaults; pass an object for prefix or
90
+ * adopter-wide guards (`middleware: [requireAdmin]`).
91
+ */
92
+ readonly inspect?: boolean | import("./inspect.js").InspectConfig;
93
+ }
94
+ /** Minimal handler-ctx shape the adopter constructs per request. */
95
+ export interface HttpKoaHandlerCtx {
96
+ resolve<T = unknown>(name: string): T;
97
+ readonly logger: Logger;
98
+ /** The Koa context, for adapters that need raw request access. */
99
+ readonly koa: Koa.ParameterizedContext;
100
+ }
101
+ /**
102
+ * `httpKoa(config)` — build the HTTP adopter. Returns an `Adapter` the
103
+ * endpoint installs via `.use(...)`.
104
+ */
105
+ /**
106
+ * The adopter handle. Extends the base `Adapter` contract with a
107
+ * `port()` accessor so tests + tooling can discover the bound port
108
+ * when `config.port === 0`.
109
+ */
110
+ export interface HttpKoaAdapter extends Adapter {
111
+ port(): number | undefined;
112
+ /** Underlying Koa instance — present after boot. Tests can pass this to
113
+ * supertest(adapter.koa()!.callback()) for in-process request simulation. */
114
+ koa(): Koa | undefined;
115
+ }
116
+ export declare function httpKoa(config?: HttpKoaConfig): HttpKoaAdapter;
@@ -0,0 +1,350 @@
1
+ /**
2
+ * `@nwire/koa` — Koa-backed HTTP adopter.
3
+ *
4
+ * import { httpKoa } from "@nwire/koa";
5
+ *
6
+ * await endpoint("api", { port: 3000 })
7
+ * .use(httpKoa({ helmet: true, cors: true }))
8
+ * .mount(app)
9
+ * .run();
10
+ *
11
+ * The adopter consumes wires whose `binding.$adapter === "http"`. For
12
+ * each wire it mounts a Koa route on the configured verb + path, parses
13
+ * params / body / query against the binding's zod schemas, builds a
14
+ * minimal handler ctx (input + resolve + logger), and invokes the
15
+ * wire's handler. Response shaping is intentionally loose at this stage
16
+ * — the handler returns a value that becomes ctx.body; status defaults
17
+ * to 200; { $status, body } objects unwrap.
18
+ *
19
+ * 0.10 first-pass scope:
20
+ * - helmet + cors + body parser layered when configured
21
+ * - per-wire handler dispatch via Koa router
22
+ * - per-request scoped container (parent = wire's source app)
23
+ * - dispatched as Adapter so endpoint().use(httpKoa()).mount(app).run()
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
33
+ */
34
+ import http from "node:http";
35
+ import { dummyContainer } from "@nwire/container";
36
+ import { ConsoleLogger } from "@nwire/logger";
37
+ import Koa from "koa";
38
+ import bodyParser from "koa-bodyparser";
39
+ import cors from "@koa/cors";
40
+ import helmet from "koa-helmet";
41
+ import KoaRouter from "@koa/router";
42
+ function isHttpBinding(b) {
43
+ return (typeof b === "object" &&
44
+ b !== null &&
45
+ b.$adapter === "http" &&
46
+ typeof b.verb === "string" &&
47
+ typeof b.path === "string");
48
+ }
49
+ export function httpKoa(config = {}) {
50
+ let server;
51
+ let koaInstance;
52
+ const logger = config.logger ?? new ConsoleLogger();
53
+ return {
54
+ $kind: "adapter",
55
+ kind: "http",
56
+ port() {
57
+ if (!server)
58
+ return undefined;
59
+ const addr = server.address();
60
+ return typeof addr === "object" && addr !== null ? addr.port : undefined;
61
+ },
62
+ koa() {
63
+ return koaInstance;
64
+ },
65
+ async boot(ctx) {
66
+ const koa = new Koa();
67
+ koaInstance = koa;
68
+ // Top-level error mapper — catches throws from middleware and
69
+ // unmatched routes. Renders defineError-shaped values with their
70
+ // {code, status, summary} envelope; otherwise opaque 500.
71
+ koa.use(async (kctx, next) => {
72
+ try {
73
+ await next();
74
+ }
75
+ catch (err) {
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
+ };
84
+ }
85
+ });
86
+ if (config.helmet !== false) {
87
+ koa.use(helmet());
88
+ }
89
+ if (config.cors) {
90
+ koa.use(cors(typeof config.cors === "object" ? config.cors : undefined));
91
+ }
92
+ koa.use(bodyParser(config.bodyParser));
93
+ // OpenAPI doc + Scalar UI (mounted before per-route middleware so
94
+ // they don't sit behind auth gates).
95
+ const openapiPath = config.openapi?.path ?? "/openapi.json";
96
+ const docsConfig = config.docs === true ? {} : config.docs === false ? null : (config.docs ?? null);
97
+ const docsPath = docsConfig?.path ?? "/docs";
98
+ const docsTitle = docsConfig?.title ?? "API docs";
99
+ if (config.openapi?.spec || config.openapi?.generator || config.openapi?.auto) {
100
+ // Auto-generation walks the adopter's wires at mount time and
101
+ // caches the resulting doc. `spec` and `generator` short-circuit
102
+ // the build; pass either if you want full control of the doc.
103
+ let cachedAutoSpec;
104
+ const buildAuto = async () => {
105
+ if (cachedAutoSpec)
106
+ return cachedAutoSpec;
107
+ const { generateOpenApi } = await import("./openapi.js");
108
+ cachedAutoSpec = await generateOpenApi(ctx.wires, {
109
+ title: config.openapi.title,
110
+ version: config.openapi.version,
111
+ description: config.openapi.description,
112
+ serverUrl: config.openapi.serverUrl,
113
+ prefix: config.prefix,
114
+ });
115
+ return cachedAutoSpec;
116
+ };
117
+ koa.use(async (kctx, next) => {
118
+ if (kctx.method !== "GET" || kctx.path !== openapiPath)
119
+ return next();
120
+ const spec = config.openapi.spec ??
121
+ (config.openapi.generator ? await config.openapi.generator() : await buildAuto());
122
+ kctx.type = "application/json";
123
+ kctx.body = JSON.stringify(spec);
124
+ });
125
+ }
126
+ if (docsConfig !== null) {
127
+ koa.use(async (kctx, next) => {
128
+ if (kctx.method !== "GET" || kctx.path !== docsPath)
129
+ return next();
130
+ kctx.type = "text/html";
131
+ kctx.body = `<!doctype html>
132
+ <html>
133
+ <head>
134
+ <title>${docsTitle}</title>
135
+ <meta charset="utf-8" />
136
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
137
+ </head>
138
+ <body>
139
+ <script id="api-reference" data-url="${openapiPath}"></script>
140
+ <script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
141
+ </body>
142
+ </html>`;
143
+ });
144
+ }
145
+ // /_nwire/* introspection — mounted before adopter-wide auth so
146
+ // Studio can read manifest/wires/runtime without an admin token.
147
+ // Production consumers gate via inspect.middleware.
148
+ if (config.inspect) {
149
+ const inspectConfig = config.inspect === true ? {} : config.inspect;
150
+ const { mountInspect } = await import("./inspect.js");
151
+ // Find a representative source app — we mount inspect against the
152
+ // first wire's owning app; the wire collection is what we report.
153
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
154
+ const firstApp = ctx.wires[0]?.app;
155
+ mountInspect(koa, {
156
+ wires: ctx.wires,
157
+ app: firstApp,
158
+ adopterKind: "http",
159
+ config: inspectConfig,
160
+ });
161
+ }
162
+ // Adopter-wide middleware runs before every wired handler.
163
+ for (const mw of config.middleware ?? []) {
164
+ koa.use(mw);
165
+ }
166
+ const router = config.prefix
167
+ ? new KoaRouter({ prefix: config.prefix })
168
+ : new KoaRouter();
169
+ for (const wire of ctx.wires) {
170
+ if (!isHttpBinding(wire.binding))
171
+ continue;
172
+ const binding = wire.binding;
173
+ const verb = binding.verb;
174
+ const path = binding.path;
175
+ const handler = async (kctx) => {
176
+ // Parse + merge params / body / query into a flat input.
177
+ let input = {};
178
+ try {
179
+ if (binding.params) {
180
+ Object.assign(input, binding.params.parse(kctx.params));
181
+ }
182
+ if (binding.body) {
183
+ Object.assign(input, binding.body.parse(kctx.request.body));
184
+ }
185
+ if (binding.query) {
186
+ Object.assign(input, binding.query.parse(kctx.request.query));
187
+ }
188
+ if (!binding.params && !binding.body && !binding.query) {
189
+ // No schemas — pass merged raw values as input for hand-rolled
190
+ // resolvers. Adopters can still read kctx via handlerCtx.koa.
191
+ input = {
192
+ ...kctx.params,
193
+ ...kctx.request.body,
194
+ ...kctx.request.query,
195
+ };
196
+ }
197
+ }
198
+ catch (err) {
199
+ kctx.status = 400;
200
+ kctx.body = {
201
+ error: {
202
+ code: "validation_failed",
203
+ summary: err.message,
204
+ },
205
+ };
206
+ return;
207
+ }
208
+ // Per-request scope: child container of the source app's container.
209
+ const parentContainer = ctx.containerOf(wire) ?? dummyContainer();
210
+ const reqContainer = parentContainer.createScope();
211
+ const handlerCtx = {
212
+ resolve: (name) => reqContainer.resolve(name),
213
+ logger,
214
+ koa: kctx,
215
+ };
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.
220
+ const envelopePartial = {
221
+ tenant: kctx.request.headers["x-tenant"] ?? undefined,
222
+ userId: kctx.state.userId ?? undefined,
223
+ correlationId: kctx.request.headers["x-correlation-id"] ?? undefined,
224
+ causationId: kctx.request.headers["x-causation-id"] ?? undefined,
225
+ };
226
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
227
+ const wireApp = wire.app;
228
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
229
+ const runtimeExecute = wireApp?.runtime?.execute;
230
+ let result;
231
+ try {
232
+ if (runtimeExecute) {
233
+ // Unified dispatch — runtime.execute builds the canonical
234
+ // ctx (input + envelope + execute + emit + enqueue + resolve
235
+ // + scope) for both HandlerDefinition and plain `(input, ctx)`
236
+ // function shapes, threads transport extras (logger, koa)
237
+ // onto the same ctx, and runs the hook chain when present.
238
+ result = await runtimeExecute.call(wireApp.runtime, wire.handler, input, envelopePartial, { logger: handlerCtx.logger, koa: handlerCtx.koa });
239
+ }
240
+ else {
241
+ // No-runtime fallback — standalone interface without an App.
242
+ // Plain functions still get the unified ctx shape; lookups go
243
+ // through the per-request container directly.
244
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
245
+ const fn = wire.handler.run ?? wire.handler;
246
+ const fullCtx = { ...handlerCtx, envelope: envelopePartial };
247
+ result = await fn(input, fullCtx);
248
+ }
249
+ }
250
+ catch (err) {
251
+ // Generic error envelope; downstream phases hook in nwire-error
252
+ // mapping + sanitised production responses.
253
+ const e = err;
254
+ 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
+ };
261
+ return;
262
+ }
263
+ // Response shaping:
264
+ // - { $kind: "response-instance", status, body } — from defineResource
265
+ // - { $status, body } — legacy envelope
266
+ // - anything else — body verbatim, status 200
267
+ if (result &&
268
+ typeof result === "object" &&
269
+ result.$kind === "response-instance") {
270
+ const env = result;
271
+ // Koa rewrites status to 204 when body is set to null/undefined.
272
+ // For explicit 202/304/410-style "no body, but specific status"
273
+ // we set the empty body first then force the status afterward.
274
+ if (env.body === undefined || env.body === null) {
275
+ kctx.body = "";
276
+ kctx.status = env.status ?? 204;
277
+ kctx.length = 0;
278
+ }
279
+ else {
280
+ kctx.body = env.body;
281
+ kctx.status = env.status ?? 200;
282
+ }
283
+ }
284
+ else if (result &&
285
+ typeof result === "object" &&
286
+ "$status" in result) {
287
+ const env = result;
288
+ kctx.status = env.$status ?? 200;
289
+ kctx.body = env.body;
290
+ }
291
+ else if (result === undefined || result === null) {
292
+ kctx.status = 204;
293
+ kctx.body = undefined;
294
+ }
295
+ else {
296
+ kctx.status = kctx.status === 404 ? 200 : kctx.status;
297
+ kctx.body = result;
298
+ }
299
+ };
300
+ // Per-route middleware lands as a Koa middleware chain before
301
+ // the dispatch handler. The binding's `middleware` field is
302
+ // declared `unknown` at the @nwire/wires layer; we narrow
303
+ // here to Koa.Middleware shape and append.
304
+ const routeMw = (binding.middleware ??
305
+ []);
306
+ switch (verb) {
307
+ case "get":
308
+ router.get(path, ...routeMw, handler);
309
+ break;
310
+ case "post":
311
+ router.post(path, ...routeMw, handler);
312
+ break;
313
+ case "put":
314
+ router.put(path, ...routeMw, handler);
315
+ break;
316
+ case "patch":
317
+ router.patch(path, ...routeMw, handler);
318
+ break;
319
+ case "delete":
320
+ router.delete(path, ...routeMw, handler);
321
+ break;
322
+ }
323
+ }
324
+ koa.use(router.routes()).use(router.allowedMethods());
325
+ // Listen if a port is available: adopter-level config.port wins,
326
+ // otherwise fall back to the endpoint-level port hint from boot ctx.
327
+ const listenPort = config.port ?? ctx.port;
328
+ const listenHost = config.host ?? ctx.host ?? "0.0.0.0";
329
+ if (listenPort !== undefined) {
330
+ server = http.createServer(koa.callback());
331
+ await new Promise((resolve) => server.listen(listenPort, listenHost, resolve));
332
+ logger.info?.(`[http-koa] listening on ${listenHost}:${listenPort}`, undefined);
333
+ }
334
+ // Register a health check that surfaces "this adopter is alive."
335
+ ctx.addCheck({
336
+ name: "http-koa",
337
+ check: () => undefined,
338
+ });
339
+ },
340
+ async shutdown() {
341
+ if (server) {
342
+ await new Promise((resolve) => server.close(() => {
343
+ resolve();
344
+ }));
345
+ server = undefined;
346
+ }
347
+ koaInstance = undefined;
348
+ },
349
+ };
350
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * `/_nwire/*` — read + dispatch surface Studio (and MCP tooling) reads
3
+ * to render the live state of a running app.
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
10
+ *
11
+ * `inspect: true` (or `inspect: { ... }`) on `httpKoa({ ... })` mounts
12
+ * these before per-route middleware so they don't sit behind auth gates.
13
+ *
14
+ * Production deployments should add an admin guard via
15
+ * `inspect: { middleware: [requireAdmin] }`.
16
+ */
17
+ import type Koa from "koa";
18
+ import type { Wire } from "@nwire/wires";
19
+ export interface InspectConfig {
20
+ readonly enabled?: boolean;
21
+ readonly prefix?: string;
22
+ /** Run before every /_nwire/* handler — for auth/audit. */
23
+ readonly middleware?: readonly Koa.Middleware[];
24
+ }
25
+ export interface MountInspectOptions {
26
+ readonly wires: ReadonlyArray<Wire>;
27
+ readonly app?: any;
28
+ readonly adopterKind: string;
29
+ readonly config: InspectConfig;
30
+ }
31
+ export declare function mountInspect(koa: Koa, opts: MountInspectOptions): void;
@@ -0,0 +1,142 @@
1
+ /**
2
+ * `/_nwire/*` — read + dispatch surface Studio (and MCP tooling) reads
3
+ * to render the live state of a running app.
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
10
+ *
11
+ * `inspect: true` (or `inspect: { ... }`) on `httpKoa({ ... })` mounts
12
+ * these before per-route middleware so they don't sit behind auth gates.
13
+ *
14
+ * Production deployments should add an admin guard via
15
+ * `inspect: { middleware: [requireAdmin] }`.
16
+ */
17
+ function summarizeWire(wire) {
18
+ const b = wire.binding;
19
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
20
+ const h = wire.handler;
21
+ return {
22
+ adapter: b.$adapter ?? "unknown",
23
+ kind: b.kind ?? "unknown",
24
+ verb: b.verb,
25
+ path: b.path,
26
+ tool: b.tool,
27
+ queue: b.queue,
28
+ cron: b.cron,
29
+ handlerName: h?.name ?? h?.action?.name,
30
+ source: b.source,
31
+ };
32
+ }
33
+ export function mountInspect(koa, opts) {
34
+ const prefix = opts.config.prefix ?? "/_nwire";
35
+ const handle = async (kctx, next) => {
36
+ if (!kctx.path.startsWith(prefix + "/"))
37
+ return next();
38
+ // Adopter-wide middleware first (if any). We model these as a chain;
39
+ // Koa doesn't expose runtime chain composition for arbitrary inputs,
40
+ // so we fold them with a manual chain.
41
+ let i = 0;
42
+ const chain = opts.config.middleware ?? [];
43
+ const runNext = async () => {
44
+ const mw = chain[i++];
45
+ if (mw)
46
+ return mw(kctx, runNext);
47
+ await dispatchInspect(kctx, opts);
48
+ };
49
+ await runNext();
50
+ };
51
+ koa.use(handle);
52
+ }
53
+ async function dispatchInspect(kctx, opts) {
54
+ const prefix = opts.config.prefix ?? "/_nwire";
55
+ const sub = kctx.path.slice(prefix.length); // "/manifest", "/wires", …
56
+ const wires = opts.wires.map(summarizeWire);
57
+ if (kctx.method === "GET" && sub === "/manifest") {
58
+ kctx.type = "application/json";
59
+ kctx.body = JSON.stringify({
60
+ appName: opts.app?.appName ?? null,
61
+ adopter: opts.adopterKind,
62
+ wireCount: opts.wires.length,
63
+ generatedAt: new Date().toISOString(),
64
+ });
65
+ return;
66
+ }
67
+ if (kctx.method === "GET" && sub === "/wires") {
68
+ kctx.type = "application/json";
69
+ kctx.body = JSON.stringify({ wires });
70
+ return;
71
+ }
72
+ if (kctx.method === "GET" && sub === "/runtime") {
73
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
74
+ const rt = opts.app?.runtime;
75
+ kctx.type = "application/json";
76
+ kctx.body = JSON.stringify({
77
+ appName: opts.app?.appName ?? null,
78
+ hasRuntime: typeof rt === "object" && rt !== null,
79
+ lifecycle: rt?.lifecycle ?? "unknown",
80
+ handlers: typeof rt?.listHandlers === "function"
81
+ ? rt.listHandlers().map((h) => typeof h === "string" ? h : (h?.name ?? null)).filter(Boolean)
82
+ : [],
83
+ });
84
+ return;
85
+ }
86
+ if (kctx.method === "GET" && sub.startsWith("/actors/")) {
87
+ // Per-type actor listing requires an ActorStore binding. We try to
88
+ // resolve it from the container; if absent, return 501.
89
+ const actorType = sub.slice("/actors/".length);
90
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
91
+ const container = opts.app?.container;
92
+ let store;
93
+ try {
94
+ store = container?.resolve("actor.store");
95
+ }
96
+ catch {
97
+ /* not registered */
98
+ }
99
+ if (!store?.list) {
100
+ kctx.status = 501;
101
+ kctx.type = "application/json";
102
+ kctx.body = JSON.stringify({ error: "no actor.store on container" });
103
+ return;
104
+ }
105
+ const items = await store.list(actorType);
106
+ kctx.type = "application/json";
107
+ kctx.body = JSON.stringify({ actorType, items });
108
+ return;
109
+ }
110
+ if (kctx.method === "POST" && sub === "/dispatch") {
111
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
112
+ const body = kctx.request.body ?? {};
113
+ const handlerName = body.handler;
114
+ const input = body.input ?? {};
115
+ if (!handlerName) {
116
+ kctx.status = 400;
117
+ kctx.body = { error: { code: "missing_handler" } };
118
+ return;
119
+ }
120
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
121
+ const rt = opts.app?.runtime;
122
+ if (!rt?.execute || typeof rt.getHandler !== "function") {
123
+ kctx.status = 501;
124
+ kctx.body = { error: { code: "no_runtime" } };
125
+ return;
126
+ }
127
+ try {
128
+ const handler = rt.getHandler(handlerName);
129
+ const result = await rt.execute(handler, input);
130
+ kctx.type = "application/json";
131
+ kctx.body = JSON.stringify({ result });
132
+ }
133
+ catch (err) {
134
+ const e = err;
135
+ kctx.status = typeof e.status === "number" ? e.status : 500;
136
+ kctx.body = { error: { code: e.code ?? "internal_error", summary: e.message ?? "" } };
137
+ }
138
+ return;
139
+ }
140
+ kctx.status = 404;
141
+ kctx.body = { error: { code: "unknown_inspect_route", path: kctx.path } };
142
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Generate an OpenAPI 3.1 document from a Nwire App's wires.
3
+ *
4
+ * Walks `app.interface.wires`, picks the HTTP bindings, and emits a path
5
+ * entry per `(verb, path)` populated from the binding's `openapi`
6
+ * metadata + zod schemas via `@asteasolutions/zod-to-openapi`. The output
7
+ * is JSON-stringifiable and can be served at `/openapi.json` directly.
8
+ *
9
+ * Optional dependency: `@asteasolutions/zod-to-openapi` is a peer dep.
10
+ * Consumers that don't need OpenAPI don't pay the install cost; consumers
11
+ * who do install it once and call `httpKoa({ openapi: { auto: true } })`.
12
+ */
13
+ import type { Wire } from "@nwire/wires";
14
+ export interface GenerateOpenApiOptions {
15
+ readonly title?: string;
16
+ readonly version?: string;
17
+ readonly description?: string;
18
+ readonly serverUrl?: string;
19
+ /** Prefix the spec paths with this. Match the httpKoa prefix. */
20
+ readonly prefix?: string;
21
+ }
22
+ /**
23
+ * Build an OpenAPI 3.1 document from a wire collection.
24
+ *
25
+ * Requires `@asteasolutions/zod-to-openapi` to be installed. Throws a
26
+ * clear error otherwise; install it on the consumer side.
27
+ */
28
+ export declare function generateOpenApi(wires: ReadonlyArray<Wire>, options?: GenerateOpenApiOptions): Promise<object>;
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Generate an OpenAPI 3.1 document from a Nwire App's wires.
3
+ *
4
+ * Walks `app.interface.wires`, picks the HTTP bindings, and emits a path
5
+ * entry per `(verb, path)` populated from the binding's `openapi`
6
+ * metadata + zod schemas via `@asteasolutions/zod-to-openapi`. The output
7
+ * is JSON-stringifiable and can be served at `/openapi.json` directly.
8
+ *
9
+ * Optional dependency: `@asteasolutions/zod-to-openapi` is a peer dep.
10
+ * Consumers that don't need OpenAPI don't pay the install cost; consumers
11
+ * who do install it once and call `httpKoa({ openapi: { auto: true } })`.
12
+ */
13
+ function isOpenApiBinding(b) {
14
+ return (typeof b === "object" &&
15
+ b !== null &&
16
+ b.$adapter === "http" &&
17
+ typeof b.verb === "string" &&
18
+ typeof b.path === "string");
19
+ }
20
+ // Convert Koa-style `/users/:id` to OpenAPI `{id}` notation.
21
+ function toOpenApiPath(p) {
22
+ return p.replace(/:(\w+)/g, "{$1}");
23
+ }
24
+ /**
25
+ * Build an OpenAPI 3.1 document from a wire collection.
26
+ *
27
+ * Requires `@asteasolutions/zod-to-openapi` to be installed. Throws a
28
+ * clear error otherwise; install it on the consumer side.
29
+ */
30
+ export async function generateOpenApi(wires, options = {}) {
31
+ let zodToOpenapi;
32
+ try {
33
+ zodToOpenapi = await import("@asteasolutions/zod-to-openapi");
34
+ }
35
+ catch {
36
+ throw new Error("@nwire/koa: generateOpenApi() needs `@asteasolutions/zod-to-openapi` — `pnpm add @asteasolutions/zod-to-openapi` then retry.");
37
+ }
38
+ const { OpenAPIRegistry, OpenApiGeneratorV31, extendZodWithOpenApi } = zodToOpenapi;
39
+ const z = await import("zod");
40
+ extendZodWithOpenApi(z.z ?? z);
41
+ const registry = new OpenAPIRegistry();
42
+ const prefix = options.prefix ?? "";
43
+ for (const wire of wires) {
44
+ if (!isOpenApiBinding(wire.binding))
45
+ continue;
46
+ const b = wire.binding;
47
+ if (!b.openapi)
48
+ continue;
49
+ const path = prefix + toOpenApiPath(b.path);
50
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
51
+ const responses = {
52
+ "200": { description: "Success" },
53
+ };
54
+ // Returns from the binding override the default 200 envelope.
55
+ if (b.openapi.returns?.length) {
56
+ const first = b.openapi.returns[0];
57
+ const status = String(first?.status ?? 200);
58
+ const schema = first?.resource?.schema;
59
+ responses[status] = schema
60
+ ? {
61
+ description: "Success",
62
+ content: { "application/json": { schema } },
63
+ }
64
+ : { description: "Success" };
65
+ }
66
+ if (b.openapi.errors?.length) {
67
+ for (const e of b.openapi.errors) {
68
+ if (typeof e.status === "number") {
69
+ responses[String(e.status)] = {
70
+ description: e.code ?? "Error",
71
+ };
72
+ }
73
+ }
74
+ }
75
+ registry.registerPath({
76
+ method: b.verb,
77
+ path,
78
+ operationId: b.openapi.operation,
79
+ tags: b.openapi.tags ? [...b.openapi.tags] : undefined,
80
+ summary: b.openapi.summary,
81
+ description: b.openapi.description,
82
+ request: {
83
+ ...(b.params ? { params: b.params } : {}),
84
+ ...(b.query ? { query: b.query } : {}),
85
+ ...(b.body
86
+ ? { body: { content: { "application/json": { schema: b.body } } } }
87
+ : {}),
88
+ },
89
+ responses,
90
+ });
91
+ }
92
+ const generator = new OpenApiGeneratorV31(registry.definitions);
93
+ return generator.generateDocument({
94
+ openapi: "3.1.0",
95
+ info: {
96
+ title: options.title ?? "API",
97
+ version: options.version ?? "1.0.0",
98
+ description: options.description,
99
+ },
100
+ servers: options.serverUrl ? [{ url: options.serverUrl }] : undefined,
101
+ });
102
+ }
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "@nwire/koa",
3
+ "version": "0.10.0",
4
+ "description": "Nwire — Koa-backed HTTP adopter. Consumes wires from @nwire/wires/http and serves them as Koa routes under endpoint lifecycle.",
5
+ "keywords": [
6
+ "adopter",
7
+ "http",
8
+ "koa",
9
+ "nwire"
10
+ ],
11
+ "license": "MIT",
12
+ "files": [
13
+ "dist",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "type": "module",
18
+ "main": "./dist/http-koa.js",
19
+ "types": "./dist/http-koa.d.ts",
20
+ "exports": {
21
+ ".": {
22
+ "import": "./dist/http-koa.js",
23
+ "types": "./dist/http-koa.d.ts"
24
+ }
25
+ },
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "dependencies": {
30
+ "@koa/cors": "^5.0.0",
31
+ "@koa/router": "^13.1.0",
32
+ "koa": "^2.16.1",
33
+ "koa-bodyparser": "^4.4.1",
34
+ "koa-helmet": "^8.0.1",
35
+ "zod": "^4.0.0",
36
+ "@nwire/endpoint": "0.10.0",
37
+ "@nwire/wires": "0.10.0",
38
+ "@nwire/logger": "0.10.0",
39
+ "@nwire/container": "0.10.0"
40
+ },
41
+ "devDependencies": {
42
+ "@asteasolutions/zod-to-openapi": "^8.0.0",
43
+ "@types/koa": "^2.15.0",
44
+ "@types/koa-bodyparser": "^4.3.13",
45
+ "@types/koa__cors": "^5.0.0",
46
+ "@types/koa__router": "^12.0.5",
47
+ "@types/node": "^22.19.9",
48
+ "typescript": "^5.9.3",
49
+ "vitest": "^4.0.18",
50
+ "@nwire/app": "0.10.0",
51
+ "@nwire/handler": "0.10.0"
52
+ },
53
+ "peerDependencies": {
54
+ "@asteasolutions/zod-to-openapi": "^8.0.0"
55
+ },
56
+ "peerDependenciesMeta": {
57
+ "@asteasolutions/zod-to-openapi": {
58
+ "optional": true
59
+ }
60
+ },
61
+ "scripts": {
62
+ "build": "tsc && node ../../scripts/fix-dist-extensions.mjs dist",
63
+ "dev": "tsc --watch",
64
+ "typecheck": "tsc --noEmit"
65
+ }
66
+ }