@johntalton/http-core 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) 2026 John
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,2 @@
1
+ # http-core
2
+ HTTP2 Server without routing used to build on
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@johntalton/http-core",
3
+ "type": "module",
4
+ "version": "1.0.0",
5
+ "license": "MIT",
6
+ "exports": {
7
+ ".": "./src/index.js"
8
+ },
9
+ "files": [
10
+ "src/*.js"
11
+ ],
12
+ "repository": {
13
+ "url": "https://github.com/johntalton/http-core"
14
+ },
15
+ "scripts": {
16
+ },
17
+ "dependencies": {
18
+ "@johntalton/http-util": "^5.0.0",
19
+ "@johntalton/sse-util": "^1.0.0"
20
+ }
21
+ }
@@ -0,0 +1,125 @@
1
+ import { Response } from '@johntalton/http-util/response/object'
2
+ import { MIME_TYPE_JSON } from '@johntalton/http-util/headers'
3
+ import { ServerSentEvents } from '@johntalton/sse-util'
4
+
5
+ /** @import { ServerHttp2Stream } from 'node:http2' */
6
+ /** @import { RouteAction, StreamID } from './index.js' */
7
+
8
+ /**
9
+ * @param {ServerHttp2Stream} stream
10
+ * @param {MessagePort} port
11
+ * @param {StreamID} streamId
12
+ * @param {AbortSignal} shutdownSignal
13
+ */
14
+ function addSSEPortHandler(stream, port, streamId, shutdownSignal) {
15
+ const signalHandler = () => {
16
+ console.log('shutdown of SSE requested (shutdown signal)', streamId, shutdownSignal.reason)
17
+ port.close()
18
+ stream.end()
19
+ }
20
+
21
+ stream.once('close', (() => {
22
+ console.log('stream close in sse handler', streamId)
23
+ shutdownSignal.removeEventListener('abort', signalHandler)
24
+ port.close()
25
+ }))
26
+
27
+ shutdownSignal.addEventListener('abort', signalHandler)
28
+
29
+ // ServerSentEvents.messageToEventStreamLines({
30
+ // comment: 'Welcome',
31
+ // retryMs: 1000 * 60,
32
+ // }).forEach(line => stream.write(line))
33
+
34
+ port.onmessage = message => {
35
+ const { data } = message
36
+ console.log('sending sse data', streamId, data)
37
+ // ServerSentEvents.messageToEventStreamLines(data)
38
+ ServerSentEvents.lineGen(data)
39
+ .forEach(line => stream.write(line))
40
+ }
41
+ }
42
+
43
+ /**
44
+ * @param {RouteAction} state
45
+ */
46
+ export function epilogue(state) {
47
+ const { type, stream, meta, streamId } = state
48
+
49
+ meta.customHeaders.push([ 'X-Request-Id', streamId ])
50
+
51
+ switch(type) {
52
+ //
53
+ case 'trace': { Response.trace(stream, state.method, state.url, state.headers, meta) } break
54
+ //
55
+ case 'preflight': { Response.preflight(stream, state.methods, state.supportedQueryTypes, undefined, meta) } break
56
+ // case 'no-content': { Response.noContent(stream, state.etag, meta)} break
57
+ // case 'accepted': { Response.accepted(stream, meta) } break
58
+ case 'created': { Response.created(stream, new URL(state.location, meta.origin), state.etag, meta) } break
59
+ case 'not-modified': { Response.notModified(stream, state.etag, state.age, { priv: true, maxAge: 60 }, meta) } break
60
+
61
+ //
62
+ // case 'multiple-choices': { Response.multipleChoices(stream, meta) } break
63
+ // case 'gone': { Response.gone(stream, meta) } break
64
+ // case 'moved-permanently': { Response.movedPermanently(stream, state.location, meta) } break
65
+ // case 'see-other': { Response.seeOther(stream, state.location, meta) } break
66
+ // case 'temporary-redirect': { Response.temporaryRedirect(stream, state.location, meta) } break
67
+
68
+ //
69
+ case '404': { Response.notFound(stream, state.message, meta) } break
70
+ case 'conflict': { Response.conflict(stream, meta) } break
71
+ case 'not-allowed': { Response.notAllowed(stream, state.methods, meta) } break
72
+ case 'not-acceptable': { Response.notAcceptable(stream, state.acceptableMediaTypes ?? [], meta)} break
73
+ case 'unsupported-media': { Response.unsupportedMediaType(stream, state.acceptableMediaTypes, state.supportedQueryTypes, meta) } break
74
+ case 'unprocessable': { Response.unprocessable(stream, meta) } break
75
+ case 'precondition-failed': { Response.preconditionFailed(stream, meta) } break
76
+ case 'not-satisfiable': { Response.rangeNotSatisfiable(stream, { size: state.contentLength }, meta) } break
77
+ // case 'content-too-large': { Response.contentTooLarge(stream, meta) } break
78
+ // case 'insufficient-storage': { Response.insufficientStorage(stream, meta) } break
79
+ // case 'too-many-requests': { Response.tooManyRequests(stream, state.limit, state.policies, meta) } break
80
+ // case 'unauthorized': { Response.unauthorized(stream, meta) } break
81
+ case 'unavailable': { Response.unavailable(stream, state.message, state.retryAfter, meta)} break
82
+ case 'not-implemented': { Response.notImplemented(stream, state.message, meta)} break
83
+ // case 'timeout': { Response.timeout(stream, meta) } break
84
+
85
+ //
86
+ case 'sse': {
87
+ const { active, bom, port } = state
88
+
89
+ Response.sse(stream, { ...meta, active, bom })
90
+ if(active) { addSSEPortHandler(stream, port, state.streamId, state.shutdownSignal) }
91
+ }
92
+ break
93
+ case 'json': {
94
+ const { obj, accept, etag } = state
95
+
96
+ if(accept.type === MIME_TYPE_JSON) {
97
+ Response.json(stream, obj, accept.encoding, etag, state.age, { priv: true, maxAge: 60 }, state.supportedQueryTypes, meta)
98
+ }
99
+ else {
100
+ // todo: but we did process the request - is that ok?
101
+ Response.notAcceptable(stream, [ MIME_TYPE_JSON ], meta)
102
+ }
103
+ }
104
+ break
105
+ case 'partial-bytes': { Response.partialContent(stream, state.contentType, state.objs, state.contentLength, undefined, state.etag, state.age, { maxAge: state.maxAge }, meta) } break
106
+ case 'bytes': { Response.bytes(stream, state.contentType, state.obj, state.contentLength, 'identity', state.etag, state.age, { maxAge: state.maxAge }, state.acceptRanges, meta) } break
107
+
108
+ //
109
+ case 'error': {
110
+ const { cause, error } = state
111
+ console.log('send error', state.streamId, cause)
112
+ if(error !== undefined) { console.log(error) }
113
+ Response.error(stream, cause, meta)
114
+ } break
115
+
116
+ //
117
+ // case 'void': {} break
118
+ // case 'request' : { throw new Error('unhandled request') } break
119
+ default: {
120
+ /** @type {never} */
121
+ const neverType = type
122
+ Response.error(stream, `unknown type ${neverType}`, meta)
123
+ } break
124
+ }
125
+ }
package/src/index.js ADDED
@@ -0,0 +1,507 @@
1
+ import http2 from 'node:http2'
2
+ import fs from 'node:fs'
3
+ import crypto from 'node:crypto'
4
+
5
+ import { HTTP_METHOD_QUERY } from '@johntalton/http-util/response'
6
+
7
+ import { preamble } from './preamble.js'
8
+ import { epilogue } from './epilogue.js'
9
+
10
+ const {
11
+ HTTP2_METHOD_GET,
12
+ HTTP2_METHOD_HEAD,
13
+ HTTP2_METHOD_POST,
14
+ HTTP2_METHOD_PUT,
15
+ HTTP2_METHOD_PATCH,
16
+ HTTP2_METHOD_OPTIONS,
17
+ HTTP2_METHOD_DELETE,
18
+ HTTP2_METHOD_TRACE
19
+ } = http2.constants
20
+
21
+ export const KNOWN_METHODS = [
22
+ HTTP2_METHOD_GET,
23
+ HTTP2_METHOD_HEAD,
24
+ HTTP2_METHOD_POST,
25
+ HTTP2_METHOD_PUT,
26
+ HTTP2_METHOD_PATCH,
27
+ HTTP2_METHOD_OPTIONS,
28
+ HTTP2_METHOD_DELETE,
29
+ HTTP2_METHOD_TRACE,
30
+ HTTP_METHOD_QUERY
31
+ ]
32
+
33
+ /** @import { Http2Stream, ServerHttp2Stream, IncomingHttpHeaders } from 'node:http2' */
34
+ /** @import { SecureServerOptions } from 'node:http2' */
35
+
36
+ /** @import { Metadata } from '@johntalton/http-util/response' */
37
+ /** @import { BodyFuture } from '@johntalton/http-util/body' */
38
+ /** @import { EtagItem, IMFFixDate, ContentRangeDirective } from '@johntalton/http-util/headers' */
39
+ /** @import { SendBody } from '@johntalton/http-util/response' */
40
+
41
+ /** @typedef {(state: RouteRequest|RouteAction) => Promise<RouteAction>} Router */
42
+
43
+ /** @typedef {'request'} RouteTypeRequest */
44
+ /** @typedef {'partial-bytes'|'bytes'|'json'|'404'|'sse'|'error'|'preflight'|'not-allowed'|'trace'|'created'|'unsupported-media'|'not-modified'|'precondition-failed'|'unprocessable'|'not-acceptable'|'conflict'|'not-implemented'|'unavailable'|'not-satisfiable'} RouteType */
45
+ /** @typedef {'GET'|'HEAD'|'POST'|'PUT'|'OPTIONS'|'DELETE'|'TRACE'} RouteMethod */
46
+
47
+ /** @typedef {string & { readonly _brand: 'sid' }} StreamID */
48
+
49
+ /**
50
+ * @typedef {Object} Config
51
+ * @property {boolean|undefined} [maintenance_mode]
52
+ */
53
+
54
+ /**
55
+ * @typedef {Object} RouteBase
56
+ * @property {RouteTypeRequest|RouteType} type
57
+ * @property {Config} config
58
+ * @property {StreamID} streamId
59
+ * @property {ServerHttp2Stream} stream
60
+ * @property {Metadata} meta
61
+ * @property {AbortSignal} shutdownSignal
62
+ */
63
+
64
+ /**
65
+ * @typedef {Object} RouteRequestBase
66
+ * @property {'request'} type
67
+ * @property {RouteMethod} method
68
+ * @property {URL} url
69
+ * @property {IncomingHttpHeaders} headers
70
+ * @property {BodyFuture} body
71
+ * @property {RouteRequestAccept} accept
72
+ * @property {RouteRemoteClient} client
73
+ * @property {RouteConditions} conditions
74
+ * @property {string} SNI
75
+ */
76
+ /** @typedef {RouteBase & RouteRequestBase} RouteRequest */
77
+
78
+ /**
79
+ * @typedef {Object} RouteErrorBase
80
+ * @property {'error'} type
81
+ * @property {string} cause
82
+ * @property {Error|undefined} [error]
83
+ */
84
+ /** @typedef {RouteBase & RouteErrorBase } RouteError */
85
+
86
+ /**
87
+ * @typedef {Object} RouteNotAllowedBase
88
+ * @property {'not-allowed'} type
89
+ * @property {RouteMethod} method
90
+ * @property {URL} url
91
+ * @property {Array<RouteMethod>} methods
92
+ */
93
+ /** @typedef {RouteBase & RouteNotAllowedBase} RouteNotAllowed */
94
+
95
+ /**
96
+ * @typedef {Object} RouteTraceBase
97
+ * @property {'trace'} type
98
+ * @property {RouteMethod} method
99
+ * @property {URL} url
100
+ * @property {IncomingHttpHeaders} headers
101
+ * @property {number} maxForwards
102
+ * @property {RouteRequestAccept} accept
103
+ */
104
+ /** @typedef {RouteBase & RouteTraceBase} RouteTrace */
105
+
106
+ /**
107
+ * @typedef {Object} RouteRequestAccept
108
+ * @property {string|undefined} type
109
+ * @property {string|undefined} encoding
110
+ * @property {string|undefined} language
111
+ */
112
+
113
+ /**
114
+ * @typedef {Object} RouteRemoteClient
115
+ * @property {string|undefined} family
116
+ * @property {string|undefined} ip
117
+ * @property {number|undefined} port
118
+ */
119
+
120
+ /**
121
+ * @typedef {Object} RouteConditions
122
+ * @property {Array<EtagItem>} match
123
+ * @property {Array<EtagItem>} noneMatch
124
+ * @property {IMFFixDate|undefined} modifiedSince
125
+ * @property {IMFFixDate|undefined} unmodifiedSince
126
+ * @property {IMFFixDate|EtagItem|undefined} [range]
127
+ */
128
+
129
+ /**
130
+ * @typedef {Object} RoutePreflightBase
131
+ * @property {'preflight'} type
132
+ * @property {RouteMethod} method
133
+ * @property {URL} url
134
+ * @property {Array<RouteMethod>} methods
135
+ * @property {Array<string>|undefined} [supportedQueryTypes]
136
+ */
137
+ /** @typedef {RouteBase & RoutePreflightBase} RoutePreflight */
138
+
139
+ /**
140
+ * @typedef {Object} RouteJSONBase
141
+ * @property {'json'} type
142
+ * @property {RouteRequestAccept} accept
143
+ * @property {Record<any, any>} obj
144
+ * @property {IMFFixDate|string|undefined} [lastModified]
145
+ * @property {EtagItem|undefined} [etag]
146
+ * @property {number|undefined} [age]
147
+ * @property {Array<string>|undefined} [supportedQueryTypes]
148
+ */
149
+ /** @typedef {RouteBase & RouteJSONBase} RouteJSON */
150
+
151
+ /**
152
+ * @typedef {Object} Route404Base
153
+ * @property {'404'} type
154
+ * @property {string} method
155
+ * @property {URL} url
156
+ * @property {string} message
157
+ */
158
+ /** @typedef {RouteBase & Route404Base} Route404 */
159
+
160
+ /**
161
+ * @typedef {Object} RouteCreatedBase
162
+ * @property {'created'} type
163
+ * @property {URL|string} location
164
+ * @property {EtagItem|undefined} [etag]
165
+ */
166
+ /** @typedef {RouteBase & RouteCreatedBase} RouteCreated */
167
+
168
+ /**
169
+ * @typedef {Object} RouteUnsupportedMediaTypeBase
170
+ * @property {'unsupported-media'} type
171
+ * @property {Array<string>|string} acceptableMediaTypes
172
+ * @property {Array<string>|undefined} [supportedQueryTypes]
173
+ */
174
+ /** @typedef {RouteBase & RouteUnsupportedMediaTypeBase} RouteUnsupportedMediaType */
175
+
176
+ /**
177
+ * @typedef {Object} RouteNotModifiedBase
178
+ * @property {'not-modified'} type
179
+ * @property {number} age
180
+ * @property {EtagItem|undefined} [etag]
181
+ * @property {number|undefined} [age]
182
+ */
183
+ /** @typedef {RouteBase & RouteNotModifiedBase} RouteNotModified */
184
+
185
+ /**
186
+ * @typedef {Object} RoutePreconditionFailedBase
187
+ * @property {'precondition-failed'} type
188
+ * @property {EtagItem|undefined} [etag]
189
+ */
190
+ /** @typedef {RouteBase & RoutePreconditionFailedBase} RoutePreconditionFailed */
191
+
192
+ /**
193
+ * @typedef {Object} RouteNotAcceptableBase
194
+ * @property {'not-acceptable'} type
195
+ * @property {Array<string>|undefined} [acceptableMediaTypes]
196
+ * @property {Array<string>|undefined} [acceptableEncodings]
197
+ * @property {Array<string>|undefined} [acceptableLanguages]
198
+ */
199
+ /** @typedef {RouteBase & RouteNotAcceptableBase} RouteNotAcceptable */
200
+
201
+ /**
202
+ * @typedef {Object} RouteUnprocessableBase
203
+ * @property {'unprocessable'} type
204
+ * @property {string} message
205
+ */
206
+ /** @typedef {RouteBase & RouteUnprocessableBase} RouteUnprocessable */
207
+
208
+ /**
209
+ * @typedef {Object} RouteConflictBase
210
+ * @property {'conflict'} type
211
+ * @property {string|undefined} [message]
212
+ */
213
+ /** @typedef {RouteBase & RouteConflictBase} RouteConflict */
214
+
215
+ /**
216
+ * @typedef {Object} RouteNotImplementedBase
217
+ * @property {'not-implemented'} type
218
+ * @property {string|undefined} [message]
219
+ */
220
+ /** @typedef {RouteBase & RouteNotImplementedBase} RouteNotImplemented */
221
+
222
+ /**
223
+ * @typedef {Object} RouteUnavailableBase
224
+ * @property {'unavailable'} type
225
+ * @property {string|undefined} [message]
226
+ * @property {number|undefined} [retryAfter]
227
+ */
228
+ /** @typedef {RouteBase & RouteUnavailableBase} RouteUnavailable */
229
+
230
+ /**
231
+ * @typedef {Object} RouteBytesBase
232
+ * @property {'bytes'} type
233
+ * @property {string} contentType
234
+ * @property {number|undefined} [contentLength]
235
+ * @property {SendBody|undefined} obj
236
+ * @property {IMFFixDate|string|undefined} [lastModified]
237
+ * @property {EtagItem|undefined} [etag]
238
+ * @property {number|undefined} [age]
239
+ * @property {number|undefined} [maxAge]
240
+ * @property {'bytes'|'none'|undefined} [acceptRanges]
241
+ */
242
+ /** @typedef {RouteBase & RouteBytesBase} RouteBytes */
243
+
244
+ /**
245
+ * @typedef {Object} PartialBytes
246
+ * @property {SendBody} obj
247
+ * @property {ContentRangeDirective} range
248
+ */
249
+
250
+ /**
251
+ * @template T
252
+ * @typedef {[ T, ...T[] ]} NonEmptyArray
253
+ */
254
+
255
+ /**
256
+ * @typedef {Object} RoutePartialBytesBase
257
+ * @property {'partial-bytes'} type
258
+ * @property {NonEmptyArray<PartialBytes>} objs
259
+ * @property {string} contentType
260
+ * @property {number|undefined} [contentLength]
261
+ * @property {EtagItem|undefined} [etag]
262
+ * @property {number|undefined} [age]
263
+ * @property {number|undefined} [maxAge]
264
+ */
265
+ /** @typedef {RouteBase & RoutePartialBytesBase} RoutePartialBytes */
266
+
267
+ /**
268
+ * @typedef {Object} RouteNotSatisfiableBase
269
+ * @property {'not-satisfiable'} type
270
+ * @property {number} contentLength
271
+ */
272
+ /** @typedef {RouteBase & RouteNotSatisfiableBase} RouteNotSatisfiable */
273
+
274
+
275
+ /**
276
+ * @typedef {Object} RouteSSEBase
277
+ * @property {'sse'} type
278
+ * @property {boolean} active
279
+ * @property {boolean} bom
280
+ * @property {MessagePort} port
281
+ * @property {RouteRequestAccept} accept
282
+ */
283
+ /** @typedef {RouteBase & RouteSSEBase} RouteSSE */
284
+
285
+ /** @typedef {
286
+ RouteError |
287
+ RouteNotAllowed |
288
+ RoutePreflight |
289
+ RouteBytes |
290
+ RouteJSON |
291
+ Route404 |
292
+ RouteSSE |
293
+ RouteTrace |
294
+ RouteCreated |
295
+ RouteUnsupportedMediaType |
296
+ RouteNotModified |
297
+ RoutePreconditionFailed |
298
+ RouteUnprocessable |
299
+ RouteNotAcceptable |
300
+ RouteConflict |
301
+ RouteNotImplemented |
302
+ RouteUnavailable |
303
+ RoutePartialBytes |
304
+ RouteNotSatisfiable
305
+ } RouteAction */
306
+
307
+ /** @typedef {Record<string, string|undefined>} RouteMatches */
308
+ /** @typedef {(matches: RouteMatches, state: RouteRequest) => Promise<RouteAction>} RouteFunction */
309
+
310
+ /**
311
+ * @param {Http2Stream} stream
312
+ * @returns {stream is ServerHttp2Stream}
313
+ */
314
+ function isServerStream(stream) {
315
+ if(stream === null) { return false }
316
+ return true
317
+ }
318
+
319
+ /**
320
+ * @param {string|undefined|Array<string>} header
321
+ * @returns {header is string}
322
+ */
323
+ export function isValidHeader(header) {
324
+ return header !== undefined && isValidLikeHeader(header)
325
+ }
326
+
327
+ /**
328
+ * @param {string|undefined|Array<string>} header
329
+ * @returns {header is string|undefined}
330
+ */
331
+ export function isValidLikeHeader(header) {
332
+ return !Array.isArray(header)
333
+ }
334
+
335
+ /**
336
+ * @param {string|undefined|Array<string>} method
337
+ * @returns {method is RouteMethod}
338
+ */
339
+ export function isValidMethod(method) {
340
+ if(!isValidHeader(method)) { return false }
341
+
342
+ return KNOWN_METHODS.includes(method)
343
+ }
344
+
345
+ /**
346
+ * @param {number} rstCode
347
+ */
348
+ export function closeCodeToString(rstCode) {
349
+ if(rstCode === http2.constants.NGHTTP2_NO_ERROR) { return '(No Error)' }
350
+ else if(rstCode === http2.constants.NGHTTP2_PROTOCOL_ERROR) { return '(Protocol Error)' }
351
+ else if(rstCode === http2.constants.NGHTTP2_INTERNAL_ERROR) { return '(Internal Error)' }
352
+ else if(rstCode === http2.constants.NGHTTP2_FLOW_CONTROL_ERROR) { return '(Flow Control Error)' }
353
+ else if(rstCode === http2.constants.NGHTTP2_SETTINGS_TIMEOUT) { return '(Settings Timeout)' }
354
+ else if(rstCode === http2.constants.NGHTTP2_STREAM_CLOSED) { return '(Closed)' }
355
+ else if(rstCode === http2.constants.NGHTTP2_FRAME_SIZE_ERROR) { return '(Frame Size Error)' }
356
+ else if(rstCode === http2.constants.NGHTTP2_REFUSED_STREAM) { return '(Refused)' }
357
+ else if(rstCode === http2.constants.NGHTTP2_CANCEL) { return '(Cancel)' }
358
+ else if(rstCode === http2.constants.NGHTTP2_COMPRESSION_ERROR) { return '(Compression Error)' }
359
+ else if(rstCode === http2.constants.NGHTTP2_CONNECT_ERROR) { return '(Connect Error)' }
360
+ else if(rstCode === http2.constants.NGHTTP2_ENHANCE_YOUR_CALM) { return '(Chill)' }
361
+ else if(rstCode === http2.constants.NGHTTP2_INADEQUATE_SECURITY) { return '(Inadequate Security)' }
362
+ else if(rstCode === http2.constants.NGHTTP2_HTTP_1_1_REQUIRED) { return '(HTTP 1.1 Requested)' }
363
+
364
+ return `(${rstCode})`
365
+ }
366
+
367
+ export const REQUEST_ID_SIZE = 5
368
+
369
+ /**
370
+ * @returns {StreamID}
371
+ */
372
+ export function requestId() {
373
+ const buffer = new Uint8Array(REQUEST_ID_SIZE)
374
+ crypto.getRandomValues(buffer)
375
+ // @ts-ignore
376
+ return buffer.toHex()
377
+ }
378
+
379
+ const {
380
+ SSL_OP_NO_TLSv1,
381
+ SSL_OP_NO_TLSv1_1,
382
+ SSL_OP_NO_TLSv1_2,
383
+ } = crypto.constants
384
+
385
+ /**
386
+ * @typedef {Object} H2CoreOptions
387
+ * @property {Config} config
388
+ * @property {boolean} ipv6Only
389
+ * @property {string} host
390
+ * @property {number} port
391
+ * @property {Array<string>} credentials
392
+ * @property {string|undefined} serverName
393
+ */
394
+
395
+ export class H2CoreServer {
396
+ #server
397
+ #controller
398
+
399
+ /** @type {H2CoreOptions} */
400
+ #h2Options
401
+
402
+ /**
403
+ * @param {Router} router
404
+ * @param {Partial<H2CoreOptions>|undefined} [h2Options]
405
+ */
406
+ constructor(router, h2Options) {
407
+ this.#h2Options = {
408
+ config: h2Options?.config ?? {},
409
+ ipv6Only: h2Options?.ipv6Only ?? true,
410
+ host: h2Options?.host ?? '',
411
+ port: h2Options?.port ?? 0,
412
+ credentials: h2Options?.credentials ?? [],
413
+ serverName: h2Options?.serverName
414
+ }
415
+
416
+ /** @type {SecureServerOptions} */
417
+ const options = {
418
+ allowHTTP1: false,
419
+ secureOptions: SSL_OP_NO_TLSv1 | SSL_OP_NO_TLSv1_1 | SSL_OP_NO_TLSv1_2,
420
+ minVersion: 'TLSv1.3',
421
+ settings: {
422
+ enablePush: false
423
+ },
424
+ ALPNProtocols: [ 'h2' ]
425
+ }
426
+
427
+ const server = http2.createSecureServer(options)
428
+ this.#server = server
429
+
430
+ this.#controller = new AbortController()
431
+
432
+ for(const credentialHost of this.#h2Options.credentials) {
433
+ server.addContext(credentialHost, {
434
+ key: fs.readFileSync(`./certificates/${credentialHost}-privkey.pem`, 'utf-8'),
435
+ cert: fs.readFileSync(`./certificates/${credentialHost}-cert.pem`, 'utf-8')
436
+ })
437
+ }
438
+
439
+ // server.setTimeout(5 * 1000)
440
+
441
+ // server.on('request', (req, res) => res.end('hello'))
442
+ server.on('drop', data => console.log('Drop', data))
443
+ server.on('connection', socket => console.log('new connection', socket.remoteAddress))
444
+ // server.on('secureConnection', socket => console.log('new secure connection'))
445
+ // server.on('keylog', (data) => console.log('key log', data))
446
+ server.on('unknownProtocol', socket => { console.log('Unknown Protocol', socket.getProtocol()) ; socket.end() })
447
+ server.on('tlsClientError', (error, _socket) => {
448
+ if('code' in error) {
449
+ if(error.code === 'ERR_SSL_SSL/TLS_ALERT_CERTIFICATE_UNKNOWN') { return }
450
+ if(error.code === 'ERR_SSL_NO_SUITABLE_SIGNATURE_ALGORITHM') { return }
451
+ // ERR_SSL_SSL/TLS_ALERT_BAD_CERTIFICATE
452
+ }
453
+ console.log('TLS Error', error)
454
+ })
455
+ server.on('error', error => console.log('Server Error', error))
456
+ server.on('sessionError', error => { console.log('session error', error) })
457
+ server.on('listening', () => console.log('Server Up', this.#h2Options.serverName, server.address()))
458
+ server.on('close', () => console.log('End of Line'))
459
+ server.on('session', session => {
460
+ console.log('new session')
461
+ session.on('close', () => console.log('session close'))
462
+ session.on('error', () => console.log('session error'))
463
+ session.on('frameError', () => console.log('session frameError'))
464
+ session.on('goaway', () => console.log('session goAway'))
465
+ })
466
+ server.on('stream', (stream, headers) => {
467
+ const streamId = requestId()
468
+
469
+ console.log('new stream', streamId, stream.id)
470
+ stream.on('aborted', () => console.log('stream aborted', streamId))
471
+ stream.on('close', () => {
472
+ // if(stream.rstCode !== http2.constants.NGHTTP2_NO_ERROR) {
473
+ console.log('stream close', streamId, closeCodeToString(stream.rstCode))
474
+ // }
475
+ })
476
+ stream.on('error', error => console.log('stream error', streamId, error.message))
477
+ stream.on('frameError', (type, code, id) => console.log('stream frameError', streamId, type, code, id))
478
+
479
+ // tickle the type
480
+ if(!isServerStream(stream)) { return }
481
+
482
+ // const start = performance.now()
483
+ const state = preamble(this.#h2Options.config, streamId, stream, headers, this.#h2Options.serverName, this.#controller.signal)
484
+ router(state)
485
+ .then(epilogue)
486
+ .catch(e => epilogue({ ...state, type: 'error', cause: e.message, error: e }))
487
+ .catch(e => console.error('Top Level Error:', streamId, e))
488
+ // .finally(() => console.log('perf', streamId, performance.now() - start))
489
+ })
490
+ }
491
+
492
+ listen() {
493
+ this.#server.listen({
494
+ ipv6Only: this.#h2Options.ipv6Only,
495
+ port: this.#h2Options.port,
496
+ host: this.#h2Options.host,
497
+ signal: this.#controller.signal
498
+ })
499
+ }
500
+
501
+ get closed() { return this.#controller.signal.aborted }
502
+
503
+ close() {
504
+ this.#controller.abort('close')
505
+ this.#server.close()
506
+ }
507
+ }
@@ -0,0 +1,295 @@
1
+ import http2 from 'node:http2'
2
+ import { TLSSocket } from 'node:tls'
3
+
4
+ import { requestBody } from '@johntalton/http-util/body'
5
+ import {
6
+ MIME_TYPE_JSON,
7
+ MIME_TYPE_TEXT,
8
+ MIME_TYPE_XML,
9
+ MIME_TYPE_EVENT_STREAM,
10
+ MIME_TYPE_MESSAGE_HTTP,
11
+ parseContentType,
12
+
13
+ Accept,
14
+ AcceptEncoding,
15
+ AcceptLanguage,
16
+
17
+ Forwarded,
18
+ FORWARDED_KEY_FOR,
19
+ KNOWN_FORWARDED_KEYS,
20
+ Conditional,
21
+ ETag
22
+ } from '@johntalton/http-util/headers'
23
+ import { ENCODER_MAP, HTTP_HEADER_FORWARDED, HTTP_HEADER_ORIGIN } from '@johntalton/http-util/response'
24
+ import { isValidHeader, isValidLikeHeader, isValidMethod } from './index.js'
25
+
26
+ /** @import { ServerHttp2Stream, IncomingHttpHeaders } from 'node:http2' */
27
+ /** @import { Config, RouteRequest, RouteAction, StreamID, RouteConditions } from './index.js' */
28
+
29
+ const { HTTP2_METHOD_OPTIONS, HTTP2_METHOD_TRACE } = http2.constants
30
+
31
+ const {
32
+ HTTP2_HEADER_METHOD,
33
+ HTTP2_HEADER_AUTHORITY,
34
+ HTTP2_HEADER_SCHEME,
35
+ HTTP2_HEADER_PATH,
36
+ HTTP2_HEADER_AUTHORIZATION,
37
+ HTTP2_HEADER_CONTENT_TYPE,
38
+ HTTP2_HEADER_CONTENT_LENGTH,
39
+ HTTP2_HEADER_ACCEPT,
40
+ HTTP2_HEADER_ACCEPT_ENCODING,
41
+ HTTP2_HEADER_ACCEPT_LANGUAGE,
42
+ // HTTP2_HEADER_REFERER,
43
+ // HTTP2_HEADER_HOST,
44
+ // HTTP2_HEADER_VIA,
45
+ // HTTP2_HEADER_CACHE_CONTROL,
46
+ HTTP2_HEADER_IF_MATCH,
47
+ HTTP2_HEADER_IF_MODIFIED_SINCE,
48
+ HTTP2_HEADER_IF_NONE_MATCH,
49
+ HTTP2_HEADER_IF_RANGE,
50
+ HTTP2_HEADER_IF_UNMODIFIED_SINCE,
51
+ // HTTP2_HEADER_LAST_MODIFIED,
52
+ HTTP2_HEADER_MAX_FORWARDS,
53
+ // HTTP2_HEADER_FROM
54
+ } = http2.constants
55
+
56
+ const DEFAULT_SUPPORTED_LANGUAGES = [ 'en-US', 'en' ]
57
+ const DEFAULT_SUPPORTED_MIME_TYPES = [
58
+ MIME_TYPE_JSON,
59
+ MIME_TYPE_XML,
60
+ MIME_TYPE_TEXT,
61
+ MIME_TYPE_EVENT_STREAM,
62
+ MIME_TYPE_MESSAGE_HTTP
63
+ ]
64
+ const DEFAULT_SUPPORTED_ENCODINGS = [ ...ENCODER_MAP.keys() ]
65
+
66
+ const FORWARDED_KEY_SECRET = 'secret'
67
+ const FORWARDED_ACCEPTABLE_KEYS = [ ...KNOWN_FORWARDED_KEYS, FORWARDED_KEY_SECRET ]
68
+ const FORWARDED_REQUIRED = process.env['FORWARDED_REQUIRED'] === 'true'
69
+ const FORWARDED_DROP_RIGHTMOST = (process.env['FORWARDED_SKIP_LIST'] ?? '').split(',').map(s => s.trim()).filter(s => s.length > 0)
70
+ const FORWARDED_SECRET = process.env['FORWARDED_SECRET']
71
+
72
+ const ALLOWED_ORIGINS = (process.env['ALLOWED_ORIGINS'] ?? '').split(',').map(s => s.trim()).filter(s => s.length > 0)
73
+
74
+ const ALLOW_TRACE = process.env['ALLOW_TRACE'] === 'true'
75
+
76
+ const BODY_TIMEOUT_SEC = 2 * 1000
77
+ const BODY_BYTE_LENGTH = 1000 * 1000
78
+
79
+ // const ipRateLimitStore = new Map()
80
+ // const ipRateLimitPolicy = {
81
+ // name: 'ip',
82
+ // quota: 25,
83
+ // windowSeconds: 15,
84
+ // size: 50,
85
+ // quotaUnits: 1
86
+ // }
87
+
88
+ /**
89
+ * @param {Config} config
90
+ * @param {StreamID} streamId
91
+ * @param {ServerHttp2Stream} stream
92
+ * @param {IncomingHttpHeaders} headers
93
+ * @param {string|undefined} servername
94
+ * @param {AbortSignal} shutdownSignal
95
+ * @returns {RouteRequest|RouteAction}
96
+ */
97
+ export function preamble(config, streamId, stream, headers, servername, shutdownSignal) {
98
+ const preambleStart = performance.now()
99
+
100
+ //
101
+ const method = headers[HTTP2_HEADER_METHOD]
102
+ const fullPathAndQuery = headers[HTTP2_HEADER_PATH]
103
+ const authority = headers[HTTP2_HEADER_AUTHORITY]
104
+ const scheme = headers[HTTP2_HEADER_SCHEME]
105
+ //
106
+ const authorization = headers[HTTP2_HEADER_AUTHORIZATION]
107
+ //
108
+ const fullForwarded = headers[HTTP_HEADER_FORWARDED]
109
+ //
110
+ const maxForwards = headers[HTTP2_HEADER_MAX_FORWARDS]
111
+ //
112
+ const fullContentType = headers[HTTP2_HEADER_CONTENT_TYPE]
113
+ const fullContentLength = headers[HTTP2_HEADER_CONTENT_LENGTH]
114
+ const fullAccept = headers[HTTP2_HEADER_ACCEPT]
115
+ const fullAcceptEncoding = headers[HTTP2_HEADER_ACCEPT_ENCODING]
116
+ const fullAcceptLanguage = headers[HTTP2_HEADER_ACCEPT_LANGUAGE]
117
+ //
118
+ const origin = headers[HTTP_HEADER_ORIGIN]
119
+ // const host = header[HTTP2_HEADER_HOST]
120
+ // const referer = header[HTTP2_HEADER_REFERER]
121
+ // const UA = header[HTTP_HEADER_USER_AGENT]
122
+
123
+ //
124
+ // const from = headers[HTTP2_HEADER_FROM]
125
+
126
+ // Conditions
127
+ const conditionIfMatch = headers[HTTP2_HEADER_IF_MATCH]
128
+ const conditionIfNoneMatch = headers[HTTP2_HEADER_IF_NONE_MATCH]
129
+ const conditionIfModifiedSince = headers[HTTP2_HEADER_IF_MODIFIED_SINCE]
130
+ const conditionIfUnmodifiedSince = headers[HTTP2_HEADER_IF_UNMODIFIED_SINCE]
131
+ const conditionIfRange = headers[HTTP2_HEADER_IF_RANGE]
132
+
133
+ // // SEC Client Hints
134
+ // const secUA = header[HTTP_HEADER_SEC_CH_UA]
135
+ // const secPlatform = header[HTTP_HEADER_SEC_CH_PLATFORM]
136
+ // const secMobile = header[HTTP_HEADER_SEC_CH_MOBILE]
137
+ // const secFetchSite = header[HTTP_HEADER_SEC_FETCH_SITE]
138
+ // const secFetchMode = header[HTTP_HEADER_SEC_FETCH_MODE]
139
+ // const secFetchDest = header[HTTP_HEADER_SEC_FETCH_DEST]
140
+
141
+ //
142
+ const allowedOrigin = (origin !== undefined) ?
143
+ ((ALLOWED_ORIGINS.includes(origin) || ALLOWED_ORIGINS.includes('*')) ?
144
+ (URL.canParse(origin) ?
145
+ origin : undefined) : undefined) : undefined
146
+
147
+ /** @type {RouteRequest|RouteAction} */
148
+ const state = {
149
+ type: 'error',
150
+ cause: 'initialize',
151
+ config,
152
+ streamId,
153
+ stream,
154
+ meta: {
155
+ servername,
156
+ performance: [],
157
+ origin: allowedOrigin,
158
+ customHeaders: []
159
+ },
160
+ shutdownSignal
161
+ }
162
+
163
+ if(shutdownSignal.aborted) {
164
+ return { ...state, type: 'unavailable', retryAfter: 60 }
165
+ }
166
+
167
+ if(stream.session === undefined) { return { ...state, type: 'error', cause: 'undefined session' } }
168
+ if(!(stream.session.socket instanceof TLSSocket)) { return { ...state, type: 'error', cause: 'not a TLSSocket' }}
169
+
170
+ const family = stream.session.socket.remoteFamily
171
+ const ip = stream.session.socket.remoteAddress
172
+ const port = stream.session.socket.remotePort
173
+
174
+ const SNI = stream.session.socket.servername // TLS SNI
175
+ if(SNI === null || SNI === false) { return { ...state, type: 'error', cause: 'invalid or unknown SNI' }}
176
+
177
+ //
178
+ if(!isValidHeader(fullPathAndQuery)) { return { ...state, type: 'error', cause: 'improper path' }}
179
+ if(!isValidMethod(method)) { return { ...state, type: 'not-implemented', message: 'unknown or invalid method' }}
180
+
181
+ if(!isValidLikeHeader(fullContentType)) { return { ...state, type: 'error', cause: 'improper header (content type)' }}
182
+ if(!isValidLikeHeader(fullContentLength)) { return { ...state, type: 'error', cause: 'improper header (content length)' }}
183
+ if(!isValidLikeHeader(fullAccept)) { return { ...state, type: 'error', cause: 'improper header (accept)' }}
184
+ if(!isValidLikeHeader(fullAcceptEncoding)) { return { ...state, type: 'error', cause: 'improper header (accept encoding)' }}
185
+ if(!isValidLikeHeader(fullAcceptLanguage)) { return { ...state, type: 'error', cause: 'improper header (accept language)' }}
186
+ if(!isValidLikeHeader(authorization)) { return { ...state, type: 'error', cause: 'improper header (authorization)' }}
187
+ if(!isValidLikeHeader(maxForwards)) { return { ...state, type: 'error', cause: 'improper header (max forwards)' } }
188
+ if(!isValidLikeHeader(conditionIfMatch)) { return { ...state, type: 'error', cause: 'improper header (if match)' } }
189
+ if(!isValidLikeHeader(conditionIfNoneMatch)) { return { ...state, type: 'error', cause: 'improper header (if none match)' } }
190
+ if(!isValidLikeHeader(conditionIfModifiedSince)) { return { ...state, type: 'error', cause: 'improper header (if modified since)' } }
191
+ if(!isValidLikeHeader(conditionIfUnmodifiedSince)) { return { ...state, type: 'error', cause: 'improper header (if unmodified since)' } }
192
+ if(!isValidLikeHeader(conditionIfRange)) { return { ...state, type: 'error', cause: 'improper header (if range)' } }
193
+
194
+ //
195
+ const requestUrl = new URL(fullPathAndQuery, `${scheme}://${authority}`)
196
+
197
+ //
198
+ /** @type {RouteConditions} */
199
+ const conditions = {
200
+ match: Conditional.parseEtagList(conditionIfMatch),
201
+ noneMatch: Conditional.parseEtagList(conditionIfNoneMatch),
202
+ modifiedSince: Conditional.parseFixDate(conditionIfModifiedSince),
203
+ unmodifiedSince: Conditional.parseFixDate(conditionIfUnmodifiedSince),
204
+ range: Conditional.parseFixDate(conditionIfRange) ?? ETag.parse(conditionIfRange)
205
+ }
206
+
207
+ //
208
+ // Forwarded
209
+ //
210
+ const forwardedList = Forwarded.parse(fullForwarded, FORWARDED_ACCEPTABLE_KEYS)
211
+ const forwarded = Forwarded.selectRightMost(forwardedList, FORWARDED_DROP_RIGHTMOST)
212
+ const forwardedFor = forwarded?.get(FORWARDED_KEY_FOR)
213
+ const forwardedSecret = forwarded?.get(FORWARDED_KEY_SECRET)
214
+
215
+ if(FORWARDED_REQUIRED && forwarded === undefined) { return { ...state, type: 'error', cause: 'forwarded required' } }
216
+ if(FORWARDED_REQUIRED && forwardedFor === undefined) { return { ...state, type: 'error', cause: 'forwarded for required' } }
217
+ if(FORWARDED_REQUIRED && forwardedSecret !== FORWARDED_SECRET) { return { ...state, type: 'error', cause: 'forwarded invalid' } }
218
+
219
+ //
220
+ // Options
221
+ //
222
+ if(method === HTTP2_METHOD_OPTIONS) {
223
+ const preambleEnd = performance.now()
224
+ state.meta.performance.push({ name: 'preamble-preflight', duration: preambleEnd - preambleStart })
225
+ return { ...state, type: 'preflight', method, methods: [], url: requestUrl }
226
+ }
227
+
228
+ //
229
+ // rate limit
230
+ //
231
+ // const ipRateLimitKey = `${ip}`
232
+ // if(!RateLimiter.test(ipRateLimitStore, ipRateLimitKey, ipRateLimitPolicy)) { return { type: 'limit', url: requestUrl, policy: ipRateLimitPolicy, ...defaultReturn } }
233
+
234
+ //
235
+ // content negotiation
236
+ //
237
+ const contentType = parseContentType(fullContentType)
238
+ const acceptedEncoding = AcceptEncoding.select(fullAcceptEncoding, DEFAULT_SUPPORTED_ENCODINGS)
239
+ const accept = Accept.select(fullAccept, DEFAULT_SUPPORTED_MIME_TYPES)
240
+ const acceptedLanguage = AcceptLanguage.select(fullAcceptLanguage, DEFAULT_SUPPORTED_LANGUAGES)
241
+ const acceptObject = {
242
+ type: accept,
243
+ encoding: acceptedEncoding,
244
+ language: acceptedLanguage
245
+ }
246
+
247
+ //
248
+ // Trace
249
+ //
250
+ if(method === HTTP2_METHOD_TRACE) {
251
+ if(!ALLOW_TRACE) { return { ...state, type: 'not-allowed', method, methods: [], url: requestUrl }}
252
+ const maxForwardsValue = maxForwards !== undefined ? parseInt(maxForwards) : 0
253
+ const preambleEnd = performance.now()
254
+ state.meta.performance.push({ name: 'preamble-trace', duration: preambleEnd - preambleStart })
255
+ if(acceptObject.type !== MIME_TYPE_MESSAGE_HTTP) { return { ...state, type: 'not-acceptable', acceptableMediaTypes: [ MIME_TYPE_MESSAGE_HTTP ] } }
256
+ return { ...state, type: 'trace', method, headers, url: requestUrl, maxForwards: maxForwardsValue, accept: acceptObject }
257
+ }
258
+
259
+ //
260
+ // setup future body
261
+ //
262
+ const contentLength = fullContentLength === undefined ? undefined : parseInt(fullContentLength, 10)
263
+ const body = requestBody(stream, {
264
+ byteLimit: BODY_BYTE_LENGTH,
265
+ contentLength,
266
+ contentType,
267
+ signal: AbortSignal.any([
268
+ shutdownSignal,
269
+ AbortSignal.timeout(BODY_TIMEOUT_SEC)
270
+ ])
271
+ })
272
+
273
+ //
274
+ // token
275
+ //
276
+ // const tokens = getTokens(authorization, requestUrl.searchParams)
277
+
278
+ //
279
+ const preambleEnd = performance.now()
280
+ state.meta.performance.push({ name: 'preamble', duration: preambleEnd - preambleStart })
281
+
282
+ return {
283
+ ...state,
284
+ type: 'request',
285
+ method,
286
+ url: requestUrl,
287
+ headers,
288
+ body,
289
+ // tokens,
290
+ conditions,
291
+ accept: acceptObject,
292
+ client: { family, ip, port },
293
+ SNI
294
+ }
295
+ }