@abtnode/util 1.17.12 → 1.17.13-beta-20260512-004004-69bacba8
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/lib/blocklet-cache.js +13 -13
- package/lib/deep-clone.js +1 -2
- package/lib/did-document.js +1 -1
- package/lib/download-file.js +1 -1
- package/lib/ensure-docker-endpoint-healthy.js +2 -2
- package/lib/ensure-endpoint-healthy.js +5 -5
- package/lib/format-context.js +1 -1
- package/lib/fs.js +1 -1
- package/lib/gcp.js +2 -2
- package/lib/get-pm2-process-info.js +1 -1
- package/lib/get-token-from-req.js +1 -2
- package/lib/logo-middleware.js +3 -3
- package/lib/middlewares/ensure-locale.js +1 -1
- package/lib/notification-preview/highlight.js +6 -6
- package/lib/notification-preview/util.js +8 -9
- package/lib/pm2/pm2-start-or-reload.js +4 -4
- package/lib/pm2/setup-graceful-shutdown.js +42 -42
- package/lib/port.js +1 -1
- package/lib/promise-spawn.js +5 -7
- package/lib/safe-tar.js +83 -0
- package/lib/sanitize.js +2 -2
- package/lib/security.js +23 -23
- package/lib/ssrf-protector.js +52 -36
- package/lib/upload-component.js +81 -82
- package/lib/url-evaluation/check-accessible-browser.js +3 -3
- package/lib/url-evaluation/check-accessible-node.js +1 -1
- package/lib/url-evaluation/index.js +15 -15
- package/lib/user.js +4 -4
- package/lib/zip.js +2 -2
- package/package.json +22 -9
package/lib/blocklet-cache.js
CHANGED
|
@@ -4,22 +4,22 @@ const { BLOCKLET_CACHE_TTL } = require('@abtnode/constant');
|
|
|
4
4
|
const BLOCKLET_INFO_SHORT_CACHE_TTL = 1000 * 60 * 30; // 30 minutes
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* Blocklet Info
|
|
7
|
+
* Blocklet Info cache for blocklet.js requests, allowing fast responses
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
* - Redis Adapter
|
|
11
|
-
* - SQLite Adapter
|
|
9
|
+
* Notes:
|
|
10
|
+
* - Redis Adapter does not use LRU cache (Redis itself is in-memory)
|
|
11
|
+
* - SQLite Adapter uses LRU cache as the L1 cache layer
|
|
12
12
|
*/
|
|
13
13
|
const blockletInfoShortCache = new DBCache(() => ({
|
|
14
14
|
prefix: 'blocklet-info-short-cache',
|
|
15
15
|
ttl: BLOCKLET_INFO_SHORT_CACHE_TTL, // 30 minutes
|
|
16
|
-
enableLruCache: true, //
|
|
16
|
+
enableLruCache: true, // only effective for SQLite
|
|
17
17
|
...getAbtNodeRedisAndSQLiteUrl(),
|
|
18
18
|
}));
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* @param {string} did: blockletDid
|
|
22
|
-
* @param {string} componentId: componentDid
|
|
22
|
+
* @param {string} componentId: componentDid format is did/children.did
|
|
23
23
|
* @param {string} type: json or js
|
|
24
24
|
* @returns {string}
|
|
25
25
|
*/
|
|
@@ -28,7 +28,7 @@ const getBlockletInfoCachePrefixKey = (did) => {
|
|
|
28
28
|
};
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
|
-
*
|
|
31
|
+
* Delete Blocklet Info cache (including memory cache and DBCache)
|
|
32
32
|
*
|
|
33
33
|
* CLUSTER MODE NOTE:
|
|
34
34
|
* - Memory cache: Cleared via delByPrefix with cluster sync.
|
|
@@ -38,10 +38,10 @@ const getBlockletInfoCachePrefixKey = (did) => {
|
|
|
38
38
|
*/
|
|
39
39
|
const clearBlockletInfoCache = async (did, logger = console) => {
|
|
40
40
|
try {
|
|
41
|
-
//
|
|
42
|
-
//
|
|
41
|
+
// cacheKey contains dynamic parameters (pathPrefix, etc.), so all possible keys cannot be enumerated
|
|
42
|
+
// use prefix matching to delete all cache entries related to this DID
|
|
43
43
|
const prefix = getBlockletInfoCachePrefixKey(did);
|
|
44
|
-
// DBCache
|
|
44
|
+
// DBCache also uses prefix deletion (del removes this key and all keys in its group)
|
|
45
45
|
await blockletInfoShortCache.del(prefix);
|
|
46
46
|
logger.info('Cleared blocklet info cache', { did, prefix });
|
|
47
47
|
} catch (error) {
|
|
@@ -50,17 +50,17 @@ const clearBlockletInfoCache = async (did, logger = console) => {
|
|
|
50
50
|
};
|
|
51
51
|
|
|
52
52
|
/**
|
|
53
|
-
* Blocklet state
|
|
53
|
+
* Blocklet state cache for internal data retrieval to avoid repeated database queries
|
|
54
54
|
*/
|
|
55
55
|
const blockletCache = new DBCache(() => ({
|
|
56
56
|
prefix: 'blocklet-state',
|
|
57
57
|
ttl: BLOCKLET_CACHE_TTL,
|
|
58
|
-
enableLruCache: false, //
|
|
58
|
+
enableLruCache: false, // explicitly disable LRU cache
|
|
59
59
|
...getAbtNodeRedisAndSQLiteUrl(),
|
|
60
60
|
}));
|
|
61
61
|
|
|
62
62
|
/**
|
|
63
|
-
*
|
|
63
|
+
* Delete Blocklet state cache
|
|
64
64
|
* @param {string} did: blockletDid
|
|
65
65
|
*/
|
|
66
66
|
const deleteBlockletCache = async (did, logger = console) => {
|
package/lib/deep-clone.js
CHANGED
|
@@ -3,8 +3,7 @@ function deepClone(o) {
|
|
|
3
3
|
return undefined;
|
|
4
4
|
}
|
|
5
5
|
|
|
6
|
-
//
|
|
7
|
-
// structuredClone(Node.js 17+ / 现代浏览器)
|
|
6
|
+
// prefer structuredClone (better perf, more type support; Node.js 17+ / modern browsers)
|
|
8
7
|
try {
|
|
9
8
|
return structuredClone(o);
|
|
10
9
|
} catch {
|
package/lib/did-document.js
CHANGED
|
@@ -216,7 +216,7 @@ const getBlockletServices = ({
|
|
|
216
216
|
rr: encodeBase32(slpDid),
|
|
217
217
|
value: daemonDidDomain,
|
|
218
218
|
domain: slpDomain,
|
|
219
|
-
derivedFrom: serverDid, //
|
|
219
|
+
derivedFrom: serverDid, // used to verify that the ownership of slpDid is legitimate
|
|
220
220
|
});
|
|
221
221
|
}
|
|
222
222
|
|
package/lib/download-file.js
CHANGED
|
@@ -119,7 +119,7 @@ const downloadFile = async (
|
|
|
119
119
|
let current = 0;
|
|
120
120
|
response.data.on('data', (chunk) => {
|
|
121
121
|
current += chunk.length;
|
|
122
|
-
//
|
|
122
|
+
// check every 2 seconds whether cancellation has been flagged in db-cache; if so, cancel the download
|
|
123
123
|
if (Date.now() - t > 2000) {
|
|
124
124
|
t = Date.now();
|
|
125
125
|
checkCanceled().then((cancelled) => {
|
|
@@ -4,14 +4,14 @@ async function ensureDockerEndpointHealthy({ host, port, timeout = 10 * 1000 })
|
|
|
4
4
|
const checkWget = await promiseSpawn(`docker exec ${host} sh -c "command -v wget || echo 'no-wget'"`, timeout);
|
|
5
5
|
|
|
6
6
|
if (checkWget.trim() === 'no-wget') {
|
|
7
|
-
//
|
|
7
|
+
// detect the container OS type and install wget
|
|
8
8
|
await promiseSpawn(
|
|
9
9
|
`docker exec ${host} sh -c "if [ -f /etc/os-release ]; then . /etc/os-release && (echo $ID | grep -q 'alpine' && apk add --no-cache wget || (echo $ID | grep -q 'debian' && apt-get update && apt-get install -y wget) || (echo $ID | grep -q 'centos' && yum install -y wget)); else echo 'Unsupported OS'; fi"`,
|
|
10
10
|
timeout
|
|
11
11
|
);
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
//
|
|
14
|
+
// use wget to check port health status
|
|
15
15
|
const res = await promiseSpawn(`docker exec ${host} wget --spider http://${host}:${port}`, timeout);
|
|
16
16
|
if (!res.includes('connected') && !res.includes('200 OK')) {
|
|
17
17
|
throw new Error(`Docker endpoint ${host}:${port} is not healthy`);
|
|
@@ -36,7 +36,7 @@ const dialHttp = (host, port, timeout = 3 * 1000) => {
|
|
|
36
36
|
const socket = net.connect({ host, port });
|
|
37
37
|
let dataBuffer = '';
|
|
38
38
|
|
|
39
|
-
//
|
|
39
|
+
// Set an overall timeout to keep the socket lifetime under 3 seconds
|
|
40
40
|
const overallTimeout = setTimeout(() => {
|
|
41
41
|
socket.destroy();
|
|
42
42
|
reject(new Error(`Socket existed for more than ${timeout}ms`));
|
|
@@ -61,17 +61,17 @@ const dialHttp = (host, port, timeout = 3 * 1000) => {
|
|
|
61
61
|
}
|
|
62
62
|
});
|
|
63
63
|
|
|
64
|
-
//
|
|
64
|
+
// Handle data on connection end: if the first line is incomplete, treat it as an invalid response
|
|
65
65
|
socket.on('end', () => {
|
|
66
66
|
clearTimeout(overallTimeout);
|
|
67
67
|
socket.destroy();
|
|
68
68
|
if (!dataBuffer) {
|
|
69
69
|
reject(new Error('No data received'));
|
|
70
70
|
} else if (dataBuffer.indexOf('\r\n') === -1) {
|
|
71
|
-
//
|
|
71
|
+
// No complete first line
|
|
72
72
|
reject(new Error(`Response did not complete the first line: "${dataBuffer}"`));
|
|
73
73
|
} else {
|
|
74
|
-
//
|
|
74
|
+
// If data has a first line but did not reach the data callback completion logic (for example interrupted data), do a final check
|
|
75
75
|
const firstLine = dataBuffer.substring(0, dataBuffer.indexOf('\r\n'));
|
|
76
76
|
if (firstLine.startsWith('HTTP')) {
|
|
77
77
|
resolve(firstLine);
|
|
@@ -104,7 +104,7 @@ const ensureStarted = async ({
|
|
|
104
104
|
await dialHttp(host, port, minConsecutiveTime);
|
|
105
105
|
} else {
|
|
106
106
|
await dial(host, port);
|
|
107
|
-
//
|
|
107
|
+
// For TCP protocols marked as requiring TCP wait, wait 3 seconds to ensure the service has started, such as MySQL
|
|
108
108
|
if (waitTCP) {
|
|
109
109
|
await sleep(WAIT_TCP_TIME);
|
|
110
110
|
}
|
package/lib/format-context.js
CHANGED
package/lib/fs.js
CHANGED
|
@@ -4,7 +4,7 @@ const path = require('path');
|
|
|
4
4
|
const canReadAndWriteDir = (dir) => {
|
|
5
5
|
const root = '/';
|
|
6
6
|
|
|
7
|
-
//
|
|
7
|
+
// walk up to the nearest ancestor directory that exists and has read/write permission; if none found, return false
|
|
8
8
|
const tmpArray = dir.split(path.sep).filter(Boolean);
|
|
9
9
|
do {
|
|
10
10
|
const tmpDir = path.join(root, tmpArray.join(path.sep));
|
package/lib/gcp.js
CHANGED
|
@@ -61,7 +61,7 @@ const getExternalIpv4 = async () => {
|
|
|
61
61
|
};
|
|
62
62
|
|
|
63
63
|
const getInternalIpv6 = async () => {
|
|
64
|
-
// TODO:
|
|
64
|
+
// TODO: no IPv6-capable machine is available; this interface has not been tested on a real machine
|
|
65
65
|
const internalIp = await getMeta('instance/network-interfaces/0/ipv6s');
|
|
66
66
|
|
|
67
67
|
debug('getLocalIpv6', { internalIp });
|
|
@@ -69,7 +69,7 @@ const getInternalIpv6 = async () => {
|
|
|
69
69
|
};
|
|
70
70
|
|
|
71
71
|
const getExternalIpv6 = async () => {
|
|
72
|
-
// TODO:
|
|
72
|
+
// TODO: no IPv6-capable machine is available; this interface has not been tested on a real machine
|
|
73
73
|
const externalIp = await getMeta('instance/network-interfaces/0/access-configs/0/external-ipv6');
|
|
74
74
|
debug('getPublicIpv6', { externalIp });
|
|
75
75
|
|
|
@@ -4,7 +4,7 @@ const pm2 = require('./pm2/async-pm2');
|
|
|
4
4
|
|
|
5
5
|
const noop = () => {};
|
|
6
6
|
|
|
7
|
-
//
|
|
7
|
+
// add 10,000 ms timeout to avoid pm2 hanging indefinitely on errors
|
|
8
8
|
const getPm2ProcessInfo = (processId, { printError = noop, throwOnNotExist = true, timeout = 10_000 } = {}) => {
|
|
9
9
|
return new Promise((resolve, reject) => {
|
|
10
10
|
let settled = false;
|
|
@@ -47,8 +47,7 @@ function getTokenFromReq(req, opts) {
|
|
|
47
47
|
if (tokenList.length > 1) {
|
|
48
48
|
_duplicate = true;
|
|
49
49
|
} else if (tokenList.length === 1) {
|
|
50
|
-
// HACK:
|
|
51
|
-
// 如果出现了这种情况,则应该判断为不重复,并最终以 headerToken 的值为准
|
|
50
|
+
// HACK: in federated-login account-switch flow, both cookie and header bearerToken may be present with different values; treat as non-duplicate and prefer headerToken
|
|
52
51
|
if (headerToken === (cookieToken || bodyToken || queryToken)) {
|
|
53
52
|
_duplicate = true;
|
|
54
53
|
}
|
package/lib/logo-middleware.js
CHANGED
|
@@ -157,7 +157,7 @@ const ensureCustomOgImage = (req, res, next) => {
|
|
|
157
157
|
const ensureBundleLogo = (req, res, next) => {
|
|
158
158
|
/**
|
|
159
159
|
* @type {{
|
|
160
|
-
* blocklet: import('@blocklet/server-js').BlockletState, //
|
|
160
|
+
* blocklet: import('@blocklet/server-js').BlockletState, // currently not fully accurate, but some properties are generic and this is acceptable
|
|
161
161
|
* sendOptions: any
|
|
162
162
|
* }}
|
|
163
163
|
* */
|
|
@@ -201,7 +201,7 @@ const cacheError = (err, req, res, next) => {
|
|
|
201
201
|
const ensureDefaultLogo = (req, res, next) => {
|
|
202
202
|
/**
|
|
203
203
|
* @type {{
|
|
204
|
-
* blocklet: import('@blocklet/server-js').BlockletState, //
|
|
204
|
+
* blocklet: import('@blocklet/server-js').BlockletState, // currently not fully accurate, but some properties are generic and this is acceptable
|
|
205
205
|
* sendOptions: any
|
|
206
206
|
* }}
|
|
207
207
|
* */
|
|
@@ -233,7 +233,7 @@ const ensureDefaultLogo = (req, res, next) => {
|
|
|
233
233
|
const ensureCustomFavicon = (req, res, next) => {
|
|
234
234
|
/**
|
|
235
235
|
* @type {{
|
|
236
|
-
* blocklet: import('@blocklet/server-js').BlockletState, //
|
|
236
|
+
* blocklet: import('@blocklet/server-js').BlockletState, // currently not fully accurate, but some properties are generic and this is acceptable
|
|
237
237
|
* sendOptions: any
|
|
238
238
|
* }}
|
|
239
239
|
* */
|
|
@@ -19,7 +19,7 @@ function ensureLocale({ methods = ['body', 'query', 'cookies'], force = false }
|
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
//
|
|
22
|
+
// normalize the language setting from the cookie into a standard locale code
|
|
23
23
|
const localeMap = {
|
|
24
24
|
'zh-CN': 'zh',
|
|
25
25
|
'en-US': 'en',
|
|
@@ -14,11 +14,11 @@ const toTextList = (str) => {
|
|
|
14
14
|
const arr = [];
|
|
15
15
|
try {
|
|
16
16
|
let startPoint = 0;
|
|
17
|
-
const pattern = /<(.+?)\(?[tx|nft|token|stake|did|dapp|link]+[:](.+?)\)?>/gi; //
|
|
17
|
+
const pattern = /<(.+?)\(?[tx|nft|token|stake|did|dapp|link]+[:](.+?)\)?>/gi; // matches <asdad(did:abt:xxx)>
|
|
18
18
|
const matches = str.matchAll(pattern);
|
|
19
19
|
|
|
20
20
|
for (const match of matches) {
|
|
21
|
-
const didPattern = /\([tx|nft|token|stake|did|dapp|link]+[:](.+?)\)/gi; //
|
|
21
|
+
const didPattern = /\([tx|nft|token|stake|did|dapp|link]+[:](.+?)\)/gi; // matches (did:abt:xxx)
|
|
22
22
|
|
|
23
23
|
const oriHit = match[0];
|
|
24
24
|
const matchIndex = match.index || startPoint;
|
|
@@ -98,7 +98,7 @@ const toClickableSpan = (str, isHighLight = true) => {
|
|
|
98
98
|
const url = getLink(item);
|
|
99
99
|
const { type, chainId, did } = item;
|
|
100
100
|
|
|
101
|
-
// HACK:
|
|
101
|
+
// HACK: dapp display is not supported in email (no dapp link available), so render as bold only
|
|
102
102
|
if (isSameAddr(type, 'dapp')) {
|
|
103
103
|
return `<em style="font-weight:bold;" data-type="${type}" data-chain-id="${chainId}" data-did="${did}">${item.text}</em>`;
|
|
104
104
|
}
|
|
@@ -106,7 +106,7 @@ const toClickableSpan = (str, isHighLight = true) => {
|
|
|
106
106
|
return `<a target="_blank" rel="noopener noreferrer" style="color:#4598fa;font-weight:bold;" href="${url}">${item.text}</a>`;
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
-
//
|
|
109
|
+
// default: render as bold
|
|
110
110
|
return `<em style="font-weight:bold;" data-type="${type}" data-chain-id="${chainId}" data-did="${did}">${item.text}</em>`;
|
|
111
111
|
}
|
|
112
112
|
return item.text;
|
|
@@ -118,7 +118,7 @@ const toClickableSpan = (str, isHighLight = true) => {
|
|
|
118
118
|
};
|
|
119
119
|
|
|
120
120
|
/**
|
|
121
|
-
*
|
|
121
|
+
* Render message content with links for Slack
|
|
122
122
|
* @param {*} str
|
|
123
123
|
* @returns
|
|
124
124
|
*/
|
|
@@ -129,7 +129,7 @@ const toSlackLink = (str) => {
|
|
|
129
129
|
if (item instanceof Link) {
|
|
130
130
|
return `<${getLink(item)}|${item.text}>`;
|
|
131
131
|
}
|
|
132
|
-
// HACK:
|
|
132
|
+
// HACK: remove :\n newlines from string content
|
|
133
133
|
return item.replace(/:\n/g, ': ');
|
|
134
134
|
})
|
|
135
135
|
.join('');
|
|
@@ -20,7 +20,7 @@ const getExplorerUrl = (chainId) => {
|
|
|
20
20
|
};
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
|
-
* notifications
|
|
23
|
+
* persistent notifications list key
|
|
24
24
|
*/
|
|
25
25
|
const generateKey = () => {
|
|
26
26
|
const did = window?.env?.appId;
|
|
@@ -63,23 +63,23 @@ const getImagePath = (url) => {
|
|
|
63
63
|
};
|
|
64
64
|
|
|
65
65
|
/**
|
|
66
|
-
*
|
|
67
|
-
* @param {Object} notification -
|
|
68
|
-
* @returns {boolean} -
|
|
66
|
+
* Check whether the notification includes an activity
|
|
67
|
+
* @param {Object} notification - the notification object
|
|
68
|
+
* @returns {boolean} - whether the notification includes an activity
|
|
69
69
|
*/
|
|
70
70
|
const isActivityIncluded = (notification) => {
|
|
71
71
|
return !isEmpty(notification.activity) && !!notification.activity.type && !!notification.activity.actor;
|
|
72
72
|
};
|
|
73
73
|
|
|
74
74
|
/**
|
|
75
|
-
*
|
|
76
|
-
*
|
|
75
|
+
* Check whether the actor is a user DID
|
|
76
|
+
* actor can be one of two types: 1) user 2) component
|
|
77
77
|
* @param {*} notification
|
|
78
78
|
* @returns
|
|
79
79
|
*/
|
|
80
80
|
const isUserActor = (notification) => {
|
|
81
|
-
// 1.
|
|
82
|
-
// 2.
|
|
81
|
+
// 1. if isActivityIncluded and the notification has actorInfo, treat as a user
|
|
82
|
+
// 2. if isActivityIncluded but actorInfo does not resolve to a user, treat as a component
|
|
83
83
|
return isActivityIncluded(notification) && !!notification.actorInfo && !!notification.actorInfo.did;
|
|
84
84
|
};
|
|
85
85
|
|
|
@@ -124,7 +124,6 @@ const ACTIVITY_DESCRIPTIONS = {
|
|
|
124
124
|
[ACTIVITY_TYPES.UN_ASSIGN]: () => 'has revoked your task assignment: ',
|
|
125
125
|
};
|
|
126
126
|
|
|
127
|
-
// 导出所有函数
|
|
128
127
|
module.exports = {
|
|
129
128
|
isEVMChain,
|
|
130
129
|
getExplorerUrl,
|
|
@@ -38,7 +38,7 @@ function loadReloadEnv() {
|
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
//
|
|
41
|
+
// wait for a single pm_id to come online
|
|
42
42
|
function waitProcOnlineById(id, timeout = 20_000) {
|
|
43
43
|
const start = Date.now();
|
|
44
44
|
return new Promise((resolve, reject) => {
|
|
@@ -61,16 +61,16 @@ async function rollingRestartWithEnv(config) {
|
|
|
61
61
|
pm2.list((err, list) => (err ? reject(err) : resolve(list)));
|
|
62
62
|
});
|
|
63
63
|
|
|
64
|
-
//
|
|
64
|
+
// ensure a fixed order
|
|
65
65
|
const targets = procs.filter((p) => p.name === name).sort((a, b) => a.pm_id - b.pm_id);
|
|
66
66
|
|
|
67
67
|
for (const p of targets) {
|
|
68
68
|
await new Promise((resolve, reject) => {
|
|
69
69
|
pm2.reload(p.pm_id, {}, (err) => (err ? reject(err) : resolve()));
|
|
70
70
|
});
|
|
71
|
-
//
|
|
71
|
+
// wait for this specific pm_id to come back online (not at the name level)
|
|
72
72
|
await waitProcOnlineById(p.pm_id, listen_timeout);
|
|
73
|
-
//
|
|
73
|
+
// add a small buffer after the switch to avoid transient disruption
|
|
74
74
|
await new Promise((r) => setTimeout(r, 500));
|
|
75
75
|
}
|
|
76
76
|
}
|
|
@@ -7,21 +7,21 @@ function setupGracefulShutdown(server, opts = {}) {
|
|
|
7
7
|
server[INSTALLED] = true;
|
|
8
8
|
|
|
9
9
|
const {
|
|
10
|
-
killTimeout = 10000, //
|
|
11
|
-
socketEndTimeout = 5000, //
|
|
12
|
-
wsServers = [], //
|
|
13
|
-
logger = console, //
|
|
14
|
-
setConnectionCloseOnShutdown = true, //
|
|
15
|
-
tuneKeepAlive = true, //
|
|
16
|
-
http503OnShutdown = false, //
|
|
17
|
-
http503DelayMs = 2000, // 503
|
|
18
|
-
quietEndDelay = 250, //
|
|
19
|
-
// ---- HTTP/2
|
|
20
|
-
enableHttp2Support = false, //
|
|
21
|
-
http2RefuseNewStreamsOnShutdown = true, //
|
|
22
|
-
// ----
|
|
23
|
-
kaGentleMs = 600, //
|
|
24
|
-
kaPhaseDelayMs = 500, //
|
|
10
|
+
killTimeout = 10000, // overall fallback exit timeout (ms)
|
|
11
|
+
socketEndTimeout = 5000, // max time to wait for all TCP connections to close gracefully (ms)
|
|
12
|
+
wsServers = [], // ws or socket.io instances to close gracefully
|
|
13
|
+
logger = console, // logger instance
|
|
14
|
+
setConnectionCloseOnShutdown = true, // set Connection: close / shouldKeepAlive=false during h1 shutdown
|
|
15
|
+
tuneKeepAlive = true, // gently shorten keepAliveTimeout during shutdown
|
|
16
|
+
http503OnShutdown = false, // whether to immediately 503 new requests/streams during shutdown window (default false: keep 200)
|
|
17
|
+
http503DelayMs = 2000, // delay (ms) before 503 takes effect, giving the new process time to take over
|
|
18
|
+
quietEndDelay = 250, // delay slightly after shutdown before ending idle sockets (reduces race conditions)
|
|
19
|
+
// ---- HTTP/2 optional settings ----
|
|
20
|
+
enableHttp2Support = false, // set to true if the server uses HTTP/2
|
|
21
|
+
http2RefuseNewStreamsOnShutdown = true, // refuse new streams during the shutdown window (respond with 503)
|
|
22
|
+
// ---- Keep-Alive tuning parameters (gentle reduction) ----
|
|
23
|
+
kaGentleMs = 600, // reduce keepAliveTimeout to this value during shutdown (recommended 300–800ms)
|
|
24
|
+
kaPhaseDelayMs = 500, // delay slightly after entering shutdown before shortening KA
|
|
25
25
|
} = opts;
|
|
26
26
|
|
|
27
27
|
let isShuttingDown = false;
|
|
@@ -42,7 +42,7 @@ function setupGracefulShutdown(server, opts = {}) {
|
|
|
42
42
|
server.on('listening', sendReadyOnce);
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
// --------- HTTP/1.1
|
|
45
|
+
// --------- HTTP/1.1 connection and request tracking ----------
|
|
46
46
|
const sockets = new Set();
|
|
47
47
|
const metaMap = new WeakMap(); // socket -> { active: number, _ended?: boolean }
|
|
48
48
|
|
|
@@ -60,11 +60,11 @@ function setupGracefulShutdown(server, opts = {}) {
|
|
|
60
60
|
const isH2 = req.httpVersionMajor >= 2;
|
|
61
61
|
|
|
62
62
|
if (isShuttingDown) {
|
|
63
|
-
// HTTP/1.x
|
|
63
|
+
// HTTP/1.x: only set shouldKeepAlive=false (Node will automatically emit Connection: close)
|
|
64
64
|
if (!isH2 && setConnectionCloseOnShutdown && !res.headersSent) {
|
|
65
65
|
try {
|
|
66
66
|
res.shouldKeepAlive = false;
|
|
67
|
-
//
|
|
67
|
+
// the line below is optional; shouldKeepAlive=false is sufficient, kept for compatibility with older stacks
|
|
68
68
|
res.setHeader?.('Connection', 'close');
|
|
69
69
|
} catch {
|
|
70
70
|
//
|
|
@@ -88,7 +88,7 @@ function setupGracefulShutdown(server, opts = {}) {
|
|
|
88
88
|
if (!m) return;
|
|
89
89
|
m.active = Math.max(0, m.active - 1);
|
|
90
90
|
|
|
91
|
-
//
|
|
91
|
+
// during shutdown, if this socket has no in-flight requests -> graceful teardown
|
|
92
92
|
if (isShuttingDown && m.active === 0 && !m._ended) {
|
|
93
93
|
m._ended = true;
|
|
94
94
|
try {
|
|
@@ -99,11 +99,11 @@ function setupGracefulShutdown(server, opts = {}) {
|
|
|
99
99
|
}
|
|
100
100
|
};
|
|
101
101
|
|
|
102
|
-
res.on('finish', done); //
|
|
103
|
-
res.on('close', done); //
|
|
102
|
+
res.on('finish', done); // normal completion
|
|
103
|
+
res.on('close', done); // client disconnected early
|
|
104
104
|
});
|
|
105
105
|
|
|
106
|
-
// --------- WebSocket / Socket.IO
|
|
106
|
+
// --------- WebSocket / Socket.IO shutdown ----------
|
|
107
107
|
function closeWebSockets() {
|
|
108
108
|
for (const s of wsServers) {
|
|
109
109
|
if (s && s.clients && typeof s.clients.forEach === 'function') {
|
|
@@ -146,7 +146,7 @@ function setupGracefulShutdown(server, opts = {}) {
|
|
|
146
146
|
}
|
|
147
147
|
}
|
|
148
148
|
}
|
|
149
|
-
s.close(); //
|
|
149
|
+
s.close(); // stop accepting new connections and close existing ones
|
|
150
150
|
} catch (e) {
|
|
151
151
|
logger.error('[shutdown] close socket.io failed:', e);
|
|
152
152
|
}
|
|
@@ -162,7 +162,7 @@ function setupGracefulShutdown(server, opts = {}) {
|
|
|
162
162
|
}
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
-
// --------- HTTP/2
|
|
165
|
+
// --------- HTTP/2 session and stream management ----------
|
|
166
166
|
const h2Sessions = new Set();
|
|
167
167
|
const h2Meta = new WeakMap(); // session -> { active: number, closed?: boolean }
|
|
168
168
|
|
|
@@ -173,27 +173,27 @@ function setupGracefulShutdown(server, opts = {}) {
|
|
|
173
173
|
const http2 = require('http2');
|
|
174
174
|
NGHTTP2_NO_ERROR = http2.constants.NGHTTP2_NO_ERROR;
|
|
175
175
|
|
|
176
|
-
//
|
|
176
|
+
// only triggered on http2 servers
|
|
177
177
|
server.on?.('session', (session) => {
|
|
178
178
|
h2Sessions.add(session);
|
|
179
179
|
h2Meta.set(session, { active: 0 });
|
|
180
180
|
|
|
181
181
|
session.on('close', () => h2Sessions.delete(session));
|
|
182
182
|
|
|
183
|
-
//
|
|
183
|
+
// count active streams (Http2Stream)
|
|
184
184
|
session.on('stream', (stream) => {
|
|
185
185
|
const meta = h2Meta.get(session);
|
|
186
186
|
if (meta) meta.active++;
|
|
187
187
|
|
|
188
|
-
//
|
|
188
|
+
// within the shutdown window: optionally refuse new stream creation
|
|
189
189
|
if (isShuttingDown && http2RefuseNewStreamsOnShutdown) {
|
|
190
190
|
const should503 =
|
|
191
191
|
http503OnShutdown && (http503DelayMs <= 0 || Date.now() - shutdownStartedAt >= http503DelayMs);
|
|
192
192
|
try {
|
|
193
|
-
//
|
|
193
|
+
// 503 is semantically clear; Node does not directly expose the REFUSED_STREAM constant
|
|
194
194
|
stream.respond({ ':status': should503 ? 503 : 200 });
|
|
195
195
|
if (should503) stream.end('server reloading');
|
|
196
|
-
else stream.end(); //
|
|
196
|
+
else stream.end(); // if not sending 503, end immediately to prompt the client to reconnect to the new instance
|
|
197
197
|
return;
|
|
198
198
|
} catch {
|
|
199
199
|
//
|
|
@@ -221,11 +221,11 @@ function setupGracefulShutdown(server, opts = {}) {
|
|
|
221
221
|
});
|
|
222
222
|
}
|
|
223
223
|
} catch (e) {
|
|
224
|
-
//
|
|
224
|
+
// environment does not support http2, ignore
|
|
225
225
|
NGHTTP2_NO_ERROR = undefined;
|
|
226
226
|
}
|
|
227
227
|
|
|
228
|
-
// --------- h1 TCP socket
|
|
228
|
+
// --------- h1 TCP socket teardown ----------
|
|
229
229
|
function drainTcpSockets() {
|
|
230
230
|
sockets.forEach((socket) => {
|
|
231
231
|
const meta = metaMap.get(socket) || { active: 0 };
|
|
@@ -239,7 +239,7 @@ function setupGracefulShutdown(server, opts = {}) {
|
|
|
239
239
|
}
|
|
240
240
|
});
|
|
241
241
|
|
|
242
|
-
//
|
|
242
|
+
// fallback: force-destroy any sockets still open at deadline (prevents leaks)
|
|
243
243
|
const hardKillAfter = Math.min(socketEndTimeout, Math.max(0, killTimeout - 200));
|
|
244
244
|
setTimeout(() => {
|
|
245
245
|
sockets.forEach((socket) => {
|
|
@@ -258,15 +258,15 @@ function setupGracefulShutdown(server, opts = {}) {
|
|
|
258
258
|
shutdownStartedAt = Date.now();
|
|
259
259
|
logger.info('[shutdown] starting graceful shutdown');
|
|
260
260
|
|
|
261
|
-
// (A) HTTP/1.x
|
|
261
|
+
// (A) HTTP/1.x: gently reduce keep-alive timeout (optional)
|
|
262
262
|
if (tuneKeepAlive) {
|
|
263
263
|
setTimeout(
|
|
264
264
|
() => {
|
|
265
265
|
try {
|
|
266
|
-
const ka = Math.max(50, Number(kaGentleMs) || 600); //
|
|
266
|
+
const ka = Math.max(50, Number(kaGentleMs) || 600); // safety lower bound: 50ms
|
|
267
267
|
if ('keepAliveTimeout' in server) server.keepAliveTimeout = ka;
|
|
268
268
|
if ('headersTimeout' in server) server.headersTimeout = Math.max(server.headersTimeout || 0, ka + 1000);
|
|
269
|
-
//
|
|
269
|
+
// do not set requestTimeout (keep 0 / unlimited) to avoid cutting off in-flight requests
|
|
270
270
|
} catch {
|
|
271
271
|
//
|
|
272
272
|
}
|
|
@@ -275,7 +275,7 @@ function setupGracefulShutdown(server, opts = {}) {
|
|
|
275
275
|
).unref?.();
|
|
276
276
|
}
|
|
277
277
|
|
|
278
|
-
// (B) HTTP/2
|
|
278
|
+
// (B) HTTP/2: send GOAWAY to existing sessions to block new streams (let existing streams finish)
|
|
279
279
|
if (enableHttp2Support && NGHTTP2_NO_ERROR != null) {
|
|
280
280
|
for (const session of h2Sessions) {
|
|
281
281
|
try {
|
|
@@ -286,10 +286,10 @@ function setupGracefulShutdown(server, opts = {}) {
|
|
|
286
286
|
}
|
|
287
287
|
}
|
|
288
288
|
|
|
289
|
-
// 1)
|
|
289
|
+
// 1) gracefully close WS/Socket.IO first
|
|
290
290
|
closeWebSockets();
|
|
291
291
|
|
|
292
|
-
// 2)
|
|
292
|
+
// 2) stop accepting new TCP connections / HTTP requests / HTTP2 sessions
|
|
293
293
|
if (typeof server.close === 'function') {
|
|
294
294
|
server.close(() => {
|
|
295
295
|
logger.log('[shutdown] http server closed, exiting 0');
|
|
@@ -297,11 +297,11 @@ function setupGracefulShutdown(server, opts = {}) {
|
|
|
297
297
|
});
|
|
298
298
|
}
|
|
299
299
|
|
|
300
|
-
// 3)
|
|
300
|
+
// 3) wait briefly before teardown (reduces race with in-flight requests)
|
|
301
301
|
setTimeout(() => {
|
|
302
302
|
drainTcpSockets(); // h1
|
|
303
303
|
|
|
304
|
-
// h2
|
|
304
|
+
// h2: close sessions with no active streams
|
|
305
305
|
if (enableHttp2Support) {
|
|
306
306
|
for (const session of h2Sessions) {
|
|
307
307
|
const meta = h2Meta.get(session) || { active: 0 };
|
|
@@ -317,7 +317,7 @@ function setupGracefulShutdown(server, opts = {}) {
|
|
|
317
317
|
}
|
|
318
318
|
}, quietEndDelay).unref?.();
|
|
319
319
|
|
|
320
|
-
// 4) h2
|
|
320
|
+
// 4) h2 fallback: destroy any sessions still open at deadline to prevent leaks
|
|
321
321
|
const hardKillAfter = Math.min(socketEndTimeout, Math.max(0, killTimeout - 200));
|
|
322
322
|
setTimeout(() => {
|
|
323
323
|
if (enableHttp2Support) {
|
|
@@ -331,7 +331,7 @@ function setupGracefulShutdown(server, opts = {}) {
|
|
|
331
331
|
}
|
|
332
332
|
}, hardKillAfter).unref?.();
|
|
333
333
|
|
|
334
|
-
// 5)
|
|
334
|
+
// 5) final fallback: force-exit if the process has not exited yet
|
|
335
335
|
setTimeout(() => {
|
|
336
336
|
logger.error('[shutdown] timeout reached, forcing exit 1');
|
|
337
337
|
process.exit(1);
|
package/lib/port.js
CHANGED
|
@@ -51,7 +51,7 @@ const isPortTaken = async (port) => {
|
|
|
51
51
|
.once('listening', () => {
|
|
52
52
|
tester.once('close', () => resolve(false)).close();
|
|
53
53
|
})
|
|
54
|
-
.listen(port, '::'); //
|
|
54
|
+
.listen(port, '::'); // Note: without host, this may still return false even when the port is occupied
|
|
55
55
|
});
|
|
56
56
|
};
|
|
57
57
|
|