@jsenv/server 9.1.1 → 9.2.3
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/package.json +1 -1
- package/src/abort/callback_race.js +31 -0
- package/src/abort/callback_race.md +38 -0
- package/src/abort/cleaner.js +58 -0
- package/src/abort/main.js +4 -1
- package/src/abort/operation.js +38 -0
- package/src/abort/process_teardown_abort.js +26 -0
- package/src/abort/process_teardown_events.js +59 -0
- package/src/internal/nodeRequestToRequest.js +46 -9
- package/src/internal/nodeStreamToObservable.js +7 -14
- package/src/internal/populateNodeResponse.js +85 -82
- package/src/startServer.js +72 -83
- package/src/abort/cleanup.js +0 -60
- package/src/internal/callback_race.js +0 -59
package/package.json
CHANGED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* See callback_race.md
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export const raceCallbacks = (raceDescription, winnerCallback) => {
|
|
6
|
+
const cleanCallbacks = []
|
|
7
|
+
|
|
8
|
+
const cleanup = () => {
|
|
9
|
+
const cleanCallbacksCopy = cleanCallbacks.slice()
|
|
10
|
+
cleanCallbacks.length = 0
|
|
11
|
+
cleanCallbacksCopy.forEach((clean) => {
|
|
12
|
+
clean()
|
|
13
|
+
})
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
Object.keys(raceDescription).forEach((candidateName) => {
|
|
17
|
+
const register = raceDescription[candidateName]
|
|
18
|
+
const returnValue = register((data) => {
|
|
19
|
+
cleanup()
|
|
20
|
+
winnerCallback({
|
|
21
|
+
name: candidateName,
|
|
22
|
+
data,
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
if (typeof returnValue === "function") {
|
|
26
|
+
cleanCallbacks.push(returnValue)
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
return cleanup
|
|
31
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
Taking different code paths based on different inputs is messy and code oftens forget to clean things behind itself.
|
|
2
|
+
|
|
3
|
+
"raceCallbacks" helps to do both properly with the following API
|
|
4
|
+
|
|
5
|
+
```js
|
|
6
|
+
import { raceCallbacks } from "somewhere"
|
|
7
|
+
|
|
8
|
+
raceCallbacks(
|
|
9
|
+
{
|
|
10
|
+
timeout: (cb) => {
|
|
11
|
+
const timeout = setTimeout(cb, 1000)
|
|
12
|
+
return () => {
|
|
13
|
+
clearTimeout(timeout)
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
error: () => {
|
|
17
|
+
something.on("error", cb)
|
|
18
|
+
return () => {
|
|
19
|
+
return something.removeListener("error", cb)
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
end: () => {
|
|
23
|
+
something.on("end", cb)
|
|
24
|
+
return () => {
|
|
25
|
+
return something.removeListener("end", cb)
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
(winner) => {
|
|
30
|
+
const raceEffects = {
|
|
31
|
+
timeout: () => console.log("timeout after 1000ms"),
|
|
32
|
+
error: (e) => console.error("error", e),
|
|
33
|
+
end: (value) => console.log("end", value),
|
|
34
|
+
}
|
|
35
|
+
raceEffects[winner.name](winner.value)
|
|
36
|
+
},
|
|
37
|
+
)
|
|
38
|
+
```
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export const createCleaner = () => {
|
|
2
|
+
let callbacks = []
|
|
3
|
+
|
|
4
|
+
const addCallback = (callback) => {
|
|
5
|
+
if (typeof callback !== "function") {
|
|
6
|
+
throw new Error(`callback must be a function, got ${callback}`)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// don't register twice
|
|
10
|
+
const existingCallback = callbacks.find((callbackCandidate) => {
|
|
11
|
+
return callbackCandidate === callback
|
|
12
|
+
})
|
|
13
|
+
if (existingCallback) {
|
|
14
|
+
if (typeof process.emitWarning === "object") {
|
|
15
|
+
process.emitWarning(`Trying to register same callback twice`, {
|
|
16
|
+
CODE: "CALLBACK_DUPLICATION",
|
|
17
|
+
detail: `It's often the sign that code is executd more than once`,
|
|
18
|
+
})
|
|
19
|
+
} else {
|
|
20
|
+
console.warn(`Trying to add same callback twice`)
|
|
21
|
+
}
|
|
22
|
+
} else {
|
|
23
|
+
callbacks = [...callbacks, callback]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return () => {
|
|
27
|
+
callbacks = arrayWithout(callbacks, callback)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const clean = async (reason) => {
|
|
32
|
+
const callbacksCopy = callbacks.slice()
|
|
33
|
+
callbacks.length = 0
|
|
34
|
+
|
|
35
|
+
await Promise.all(
|
|
36
|
+
callbacksCopy.map(async (callback) => {
|
|
37
|
+
await callback(reason)
|
|
38
|
+
}),
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return { addCallback, clean }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const arrayWithout = (array, item) => {
|
|
46
|
+
if (array.length === 0) return array
|
|
47
|
+
const arrayWithoutItem = []
|
|
48
|
+
let i = 0
|
|
49
|
+
while (i < array.length) {
|
|
50
|
+
const value = array[i]
|
|
51
|
+
i++
|
|
52
|
+
if (value === item) {
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
55
|
+
arrayWithoutItem.push(value)
|
|
56
|
+
}
|
|
57
|
+
return arrayWithoutItem
|
|
58
|
+
}
|
package/src/abort/main.js
CHANGED
|
@@ -27,4 +27,7 @@
|
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
29
|
export { Abort } from "./abort.js"
|
|
30
|
-
export {
|
|
30
|
+
export { createCleaner } from "./cleaner.js"
|
|
31
|
+
|
|
32
|
+
// When these file will become a NPM package, this export should be Node.js only
|
|
33
|
+
export { createOperation } from "./operation.js"
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Abort } from "./abort.js"
|
|
2
|
+
import { raceCallbacks } from "./callback_race.js"
|
|
3
|
+
import { createCleaner } from "./cleaner.js"
|
|
4
|
+
|
|
5
|
+
export const createOperation = ({
|
|
6
|
+
abortSignal = Abort.dormantSignal(),
|
|
7
|
+
cleanOnAbort = false,
|
|
8
|
+
} = {}) => {
|
|
9
|
+
const cleaner = createCleaner()
|
|
10
|
+
const operation = { abortSignal, cleaner }
|
|
11
|
+
|
|
12
|
+
if (cleanOnAbort) {
|
|
13
|
+
raceCallbacks(
|
|
14
|
+
{
|
|
15
|
+
aborted: (cb) => {
|
|
16
|
+
operation.abortSignal.addEventListener("abort", cb)
|
|
17
|
+
return () => {
|
|
18
|
+
operation.abortSignal.removeEventListener("abort", cb)
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
cleaned: (cb) => {
|
|
22
|
+
return cleaner.addCallback(cb)
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
(winner) => {
|
|
26
|
+
const raceEffects = {
|
|
27
|
+
aborted: () => {
|
|
28
|
+
cleaner.clean()
|
|
29
|
+
},
|
|
30
|
+
cleaned: () => {},
|
|
31
|
+
}
|
|
32
|
+
raceEffects[winner.name]()
|
|
33
|
+
},
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return operation
|
|
38
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Abort } from "./abort.js"
|
|
2
|
+
import { raceProcessTeardownEvents } from "./process_teardown_events.js"
|
|
3
|
+
|
|
4
|
+
export const abortOperationOnProcessTeardown = (
|
|
5
|
+
operation,
|
|
6
|
+
processTeardownEvents,
|
|
7
|
+
) => {
|
|
8
|
+
const processSignalAbortController = new AbortController()
|
|
9
|
+
operation.abortSignal = Abort.composeTwoAbortSignals(
|
|
10
|
+
operation.abortSignal,
|
|
11
|
+
processSignalAbortController.signal,
|
|
12
|
+
)
|
|
13
|
+
const cancelProcessTeardownRace = raceProcessTeardownEvents(
|
|
14
|
+
processTeardownEvents,
|
|
15
|
+
() => {
|
|
16
|
+
processSignalAbortController.abort()
|
|
17
|
+
},
|
|
18
|
+
)
|
|
19
|
+
const removeCleanCallback = operation.cleaner.addCallback(() => {
|
|
20
|
+
cancelProcessTeardownRace()
|
|
21
|
+
})
|
|
22
|
+
return () => {
|
|
23
|
+
cancelProcessTeardownRace()
|
|
24
|
+
removeCleanCallback()
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { raceCallbacks } from "./callback_race.js"
|
|
2
|
+
|
|
3
|
+
export const raceProcessTeardownEvents = (processTeardownEvents, callback) => {
|
|
4
|
+
return raceCallbacks(
|
|
5
|
+
{
|
|
6
|
+
...(processTeardownEvents.SIGHUP ? SIGHUP_CALLBACK : {}),
|
|
7
|
+
...(processTeardownEvents.SIGTERM ? SIGTERM_CALLBACK : {}),
|
|
8
|
+
...(processTeardownEvents.SIGINT ? SIGINT_CALLBACK : {}),
|
|
9
|
+
...(processTeardownEvents.beforeExit ? BEFORE_EXIT_CALLBACK : {}),
|
|
10
|
+
...(processTeardownEvents.exit ? EXIT_CALLBACK : {}),
|
|
11
|
+
},
|
|
12
|
+
callback,
|
|
13
|
+
)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const SIGHUP_CALLBACK = {
|
|
17
|
+
SIGHUP: (cb) => {
|
|
18
|
+
process.on("SIGHUP", cb)
|
|
19
|
+
return () => {
|
|
20
|
+
process.removeListener("SIGHUP", cb)
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const SIGTERM_CALLBACK = {
|
|
26
|
+
SIGTERM: (cb) => {
|
|
27
|
+
process.on("SIGTERM", cb)
|
|
28
|
+
return () => {
|
|
29
|
+
process.removeListener("SIGTERM", cb)
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const BEFORE_EXIT_CALLBACK = {
|
|
35
|
+
beforeExit: (cb) => {
|
|
36
|
+
process.on("beforeExit", cb)
|
|
37
|
+
return () => {
|
|
38
|
+
process.removeListener("beforeExit", cb)
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const EXIT_CALLBACK = {
|
|
44
|
+
exit: (cb) => {
|
|
45
|
+
process.on("exit", cb)
|
|
46
|
+
return () => {
|
|
47
|
+
process.removeListener("exit", cb)
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const SIGINT_CALLBACK = {
|
|
53
|
+
SIGINT: (cb) => {
|
|
54
|
+
process.on("SIGINT", cb)
|
|
55
|
+
return () => {
|
|
56
|
+
process.removeListener("SIGINT", cb)
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import { raceCallbacks } from "../abort/callback_race.js"
|
|
1
2
|
import { nodeStreamToObservable } from "./nodeStreamToObservable.js"
|
|
2
3
|
import { headersFromObject } from "./headersFromObject.js"
|
|
3
4
|
|
|
4
5
|
export const nodeRequestToRequest = (
|
|
5
6
|
nodeRequest,
|
|
6
|
-
{
|
|
7
|
+
{ serverOperation, serverOrigin },
|
|
7
8
|
) => {
|
|
8
9
|
const { method } = nodeRequest
|
|
9
10
|
const { url: ressource } = nodeRequest
|
|
@@ -24,7 +25,7 @@ export const nodeRequestToRequest = (
|
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
return Object.freeze({
|
|
27
|
-
abortSignal: createRequestAbortSignal({ nodeRequest,
|
|
28
|
+
abortSignal: createRequestAbortSignal({ nodeRequest, serverOperation }),
|
|
28
29
|
origin: requestOrigin,
|
|
29
30
|
http2: Boolean(nodeRequest.stream),
|
|
30
31
|
ressource,
|
|
@@ -34,13 +35,49 @@ export const nodeRequestToRequest = (
|
|
|
34
35
|
})
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
const createRequestAbortSignal = ({ nodeRequest,
|
|
38
|
+
const createRequestAbortSignal = ({ nodeRequest, serverOperation }) => {
|
|
38
39
|
const abortController = new AbortController()
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
40
|
+
raceCallbacks(
|
|
41
|
+
{
|
|
42
|
+
server_closing: (cb) => {
|
|
43
|
+
return serverOperation.cleaner.addCallback(cb)
|
|
44
|
+
},
|
|
45
|
+
request_close: (cb) => {
|
|
46
|
+
nodeRequest.on("close", cb)
|
|
47
|
+
return () => {
|
|
48
|
+
nodeRequest.removeListener("close", cb)
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
request_error: (cb) => {
|
|
52
|
+
nodeRequest.on("error", cb)
|
|
53
|
+
return () => {
|
|
54
|
+
nodeRequest.removeListener("error", cb)
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
request_finish: (cb) => {
|
|
58
|
+
nodeRequest.on("finish", cb)
|
|
59
|
+
return () => {
|
|
60
|
+
nodeRequest.removeListener("finish", cb)
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
(winner) => {
|
|
65
|
+
const raceEffects = {
|
|
66
|
+
server_closing: () => {
|
|
67
|
+
abortController.abort()
|
|
68
|
+
},
|
|
69
|
+
request_close: () => {
|
|
70
|
+
if (!nodeRequest.complete) {
|
|
71
|
+
// aborted while reading request body
|
|
72
|
+
abortController.abort()
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
// no special effects on thoose, just remove other callbacks
|
|
76
|
+
request_error: () => {},
|
|
77
|
+
request_finish: () => {},
|
|
78
|
+
}
|
|
79
|
+
raceEffects[winner.name]()
|
|
80
|
+
},
|
|
81
|
+
)
|
|
45
82
|
return abortController.signal
|
|
46
83
|
}
|
|
@@ -21,26 +21,19 @@ export const nodeStreamToObservable = (nodeStream) => {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
return createObservable(({ next, error, complete }) => {
|
|
24
|
-
// should we do nodeStream.resume() in case the stream was paused ?
|
|
25
|
-
nodeStream.once("error", error)
|
|
26
|
-
nodeStream.on("data", next)
|
|
27
|
-
nodeStream.once("end", complete)
|
|
28
|
-
|
|
29
24
|
const cleanup = () => {
|
|
30
25
|
nodeStream.removeListener("data", next)
|
|
31
26
|
nodeStream.removeListener("error", error)
|
|
32
27
|
nodeStream.removeListener("end", complete)
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
nodeStream.abort()
|
|
36
|
-
} else {
|
|
37
|
-
nodeStream.destroy()
|
|
38
|
-
}
|
|
28
|
+
nodeStream.removeListener("close", cleanup)
|
|
29
|
+
nodeStream.destroy()
|
|
39
30
|
}
|
|
40
31
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
32
|
+
// should we do nodeStream.resume() in case the stream was paused ?
|
|
33
|
+
nodeStream.once("error", error)
|
|
34
|
+
nodeStream.on("data", next)
|
|
35
|
+
nodeStream.once("close", cleanup)
|
|
36
|
+
nodeStream.once("end", complete)
|
|
44
37
|
|
|
45
38
|
return cleanup
|
|
46
39
|
})
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Http2ServerResponse } from "node:http2"
|
|
2
2
|
|
|
3
|
-
import { raceCallbacks } from "
|
|
3
|
+
import { raceCallbacks } from "@jsenv/server/src/abort/callback_race.js"
|
|
4
4
|
import { normalizeResponseBodyMethods } from "./responseBody.js"
|
|
5
5
|
|
|
6
6
|
export const populateNodeResponse = async (
|
|
@@ -41,92 +41,95 @@ export const populateNodeResponse = async (
|
|
|
41
41
|
responseStream.setEncoding(bodyEncoding)
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
const winnerPromise = raceCallbacks({
|
|
45
|
-
abort: (cb) => {
|
|
46
|
-
abortSignal.addEventListener("abort", cb)
|
|
47
|
-
return () => {
|
|
48
|
-
abortSignal.removeEventListener("abort", cb)
|
|
49
|
-
}
|
|
50
|
-
},
|
|
51
|
-
error: (cb) => {
|
|
52
|
-
responseStream.on("error", cb)
|
|
53
|
-
return () => {
|
|
54
|
-
responseStream.removeListener("error", cb)
|
|
55
|
-
}
|
|
56
|
-
},
|
|
57
|
-
close: (cb) => {
|
|
58
|
-
responseStream.on("close", cb)
|
|
59
|
-
return () => {
|
|
60
|
-
responseStream.removeListener("close", cb)
|
|
61
|
-
}
|
|
62
|
-
},
|
|
63
|
-
finish: (cb) => {
|
|
64
|
-
responseStream.on("finish", cb)
|
|
65
|
-
return () => {
|
|
66
|
-
responseStream.removeListener("finish", cb)
|
|
67
|
-
}
|
|
68
|
-
},
|
|
69
|
-
})
|
|
70
|
-
|
|
71
|
-
const observable = bodyMethods.asObservable()
|
|
72
|
-
const subscription = observable.subscribe({
|
|
73
|
-
next: (data) => {
|
|
74
|
-
try {
|
|
75
|
-
responseStream.write(data)
|
|
76
|
-
} catch (e) {
|
|
77
|
-
// Something inside Node.js sometimes puts stream
|
|
78
|
-
// in a state where .write() throw despites nodeResponse.destroyed
|
|
79
|
-
// being undefined and "close" event not being emitted.
|
|
80
|
-
// I have tested if we are the one calling destroy
|
|
81
|
-
// (I have commented every .destroy() call)
|
|
82
|
-
// but issue still occurs
|
|
83
|
-
// For the record it's "hard" to reproduce but can be by running
|
|
84
|
-
// a lot of tests against a browser in the context of @jsenv/core testing
|
|
85
|
-
if (e.code === "ERR_HTTP2_INVALID_STREAM") {
|
|
86
|
-
return
|
|
87
|
-
}
|
|
88
|
-
responseStream.emit("error", e)
|
|
89
|
-
}
|
|
90
|
-
},
|
|
91
|
-
error: (value) => {
|
|
92
|
-
responseStream.emit("error", value)
|
|
93
|
-
},
|
|
94
|
-
complete: () => {
|
|
95
|
-
responseStream.end()
|
|
96
|
-
},
|
|
97
|
-
})
|
|
98
|
-
|
|
99
|
-
const winner = await winnerPromise
|
|
100
44
|
await new Promise((resolve) => {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
45
|
+
raceCallbacks(
|
|
46
|
+
{
|
|
47
|
+
abort: (cb) => {
|
|
48
|
+
abortSignal.addEventListener("abort", cb)
|
|
49
|
+
return () => {
|
|
50
|
+
abortSignal.removeEventListener("abort", cb)
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
error: (cb) => {
|
|
54
|
+
responseStream.on("error", cb)
|
|
55
|
+
return () => {
|
|
56
|
+
responseStream.removeListener("error", cb)
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
close: (cb) => {
|
|
60
|
+
responseStream.on("close", cb)
|
|
61
|
+
return () => {
|
|
62
|
+
responseStream.removeListener("close", cb)
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
finish: (cb) => {
|
|
66
|
+
responseStream.on("finish", cb)
|
|
67
|
+
return () => {
|
|
68
|
+
responseStream.removeListener("finish", cb)
|
|
69
|
+
}
|
|
70
|
+
},
|
|
107
71
|
},
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
72
|
+
(winner) => {
|
|
73
|
+
const raceEffects = {
|
|
74
|
+
abort: () => {
|
|
75
|
+
subscription.unsubscribe()
|
|
76
|
+
responseStream.destroy()
|
|
77
|
+
onAbort()
|
|
78
|
+
resolve()
|
|
79
|
+
},
|
|
80
|
+
error: (error) => {
|
|
81
|
+
subscription.unsubscribe()
|
|
82
|
+
responseStream.destroy()
|
|
83
|
+
onError(error)
|
|
84
|
+
resolve()
|
|
85
|
+
},
|
|
86
|
+
close: () => {
|
|
87
|
+
// close body in case nodeResponse is prematurely closed
|
|
88
|
+
// while body is writing
|
|
89
|
+
// it may happen in case of server sent event
|
|
90
|
+
// where body is kept open to write to client
|
|
91
|
+
// and the browser is reloaded or closed for instance
|
|
92
|
+
subscription.unsubscribe()
|
|
93
|
+
responseStream.destroy()
|
|
94
|
+
onAbort()
|
|
95
|
+
resolve()
|
|
96
|
+
},
|
|
97
|
+
finish: () => {
|
|
98
|
+
onEnd()
|
|
99
|
+
resolve()
|
|
100
|
+
},
|
|
101
|
+
}
|
|
102
|
+
raceEffects[winner.name](winner.data)
|
|
103
|
+
},
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
const observable = bodyMethods.asObservable()
|
|
107
|
+
const subscription = observable.subscribe({
|
|
108
|
+
next: (data) => {
|
|
109
|
+
try {
|
|
110
|
+
responseStream.write(data)
|
|
111
|
+
} catch (e) {
|
|
112
|
+
// Something inside Node.js sometimes puts stream
|
|
113
|
+
// in a state where .write() throw despites nodeResponse.destroyed
|
|
114
|
+
// being undefined and "close" event not being emitted.
|
|
115
|
+
// I have tested if we are the one calling destroy
|
|
116
|
+
// (I have commented every .destroy() call)
|
|
117
|
+
// but issue still occurs
|
|
118
|
+
// For the record it's "hard" to reproduce but can be by running
|
|
119
|
+
// a lot of tests against a browser in the context of @jsenv/core testing
|
|
120
|
+
if (e.code === "ERR_HTTP2_INVALID_STREAM") {
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
responseStream.emit("error", e)
|
|
124
|
+
}
|
|
113
125
|
},
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
// while body is writing
|
|
117
|
-
// it may happen in case of server sent event
|
|
118
|
-
// where body is kept open to write to client
|
|
119
|
-
// and the browser is reloaded or closed for instance
|
|
120
|
-
subscription.unsubscribe()
|
|
121
|
-
onEnd()
|
|
122
|
-
resolve()
|
|
126
|
+
error: (value) => {
|
|
127
|
+
responseStream.emit("error", value)
|
|
123
128
|
},
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
resolve()
|
|
129
|
+
complete: () => {
|
|
130
|
+
responseStream.end()
|
|
127
131
|
},
|
|
128
|
-
}
|
|
129
|
-
raceEffects[winner.name](winner.data)
|
|
132
|
+
})
|
|
130
133
|
})
|
|
131
134
|
}
|
|
132
135
|
|
package/src/startServer.js
CHANGED
|
@@ -3,9 +3,9 @@ import cluster from "node:cluster"
|
|
|
3
3
|
import { memoize } from "@jsenv/filesystem"
|
|
4
4
|
import { createDetailedMessage, createLogger } from "@jsenv/logger"
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import { createOperation, Abort } from "./abort/main.js"
|
|
7
|
+
import { abortOperationOnProcessTeardown } from "./abort/process_teardown_abort.js"
|
|
7
8
|
|
|
8
|
-
import { raceCallbacks } from "./internal/callback_race.js"
|
|
9
9
|
import { createPolyglotServer } from "./internal/server-polyglot.js"
|
|
10
10
|
import { trackServerPendingConnections } from "./internal/trackServerPendingConnections.js"
|
|
11
11
|
import { trackServerPendingRequests } from "./internal/trackServerPendingRequests.js"
|
|
@@ -27,9 +27,10 @@ import {
|
|
|
27
27
|
STOP_REASON_PROCESS_EXIT,
|
|
28
28
|
STOP_REASON_NOT_SPECIFIED,
|
|
29
29
|
} from "./stopReasons.js"
|
|
30
|
+
import { raceProcessTeardownEvents } from "./abort/process_teardown_events.js"
|
|
30
31
|
|
|
31
32
|
export const startServer = async ({
|
|
32
|
-
abortSignal
|
|
33
|
+
abortSignal,
|
|
33
34
|
logLevel,
|
|
34
35
|
serverName = "server",
|
|
35
36
|
|
|
@@ -59,8 +60,7 @@ export const startServer = async ({
|
|
|
59
60
|
sendServerInternalErrorDetails = false,
|
|
60
61
|
serverInternalErrorToResponse = jsenvServerInternalErrorToResponse,
|
|
61
62
|
|
|
62
|
-
|
|
63
|
-
stoppedCallback = () => {},
|
|
63
|
+
onStop = () => {},
|
|
64
64
|
nagle = true,
|
|
65
65
|
} = {}) => {
|
|
66
66
|
if (protocol !== "http" && protocol !== "https") {
|
|
@@ -78,9 +78,23 @@ export const startServer = async ({
|
|
|
78
78
|
throw new Error(`http2 needs "https" but protocol is "${protocol}"`)
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
const serverOperation = createOperation({
|
|
82
|
+
abortSignal,
|
|
83
|
+
})
|
|
84
|
+
const processTeardownEvents = {
|
|
85
|
+
SIGHUP: stopOnExit,
|
|
86
|
+
SIGTERM: stopOnExit,
|
|
87
|
+
SIGINT: stopOnSIGINT,
|
|
88
|
+
beforeExit: stopOnExit,
|
|
89
|
+
exit: stopOnExit,
|
|
90
|
+
}
|
|
91
|
+
const cancelAbortOnProcessTeardown = abortOperationOnProcessTeardown(
|
|
92
|
+
serverOperation,
|
|
93
|
+
processTeardownEvents,
|
|
94
|
+
)
|
|
81
95
|
// we "must" listen abort signal only while server is starting
|
|
82
96
|
// after this, it's stop() that must be used
|
|
83
|
-
Abort.throwIfAborted(abortSignal)
|
|
97
|
+
Abort.throwIfAborted(serverOperation.abortSignal)
|
|
84
98
|
|
|
85
99
|
const logger = createLogger({ logLevel })
|
|
86
100
|
if (
|
|
@@ -113,29 +127,6 @@ export const startServer = async ({
|
|
|
113
127
|
throw error
|
|
114
128
|
}
|
|
115
129
|
|
|
116
|
-
const serverCleanup = Cleanup.create()
|
|
117
|
-
|
|
118
|
-
if (stopOnExit || stopOnSIGINT) {
|
|
119
|
-
raceCallbacks({
|
|
120
|
-
...(stopOnExit ? EXIT_CALLBACKS : {}),
|
|
121
|
-
...(stopOnSIGINT ? SIGINT_CALLBACKS : {}),
|
|
122
|
-
// to ensure other events are cleaned up when stop is called for other reasons
|
|
123
|
-
other_stop_reasons: (cb) => {
|
|
124
|
-
return serverCleanup.addCallback(cb)
|
|
125
|
-
},
|
|
126
|
-
}).then((winner) => {
|
|
127
|
-
const effects = {
|
|
128
|
-
SIGHUP: () => stop(STOP_REASON_PROCESS_SIGHUP),
|
|
129
|
-
SIGTERM: () => stop(STOP_REASON_PROCESS_SIGTERM),
|
|
130
|
-
SIGINT: () => stop(STOP_REASON_PROCESS_SIGINT),
|
|
131
|
-
beforeExit: () => stop(STOP_REASON_PROCESS_BEFORE_EXIT),
|
|
132
|
-
exit: () => stop(STOP_REASON_PROCESS_EXIT),
|
|
133
|
-
other_stop_reasons: () => {},
|
|
134
|
-
}
|
|
135
|
-
effects[winner.name]()
|
|
136
|
-
})
|
|
137
|
-
}
|
|
138
|
-
|
|
139
130
|
const nodeServer = await createNodeServer({
|
|
140
131
|
protocol,
|
|
141
132
|
redirectHttpToHttps,
|
|
@@ -145,7 +136,7 @@ export const startServer = async ({
|
|
|
145
136
|
http2,
|
|
146
137
|
http1Allowed,
|
|
147
138
|
})
|
|
148
|
-
Abort.throwIfAborted(abortSignal)
|
|
139
|
+
Abort.throwIfAborted(serverOperation.abortSignal)
|
|
149
140
|
|
|
150
141
|
// https://nodejs.org/api/net.html#net_server_unref
|
|
151
142
|
if (!keepProcessAlive) {
|
|
@@ -160,16 +151,16 @@ export const startServer = async ({
|
|
|
160
151
|
const stop = memoize(async (reason = STOP_REASON_NOT_SPECIFIED) => {
|
|
161
152
|
status = "stopping"
|
|
162
153
|
logger.info(`${serverName} stopping server because ${reason}`)
|
|
154
|
+
onStop({ reason })
|
|
163
155
|
|
|
164
|
-
await
|
|
156
|
+
await serverOperation.cleaner.clean(reason)
|
|
165
157
|
await stopListening(nodeServer)
|
|
166
158
|
status = "stopped"
|
|
167
|
-
stoppedCallback({ reason })
|
|
168
159
|
stoppedResolve(reason)
|
|
169
160
|
})
|
|
170
161
|
|
|
171
162
|
port = await listen({
|
|
172
|
-
abortSignal,
|
|
163
|
+
abortSignal: serverOperation.abortSignal,
|
|
173
164
|
server: nodeServer,
|
|
174
165
|
port,
|
|
175
166
|
portHint,
|
|
@@ -184,19 +175,19 @@ export const startServer = async ({
|
|
|
184
175
|
nodeServer,
|
|
185
176
|
onError,
|
|
186
177
|
)
|
|
187
|
-
|
|
178
|
+
serverOperation.cleaner.addCallback(removeConnectionErrorListener)
|
|
188
179
|
|
|
189
180
|
const connectionsTracker = trackServerPendingConnections(nodeServer, {
|
|
190
181
|
http2,
|
|
191
182
|
})
|
|
192
183
|
// opened connection must be shutdown before the close event is emitted
|
|
193
|
-
|
|
184
|
+
serverOperation.cleaner.addCallback(connectionsTracker.stop)
|
|
194
185
|
|
|
195
186
|
const pendingRequestsTracker = trackServerPendingRequests(nodeServer, {
|
|
196
187
|
http2,
|
|
197
188
|
})
|
|
198
189
|
// ensure pending requests got a response from the server
|
|
199
|
-
|
|
190
|
+
serverOperation.cleaner.addCallback((reason) => {
|
|
200
191
|
pendingRequestsTracker.stop({
|
|
201
192
|
status: reason === STOP_REASON_INTERNAL_ERROR ? 500 : 503,
|
|
202
193
|
reason,
|
|
@@ -216,7 +207,7 @@ export const startServer = async ({
|
|
|
216
207
|
}
|
|
217
208
|
|
|
218
209
|
const request = nodeRequestToRequest(nodeRequest, {
|
|
219
|
-
|
|
210
|
+
serverOperation,
|
|
220
211
|
serverOrigin,
|
|
221
212
|
})
|
|
222
213
|
|
|
@@ -356,16 +347,18 @@ export const startServer = async ({
|
|
|
356
347
|
parentHttp2Stream: pushStream,
|
|
357
348
|
})
|
|
358
349
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
350
|
+
if (responseProperties !== ABORTED_RESPONSE_PROPERTIES) {
|
|
351
|
+
const responseLength = responseProperties.headers["content-length"] || 0
|
|
352
|
+
// should we check on "parentHttp2Stream" or "nodeResponse.stream"?
|
|
353
|
+
const { effectiveRecvDataLength, remoteWindowSize } =
|
|
354
|
+
parentHttp2Stream.session.state
|
|
355
|
+
if (effectiveRecvDataLength + responseLength > remoteWindowSize) {
|
|
356
|
+
addRequestLog(requestNode, {
|
|
357
|
+
type: "debug",
|
|
358
|
+
value: `Aborting stream to prevent exceeding remoteWindowSize`,
|
|
359
|
+
})
|
|
360
|
+
pushRequestAbortController.abort()
|
|
361
|
+
}
|
|
369
362
|
}
|
|
370
363
|
|
|
371
364
|
await sendResponse({
|
|
@@ -411,7 +404,7 @@ export const startServer = async ({
|
|
|
411
404
|
}
|
|
412
405
|
}
|
|
413
406
|
})
|
|
414
|
-
let { error, responseProperties } = responseFromShortcircuit
|
|
407
|
+
let { aborted, error, responseProperties } = responseFromShortcircuit
|
|
415
408
|
? { responseProperties: responseFromShortcircuit }
|
|
416
409
|
: await generateResponseDescription({
|
|
417
410
|
requestToResponse,
|
|
@@ -429,6 +422,10 @@ export const startServer = async ({
|
|
|
429
422
|
},
|
|
430
423
|
})
|
|
431
424
|
|
|
425
|
+
if (aborted) {
|
|
426
|
+
return ABORTED_RESPONSE_PROPERTIES
|
|
427
|
+
}
|
|
428
|
+
|
|
432
429
|
// internal error, create 500 response
|
|
433
430
|
if (error) {
|
|
434
431
|
if (
|
|
@@ -580,12 +577,25 @@ export const startServer = async ({
|
|
|
580
577
|
|
|
581
578
|
const removeRequestListener = listenRequest(nodeServer, requestCallback)
|
|
582
579
|
// ensure we don't try to handle new requests while server is stopping
|
|
583
|
-
|
|
580
|
+
serverOperation.cleaner.addCallback(removeRequestListener)
|
|
584
581
|
|
|
585
582
|
logger.info(
|
|
586
583
|
`${serverName} started at ${serverOrigin} (${serverOrigins.external})`,
|
|
587
584
|
)
|
|
588
|
-
|
|
585
|
+
|
|
586
|
+
cancelAbortOnProcessTeardown() // it's now handled by stop()
|
|
587
|
+
const cancelProcessTeardownRace = raceProcessTeardownEvents(
|
|
588
|
+
processTeardownEvents,
|
|
589
|
+
(winner) => {
|
|
590
|
+
stop(PROCESS_TEARDOWN_EVENTS_MAP[winner.name])
|
|
591
|
+
},
|
|
592
|
+
)
|
|
593
|
+
serverOperation.cleaner.addCallback(() => {
|
|
594
|
+
cancelProcessTeardownRace()
|
|
595
|
+
})
|
|
596
|
+
nodeServer.once("close", () => {
|
|
597
|
+
cancelProcessTeardownRace()
|
|
598
|
+
})
|
|
589
599
|
|
|
590
600
|
return {
|
|
591
601
|
getStatus: () => status,
|
|
@@ -636,6 +646,11 @@ const generateResponseDescription = async ({
|
|
|
636
646
|
responseProperties: returnValue,
|
|
637
647
|
}
|
|
638
648
|
} catch (error) {
|
|
649
|
+
if (error.name === "AbortError" && request.abortSignal.aborted) {
|
|
650
|
+
return {
|
|
651
|
+
aborted: true,
|
|
652
|
+
}
|
|
653
|
+
}
|
|
639
654
|
return {
|
|
640
655
|
error,
|
|
641
656
|
}
|
|
@@ -695,38 +710,12 @@ const testCanPushStream = (http2Stream) => {
|
|
|
695
710
|
}
|
|
696
711
|
}
|
|
697
712
|
|
|
698
|
-
const
|
|
699
|
-
SIGHUP: (cb) => {
|
|
700
|
-
process.on("SIGHUP", cb)
|
|
701
|
-
return () => {
|
|
702
|
-
process.removeListener("SIGHUP", cb)
|
|
703
|
-
}
|
|
704
|
-
},
|
|
705
|
-
SIGTERM: (cb) => {
|
|
706
|
-
process.on("SIGTERM", cb)
|
|
707
|
-
return () => {
|
|
708
|
-
process.removeListener("SIGTERM", cb)
|
|
709
|
-
}
|
|
710
|
-
},
|
|
711
|
-
beforeExit: (cb) => {
|
|
712
|
-
process.on("beforeExit", cb)
|
|
713
|
-
return () => {
|
|
714
|
-
process.removeListener("beforeExit", cb)
|
|
715
|
-
}
|
|
716
|
-
},
|
|
717
|
-
exit: (cb) => {
|
|
718
|
-
process.on("exit", cb)
|
|
719
|
-
return () => {
|
|
720
|
-
process.removeListener("exit", cb)
|
|
721
|
-
}
|
|
722
|
-
},
|
|
723
|
-
}
|
|
713
|
+
const ABORTED_RESPONSE_PROPERTIES = {}
|
|
724
714
|
|
|
725
|
-
const
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
},
|
|
715
|
+
const PROCESS_TEARDOWN_EVENTS_MAP = {
|
|
716
|
+
SIGHUP: STOP_REASON_PROCESS_SIGHUP,
|
|
717
|
+
SIGTERM: STOP_REASON_PROCESS_SIGTERM,
|
|
718
|
+
SIGINT: STOP_REASON_PROCESS_SIGINT,
|
|
719
|
+
beforeExit: STOP_REASON_PROCESS_BEFORE_EXIT,
|
|
720
|
+
exit: STOP_REASON_PROCESS_EXIT,
|
|
732
721
|
}
|
package/src/abort/cleanup.js
DELETED
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
export const Cleanup = {
|
|
2
|
-
create: () => {
|
|
3
|
-
let callbacks = []
|
|
4
|
-
|
|
5
|
-
const addCallback = (callback) => {
|
|
6
|
-
if (typeof callback !== "function") {
|
|
7
|
-
throw new Error(`callback must be a function, got ${callback}`)
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
// don't register twice
|
|
11
|
-
const existingCallback = callbacks.find((callbackCandidate) => {
|
|
12
|
-
return callbackCandidate === callback
|
|
13
|
-
})
|
|
14
|
-
if (existingCallback) {
|
|
15
|
-
if (typeof process.emitWarning === "object") {
|
|
16
|
-
process.emitWarning(`Trying to register same callback twice`, {
|
|
17
|
-
CODE: "CALLBACK_DUPLICATION",
|
|
18
|
-
detail: `It's often the sign that code is executd more than once`,
|
|
19
|
-
})
|
|
20
|
-
} else {
|
|
21
|
-
console.warn(`Trying to add same callback twice`)
|
|
22
|
-
}
|
|
23
|
-
} else {
|
|
24
|
-
callbacks = [...callbacks, callback]
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
return () => {
|
|
28
|
-
callbacks = arrayWithout(callbacks, callback)
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const cleanup = async (reason) => {
|
|
33
|
-
const callbacksCopy = callbacks.slice()
|
|
34
|
-
callbacks.length = 0
|
|
35
|
-
|
|
36
|
-
await Promise.all(
|
|
37
|
-
callbacksCopy.map(async (callback) => {
|
|
38
|
-
await callback(reason)
|
|
39
|
-
}),
|
|
40
|
-
)
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
return { addCallback, cleanup }
|
|
44
|
-
},
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const arrayWithout = (array, item) => {
|
|
48
|
-
if (array.length === 0) return array
|
|
49
|
-
const arrayWithoutItem = []
|
|
50
|
-
let i = 0
|
|
51
|
-
while (i < array.length) {
|
|
52
|
-
const value = array[i]
|
|
53
|
-
i++
|
|
54
|
-
if (value === item) {
|
|
55
|
-
continue
|
|
56
|
-
}
|
|
57
|
-
arrayWithoutItem.push(value)
|
|
58
|
-
}
|
|
59
|
-
return arrayWithoutItem
|
|
60
|
-
}
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Taking different code paths based on different inputs is messy
|
|
3
|
-
* and code oftens forget to clean things behind itself
|
|
4
|
-
* "raceCallbacks" helps to do both properly with the following API:
|
|
5
|
-
*
|
|
6
|
-
* const winnerPromise = raceCallbacks({
|
|
7
|
-
* timeout: (cb) => {
|
|
8
|
-
* const timeout = setTimeout(cb, 1000)
|
|
9
|
-
* return () => {
|
|
10
|
-
* clearTimeout(timeout)
|
|
11
|
-
* }
|
|
12
|
-
* },
|
|
13
|
-
* error: () => {
|
|
14
|
-
* something.on('error', cb)
|
|
15
|
-
* return () => {
|
|
16
|
-
* return something.removeListener('error', cb)
|
|
17
|
-
* }
|
|
18
|
-
* },
|
|
19
|
-
* end: () => {
|
|
20
|
-
* something.on('end', cb)
|
|
21
|
-
* return () => {
|
|
22
|
-
* return something.removeListener('end', cb)
|
|
23
|
-
* }
|
|
24
|
-
* }
|
|
25
|
-
* })
|
|
26
|
-
*
|
|
27
|
-
* // do stuff
|
|
28
|
-
*
|
|
29
|
-
* const winner = await winnerPromise
|
|
30
|
-
* {
|
|
31
|
-
* timeout: () => console.log('timeout after 1000ms'),
|
|
32
|
-
* error: (e) => console.error('error', e),
|
|
33
|
-
* end: (value) => console.log('end', value)
|
|
34
|
-
* }[winner.name](winner.value)
|
|
35
|
-
*
|
|
36
|
-
*/
|
|
37
|
-
|
|
38
|
-
export const raceCallbacks = (raceDescription) => {
|
|
39
|
-
return new Promise((resolve) => {
|
|
40
|
-
const cleanupCallbacks = []
|
|
41
|
-
Object.keys(raceDescription).forEach((candidateName) => {
|
|
42
|
-
const register = raceDescription[candidateName]
|
|
43
|
-
const returnValue = register((data) => {
|
|
44
|
-
const cleanupCallbacksCopy = cleanupCallbacks.slice()
|
|
45
|
-
cleanupCallbacks.length = 0
|
|
46
|
-
cleanupCallbacksCopy.forEach((cleanup) => {
|
|
47
|
-
cleanup()
|
|
48
|
-
})
|
|
49
|
-
resolve({
|
|
50
|
-
name: candidateName,
|
|
51
|
-
data,
|
|
52
|
-
})
|
|
53
|
-
})
|
|
54
|
-
if (typeof returnValue === "function") {
|
|
55
|
-
cleanupCallbacks.push(returnValue)
|
|
56
|
-
}
|
|
57
|
-
})
|
|
58
|
-
})
|
|
59
|
-
}
|