claude-code-hub 1.0.3 → 1.2.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/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
- const h = String(host).toLowerCase();
44
- return LOOPBACK_HOSTS.has(h) || h.startsWith('127.');
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
- function frameGuard(_req, res, next) {
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. Failures are swallowed: it is an optimization, not a requirement.
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
- actualPort = server.address().port;
265
- if (!EXPOSED) {
266
- const otherFamily = BIND_HOST.includes(':') ? '127.0.0.1' : '::1';
267
- try {
268
- secondary = makeServer(app);
269
- secondary.on('error', () => {}); // that family may not exist on this host
270
- secondary.listen({ host: otherFamily, port: actualPort, ipv6Only: otherFamily === '::1' });
271
- } catch { /* best effort */ }
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.0.3",
3
+ "version": "1.2.0",
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.0.0",
49
- "claude-code-kanban": "5.1.1",
50
- "claude-code-marketplace": "1.0.0",
51
- "claude-code-memory-explorer": "1.0.0",
48
+ "claude-code-cost": "1.2.0",
49
+ "claude-code-kanban": "5.3.0",
50
+ "claude-code-marketplace": "1.2.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. Matches memory/server.js encodeProjectPath. The hub
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(/\\/g, '/').replace(/:/g, '-').replace(/\//g, '-');
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
- function setProject(absPath) {
158
- projectState = { project: absPath, encoded: encodeProjectPath(absPath), name: basename(absPath) };
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
 
@@ -229,6 +235,7 @@ async function init() {
229
235
  listenKeys();
230
236
  bindPalette();
231
237
  registerSW();
238
+ bindErrorToast();
232
239
  showUpdateToast();
233
240
  }
234
241
 
@@ -273,6 +280,37 @@ async function showUpdateToast() {
273
280
  arm();
274
281
  }
275
282
 
283
+ const ERROR_TOAST_MS = 8_000;
284
+ let errorToastTimer = 0;
285
+
286
+ function hideErrorToast() {
287
+ clearTimeout(errorToastTimer);
288
+ const toast = document.getElementById('error-toast');
289
+ toast.classList.add('leaving');
290
+ setTimeout(() => (toast.hidden = true), 200);
291
+ }
292
+
293
+ // A 401 means the hub_token cookie no longer matches the token file. Opening /?token=… sets a new
294
+ // cookie; reloading lands on the locked page, where the token can be pasted instead.
295
+ function showErrorToast(action, err) {
296
+ const unauthorized = err.status === 401;
297
+ document.getElementById('error-title').textContent = unauthorized ? `${action}: hub token rejected` : action;
298
+ document.getElementById('error-detail').textContent = unauthorized
299
+ ? `This browser has an old hub token. Open the link the hub printed in the terminal (${location.origin}/?token=…) to renew it.`
300
+ : err.message;
301
+ document.getElementById('error-actions').hidden = !unauthorized;
302
+ const toast = document.getElementById('error-toast');
303
+ toast.classList.remove('leaving');
304
+ toast.hidden = false;
305
+ clearTimeout(errorToastTimer);
306
+ if (!unauthorized) errorToastTimer = setTimeout(hideErrorToast, ERROR_TOAST_MS);
307
+ }
308
+
309
+ function bindErrorToast() {
310
+ document.getElementById('error-close').addEventListener('click', hideErrorToast);
311
+ document.getElementById('error-reload').addEventListener('click', () => location.reload());
312
+ }
313
+
276
314
  // The hub has no visible chrome, so the window/tab title is the only place the active config dir
277
315
  // shows. Only a non-default dir is named, and by basename alone — the PWA window prepends the
278
316
  // manifest name, so a product name here would read as "Claude Code Hub - .claude-eom · Hub".
@@ -612,6 +650,8 @@ async function loadProjects() {
612
650
  path: p.path,
613
651
  name: basename(p.path),
614
652
  parent: parentOf(p.path),
653
+ worktrees: p.worktrees ?? [],
654
+ worktreeNames: (p.worktrees ?? []).map((w) => basename(w).toLowerCase()),
615
655
  ts: p.modifiedAt ? Date.parse(p.modifiedAt) : 0,
616
656
  }))
617
657
  .sort((a, b) => b.ts - a.ts);
@@ -644,6 +684,7 @@ function projectRows(projects, q) {
644
684
  const path = p.path.toLowerCase();
645
685
  if (path.split(/[/\\]/).includes(q)) return PATH_RANK;
646
686
  if (path.includes(q)) return PATH_RANK + 1;
687
+ if (p.worktreeNames.some((w) => w.includes(q))) return PATH_RANK + 2;
647
688
  return -1;
648
689
  };
649
690
  let out = projects.map((p) => ({ p, rank: rankOf(p) })).filter((x) => x.rank >= 0);
@@ -712,6 +753,10 @@ function relAge(ts) {
712
753
  return `${Math.round(m / 1440)}d`;
713
754
  }
714
755
 
756
+ async function loadAppStats() {
757
+ palette.appStats = await sendJson('GET', '/api/apps/stats');
758
+ }
759
+
715
760
  async function loadConfigDirs() {
716
761
  palette.configDirs = await sendJson('GET', '/api/config-dirs');
717
762
  }
@@ -722,7 +767,7 @@ async function removeConfigDir(dirPath) {
722
767
  try {
723
768
  palette.configDirs = await sendJson('DELETE', '/api/config-dirs', { path: dirPath });
724
769
  } catch (err) {
725
- console.warn('remove config dir failed:', err.message);
770
+ showErrorToast('Could not remove config dir', err);
726
771
  return;
727
772
  }
728
773
  if (dirPath in themeState.colorThemes) {
@@ -908,12 +953,17 @@ const PALETTE_MODES = {
908
953
  hint: 'Enter switch · Ctrl+R restart the app',
909
954
  empty: 'No matching app',
910
955
  rows: (_typed, q) => appRows(q),
956
+ load: loadAppStats,
911
957
  initialSel(rows) {
912
958
  const prev = rows.findIndex((r) => r.id === previousApp);
913
959
  return prev < 0 ? 0 : prev;
914
960
  },
915
961
  fillRow(li, row, q) {
916
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);
917
967
  const key = row.id === activeApp ? 'active' : row.index < 9 ? `Alt+${row.index + 1}` : '';
918
968
  li.append(el('span', 'age', key));
919
969
  li.append(rowButton('palette-restart', '↻', 'r', 'Restart the app (Ctrl+R)', `Restart ${row.app.name}`));
@@ -993,7 +1043,10 @@ function openPalette(mode) {
993
1043
  .then(() => {
994
1044
  if (palette.open && palette.mode === mode) renderPalette();
995
1045
  })
996
- .catch((err) => console.warn(`${mode} list unavailable:`, err.message));
1046
+ .catch((err) => {
1047
+ if (err.status === 401) showErrorToast('Could not load the list', err);
1048
+ else console.warn(`${mode} list unavailable:`, err.message);
1049
+ });
997
1050
  }
998
1051
 
999
1052
  function closePalette() {
@@ -1025,7 +1078,7 @@ async function commitProject(row) {
1025
1078
  const absPath = row.kind === 'project' ? row.path : await resolveTypedPath(row.path);
1026
1079
  if (!absPath) return;
1027
1080
  closePalette();
1028
- setProject(absPath);
1081
+ setProject(absPath, row.p?.worktrees);
1029
1082
  }
1030
1083
 
1031
1084
  async function sendJson(method, url, body) {
@@ -1057,7 +1110,7 @@ async function commitConfigDir(row) {
1057
1110
  try {
1058
1111
  target = (await withPaletteBusy(() => sendJson('POST', '/api/config-dirs', { path: row.path }))).path;
1059
1112
  } catch (err) {
1060
- console.warn('add config dir failed:', err.message);
1113
+ showErrorToast('Could not add config dir', err);
1061
1114
  return;
1062
1115
  }
1063
1116
  }
@@ -1075,7 +1128,7 @@ async function commitConfigDir(row) {
1075
1128
  applyHubTheme();
1076
1129
  reloadIframes();
1077
1130
  } catch (err) {
1078
- console.warn('config dir switch failed:', err.message);
1131
+ showErrorToast('Could not switch config dir', err);
1079
1132
  hideLoading();
1080
1133
  }
1081
1134
  }
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);
@@ -376,6 +384,7 @@
376
384
  }
377
385
 
378
386
  .update-toast-close:hover { color: var(--text); }
387
+ .error-toast { z-index: 25; border-left-color: #e5484d; }
379
388
  </style>
380
389
  </head>
381
390
  <body>
@@ -448,6 +457,17 @@
448
457
  <button class="update-toast-close" id="update-close" type="button" aria-label="Close">×</button>
449
458
  </div>
450
459
 
460
+ <div class="update-toast error-toast" id="error-toast" role="alert" hidden>
461
+ <div class="update-toast-body">
462
+ <div class="update-toast-title" id="error-title"></div>
463
+ <div class="update-toast-meta" id="error-detail"></div>
464
+ <div class="update-toast-actions" id="error-actions" hidden>
465
+ <button class="update-toast-skip" id="error-reload" type="button">Paste token instead</button>
466
+ </div>
467
+ </div>
468
+ <button class="update-toast-close" id="error-close" type="button" aria-label="Close">×</button>
469
+ </div>
470
+
451
471
  <script src="/app.js"></script>
452
472
  </body>
453
473
  </html>
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/ping');
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 };