@mmmbuto/nexuscrew 0.9.13 → 0.9.15

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/ws/bridge.js CHANGED
@@ -1,4 +1,5 @@
1
1
  'use strict';
2
+ const crypto = require('node:crypto');
2
3
  // Bridges ONE WebSocket to ONE PTY attach. Dependencies are injectable for tests.
3
4
  // Hardening: close on protocol violation, no 2nd attach, clamp cols/rows,
4
5
  // validated session, backpressure cutoff, errors as JSON with a code.
@@ -11,19 +12,138 @@ function clamp(n, lo, hi, def) {
11
12
  }
12
13
 
13
14
  const ATTACH_TIMEOUT_MS = 15000;
15
+ // Mobile handovers and tunnel renegotiation can last longer than a LAN retry.
16
+ // Thirty seconds is finite but configurable, so a small host can tune the
17
+ // trade-off without making the suspended PTY lifetime unbounded.
18
+ const PTY_GRACE_MS = 30000;
19
+ const PTY_GRACE_MAX_SESSIONS = 8;
20
+ // No PTY output is buffered while detached; this bounds the retained record,
21
+ // rather than pretending a stale output buffer is a current terminal screen.
22
+ const PTY_GRACE_RECORD_BYTES = 1024;
23
+ const PTY_GRACE_MAX_MEMORY_BYTES = PTY_GRACE_MAX_SESSIONS * PTY_GRACE_RECORD_BYTES;
24
+
25
+ function createPtyGraceStore({
26
+ graceMs = PTY_GRACE_MS,
27
+ maxSessions = PTY_GRACE_MAX_SESSIONS,
28
+ maxMemoryBytes = PTY_GRACE_MAX_MEMORY_BYTES,
29
+ recordBytes = PTY_GRACE_RECORD_BYTES,
30
+ randomBytes = crypto.randomBytes,
31
+ } = {}) {
32
+ const suspended = new Map();
33
+ const sessionLimit = Math.max(1, Math.floor(Number(maxSessions) || PTY_GRACE_MAX_SESSIONS));
34
+ const memoryLimit = Math.max(1, Math.floor(Number(maxMemoryBytes) || PTY_GRACE_MAX_MEMORY_BYTES));
35
+ const retainedRecordBytes = Math.max(1, Math.floor(Number(recordBytes) || PTY_GRACE_RECORD_BYTES));
36
+ let memoryBytes = 0;
37
+
38
+ const clearRecordTimer = (record) => {
39
+ if (record.graceTimer) clearTimeout(record.graceTimer);
40
+ record.graceTimer = null;
41
+ };
42
+
43
+ const issueToken = (record) => {
44
+ if (!record.resumeToken) record.resumeToken = randomBytes(32).toString('base64url');
45
+ return record.resumeToken;
46
+ };
47
+
48
+ const releaseRecord = (token, record) => {
49
+ if (suspended.get(token) !== record) return false;
50
+ suspended.delete(token);
51
+ memoryBytes = Math.max(0, memoryBytes - record.graceBytes);
52
+ return true;
53
+ };
54
+
55
+ const suspend = (record) => {
56
+ if (!record || record.ended) return null;
57
+ const token = issueToken(record);
58
+ if (!suspended.has(token)
59
+ && (suspended.size >= sessionLimit || memoryBytes + retainedRecordBytes > memoryLimit)) return null;
60
+ clearRecordTimer(record);
61
+ if (!suspended.has(token)) {
62
+ record.graceBytes = retainedRecordBytes;
63
+ memoryBytes += retainedRecordBytes;
64
+ }
65
+ suspended.set(token, record);
66
+ record.graceTimer = setTimeout(() => {
67
+ if (suspended.get(token) !== record) return;
68
+ releaseRecord(token, record);
69
+ record.graceTimer = null;
70
+ record.graceExpired = true;
71
+ try { record.pty.kill(); } catch (_) {}
72
+ }, graceMs);
73
+ if (typeof record.graceTimer.unref === 'function') record.graceTimer.unref();
74
+ return token;
75
+ };
76
+
77
+ const resume = (token, session) => {
78
+ if (!token || typeof session !== 'string') return null;
79
+ const record = suspended.get(token);
80
+ if (!record || record.ended || record.session !== session || record.ws) return null;
81
+ releaseRecord(token, record);
82
+ clearRecordTimer(record);
83
+ return record;
84
+ };
85
+
86
+ const resumeEnded = (token, session) => {
87
+ if (!token || typeof session !== 'string') return null;
88
+ const record = suspended.get(token);
89
+ if (!record || !record.ended || record.session !== session || record.ws) return null;
90
+ releaseRecord(token, record);
91
+ clearRecordTimer(record);
92
+ return record;
93
+ };
94
+
95
+ const close = () => {
96
+ for (const record of suspended.values()) {
97
+ clearRecordTimer(record);
98
+ try { record.pty.kill(); } catch (_) {}
99
+ }
100
+ suspended.clear();
101
+ memoryBytes = 0;
102
+ };
103
+
104
+ return {
105
+ issueToken,
106
+ suspend,
107
+ resume,
108
+ resumeEnded,
109
+ close,
110
+ size: () => suspended.size,
111
+ memoryBytes: () => memoryBytes,
112
+ limits: () => ({ maxSessions: sessionLimit, maxMemoryBytes: memoryLimit }),
113
+ };
114
+ }
115
+
116
+ function sendJson(ws, value) {
117
+ try { ws.send(JSON.stringify(value)); } catch (_) {}
118
+ }
14
119
 
15
120
  function bindWs(ws, deps) {
16
- const { openAttach, verifyToken, isValidSession = () => true, runAction = () => false, countClients = () => 0, defaults = {}, onAttach = () => {} } = deps;
17
- let pty = null;
121
+ const { openAttach, verifyToken, isValidSession = () => true, runAction = () => false, countClients = () => 0, defaults = {}, onAttach = () => {}, ptyGrace = null } = deps;
122
+ let record = null;
18
123
  let attached = false;
19
124
  let session = null;
20
125
 
126
+ const detach = () => {
127
+ if (!record || record.ws !== ws) return;
128
+ record.ws = null;
129
+ if (record.ended) return;
130
+ if (!ptyGrace) {
131
+ try { record.pty.kill(); } catch (_) {}
132
+ return;
133
+ }
134
+ if (!ptyGrace.suspend(record)) {
135
+ // A bounded host refuses another suspended PTY instead of exceeding its
136
+ // explicit process/memory budget.
137
+ try { record.pty.kill(); } catch (_) {}
138
+ }
139
+ };
140
+
21
141
  function fail(code, reason) {
22
142
  // Si spegne anche la scadenza pre-attach: un frame rifiutato (token o
23
143
  // handshake non validi) chiude gia' il socket, e lasciare il timer vivo
24
144
  // fino al close event tiene in piedi un handle senza scopo.
25
145
  clearAttachTimer();
26
- try { ws.send(JSON.stringify({ type: 'error', reason })); } catch (_) {}
146
+ sendJson(ws, { type: 'error', reason });
27
147
  try { ws.close(code, reason); } catch (_) {}
28
148
  }
29
149
 
@@ -51,25 +171,53 @@ function bindWs(ws, deps) {
51
171
  try { msg = JSON.parse(data.toString()); } catch (_) { return fail(1002, 'bad handshake'); }
52
172
  if (msg.type !== 'attach') return fail(1002, 'expected attach');
53
173
  if (!verifyToken(msg.token)) return fail(4401, 'bad token');
54
- if (!isValidSession(msg.session)) return fail(4404, 'no such session');
174
+
175
+ // Authorization precedes every live-PTY resume. A terminated record is
176
+ // different: its only remaining purpose is to deliver the late exit
177
+ // outcome, so it has an explicit, non-attach path below.
178
+ if (!isValidSession(msg.session)) {
179
+ const ended = ptyGrace && ptyGrace.resumeEnded(msg.reconnectToken, msg.session);
180
+ if (ended) {
181
+ clearAttachTimer();
182
+ sendJson(ws, { type: 'exit', code: ended.exitCode });
183
+ try { ws.close(1000, 'exit'); } catch (_) {}
184
+ return;
185
+ }
186
+ return fail(4404, 'no such session');
187
+ }
188
+
189
+ // The capability is bound to the authenticated session and can resume
190
+ // only a still-live PTY. It is never an alternative to isValidSession.
191
+ const resumed = ptyGrace && ptyGrace.resume(msg.reconnectToken, msg.session);
192
+ if (resumed) {
193
+ clearAttachTimer();
194
+ record = resumed;
195
+ record.ws = ws;
196
+ record.readonly = record.readonly || !!msg.readonly;
197
+ attached = true;
198
+ session = record.session;
199
+ onAttach(session, ws);
200
+ sendJson(ws, { type: 'attached', reconnectToken: record.resumeToken });
201
+ return;
202
+ }
55
203
  attached = true;
56
204
  clearAttachTimer();
57
205
  session = msg.session;
58
- onAttach(session, ws);
59
206
  // Resize default: when nobody else is attached, drive the session size so a
60
207
  // small phone gets a usable (non-clipped) view and clean line editing. When a
61
208
  // real terminal is already attached, default to ignore-size so we don't shrink
62
209
  // its window. An explicit takeSize from the client always wins.
63
- const takeSize = msg.takeSize !== undefined
64
- ? !!msg.takeSize
65
- : countClients(msg.session) === 0;
66
210
  // "Segue il focus": garantisce window-size latest sulla sessione (il
67
211
  // client usato piu' di recente ne guida la geometria). Fire-and-forget.
68
212
  try {
69
213
  require('node:child_process').execFile(defaults.tmuxBin || 'tmux',
70
214
  ['set-option', '-t', `=${msg.session}:`, 'window-size', 'latest'], () => {});
71
215
  } catch (_) {}
72
- pty = openAttach(msg.session, {
216
+ const takeSize = msg.takeSize !== undefined
217
+ ? !!msg.takeSize
218
+ : countClients(msg.session) === 0;
219
+ record = {
220
+ pty: openAttach(msg.session, {
73
221
  // readonlyDefault del server e' un PAVIMENTO, non un default: se il server
74
222
  // e' READONLY nessun client puo' declassarlo (msg.readonly:false non deve
75
223
  // vincere). Il client puo' solo AGGIUNGERE restrizione (attach read-only
@@ -80,34 +228,52 @@ function bindWs(ws, deps) {
80
228
  cols: clamp(msg.cols, 20, 300, 80),
81
229
  rows: clamp(msg.rows, 5, 120, 24),
82
230
  tmuxBin: defaults.tmuxBin || 'tmux',
231
+ }),
232
+ ws,
233
+ session: msg.session,
234
+ readonly: defaults.readonlyDefault === true || !!msg.readonly,
235
+ takeSize,
236
+ ended: false,
237
+ exitCode: undefined,
238
+ resumeToken: null,
239
+ graceTimer: null,
240
+ graceExpired: false,
241
+ };
242
+ // The token is issued for this record, not for an arbitrary caller.
243
+ if (ptyGrace) record.resumeToken = ptyGrace.issueToken(record);
244
+ onAttach(session, ws);
245
+ record.pty.onData((d) => {
246
+ if (!record.ws) return;
247
+ try { record.ws.send(Buffer.from(d), { binary: true }); } catch (_) { return; }
248
+ if ((record.ws.bufferedAmount || 0) > MAX_BUFFERED) fail(1011, 'backpressure');
83
249
  });
84
- pty.onData((d) => {
85
- try { ws.send(Buffer.from(d), { binary: true }); } catch (_) { return; }
86
- if ((ws.bufferedAmount || 0) > MAX_BUFFERED) fail(1011, 'backpressure');
87
- });
88
- pty.onExit((info) => {
89
- try { ws.send(JSON.stringify({ type: 'exit', code: info && info.exitCode })); } catch (_) {}
90
- try { ws.close(1000, 'exit'); } catch (_) {}
250
+ record.pty.onExit((info) => {
251
+ record.ended = true;
252
+ record.exitCode = info && info.exitCode;
253
+ if (!record.ws) return;
254
+ sendJson(record.ws, { type: 'exit', code: record.exitCode });
255
+ try { record.ws.close(1000, 'exit'); } catch (_) {}
91
256
  });
257
+ sendJson(ws, { type: 'attached', reconnectToken: record.resumeToken });
92
258
  return;
93
259
  }
94
260
  // dopo l'attach
95
- if (isBinary) { pty.write(data); return; }
261
+ if (isBinary) { if (!record.readonly) record.pty.write(data); return; }
96
262
  let msg;
97
263
  try { msg = JSON.parse(data.toString()); } catch (_) { return; }
98
264
  if (msg.type === 'attach') return fail(1002, 'already attached'); // no 2nd attach
99
- if (msg.type === 'resize') pty.resize(clamp(msg.cols, 20, 300, 80), clamp(msg.rows, 5, 120, 24));
265
+ if (msg.type === 'resize') record.pty.resize(clamp(msg.cols, 20, 300, 80), clamp(msg.rows, 5, 120, 24));
100
266
  // focus: il tile che prende il focus diventa size-owner (promote); perdendolo
101
267
  // torna ignore-size (demote). Cosi' N deck/tile sulla stessa sessione NON si
102
268
  // contendono la geometria: comanda solo chi ha il focus (§5b size policy).
103
- else if (msg.type === 'focus') { if (msg.on) pty.promote(); else pty.demote(); }
104
- else if (msg.type === 'input') pty.write(typeof msg.data === 'string' ? msg.data : '');
105
- else if (msg.type === 'key') pty.write(typeof msg.seq === 'string' ? msg.seq.slice(0, 64) : '');
269
+ else if (msg.type === 'focus') { if (msg.on) record.pty.promote(); else record.pty.demote(); }
270
+ else if (msg.type === 'input' && !record.readonly) record.pty.write(typeof msg.data === 'string' ? msg.data : '');
271
+ else if (msg.type === 'key' && !record.readonly) record.pty.write(typeof msg.seq === 'string' ? msg.seq.slice(0, 64) : '');
106
272
  else if (msg.type === 'action') runAction(session, msg.name); // nav window/pane server-side
107
273
  }
108
274
 
109
275
  ws.on('message', onMessage);
110
- ws.on('close', () => { clearAttachTimer(); if (pty) pty.kill(); pty = null; });
111
- ws.on('error', () => { clearAttachTimer(); if (pty) pty.kill(); pty = null; });
276
+ ws.on('close', () => { clearAttachTimer(); detach(); });
277
+ ws.on('error', () => { clearAttachTimer(); detach(); });
112
278
  }
113
- module.exports = { bindWs, clamp };
279
+ module.exports = { bindWs, clamp, createPtyGraceStore, PTY_GRACE_MS };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmmbuto/nexuscrew",
3
- "version": "0.9.13",
3
+ "version": "0.9.15",
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": {