@mmmbuto/nexuscrew 0.9.38 → 0.9.39

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.
@@ -11,8 +11,8 @@
11
11
  <meta name="apple-mobile-web-app-title" content="NexusCrew" />
12
12
  <link rel="manifest" href="/manifest.json" />
13
13
  <title>NexusCrew</title>
14
- <script type="module" crossorigin src="/assets/index-DDuZWnkm.js"></script>
15
- <link rel="stylesheet" crossorigin href="/assets/index-XusWm2UG.css">
14
+ <script type="module" crossorigin src="/assets/index-Dhmlcepq.js"></script>
15
+ <link rel="stylesheet" crossorigin href="/assets/index-Csz8VaFz.css">
16
16
  </head>
17
17
  <body>
18
18
  <div id="root"></div>
@@ -1 +1 @@
1
- {"version":"0.9.38"}
1
+ {"version":"0.9.39"}
@@ -1528,6 +1528,33 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
1528
1528
  path = `/federation/route/${rest.length ? `${rest.join('/')}/` : ''}${ROUTE_DELIMITER}${parsed.resource}`;
1529
1529
  }
1530
1530
  const up = net.connect({ host: '127.0.0.1', port });
1531
+ // Osservatore NON consumante della testa di risposta dell'upstream: serve
1532
+ // solo a sapere se il 101 e' transitato (client in stato WebSocket) o se
1533
+ // l'hop e' morto prima. La pipe resta l'unico trasporto dei byte: il
1534
+ // listener non sottrae nulla, guarda gli stessi chunk.
1535
+ let saw101 = false;
1536
+ let headBuf = Buffer.alloc(0);
1537
+ let gone = false;
1538
+ up.on('data', (chunk) => {
1539
+ if (saw101 || headBuf.length >= 1024) return;
1540
+ headBuf = Buffer.concat([headBuf, chunk.subarray(0, Math.max(0, 1024 - headBuf.length))]);
1541
+ if (headBuf.includes(' 101')) saw101 = true;
1542
+ });
1543
+ // L'hop e' morto (errore di connessione o chiusura a stream avviato): il
1544
+ // client deve riceverlo come codice, non come troncamento.
1545
+ const upstreamGone = () => {
1546
+ if (gone) return;
1547
+ gone = true;
1548
+ if (saw101) {
1549
+ // Handshake gia' transitato: un frame di close e' cio' che il browser
1550
+ // sa leggere. Se l'upstream aveva gia' chiuso con un suo codice, quel
1551
+ // frame e' arrivato prima e vince (il browser si ferma al primo).
1552
+ try { socket.write(wsCloseFrame(4402, 'upstream gone')); } catch (_) { /* socket morto */ }
1553
+ try { socket.end(); } catch (_) { /* idem */ }
1554
+ return;
1555
+ }
1556
+ acceptWsThenClose(socket, req, 4402, 'upstream gone');
1557
+ };
1531
1558
  up.once('connect', () => {
1532
1559
  // Ultimo hop: l'upgrade rientra nell'API locale col Bearer locale e da li'
1533
1560
  // in poi sarebbe indistinguibile da un attach diretto di chi possiede quel
@@ -1554,9 +1581,15 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
1554
1581
  const remove = () => { activeSockets.delete(socket); activeSockets.delete(up); };
1555
1582
  socket.once('close', remove); up.once('close', remove);
1556
1583
  }
1557
- socket.pipe(up); up.pipe(socket);
1584
+ socket.pipe(up);
1585
+ // end:false: la chiusura verso il browser la governiamo noi, o la pipe
1586
+ // chiuderebbe il socket PRIMA che il frame di close 4402 possa partire.
1587
+ up.pipe(socket, { end: false });
1558
1588
  });
1559
- up.on('error', () => reject(socket, 502)); socket.on('error', () => up.destroy());
1589
+ up.on('error', () => upstreamGone());
1590
+ up.on('end', () => upstreamGone());
1591
+ up.on('close', () => upstreamGone());
1592
+ socket.on('error', () => up.destroy());
1560
1593
  }
1561
1594
 
1562
1595
  // Indistinguibile da «sessione inesistente» (lib/ws/bridge.js): le stesse
@@ -1579,6 +1612,38 @@ function rejectAsNoSuchSession(socket, req) {
1579
1612
 
1580
1613
  function reject(socket, code) { try { socket.end(`HTTP/1.1 ${code} Error\r\nConnection: close\r\n\r\n`); } catch (_) {} }
1581
1614
 
1615
+ // Frame di close costruito a mano: sul ramo "hop morto DOPO il 101" non c'e'
1616
+ // piu' una richiesta di upgrade da consegnare a handleUpgrade (l'handshake e'
1617
+ // gia' transitato), quindi il frame va scritto direttamente.
1618
+ function wsCloseFrame(code, reason = '') {
1619
+ const text = String(reason).slice(0, 100);
1620
+ const payload = Buffer.alloc(2 + Buffer.byteLength(text));
1621
+ payload.writeUInt16BE((Number(code) || 1000) & 0xffff, 0);
1622
+ payload.write(text, 2, 'utf8');
1623
+ const len = payload.length;
1624
+ return len < 126
1625
+ ? Buffer.concat([Buffer.from([0x88, len]), payload])
1626
+ : Buffer.concat([Buffer.from([0x88, 126, (len >> 8) & 0xff, len & 0xff]), payload]);
1627
+ }
1628
+
1629
+ // Hop morto e il client NON ha ancora visto il 101: completiamo l'handshake
1630
+ // qui, cosi' il browser entra in stato WebSocket e puo' leggere un frame di
1631
+ // close LEGGIBILE (4402) invece di un 502 grezzo o di un EOF muto. L'accept
1632
+ // lo calcola la libreria (stesso REJECT_WSS del rifiuto "no such session"),
1633
+ // cosi' il GUID della RFC 6455 non vive in questo file. Per una richiesta che
1634
+ // non e' un vero upgrade resta il rifiuto HTTP di prima.
1635
+ function acceptWsThenClose(socket, req, code, reason) {
1636
+ const key = req && req.headers && req.headers['sec-websocket-key'];
1637
+ if (!key) return reject(socket, 502);
1638
+ try {
1639
+ REJECT_WSS.handleUpgrade(req, socket, Buffer.alloc(0), (ws) => {
1640
+ try { ws.send(JSON.stringify({ type: 'error', reason })); } catch (_) { /* socket morto */ }
1641
+ try { ws.close(code, reason); } catch (_) { /* idem */ }
1642
+ });
1643
+ } catch (_) { try { socket.destroy(); } catch (_) { /* idem */ } }
1644
+ }
1645
+
1646
+
1582
1647
  module.exports = {
1583
1648
  isPanelResource, panelAllowedFor, isLiveHostResource, liveHostAllowedFor, routeHandler,
1584
1649
  MAX_HOPS, ROUTE_DELIMITER, TOPOLOGY_PEER_TIMEOUT_MS, SHARE_NOT_READY_CODE, classifyShareFailure, activeReversePort,
package/lib/ws/bridge.js CHANGED
@@ -117,8 +117,20 @@ function sendJson(ws, value) {
117
117
  try { ws.send(JSON.stringify(value)); } catch (_) {}
118
118
  }
119
119
 
120
+ // Resync del buffer dopo una riconnessione: il client chiede {type:'resync'}
121
+ // e riceve l'intero pane (scrollback cap 2000 righe) con le sequenze di
122
+ // escape (-e) per ridipingere lo schermo senza mai mostrarlo vuoto.
123
+ const RESYNC_MAX_LINES = 2000;
124
+ function defaultCapturePane(session, tmuxBin, cb) {
125
+ try {
126
+ require('node:child_process').execFile(tmuxBin || 'tmux',
127
+ ['capture-pane', '-e', '-p', '-S', `-${RESYNC_MAX_LINES}`, '-t', String(session)],
128
+ { maxBuffer: 16 * 1024 * 1024 }, (err, stdout) => cb(err, stdout));
129
+ } catch (e) { cb(e); }
130
+ }
131
+
120
132
  function bindWs(ws, deps) {
121
- const { openAttach, verifyToken, isValidSession = () => true, runAction = () => false, countClients = () => 0, defaults = {}, onAttach = () => {}, ptyGrace = null, diagnostics = null, dropCounter = null } = deps;
133
+ const { openAttach, verifyToken, isValidSession = () => true, runAction = () => false, countClients = () => 0, defaults = {}, onAttach = () => {}, ptyGrace = null, diagnostics = null, dropCounter = null, capturePane = defaultCapturePane } = deps;
122
134
  let record = null;
123
135
  let attached = false;
124
136
  let session = null;
@@ -218,6 +230,7 @@ function bindWs(ws, deps) {
218
230
  onAttach(session, ws);
219
231
  logReattach();
220
232
  sendJson(ws, { type: 'attached', reconnectToken: record.resumeToken });
233
+ sendJson(ws, { type: 'link', state: 'live' });
221
234
  return;
222
235
  }
223
236
  attached = true;
@@ -279,6 +292,7 @@ function bindWs(ws, deps) {
279
292
  try { record.ws.close(1000, 'exit'); } catch (_) {}
280
293
  });
281
294
  sendJson(ws, { type: 'attached', reconnectToken: record.resumeToken });
295
+ sendJson(ws, { type: 'link', state: 'live' });
282
296
  return;
283
297
  }
284
298
  // dopo l'attach
@@ -294,6 +308,15 @@ function bindWs(ws, deps) {
294
308
  else if (msg.type === 'input' && !record.readonly) record.pty.write(typeof msg.data === 'string' ? msg.data : '');
295
309
  else if (msg.type === 'key' && !record.readonly) record.pty.write(typeof msg.seq === 'string' ? msg.seq.slice(0, 64) : '');
296
310
  else if (msg.type === 'action') runAction(session, msg.name); // nav window/pane server-side
311
+ // Resync post-riconnessione: ridipinge il buffer del client dal pane
312
+ // reale. Best-effort — se il capture fallisce lo stream live continua e
313
+ // il client resta sul buffer che aveva (mai uno schermo vuoto forzato).
314
+ else if (msg.type === 'resync') {
315
+ capturePane(session, defaults.tmuxBin || 'tmux', (err, text) => {
316
+ if (err) return;
317
+ sendJson(ws, { type: 'snapshot', data: String(text || '') });
318
+ });
319
+ }
297
320
  }
298
321
 
299
322
  // Alla riconnessione la DURATA della caduta e' il gap close->reopen della
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmmbuto/nexuscrew",
3
- "version": "0.9.38",
3
+ "version": "0.9.39",
4
4
  "description": "Faithful browser tmux client — attach to live sessions over a real PTY, localhost-only, mobile-easy",
5
5
  "main": "lib/server.js",
6
6
  "bin": {