@znight/http 0.0.0-stage → 0.1.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.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +2333 -2
  3. package/dist/adapter-CGSceVEN.d.mts +876 -0
  4. package/dist/compiler.d.mts +156 -0
  5. package/dist/compiler.mjs +2180 -0
  6. package/dist/compiler.mjs.map +1 -0
  7. package/dist/express.d.mts +39 -0
  8. package/dist/express.mjs +135 -0
  9. package/dist/express.mjs.map +1 -0
  10. package/dist/fastify.d.mts +33 -0
  11. package/dist/fastify.mjs +191 -0
  12. package/dist/fastify.mjs.map +1 -0
  13. package/dist/hono.d.mts +49 -0
  14. package/dist/hono.mjs +319 -0
  15. package/dist/hono.mjs.map +1 -0
  16. package/dist/index.d.mts +1254 -0
  17. package/dist/index.mjs +5618 -0
  18. package/dist/index.mjs.map +1 -0
  19. package/dist/node-BUnkdSsa.mjs +183 -0
  20. package/dist/node-BUnkdSsa.mjs.map +1 -0
  21. package/dist/node-io-BY1ZDLRQ.mjs +1566 -0
  22. package/dist/node-io-BY1ZDLRQ.mjs.map +1 -0
  23. package/dist/node.d.mts +30 -0
  24. package/dist/node.mjs +1 -0
  25. package/dist/path-JI87C4k8.d.mts +109 -0
  26. package/dist/path.d.mts +2 -0
  27. package/dist/path.mjs +245 -0
  28. package/dist/path.mjs.map +1 -0
  29. package/dist/size-D8rsQcV0.mjs +184 -0
  30. package/dist/size-D8rsQcV0.mjs.map +1 -0
  31. package/dist/utils-DCbu3a4w.mjs +27 -0
  32. package/dist/utils-DCbu3a4w.mjs.map +1 -0
  33. package/package.json +142 -4
  34. package/src/adapter.ts +240 -0
  35. package/src/adapters/express.ts +201 -0
  36. package/src/adapters/fastify.ts +280 -0
  37. package/src/adapters/hono.ts +488 -0
  38. package/src/adapters/node-io.ts +368 -0
  39. package/src/adapters/node.ts +135 -0
  40. package/src/adapters/shared.ts +508 -0
  41. package/src/body.ts +135 -0
  42. package/src/coerce.ts +195 -0
  43. package/src/compiler/analyze.ts +1513 -0
  44. package/src/compiler/ast.ts +90 -0
  45. package/src/compiler/coercion.ts +267 -0
  46. package/src/compiler/generate.ts +374 -0
  47. package/src/compiler/index.ts +76 -0
  48. package/src/compiler/model.ts +260 -0
  49. package/src/compiler/route-decorators.ts +405 -0
  50. package/src/compiler/validate.ts +288 -0
  51. package/src/context.ts +533 -0
  52. package/src/cookie-settings.ts +68 -0
  53. package/src/cookie-signer.ts +106 -0
  54. package/src/cookies.ts +726 -0
  55. package/src/cors.ts +349 -0
  56. package/src/decisions.ts +331 -0
  57. package/src/decorators.ts +486 -0
  58. package/src/event-channel.ts +197 -0
  59. package/src/exceptions.ts +257 -0
  60. package/src/files.ts +429 -0
  61. package/src/headers.ts +39 -0
  62. package/src/index.ts +185 -0
  63. package/src/inject-sse.ts +309 -0
  64. package/src/interfaces.ts +101 -0
  65. package/src/metadata.ts +162 -0
  66. package/src/mime.ts +157 -0
  67. package/src/multipart.ts +597 -0
  68. package/src/option-error.ts +31 -0
  69. package/src/path.ts +288 -0
  70. package/src/pipeline.ts +918 -0
  71. package/src/plugin-routes.ts +188 -0
  72. package/src/plugin.ts +1432 -0
  73. package/src/proxy.ts +236 -0
  74. package/src/query.ts +30 -0
  75. package/src/response.ts +240 -0
  76. package/src/router.ts +119 -0
  77. package/src/security-headers.ts +161 -0
  78. package/src/serialize.ts +264 -0
  79. package/src/size.ts +66 -0
  80. package/src/sse-parse.ts +194 -0
  81. package/src/sse.ts +650 -0
  82. package/src/standard-schema.ts +81 -0
  83. package/src/status.ts +78 -0
  84. package/src/stream.ts +200 -0
  85. package/src/tokens.ts +21 -0
  86. package/src/uploads.ts +176 -0
  87. package/src/utils.ts +31 -0
  88. package/src/validate.ts +65 -0
package/package.json CHANGED
@@ -1,6 +1,144 @@
1
1
  {
2
2
  "name": "@znight/http",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Znight HTTP plugin: controllers, routing, guards, interceptors, validation with Fastify, Express or Hono",
5
+ "license": "MIT",
6
+ "author": "Ugho STEPHAN",
7
+ "homepage": "https://github.com/znight-org/znight/tree/main/packages/http#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/znight-org/znight.git",
11
+ "directory": "packages/http"
12
+ },
13
+ "bugs": "https://github.com/znight-org/znight/issues",
14
+ "keywords": [
15
+ "znight",
16
+ "http",
17
+ "rest",
18
+ "controllers",
19
+ "routing",
20
+ "guards",
21
+ "interceptors",
22
+ "validation",
23
+ "standard-schema",
24
+ "fastify",
25
+ "express",
26
+ "hono",
27
+ "bun"
28
+ ],
29
+ "type": "module",
30
+ "sideEffects": false,
31
+ "engines": {
32
+ "node": ">=24"
33
+ },
34
+ "exports": {
35
+ ".": {
36
+ "@znight/source": "./src/index.ts",
37
+ "types": "./dist/index.d.mts",
38
+ "default": "./dist/index.mjs"
39
+ },
40
+ "./path": {
41
+ "@znight/source": "./src/path.ts",
42
+ "types": "./dist/path.d.mts",
43
+ "default": "./dist/path.mjs"
44
+ },
45
+ "./node": {
46
+ "@znight/source": "./src/adapters/node.ts",
47
+ "types": "./dist/node.d.mts",
48
+ "default": "./dist/node.mjs"
49
+ },
50
+ "./fastify": {
51
+ "@znight/source": "./src/adapters/fastify.ts",
52
+ "types": "./dist/fastify.d.mts",
53
+ "default": "./dist/fastify.mjs"
54
+ },
55
+ "./express": {
56
+ "@znight/source": "./src/adapters/express.ts",
57
+ "types": "./dist/express.d.mts",
58
+ "default": "./dist/express.mjs"
59
+ },
60
+ "./hono": {
61
+ "@znight/source": "./src/adapters/hono.ts",
62
+ "types": "./dist/hono.d.mts",
63
+ "default": "./dist/hono.mjs"
64
+ },
65
+ "./compiler": {
66
+ "@znight/source": "./src/compiler/index.ts",
67
+ "types": "./dist/compiler.d.mts",
68
+ "default": "./dist/compiler.mjs"
69
+ },
70
+ "./package.json": "./package.json"
71
+ },
72
+ "types": "./dist/index.d.mts",
73
+ "files": ["dist", "src"],
74
+ "scripts": {
75
+ "build": "tsdown",
76
+ "typecheck": "tsc --noEmit && tsc --noEmit -p test"
77
+ },
78
+ "publishConfig": {
79
+ "access": "public"
80
+ },
81
+ "dependencies": {
82
+ "@znight/core": "0.1.0"
83
+ },
84
+ "peerDependencies": {
85
+ "@hono/node-server": "^2.1.5",
86
+ "express": "^5.3.0",
87
+ "fastify": "^5.12.7",
88
+ "hono": "^4.13.14",
89
+ "light-my-request": "^6.8.0",
90
+ "@znight/compiler": "^0.1.0"
91
+ },
92
+ "peerDependenciesMeta": {
93
+ "@hono/node-server": {
94
+ "optional": true
95
+ },
96
+ "express": {
97
+ "optional": true
98
+ },
99
+ "fastify": {
100
+ "optional": true
101
+ },
102
+ "hono": {
103
+ "optional": true
104
+ },
105
+ "light-my-request": {
106
+ "optional": true
107
+ },
108
+ "@znight/compiler": {
109
+ "optional": true
110
+ }
111
+ },
112
+ "devDependencies": {
113
+ "@hono/node-server": "2.1.5",
114
+ "@types/express": "5.0.6",
115
+ "express": "5.3.0",
116
+ "fastify": "5.12.7",
117
+ "hono": "4.13.14",
118
+ "valibot": "1.5.0",
119
+ "zod": "4.6.7",
120
+ "light-my-request": "6.8.0",
121
+ "@znight/compiler": "0.1.0"
122
+ },
123
+ "znight": {
124
+ "apiVersion": 1,
125
+ "compiler": "@znight/http/compiler",
126
+ "provides": [
127
+ {
128
+ "export": "HttpContext",
129
+ "plugin": "HttpPlugin",
130
+ "scope": "request"
131
+ },
132
+ {
133
+ "export": "RequestId",
134
+ "plugin": "HttpPlugin",
135
+ "scope": "request"
136
+ },
137
+ {
138
+ "export": "HttpPlugin",
139
+ "plugin": "HttpPlugin",
140
+ "scope": "singleton"
141
+ }
142
+ ]
143
+ }
144
+ }
package/src/adapter.ts ADDED
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Contract between the HTTP plugin and the server adapters (`@znight/http/node`, `/fastify`,
3
+ * `/express`, `/hono`). Adapters only route requests to handlers (with their native router) and
4
+ * translate I/O; everything else (parameters, query and body parsing, guards, serialisation, errors)
5
+ * is done by the plugin so that every adapter behaves the same.
6
+ */
7
+ import type { SetCookieInfo } from './cookies.ts'
8
+ import type { HeaderValue } from './exceptions.ts'
9
+ import type { HttpMethod } from './metadata.ts'
10
+ import type { RoutePath } from './path.ts'
11
+
12
+ /** A request, as seen by the plugin. */
13
+ export interface AdapterRequest {
14
+ /** Upper-case method. */
15
+ readonly method: string
16
+ /** Raw request target: path and query string, percent-encoded (`/users/a%20b?page=2`). */
17
+ readonly url: string
18
+ /** Request headers with lower-case names (repeated headers joined with `, `). */
19
+ readonly headers: Readonly<Record<string, string | undefined>>
20
+ /**
21
+ * Client address, when known: the socket peer, or the address the server framework derives from
22
+ * its own proxy settings (Fastify `trustProxy`, Express `trust proxy`). This is `ctx.ip` unless
23
+ * the plugin option `trustProxy` is set.
24
+ */
25
+ readonly ip: string | undefined
26
+ /**
27
+ * Address of the socket peer, **never** taken from headers: where the plugin starts walking
28
+ * `x-forwarded-for` with `trustProxy`. Optional (`ip` is used without it); the built-in adapters
29
+ * set it.
30
+ */
31
+ readonly remoteAddress?: string | undefined
32
+ /** `true` when the connection uses TLS (`ctx.protocol` without a trusted proxy). Optional. */
33
+ readonly encrypted?: boolean
34
+ /** Native request object (`@Req()`). */
35
+ readonly raw: unknown
36
+ /** Native response object (`@Res()`). */
37
+ readonly rawResponse: unknown
38
+ /**
39
+ * Reads the whole body (once). Rejects with a `PayloadTooLargeException` when it exceeds `limit`
40
+ * bytes (checked on `content-length` first, then while reading).
41
+ */
42
+ readBody(limit: number): Promise<Uint8Array>
43
+ /**
44
+ * The body as a Web stream, read as the consumer pulls it (once; exclusive with `readBody`). It
45
+ * errors with a `PayloadTooLargeException` past `limit` bytes (`content-length` checked first) and
46
+ * with a `BadRequestException` when the client aborts. Cancelling it discards the rest of the body
47
+ * without closing the connection before the response is written. Optional: without it the plugin
48
+ * streams the result of `readBody` (multipart uploads are then buffered before being parsed).
49
+ */
50
+ streamBody?(limit: number): ReadableStream<Uint8Array>
51
+ /**
52
+ * Whether the request has a body, asked only when its headers declare none (neither
53
+ * `content-length` nor `transfer-encoding`: Web `Request`s built in memory). Return `false` for
54
+ * requests read from an HTTP/1.1 connection (without those headers they have no body). Optional:
55
+ * no body is assumed otherwise.
56
+ */
57
+ hasBody?(): boolean
58
+ /** Status written by a handler that took over the response with `@Res()`, when known. */
59
+ rawStatus?(): number | undefined
60
+ /**
61
+ * Registers a listener called once when the client disconnects before the response was fully
62
+ * written. When it already happened, the listener is called on the next microtask. Never called
63
+ * after a complete response. Optional: without it, `ctx.signal` only aborts when the response
64
+ * body is cancelled or an event stream is closed by the server.
65
+ */
66
+ onAbort?(listener: () => void): void
67
+ }
68
+
69
+ /**
70
+ * Body of a reply: bytes, a stream or nothing. Adapters MUST either read a stream to its end or
71
+ * cancel it (client disconnected, response not sent...): the request scope and the in-flight count
72
+ * are released then. A `Blob` is sent with its size; the plugin itself never passes one: it sends
73
+ * files as a stream reading the `Blob`, given in {@link AdapterReply.blob}.
74
+ */
75
+ export type ReplyBody = Uint8Array | ReadableStream<Uint8Array> | Blob | null
76
+
77
+ /** A response computed by the plugin, written by the adapter. */
78
+ export interface AdapterReply {
79
+ readonly status: number
80
+ /** Response headers, lower-case names (validated: every name and value can be sent). */
81
+ readonly headers: Readonly<Record<string, HeaderValue>>
82
+ /** `null` for HEAD requests and bodyless statuses (headers such as `content-length` are kept). */
83
+ readonly body: ReplyBody
84
+ /**
85
+ * The `Blob` (file) that `body` streams, for adapters able to send it natively (`sendfile`, exact
86
+ * length: `new Response(blob)` on `Bun.serve()`). An adapter sending it instead of reading `body`
87
+ * MUST cancel `body` once the server holds the blob (file opened): that releases the request
88
+ * scope, which may delete the file. Headers (`content-length`...) are the same either way.
89
+ */
90
+ readonly blob?: Blob
91
+ /** The handler wrote the response itself through `@Res()`: write nothing. */
92
+ readonly raw?: boolean
93
+ /**
94
+ * The body is a long-lived event stream (a streamed `text/event-stream` body: `@Sse`,
95
+ * `HttpResponse.sse()`, a proxied `fetch()` response...). Adapters MUST send the headers without
96
+ * waiting for the first chunk (a quiet stream may send nothing for a while), MUST NOT apply idle or
97
+ * inactivity timeouts to the response while it is sent, and MUST write each chunk as soon as it is
98
+ * read (no buffering, no compression).
99
+ */
100
+ readonly eventStream?: boolean
101
+ }
102
+
103
+ /** Handles one request. Never rejects: errors are already mapped to a reply. */
104
+ export type AdapterHandler = (request: AdapterRequest) => Promise<AdapterReply>
105
+
106
+ /**
107
+ * Handles requests no route matched (`404`/`405`, automatic `OPTIONS`) and errors raised by the
108
+ * server framework before a route handler ran (`error`, e.g. a malformed URL). Never rejects.
109
+ */
110
+ export type FallbackHandler = (request: AdapterRequest, error?: unknown) => Promise<AdapterReply>
111
+
112
+ /** A concrete route to register (the plugin expands `ALL` and adds `HEAD` for `GET` routes). */
113
+ export interface AdapterRoute {
114
+ readonly method: Exclude<HttpMethod, 'ALL'>
115
+ /** Parsed full path (plugin prefix included). Translate it with the helpers of `@znight/http/path`. */
116
+ readonly path: RoutePath
117
+ readonly handler: AdapterHandler
118
+ }
119
+
120
+ /** Address of a listening server. Converts to its URL (`String(address)`). */
121
+ export interface HttpServerAddress {
122
+ /** Host the server is bound to (`0.0.0.0`, `::`, `127.0.0.1`...). */
123
+ readonly host: string
124
+ readonly port: number
125
+ /** `IPv4` or `IPv6`. */
126
+ readonly family: string
127
+ /** Base URL usable by clients (`http://127.0.0.1:3000`; wildcard hosts become loopback addresses). */
128
+ readonly url: string
129
+ toString(): string
130
+ }
131
+
132
+ /** A request for {@link HttpAdapter.inject} (in-memory, no network). */
133
+ export interface InjectRequest {
134
+ readonly method: string
135
+ /** Path and optional query string (`/users?page=2`). */
136
+ readonly url: string
137
+ readonly headers?: Readonly<Record<string, string>>
138
+ /**
139
+ * Request body: a string or bytes are sent as is (`text/plain` / `application/octet-stream` when
140
+ * no `content-type` is given), a `FormData` as `multipart/form-data` (files included), a `Blob` /
141
+ * `File` with its type, anything else as JSON (`application/json`).
142
+ */
143
+ readonly body?: unknown
144
+ /** Query parameters appended to `url`. */
145
+ readonly query?: Readonly<Record<string, string | readonly string[]>>
146
+ /**
147
+ * Request cookies (name → value), sent in a `cookie` header after any `cookie` header of
148
+ * `headers`. Values are encoded like the values of `serializeCookie`, so a value read from
149
+ * `response.cookies` can be sent back as is (signed values included).
150
+ */
151
+ readonly cookies?: Readonly<Record<string, string>>
152
+ /**
153
+ * Cookies signed by `HttpPlugin` with its first secret (`cookies.secrets`) and merged into
154
+ * `cookies` before dispatch: the way to send a valid `@SignedCookie`. Adapters never receive this
155
+ * field.
156
+ */
157
+ readonly signedCookies?: Readonly<Record<string, string>>
158
+ }
159
+
160
+ /** A request for {@link HttpAdapter.injectStream}: an {@link InjectRequest} that can be aborted. */
161
+ export interface InjectStreamRequest extends InjectRequest {
162
+ /** Aborting it disconnects the client (like cancelling the response body). */
163
+ readonly signal?: AbortSignal
164
+ }
165
+
166
+ /**
167
+ * A request for `HttpPlugin.injectStream()` / `injectSse()`: an {@link InjectStreamRequest} whose
168
+ * method defaults to `GET`.
169
+ */
170
+ export type InjectStreamOptions = Omit<InjectStreamRequest, 'method'> & { readonly method?: string }
171
+
172
+ /** The response of {@link HttpAdapter.inject}. */
173
+ export interface InjectResponse {
174
+ readonly status: number
175
+ /**
176
+ * Lower-case header names; repeated headers are joined with `, `, except `set-cookie`: an array
177
+ * with one entry per cookie (cookie values may contain `, `).
178
+ */
179
+ readonly headers: Record<string, string | string[]>
180
+ /** Body decoded as UTF-8. */
181
+ readonly body: string
182
+ /** Raw body bytes (binary downloads). */
183
+ readonly bytes: Uint8Array
184
+ /** `JSON.parse(body)`. */
185
+ json<T = unknown>(): T
186
+ /** `set-cookie` parsed, by cookie name (the last one wins). */
187
+ readonly cookies: Readonly<Record<string, SetCookieInfo>>
188
+ }
189
+
190
+ /**
191
+ * The response of {@link HttpAdapter.injectStream}: available as soon as the headers are sent, its
192
+ * body read as it is produced.
193
+ */
194
+ export interface InjectStreamResponse {
195
+ readonly status: number
196
+ /** Like {@link InjectResponse.headers} (`set-cookie` as an array). */
197
+ readonly headers: Record<string, string | string[]>
198
+ readonly cookies: Readonly<Record<string, SetCookieInfo>>
199
+ /** The body as it is produced. Cancelling it disconnects the client (the server sees `onAbort` and a cancelled reply body). */
200
+ readonly body: ReadableStream<Uint8Array>
201
+ }
202
+
203
+ /** Options of {@link HttpAdapter.close}. */
204
+ export interface AdapterCloseOptions {
205
+ /** Destroy open connections instead of waiting for in-flight requests. */
206
+ readonly force?: boolean
207
+ }
208
+
209
+ /**
210
+ * A server implementation. Create one with `nodeAdapter()` (default), `fastifyAdapter()`,
211
+ * `expressAdapter()` or `honoAdapter()`. An adapter instance serves one plugin.
212
+ */
213
+ export interface HttpAdapter {
214
+ /** `node`, `fastify`, `express`, `hono`... (exposed as `ctx.adapter`). */
215
+ readonly name: string
216
+ /** Registers a route. Called during `onBootstrap`, before `listen`/`inject`. */
217
+ registerRoute(route: AdapterRoute): void
218
+ /** Sets the handler of unmatched requests and framework errors. Called once, before `listen`/`inject`. */
219
+ setFallbackHandler(handler: FallbackHandler): void
220
+ /** Starts listening. `port` 0 picks a free port. */
221
+ listen(port: number, host: string): Promise<HttpServerAddress>
222
+ /**
223
+ * Stops accepting connections, closes idle ones and resolves once in-flight requests completed and
224
+ * every connection is closed (`force`: destroys them). Idempotent; also releases what the adapter
225
+ * created when it never listened.
226
+ */
227
+ close(options?: AdapterCloseOptions): Promise<void>
228
+ /**
229
+ * Sends a request in memory (no socket), through the same routing and pipeline, and reads the
230
+ * whole response. The built-in adapters reject an event stream (`text/event-stream`, which would
231
+ * never end) with a hint to `injectSse()` / `injectStream()`.
232
+ */
233
+ inject(request: InjectRequest): Promise<InjectResponse>
234
+ /**
235
+ * In-memory request whose response body is returned as it is produced (resolves at the headers).
236
+ * Cancelling `body` (or aborting `signal`) disconnects the client. Optional for third-party
237
+ * adapters; the four built-in adapters implement it.
238
+ */
239
+ injectStream?(request: InjectStreamRequest): Promise<InjectStreamResponse>
240
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * `@znight/http/express`: serves Znight routes with [Express](https://expressjs.com) 5 (optional
3
+ * peer dependency `express`). Routes are mounted on a dedicated router, after the middleware of the
4
+ * application you pass. `inject()` uses the optional peer `light-my-request`.
5
+ */
6
+ import type { IncomingMessage, ServerResponse } from 'node:http'
7
+ import type {
8
+ AdapterCloseOptions,
9
+ AdapterRoute,
10
+ FallbackHandler,
11
+ HttpAdapter,
12
+ HttpServerAddress,
13
+ InjectRequest,
14
+ InjectResponse,
15
+ InjectStreamRequest,
16
+ InjectStreamResponse,
17
+ } from '../adapter.ts'
18
+
19
+ import { createServer, type Server } from 'node:http'
20
+ import express, { type Express, type NextFunction, type Request, type Response, type Router } from 'express'
21
+
22
+ import { toRegExpSource } from '../path.ts'
23
+ import { ConnectionTracker, closeServer, nodeAdapterRequest, writeNodeFailure, writeNodeReply } from './node-io.ts'
24
+ import { bufferInjectStream, injectNodeStream, serverAddress } from './shared.ts'
25
+
26
+ /** Options of {@link expressAdapter}. */
27
+ export interface ExpressAdapterOptions {
28
+ /**
29
+ * An existing application: its middleware (CORS, helmet, sessions...) runs before the Znight
30
+ * routes. Do not add body parsers for Znight routes: Znight reads bodies itself.
31
+ */
32
+ readonly app?: Express
33
+ /**
34
+ * The server to listen with (e.g. `https.createServer({ key, cert })`): the adapter adds its
35
+ * `request` listener. Default: a new `http.createServer()`.
36
+ */
37
+ readonly server?: Server
38
+ }
39
+
40
+ /** The Express adapter. */
41
+ export interface ExpressAdapter extends HttpAdapter {
42
+ readonly name: 'express'
43
+ /** The Express application. */
44
+ readonly app: Express
45
+ /** The router holding the Znight routes (mounted on {@link app}). */
46
+ readonly router: Router
47
+ /** The HTTP server, once listening. */
48
+ readonly server: Server | undefined
49
+ }
50
+
51
+ /**
52
+ * Creates an Express adapter.
53
+ *
54
+ * ```ts
55
+ * import { expressAdapter } from '@znight/http/express'
56
+ *
57
+ * const app = express()
58
+ * app.use(cors())
59
+ * HttpPlugin.configure({ adapter: expressAdapter({ app }) })
60
+ * ```
61
+ */
62
+ export function expressAdapter(options: ExpressAdapterOptions = {}): ExpressAdapter {
63
+ return new ExpressHttpAdapter(options)
64
+ }
65
+
66
+ type Method = 'get' | 'head' | 'post' | 'put' | 'patch' | 'delete' | 'options'
67
+
68
+ class ExpressHttpAdapter implements ExpressAdapter {
69
+ readonly name = 'express' as const
70
+ readonly app: Express
71
+ readonly router: Router
72
+ #fallback: FallbackHandler | undefined
73
+ #mounted = false
74
+ #server: Server | undefined
75
+ #tracker: ConnectionTracker | undefined
76
+ #closing: Promise<void> | undefined
77
+
78
+ readonly #givenServer: Server | undefined
79
+
80
+ constructor(options: ExpressAdapterOptions) {
81
+ this.#givenServer = options.server
82
+ if (options.app === undefined) {
83
+ this.app = express()
84
+ this.app.disable('x-powered-by')
85
+ } else {
86
+ this.app = options.app
87
+ }
88
+ this.router = express.Router({ caseSensitive: true, strict: true })
89
+ }
90
+
91
+ get server(): Server | undefined {
92
+ return this.#server
93
+ }
94
+
95
+ registerRoute(route: AdapterRoute): void {
96
+ const handler = route.handler
97
+ // anchored regular expressions without capture groups: Express decodes nothing, Znight extracts parameters
98
+ this.router[route.method.toLowerCase() as Method](new RegExp(toRegExpSource(route.path)), (request, response) => {
99
+ handler(adapterRequest(request, response)).then(
100
+ reply => writeNodeReply(response, reply),
101
+ () => writeNodeFailure(response),
102
+ )
103
+ })
104
+ }
105
+
106
+ setFallbackHandler(handler: FallbackHandler): void {
107
+ this.#fallback = handler
108
+ }
109
+
110
+ #mount(): void {
111
+ if (this.#mounted) return
112
+ this.#mounted = true
113
+ // Express answers unmatched OPTIONS requests itself (200): route them to the Znight fallback
114
+ this.router.options(/^.*$/, (request: Request, response: Response) =>
115
+ this.#dispatchFallback(request, response, undefined),
116
+ )
117
+ this.app.use(this.router)
118
+ this.app.use((request: Request, response: Response) => this.#dispatchFallback(request, response, undefined))
119
+ this.app.use((error: unknown, request: Request, response: Response, _next: NextFunction) =>
120
+ this.#dispatchFallback(request, response, error),
121
+ )
122
+ }
123
+
124
+ #dispatchFallback(request: Request, response: Response, error: unknown): void {
125
+ if (this.#fallback === undefined) {
126
+ writeNodeFailure(response)
127
+ return
128
+ }
129
+ this.#fallback(adapterRequest(request, response), error).then(
130
+ reply => writeNodeReply(response, reply),
131
+ () => writeNodeFailure(response),
132
+ )
133
+ }
134
+
135
+ readonly #listener = (request: IncomingMessage, response: ServerResponse): void => {
136
+ this.app(request as Request, response as Response)
137
+ }
138
+
139
+ async listen(port: number, host: string): Promise<HttpServerAddress> {
140
+ this.#mount()
141
+ const server = this.#givenServer ?? createServer()
142
+ if (!server.listeners('request').includes(this.#listener)) server.on('request', this.#listener)
143
+ this.#server = server
144
+ this.#tracker ??= new ConnectionTracker(server)
145
+ this.#closing = undefined
146
+ await new Promise<void>((resolve, reject) => {
147
+ const onError = (error: Error): void => reject(error)
148
+ server.once('error', onError)
149
+ server.listen(port, host, () => {
150
+ server.off('error', onError)
151
+ resolve()
152
+ })
153
+ })
154
+ const address = server.address()
155
+ if (address === null || typeof address === 'string') return serverAddress(host, port)
156
+ return serverAddress(address.address, address.port, address.family)
157
+ }
158
+
159
+ close(options: AdapterCloseOptions = {}): Promise<void> {
160
+ const server = this.#server
161
+ const tracker = this.#tracker
162
+ if (server === undefined || tracker === undefined) return Promise.resolve()
163
+ this.#closing = closeServer(server, tracker, this.#closing, options.force === true)
164
+ return this.#closing
165
+ }
166
+
167
+ inject(request: InjectRequest): Promise<InjectResponse> {
168
+ return bufferInjectStream(stream => this.injectStream(stream), request)
169
+ }
170
+
171
+ injectStream(request: InjectStreamRequest): Promise<InjectStreamResponse> {
172
+ this.#mount()
173
+ return injectNodeStream('express', this.#injectListener as (req: never, res: never) => void, request)
174
+ }
175
+
176
+ /**
177
+ * Express replaces the prototype of `req`/`res` with its own, which drops the overrides of the
178
+ * light-my-request fakes (payload capture, body streaming): pin them as own properties first.
179
+ */
180
+ readonly #injectListener = (request: IncomingMessage, response: ServerResponse): void => {
181
+ pinPrototype(request)
182
+ pinPrototype(response)
183
+ this.#listener(request, response)
184
+ }
185
+ }
186
+
187
+ function pinPrototype(target: object): void {
188
+ const prototype: unknown = Object.getPrototypeOf(target)
189
+ if (prototype === null || typeof prototype !== 'object') return
190
+ for (const key of Object.getOwnPropertyNames(prototype)) {
191
+ if (key === 'constructor' || Object.hasOwn(target, key)) continue
192
+ const descriptor = Object.getOwnPropertyDescriptor(prototype, key)
193
+ if (typeof descriptor?.value === 'function') {
194
+ Object.defineProperty(target, key, { value: descriptor.value, writable: true, configurable: true })
195
+ }
196
+ }
197
+ }
198
+
199
+ function adapterRequest(request: Request, response: Response): ReturnType<typeof nodeAdapterRequest> {
200
+ return nodeAdapterRequest(request, response, { url: request.originalUrl ?? request.url, ip: request.ip })
201
+ }