@jsenv/server 13.0.0 → 13.1.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/README.md +16 -49
- package/package.json +3 -2
- package/src/internal/request_factory.js +3 -1
- package/src/internal/server-polyglot.js +1 -0
- package/src/main.js +1 -1
- package/src/service_controller.js +1 -0
- package/src/start_server.js +962 -0
- package/src/server.js +0 -896
|
@@ -0,0 +1,962 @@
|
|
|
1
|
+
import http from "node:http"
|
|
2
|
+
import cluster from "node:cluster"
|
|
3
|
+
import { createDetailedMessage, createLogger } from "@jsenv/log"
|
|
4
|
+
import {
|
|
5
|
+
Abort,
|
|
6
|
+
raceProcessTeardownEvents,
|
|
7
|
+
createCallbackListNotifiedOnce,
|
|
8
|
+
} from "@jsenv/abort"
|
|
9
|
+
import { memoize } from "@jsenv/utils/src/memoize/memoize.js"
|
|
10
|
+
|
|
11
|
+
import { createServiceController } from "./service_controller.js"
|
|
12
|
+
import { timingToServerTimingResponseHeaders } from "./server_timing/timing_header.js"
|
|
13
|
+
import { createPolyglotServer } from "./internal/server-polyglot.js"
|
|
14
|
+
import { trackServerPendingConnections } from "./internal/trackServerPendingConnections.js"
|
|
15
|
+
import { trackServerPendingRequests } from "./internal/trackServerPendingRequests.js"
|
|
16
|
+
import {
|
|
17
|
+
fromNodeRequest,
|
|
18
|
+
createPushRequest,
|
|
19
|
+
applyRedirectionToRequest,
|
|
20
|
+
} from "./internal/request_factory.js"
|
|
21
|
+
import { populateNodeResponse } from "./internal/populateNodeResponse.js"
|
|
22
|
+
import {
|
|
23
|
+
statusToType,
|
|
24
|
+
colorizeResponseStatus,
|
|
25
|
+
} from "./internal/colorizeResponseStatus.js"
|
|
26
|
+
import { getServerOrigins } from "./internal/getServerOrigins.js"
|
|
27
|
+
import { listen, stopListening } from "./internal/listen.js"
|
|
28
|
+
import { composeTwoResponses } from "./internal/response_composition.js"
|
|
29
|
+
import { listenRequest } from "./internal/listenRequest.js"
|
|
30
|
+
import { listenEvent } from "./internal/listenEvent.js"
|
|
31
|
+
import { listenServerConnectionError } from "./internal/listenServerConnectionError.js"
|
|
32
|
+
import {
|
|
33
|
+
STOP_REASON_INTERNAL_ERROR,
|
|
34
|
+
STOP_REASON_PROCESS_SIGHUP,
|
|
35
|
+
STOP_REASON_PROCESS_SIGTERM,
|
|
36
|
+
STOP_REASON_PROCESS_SIGINT,
|
|
37
|
+
STOP_REASON_PROCESS_BEFORE_EXIT,
|
|
38
|
+
STOP_REASON_PROCESS_EXIT,
|
|
39
|
+
STOP_REASON_NOT_SPECIFIED,
|
|
40
|
+
} from "./stopReasons.js"
|
|
41
|
+
import { composeTwoHeaders } from "./internal/headers_composition.js"
|
|
42
|
+
|
|
43
|
+
export const startServer = async ({
|
|
44
|
+
signal = new AbortController().signal,
|
|
45
|
+
logLevel,
|
|
46
|
+
startLog = true,
|
|
47
|
+
serverName = "server",
|
|
48
|
+
|
|
49
|
+
protocol = "http",
|
|
50
|
+
http2 = false,
|
|
51
|
+
http1Allowed = true,
|
|
52
|
+
redirectHttpToHttps,
|
|
53
|
+
allowHttpRequestOnHttps = false,
|
|
54
|
+
acceptAnyIp = false,
|
|
55
|
+
host = acceptAnyIp ? undefined : "localhost",
|
|
56
|
+
port = 0, // assign a random available port
|
|
57
|
+
portHint,
|
|
58
|
+
privateKey,
|
|
59
|
+
certificate,
|
|
60
|
+
|
|
61
|
+
// when inside a worker, we should not try to stop server on SIGINT
|
|
62
|
+
// otherwise it can create an EPIPE error while primary process tries
|
|
63
|
+
// to kill the server
|
|
64
|
+
stopOnSIGINT = !cluster.isWorker,
|
|
65
|
+
// auto close the server when the process exits
|
|
66
|
+
stopOnExit = true,
|
|
67
|
+
// auto close when requestToResponse throw an error
|
|
68
|
+
stopOnInternalError = false,
|
|
69
|
+
keepProcessAlive = true,
|
|
70
|
+
services = [],
|
|
71
|
+
nagle = true,
|
|
72
|
+
serverTiming = false,
|
|
73
|
+
requestWaitingMs = 0,
|
|
74
|
+
requestWaitingCallback = ({ request, warn, requestWaitingMs }) => {
|
|
75
|
+
warn(
|
|
76
|
+
createDetailedMessage(
|
|
77
|
+
`still no response found for request after ${requestWaitingMs} ms`,
|
|
78
|
+
{
|
|
79
|
+
"request url": `${request.origin}${request.ressource}`,
|
|
80
|
+
"request headers": JSON.stringify(request.headers, null, " "),
|
|
81
|
+
},
|
|
82
|
+
),
|
|
83
|
+
)
|
|
84
|
+
},
|
|
85
|
+
} = {}) => {
|
|
86
|
+
if (protocol !== "http" && protocol !== "https") {
|
|
87
|
+
throw new Error(`protocol must be http or https, got ${protocol}`)
|
|
88
|
+
}
|
|
89
|
+
if (protocol === "https") {
|
|
90
|
+
if (!certificate) {
|
|
91
|
+
throw new Error(`missing certificate for https server`)
|
|
92
|
+
}
|
|
93
|
+
if (!privateKey) {
|
|
94
|
+
throw new Error(`missing privateKey for https server`)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (http2 && protocol !== "https") {
|
|
98
|
+
throw new Error(`http2 needs "https" but protocol is "${protocol}"`)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const logger = createLogger({ logLevel })
|
|
102
|
+
if (
|
|
103
|
+
redirectHttpToHttps === undefined &&
|
|
104
|
+
protocol === "https" &&
|
|
105
|
+
!allowHttpRequestOnHttps
|
|
106
|
+
) {
|
|
107
|
+
redirectHttpToHttps = true
|
|
108
|
+
}
|
|
109
|
+
if (redirectHttpToHttps && protocol === "http") {
|
|
110
|
+
logger.warn(`redirectHttpToHttps ignored because protocol is http`)
|
|
111
|
+
redirectHttpToHttps = false
|
|
112
|
+
}
|
|
113
|
+
if (allowHttpRequestOnHttps && redirectHttpToHttps) {
|
|
114
|
+
logger.warn(
|
|
115
|
+
`redirectHttpToHttps ignored because allowHttpRequestOnHttps is enabled`,
|
|
116
|
+
)
|
|
117
|
+
redirectHttpToHttps = false
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (allowHttpRequestOnHttps && protocol === "http") {
|
|
121
|
+
logger.warn(`allowHttpRequestOnHttps ignored because protocol is http`)
|
|
122
|
+
allowHttpRequestOnHttps = false
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const server = {}
|
|
126
|
+
const serviceController = createServiceController(services)
|
|
127
|
+
|
|
128
|
+
const processTeardownEvents = {
|
|
129
|
+
SIGHUP: stopOnExit,
|
|
130
|
+
SIGTERM: stopOnExit,
|
|
131
|
+
SIGINT: stopOnSIGINT,
|
|
132
|
+
beforeExit: stopOnExit,
|
|
133
|
+
exit: stopOnExit,
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let status = "starting"
|
|
137
|
+
let nodeServer
|
|
138
|
+
const startServerOperation = Abort.startOperation()
|
|
139
|
+
const stopCallbackList = createCallbackListNotifiedOnce()
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
startServerOperation.addAbortSignal(signal)
|
|
143
|
+
startServerOperation.addAbortSource((abort) => {
|
|
144
|
+
return raceProcessTeardownEvents(processTeardownEvents, ({ name }) => {
|
|
145
|
+
logger.info(`process teardown (${name}) -> aborting start server`)
|
|
146
|
+
abort()
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
startServerOperation.throwIfAborted()
|
|
150
|
+
nodeServer = await createNodeServer({
|
|
151
|
+
protocol,
|
|
152
|
+
redirectHttpToHttps,
|
|
153
|
+
allowHttpRequestOnHttps,
|
|
154
|
+
certificate,
|
|
155
|
+
privateKey,
|
|
156
|
+
http2,
|
|
157
|
+
http1Allowed,
|
|
158
|
+
})
|
|
159
|
+
startServerOperation.throwIfAborted()
|
|
160
|
+
|
|
161
|
+
// https://nodejs.org/api/net.html#net_server_unref
|
|
162
|
+
if (!keepProcessAlive) {
|
|
163
|
+
nodeServer.unref()
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
port = await listen({
|
|
167
|
+
signal: startServerOperation.signal,
|
|
168
|
+
server: nodeServer,
|
|
169
|
+
port,
|
|
170
|
+
portHint,
|
|
171
|
+
host,
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
serviceController.callHooks("serverListening", { port })
|
|
175
|
+
startServerOperation.addAbortCallback(async () => {
|
|
176
|
+
await stopListening(nodeServer)
|
|
177
|
+
})
|
|
178
|
+
startServerOperation.throwIfAborted()
|
|
179
|
+
} finally {
|
|
180
|
+
await startServerOperation.end()
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// now the server is started (listening) it cannot be aborted anymore
|
|
184
|
+
// (otherwise an AbortError is thrown to the code calling "startServer")
|
|
185
|
+
// we can proceed to create a stop function to stop it gacefully
|
|
186
|
+
// and add a request handler
|
|
187
|
+
stopCallbackList.add(({ reason }) => {
|
|
188
|
+
logger.info(`${serverName} stopping server (reason: ${reason})`)
|
|
189
|
+
})
|
|
190
|
+
stopCallbackList.add(async () => {
|
|
191
|
+
await stopListening(nodeServer)
|
|
192
|
+
})
|
|
193
|
+
let stoppedResolve
|
|
194
|
+
const stoppedPromise = new Promise((resolve) => {
|
|
195
|
+
stoppedResolve = resolve
|
|
196
|
+
})
|
|
197
|
+
const stop = memoize(async (reason = STOP_REASON_NOT_SPECIFIED) => {
|
|
198
|
+
status = "stopping"
|
|
199
|
+
await Promise.all(stopCallbackList.notify({ reason }))
|
|
200
|
+
serviceController.callHooks("serverStopped", { reason })
|
|
201
|
+
status = "stopped"
|
|
202
|
+
stoppedResolve(reason)
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
const cancelProcessTeardownRace = raceProcessTeardownEvents(
|
|
206
|
+
processTeardownEvents,
|
|
207
|
+
(winner) => {
|
|
208
|
+
stop(PROCESS_TEARDOWN_EVENTS_MAP[winner.name])
|
|
209
|
+
},
|
|
210
|
+
)
|
|
211
|
+
stopCallbackList.add(cancelProcessTeardownRace)
|
|
212
|
+
|
|
213
|
+
const onError = (error) => {
|
|
214
|
+
if (status === "stopping" && error.code === "ECONNRESET") {
|
|
215
|
+
return
|
|
216
|
+
}
|
|
217
|
+
throw error
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
status = "opened"
|
|
221
|
+
const serverOrigins = await getServerOrigins({ protocol, host, port })
|
|
222
|
+
const serverOrigin = serverOrigins.local
|
|
223
|
+
|
|
224
|
+
const removeConnectionErrorListener = listenServerConnectionError(
|
|
225
|
+
nodeServer,
|
|
226
|
+
onError,
|
|
227
|
+
)
|
|
228
|
+
stopCallbackList.add(removeConnectionErrorListener)
|
|
229
|
+
|
|
230
|
+
const connectionsTracker = trackServerPendingConnections(nodeServer, {
|
|
231
|
+
http2,
|
|
232
|
+
})
|
|
233
|
+
// opened connection must be shutdown before the close event is emitted
|
|
234
|
+
stopCallbackList.add(connectionsTracker.stop)
|
|
235
|
+
|
|
236
|
+
const pendingRequestsTracker = trackServerPendingRequests(nodeServer, {
|
|
237
|
+
http2,
|
|
238
|
+
})
|
|
239
|
+
// ensure pending requests got a response from the server
|
|
240
|
+
stopCallbackList.add((reason) => {
|
|
241
|
+
pendingRequestsTracker.stop({
|
|
242
|
+
status: reason === STOP_REASON_INTERNAL_ERROR ? 500 : 503,
|
|
243
|
+
reason,
|
|
244
|
+
})
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
request: {
|
|
248
|
+
const requestCallback = async (nodeRequest, nodeResponse) => {
|
|
249
|
+
// pause the stream to let a chance to "requestToResponse"
|
|
250
|
+
// to call "requestRequestBody". Without this the request body readable stream
|
|
251
|
+
// might be closed when we'll try to attach "data" and "end" listeners to it
|
|
252
|
+
nodeRequest.pause()
|
|
253
|
+
if (!nagle) {
|
|
254
|
+
nodeRequest.connection.setNoDelay(true)
|
|
255
|
+
}
|
|
256
|
+
if (redirectHttpToHttps && !nodeRequest.connection.encrypted) {
|
|
257
|
+
nodeResponse.writeHead(301, {
|
|
258
|
+
location: `${serverOrigin}${nodeRequest.url}`,
|
|
259
|
+
})
|
|
260
|
+
nodeResponse.end()
|
|
261
|
+
return
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const receiveRequestOperation = Abort.startOperation()
|
|
265
|
+
receiveRequestOperation.addAbortSource((abort) => {
|
|
266
|
+
const closeEventCallback = () => {
|
|
267
|
+
if (nodeRequest.complete) {
|
|
268
|
+
receiveRequestOperation.end()
|
|
269
|
+
} else {
|
|
270
|
+
nodeResponse.destroy()
|
|
271
|
+
abort()
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
nodeRequest.once("close", closeEventCallback)
|
|
275
|
+
return () => {
|
|
276
|
+
nodeRequest.removeListener("close", closeEventCallback)
|
|
277
|
+
}
|
|
278
|
+
})
|
|
279
|
+
receiveRequestOperation.addAbortSource((abort) => {
|
|
280
|
+
return stopCallbackList.add(abort)
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
const sendResponseOperation = Abort.startOperation()
|
|
284
|
+
sendResponseOperation.addAbortSignal(receiveRequestOperation.signal)
|
|
285
|
+
sendResponseOperation.addAbortSource((abort) => {
|
|
286
|
+
return stopCallbackList.add(abort)
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
const request = fromNodeRequest(nodeRequest, {
|
|
290
|
+
serverOrigin,
|
|
291
|
+
signal: receiveRequestOperation.signal,
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
// Handling request is asynchronous, we buffer logs for that request
|
|
295
|
+
// until we know what happens with that request
|
|
296
|
+
// It delays logs until we know of the request will be handled
|
|
297
|
+
// but it's mandatory to make logs readable.
|
|
298
|
+
const rootRequestNode = {
|
|
299
|
+
logs: [],
|
|
300
|
+
children: [],
|
|
301
|
+
}
|
|
302
|
+
const addRequestLog = (node, { type, value }) => {
|
|
303
|
+
node.logs.push({ type, value })
|
|
304
|
+
}
|
|
305
|
+
const onRequestHandled = (node) => {
|
|
306
|
+
if (node !== rootRequestNode) {
|
|
307
|
+
// keep buffering until root request write logs for everyone
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
const prefixLines = (string, prefix) => {
|
|
311
|
+
return string.replace(/^(?!\s*$)/gm, prefix)
|
|
312
|
+
}
|
|
313
|
+
const writeLog = (
|
|
314
|
+
{ type, value },
|
|
315
|
+
{ someLogIsError, someLogIsWarn, depth },
|
|
316
|
+
) => {
|
|
317
|
+
if (depth > 0) {
|
|
318
|
+
value = prefixLines(value, " ".repeat(depth))
|
|
319
|
+
}
|
|
320
|
+
if (type === "info") {
|
|
321
|
+
if (someLogIsError) {
|
|
322
|
+
type = "error"
|
|
323
|
+
} else if (someLogIsWarn) {
|
|
324
|
+
type = "warn"
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
logger[type](value)
|
|
328
|
+
}
|
|
329
|
+
const visitRequestNodeToLog = (requestNode, depth) => {
|
|
330
|
+
let someLogIsError = false
|
|
331
|
+
let someLogIsWarn = false
|
|
332
|
+
requestNode.logs.forEach((log) => {
|
|
333
|
+
if (log.type === "error") {
|
|
334
|
+
someLogIsError = true
|
|
335
|
+
}
|
|
336
|
+
if (log.type === "warn") {
|
|
337
|
+
someLogIsWarn = true
|
|
338
|
+
}
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
const firstLog = requestNode.logs.shift()
|
|
342
|
+
const lastLog = requestNode.logs.pop()
|
|
343
|
+
const middleLogs = requestNode.logs
|
|
344
|
+
|
|
345
|
+
writeLog(firstLog, { someLogIsError, someLogIsWarn, depth })
|
|
346
|
+
middleLogs.forEach((log) => {
|
|
347
|
+
writeLog(log, { someLogIsError, someLogIsWarn, depth })
|
|
348
|
+
})
|
|
349
|
+
requestNode.children.forEach((child) => {
|
|
350
|
+
visitRequestNodeToLog(child, depth + 1)
|
|
351
|
+
})
|
|
352
|
+
if (lastLog) {
|
|
353
|
+
writeLog(lastLog, {
|
|
354
|
+
someLogIsError,
|
|
355
|
+
someLogIsWarn,
|
|
356
|
+
depth: depth + 1,
|
|
357
|
+
})
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
visitRequestNodeToLog(rootRequestNode, 0)
|
|
361
|
+
}
|
|
362
|
+
nodeRequest.on("error", (error) => {
|
|
363
|
+
if (error.message === "aborted") {
|
|
364
|
+
addRequestLog(rootRequestNode, {
|
|
365
|
+
type: "debug",
|
|
366
|
+
value: createDetailedMessage(`request aborted by client`, {
|
|
367
|
+
"error message": error.message,
|
|
368
|
+
}),
|
|
369
|
+
})
|
|
370
|
+
} else {
|
|
371
|
+
// I'm not sure this can happen but it's here in case
|
|
372
|
+
addRequestLog(rootRequestNode, {
|
|
373
|
+
type: "error",
|
|
374
|
+
value: createDetailedMessage(`"error" event emitted on request`, {
|
|
375
|
+
"error stack": error.stack,
|
|
376
|
+
}),
|
|
377
|
+
})
|
|
378
|
+
}
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
const pushResponse = async ({ path, method }, { requestNode }) => {
|
|
382
|
+
const http2Stream = nodeResponse.stream
|
|
383
|
+
|
|
384
|
+
// being able to push a stream is nice to have
|
|
385
|
+
// so when it fails it's not critical
|
|
386
|
+
const onPushStreamError = (e) => {
|
|
387
|
+
addRequestLog(requestNode, {
|
|
388
|
+
type: "error",
|
|
389
|
+
value: createDetailedMessage(
|
|
390
|
+
`An error occured while pushing a stream to the response for ${request.ressource}`,
|
|
391
|
+
{
|
|
392
|
+
"error stack": e.stack,
|
|
393
|
+
},
|
|
394
|
+
),
|
|
395
|
+
})
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// not aborted, let's try to push a stream into that response
|
|
399
|
+
// https://nodejs.org/docs/latest-v16.x/api/http2.html#http2streampushstreamheaders-options-callback
|
|
400
|
+
let pushStream
|
|
401
|
+
try {
|
|
402
|
+
pushStream = await new Promise((resolve, reject) => {
|
|
403
|
+
http2Stream.pushStream(
|
|
404
|
+
{
|
|
405
|
+
":path": path,
|
|
406
|
+
...(method ? { ":method": method } : {}),
|
|
407
|
+
},
|
|
408
|
+
async (
|
|
409
|
+
error,
|
|
410
|
+
pushStream,
|
|
411
|
+
// headers
|
|
412
|
+
) => {
|
|
413
|
+
if (error) {
|
|
414
|
+
reject(error)
|
|
415
|
+
}
|
|
416
|
+
resolve(pushStream)
|
|
417
|
+
},
|
|
418
|
+
)
|
|
419
|
+
})
|
|
420
|
+
} catch (e) {
|
|
421
|
+
onPushStreamError(e)
|
|
422
|
+
return
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const abortController = new AbortController()
|
|
426
|
+
// It's possible to get NGHTTP2_REFUSED_STREAM errors here
|
|
427
|
+
// https://github.com/nodejs/node/issues/20824
|
|
428
|
+
const pushErrorCallback = (error) => {
|
|
429
|
+
onPushStreamError(error)
|
|
430
|
+
abortController.abort()
|
|
431
|
+
}
|
|
432
|
+
pushStream.on("error", pushErrorCallback)
|
|
433
|
+
sendResponseOperation.addEndCallback(() => {
|
|
434
|
+
pushStream.removeListener("error", onPushStreamError)
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
await sendResponseOperation.withSignal(async (signal) => {
|
|
438
|
+
const pushResponseOperation = Abort.startOperation()
|
|
439
|
+
pushResponseOperation.addAbortSignal(signal)
|
|
440
|
+
pushResponseOperation.addAbortSignal(abortController.signal)
|
|
441
|
+
|
|
442
|
+
const pushRequest = createPushRequest(request, {
|
|
443
|
+
signal: pushResponseOperation.signal,
|
|
444
|
+
pathname: path,
|
|
445
|
+
method,
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
try {
|
|
449
|
+
const responseProperties = await handleRequest(pushRequest, {
|
|
450
|
+
requestNode,
|
|
451
|
+
})
|
|
452
|
+
if (!abortController.signal.aborted) {
|
|
453
|
+
if (pushStream.destroyed) {
|
|
454
|
+
abortController.abort()
|
|
455
|
+
} else if (!http2Stream.pushAllowed) {
|
|
456
|
+
abortController.abort()
|
|
457
|
+
} else if (responseProperties !== ABORTED_RESPONSE_PROPERTIES) {
|
|
458
|
+
const responseLength =
|
|
459
|
+
responseProperties.headers["content-length"] || 0
|
|
460
|
+
const { effectiveRecvDataLength, remoteWindowSize } =
|
|
461
|
+
http2Stream.session.state
|
|
462
|
+
if (
|
|
463
|
+
effectiveRecvDataLength + responseLength >
|
|
464
|
+
remoteWindowSize
|
|
465
|
+
) {
|
|
466
|
+
addRequestLog(requestNode, {
|
|
467
|
+
type: "debug",
|
|
468
|
+
value: `Aborting stream to prevent exceeding remoteWindowSize`,
|
|
469
|
+
})
|
|
470
|
+
abortController.abort()
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
await sendResponse({
|
|
475
|
+
signal: pushResponseOperation.signal,
|
|
476
|
+
request: pushRequest,
|
|
477
|
+
requestNode,
|
|
478
|
+
responseStream: pushStream,
|
|
479
|
+
responseProperties,
|
|
480
|
+
})
|
|
481
|
+
} finally {
|
|
482
|
+
await pushResponseOperation.end()
|
|
483
|
+
}
|
|
484
|
+
})
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const handleRequest = async (request, { requestNode }) => {
|
|
488
|
+
let requestReceivedMeasure
|
|
489
|
+
if (serverTiming) {
|
|
490
|
+
requestReceivedMeasure = performance.now()
|
|
491
|
+
}
|
|
492
|
+
addRequestLog(requestNode, {
|
|
493
|
+
type: "info",
|
|
494
|
+
value: request.parent
|
|
495
|
+
? `Push ${request.ressource}`
|
|
496
|
+
: `${request.method} ${request.origin}${request.ressource}`,
|
|
497
|
+
})
|
|
498
|
+
const warn = (value) => {
|
|
499
|
+
addRequestLog(requestNode, {
|
|
500
|
+
type: "warn",
|
|
501
|
+
value,
|
|
502
|
+
})
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
let requestWaitingTimeout
|
|
506
|
+
if (requestWaitingMs) {
|
|
507
|
+
requestWaitingTimeout = setTimeout(
|
|
508
|
+
() => requestWaitingCallback({ request, warn, requestWaitingMs }),
|
|
509
|
+
requestWaitingMs,
|
|
510
|
+
).unref()
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
serviceController.callHooks(
|
|
514
|
+
"redirectRequest",
|
|
515
|
+
request,
|
|
516
|
+
{ warn },
|
|
517
|
+
(newRequestProperties) => {
|
|
518
|
+
if (newRequestProperties) {
|
|
519
|
+
request = applyRedirectionToRequest(request, {
|
|
520
|
+
original: request.original || request,
|
|
521
|
+
previous: request,
|
|
522
|
+
...newRequestProperties,
|
|
523
|
+
})
|
|
524
|
+
}
|
|
525
|
+
},
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
let handleRequestReturnValue
|
|
529
|
+
let errorWhileHandlingRequest = null
|
|
530
|
+
let handleRequestTimings = serverTiming ? {} : null
|
|
531
|
+
try {
|
|
532
|
+
handleRequestReturnValue =
|
|
533
|
+
await serviceController.callAsyncHooksUntil(
|
|
534
|
+
"handleRequest",
|
|
535
|
+
request,
|
|
536
|
+
{
|
|
537
|
+
timing: handleRequestTimings,
|
|
538
|
+
warn,
|
|
539
|
+
pushResponse: async ({ path, method }) => {
|
|
540
|
+
if (typeof path !== "string" || path[0] !== "/") {
|
|
541
|
+
addRequestLog(requestNode, {
|
|
542
|
+
type: "warn",
|
|
543
|
+
value: `response push ignored because path is invalid (must be a string starting with "/", found ${path})`,
|
|
544
|
+
})
|
|
545
|
+
return
|
|
546
|
+
}
|
|
547
|
+
if (!request.http2) {
|
|
548
|
+
addRequestLog(requestNode, {
|
|
549
|
+
type: "warn",
|
|
550
|
+
value: `response push ignored because request is not http2`,
|
|
551
|
+
})
|
|
552
|
+
return
|
|
553
|
+
}
|
|
554
|
+
const canPushStream = testCanPushStream(nodeResponse.stream)
|
|
555
|
+
if (!canPushStream.can) {
|
|
556
|
+
addRequestLog(requestNode, {
|
|
557
|
+
type: "debug",
|
|
558
|
+
value: `response push ignored because ${canPushStream.reason}`,
|
|
559
|
+
})
|
|
560
|
+
return
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
let preventedByService = null
|
|
564
|
+
const prevent = () => {
|
|
565
|
+
preventedByService = serviceController.getCurrentService()
|
|
566
|
+
}
|
|
567
|
+
serviceController.callHooksUntil(
|
|
568
|
+
"onResponsePush",
|
|
569
|
+
{ path, method },
|
|
570
|
+
{
|
|
571
|
+
request,
|
|
572
|
+
warn,
|
|
573
|
+
prevent,
|
|
574
|
+
},
|
|
575
|
+
() => preventedByService,
|
|
576
|
+
)
|
|
577
|
+
if (preventedByService) {
|
|
578
|
+
addRequestLog(requestNode, {
|
|
579
|
+
type: "debug",
|
|
580
|
+
value: `response push prevented by "${preventedByService.name}" service`,
|
|
581
|
+
})
|
|
582
|
+
return
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const requestChildNode = { logs: [], children: [] }
|
|
586
|
+
requestNode.children.push(requestChildNode)
|
|
587
|
+
await pushResponse(
|
|
588
|
+
{ path, method },
|
|
589
|
+
{
|
|
590
|
+
requestNode: requestChildNode,
|
|
591
|
+
parentHttp2Stream: nodeResponse.stream,
|
|
592
|
+
},
|
|
593
|
+
)
|
|
594
|
+
},
|
|
595
|
+
},
|
|
596
|
+
)
|
|
597
|
+
} catch (error) {
|
|
598
|
+
errorWhileHandlingRequest = error
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
let responseProperties
|
|
602
|
+
if (errorWhileHandlingRequest) {
|
|
603
|
+
if (
|
|
604
|
+
errorWhileHandlingRequest.name === "AbortError" &&
|
|
605
|
+
request.signal.aborted
|
|
606
|
+
) {
|
|
607
|
+
responseProperties = ABORTED_RESPONSE_PROPERTIES
|
|
608
|
+
} else {
|
|
609
|
+
// internal error, create 500 response
|
|
610
|
+
if (
|
|
611
|
+
// stopOnInternalError stops server only if requestToResponse generated
|
|
612
|
+
// a non controlled error (internal error).
|
|
613
|
+
// if requestToResponse gracefully produced a 500 response (it did not throw)
|
|
614
|
+
// then we can assume we are still in control of what we are doing
|
|
615
|
+
stopOnInternalError
|
|
616
|
+
) {
|
|
617
|
+
// il faudrais pouvoir stop que les autres response ?
|
|
618
|
+
stop(STOP_REASON_INTERNAL_ERROR)
|
|
619
|
+
}
|
|
620
|
+
const handleErrorReturnValue =
|
|
621
|
+
await serviceController.callAsyncHooksUntil(
|
|
622
|
+
"handleError",
|
|
623
|
+
errorWhileHandlingRequest,
|
|
624
|
+
{
|
|
625
|
+
request,
|
|
626
|
+
warn,
|
|
627
|
+
},
|
|
628
|
+
)
|
|
629
|
+
if (!handleErrorReturnValue) {
|
|
630
|
+
throw errorWhileHandlingRequest
|
|
631
|
+
}
|
|
632
|
+
addRequestLog(requestNode, {
|
|
633
|
+
type: "error",
|
|
634
|
+
value: createDetailedMessage(
|
|
635
|
+
`internal error while handling request`,
|
|
636
|
+
{
|
|
637
|
+
"error stack": errorWhileHandlingRequest.stack,
|
|
638
|
+
},
|
|
639
|
+
),
|
|
640
|
+
})
|
|
641
|
+
responseProperties = composeTwoResponses(
|
|
642
|
+
{
|
|
643
|
+
status: 500,
|
|
644
|
+
statusText: "Internal Server Error",
|
|
645
|
+
headers: {
|
|
646
|
+
// ensure error are not cached
|
|
647
|
+
"cache-control": "no-store",
|
|
648
|
+
"content-type": "text/plain",
|
|
649
|
+
},
|
|
650
|
+
},
|
|
651
|
+
handleErrorReturnValue,
|
|
652
|
+
)
|
|
653
|
+
}
|
|
654
|
+
} else {
|
|
655
|
+
const {
|
|
656
|
+
status = 501,
|
|
657
|
+
statusText,
|
|
658
|
+
statusMessage,
|
|
659
|
+
headers = {},
|
|
660
|
+
body,
|
|
661
|
+
...rest
|
|
662
|
+
} = handleRequestReturnValue || {}
|
|
663
|
+
responseProperties = {
|
|
664
|
+
status,
|
|
665
|
+
statusText,
|
|
666
|
+
statusMessage,
|
|
667
|
+
headers,
|
|
668
|
+
body,
|
|
669
|
+
...rest,
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
if (serverTiming) {
|
|
674
|
+
const responseReadyMeasure = performance.now()
|
|
675
|
+
const timeToStartResponding =
|
|
676
|
+
responseReadyMeasure - requestReceivedMeasure
|
|
677
|
+
const serverTiming = {
|
|
678
|
+
...handleRequestTimings,
|
|
679
|
+
...responseProperties.timing,
|
|
680
|
+
"time to start responding": timeToStartResponding,
|
|
681
|
+
}
|
|
682
|
+
responseProperties.headers = composeTwoHeaders(
|
|
683
|
+
responseProperties.headers,
|
|
684
|
+
timingToServerTimingResponseHeaders(serverTiming),
|
|
685
|
+
)
|
|
686
|
+
}
|
|
687
|
+
if (requestWaitingMs) {
|
|
688
|
+
clearTimeout(requestWaitingTimeout)
|
|
689
|
+
}
|
|
690
|
+
if (
|
|
691
|
+
request.method !== "HEAD" &&
|
|
692
|
+
responseProperties.headers["content-length"] > 0 &&
|
|
693
|
+
!responseProperties.body
|
|
694
|
+
) {
|
|
695
|
+
addRequestLog(requestNode, {
|
|
696
|
+
type: "warn",
|
|
697
|
+
value: `content-length header is ${responseProperties.headers["content-length"]} but body is empty`,
|
|
698
|
+
})
|
|
699
|
+
}
|
|
700
|
+
serviceController.callHooks(
|
|
701
|
+
"injectResponseHeaders",
|
|
702
|
+
responseProperties,
|
|
703
|
+
{
|
|
704
|
+
request,
|
|
705
|
+
warn,
|
|
706
|
+
},
|
|
707
|
+
(returnValue) => {
|
|
708
|
+
if (returnValue) {
|
|
709
|
+
responseProperties.headers = composeTwoHeaders(
|
|
710
|
+
responseProperties.headers,
|
|
711
|
+
returnValue,
|
|
712
|
+
)
|
|
713
|
+
}
|
|
714
|
+
},
|
|
715
|
+
)
|
|
716
|
+
serviceController.callHooks("responseReady", responseProperties, {
|
|
717
|
+
request,
|
|
718
|
+
warn,
|
|
719
|
+
})
|
|
720
|
+
return responseProperties
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const sendResponse = async ({
|
|
724
|
+
signal,
|
|
725
|
+
request,
|
|
726
|
+
requestNode,
|
|
727
|
+
responseStream,
|
|
728
|
+
responseProperties,
|
|
729
|
+
}) => {
|
|
730
|
+
// When "pushResponse" is called and the parent response has no body
|
|
731
|
+
// the parent response is immediatly ended. It means child responses (pushed streams)
|
|
732
|
+
// won't get a chance to be pushed.
|
|
733
|
+
// To let a chance to pushed streams we wait a little before sending the response
|
|
734
|
+
const ignoreBody = request.method === "HEAD"
|
|
735
|
+
const bodyIsEmpty = !responseProperties.body || ignoreBody
|
|
736
|
+
if (bodyIsEmpty && requestNode.children.length > 0) {
|
|
737
|
+
await new Promise((resolve) => setTimeout(resolve))
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
await populateNodeResponse(responseStream, responseProperties, {
|
|
741
|
+
signal,
|
|
742
|
+
ignoreBody,
|
|
743
|
+
onAbort: () => {
|
|
744
|
+
addRequestLog(requestNode, {
|
|
745
|
+
type: "info",
|
|
746
|
+
value: `response aborted`,
|
|
747
|
+
})
|
|
748
|
+
onRequestHandled(requestNode)
|
|
749
|
+
},
|
|
750
|
+
onError: (error) => {
|
|
751
|
+
addRequestLog(requestNode, {
|
|
752
|
+
type: "error",
|
|
753
|
+
value: createDetailedMessage(
|
|
754
|
+
`An error occured while sending response`,
|
|
755
|
+
{
|
|
756
|
+
"error stack": error.stack,
|
|
757
|
+
},
|
|
758
|
+
),
|
|
759
|
+
})
|
|
760
|
+
onRequestHandled(requestNode)
|
|
761
|
+
},
|
|
762
|
+
onHeadersSent: ({ status, statusText }) => {
|
|
763
|
+
const statusType = statusToType(status)
|
|
764
|
+
addRequestLog(requestNode, {
|
|
765
|
+
type: {
|
|
766
|
+
information: "info",
|
|
767
|
+
success: "info",
|
|
768
|
+
redirection: "info",
|
|
769
|
+
client_error: "warn",
|
|
770
|
+
server_error: "error",
|
|
771
|
+
}[statusType],
|
|
772
|
+
value: `${colorizeResponseStatus(status)} ${
|
|
773
|
+
responseProperties.statusMessage || statusText
|
|
774
|
+
}`,
|
|
775
|
+
})
|
|
776
|
+
},
|
|
777
|
+
onEnd: () => {
|
|
778
|
+
onRequestHandled(requestNode)
|
|
779
|
+
},
|
|
780
|
+
})
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
try {
|
|
784
|
+
if (receiveRequestOperation.signal.aborted) {
|
|
785
|
+
return
|
|
786
|
+
}
|
|
787
|
+
const responseProperties = await handleRequest(request, {
|
|
788
|
+
requestNode: rootRequestNode,
|
|
789
|
+
})
|
|
790
|
+
nodeRequest.resume()
|
|
791
|
+
if (receiveRequestOperation.signal.aborted) {
|
|
792
|
+
return
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// the node request readable stream is never closed because
|
|
796
|
+
// the response headers contains "connection: keep-alive"
|
|
797
|
+
// In this scenario we want to disable READABLE_STREAM_TIMEOUT warning
|
|
798
|
+
if (responseProperties.headers.connection === "keep-alive") {
|
|
799
|
+
clearTimeout(request.body.timeout)
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
await sendResponse({
|
|
803
|
+
signal: sendResponseOperation.signal,
|
|
804
|
+
request,
|
|
805
|
+
requestNode: rootRequestNode,
|
|
806
|
+
responseStream: nodeResponse,
|
|
807
|
+
responseProperties,
|
|
808
|
+
})
|
|
809
|
+
} finally {
|
|
810
|
+
await sendResponseOperation.end()
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
const removeRequestListener = listenRequest(nodeServer, requestCallback)
|
|
814
|
+
// ensure we don't try to handle new requests while server is stopping
|
|
815
|
+
stopCallbackList.add(removeRequestListener)
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
websocket: {
|
|
819
|
+
// https://github.com/websockets/ws/blob/master/doc/ws.md#class-websocket
|
|
820
|
+
const websocketHandlers = []
|
|
821
|
+
serviceController.services.forEach((service) => {
|
|
822
|
+
const { handleWebsocket } = service
|
|
823
|
+
if (handleWebsocket) {
|
|
824
|
+
websocketHandlers.push(handleWebsocket)
|
|
825
|
+
}
|
|
826
|
+
})
|
|
827
|
+
if (websocketHandlers.length > 0) {
|
|
828
|
+
const websocketClients = new Set()
|
|
829
|
+
const { WebSocketServer } = await import("ws")
|
|
830
|
+
let websocketServer = new WebSocketServer({ noServer: true })
|
|
831
|
+
const websocketOrigin =
|
|
832
|
+
protocol === "https" ? `wss://${host}:${port}` : `ws://${host}:${port}`
|
|
833
|
+
server.websocketOrigin = websocketOrigin
|
|
834
|
+
const upgradeCallback = (nodeRequest, socket, head) => {
|
|
835
|
+
websocketServer.handleUpgrade(
|
|
836
|
+
nodeRequest,
|
|
837
|
+
socket,
|
|
838
|
+
head,
|
|
839
|
+
async (websocket) => {
|
|
840
|
+
websocketClients.add(websocket)
|
|
841
|
+
websocket.once("close", () => {
|
|
842
|
+
websocketClients.delete(websocket)
|
|
843
|
+
})
|
|
844
|
+
const request = fromNodeRequest(nodeRequest, {
|
|
845
|
+
serverOrigin: websocketOrigin,
|
|
846
|
+
signal: new AbortController().signal,
|
|
847
|
+
})
|
|
848
|
+
serviceController.callAsyncHooksUntil(
|
|
849
|
+
"handleWebsocket",
|
|
850
|
+
websocket,
|
|
851
|
+
{
|
|
852
|
+
request,
|
|
853
|
+
},
|
|
854
|
+
)
|
|
855
|
+
},
|
|
856
|
+
)
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// see server-polyglot.js, upgrade must be listened on https server when used
|
|
860
|
+
const facadeServer = nodeServer._tlsServer || nodeServer
|
|
861
|
+
const removeUpgradeCallback = listenEvent(
|
|
862
|
+
facadeServer,
|
|
863
|
+
"upgrade",
|
|
864
|
+
upgradeCallback,
|
|
865
|
+
)
|
|
866
|
+
stopCallbackList.add(removeUpgradeCallback)
|
|
867
|
+
stopCallbackList.add(() => {
|
|
868
|
+
websocketServer.close()
|
|
869
|
+
websocketServer = null
|
|
870
|
+
})
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
if (startLog) {
|
|
875
|
+
if (serverOrigins.network) {
|
|
876
|
+
logger.info(
|
|
877
|
+
`${serverName} started at ${serverOrigins.local} (${serverOrigins.network})`,
|
|
878
|
+
)
|
|
879
|
+
} else {
|
|
880
|
+
logger.info(`${serverName} started at ${serverOrigins.local}`)
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
Object.assign(server, {
|
|
885
|
+
getStatus: () => status,
|
|
886
|
+
port,
|
|
887
|
+
origin: serverOrigin,
|
|
888
|
+
origins: serverOrigins,
|
|
889
|
+
nodeServer,
|
|
890
|
+
stop,
|
|
891
|
+
stoppedPromise,
|
|
892
|
+
addEffect: (callback) => {
|
|
893
|
+
const cleanup = callback()
|
|
894
|
+
if (typeof cleanup === "function") {
|
|
895
|
+
stopCallbackList.add(cleanup)
|
|
896
|
+
}
|
|
897
|
+
},
|
|
898
|
+
})
|
|
899
|
+
return server
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
const createNodeServer = async ({
|
|
903
|
+
protocol,
|
|
904
|
+
redirectHttpToHttps,
|
|
905
|
+
allowHttpRequestOnHttps,
|
|
906
|
+
certificate,
|
|
907
|
+
privateKey,
|
|
908
|
+
http2,
|
|
909
|
+
http1Allowed,
|
|
910
|
+
}) => {
|
|
911
|
+
if (protocol === "http") {
|
|
912
|
+
return http.createServer()
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
if (redirectHttpToHttps || allowHttpRequestOnHttps) {
|
|
916
|
+
return createPolyglotServer({
|
|
917
|
+
certificate,
|
|
918
|
+
privateKey,
|
|
919
|
+
http2,
|
|
920
|
+
http1Allowed,
|
|
921
|
+
})
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
const { createServer } = await import("node:https")
|
|
925
|
+
return createServer({
|
|
926
|
+
cert: certificate,
|
|
927
|
+
key: privateKey,
|
|
928
|
+
})
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
const testCanPushStream = (http2Stream) => {
|
|
932
|
+
if (!http2Stream.pushAllowed) {
|
|
933
|
+
return {
|
|
934
|
+
can: false,
|
|
935
|
+
reason: `stream.pushAllowed is false`,
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// See https://nodejs.org/dist/latest-v16.x/docs/api/http2.html#http2sessionstate
|
|
940
|
+
// And https://github.com/google/node-h2-auto-push/blob/67a36c04cbbd6da7b066a4e8d361c593d38853a4/src/index.ts#L100-L106
|
|
941
|
+
const { remoteWindowSize } = http2Stream.session.state
|
|
942
|
+
if (remoteWindowSize === 0) {
|
|
943
|
+
return {
|
|
944
|
+
can: false,
|
|
945
|
+
reason: `no more remoteWindowSize`,
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
return {
|
|
950
|
+
can: true,
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
const ABORTED_RESPONSE_PROPERTIES = {}
|
|
955
|
+
|
|
956
|
+
const PROCESS_TEARDOWN_EVENTS_MAP = {
|
|
957
|
+
SIGHUP: STOP_REASON_PROCESS_SIGHUP,
|
|
958
|
+
SIGTERM: STOP_REASON_PROCESS_SIGTERM,
|
|
959
|
+
SIGINT: STOP_REASON_PROCESS_SIGINT,
|
|
960
|
+
beforeExit: STOP_REASON_PROCESS_BEFORE_EXIT,
|
|
961
|
+
exit: STOP_REASON_PROCESS_EXIT,
|
|
962
|
+
}
|