@nwire/express 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,84 @@
1
+ # @nwire/express
2
+
3
+ > Express-backed HTTP adopter — same wires as `@nwire/koa`, different runtime.
4
+
5
+ ```bash
6
+ pnpm add @nwire/express @nwire/app @nwire/endpoint @nwire/wires
7
+ ```
8
+
9
+ `express@^4 || ^5` is a peer dep.
10
+
11
+ ## Two integration modes
12
+
13
+ ### 1. Drive Nwire as the HTTP server (Adapter mode)
14
+
15
+ ```ts
16
+ import { createApp } from "@nwire/app";
17
+ import { endpoint } from "@nwire/endpoint";
18
+ import { post } from "@nwire/wires/http";
19
+ import { expressAdapter } from "@nwire/express";
20
+ import { z } from "zod";
21
+
22
+ const app = createApp({ appName: "api" });
23
+ app.wire(
24
+ post("/hello", { body: z.object({ name: z.string() }) }),
25
+ async (input) => ({ message: `Hello, ${input.name}!` }),
26
+ );
27
+
28
+ await endpoint("api", { port: 3000 })
29
+ .use(expressAdapter({ prefix: "/api" }))
30
+ .mount(app)
31
+ .run();
32
+ ```
33
+
34
+ ### 2. Mount Nwire wires inside an existing Express app
35
+
36
+ When you already run Express (or NestJS, which uses Express under the
37
+ hood), `nwireToExpressRouter` extracts wired routes as an Express Router
38
+ you mount alongside legacy handlers. Migration without rewrite.
39
+
40
+ ```ts
41
+ import express from "express";
42
+ import { createApp } from "@nwire/app";
43
+ import { nwireToExpressRouter } from "@nwire/express";
44
+
45
+ const legacy = express();
46
+ legacy.get("/legacy", legacyHandler);
47
+
48
+ const nwireApp = createApp({ appName: "orders" });
49
+ nwireApp.wire(/* ... */);
50
+ await nwireApp.start();
51
+
52
+ legacy.use("/api/nwire", nwireToExpressRouter(nwireApp));
53
+ legacy.listen(3000);
54
+ ```
55
+
56
+ The router handles its own JSON body-parsing (`bodyParser: false` opts
57
+ out) so it works whether Express has body-parser globally or not. See
58
+ [`examples/nest-interop`](../../examples/nest-interop) for the full
59
+ Nwire-inside-NestJS pattern.
60
+
61
+ ## Adopter config
62
+
63
+ ```ts
64
+ expressAdapter({
65
+ port: 3000, // 0 = ephemeral; .port() returns the bound port
66
+ host: "0.0.0.0",
67
+ prefix: "/api",
68
+ middleware: [helmet(), morgan("combined")],
69
+ logger: myLogger,
70
+ });
71
+ ```
72
+
73
+ ## Bridging middleware
74
+
75
+ `fromExpressMiddleware(mw)` wraps an Express middleware so it can be
76
+ passed in `httpKoa({ middleware: [...] })` — useful when an Express-only
77
+ middleware (e.g., a vendored auth check) needs to run inside a Koa-backed
78
+ adopter.
79
+
80
+ ## Related
81
+
82
+ - [`@nwire/koa`](../nwire-koa) — Koa-backed sibling adopter
83
+ - [`@nwire/endpoint`](../core-endpoint) — the lifecycle host
84
+ - [`examples/nest-interop`](../../examples/nest-interop) — Nwire inside NestJS
@@ -0,0 +1,73 @@
1
+ /**
2
+ * `@nwire/express` — Express-backed HTTP adopter.
3
+ *
4
+ * import { expressAdapter } from "@nwire/express";
5
+ *
6
+ * await endpoint("api", { port: 3000 })
7
+ * .use(expressAdapter({ prefix: "/api" }))
8
+ * .mount(app)
9
+ * .run();
10
+ *
11
+ * Consumes wires whose `binding.$adapter === "http"` — same contract as
12
+ * `httpKoa`. Pick one or the other based on which framework your team
13
+ * lives in; both run the same wires identically.
14
+ *
15
+ * Also exports `fromExpressMiddleware(mw)` — wraps an Express middleware
16
+ * function so it can be passed as `httpKoa({ middleware: [...] })`. Useful
17
+ * when adopting Nwire inside an existing Express monorepo where stock
18
+ * middleware (helmet, morgan, passport) is already in place.
19
+ */
20
+ import express, { type Application, type NextFunction, type Request, type Response } from "express";
21
+ import type { Adapter } from "@nwire/endpoint";
22
+ import type { Container } from "@nwire/container";
23
+ import type { Wire } from "@nwire/wires";
24
+ import { type Logger } from "@nwire/logger";
25
+ export interface ExpressAdapterConfig {
26
+ /** Bound port. 0 = ephemeral. Default 3000. */
27
+ readonly port?: number;
28
+ /** Host bind address. Default 0.0.0.0. */
29
+ readonly host?: string;
30
+ /** Route prefix mounted under (e.g. "/api"). Default "/". */
31
+ readonly prefix?: string;
32
+ /** Adopter-wide Express middleware run before every wired handler. */
33
+ readonly middleware?: ReadonlyArray<(req: Request, res: Response, next: NextFunction) => void>;
34
+ /** Logger. Defaults to ConsoleLogger. */
35
+ readonly logger?: Logger;
36
+ }
37
+ export interface ExpressAdapter extends Adapter {
38
+ port(): number | undefined;
39
+ /** Underlying Express app — present after boot. Tests can pass this to
40
+ * supertest(adapter.app()!) for in-process request simulation. */
41
+ app(): Application | undefined;
42
+ }
43
+ export declare function expressAdapter(config?: ExpressAdapterConfig): ExpressAdapter;
44
+ /**
45
+ * Build an Express Router from a Nwire App's wires. Mount it on an
46
+ * existing Express server to add Nwire routes alongside legacy handlers:
47
+ *
48
+ * const nwireApp = buildNwireApp();
49
+ * await nwireApp.start();
50
+ * const router = nwireToExpressRouter(nwireApp);
51
+ * expressApp.use("/api/nwire", router);
52
+ *
53
+ * The router uses the same dispatch path as `expressAdapter` — same
54
+ * validation, error envelope, container scoping — but doesn't boot its
55
+ * own HTTP server.
56
+ */
57
+ export declare function nwireToExpressRouter(app: {
58
+ interface: {
59
+ wires: ReadonlyArray<Wire>;
60
+ };
61
+ container: Container;
62
+ }, options?: {
63
+ logger?: Logger;
64
+ bodyParser?: boolean;
65
+ }): express.Router;
66
+ /**
67
+ * Wrap an Express middleware so it can be passed to `httpKoa({ middleware: [...] })`.
68
+ * Bridges the `(req, res, next)` Express contract into Koa's async chain.
69
+ */
70
+ export declare function fromExpressMiddleware(mw: (req: Request, res: Response, next: NextFunction) => void): (kctx: {
71
+ req: unknown;
72
+ res: unknown;
73
+ }, next: () => Promise<unknown>) => Promise<void>;
@@ -0,0 +1,336 @@
1
+ /**
2
+ * `@nwire/express` — Express-backed HTTP adopter.
3
+ *
4
+ * import { expressAdapter } from "@nwire/express";
5
+ *
6
+ * await endpoint("api", { port: 3000 })
7
+ * .use(expressAdapter({ prefix: "/api" }))
8
+ * .mount(app)
9
+ * .run();
10
+ *
11
+ * Consumes wires whose `binding.$adapter === "http"` — same contract as
12
+ * `httpKoa`. Pick one or the other based on which framework your team
13
+ * lives in; both run the same wires identically.
14
+ *
15
+ * Also exports `fromExpressMiddleware(mw)` — wraps an Express middleware
16
+ * function so it can be passed as `httpKoa({ middleware: [...] })`. Useful
17
+ * when adopting Nwire inside an existing Express monorepo where stock
18
+ * middleware (helmet, morgan, passport) is already in place.
19
+ */
20
+ import http from "node:http";
21
+ import express from "express";
22
+ import { dummyContainer } from "@nwire/container";
23
+ import { ConsoleLogger } from "@nwire/logger";
24
+ function isHttpBinding(b) {
25
+ return (typeof b === "object" &&
26
+ b !== null &&
27
+ b.$adapter === "http" &&
28
+ typeof b.verb === "string" &&
29
+ typeof b.path === "string");
30
+ }
31
+ export function expressAdapter(config = {}) {
32
+ let server;
33
+ let appInstance;
34
+ const logger = config.logger ?? new ConsoleLogger();
35
+ return {
36
+ $kind: "adapter",
37
+ kind: "http",
38
+ port() {
39
+ if (!server)
40
+ return undefined;
41
+ const addr = server.address();
42
+ return typeof addr === "object" && addr !== null ? addr.port : undefined;
43
+ },
44
+ app() {
45
+ return appInstance;
46
+ },
47
+ async boot(ctx) {
48
+ const expressApp = express();
49
+ appInstance = expressApp;
50
+ expressApp.use(express.json());
51
+ for (const mw of config.middleware ?? []) {
52
+ expressApp.use(mw);
53
+ }
54
+ const prefix = config.prefix ?? "";
55
+ for (const wire of ctx.wires) {
56
+ if (!isHttpBinding(wire.binding))
57
+ continue;
58
+ const binding = wire.binding;
59
+ const verb = binding.verb;
60
+ const path = prefix + binding.path;
61
+ const handler = async (req, res) => {
62
+ let input = {};
63
+ try {
64
+ if (binding.params) {
65
+ Object.assign(input, binding.params.parse(req.params));
66
+ }
67
+ if (binding.body) {
68
+ Object.assign(input, binding.body.parse(req.body));
69
+ }
70
+ if (binding.query) {
71
+ Object.assign(input, binding.query.parse(req.query));
72
+ }
73
+ if (!binding.params && !binding.body && !binding.query) {
74
+ input = {
75
+ ...req.params,
76
+ ...req.body,
77
+ ...req.query,
78
+ };
79
+ }
80
+ }
81
+ catch (err) {
82
+ res.status(400).json({
83
+ error: { code: "validation_failed", summary: err.message },
84
+ });
85
+ return;
86
+ }
87
+ const parentContainer = ctx.containerOf(wire) ?? dummyContainer();
88
+ const reqContainer = parentContainer.createScope();
89
+ const envelopePartial = {
90
+ tenant: req.headers["x-tenant"] ?? undefined,
91
+ userId: req.user?.id ?? undefined,
92
+ correlationId: req.headers["x-correlation-id"] ?? undefined,
93
+ causationId: req.headers["x-causation-id"] ?? undefined,
94
+ };
95
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
96
+ const wireApp = wire.app;
97
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
98
+ const runtimeExecute = wireApp?.runtime?.execute;
99
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
100
+ const hasRunMethod = typeof wire.handler?.run === "function";
101
+ let result;
102
+ try {
103
+ if (runtimeExecute && hasRunMethod) {
104
+ result = await runtimeExecute.call(wireApp.runtime, wire.handler, input, envelopePartial);
105
+ }
106
+ else {
107
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
108
+ const fn = wire.handler.run ?? wire.handler;
109
+ const handlerCtx = {
110
+ resolve: (name) => reqContainer.resolve(name),
111
+ logger,
112
+ req,
113
+ res,
114
+ envelope: envelopePartial,
115
+ };
116
+ result = await fn(input, handlerCtx);
117
+ }
118
+ }
119
+ catch (err) {
120
+ const e = err;
121
+ res.status(typeof e.status === "number" ? e.status : 500).json({
122
+ error: {
123
+ code: e.code ?? "internal_error",
124
+ summary: e.summary ?? e.message ?? "Internal error",
125
+ },
126
+ });
127
+ return;
128
+ }
129
+ // Response shaping: response-instance | $status envelope | verbatim.
130
+ if (result &&
131
+ typeof result === "object" &&
132
+ result.$kind === "response-instance") {
133
+ const env = result;
134
+ res.status(env.status ?? 200).json(env.body);
135
+ }
136
+ else if (result &&
137
+ typeof result === "object" &&
138
+ "$status" in result) {
139
+ const env = result;
140
+ res.status(env.$status ?? 200).json(env.body);
141
+ }
142
+ else if (result === undefined) {
143
+ res.status(204).end();
144
+ }
145
+ else {
146
+ res.status(200).json(result);
147
+ }
148
+ };
149
+ switch (verb) {
150
+ case "get":
151
+ expressApp.get(path, handler);
152
+ break;
153
+ case "post":
154
+ expressApp.post(path, handler);
155
+ break;
156
+ case "put":
157
+ expressApp.put(path, handler);
158
+ break;
159
+ case "patch":
160
+ expressApp.patch(path, handler);
161
+ break;
162
+ case "delete":
163
+ expressApp.delete(path, handler);
164
+ break;
165
+ }
166
+ }
167
+ // Top-level error handler — catches throws from middleware.
168
+ expressApp.use((err, _req, res, _next) => {
169
+ const e = err;
170
+ res.status(typeof e.status === "number" ? e.status : 500).json({
171
+ error: {
172
+ code: e.code ?? "internal_error",
173
+ summary: e.summary ?? e.message ?? "Internal error",
174
+ },
175
+ });
176
+ });
177
+ server = http.createServer(expressApp);
178
+ await new Promise((resolve, reject) => {
179
+ server.once("error", reject);
180
+ server.listen(config.port ?? 3000, config.host ?? "0.0.0.0", () => {
181
+ server.off("error", reject);
182
+ resolve();
183
+ });
184
+ });
185
+ logger.info(`[express] listening on ${config.host ?? "0.0.0.0"}:${server.address().port}`);
186
+ ctx.addCheck({ name: "express", check: () => undefined });
187
+ },
188
+ async shutdown() {
189
+ if (server) {
190
+ await new Promise((resolve) => server.close(() => {
191
+ resolve();
192
+ }));
193
+ server = undefined;
194
+ }
195
+ appInstance = undefined;
196
+ },
197
+ };
198
+ }
199
+ // ─── Foreign integration: expose Nwire as Express middleware ──────
200
+ /**
201
+ * Build an Express Router from a Nwire App's wires. Mount it on an
202
+ * existing Express server to add Nwire routes alongside legacy handlers:
203
+ *
204
+ * const nwireApp = buildNwireApp();
205
+ * await nwireApp.start();
206
+ * const router = nwireToExpressRouter(nwireApp);
207
+ * expressApp.use("/api/nwire", router);
208
+ *
209
+ * The router uses the same dispatch path as `expressAdapter` — same
210
+ * validation, error envelope, container scoping — but doesn't boot its
211
+ * own HTTP server.
212
+ */
213
+ export function nwireToExpressRouter(app, options = {}) {
214
+ const router = express.Router();
215
+ const logger = options.logger ?? new ConsoleLogger();
216
+ if (options.bodyParser !== false) {
217
+ router.use(express.json());
218
+ }
219
+ for (const wire of app.interface.wires) {
220
+ if (!isHttpBinding(wire.binding))
221
+ continue;
222
+ const binding = wire.binding;
223
+ const handler = async (req, res) => {
224
+ let input = {};
225
+ try {
226
+ if (binding.params)
227
+ Object.assign(input, binding.params.parse(req.params));
228
+ if (binding.body)
229
+ Object.assign(input, binding.body.parse(req.body));
230
+ if (binding.query)
231
+ Object.assign(input, binding.query.parse(req.query));
232
+ if (!binding.params && !binding.body && !binding.query) {
233
+ input = {
234
+ ...req.params,
235
+ ...req.body,
236
+ ...req.query,
237
+ };
238
+ }
239
+ }
240
+ catch (err) {
241
+ res.status(400).json({
242
+ error: { code: "validation_failed", summary: err.message },
243
+ });
244
+ return;
245
+ }
246
+ const reqContainer = app.container.createScope();
247
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
248
+ const wireApp = wire.app ?? app;
249
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
250
+ const runtimeExecute = wireApp?.runtime?.execute;
251
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
252
+ const hasRunMethod = typeof wire.handler?.run === "function";
253
+ try {
254
+ let result;
255
+ if (runtimeExecute && hasRunMethod) {
256
+ result = await runtimeExecute.call(wireApp.runtime, wire.handler, input, {});
257
+ }
258
+ else {
259
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
260
+ const fn = wire.handler.run ?? wire.handler;
261
+ const ctx = {
262
+ resolve: (name) => reqContainer.resolve(name),
263
+ logger,
264
+ req,
265
+ res,
266
+ envelope: {},
267
+ };
268
+ result = await fn(input, ctx);
269
+ }
270
+ if (result &&
271
+ typeof result === "object" &&
272
+ result.$kind === "response-instance") {
273
+ const env = result;
274
+ res.status(env.status ?? 200).json(env.body);
275
+ }
276
+ else if (result &&
277
+ typeof result === "object" &&
278
+ "$status" in result) {
279
+ const env = result;
280
+ res.status(env.$status ?? 200).json(env.body);
281
+ }
282
+ else if (result === undefined) {
283
+ res.status(204).end();
284
+ }
285
+ else {
286
+ res.status(200).json(result);
287
+ }
288
+ }
289
+ catch (err) {
290
+ const e = err;
291
+ res.status(typeof e.status === "number" ? e.status : 500).json({
292
+ error: {
293
+ code: e.code ?? "internal_error",
294
+ summary: e.summary ?? e.message ?? "Internal error",
295
+ },
296
+ });
297
+ }
298
+ };
299
+ switch (binding.verb) {
300
+ case "get":
301
+ router.get(binding.path, handler);
302
+ break;
303
+ case "post":
304
+ router.post(binding.path, handler);
305
+ break;
306
+ case "put":
307
+ router.put(binding.path, handler);
308
+ break;
309
+ case "patch":
310
+ router.patch(binding.path, handler);
311
+ break;
312
+ case "delete":
313
+ router.delete(binding.path, handler);
314
+ break;
315
+ }
316
+ }
317
+ return router;
318
+ }
319
+ // ─── Koa <-> Express middleware bridge ─────────────────────────────
320
+ /**
321
+ * Wrap an Express middleware so it can be passed to `httpKoa({ middleware: [...] })`.
322
+ * Bridges the `(req, res, next)` Express contract into Koa's async chain.
323
+ */
324
+ export function fromExpressMiddleware(mw) {
325
+ return async (kctx, next) => {
326
+ await new Promise((resolve, reject) => {
327
+ try {
328
+ mw(kctx.req, kctx.res, (err) => err ? reject(err) : resolve());
329
+ }
330
+ catch (err) {
331
+ reject(err);
332
+ }
333
+ });
334
+ await next();
335
+ };
336
+ }
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@nwire/express",
3
+ "version": "0.10.0",
4
+ "description": "Nwire — Express-backed HTTP adopter. expressAdapter() consumes wires with binding.$adapter==='http' and mounts them on an Express server; fromExpressMiddleware() bridges Express middleware into httpKoa's middleware chain.",
5
+ "keywords": [
6
+ "adopter",
7
+ "express",
8
+ "http",
9
+ "interop",
10
+ "nwire"
11
+ ],
12
+ "license": "MIT",
13
+ "files": [
14
+ "dist",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "type": "module",
19
+ "main": "./dist/http-express.js",
20
+ "types": "./dist/http-express.d.ts",
21
+ "exports": {
22
+ ".": {
23
+ "import": "./dist/http-express.js",
24
+ "types": "./dist/http-express.d.ts"
25
+ }
26
+ },
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "dependencies": {
31
+ "express": "^5.1.0",
32
+ "@nwire/container": "0.10.0",
33
+ "@nwire/endpoint": "0.10.0",
34
+ "@nwire/wires": "0.10.0",
35
+ "@nwire/logger": "0.10.0"
36
+ },
37
+ "devDependencies": {
38
+ "@types/express": "^5.0.0",
39
+ "@types/node": "^22.19.9",
40
+ "@types/supertest": "^6.0.3",
41
+ "supertest": "^7.2.2",
42
+ "typescript": "^5.9.3",
43
+ "vitest": "^4.0.18",
44
+ "zod": "^4.0.0",
45
+ "@nwire/app": "0.10.0"
46
+ },
47
+ "peerDependencies": {
48
+ "express": "^4.0.0 || ^5.0.0"
49
+ },
50
+ "scripts": {
51
+ "build": "tsc && node ../../scripts/fix-dist-extensions.mjs dist",
52
+ "dev": "tsc --watch",
53
+ "typecheck": "tsc --noEmit"
54
+ }
55
+ }