@nxtedition/http 1.0.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) nxtedition
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,172 @@
1
+ # @nxtedition/http
2
+
3
+ HTTP/HTTP2 server middleware framework with priority-based scheduling, request lifecycle tracking, and Koa-style middleware composition.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @nxtedition/http
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ### Basic Server
14
+
15
+ ```js
16
+ import { createServer } from '@nxtedition/http'
17
+
18
+ const server = createServer({ keepAliveTimeout: 120_000 }, { logger: pino() }, (ctx) => {
19
+ ctx.res.writeHead(200, { 'content-type': 'text/plain' })
20
+ ctx.res.end('Hello World')
21
+ })
22
+
23
+ server.listen(3000)
24
+ ```
25
+
26
+ ### Middleware Composition
27
+
28
+ ```js
29
+ import { createServer, compose } from '@nxtedition/http'
30
+
31
+ const auth = async (ctx, next) => {
32
+ if (!ctx.req.headers.authorization) {
33
+ const err = new Error('Unauthorized')
34
+ err.statusCode = 401
35
+ err.expose = true
36
+ throw err
37
+ }
38
+ await next()
39
+ }
40
+
41
+ const handler = async (ctx) => {
42
+ ctx.res.writeHead(200)
43
+ ctx.res.end(JSON.stringify({ user: 'authenticated' }))
44
+ }
45
+
46
+ const server = createServer({ logger: pino() }, [auth, handler])
47
+ ```
48
+
49
+ ### Priority-Based Scheduling
50
+
51
+ Requests are automatically prioritized based on the `nxt-priority` header or user-agent detection. Priority maps to DSCP/ToS values on the socket:
52
+
53
+ | Priority | Value | DSCP | Use Case |
54
+ | -------- | ----- | ---- | ---------------- |
55
+ | highest | 3 | EF | Realtime |
56
+ | higher | 2 | AF41 | High priority |
57
+ | high | 1 | AF31 | User experience |
58
+ | normal | 0 | BE | Default traffic |
59
+ | low | -1 | LE | Background tasks |
60
+ | lower | -2 | LE | Replication |
61
+ | lowest | -3 | LE | Batch jobs |
62
+
63
+ ### Error Handling
64
+
65
+ Thrown errors are automatically serialized to HTTP responses:
66
+
67
+ ```js
68
+ const handler = (ctx) => {
69
+ const err = new Error('Validation failed')
70
+ err.statusCode = 422
71
+ err.body = { errors: [{ field: 'email', message: 'required' }] }
72
+ err.headers = { 'x-request-id': ctx.id }
73
+ throw err
74
+ }
75
+ ```
76
+
77
+ Error properties:
78
+
79
+ - `statusCode` - HTTP status code (default: 500)
80
+ - `body` - Response body (object, string, or Buffer)
81
+ - `headers` - Additional response headers (object or flat array)
82
+ - `expose` - If `true`, includes `err.message` as JSON response body
83
+
84
+ ### Context Factory
85
+
86
+ ```js
87
+ import { createServer, Context } from '@nxtedition/http'
88
+
89
+ const server = createServer((req, res) => {
90
+ const ctx = new Context(req, res, logger)
91
+ ctx.db = database
92
+ return ctx
93
+ }, handler)
94
+ ```
95
+
96
+ ### AbortSignal Support
97
+
98
+ ```js
99
+ const handler = async (ctx) => {
100
+ // ctx.signal is aborted when the request errors or is aborted
101
+ const data = await fetch('http://upstream/api', { signal: ctx.signal })
102
+ ctx.res.writeHead(200)
103
+ ctx.res.end(await data.text())
104
+ }
105
+ ```
106
+
107
+ ## API
108
+
109
+ ### `createServer(ctx, middleware?)`
110
+
111
+ ### `createServer(options, ctx, middleware?)`
112
+
113
+ Creates an HTTP server with enhanced request/response classes.
114
+
115
+ - **options** - Server options (extends `http.ServerOptions`)
116
+ - `logger` - Pino-compatible logger
117
+ - `signal` - `AbortSignal` for graceful shutdown
118
+ - `socketTimeout` / `timeout` - Socket timeout (default: 120s)
119
+ - **ctx** - Context config object `{ logger, ...opaque }` or factory function `(req, res) => Context`
120
+ - **middleware** - Handler function or array of middleware functions
121
+
122
+ ### `compose(...middleware)`
123
+
124
+ Composes middleware functions into a single middleware. Supports arrays and variadic arguments. In development mode, validates that `next()` is not called multiple times and that middleware properly awaits downstream.
125
+
126
+ ### `Context`
127
+
128
+ Request context wrapping `req` and `res` with:
129
+
130
+ - `id` - Unique request ID
131
+ - `req` - `IncomingMessage` instance
132
+ - `res` - `ServerResponse` instance
133
+ - `target` - Parsed URL target (protocol, hostname, port, pathname, search)
134
+ - `query` - Parsed query parameters
135
+ - `userAgent` - User-Agent header value
136
+ - `priority` - Resolved request priority
137
+ - `signal` - `AbortSignal` (lazily created)
138
+ - `logger` - Child logger with request ID
139
+
140
+ ### `IncomingMessage`
141
+
142
+ Extended `http.IncomingMessage` with computed `id`, `target`, and `query` properties. Results are lazily cached and invalidated when URL or host changes.
143
+
144
+ ### `ServerResponse`
145
+
146
+ Extended `http.ServerResponse` with:
147
+
148
+ - `timing` - Request timing metrics (created, connect, headers, data, end)
149
+ - `bytesWritten` - Total bytes written to response
150
+ - Custom header tracking that is independent of Node.js internals
151
+
152
+ ### `requestMiddleware(ctx, next)`
153
+
154
+ Core middleware that handles request lifecycle:
155
+
156
+ - Sets `request-id` response header
157
+ - Fast-dumps GET/HEAD request bodies
158
+ - Tracks pending requests
159
+ - Serializes errors to HTTP responses
160
+ - Logs request lifecycle events
161
+
162
+ ### `upgradeMiddleware(ctx, next)`
163
+
164
+ Middleware for WebSocket/upgrade request handling with lifecycle logging.
165
+
166
+ ### `genReqId()`
167
+
168
+ Generates unique request IDs using V8 SMI-optimized integers (`req-{base36}`).
169
+
170
+ ## License
171
+
172
+ MIT
package/lib/index.d.ts ADDED
@@ -0,0 +1,112 @@
1
+ import http2 from 'node:http2';
2
+ import type net from 'node:net';
3
+ import type { RequestTarget } from 'request-target';
4
+ import http from 'http';
5
+ import type { Logger } from 'pino';
6
+ export type { RequestTarget };
7
+ export declare const kAbortController: unique symbol;
8
+ export declare function genReqId(): string;
9
+ declare const kPendingIndex: unique symbol;
10
+ export declare class Context {
11
+ #private;
12
+ [kPendingIndex]: number;
13
+ constructor(req: IncomingMessage, res: ServerResponse, logger: Logger);
14
+ get priority(): -3 | -2 | -1 | 0 | 1 | 2 | 3;
15
+ get id(): string;
16
+ get logger(): Logger;
17
+ get req(): IncomingMessage;
18
+ get res(): ServerResponse;
19
+ get target(): Readonly<RequestTarget> | null;
20
+ get query(): Record<string, string>;
21
+ get userAgent(): string;
22
+ get [kAbortController](): AbortController | undefined;
23
+ get signal(): AbortSignal;
24
+ }
25
+ export declare function upgradeMiddleware(ctx: Context & {
26
+ req: http.IncomingMessage;
27
+ socket: net.Socket;
28
+ }, next: () => unknown): Promise<void>;
29
+ export declare function requestMiddleware(ctx: Context & {
30
+ stats?: {
31
+ pending?: number;
32
+ completed?: number;
33
+ failed?: number;
34
+ };
35
+ }, next: () => unknown): Promise<void>;
36
+ export declare class IncomingMessage extends http.IncomingMessage {
37
+ #private;
38
+ get id(): string;
39
+ get target(): Readonly<RequestTarget> | null;
40
+ get query(): Record<string, string>;
41
+ }
42
+ export declare class ServerResponse extends http.ServerResponse {
43
+ #private;
44
+ get timing(): {
45
+ created: number;
46
+ connect: number;
47
+ headers: number;
48
+ data: number;
49
+ end: number;
50
+ };
51
+ get bytesWritten(): number;
52
+ setHeader(key: string, val: string | number): this;
53
+ removeHeader(key: string): void;
54
+ getHeaderNames(): string[];
55
+ getHeaders(): Record<string, string | undefined>;
56
+ get headersSent(): boolean;
57
+ writeHead(statusCode: number, statusMessage?: string, headers?: http.OutgoingHttpHeaders): this;
58
+ writeHead(statusCode: number, headers?: http.OutgoingHttpHeaders): this;
59
+ assignSocket(socket: net.Socket): void;
60
+ write(chunk: string | Buffer, callback?: (error: Error | null | undefined) => void): boolean;
61
+ write(chunk: string | Buffer, encoding: BufferEncoding, callback?: (error: Error | null | undefined) => void): boolean;
62
+ end(callback?: () => void): this;
63
+ end(data: string | Uint8Array, callback?: () => void): this;
64
+ end(data: string | Uint8Array, encoding: BufferEncoding, callback?: () => void): this;
65
+ destroy(err?: Error): this;
66
+ }
67
+ export declare class Http2ServerRequest extends http2.Http2ServerRequest {
68
+ #private;
69
+ get id(): string;
70
+ get target(): Readonly<RequestTarget> | null;
71
+ get query(): Record<string, string>;
72
+ }
73
+ export declare class Http2ServerResponse extends http2.Http2ServerResponse {
74
+ #private;
75
+ get timing(): {
76
+ created: number;
77
+ connect: number;
78
+ headers: number;
79
+ data: number;
80
+ end: number;
81
+ };
82
+ get bytesWritten(): number;
83
+ setHeader(key: string, val: string | number): this;
84
+ removeHeader(key: string): void;
85
+ getHeaderNames(): string[];
86
+ getHeaders(): Record<string, string | undefined>;
87
+ get headersSent(): boolean;
88
+ writeHead(statusCode: number, headers?: http2.OutgoingHttpHeaders): this;
89
+ writeHead(statusCode: number, statusMessage: string, headers?: http2.OutgoingHttpHeaders): this;
90
+ write(chunk: string | Uint8Array, callback?: (err: Error) => void): boolean;
91
+ write(chunk: string | Uint8Array, encoding: BufferEncoding, callback?: (err: Error) => void): boolean;
92
+ end(callback?: () => void): this;
93
+ end(data: string | Uint8Array, callback?: () => void): this;
94
+ end(data: string | Uint8Array, encoding: BufferEncoding, callback?: () => void): this;
95
+ destroy(err?: Error): this;
96
+ }
97
+ interface CreateServerOptions extends http.ServerOptions {
98
+ logger?: Logger;
99
+ signal?: AbortSignal;
100
+ socketTimeout?: number;
101
+ keepAliveTimeout?: number;
102
+ headersTimeout?: number;
103
+ requestTimeout?: number;
104
+ }
105
+ type ContextFactory = (req: IncomingMessage, res: ServerResponse) => Context;
106
+ type ContextOptions = Record<string, unknown> & {
107
+ logger?: Logger;
108
+ };
109
+ export declare function createServer(ctx: ContextFactory | ContextOptions, middleware?: MiddlewareFn | MiddlewareFn[]): http.Server;
110
+ export declare function createServer(options: CreateServerOptions, ctx: ContextOptions | ContextFactory, middleware?: MiddlewareFn | MiddlewareFn[]): http.Server;
111
+ type MiddlewareFn = (ctx: Context, next: () => unknown) => unknown;
112
+ export declare const compose: (...middleware: (MiddlewareFn | MiddlewareFn[])[]) => MiddlewareFn;
package/lib/index.js ADDED
@@ -0,0 +1,1076 @@
1
+ import http2 from 'node:http2'
2
+ import assert from 'node:assert'
3
+ import { TLSSocket } from 'node:tls'
4
+
5
+ import { performance } from 'perf_hooks'
6
+ import requestTarget from 'request-target'
7
+
8
+ import querystring from 'fast-querystring'
9
+ import http from 'http'
10
+ import { parsePriority, Scheduler } from '@nxtedition/scheduler'
11
+
12
+
13
+
14
+
15
+ export const kAbortController = Symbol('abortController')
16
+
17
+ const kEmptyObj = Object.freeze({})
18
+ const kEmptyArr = Object.freeze([] )
19
+
20
+ const ERR_HEADER_EXPR =
21
+ /^(content-length|content-type|te|host|upgrade|trailers|connection|keep-alive|http2-settings|transfer-encoding|proxy-connection|proxy-authenticate|proxy-authorization)$/i
22
+
23
+ // https://github.com/fastify/fastify/blob/main/lib/reqIdGenFactory.js
24
+ // 2,147,483,647 (2^31 − 1) stands for max SMI value (an internal optimization of V8).
25
+ // With this upper bound, if you'll be generating 1k ids/sec, you're going to hit it in ~25 days.
26
+ // This is very likely to happen in real-world applications, hence the limit is enforced.
27
+ // Growing beyond this value will make the id generation slower and cause a deopt.
28
+ // In the worst cases, it will become a float, losing accuracy.
29
+ const maxInt = 2147483647
30
+ let nextReqId = Math.floor(Math.random() * maxInt)
31
+ export function genReqId() {
32
+ nextReqId = (nextReqId + 1) & maxInt
33
+ return 'req-' + nextReqId.toString(36)
34
+ }
35
+
36
+ function requestTargetSlow(req ) {
37
+ let base
38
+ if (req.httpVersionMajor === 2) {
39
+ base = `${req.headers[':scheme']}://${req.headers[':authority'] || 'localhost'}`
40
+ } else {
41
+ const scheme = req.socket instanceof TLSSocket ? 'https:' : 'http:'
42
+ base = `${scheme}://${req.headers.host || 'localhost'}`
43
+ }
44
+ try {
45
+ const url = new URL(req.url, base)
46
+ return {
47
+ protocol: url.protocol,
48
+ hostname: url.hostname,
49
+ port: url.port || (url.protocol === 'https:' ? '443' : '80'),
50
+ pathname: url.pathname,
51
+ search: url.search || '',
52
+ }
53
+ } catch {
54
+ return null
55
+ }
56
+ }
57
+
58
+ const pending = ((
59
+ globalThis
60
+ ).__nxt_lib_http_pending = [])
61
+ const kPendingIndex = Symbol('pendingIndex')
62
+
63
+ // # | highest | 0 | 0 | EF(0x2e → TOS ) | 7 | Realtime CasparCG |
64
+ // # | higher | 1 | 1 | AF41(0x22 → TOS 0x88) | 6 | Deepstream |
65
+ // # | high | 2 | 2 | AF31(0x1a → TOS 0x68) | 5 | User - experience: drive cache, hub request |
66
+ // # | normal | 3 | 3 | BE(0x00 → TOS 0x00) | 0 | Default traffic, record updates, live changes |
67
+ // # | low | 4 | 4 | LE(0x01 → TOS 0x04) | 4 | Scheduled renders, background proxies |
68
+ // # | lower | 5 | 5 | LE(0x01 → TOS 0x04) | 3 | Replication, index rebuilds, design views |
69
+ // # | lowest | 6 | 6 | LE(0x01 → TOS 0x04) | 1 | Transcription, AI analysis, batch jobs |
70
+ const PRIORITY_TOS = []
71
+ assert(Scheduler.LOWEST === -3)
72
+ PRIORITY_TOS[Scheduler.HIGHEST + 3] = 0xb8 // EF
73
+ PRIORITY_TOS[Scheduler.HIGHER + 3] = 0x88 // AF41
74
+ PRIORITY_TOS[Scheduler.HIGH + 3] = 0x68 // AF31
75
+ PRIORITY_TOS[Scheduler.NORMAL + 3] = 0x00 // BE
76
+ PRIORITY_TOS[Scheduler.LOW + 3] = 0x04 // LE
77
+ PRIORITY_TOS[Scheduler.LOWER + 3] = 0x04 // LE
78
+ PRIORITY_TOS[Scheduler.LOWEST + 3] = 0x04 // LE
79
+
80
+ function isPlainObject(value ) {
81
+ if (typeof value !== 'object' || value === null) {
82
+ return false
83
+ }
84
+ const proto = Object.getPrototypeOf(value)
85
+ return proto === Object.prototype || proto === null
86
+ }
87
+
88
+ function getHeader(
89
+ val ,
90
+ key ,
91
+ ) {
92
+ const v = val[key]
93
+ return typeof v === 'string' ? v : undefined
94
+ }
95
+
96
+ export class Context {
97
+ #id
98
+ #req
99
+ #res
100
+ #ac
101
+ #logger
102
+ #query
103
+ #target
104
+ #priority ;
105
+ [kPendingIndex] = -1
106
+
107
+ constructor(req , res , logger ) {
108
+ assert(req)
109
+ assert(res)
110
+
111
+ this.#id =
112
+ req.id ||
113
+ getHeader(req.headers, 'request-id') ||
114
+ getHeader(req.headers, 'Request-Id') ||
115
+ genReqId()
116
+ this.#req = req
117
+ this.#res = res
118
+
119
+ const p = getHeader(req.headers, 'nxt-priority')
120
+ if (!p) {
121
+ this.#priority = (
122
+ getHeader(req.headers, 'user-agent') || getHeader(req.headers, 'User-Agent')
123
+ )?.startsWith('caspar/')
124
+ ? Scheduler.HIGHEST
125
+ : Scheduler.NORMAL
126
+ } else {
127
+ this.#priority = parsePriority(p)
128
+ }
129
+
130
+ this.#logger = logger.child({ rid: this.#id, priority: this.#priority })
131
+
132
+ if (!('stream' in res) && res.socket && 'setTypeOfService' in res.socket) {
133
+ ;(res.socket ).setTypeOfService(
134
+ PRIORITY_TOS[this.#priority + 3] ?? 0x0,
135
+ )
136
+ }
137
+ }
138
+
139
+ get priority() {
140
+ return this.#priority
141
+ }
142
+
143
+ get id() {
144
+ return this.#id
145
+ }
146
+
147
+ get logger() {
148
+ return this.#logger
149
+ }
150
+
151
+ get req() {
152
+ return this.#req
153
+ }
154
+
155
+ get res() {
156
+ return this.#res
157
+ }
158
+
159
+ get target() {
160
+ if (this.#req.target) {
161
+ return this.#req.target
162
+ }
163
+ if (this.#target === undefined) {
164
+ const t = requestTarget(this.#req)
165
+ this.#target = t ? Object.freeze(t) : null
166
+ }
167
+ return this.#target
168
+ }
169
+
170
+ get query() {
171
+ if (this.#req.query) {
172
+ return this.#req.query
173
+ }
174
+
175
+ const search = this.target?.search
176
+
177
+ return (this.#query ??=
178
+ search && search.length > 1 ? Object.freeze(querystring.parse(search.slice(1))) : {})
179
+ }
180
+
181
+ get userAgent() {
182
+ return (
183
+ getHeader(this.#req.headers, 'user-agent') || getHeader(this.#req.headers, 'User-Agent') || ''
184
+ )
185
+ }
186
+
187
+ get [kAbortController]() {
188
+ return this.#ac
189
+ }
190
+
191
+ get signal() {
192
+ this.#ac ??= new AbortController()
193
+ return this.#ac.signal
194
+ }
195
+ }
196
+
197
+ function noop() {}
198
+
199
+ export async function upgradeMiddleware(
200
+ ctx ,
201
+ next ,
202
+ ) {
203
+ const { req, socket } = ctx
204
+ const startTime = performance.now()
205
+
206
+ let aborted = false
207
+
208
+ req.on('error', noop)
209
+ socket.on('error', (err ) => {
210
+ // NOTE: Special case where the client becomes unreachable.
211
+ if (err.message.startsWith('read ')) {
212
+ aborted = true
213
+ }
214
+ })
215
+
216
+ const reqLogger = ctx.logger?.child({ req })
217
+ try {
218
+ const isHealthcheck = req.url === '/healthcheck' || req.url === '/_up'
219
+ if (!isHealthcheck) {
220
+ reqLogger?.debug({ req }, 'request started')
221
+ }
222
+
223
+ const thenable = next()
224
+
225
+ if (Object.hasOwnProperty.call(thenable, 'then')) {
226
+ await thenable
227
+ }
228
+
229
+ if (!socket.destroyed && !socket.writableEnded) {
230
+ throw new Error('Stream not completed')
231
+ }
232
+
233
+ const elapsedTime = performance.now() - startTime
234
+
235
+ if (isHealthcheck) {
236
+ // Do nothing...
237
+ } else if (socket.errored) {
238
+ reqLogger?.error({ err: socket.errored, req, socket, elapsedTime }, 'stream error')
239
+ } else if (!socket.writableEnded) {
240
+ reqLogger?.debug({ socket, elapsedTime }, 'stream aborted')
241
+ } else if ((socket ).statusCode >= 500) {
242
+ reqLogger?.error({ socket, elapsedTime }, 'stream error')
243
+ } else if ((socket ).statusCode >= 400) {
244
+ reqLogger?.warn({ socket, elapsedTime }, 'stream failed')
245
+ } else {
246
+ reqLogger?.debug({ socket, elapsedTime }, 'stream completed')
247
+ }
248
+ } catch (err) {
249
+ ctx[kAbortController]?.abort(err)
250
+
251
+ const statusCode = err.statusCode || err.$metadata?.httpStatusCode || 500
252
+ const elapsedTime = performance.now() - startTime
253
+
254
+ if (req.aborted || aborted || (!socket.errored && socket.closed) || err.name === 'AbortError') {
255
+ reqLogger?.debug({ err, req, socket, elapsedTime }, 'stream aborted')
256
+ } else if (
257
+ statusCode < 500 ||
258
+ err.code === 'ERR_STREAM_PREMATURE_CLOSE' ||
259
+ err.code === 'EPIPE'
260
+ ) {
261
+ reqLogger?.warn({ err, req, socket, elapsedTime }, 'stream failed')
262
+ } else {
263
+ reqLogger?.error({ err, req, socket, elapsedTime }, 'stream error')
264
+ }
265
+ socket.destroy(err )
266
+ } finally {
267
+ if (!socket.writableEnded && !socket.destroyed) {
268
+ socket.destroy()
269
+ reqLogger?.warn('socket destroyed')
270
+ }
271
+ }
272
+ }
273
+
274
+ export async function requestMiddleware(
275
+ ctx ,
276
+ next ,
277
+ ) {
278
+ const { req, res, target, stats } = ctx
279
+ const startTime = performance.now()
280
+
281
+ const reqLogger = ctx.logger?.child({ req })
282
+
283
+ if (stats?.pending != null) {
284
+ stats.pending++
285
+ }
286
+
287
+ if (ctx[kPendingIndex] !== -1) {
288
+ throw new Error('context is already pending')
289
+ }
290
+
291
+ ctx[kPendingIndex] = pending.push(ctx) - 1
292
+ try {
293
+ const isHealthcheck = req.url === '/healthcheck' || req.url === '/_up'
294
+ if (!isHealthcheck) {
295
+ reqLogger?.debug({ req }, 'request started')
296
+ }
297
+
298
+ if (!target) {
299
+ throw Object.assign(new Error('invalid url'), { statusCode: 400, expose: true })
300
+ }
301
+
302
+ if (ctx.id) {
303
+ res.setHeader('request-id', ctx.id)
304
+ }
305
+
306
+ if (req.httpVersionMajor === 1 && (req.method === 'HEAD' || req.method === 'GET')) {
307
+ // Fast dump where request "has" already emitted all lifecycle events.
308
+ // This avoid a lot of unnecessary overhead otherwise introduced by
309
+ // stream.Readable life cycle rules. The downside is that this will
310
+ // break some servers that read GET bodies.
311
+ const r = req
312
+
313
+
314
+
315
+
316
+ r._dumped = true
317
+ r._readableState.ended = true
318
+ r._readableState.endEmitted = true
319
+ r._readableState.destroyed = true
320
+ r._readableState.closed = true
321
+ r._readableState.closeEmitted = true
322
+
323
+ r._read()
324
+ }
325
+
326
+ await next()
327
+
328
+ if (!res.destroyed && !res.writableEnded) {
329
+ throw new Error('Response not completed')
330
+ }
331
+
332
+ if (stats?.completed != null) {
333
+ stats.completed++
334
+ }
335
+
336
+ const elapsedTime = performance.now() - startTime
337
+
338
+ if (isHealthcheck) {
339
+ // Do nothing...
340
+ } else if (res.errored) {
341
+ reqLogger?.error({ err: res.errored, res, elapsedTime }, 'request error')
342
+ } else if (!res.writableEnded) {
343
+ reqLogger?.debug({ res, elapsedTime }, 'request aborted')
344
+ } else if (res.statusCode >= 500) {
345
+ reqLogger?.error({ res, elapsedTime }, 'request error')
346
+ } else if (res.statusCode >= 400) {
347
+ reqLogger?.warn({ res, elapsedTime }, 'request failed')
348
+ } else {
349
+ reqLogger?.debug({ res, elapsedTime }, 'request completed')
350
+ }
351
+ } catch (err) {
352
+ if (stats?.failed != null) {
353
+ stats.failed++
354
+ }
355
+
356
+ ctx[kAbortController]?.abort(err)
357
+
358
+ if (!req.closed) {
359
+ req.on('error', (err ) => {
360
+ reqLogger?.warn({ err }, 'request error')
361
+ })
362
+ }
363
+
364
+ if (!res.closed) {
365
+ res.on('error', (err ) => {
366
+ reqLogger?.warn({ err }, 'response error')
367
+ })
368
+ }
369
+
370
+ const statusCode = err.statusCode || err.$metadata?.httpStatusCode || 500
371
+ const elapsedTime = performance.now() - startTime
372
+
373
+ // res.destroyed is not properly set by http2 compat so we need to
374
+ // also check res.closed.
375
+ if (!res.headersSent && !res.destroyed && !res.closed && !req.aborted) {
376
+ res.statusCode = statusCode
377
+
378
+ for (const name of res.getHeaderNames()) {
379
+ res.removeHeader(name)
380
+ }
381
+
382
+ if (ctx.id) {
383
+ res.setHeader('request-id', ctx.id)
384
+ }
385
+
386
+ const { headers } = err
387
+
388
+ if (isPlainObject(headers)) {
389
+ for (const [key, val] of Object.entries(headers)) {
390
+ if (typeof key === 'string' && !ERR_HEADER_EXPR.test(key)) {
391
+ res.setHeader(key, val )
392
+ }
393
+ }
394
+ } else if (Array.isArray(err.headers)) {
395
+ assert(headers.length % 2 === 0)
396
+ assert(headers.length === 0 || typeof headers[0] === 'string')
397
+ for (let n = 0; n < headers.length; n += 2) {
398
+ const key = headers[n + 0]
399
+ const val = headers[n + 1]
400
+ if (typeof key === 'string' && !ERR_HEADER_EXPR.test(key)) {
401
+ res.setHeader(key, val )
402
+ }
403
+ }
404
+ } else if (err.headers != null) {
405
+ reqLogger?.warn({ req, err }, 'invalid headers')
406
+ }
407
+
408
+ if (isPlainObject(err.body)) {
409
+ res.setHeader('content-type', 'application/json')
410
+ res.end(JSON.stringify(err.body))
411
+ } else if (typeof err.body === 'string') {
412
+ res.end(err.body )
413
+ } else if (Buffer.isBuffer(err.body)) {
414
+ res.end(err.body )
415
+ } else if (err.expose === true && typeof err.message === 'string') {
416
+ res.setHeader('content-type', 'application/json')
417
+ res.end(JSON.stringify({ message: err.message }))
418
+ } else {
419
+ res.end()
420
+ }
421
+
422
+ if (statusCode < 500) {
423
+ reqLogger?.warn({ req, res, err, elapsedTime }, 'request failed')
424
+ } else {
425
+ reqLogger?.error({ req, res, err, elapsedTime }, 'request error')
426
+ }
427
+ } else {
428
+ if (req.aborted || (!res.errored && res.closed) || err.name === 'AbortError') {
429
+ reqLogger?.debug({ req, res, err, elapsedTime }, 'request aborted')
430
+ } else if (statusCode < 500) {
431
+ reqLogger?.warn({ req, res, err, elapsedTime }, 'request failed')
432
+ } else {
433
+ reqLogger?.error({ req, res, err, elapsedTime }, 'request error')
434
+ }
435
+ res.destroy(err )
436
+ }
437
+ } finally {
438
+ if (stats?.pending != null) {
439
+ stats.pending--
440
+ }
441
+
442
+ if (ctx[kPendingIndex] !== -1) {
443
+ const idx = ctx[kPendingIndex]
444
+ ctx[kPendingIndex] = -1
445
+
446
+ const tmp = pending.pop()
447
+ if (tmp !== ctx) {
448
+ pending[idx] = tmp
449
+ tmp[kPendingIndex] = idx
450
+ }
451
+ }
452
+
453
+ if (
454
+ res.writableEnded ||
455
+ res.destroyed ||
456
+ ('stream' in res && (res ).stream?.destroyed)
457
+ ) {
458
+ // Do nothing..
459
+ } else {
460
+ res.on('error', noop)
461
+ res.destroy()
462
+ ctx.logger?.warn('request destroyed')
463
+ }
464
+ }
465
+ }
466
+
467
+ export class IncomingMessage extends http.IncomingMessage {
468
+ #host
469
+ #url
470
+ #socket
471
+ #target
472
+
473
+ #id
474
+ #search
475
+ #query
476
+
477
+ get id() {
478
+ return (this.#id ??= (this.headers['request-id'] ) || genReqId())
479
+ }
480
+
481
+ get target() {
482
+ if (
483
+ this.#host !== this.headers.host ||
484
+ this.#url !== this.url ||
485
+ this.#socket !== this.socket
486
+ ) {
487
+ this.#socket = this.socket
488
+ this.#host = this.headers.host
489
+ this.#url = this.url
490
+ this.#target = undefined
491
+ }
492
+
493
+ if (this.#target === undefined) {
494
+ const t = requestTarget(this)
495
+ this.#target = t ? Object.freeze(t) : null
496
+ }
497
+ return this.#target
498
+ }
499
+
500
+ get query() {
501
+ const search = this.target?.search
502
+
503
+ if (this.#search !== search) {
504
+ this.#search = search
505
+ this.#query = undefined
506
+ }
507
+
508
+ return (this.#query ??=
509
+ search && search.length > 1 ? Object.freeze(querystring.parse(search.slice(1))) : {})
510
+ }
511
+ }
512
+
513
+ export class ServerResponse extends http.ServerResponse {
514
+ #bytesWritten = 0
515
+
516
+ #created = performance.now()
517
+ #connect = -1
518
+ #headers = -1
519
+ #data = -1
520
+ #end = -1
521
+
522
+ #headersObj = kEmptyObj
523
+ #headersSent = false
524
+
525
+ get timing()
526
+
527
+
528
+
529
+
530
+
531
+ {
532
+ return {
533
+ created: performance.timeOrigin + this.#created,
534
+ connect: this.#connect,
535
+ headers: this.#headers,
536
+ data: this.#data,
537
+ end: this.#end,
538
+ }
539
+ }
540
+
541
+ get bytesWritten() {
542
+ return this.#bytesWritten
543
+ }
544
+
545
+ setHeader(key , val ) {
546
+ if (this.#headersSent) {
547
+ throw new Error('headers already sent')
548
+ }
549
+
550
+ if (this.#headersObj === (kEmptyObj )) {
551
+ this.#headersObj = {}
552
+ }
553
+
554
+ this.#headersObj[key] = val === undefined ? undefined : `${val}`
555
+ return this
556
+ }
557
+
558
+ removeHeader(key ) {
559
+ if (this.#headersSent) {
560
+ throw new Error('headers already sent')
561
+ }
562
+
563
+ if (this.#headersObj !== (kEmptyObj )) {
564
+ delete this.#headersObj[key]
565
+ }
566
+ }
567
+
568
+ getHeaderNames() {
569
+ return this.#headersObj === (kEmptyObj )
570
+ ? (kEmptyArr )
571
+ : Object.keys(this.#headersObj)
572
+ }
573
+
574
+ getHeaders() {
575
+ return this.#headersObj
576
+ }
577
+
578
+ // @ts-expect-error override property with accessor
579
+ get headersSent() {
580
+ return this.#headersSent
581
+ }
582
+
583
+
584
+
585
+ writeHead(
586
+ statusCode ,
587
+ statusMessageOrHeaders ,
588
+ maybeHeaders ,
589
+ ) {
590
+ if (this.#headersSent) {
591
+ throw new Error('headers already sent')
592
+ }
593
+
594
+ let headers
595
+ let statusMessage
596
+ if (typeof statusMessageOrHeaders === 'string') {
597
+ statusMessage = statusMessageOrHeaders
598
+ headers = maybeHeaders
599
+ } else {
600
+ headers = statusMessageOrHeaders
601
+ }
602
+
603
+ if (this.#headersObj !== (kEmptyObj )) {
604
+ headers = headers ? Object.assign(this.#headersObj, headers) : this.#headersObj
605
+ }
606
+
607
+ if (!this.destroyed) {
608
+ if (this.#headers === -1) {
609
+ this.#headers = performance.now() - this.#created
610
+ }
611
+ }
612
+
613
+ this.#headersSent = true
614
+
615
+ if (statusMessage) {
616
+ return super.writeHead(statusCode, statusMessage, headers)
617
+ }
618
+ return super.writeHead(statusCode, headers)
619
+ }
620
+
621
+ assignSocket(socket ) {
622
+ if (!this.destroyed) {
623
+ if (this.#connect === -1) {
624
+ this.#connect = performance.now() - this.#created
625
+ }
626
+ }
627
+ return super.assignSocket(socket)
628
+ }
629
+
630
+
631
+
632
+
633
+
634
+
635
+
636
+ write(
637
+ chunk ,
638
+ encoding ,
639
+ callback ,
640
+ ) {
641
+ if (!this.destroyed) {
642
+ if (this.#data === -1) {
643
+ this.#data = performance.now() - this.#created
644
+ }
645
+
646
+ if (this.#headers === -1) {
647
+ this.#headers = this.#data
648
+ }
649
+ }
650
+
651
+ if (chunk != null && typeof chunk !== 'function') {
652
+ if (typeof chunk !== 'string' && this.#bytesWritten >= 0) {
653
+ this.#bytesWritten += chunk.byteLength
654
+ } else {
655
+ this.#bytesWritten = -1
656
+ }
657
+ }
658
+
659
+ return super.write(chunk, encoding , callback)
660
+ }
661
+
662
+
663
+
664
+
665
+ end(
666
+ chunk ,
667
+ encoding ,
668
+ callback ,
669
+ ) {
670
+ if (!this.destroyed) {
671
+ this.#end = performance.now() - this.#created
672
+
673
+ if (this.#data === -1) {
674
+ this.#data = this.#end
675
+ }
676
+
677
+ if (this.#headers === -1) {
678
+ this.#headers = this.#end
679
+ }
680
+ }
681
+
682
+ if (chunk != null && typeof chunk !== 'function') {
683
+ if (typeof chunk !== 'string' && this.#bytesWritten >= 0) {
684
+ this.#bytesWritten += chunk.byteLength
685
+ } else {
686
+ this.#bytesWritten = -1
687
+ }
688
+ }
689
+
690
+ return super.end(chunk, encoding , callback)
691
+ }
692
+
693
+ destroy(err ) {
694
+ if (!this.destroyed) {
695
+ if (this.#end === -1) {
696
+ this.#end = performance.now() - this.#created
697
+ }
698
+ }
699
+
700
+ return super.destroy(err)
701
+ }
702
+ }
703
+
704
+ export class Http2ServerRequest extends http2.Http2ServerRequest {
705
+ #host
706
+ #url
707
+ #socket
708
+ #target
709
+
710
+ #id
711
+ #search
712
+ #query
713
+
714
+ get id() {
715
+ return (this.#id ??= (this.headers['request-id'] ) || genReqId())
716
+ }
717
+
718
+ get target() {
719
+ if (
720
+ this.#host !== this.headers.host ||
721
+ this.#url !== this.url ||
722
+ this.#socket !== this.socket
723
+ ) {
724
+ this.#socket = this.socket
725
+ this.#host = this.headers.host
726
+ this.#url = this.url
727
+ this.#target = undefined
728
+ }
729
+
730
+ if (this.#target === undefined) {
731
+ const t = requestTargetSlow(this)
732
+ this.#target = t ? Object.freeze(t) : null
733
+ }
734
+ return this.#target
735
+ }
736
+
737
+ get query() {
738
+ const search = this.target?.search
739
+
740
+ if (this.#search !== search) {
741
+ this.#search = search
742
+ this.#query = undefined
743
+ }
744
+
745
+ return (this.#query ??=
746
+ search && search.length > 1 ? Object.freeze(querystring.parse(search.slice(1))) : {})
747
+ }
748
+ }
749
+
750
+ export class Http2ServerResponse extends http2.Http2ServerResponse {
751
+ #created = performance.now()
752
+ #bytesWritten = 0
753
+ #connect = -1
754
+ #headers = -1
755
+ #data = -1
756
+ #end = -1
757
+
758
+ #headersObj = kEmptyObj
759
+ #headersSent = false
760
+
761
+ get timing()
762
+
763
+
764
+
765
+
766
+
767
+ {
768
+ return {
769
+ created: performance.timeOrigin + this.#created,
770
+ connect: this.#connect,
771
+ headers: this.#headers,
772
+ data: this.#data,
773
+ end: this.#end,
774
+ }
775
+ }
776
+
777
+ get bytesWritten() {
778
+ return this.#bytesWritten
779
+ }
780
+
781
+ setHeader(key , val ) {
782
+ if (this.#headersSent) {
783
+ throw new Error('headers already sent')
784
+ }
785
+
786
+ if (this.#headersObj === (kEmptyObj )) {
787
+ this.#headersObj = {}
788
+ }
789
+
790
+ this.#headersObj[key] = val === undefined ? undefined : `${val}`
791
+ return this
792
+ }
793
+
794
+ removeHeader(key ) {
795
+ if (this.#headersSent) {
796
+ throw new Error('headers already sent')
797
+ }
798
+
799
+ if (this.#headersObj !== (kEmptyObj )) {
800
+ delete this.#headersObj[key]
801
+ }
802
+ }
803
+
804
+ getHeaderNames() {
805
+ return this.#headersObj === (kEmptyObj )
806
+ ? (kEmptyArr )
807
+ : Object.keys(this.#headersObj)
808
+ }
809
+
810
+ getHeaders() {
811
+ return this.#headersObj
812
+ }
813
+
814
+ // @ts-expect-error override property with accessor
815
+ get headersSent() {
816
+ return this.#headersSent
817
+ }
818
+
819
+
820
+
821
+ writeHead(
822
+ statusCode ,
823
+ statusMessageOrHeaders ,
824
+ maybeHeaders ,
825
+ ) {
826
+ if (this.#headersSent) {
827
+ throw new Error('headers already sent')
828
+ }
829
+
830
+ let headers
831
+ let statusMessage
832
+ if (typeof statusMessageOrHeaders === 'string') {
833
+ statusMessage = statusMessageOrHeaders
834
+ headers = maybeHeaders
835
+ } else {
836
+ headers = statusMessageOrHeaders
837
+ }
838
+
839
+ if (this.#headersObj !== (kEmptyObj )) {
840
+ headers = headers ? Object.assign(this.#headersObj, headers) : this.#headersObj
841
+ }
842
+
843
+ if (!this.destroyed) {
844
+ if (this.#headers === -1) {
845
+ this.#headers = performance.now() - this.#created
846
+ }
847
+ }
848
+
849
+ this.#headersSent = true
850
+
851
+ if (statusMessage) {
852
+ return super.writeHead(statusCode, statusMessage, headers)
853
+ }
854
+ return super.writeHead(statusCode, headers)
855
+ }
856
+
857
+
858
+
859
+
860
+
861
+
862
+
863
+ write(
864
+ chunk ,
865
+ encoding ,
866
+ callback ,
867
+ ) {
868
+ if (!this.destroyed) {
869
+ if (this.#data === -1) {
870
+ this.#data = performance.now() - this.#created
871
+ }
872
+
873
+ if (this.#headers === -1) {
874
+ this.#headers = this.#data
875
+ }
876
+ }
877
+
878
+ if (chunk != null) {
879
+ if (typeof chunk !== 'string' && this.#bytesWritten >= 0) {
880
+ this.#bytesWritten += chunk.byteLength
881
+ } else {
882
+ this.#bytesWritten = -1
883
+ }
884
+ }
885
+
886
+ return super.write(chunk, encoding , callback)
887
+ }
888
+
889
+
890
+
891
+
892
+ end(
893
+ chunk ,
894
+ encoding ,
895
+ callback ,
896
+ ) {
897
+ if (!this.destroyed) {
898
+ this.#end = performance.now() - this.#created
899
+
900
+ if (this.#data === -1) {
901
+ this.#data = this.#end
902
+ }
903
+
904
+ if (this.#headers === -1) {
905
+ this.#headers = this.#end
906
+ }
907
+ }
908
+
909
+ if (chunk != null && typeof chunk !== 'function') {
910
+ if (typeof chunk !== 'string' && this.#bytesWritten >= 0) {
911
+ this.#bytesWritten += chunk.byteLength
912
+ } else {
913
+ this.#bytesWritten = -1
914
+ }
915
+ }
916
+
917
+ return super.end(chunk , encoding , callback)
918
+ }
919
+
920
+ destroy(err ) {
921
+ if (!this.destroyed) {
922
+ if (this.#end === -1) {
923
+ this.#end = performance.now() - this.#created
924
+ }
925
+ }
926
+
927
+ return super.destroy(err)
928
+ }
929
+ }
930
+
931
+
932
+
933
+
934
+
935
+
936
+
937
+
938
+
939
+
940
+
941
+
942
+
943
+ const httpServers = ((globalThis ).__nxt_lib_http_servers =
944
+ new Array ())
945
+
946
+
947
+
948
+
949
+
950
+
951
+
952
+
953
+
954
+ export function createServer(...args ) {
955
+ let options
956
+ let ctx
957
+ let middleware
958
+
959
+ if (typeof args[0] === 'function') {
960
+ ctx = args[0]
961
+ middleware = args[1]
962
+ } else {
963
+ options = args[0]
964
+ ctx = args[1]
965
+ middleware = args[2]
966
+ }
967
+
968
+ if (middleware) {
969
+ middleware = [middleware].flat(16).filter(Boolean)
970
+ middleware = middleware.includes(requestMiddleware)
971
+ ? middleware
972
+ : [requestMiddleware, ...middleware]
973
+ middleware = compose(middleware)
974
+ }
975
+
976
+ let factory
977
+ if (typeof ctx === 'function') {
978
+ factory = ctx
979
+ } else {
980
+ const ctxObj = (ctx ?? {})
981
+ const { logger: ctxLogger, ...opaque } = ctxObj
982
+ const hasOpaque = Object.keys(opaque).length > 0
983
+
984
+ factory = (req , res ) => {
985
+ const context = new Context(req, res, (ctxLogger ?? options?.logger) )
986
+ if (hasOpaque) {
987
+ Object.assign(context, opaque)
988
+ }
989
+ return context
990
+ }
991
+ }
992
+
993
+ const server = http.createServer(
994
+ {
995
+ IncomingMessage,
996
+ ServerResponse,
997
+ keepAliveTimeout: 2 * 60e3,
998
+ headersTimeout: 2 * 60e3,
999
+ requestTimeout: 48 * 60e3,
1000
+ ...options,
1001
+ },
1002
+ (req, res) =>
1003
+ middleware
1004
+ ? middleware(factory(req , res ), noop)
1005
+ : factory(req , res ),
1006
+ )
1007
+
1008
+ server.setTimeout(options?.socketTimeout ?? 2 * 60e3)
1009
+
1010
+ if (options?.signal?.aborted) {
1011
+ queueMicrotask(() => server.close())
1012
+ } else {
1013
+ options?.signal?.addEventListener('abort', () => server.close())
1014
+ }
1015
+
1016
+ httpServers.push(server)
1017
+
1018
+ return server
1019
+ }
1020
+
1021
+
1022
+
1023
+ const composeSlim =
1024
+ (middleware ) =>
1025
+ (ctx, next) => {
1026
+ const dispatch = (i ) => () => {
1027
+ const fn = i === middleware.length ? next : middleware[i]
1028
+ return fn ? fn(ctx, dispatch(i + 1)) : undefined
1029
+ }
1030
+ return dispatch(0)()
1031
+ }
1032
+
1033
+ export const compose = (...middleware ) => {
1034
+ const funcs = middleware.flat()
1035
+
1036
+ for (const fn of funcs) {
1037
+ if (typeof fn !== 'function') {
1038
+ throw new TypeError('Middleware must be composed of functions!')
1039
+ }
1040
+ }
1041
+
1042
+ if (process.env.NODE_ENV === 'production') {
1043
+ return composeSlim(funcs)
1044
+ }
1045
+
1046
+ return async (ctx , next = noop) => {
1047
+ const dispatch = async (i ) => {
1048
+ const fn = i === funcs.length ? next : funcs[i]
1049
+ if (!fn) {
1050
+ return
1051
+ }
1052
+
1053
+ let nextCalled = false
1054
+ let nextResolved = false
1055
+ const nextProxy = async () => {
1056
+ if (nextCalled) {
1057
+ throw Error('next() called multiple times')
1058
+ }
1059
+ nextCalled = true
1060
+ try {
1061
+ return await dispatch(i + 1)
1062
+ } finally {
1063
+ nextResolved = true
1064
+ }
1065
+ }
1066
+ const result = await fn(ctx, nextProxy)
1067
+ if (nextCalled && !nextResolved) {
1068
+ throw Error(
1069
+ 'Middleware resolved before downstream.\n\tYou are probably missing an await or return',
1070
+ )
1071
+ }
1072
+ return result
1073
+ }
1074
+ return dispatch(0)
1075
+ }
1076
+ }
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@nxtedition/http",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "main": "lib/index.js",
6
+ "types": "lib/index.d.ts",
7
+ "files": [
8
+ "lib",
9
+ "README.md",
10
+ "LICENSE"
11
+ ],
12
+ "license": "MIT",
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "scripts": {
17
+ "build": "rimraf lib && tsc && amaroc ./src/index.ts && mv src/index.js lib/",
18
+ "prepublishOnly": "yarn build",
19
+ "typecheck": "tsc --noEmit",
20
+ "test": "node --test --experimental-test-coverage --test-coverage-include=src/index.ts --test-coverage-lines=65 --test-coverage-branches=80 --test-coverage-functions=60",
21
+ "test:ci": "node --test --experimental-test-coverage --test-coverage-include=src/index.ts --test-coverage-lines=65 --test-coverage-branches=80 --test-coverage-functions=60"
22
+ },
23
+ "dependencies": {
24
+ "@nxtedition/scheduler": "^3.0.9",
25
+ "fast-querystring": "^1.1.2",
26
+ "request-target": "^1.0.2"
27
+ },
28
+ "devDependencies": {
29
+ "@types/node": "^25.2.3",
30
+ "amaroc": "^1.0.1",
31
+ "oxlint-tsgolint": "^0.12.2",
32
+ "rimraf": "^6.1.2",
33
+ "typescript": "^5.9.3"
34
+ },
35
+ "gitHead": "64c1bd97cf9a785ca4a750b5380418878cbed2d2"
36
+ }