@znight/http 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +2333 -2
- package/dist/adapter-CGSceVEN.d.mts +876 -0
- package/dist/compiler.d.mts +156 -0
- package/dist/compiler.mjs +2180 -0
- package/dist/compiler.mjs.map +1 -0
- package/dist/express.d.mts +39 -0
- package/dist/express.mjs +135 -0
- package/dist/express.mjs.map +1 -0
- package/dist/fastify.d.mts +33 -0
- package/dist/fastify.mjs +191 -0
- package/dist/fastify.mjs.map +1 -0
- package/dist/hono.d.mts +49 -0
- package/dist/hono.mjs +319 -0
- package/dist/hono.mjs.map +1 -0
- package/dist/index.d.mts +1254 -0
- package/dist/index.mjs +5618 -0
- package/dist/index.mjs.map +1 -0
- package/dist/node-BUnkdSsa.mjs +183 -0
- package/dist/node-BUnkdSsa.mjs.map +1 -0
- package/dist/node-io-BY1ZDLRQ.mjs +1566 -0
- package/dist/node-io-BY1ZDLRQ.mjs.map +1 -0
- package/dist/node.d.mts +30 -0
- package/dist/node.mjs +1 -0
- package/dist/path-JI87C4k8.d.mts +109 -0
- package/dist/path.d.mts +2 -0
- package/dist/path.mjs +245 -0
- package/dist/path.mjs.map +1 -0
- package/dist/size-D8rsQcV0.mjs +184 -0
- package/dist/size-D8rsQcV0.mjs.map +1 -0
- package/dist/utils-DCbu3a4w.mjs +27 -0
- package/dist/utils-DCbu3a4w.mjs.map +1 -0
- package/package.json +142 -4
- package/src/adapter.ts +240 -0
- package/src/adapters/express.ts +201 -0
- package/src/adapters/fastify.ts +280 -0
- package/src/adapters/hono.ts +488 -0
- package/src/adapters/node-io.ts +368 -0
- package/src/adapters/node.ts +135 -0
- package/src/adapters/shared.ts +508 -0
- package/src/body.ts +135 -0
- package/src/coerce.ts +195 -0
- package/src/compiler/analyze.ts +1513 -0
- package/src/compiler/ast.ts +90 -0
- package/src/compiler/coercion.ts +267 -0
- package/src/compiler/generate.ts +374 -0
- package/src/compiler/index.ts +76 -0
- package/src/compiler/model.ts +260 -0
- package/src/compiler/route-decorators.ts +405 -0
- package/src/compiler/validate.ts +288 -0
- package/src/context.ts +533 -0
- package/src/cookie-settings.ts +68 -0
- package/src/cookie-signer.ts +106 -0
- package/src/cookies.ts +726 -0
- package/src/cors.ts +349 -0
- package/src/decisions.ts +331 -0
- package/src/decorators.ts +486 -0
- package/src/event-channel.ts +197 -0
- package/src/exceptions.ts +257 -0
- package/src/files.ts +429 -0
- package/src/headers.ts +39 -0
- package/src/index.ts +185 -0
- package/src/inject-sse.ts +309 -0
- package/src/interfaces.ts +101 -0
- package/src/metadata.ts +162 -0
- package/src/mime.ts +157 -0
- package/src/multipart.ts +597 -0
- package/src/option-error.ts +31 -0
- package/src/path.ts +288 -0
- package/src/pipeline.ts +918 -0
- package/src/plugin-routes.ts +188 -0
- package/src/plugin.ts +1432 -0
- package/src/proxy.ts +236 -0
- package/src/query.ts +30 -0
- package/src/response.ts +240 -0
- package/src/router.ts +119 -0
- package/src/security-headers.ts +161 -0
- package/src/serialize.ts +264 -0
- package/src/size.ts +66 -0
- package/src/sse-parse.ts +194 -0
- package/src/sse.ts +650 -0
- package/src/standard-schema.ts +81 -0
- package/src/status.ts +78 -0
- package/src/stream.ts +200 -0
- package/src/tokens.ts +21 -0
- package/src/uploads.ts +176 -0
- package/src/utils.ts +31 -0
- package/src/validate.ts +65 -0
package/package.json
CHANGED
|
@@ -1,6 +1,144 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@znight/http",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
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.1"
|
|
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.1"
|
|
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.1"
|
|
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
|
+
}
|