@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 +21 -0
- package/README.md +172 -0
- package/lib/index.d.ts +112 -0
- package/lib/index.js +1076 -0
- package/package.json +36 -0
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
|
+
}
|