@superhero/http-server 4.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/index.js ADDED
@@ -0,0 +1,424 @@
1
+ import View from '@superhero/http-server/view'
2
+ import Router from '@superhero/router'
3
+ import http from 'node:http'
4
+ import https from 'node:https'
5
+ import http2 from 'node:http2'
6
+ import net from 'node:net'
7
+ import tls from 'node:tls'
8
+ import { URL } from 'node:url'
9
+
10
+ export function locate(locator)
11
+ {
12
+ const router = new Router(locator)
13
+ return new HttpServer(router)
14
+ }
15
+
16
+ /**
17
+ * @memberof @superhero/http-server
18
+ */
19
+ export default class HttpServer
20
+ {
21
+ #sessions = new Set()
22
+
23
+ constructor(router)
24
+ {
25
+ this.router = router
26
+ }
27
+
28
+ async bootstrap(settings)
29
+ {
30
+ const
31
+ routes = settings?.router?.routes ?? {},
32
+ serverSettings = Object.assign({}, settings?.server)
33
+
34
+ this.router.setRoutes(routes, settings?.router?.seperators)
35
+
36
+ if(serverSettings.pfx
37
+ || serverSettings.key
38
+ || serverSettings.cert)
39
+ {
40
+ this.#bootstrapSecureServers(serverSettings)
41
+ }
42
+ else
43
+ {
44
+ this.#bootstrapNonSecureServers(serverSettings)
45
+ }
46
+
47
+ this.http2Server.on('session', this.addSession.bind(this))
48
+ this.gateway.on('close', () => setImmediate(() => this.log.info`closed`))
49
+ this.gateway.on('error', this.#onServerError.bind(this))
50
+ }
51
+
52
+ async listen(...args)
53
+ {
54
+ await new Promise((accept, reject) =>
55
+ {
56
+ if(this.gateway)
57
+ {
58
+ const
59
+ acceptCb = () => { this.gateway.off('error', rejectCb); accept() },
60
+ rejectCb = () => { this.gateway.off('listening', acceptCb); reject() }
61
+
62
+ this.gateway.once('error', rejectCb)
63
+ this.gateway.once('listening', acceptCb)
64
+
65
+ this.gateway.listen(...args)
66
+ }
67
+ else
68
+ {
69
+ this.#onServerNotAvailible(reject)
70
+ }
71
+ })
72
+
73
+ // Log the port the server is listening on.
74
+ const { port } = this.gateway.address()
75
+ this.log.info`port ${port} ⇡ listening`
76
+ }
77
+
78
+ async close()
79
+ {
80
+ // Close the gateway.
81
+ await new Promise((accept, reject) =>
82
+ this.gateway
83
+ ? this.gateway.close((error) =>
84
+ error
85
+ ? reject(error)
86
+ : setImmediate(accept))
87
+ : this.#onServerNotAvailible(reject))
88
+
89
+ // Close all sessions.
90
+ for(const session of this.#sessions)
91
+ await new Promise((accept) =>
92
+ session.closed
93
+ ? accept()
94
+ : session.close(accept))
95
+ }
96
+
97
+ addSession(session)
98
+ {
99
+ if(false === session.closed)
100
+ {
101
+ session.id = this.#composeSessionId()
102
+
103
+ session.on('close', () => this.log.info`${session.id} ⇣ closed`)
104
+ session.on('close', () => this.#sessions.delete(session))
105
+ session.on('error', this.log.error)
106
+ this.#sessions.add(session)
107
+ this.log.info`${session.id} ⇡ session`
108
+ }
109
+ }
110
+
111
+ // RFC 7540, Section 3.5
112
+ // The preface of a HTTP/2 request is designed to unambiguously signal
113
+ // that the client wants to use HTTP/2.
114
+ static #_HTTP2_PREFACE_BUFFER = Buffer.from('PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n')
115
+
116
+ /**
117
+ * @param {net.Socket|tls.Socket} socket
118
+ * @returns {Buffer}
119
+ */
120
+ async #readPreface(socket)
121
+ {
122
+ let preface
123
+
124
+ do
125
+ {
126
+ if(socket.destroyed)
127
+ {
128
+ return Buffer.alloc(0)
129
+ }
130
+
131
+ if(preface = socket.read(HttpServer.#_HTTP2_PREFACE_BUFFER.length))
132
+ {
133
+ return preface
134
+ }
135
+
136
+ await new Promise((accept) => process.nextTick(accept))
137
+ }
138
+ while(null === preface)
139
+ }
140
+
141
+ #onGatewayConnection(onHttp1Socket, onHttp2Socket, socket)
142
+ {
143
+ socket.once('readable', async () =>
144
+ {
145
+ const preface = await this.#readPreface(socket)
146
+
147
+ clearTimeout(timeout)
148
+ socket.unshift(preface)
149
+
150
+ preface.equals(HttpServer.#_HTTP2_PREFACE_BUFFER)
151
+ ? onHttp2Socket(socket)
152
+ : onHttp1Socket(socket)
153
+ })
154
+
155
+ const timeout = setTimeout(socket.destroy.bind(socket), 1e3)
156
+ }
157
+
158
+ #onGatewayConnectionHttp1Socket(socket)
159
+ {
160
+ this.http1Server.emit('connection', socket)
161
+ }
162
+
163
+ #onGatewayConnectionHttp2Socket(socket)
164
+ {
165
+ this.http2Server.emit('connection', socket)
166
+ }
167
+
168
+ #onGatewayConnectionSecureHttp1Socket(socket)
169
+ {
170
+ this.http1Server.emit('secureConnection', socket)
171
+ }
172
+
173
+ #onGatewayConnectionSecureHttp2Socket(socket)
174
+ {
175
+ // If possible to get this session some other way, then it should
176
+ // be concidered due to the version constraint.
177
+ // Added in: v21.7.0, v20.12.0.
178
+ const session = http2.performServerHandshake(socket)
179
+ this.http2Server.emit('session', session)
180
+
181
+ session.on('stream', (stream, headers) =>
182
+ {
183
+ const
184
+ upstream = new http2.Http2ServerRequest(stream, headers),
185
+ downstream = new http2.Http2ServerResponse(stream)
186
+
187
+ this.http2Server.emit('request', upstream, downstream)
188
+ })
189
+ }
190
+
191
+ #onHttp1Request(request, response)
192
+ {
193
+ if(undefined === request.socket.socketID)
194
+ {
195
+ request.socket.socketID = this.#composeSessionId()
196
+ request.socket.socketIndex = 1
197
+ }
198
+ else
199
+ {
200
+ request.socket.socketIndex += 1
201
+ }
202
+
203
+ if('keep-alive' === request.headers.connection)
204
+ {
205
+ const timeout = Math.floor(this.http1Server.keepAliveTimeout / 1e3)
206
+ response.setHeader('keep-alive', 'timeout=' + timeout)
207
+ this.log.info`${request.socket.socketID} ⇡ keep-alive`
208
+ }
209
+ }
210
+
211
+ #onServerRequest(protocol, upstream, downstream)
212
+ {
213
+ const requestID = this.#composeRequestId(upstream)
214
+
215
+ this.log.info`${requestID} ⇡ request`
216
+
217
+ const
218
+ session = {},
219
+ request = {},
220
+ url = new URL(upstream.url, `${protocol}://${upstream.headers.host}`),
221
+ criteria = url.pathname.replace(/\/+$/, ''), // removed trailing slashes
222
+ body = this.#bufferBody(upstream, request),
223
+ abortion = new AbortController
224
+
225
+ Object.defineProperties(request,
226
+ {
227
+ // configurable and writable criteria property
228
+ criteria : { writable:true, configurable:true, value:criteria },
229
+ // configurable and writable stream data reader
230
+ body : { writable:true, configurable:true, value:body },
231
+ // enumerable data readers, non-configurable
232
+ method : { enumerable:true, value:upstream.method },
233
+ headers : { enumerable:true, value:upstream.headers },
234
+ url : { enumerable:true, value:url },
235
+ })
236
+ Object.defineProperties(session,
237
+ {
238
+ // non enumerable and non-configurable session properties
239
+ downstream : { value:downstream },
240
+ upstream : { value:upstream },
241
+ abortion : { value:abortion }
242
+ })
243
+
244
+ Object.defineProperty(session, 'view', { enumerable: true, value: new View(session) })
245
+
246
+ abortion.signal.onabort = () => this.#onAbortedRequest(session)
247
+
248
+ upstream.on('aborted', this.#onUpstreamAborted.bind(this, session))
249
+ upstream.on('error', this.#onUpstreamError.bind(this))
250
+
251
+ downstream.on('error', this.#onDownstreamError.bind(this))
252
+ downstream.on('close', this.#onStreamClosed.bind(this, session))
253
+ downstream.on('close', () => this.log.info`${requestID} ⇣ closed`)
254
+
255
+ this.router.dispatch(request, session)
256
+ .catch(this.#onRouterDispatchRejected.bind(this, session))
257
+ .then(session.view.present.bind(session.view))
258
+ }
259
+
260
+ #bufferBody(upstream, request)
261
+ {
262
+ return new Promise((accept, reject) =>
263
+ {
264
+ if(upstream.readableEnded)
265
+ {
266
+ const error = new Error('The upstream is already closed')
267
+ error.code = 'E_HTTP_SERVER_READ_BUFFERED_UPSTREAM_CLOSED'
268
+ reject(error)
269
+ }
270
+ else
271
+ {
272
+ const
273
+ data = [],
274
+ pushChunk = (chunk) => data.push(chunk)
275
+
276
+ upstream.on('error', reject)
277
+ upstream.on('data', pushChunk)
278
+ upstream.on('end', () =>
279
+ {
280
+ upstream.off('error', reject)
281
+ upstream.off('data', pushChunk)
282
+
283
+ request.body = Buffer.concat(data)
284
+
285
+ accept(request.body)
286
+ })
287
+ }
288
+ })
289
+ }
290
+
291
+ #composeIdSegment(index)
292
+ {
293
+ return index.toString(36).toUpperCase().padStart(4, '0')
294
+ }
295
+
296
+ #composeSessionId()
297
+ {
298
+ const
299
+ timestamp = Date.now().toString(36),
300
+ randomKey = Math.random().toString(36).slice(2, 6).padStart(4, '0')
301
+
302
+ return `${timestamp}.${randomKey}`.toUpperCase()
303
+ }
304
+
305
+ // HTTP/2 streams have a unique ID, while HTTP/1.1 requests are identified by the socket.
306
+ #composeRequestId(upstream)
307
+ {
308
+ if(upstream.stream)
309
+ {
310
+ const { id:streamID, session:{ id:sessionID } } = upstream.stream
311
+ return sessionID + '.' + this.#composeIdSegment(streamID)
312
+ }
313
+ else
314
+ {
315
+ const { socketID, socketIndex } = upstream.socket
316
+ return socketID + '.' + this.#composeIdSegment(socketIndex)
317
+ }
318
+ }
319
+
320
+ #bootstrapSecureServers(serverSettings)
321
+ {
322
+ this.http1Server = https.createServer(serverSettings)
323
+ this.http2Server = http2.createSecureServer(serverSettings)
324
+ this.gateway = tls.createServer(serverSettings)
325
+
326
+ const
327
+ onHttp1Socket = this.#onGatewayConnectionSecureHttp1Socket.bind(this),
328
+ onHttp2Socket = this.#onGatewayConnectionSecureHttp2Socket.bind(this)
329
+
330
+ this.gateway.on('secureConnection', this.#onGatewayConnection.bind(this, onHttp1Socket, onHttp2Socket))
331
+ this.http1Server.on('request', this.#onHttp1Request.bind(this))
332
+ this.http1Server.on('request', this.#onServerRequest.bind(this, 'https'))
333
+ this.http2Server.on('request', this.#onServerRequest.bind(this, 'https'))
334
+ }
335
+
336
+ #bootstrapNonSecureServers(serverSettings)
337
+ {
338
+ this.http1Server = http.createServer(serverSettings)
339
+ this.http2Server = http2.createServer(serverSettings)
340
+ this.gateway = net.createServer(serverSettings)
341
+
342
+ const
343
+ onHttp1Socket = this.#onGatewayConnectionHttp1Socket.bind(this),
344
+ onHttp2Socket = this.#onGatewayConnectionHttp2Socket.bind(this)
345
+
346
+ this.gateway.on('connection', this.#onGatewayConnection.bind(this, onHttp1Socket, onHttp2Socket))
347
+ this.http1Server.on('request', this.#onHttp1Request.bind(this))
348
+ this.http1Server.on('request', this.#onServerRequest.bind(this, 'http'))
349
+ this.http2Server.on('request', this.#onServerRequest.bind(this, 'http'))
350
+ }
351
+
352
+ #onServerNotAvailible(reject)
353
+ {
354
+ const error = new Error('Server not availible (requires a bootstrap process)')
355
+ error.code = 'E_HTTP_SERVER_NOT_AVAILIBLE'
356
+ reject(error)
357
+ }
358
+
359
+ #onStreamClosed(session, reason)
360
+ {
361
+ const error = new Error('Stream closed')
362
+ error.code = 'E_HTTP_SERVER_STREAM_CLOSED'
363
+ error.cause = reason
364
+
365
+ session.abortion.abort(error)
366
+ }
367
+
368
+ #onUpstreamAborted(session)
369
+ {
370
+ const error = new Error('Upstream aborted')
371
+ error.code = 'E_HTTP_SERVER_UPSTREAM_ABORTED'
372
+ session.abortion.abort(error)
373
+ }
374
+
375
+ #onAbortedRequest(session)
376
+ {
377
+ session.abortion.signal.reason instanceof Error
378
+ ? session.view.presentError(session.abortion.signal.reason)
379
+ : session.view.present()
380
+ }
381
+
382
+ #onRouterDispatchRejected(session, reason)
383
+ {
384
+ session.view.presentError(reason.cause)
385
+ this.log.error(reason)
386
+ }
387
+
388
+ #onDownstreamError(reason)
389
+ {
390
+ const error = new Error('Downstream error')
391
+ error.code = 'E_HTTP_SERVER_DOWNSTREAM_ERROR'
392
+ error.cause = reason
393
+ this.log.error(error)
394
+ }
395
+
396
+ #onUpstreamError(reason)
397
+ {
398
+ const error = new Error('Upstream error')
399
+ error.code = 'E_HTTP_SERVER_UPSTREAM_ERROR'
400
+ error.cause = reason
401
+ this.log.error(error)
402
+ }
403
+
404
+ #onServerError(reason)
405
+ {
406
+ const error = new Error('Server error')
407
+ error.code = 'E_HTTP_SERVER_ERROR'
408
+ error.cause = reason
409
+ this.log.error(error)
410
+ }
411
+
412
+ // Will make the log methods availible to overwrite
413
+ // if a custom logging is desired.
414
+ log =
415
+ {
416
+ label : '[HTTP:SERVER] ⇢ ',
417
+ simple : (template, ...args) => this.log.label + template.reduce((result, part, i) => result + args[i - 1] + part),
418
+ colors : (template, ...args) => '\x1b[90m\x1b[1m\x1b[2m' + this.log.label + '\x1b[22m' + template.reduce((result, part, i) => result + '\x1b[96m\x1b[2m' + args[i - 1] + '\x1b[90m' + part) + '\x1b[0m',
419
+ format : (...args) => this.log.colors(...args),
420
+ info : (...args) => console.info (this.log.format(...args)),
421
+ warning : (...args) => console.warn (this.log.format(...args)),
422
+ error : (...args) => console.error (this.log.format`failure`, ...args)
423
+ }
424
+ }