@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/LICENCE +21 -0
- package/README.md +451 -0
- package/config.json +18 -0
- package/index.js +424 -0
- package/index.test.js +464 -0
- package/middleware/upstream/header/accept.js +52 -0
- package/middleware/upstream/header/content-type/application/json.js +29 -0
- package/middleware/upstream/header/content-type.js +45 -0
- package/middleware/upstream/method.js +38 -0
- package/package.json +42 -0
- package/view.js +285 -0
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
|
+
}
|