claude-code-hub 1.1.0 → 1.2.1
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/ipc.js +34 -0
- package/lib/net-guard.js +41 -14
- package/package.json +5 -5
- package/packages/claude-hub-sdk/src/client.js +3 -1
- package/packages/claude-hub-sdk/src/server.js +22 -0
- package/packages/claude-hub-sdk/src/stub.js +12 -2
- package/public/app.js +23 -5
- package/public/index.html +8 -0
- package/server.js +11 -1
- package/lib/ping.js +0 -23
package/lib/ipc.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
let seq = 0;
|
|
4
|
+
|
|
5
|
+
// Resolves with the child's answer to a `type` message over its IPC channel, or null after timeoutMs.
|
|
6
|
+
// The channel does not use TCP, so a loopback connect failure does not stop the answer.
|
|
7
|
+
function ask(child, type, timeoutMs, answerType = type) {
|
|
8
|
+
if (!child?.connected) return Promise.resolve(null);
|
|
9
|
+
const id = ++seq;
|
|
10
|
+
return new Promise((resolve) => {
|
|
11
|
+
const done = (m) => {
|
|
12
|
+
clearTimeout(timer);
|
|
13
|
+
child.off('message', onMessage);
|
|
14
|
+
resolve(m);
|
|
15
|
+
};
|
|
16
|
+
const onMessage = (m) => m?.type === answerType && m.id === id && done(m);
|
|
17
|
+
const timer = setTimeout(() => done(null), timeoutMs);
|
|
18
|
+
child.on('message', onMessage);
|
|
19
|
+
child.send({ type, id }, (err) => err && done(null));
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Resolves true when the child answers hub:ping within timeoutMs.
|
|
24
|
+
async function ping(child, timeoutMs) {
|
|
25
|
+
return !!(await ask(child, 'hub:ping', timeoutMs, 'hub:pong'));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// An app that does not mount the SDK server does not answer, so it resolves null.
|
|
29
|
+
async function stats(child, timeoutMs) {
|
|
30
|
+
const m = await ask(child, 'hub:stats', timeoutMs);
|
|
31
|
+
return m && { rss: m.rss, cpu: m.cpu };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
module.exports = { ping, stats };
|
package/lib/net-guard.js
CHANGED
|
@@ -37,11 +37,14 @@ const PORT_RE = /^[0-9]{1,5}$/;
|
|
|
37
37
|
// port dynamic, so only the host is allowlisted.
|
|
38
38
|
const validPort = (p) => PORT_RE.test(p) && Number(p) >= 1 && Number(p) <= 65535;
|
|
39
39
|
const KEEP_ALIVE_MS = 65000;
|
|
40
|
+
// A whole IPv4 literal: a prefix test would let a rebinding name like 127.evil.com through.
|
|
41
|
+
const LOOPBACK_V4_RE = /^127(?:\.(?:25[0-5]|2[0-4]\d|1?\d?\d)){3}$/;
|
|
40
42
|
|
|
41
43
|
function isLoopbackAddress(host) {
|
|
42
44
|
if (!host) return false;
|
|
43
|
-
|
|
44
|
-
|
|
45
|
+
// An Origin or URL host carries an IPv6 literal in brackets.
|
|
46
|
+
const h = String(host).toLowerCase().replace(/^\[(.*)\]$/, '$1');
|
|
47
|
+
return LOOPBACK_HOSTS.has(h) || LOOPBACK_V4_RE.test(h);
|
|
45
48
|
}
|
|
46
49
|
|
|
47
50
|
// Hand-parsed rather than via new URL(), which is far more permissive than we
|
|
@@ -102,7 +105,7 @@ function getFlag(name) {
|
|
|
102
105
|
}
|
|
103
106
|
|
|
104
107
|
/**
|
|
105
|
-
* @param {{appName?: string}} [config]
|
|
108
|
+
* @param {{appName?: string, selfFramedPaths?: string[]}} [config]
|
|
106
109
|
*/
|
|
107
110
|
function createNetGuard(config = {}) {
|
|
108
111
|
const appName = config.appName || 'This app';
|
|
@@ -216,9 +219,18 @@ function createNetGuard(config = {}) {
|
|
|
216
219
|
CSP = "frame-ancestors 'none'";
|
|
217
220
|
}
|
|
218
221
|
|
|
219
|
-
|
|
222
|
+
// Alone, these pages may be framed by this app's own port under its loopback names, for a page
|
|
223
|
+
// that frames itself from its other name to get its own renderer process.
|
|
224
|
+
const selfFramed = new Set(config.selfFramedPaths || []);
|
|
225
|
+
|
|
226
|
+
function frameGuard(req, res, next) {
|
|
220
227
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
221
228
|
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
229
|
+
if (!hubOrigin && selfFramed.has(req.path)) {
|
|
230
|
+
const port = req.socket.localPort;
|
|
231
|
+
res.setHeader('Content-Security-Policy', `frame-ancestors 'self' http://localhost:${port} http://127.0.0.1:${port}`);
|
|
232
|
+
return next();
|
|
233
|
+
}
|
|
222
234
|
res.setHeader('Content-Security-Policy', CSP);
|
|
223
235
|
if (!hubOrigin) res.setHeader('X-Frame-Options', 'DENY');
|
|
224
236
|
next();
|
|
@@ -232,7 +244,10 @@ function createNetGuard(config = {}) {
|
|
|
232
244
|
* the OS resolver orders A/AAAA records - binding only 127.0.0.1 breaks hosts
|
|
233
245
|
* where `localhost` resolves to ::1, and vice versa. Doing it on the *resolved*
|
|
234
246
|
* port means every existing URL, startup banner and postMessage origin is
|
|
235
|
-
* unchanged.
|
|
247
|
+
* unchanged. A family the host does not have is skipped. A port that another
|
|
248
|
+
* process holds on the other family is treated as busy: the primary closes and
|
|
249
|
+
* emits EADDRINUSE (port 0 picks another random port instead), because that process would answer `localhost:<port>` (and a
|
|
250
|
+
* cross-site frame on it) in our name. onReady waits for the second bind.
|
|
236
251
|
*
|
|
237
252
|
* Returns the primary server, so callers keep attaching their own 'error'
|
|
238
253
|
* handler for the EADDRINUSE fallback.
|
|
@@ -259,18 +274,30 @@ function createNetGuard(config = {}) {
|
|
|
259
274
|
};
|
|
260
275
|
const server = makeServer(app);
|
|
261
276
|
let secondary = null;
|
|
277
|
+
let randomTries = 0;
|
|
262
278
|
|
|
263
279
|
server.on('listening', () => {
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
280
|
+
const boundPort = server.address().port;
|
|
281
|
+
actualPort = boundPort;
|
|
282
|
+
// Not actualPort: the hub listens several servers through one guard, and another one can bind
|
|
283
|
+
// while this one waits for its second family.
|
|
284
|
+
const ready = () => { if (onReady) onReady(boundPort); };
|
|
285
|
+
if (EXPOSED) return ready();
|
|
286
|
+
const otherFamily = BIND_HOST.includes(':') ? '127.0.0.1' : '::1';
|
|
287
|
+
try {
|
|
288
|
+
secondary = makeServer(app);
|
|
289
|
+
secondary.once('listening', ready);
|
|
290
|
+
secondary.on('error', (err) => {
|
|
291
|
+
if (err.code !== 'EADDRINUSE') return ready();
|
|
292
|
+
// A caller asking for port 0 has no fallback of its own, so it gets another random port here.
|
|
293
|
+
if (port === 0 && ++randomTries < 5) return server.close(() => server.listen(0, BIND_HOST));
|
|
294
|
+
server.close();
|
|
295
|
+
server.emit('error', err);
|
|
296
|
+
});
|
|
297
|
+
secondary.listen({ host: otherFamily, port: boundPort, ipv6Only: otherFamily === '::1' });
|
|
298
|
+
} catch {
|
|
299
|
+
ready();
|
|
272
300
|
}
|
|
273
|
-
if (onReady) onReady(actualPort);
|
|
274
301
|
});
|
|
275
302
|
server.on('close', () => { try { if (secondary) secondary.close(); } catch { /* already gone */ } });
|
|
276
303
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-hub",
|
|
3
|
-
"version": "1.1
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"description": "Unified hub for Claude Code tools — Marketplace + Kanban in one PWA",
|
|
5
5
|
"main": "server.js",
|
|
6
6
|
"bin": {
|
|
@@ -45,10 +45,10 @@
|
|
|
45
45
|
},
|
|
46
46
|
"homepage": "https://github.com/NikiforovAll/claude-code-hub#readme",
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"claude-code-cost": "1.
|
|
49
|
-
"claude-code-kanban": "5.
|
|
50
|
-
"claude-code-marketplace": "1.
|
|
51
|
-
"claude-code-memory-explorer": "1.1.
|
|
48
|
+
"claude-code-cost": "1.2.0",
|
|
49
|
+
"claude-code-kanban": "5.4.0",
|
|
50
|
+
"claude-code-marketplace": "1.3.0",
|
|
51
|
+
"claude-code-memory-explorer": "1.1.1",
|
|
52
52
|
"express": "^4.21.0",
|
|
53
53
|
"open": "^10.1.0"
|
|
54
54
|
},
|
|
@@ -334,6 +334,8 @@
|
|
|
334
334
|
},
|
|
335
335
|
// For an element that eats keys before the document sees them, like a terminal.
|
|
336
336
|
forwards,
|
|
337
|
+
// The combos forwards() matches, for a frame that tests keys with ClaudeHub.comboOf on its own.
|
|
338
|
+
forwardCombos: () => (forward ? [...forward] : []),
|
|
337
339
|
closeGuard(on) {
|
|
338
340
|
post({ type: 'hub:closeGuard', on: !!on });
|
|
339
341
|
},
|
|
@@ -363,7 +365,7 @@
|
|
|
363
365
|
return hub;
|
|
364
366
|
}
|
|
365
367
|
|
|
366
|
-
return { connect };
|
|
368
|
+
return { connect, comboOf };
|
|
367
369
|
}
|
|
368
370
|
|
|
369
371
|
if (typeof module === 'object' && module.exports) module.exports = { createClaudeHub, comboOf };
|
|
@@ -5,12 +5,34 @@ const path = require('node:path');
|
|
|
5
5
|
// The hub hands this file to each app it spawns (HUB_SDK_SERVER), so it runs the hub's version, not
|
|
6
6
|
// one the app shipped. An app run alone never loads it.
|
|
7
7
|
|
|
8
|
+
// The app's own process only: a process-tree walk needs an OS call that costs more than the number
|
|
9
|
+
// is worth. CPU is sampled over a short window when asked, so nothing runs between asks, and asks
|
|
10
|
+
// that overlap share one sample.
|
|
11
|
+
const STATS_SAMPLE_MS = 250;
|
|
12
|
+
let statsSample = null;
|
|
13
|
+
|
|
14
|
+
function sampleStats() {
|
|
15
|
+
statsSample ??= new Promise((resolve) => {
|
|
16
|
+
const cpu0 = process.cpuUsage();
|
|
17
|
+
const t0 = process.hrtime.bigint();
|
|
18
|
+
setTimeout(() => {
|
|
19
|
+
const cpu = process.cpuUsage(cpu0);
|
|
20
|
+
const elapsedUs = Number(process.hrtime.bigint() - t0) / 1000;
|
|
21
|
+
statsSample = null;
|
|
22
|
+
resolve({ rss: process.memoryUsage.rss(), cpu: Math.round(((cpu.user + cpu.system) / elapsedUs) * 100) });
|
|
23
|
+
}, STATS_SAMPLE_MS);
|
|
24
|
+
});
|
|
25
|
+
return statsSample;
|
|
26
|
+
}
|
|
27
|
+
|
|
8
28
|
// The hub spawns each app with an IPC channel. A loopback connect can fail while the app is fine,
|
|
9
29
|
// so before the hub replaces an app it asks over the channel, which does not go through TCP.
|
|
10
30
|
function answerHub() {
|
|
11
31
|
if (!process.channel) return;
|
|
12
32
|
process.on('message', (m) => {
|
|
13
33
|
if (m?.type === 'hub:ping') process.send({ type: 'hub:pong', id: m.id });
|
|
34
|
+
if (m?.type === 'hub:stats')
|
|
35
|
+
sampleStats().then((s) => process.connected && process.send({ type: 'hub:stats', id: m.id, ...s }));
|
|
14
36
|
});
|
|
15
37
|
process.on('disconnect', () => process.exit(0));
|
|
16
38
|
// Listeners ref the channel. The app's own server keeps it alive, not the hub.
|
|
@@ -3,6 +3,15 @@
|
|
|
3
3
|
((root) => {
|
|
4
4
|
'use strict';
|
|
5
5
|
|
|
6
|
+
// A copy of client.js comboOf(); a test checks they match.
|
|
7
|
+
function comboOf(e) {
|
|
8
|
+
const lower = typeof e.key === 'string' ? e.key.toLowerCase() : '';
|
|
9
|
+
const m = /^(?:Key|Digit)([A-Z1-9])$/.exec(e.code || '');
|
|
10
|
+
const key = /^[a-z1-9]$/.test(lower) ? lower : m ? m[1].toLowerCase() : e.key;
|
|
11
|
+
const mods = [e.ctrlKey && 'ctrl', e.altKey && 'alt', e.shiftKey && 'shift', e.metaKey && 'meta'];
|
|
12
|
+
return [...mods, key].filter(Boolean).join('+');
|
|
13
|
+
}
|
|
14
|
+
|
|
6
15
|
function createClaudeHub(win) {
|
|
7
16
|
let hub = null;
|
|
8
17
|
const off = () => {};
|
|
@@ -32,6 +41,7 @@
|
|
|
32
41
|
},
|
|
33
42
|
can: (action) => !!standaloneFn(action),
|
|
34
43
|
forwards: () => false,
|
|
44
|
+
forwardCombos: () => [],
|
|
35
45
|
closeGuard() {},
|
|
36
46
|
openExternal(url) {
|
|
37
47
|
win.open(url, '_blank', 'noopener');
|
|
@@ -41,9 +51,9 @@
|
|
|
41
51
|
return hub;
|
|
42
52
|
}
|
|
43
53
|
|
|
44
|
-
return { connect };
|
|
54
|
+
return { connect, comboOf };
|
|
45
55
|
}
|
|
46
56
|
|
|
47
|
-
if (typeof module === 'object' && module.exports) module.exports = { createClaudeHub };
|
|
57
|
+
if (typeof module === 'object' && module.exports) module.exports = { createClaudeHub, comboOf };
|
|
48
58
|
else root.ClaudeHub = createClaudeHub(root);
|
|
49
59
|
})(typeof window !== 'undefined' ? window : globalThis);
|
package/public/app.js
CHANGED
|
@@ -118,10 +118,10 @@ function applyHubTheme() {
|
|
|
118
118
|
for (const [name, value] of Object.entries(vars ?? {})) root.style.setProperty(name, value);
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
// Claude's on-disk project-directory name
|
|
121
|
+
// Claude's on-disk project-directory name: every non-alphanumeric character becomes a dash. The hub
|
|
122
122
|
// owns this transform so cost never has to convert anything; the inverse is lossy and not needed.
|
|
123
123
|
function encodeProjectPath(p) {
|
|
124
|
-
return p.replace(
|
|
124
|
+
return p.replace(/[^a-zA-Z0-9]/g, '-');
|
|
125
125
|
}
|
|
126
126
|
|
|
127
127
|
function basename(p) {
|
|
@@ -154,8 +154,14 @@ function sendTopic(appId, topic, payload) {
|
|
|
154
154
|
if (liveApps.get(appId)?.has(topic)) postTo(appId, { type: 'hub:event', topic, payload });
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
-
|
|
158
|
-
|
|
157
|
+
// `worktrees` are the linked worktrees the projects provider folded into this repo.
|
|
158
|
+
function setProject(absPath, worktrees = []) {
|
|
159
|
+
projectState = {
|
|
160
|
+
project: absPath,
|
|
161
|
+
encoded: encodeProjectPath(absPath),
|
|
162
|
+
name: basename(absPath),
|
|
163
|
+
worktrees: worktrees.map((p) => ({ path: p, encoded: encodeProjectPath(p) })),
|
|
164
|
+
};
|
|
159
165
|
for (const id of Object.keys(iframes)) sendProject(id);
|
|
160
166
|
}
|
|
161
167
|
|
|
@@ -644,6 +650,8 @@ async function loadProjects() {
|
|
|
644
650
|
path: p.path,
|
|
645
651
|
name: basename(p.path),
|
|
646
652
|
parent: parentOf(p.path),
|
|
653
|
+
worktrees: p.worktrees ?? [],
|
|
654
|
+
worktreeNames: (p.worktrees ?? []).map((w) => basename(w).toLowerCase()),
|
|
647
655
|
ts: p.modifiedAt ? Date.parse(p.modifiedAt) : 0,
|
|
648
656
|
}))
|
|
649
657
|
.sort((a, b) => b.ts - a.ts);
|
|
@@ -676,6 +684,7 @@ function projectRows(projects, q) {
|
|
|
676
684
|
const path = p.path.toLowerCase();
|
|
677
685
|
if (path.split(/[/\\]/).includes(q)) return PATH_RANK;
|
|
678
686
|
if (path.includes(q)) return PATH_RANK + 1;
|
|
687
|
+
if (p.worktreeNames.some((w) => w.includes(q))) return PATH_RANK + 2;
|
|
679
688
|
return -1;
|
|
680
689
|
};
|
|
681
690
|
let out = projects.map((p) => ({ p, rank: rankOf(p) })).filter((x) => x.rank >= 0);
|
|
@@ -744,6 +753,10 @@ function relAge(ts) {
|
|
|
744
753
|
return `${Math.round(m / 1440)}d`;
|
|
745
754
|
}
|
|
746
755
|
|
|
756
|
+
async function loadAppStats() {
|
|
757
|
+
palette.appStats = await sendJson('GET', '/api/apps/stats');
|
|
758
|
+
}
|
|
759
|
+
|
|
747
760
|
async function loadConfigDirs() {
|
|
748
761
|
palette.configDirs = await sendJson('GET', '/api/config-dirs');
|
|
749
762
|
}
|
|
@@ -940,12 +953,17 @@ const PALETTE_MODES = {
|
|
|
940
953
|
hint: 'Enter switch · Ctrl+R restart the app',
|
|
941
954
|
empty: 'No matching app',
|
|
942
955
|
rows: (_typed, q) => appRows(q),
|
|
956
|
+
load: loadAppStats,
|
|
943
957
|
initialSel(rows) {
|
|
944
958
|
const prev = rows.findIndex((r) => r.id === previousApp);
|
|
945
959
|
return prev < 0 ? 0 : prev;
|
|
946
960
|
},
|
|
947
961
|
fillRow(li, row, q) {
|
|
948
962
|
li.append(appIcon(row.app), markedSpan('name', row.app.name, q));
|
|
963
|
+
const s = palette.appStats?.[row.id];
|
|
964
|
+
const stats = el('span', 'stats', s ? `${Math.round(s.rss / 1048576)} MB · ${s.cpu}%` : '');
|
|
965
|
+
stats.title = 'Memory and CPU of the app process. Child processes are not counted. 100% is one core.';
|
|
966
|
+
li.append(stats);
|
|
949
967
|
const key = row.id === activeApp ? 'active' : row.index < 9 ? `Alt+${row.index + 1}` : '';
|
|
950
968
|
li.append(el('span', 'age', key));
|
|
951
969
|
li.append(rowButton('palette-restart', '↻', 'r', 'Restart the app (Ctrl+R)', `Restart ${row.app.name}`));
|
|
@@ -1060,7 +1078,7 @@ async function commitProject(row) {
|
|
|
1060
1078
|
const absPath = row.kind === 'project' ? row.path : await resolveTypedPath(row.path);
|
|
1061
1079
|
if (!absPath) return;
|
|
1062
1080
|
closePalette();
|
|
1063
|
-
setProject(absPath);
|
|
1081
|
+
setProject(absPath, row.p?.worktrees);
|
|
1064
1082
|
}
|
|
1065
1083
|
|
|
1066
1084
|
async function sendJson(method, url, body) {
|
package/public/index.html
CHANGED
|
@@ -264,6 +264,14 @@
|
|
|
264
264
|
padding-left: 10px;
|
|
265
265
|
}
|
|
266
266
|
|
|
267
|
+
.palette-row .stats {
|
|
268
|
+
color: var(--text-dim);
|
|
269
|
+
margin-left: auto;
|
|
270
|
+
font-variant-numeric: tabular-nums;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
.palette-row .stats + .age { margin-left: 0; }
|
|
274
|
+
|
|
267
275
|
.palette-row.literal {
|
|
268
276
|
border-top: 1px solid var(--border);
|
|
269
277
|
color: var(--accent);
|
package/server.js
CHANGED
|
@@ -12,7 +12,7 @@ const { actionTable } = require('./lib/actions');
|
|
|
12
12
|
const { selectApps, loadApp, conflicts } = require('./lib/apps');
|
|
13
13
|
const { stripCookie } = require('./lib/cookies');
|
|
14
14
|
const { createNetGuard, KEEP_ALIVE_MS } = require('./lib/net-guard');
|
|
15
|
-
const { ping } = require('./lib/
|
|
15
|
+
const { ping, stats } = require('./lib/ipc');
|
|
16
16
|
const { killChild } = require('./lib/kill-child');
|
|
17
17
|
const { readRegistry, themeConfig } = require('./lib/themes');
|
|
18
18
|
const { createUpdateCheck } = require('./lib/update-check');
|
|
@@ -926,6 +926,16 @@ app.post('/api/config-dirs/activate', async (req, res) => {
|
|
|
926
926
|
res.json({ apps: appsConfig() });
|
|
927
927
|
});
|
|
928
928
|
|
|
929
|
+
const STATS_TIMEOUT_MS = 1000;
|
|
930
|
+
app.get('/api/apps/stats', async (_req, res) => {
|
|
931
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
932
|
+
const pool = activePool();
|
|
933
|
+
const entries = await Promise.all(
|
|
934
|
+
ENABLED_APPS.map(async (a) => [a.id, await stats(pool?.children.get(a.id), STATS_TIMEOUT_MS)]),
|
|
935
|
+
);
|
|
936
|
+
res.json(Object.fromEntries(entries));
|
|
937
|
+
});
|
|
938
|
+
|
|
929
939
|
// Restarting the terminal provider ends its live terminals, so that takes force: true.
|
|
930
940
|
const restarting = new Set();
|
|
931
941
|
app.post('/api/apps/:id/restart', async (req, res) => {
|
package/lib/ping.js
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
let seq = 0;
|
|
4
|
-
|
|
5
|
-
// Resolves true when the child answers hub:ping over its IPC channel within timeoutMs. The channel
|
|
6
|
-
// does not use TCP, so a loopback connect failure does not stop the answer.
|
|
7
|
-
function ping(child, timeoutMs) {
|
|
8
|
-
if (!child?.connected) return Promise.resolve(false);
|
|
9
|
-
const id = ++seq;
|
|
10
|
-
return new Promise((resolve) => {
|
|
11
|
-
const done = (ok) => {
|
|
12
|
-
clearTimeout(timer);
|
|
13
|
-
child.off('message', onMessage);
|
|
14
|
-
resolve(ok);
|
|
15
|
-
};
|
|
16
|
-
const onMessage = (m) => m?.type === 'hub:pong' && m.id === id && done(true);
|
|
17
|
-
const timer = setTimeout(() => done(false), timeoutMs);
|
|
18
|
-
child.on('message', onMessage);
|
|
19
|
-
child.send({ type: 'hub:ping', id }, (err) => err && done(false));
|
|
20
|
-
});
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
module.exports = { ping };
|