@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.
- 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
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
/** Node.js `IncomingMessage` / `ServerResponse` helpers shared by the Node and Express adapters (internal). */
|
|
2
|
+
import type { IncomingHttpHeaders, IncomingMessage, OutgoingHttpHeaders, Server, ServerResponse } from 'node:http'
|
|
3
|
+
import type { Socket } from 'node:net'
|
|
4
|
+
import type { AdapterReply, AdapterRequest } from '../adapter.ts'
|
|
5
|
+
import type { HeaderValue } from '../exceptions.ts'
|
|
6
|
+
|
|
7
|
+
import { Readable } from 'node:stream'
|
|
8
|
+
import { pipeline } from 'node:stream/promises'
|
|
9
|
+
|
|
10
|
+
import { BadRequestException } from '../exceptions.ts'
|
|
11
|
+
import { discardStream, failedStream } from '../stream.ts'
|
|
12
|
+
import { concat, exceedsDeclaredLength, payloadTooLarge } from './shared.ts'
|
|
13
|
+
|
|
14
|
+
/** Bytes buffered by a request body stream before the request is paused. */
|
|
15
|
+
const BODY_HIGH_WATER_MARK = 64 * 1024
|
|
16
|
+
|
|
17
|
+
/** Request headers with array values (only `set-cookie` in Node.js) joined with `, `. */
|
|
18
|
+
export function normalizeNodeHeaders(headers: IncomingHttpHeaders): Record<string, string | undefined> {
|
|
19
|
+
for (const name in headers) {
|
|
20
|
+
if (typeof headers[name] !== 'string') return copyHeaders(headers)
|
|
21
|
+
}
|
|
22
|
+
return headers as Record<string, string | undefined>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function copyHeaders(headers: IncomingHttpHeaders): Record<string, string | undefined> {
|
|
26
|
+
const result: Record<string, string | undefined> = {}
|
|
27
|
+
for (const name in headers) {
|
|
28
|
+
const value = headers[name]
|
|
29
|
+
result[name] = Array.isArray(value) ? value.join(', ') : value
|
|
30
|
+
}
|
|
31
|
+
return result
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Whether the body was already read by someone else: an Express body parser (`express.json()`...)
|
|
36
|
+
* marks the request with `_body`; otherwise a declared body that already ended.
|
|
37
|
+
*/
|
|
38
|
+
function consumedElsewhere(request: IncomingMessage): boolean {
|
|
39
|
+
if ((request as { _body?: unknown })._body === true) return true
|
|
40
|
+
if (!request.readableEnded) return false
|
|
41
|
+
const length = request.headers['content-length']
|
|
42
|
+
return request.headers['transfer-encoding'] !== undefined || (length !== undefined && length.trim() !== '0')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The error of a body consumed before Znight read it (an empty body would be silently wrong). */
|
|
46
|
+
function consumedError(): Error {
|
|
47
|
+
return new Error(
|
|
48
|
+
'The request body was already read by a middleware (an Express body parser such as express.json()?): ' +
|
|
49
|
+
'Znight reads the bodies of its routes itself, do not register body parsers for them',
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Reads a Node.js request body, enforcing `limit` (on `content-length`, then while streaming). */
|
|
54
|
+
export function readNodeBody(request: IncomingMessage, limit: number): Promise<Uint8Array> {
|
|
55
|
+
if (exceedsDeclaredLength(request.headers['content-length'], limit)) {
|
|
56
|
+
request.resume()
|
|
57
|
+
return Promise.reject(payloadTooLarge(limit))
|
|
58
|
+
}
|
|
59
|
+
if (consumedElsewhere(request)) return Promise.reject(consumedError())
|
|
60
|
+
if (request.readableEnded) return Promise.resolve(new Uint8Array(0))
|
|
61
|
+
if (request.destroyed) return Promise.reject(new BadRequestException('The request was aborted'))
|
|
62
|
+
return new Promise((resolve, reject) => {
|
|
63
|
+
const chunks: Uint8Array[] = []
|
|
64
|
+
let size = 0
|
|
65
|
+
const cleanup = (): void => {
|
|
66
|
+
request.off('data', onData)
|
|
67
|
+
request.off('end', onEnd)
|
|
68
|
+
request.off('error', onError)
|
|
69
|
+
request.off('close', onClose)
|
|
70
|
+
}
|
|
71
|
+
const onData = (chunk: Uint8Array | string): void => {
|
|
72
|
+
const bytes = typeof chunk === 'string' ? Buffer.from(chunk) : chunk
|
|
73
|
+
size += bytes.byteLength
|
|
74
|
+
if (size > limit) {
|
|
75
|
+
cleanup()
|
|
76
|
+
request.resume() // discard the rest; the 413 response closes the connection
|
|
77
|
+
reject(payloadTooLarge(limit))
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
chunks.push(bytes)
|
|
81
|
+
}
|
|
82
|
+
const onEnd = (): void => {
|
|
83
|
+
cleanup()
|
|
84
|
+
resolve(concat(chunks, size))
|
|
85
|
+
}
|
|
86
|
+
const onError = (error: Error): void => {
|
|
87
|
+
cleanup()
|
|
88
|
+
reject(new BadRequestException('Failed to read the request body', undefined, { cause: error }))
|
|
89
|
+
}
|
|
90
|
+
const onClose = (): void => {
|
|
91
|
+
if (request.complete) return
|
|
92
|
+
cleanup()
|
|
93
|
+
reject(new BadRequestException('The request was aborted'))
|
|
94
|
+
}
|
|
95
|
+
request.on('data', onData)
|
|
96
|
+
request.on('end', onEnd)
|
|
97
|
+
request.on('error', onError)
|
|
98
|
+
request.on('close', onClose)
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* A Node.js request body as a Web stream (see `AdapterRequest.streamBody`): the request is paused
|
|
104
|
+
* while the consumer does not read (backpressure); past `limit` bytes the stream errors with 413 and
|
|
105
|
+
* the rest of the body is discarded (`request.resume()`), like when the consumer cancels the stream,
|
|
106
|
+
* so the response can still be written (it carries `connection: close`).
|
|
107
|
+
*/
|
|
108
|
+
export function nodeBodyStream(request: IncomingMessage, limit: number): ReadableStream<Uint8Array> {
|
|
109
|
+
if (exceedsDeclaredLength(request.headers['content-length'], limit)) {
|
|
110
|
+
request.resume()
|
|
111
|
+
return failedStream(payloadTooLarge(limit))
|
|
112
|
+
}
|
|
113
|
+
if (consumedElsewhere(request)) return failedStream(consumedError())
|
|
114
|
+
if (request.readableEnded) return new ReadableStream<Uint8Array>({ start: controller => controller.close() })
|
|
115
|
+
if (request.destroyed) return failedStream(new BadRequestException('The request was aborted'))
|
|
116
|
+
let size = 0
|
|
117
|
+
let cleanup = (): void => undefined
|
|
118
|
+
return new ReadableStream<Uint8Array>(
|
|
119
|
+
{
|
|
120
|
+
start(controller) {
|
|
121
|
+
const onData = (chunk: Uint8Array | string): void => {
|
|
122
|
+
const bytes = typeof chunk === 'string' ? Buffer.from(chunk) : chunk
|
|
123
|
+
size += bytes.byteLength
|
|
124
|
+
if (size > limit) {
|
|
125
|
+
cleanup()
|
|
126
|
+
request.resume() // discard the rest; the 413 response closes the connection
|
|
127
|
+
controller.error(payloadTooLarge(limit))
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
controller.enqueue(bytes)
|
|
131
|
+
if ((controller.desiredSize ?? 0) <= 0) request.pause()
|
|
132
|
+
}
|
|
133
|
+
const onEnd = (): void => {
|
|
134
|
+
cleanup()
|
|
135
|
+
controller.close()
|
|
136
|
+
}
|
|
137
|
+
const onError = (error: Error): void => {
|
|
138
|
+
cleanup()
|
|
139
|
+
controller.error(new BadRequestException('Failed to read the request body', undefined, { cause: error }))
|
|
140
|
+
}
|
|
141
|
+
const onClose = (): void => {
|
|
142
|
+
if (request.complete) return
|
|
143
|
+
cleanup()
|
|
144
|
+
controller.error(new BadRequestException('The request was aborted'))
|
|
145
|
+
}
|
|
146
|
+
cleanup = (): void => {
|
|
147
|
+
request.off('data', onData)
|
|
148
|
+
request.off('end', onEnd)
|
|
149
|
+
request.off('error', onError)
|
|
150
|
+
request.off('close', onClose)
|
|
151
|
+
}
|
|
152
|
+
request.on('data', onData)
|
|
153
|
+
request.on('end', onEnd)
|
|
154
|
+
request.on('error', onError)
|
|
155
|
+
request.on('close', onClose)
|
|
156
|
+
},
|
|
157
|
+
pull() {
|
|
158
|
+
if (request.isPaused()) request.resume()
|
|
159
|
+
},
|
|
160
|
+
cancel() {
|
|
161
|
+
cleanup()
|
|
162
|
+
request.resume() // discard the rest of the body
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
{ highWaterMark: BODY_HIGH_WATER_MARK, size: (chunk: Uint8Array | undefined) => chunk?.byteLength ?? 0 },
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Options of {@link nodeAdapterRequest}. */
|
|
170
|
+
export interface NodeRequestOptions {
|
|
171
|
+
/** Request target (default: `request.url`). */
|
|
172
|
+
readonly url?: string
|
|
173
|
+
/** `@Req()` value (default: the `IncomingMessage`). */
|
|
174
|
+
readonly raw?: unknown
|
|
175
|
+
/** `@Res()` value (default: the `ServerResponse`). */
|
|
176
|
+
readonly rawResponse?: unknown
|
|
177
|
+
readonly ip?: string | undefined
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Wraps a Node.js request/response pair as an {@link AdapterRequest}. */
|
|
181
|
+
export function nodeAdapterRequest(
|
|
182
|
+
request: IncomingMessage,
|
|
183
|
+
response: ServerResponse,
|
|
184
|
+
options: NodeRequestOptions = {},
|
|
185
|
+
): AdapterRequest {
|
|
186
|
+
let body: Promise<Uint8Array> | undefined
|
|
187
|
+
return {
|
|
188
|
+
method: request.method ?? 'GET',
|
|
189
|
+
url: options.url ?? request.url ?? '/',
|
|
190
|
+
headers: normalizeNodeHeaders(request.headers),
|
|
191
|
+
ip: options.ip ?? request.socket?.remoteAddress,
|
|
192
|
+
remoteAddress: request.socket?.remoteAddress,
|
|
193
|
+
encrypted: isEncrypted(request),
|
|
194
|
+
raw: options.raw ?? request,
|
|
195
|
+
rawResponse: options.rawResponse ?? response,
|
|
196
|
+
readBody: (limit: number): Promise<Uint8Array> => {
|
|
197
|
+
body ??= readNodeBody(request, limit)
|
|
198
|
+
return body
|
|
199
|
+
},
|
|
200
|
+
streamBody: (limit: number): ReadableStream<Uint8Array> => nodeBodyStream(request, limit),
|
|
201
|
+
rawStatus: (): number => response.statusCode,
|
|
202
|
+
onAbort: (listener: () => void): void => onResponseAbort(response, listener),
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Calls `listener` once when `response` closes before it was fully written (the client left): on
|
|
208
|
+
* the next microtask when it is already destroyed, never after a complete response.
|
|
209
|
+
*/
|
|
210
|
+
export function onResponseAbort(response: ServerResponse, listener: () => void): void {
|
|
211
|
+
if (response.writableFinished) return
|
|
212
|
+
if (response.destroyed) {
|
|
213
|
+
queueMicrotask(listener)
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
response.once('close', () => {
|
|
217
|
+
if (!response.writableFinished) listener()
|
|
218
|
+
})
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Whether the request came over TLS (`node:https`, `node:http2` secure servers). */
|
|
222
|
+
export function isEncrypted(request: IncomingMessage): boolean {
|
|
223
|
+
return (request.socket as { readonly encrypted?: unknown } | undefined)?.encrypted === true
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Writes a reply to a `ServerResponse`. Resolves when the response is complete (streams included);
|
|
228
|
+
* never rejects (a failing stream destroys the response).
|
|
229
|
+
*/
|
|
230
|
+
export async function writeNodeReply(response: ServerResponse, reply: AdapterReply): Promise<void> {
|
|
231
|
+
if (reply.raw === true) {
|
|
232
|
+
discardStream(reply.body instanceof ReadableStream ? reply.body : null)
|
|
233
|
+
return
|
|
234
|
+
}
|
|
235
|
+
if (response.headersSent || response.destroyed) {
|
|
236
|
+
discardStream(reply.body instanceof ReadableStream ? reply.body : null)
|
|
237
|
+
if (!response.writableEnded && !response.destroyed) response.end()
|
|
238
|
+
return
|
|
239
|
+
}
|
|
240
|
+
try {
|
|
241
|
+
// one call (merged over headers set earlier with setHeader(), e.g. by Express middleware); the
|
|
242
|
+
// cookies set earlier are kept before the reply's own (writeHead would replace them)
|
|
243
|
+
const headers = withNativeCookies(response.getHeader('set-cookie'), reply.headers)
|
|
244
|
+
response.writeHead(reply.status, headers as OutgoingHttpHeaders)
|
|
245
|
+
} catch {
|
|
246
|
+
// invalid header name or value set by user code
|
|
247
|
+
discardStream(reply.body instanceof ReadableStream ? reply.body : null)
|
|
248
|
+
if (!response.headersSent) {
|
|
249
|
+
for (const name of response.getHeaderNames()) response.removeHeader(name)
|
|
250
|
+
response.statusCode = 500
|
|
251
|
+
response.setHeader('content-type', 'application/json; charset=utf-8')
|
|
252
|
+
response.end('{"statusCode":500,"error":"Internal Server Error"}')
|
|
253
|
+
}
|
|
254
|
+
return
|
|
255
|
+
}
|
|
256
|
+
const body = reply.body
|
|
257
|
+
if (body === null) {
|
|
258
|
+
response.end()
|
|
259
|
+
return
|
|
260
|
+
}
|
|
261
|
+
// an event stream may stay quiet longer than a socket timeout (`server.timeout`, Bun's emulation)
|
|
262
|
+
if (reply.eventStream === true && (response as { isLightMyRequest?: boolean }).isLightMyRequest !== true) {
|
|
263
|
+
response.setTimeout(0)
|
|
264
|
+
}
|
|
265
|
+
if (body instanceof Uint8Array) {
|
|
266
|
+
response.end(body)
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
const stream = body instanceof Blob ? body.stream() : body
|
|
270
|
+
try {
|
|
271
|
+
await pipeline(Readable.fromWeb(stream as import('node:stream/web').ReadableStream), response)
|
|
272
|
+
} catch {
|
|
273
|
+
if (!response.destroyed) response.destroy()
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* `headers` with the `set-cookie` lines already set on the native response (Express middleware
|
|
279
|
+
* `res.cookie()`, Fastify hooks `reply.header('set-cookie', ...)`) before its own: `headers`
|
|
280
|
+
* unchanged when either side has none.
|
|
281
|
+
*/
|
|
282
|
+
export function withNativeCookies(
|
|
283
|
+
native: number | string | readonly string[] | undefined,
|
|
284
|
+
headers: Readonly<Record<string, HeaderValue>>,
|
|
285
|
+
): Readonly<Record<string, HeaderValue>> {
|
|
286
|
+
const own = headers['set-cookie']
|
|
287
|
+
if (own === undefined || native === undefined || typeof native === 'number') return headers
|
|
288
|
+
const before = typeof native === 'string' ? [native] : native
|
|
289
|
+
if (before.length === 0) return headers
|
|
290
|
+
return { ...headers, 'set-cookie': [...before, ...(typeof own === 'string' ? [own] : own)] }
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Last-resort 500 when a handler promise rejected (should not happen: the pipeline maps errors). */
|
|
294
|
+
export function writeNodeFailure(response: ServerResponse): void {
|
|
295
|
+
if (response.headersSent) {
|
|
296
|
+
if (!response.writableEnded) response.destroy()
|
|
297
|
+
return
|
|
298
|
+
}
|
|
299
|
+
response.statusCode = 500
|
|
300
|
+
response.setHeader('content-type', 'application/json; charset=utf-8')
|
|
301
|
+
response.end('{"statusCode":500,"error":"Internal Server Error"}')
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Tracks the connections of a `node:http` server to close them on shutdown: idle keep-alive
|
|
306
|
+
* connections first (graceful), every connection when forced. `server.closeIdleConnections()` /
|
|
307
|
+
* `closeAllConnections()` are not reliable on every runtime (Bun), so sockets are tracked here.
|
|
308
|
+
*/
|
|
309
|
+
export class ConnectionTracker {
|
|
310
|
+
/** Socket → number of requests in progress. */
|
|
311
|
+
readonly #sockets = new Map<Socket, number>()
|
|
312
|
+
#closing = false
|
|
313
|
+
|
|
314
|
+
constructor(server: Server) {
|
|
315
|
+
server.on('connection', (socket: Socket) => {
|
|
316
|
+
this.#sockets.set(socket, 0)
|
|
317
|
+
socket.once('close', () => this.#sockets.delete(socket))
|
|
318
|
+
})
|
|
319
|
+
server.on('request', (request: IncomingMessage, response: ServerResponse) => {
|
|
320
|
+
const socket = request.socket
|
|
321
|
+
if (!this.#sockets.has(socket)) return
|
|
322
|
+
this.#sockets.set(socket, (this.#sockets.get(socket) ?? 0) + 1)
|
|
323
|
+
response.once('close', () => {
|
|
324
|
+
const active = this.#sockets.get(socket)
|
|
325
|
+
if (active === undefined) return
|
|
326
|
+
this.#sockets.set(socket, active - 1)
|
|
327
|
+
if (active - 1 === 0 && this.#closing) socket.end()
|
|
328
|
+
})
|
|
329
|
+
})
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Ends idle connections now and the others once their requests completed. */
|
|
333
|
+
closeIdle(): void {
|
|
334
|
+
this.#closing = true
|
|
335
|
+
for (const [socket, active] of this.#sockets) if (active === 0) socket.end()
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Destroys every connection. */
|
|
339
|
+
destroyAll(): void {
|
|
340
|
+
this.#closing = true
|
|
341
|
+
for (const socket of this.#sockets.keys()) socket.destroy()
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Closes a `node:http` server: graceful (idle connections ended), then forced when asked. */
|
|
346
|
+
export function closeServer(
|
|
347
|
+
server: Server,
|
|
348
|
+
tracker: ConnectionTracker,
|
|
349
|
+
closing: Promise<void> | undefined,
|
|
350
|
+
force: boolean,
|
|
351
|
+
): Promise<void> {
|
|
352
|
+
const promise =
|
|
353
|
+
closing ??
|
|
354
|
+
new Promise<void>(resolve => {
|
|
355
|
+
if (!server.listening) {
|
|
356
|
+
resolve()
|
|
357
|
+
return
|
|
358
|
+
}
|
|
359
|
+
server.close(() => resolve())
|
|
360
|
+
server.closeIdleConnections?.()
|
|
361
|
+
tracker.closeIdle()
|
|
362
|
+
})
|
|
363
|
+
if (force) {
|
|
364
|
+
server.closeAllConnections?.()
|
|
365
|
+
tracker.destroyAll()
|
|
366
|
+
}
|
|
367
|
+
return promise
|
|
368
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@znight/http/node`: the built-in, zero-dependency adapter on `node:http` (default adapter of
|
|
3
|
+
* `HttpPlugin`). Works on Node.js and Bun. `inject()` uses the optional peer `light-my-request`.
|
|
4
|
+
*/
|
|
5
|
+
import type {
|
|
6
|
+
AdapterCloseOptions,
|
|
7
|
+
AdapterHandler,
|
|
8
|
+
AdapterRoute,
|
|
9
|
+
FallbackHandler,
|
|
10
|
+
HttpAdapter,
|
|
11
|
+
HttpServerAddress,
|
|
12
|
+
InjectRequest,
|
|
13
|
+
InjectResponse,
|
|
14
|
+
InjectStreamRequest,
|
|
15
|
+
InjectStreamResponse,
|
|
16
|
+
} from '../adapter.ts'
|
|
17
|
+
|
|
18
|
+
import { createServer, type IncomingMessage, type Server, type ServerOptions, type ServerResponse } from 'node:http'
|
|
19
|
+
|
|
20
|
+
import { RouteTable } from '../router.ts'
|
|
21
|
+
import { ConnectionTracker, closeServer, nodeAdapterRequest, writeNodeFailure, writeNodeReply } from './node-io.ts'
|
|
22
|
+
import { bufferInjectStream, injectNodeStream, serverAddress } from './shared.ts'
|
|
23
|
+
|
|
24
|
+
/** Options of {@link nodeAdapter}. */
|
|
25
|
+
export interface NodeAdapterOptions {
|
|
26
|
+
/** Options of `http.createServer()` (timeouts, `maxHeaderSize`...). Ignored with {@link server}. */
|
|
27
|
+
readonly serverOptions?: ServerOptions
|
|
28
|
+
/**
|
|
29
|
+
* An existing server (e.g. `https.createServer({ key, cert })`). The adapter adds its `request`
|
|
30
|
+
* listener on `listen()` and closes the server on shutdown.
|
|
31
|
+
*/
|
|
32
|
+
readonly server?: Server
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The built-in Node.js adapter. */
|
|
36
|
+
export interface NodeAdapter extends HttpAdapter {
|
|
37
|
+
readonly name: 'node'
|
|
38
|
+
/** Request listener serving the routes, to mount the application in a server you manage. */
|
|
39
|
+
readonly handler: (request: IncomingMessage, response: ServerResponse) => void
|
|
40
|
+
/** The server, once listening (or the one given in the options). */
|
|
41
|
+
readonly server: Server | undefined
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Creates the built-in `node:http` adapter: a segment-trie router (static > `:param` > `*`, with
|
|
46
|
+
* backtracking), zero dependencies.
|
|
47
|
+
*
|
|
48
|
+
* ```ts
|
|
49
|
+
* HttpPlugin.configure({ adapter: nodeAdapter({ serverOptions: { requestTimeout: 30_000 } }) })
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
export function nodeAdapter(options: NodeAdapterOptions = {}): NodeAdapter {
|
|
53
|
+
return new NodeHttpAdapter(options)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
class NodeHttpAdapter implements NodeAdapter {
|
|
57
|
+
readonly name = 'node' as const
|
|
58
|
+
readonly #routes = new RouteTable<AdapterHandler>()
|
|
59
|
+
readonly #options: NodeAdapterOptions
|
|
60
|
+
#fallback: FallbackHandler | undefined
|
|
61
|
+
#server: Server | undefined
|
|
62
|
+
#tracker: ConnectionTracker | undefined
|
|
63
|
+
#closing: Promise<void> | undefined
|
|
64
|
+
|
|
65
|
+
constructor(options: NodeAdapterOptions) {
|
|
66
|
+
this.#options = options
|
|
67
|
+
this.#server = options.server
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
get server(): Server | undefined {
|
|
71
|
+
return this.#server
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
registerRoute(route: AdapterRoute): void {
|
|
75
|
+
if (this.#routes.add(route.method, route.path, route.handler) !== undefined) {
|
|
76
|
+
throw new Error(`Duplicate route ${route.method} ${route.path.path}`)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
setFallbackHandler(handler: FallbackHandler): void {
|
|
81
|
+
this.#fallback = handler
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
readonly handler = (request: IncomingMessage, response: ServerResponse): void => {
|
|
85
|
+
const url = request.url ?? '/'
|
|
86
|
+
const query = url.indexOf('?')
|
|
87
|
+
const handler = this.#routes.find(request.method ?? 'GET', query === -1 ? url : url.slice(0, query))
|
|
88
|
+
const adapterRequest = nodeAdapterRequest(request, response)
|
|
89
|
+
let reply: ReturnType<AdapterHandler>
|
|
90
|
+
if (handler !== undefined) reply = handler(adapterRequest)
|
|
91
|
+
else if (this.#fallback === undefined) {
|
|
92
|
+
writeNodeFailure(response)
|
|
93
|
+
return
|
|
94
|
+
} else reply = this.#fallback(adapterRequest)
|
|
95
|
+
reply.then(
|
|
96
|
+
result => writeNodeReply(response, result),
|
|
97
|
+
() => writeNodeFailure(response),
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async listen(port: number, host: string): Promise<HttpServerAddress> {
|
|
102
|
+
const server = this.#server ?? createServer(this.#options.serverOptions ?? {})
|
|
103
|
+
this.#server = server
|
|
104
|
+
this.#tracker ??= new ConnectionTracker(server)
|
|
105
|
+
this.#closing = undefined
|
|
106
|
+
if (!server.listeners('request').includes(this.handler)) server.on('request', this.handler)
|
|
107
|
+
await new Promise<void>((resolve, reject) => {
|
|
108
|
+
const onError = (error: Error): void => reject(error)
|
|
109
|
+
server.once('error', onError)
|
|
110
|
+
server.listen(port, host, () => {
|
|
111
|
+
server.off('error', onError)
|
|
112
|
+
resolve()
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
const address = server.address()
|
|
116
|
+
if (address === null || typeof address === 'string') return serverAddress(host, port)
|
|
117
|
+
return serverAddress(address.address, address.port, address.family)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
close(options: AdapterCloseOptions = {}): Promise<void> {
|
|
121
|
+
const server = this.#server
|
|
122
|
+
const tracker = this.#tracker
|
|
123
|
+
if (server === undefined || tracker === undefined) return Promise.resolve()
|
|
124
|
+
this.#closing = closeServer(server, tracker, this.#closing, options.force === true)
|
|
125
|
+
return this.#closing
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
inject(request: InjectRequest): Promise<InjectResponse> {
|
|
129
|
+
return bufferInjectStream(stream => this.injectStream(stream), request)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
injectStream(request: InjectStreamRequest): Promise<InjectStreamResponse> {
|
|
133
|
+
return injectNodeStream('node', this.handler as (req: never, res: never) => void, request)
|
|
134
|
+
}
|
|
135
|
+
}
|