tabminal 2.0.12 → 2.0.13

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tabminal",
3
- "version": "2.0.12",
3
+ "version": "2.0.13",
4
4
  "description": "A modern, persistent web terminal with multi-tab support and real-time system monitoring.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -43,7 +43,9 @@
43
43
  "node-pty": "^1.1.0",
44
44
  "openai": "^6.32.0",
45
45
  "utilitas": "^2001.1.135",
46
- "ws": "^8.19.0"
46
+ "ws": "^8.19.0",
47
+ "xterm-addon-serialize": "^0.11.0",
48
+ "xterm-headless": "^5.3.0"
47
49
  },
48
50
  "repository": {
49
51
  "type": "git",
package/public/app.js CHANGED
@@ -977,8 +977,13 @@ class Session {
977
977
  this.layoutState = {
978
978
  editorFlex: '2 1 0%'
979
979
  };
980
+ this.wrapperElement = null;
981
+ this._createTerminals();
980
982
 
981
- // Preview Terminal (Always create instance to maintain logic consistency)
983
+ this.connect();
984
+ }
985
+
986
+ _createTerminals() {
982
987
  this.previewTerm = new Terminal({
983
988
  disableStdin: true,
984
989
  cursorBlink: false,
@@ -986,17 +991,18 @@ class Session {
986
991
  fontSize: 10,
987
992
  rows: this.rows,
988
993
  cols: this.cols,
989
- theme: { background: '#002b36', foreground: '#839496', cursor: 'transparent', selectionBackground: 'transparent' }
994
+ theme: {
995
+ background: '#002b36',
996
+ foreground: '#839496',
997
+ cursor: 'transparent',
998
+ selectionBackground: 'transparent'
999
+ }
990
1000
  });
991
-
992
- // Only load CanvasAddon on Desktop to save GPU memory
1001
+
993
1002
  if (window.innerWidth >= 768) {
994
1003
  this.previewTerm.loadAddon(new CanvasAddon());
995
1004
  }
996
-
997
- this.wrapperElement = null;
998
1005
 
999
- // Main Terminal
1000
1006
  this.mainTerm = new Terminal({
1001
1007
  allowTransparency: true,
1002
1008
  convertEol: true,
@@ -1005,7 +1011,13 @@ class Session {
1005
1011
  fontSize: IS_MOBILE ? 14 : 12,
1006
1012
  rows: this.rows,
1007
1013
  cols: this.cols,
1008
- theme: { background: '#002b36', foreground: '#839496', cursor: '#93a1a1', cursorAccent: '#002b36', selectionBackground: '#073642' }
1014
+ theme: {
1015
+ background: '#002b36',
1016
+ foreground: '#839496',
1017
+ cursor: '#93a1a1',
1018
+ cursorAccent: '#002b36',
1019
+ selectionBackground: '#073642'
1020
+ }
1009
1021
  });
1010
1022
  this.mainFitAddon = new FitAddon();
1011
1023
  this.mainLinksAddon = new WebLinksAddon();
@@ -1015,21 +1027,54 @@ class Session {
1015
1027
  this.mainTerm.loadAddon(this.searchAddon);
1016
1028
  this.mainTerm.loadAddon(new CanvasAddon());
1017
1029
 
1018
- // Event Listeners
1019
- this.mainTerm.onData(data => {
1030
+ this.mainTerm.onData((data) => {
1020
1031
  if (this.isRestoring) return;
1021
1032
  this.send({ type: 'input', data });
1022
1033
  });
1023
1034
 
1024
- this.mainTerm.onResize(size => {
1035
+ this.mainTerm.onResize((size) => {
1025
1036
  this.previewTerm.resize(size.cols, size.rows);
1026
1037
  this.updatePreviewScale();
1027
-
1038
+
1028
1039
  const pending = getPendingSession(this.key);
1029
1040
  pending.resize = { cols: size.cols, rows: size.rows };
1030
1041
  });
1042
+ }
1031
1043
 
1032
- this.connect();
1044
+ recreateTerminals() {
1045
+ const wasActive = state.activeSessionKey === this.key;
1046
+ const previewWrapper = this.wrapperElement;
1047
+
1048
+ try {
1049
+ this.previewTerm?.dispose();
1050
+ } catch (e) {
1051
+ if (!e.message?.includes('onRequestRedraw')) {
1052
+ console.warn('Error disposing preview terminal:', e);
1053
+ }
1054
+ }
1055
+
1056
+ try {
1057
+ this.mainTerm?.dispose();
1058
+ } catch (e) {
1059
+ if (!e.message?.includes('onRequestRedraw')) {
1060
+ console.warn('Error disposing main terminal:', e);
1061
+ }
1062
+ }
1063
+
1064
+ this._createTerminals();
1065
+
1066
+ if (previewWrapper && window.innerWidth >= 768) {
1067
+ previewWrapper.innerHTML = '';
1068
+ this.previewTerm.open(previewWrapper);
1069
+ this.updatePreviewScale();
1070
+ }
1071
+
1072
+ if (wasActive && terminalEl) {
1073
+ terminalEl.innerHTML = '';
1074
+ this.mainTerm.open(terminalEl);
1075
+ this.mainFitAddon.fit();
1076
+ this.mainTerm.focus();
1077
+ }
1033
1078
  }
1034
1079
 
1035
1080
  update(data) {
@@ -1187,15 +1232,19 @@ class Session {
1187
1232
  handleMessage(message) {
1188
1233
  switch (message.type) {
1189
1234
  case 'snapshot':
1190
- if (this.previewTerm) this.previewTerm.reset();
1191
- this.mainTerm.reset();
1192
- this.history = message.data || '';
1193
1235
  this.isRestoring = true;
1194
- if (this.previewTerm) this.previewTerm.write(this.history);
1195
- this.mainTerm.write(this.history, () => { this.isRestoring = false; });
1236
+ this.recreateTerminals();
1237
+ if (this.previewTerm) this.previewTerm.write(message.data || '');
1238
+ this.mainTerm.write(message.data || '', () => {
1239
+ this.isRestoring = false;
1240
+ if (state.activeSessionKey === this.key) {
1241
+ this.mainFitAddon.fit();
1242
+ this.mainTerm.focus();
1243
+ this.reportResize();
1244
+ }
1245
+ });
1196
1246
  break;
1197
1247
  case 'output':
1198
- this.history += message.data;
1199
1248
  this.writeToTerminals(message.data);
1200
1249
  break;
1201
1250
  case 'meta':
@@ -7,6 +7,7 @@ const BASE_DIR = path.join(HOME_DIR, '.tabminal');
7
7
  const SESSIONS_DIR = path.join(BASE_DIR, 'sessions');
8
8
  const MEMORY_FILE = path.join(BASE_DIR, 'memory.json');
9
9
  const CLUSTER_FILE = path.join(BASE_DIR, 'cluster.json');
10
+ const getSessionSnapshotPath = (id) => path.join(SESSIONS_DIR, `${id}.snapshot`);
10
11
 
11
12
  // Ensure directories exist
12
13
  const init = async () => {
@@ -45,6 +46,7 @@ export const saveSession = async (id, data) => {
45
46
  export const deleteSession = async (id) => {
46
47
  const jsonPath = path.join(SESSIONS_DIR, `${id}.json`);
47
48
  const logPath = path.join(SESSIONS_DIR, `${id}.log`);
49
+ const snapshotPath = getSessionSnapshotPath(id);
48
50
 
49
51
  try {
50
52
  await fs.unlink(jsonPath);
@@ -57,6 +59,12 @@ export const deleteSession = async (id) => {
57
59
  } catch (e) {
58
60
  if (e.code !== 'ENOENT') console.error(`[Persistence] Failed to delete session log ${id}:`, e);
59
61
  }
62
+
63
+ try {
64
+ await fs.unlink(snapshotPath);
65
+ } catch (e) {
66
+ if (e.code !== 'ENOENT') console.error(`[Persistence] Failed to delete session snapshot ${id}:`, e);
67
+ }
60
68
  };
61
69
 
62
70
  export const loadSessions = async () => {
@@ -197,6 +205,16 @@ export const appendSessionLog = async (id, chunk) => {
197
205
  }
198
206
  };
199
207
 
208
+ export const saveSessionSnapshot = async (id, snapshot) => {
209
+ await init();
210
+ const filePath = getSessionSnapshotPath(id);
211
+ try {
212
+ await fs.writeFile(filePath, snapshot, 'utf8');
213
+ } catch (e) {
214
+ console.error(`[Persistence] Failed to save snapshot for ${id}:`, e);
215
+ }
216
+ };
217
+
200
218
  export const loadSessionLog = async (id, limit = 1024 * 1024) => {
201
219
  const filePath = path.join(SESSIONS_DIR, `${id}.log`);
202
220
  try {
@@ -218,3 +236,19 @@ export const loadSessionLog = async (id, limit = 1024 * 1024) => {
218
236
  return '';
219
237
  }
220
238
  };
239
+
240
+ export const loadSessionSnapshot = async (id) => {
241
+ const filePath = getSessionSnapshotPath(id);
242
+ try {
243
+ return await fs.readFile(filePath, 'utf8');
244
+ } catch (e) {
245
+ if (e.code !== 'ENOENT') {
246
+ console.error(
247
+ `[Persistence] Failed to load snapshot for ${id}:`,
248
+ e
249
+ );
250
+ }
251
+ }
252
+
253
+ return loadSessionLog(id);
254
+ };
@@ -59,6 +59,7 @@ function buildBashBootstrap({
59
59
  export class TerminalManager {
60
60
  constructor() {
61
61
  this.sessions = new Map();
62
+ this.snapshotPersistTimers = new Map();
62
63
  this.lastCols = initialCols;
63
64
  this.lastRows = initialRows;
64
65
  this.disposing = false;
@@ -192,8 +193,10 @@ precmd_functions+=(_tabminal_zsh_apply_prompt_marker)
192
193
  });
193
194
 
194
195
  if (restoredData) {
195
- persistence.loadSessionLog(id).then(log => {
196
- if (log) session.history = log;
196
+ persistence.loadSessionSnapshot(id).then(async (snapshot) => {
197
+ if (!snapshot) return;
198
+ await session.restoreSnapshot(snapshot);
199
+ this.scheduleSnapshotPersist(id);
197
200
  });
198
201
  }
199
202
 
@@ -238,8 +241,25 @@ precmd_functions+=(_tabminal_zsh_apply_prompt_marker)
238
241
  }
239
242
  }
240
243
 
241
- appendLog(id, chunk) {
242
- persistence.appendSessionLog(id, chunk);
244
+ scheduleSnapshotPersist(id) {
245
+ const session = this.sessions.get(id);
246
+ if (!session) return;
247
+
248
+ const existing = this.snapshotPersistTimers.get(id);
249
+ if (existing) {
250
+ clearTimeout(existing);
251
+ }
252
+
253
+ const timer = setTimeout(async () => {
254
+ this.snapshotPersistTimers.delete(id);
255
+ const currentSession = this.sessions.get(id);
256
+ if (!currentSession) return;
257
+
258
+ const snapshot = await currentSession.serializeSnapshot();
259
+ await persistence.saveSessionSnapshot(id, snapshot);
260
+ }, 250);
261
+
262
+ this.snapshotPersistTimers.set(id, timer);
243
263
  }
244
264
 
245
265
  getSession(id) {
@@ -263,6 +283,11 @@ precmd_functions+=(_tabminal_zsh_apply_prompt_marker)
263
283
  removeSession(id) {
264
284
  const session = this.sessions.get(id);
265
285
  if (session) {
286
+ const timer = this.snapshotPersistTimers.get(id);
287
+ if (timer) {
288
+ clearTimeout(timer);
289
+ this.snapshotPersistTimers.delete(id);
290
+ }
266
291
  session.dispose();
267
292
  this.sessions.delete(id);
268
293
  persistence.deleteSession(id);
@@ -289,6 +314,10 @@ precmd_functions+=(_tabminal_zsh_apply_prompt_marker)
289
314
  dispose() {
290
315
  debugLog('[Manager] Disposing all sessions.');
291
316
  this.disposing = true;
317
+ for (const timer of this.snapshotPersistTimers.values()) {
318
+ clearTimeout(timer);
319
+ }
320
+ this.snapshotPersistTimers.clear();
292
321
  for (const session of this.sessions.values()) {
293
322
  try {
294
323
  if (process.platform === 'win32') {
@@ -6,10 +6,15 @@ import { alan } from 'utilitas';
6
6
  import { config } from './config.mjs';
7
7
 
8
8
  const execAsync = promisify(exec);
9
+ const {
10
+ HeadlessTerminal,
11
+ SerializeAddon
12
+ } = await loadHeadlessXtermPackages();
9
13
  const WS_STATE_OPEN = 1;
10
14
  const DEFAULT_HISTORY_LIMIT = 512 * 1024; // chars
11
15
  const OSC_SEQUENCE_REGEX =
12
16
  /\u001b\]1337;(ExitCode=(\d+);CommandB64=([a-zA-Z0-9+/=]+)|TabminalPrompt)\u0007/g;
17
+ const EXTRA_PRIVATE_MODE_REGEX = /\u001b\[\?(1005|1006|1015)([hl])/g;
13
18
  const CSI_SEQUENCE_REGEX = /\u001b\[[0-9;?]*[ -\/]*[@-~]/g;
14
19
  const OSC_STRIP_REGEX = /\u001b\][\s\S]*?(?:\u0007|\u001b\\)/g;
15
20
  const DCS_SEQUENCE_REGEX = /\u001bP[\s\S]*?(?:\u0007|\u001b\\)/g;
@@ -26,6 +31,39 @@ const IGNORED_COMMANDS = [
26
31
 
27
32
  const PROMPT_PREFIX = "You are now operating as an AI terminal assistant. Your name is `Tabminal`. You will assist users in resolving terminal or coding issues and answering other inquiries. When troubleshooting terminal errors, you will be provided with the execution history to understand the context. However, please focus primarily on the most recent runtime errors and the user's latest questions. Keep your answers concise and accurate. Resolve the issue clearly and provide the reasoning while avoiding lengthy elaborations. Most user terminal variable keys are normal under typical circumstances and do not need to be treated as security risks.\n\n";
28
33
 
34
+ async function loadHeadlessXtermPackages() {
35
+ const hadNavigator = Object.prototype.hasOwnProperty.call(
36
+ globalThis,
37
+ 'navigator'
38
+ );
39
+ const navigatorDescriptor = hadNavigator
40
+ ? Object.getOwnPropertyDescriptor(globalThis, 'navigator')
41
+ : null;
42
+
43
+ if (hadNavigator) {
44
+ delete globalThis.navigator;
45
+ }
46
+
47
+ try {
48
+ const headlessPackage = await import('xterm-headless');
49
+ const serializePackage = await import('xterm-addon-serialize');
50
+ const { Terminal } = headlessPackage.default ?? headlessPackage;
51
+ const { SerializeAddon } = serializePackage.default ?? serializePackage;
52
+ return {
53
+ HeadlessTerminal: Terminal,
54
+ SerializeAddon
55
+ };
56
+ } finally {
57
+ if (hadNavigator && navigatorDescriptor) {
58
+ Object.defineProperty(
59
+ globalThis,
60
+ 'navigator',
61
+ navigatorDescriptor
62
+ );
63
+ }
64
+ }
65
+ }
66
+
29
67
  function splitTrailingPartialSequence(chunk) {
30
68
  if (!chunk) {
31
69
  return { complete: '', partial: '' };
@@ -45,6 +83,13 @@ function splitTrailingPartialSequence(chunk) {
45
83
  return { complete: chunk, partial: '' };
46
84
  }
47
85
 
86
+ function estimateSnapshotScrollback(cols, rows, historyLimit) {
87
+ const safeCols = Math.max(1, cols || 80);
88
+ const safeRows = Math.max(24, rows || 24);
89
+ const estimatedRows = Math.ceil(historyLimit / safeCols);
90
+ return Math.max(safeRows, Math.min(50000, estimatedRows));
91
+ }
92
+
48
93
  export class TerminalSession {
49
94
  constructor(pty, options = {}) {
50
95
  this.pty = pty;
@@ -69,6 +114,7 @@ export class TerminalSession {
69
114
  this.historyLimit = Math.max(1, options.historyLimit ?? DEFAULT_HISTORY_LIMIT);
70
115
  this.history = '';
71
116
  this.clients = new Set();
117
+ this.pendingClients = new Map();
72
118
  this.closed = false;
73
119
  this.pollingInterval = null;
74
120
  this.captureBuffer = '';
@@ -76,6 +122,27 @@ export class TerminalSession {
76
122
  this.lastExecution = null;
77
123
  this.skipNextShellLog = false;
78
124
  this.partialSequenceBuffer = '';
125
+ this.snapshotScrollback = estimateSnapshotScrollback(
126
+ this.pty.cols,
127
+ this.pty.rows,
128
+ this.historyLimit
129
+ );
130
+ this.snapshotTerminal = new HeadlessTerminal({
131
+ cols: this.pty.cols,
132
+ rows: this.pty.rows,
133
+ scrollback: this.snapshotScrollback,
134
+ allowProposedApi: true,
135
+ convertEol: true,
136
+ logLevel: 'off'
137
+ });
138
+ this.snapshotSerializeAddon = new SerializeAddon();
139
+ this.snapshotTerminal.loadAddon(this.snapshotSerializeAddon);
140
+ this.snapshotWritePromise = Promise.resolve();
141
+ this.extraPrivateModes = {
142
+ 1005: false,
143
+ 1006: false,
144
+ 1015: false
145
+ };
79
146
 
80
147
  this.ansiParser = new AnsiParser({
81
148
  inst_o: (s) => {
@@ -112,10 +179,6 @@ export class TerminalSession {
112
179
  chunk = split.complete;
113
180
  this.partialSequenceBuffer = split.partial;
114
181
 
115
- if (this.manager) {
116
- this.manager.appendLog(this.id, chunk);
117
- }
118
-
119
182
  let cleaned = '';
120
183
  let lastIndex = 0;
121
184
  OSC_SEQUENCE_REGEX.lastIndex = 0;
@@ -148,8 +211,12 @@ export class TerminalSession {
148
211
 
149
212
  if (!cleaned) return;
150
213
 
214
+ this._appendSnapshotData(cleaned);
151
215
  this._appendHistory(cleaned);
152
216
  this.ansiParser.parse(cleaned);
217
+ if (this.manager?.scheduleSnapshotPersist) {
218
+ this.manager.scheduleSnapshotPersist(this.id);
219
+ }
153
220
  this._broadcast({ type: 'output', data: cleaned });
154
221
  };
155
222
 
@@ -272,36 +339,57 @@ export class TerminalSession {
272
339
 
273
340
  attach(ws) {
274
341
  if (!ws) throw new Error('WebSocket instance required');
275
- this.clients.add(ws);
276
- ws.once('close', () => this.clients.delete(ws));
342
+ this.pendingClients.set(ws, []);
343
+ ws.once('close', () => {
344
+ this.clients.delete(ws);
345
+ this.pendingClients.delete(ws);
346
+ });
277
347
  ws.on('message', (raw) => this._routeIncoming(raw, ws));
278
348
  ws.on('error', () => ws.close());
279
349
 
280
- this._send(ws, { type: 'snapshot', data: this.history });
281
- this._send(ws, { type: 'meta', title: this.title, cwd: this.cwd, env: this.env, cols: this.pty.cols, rows: this.pty.rows });
282
- if (this.closed) {
283
- this._send(ws, { type: 'status', status: 'terminated' });
284
- } else {
285
- this._send(ws, { type: 'status', status: 'ready' });
286
- }
350
+ void this._sendInitialState(ws);
287
351
  }
288
352
 
289
353
  dispose() {
290
354
  this.stopTitlePolling();
291
355
  this.clients.clear();
356
+ this.pendingClients.clear();
292
357
  this.dataSubscription?.dispose?.();
293
358
  this.exitSubscription?.dispose?.();
359
+ this.snapshotTerminal?.dispose?.();
294
360
  }
295
361
 
296
362
  resize(cols, rows) {
297
363
  if (this.closed) return;
298
364
  this.pty.resize(cols, rows);
365
+ this._queueSnapshotMutation(() => {
366
+ this.snapshotTerminal.resize(cols, rows);
367
+ });
368
+ if (this.manager?.scheduleSnapshotPersist) {
369
+ this.manager.scheduleSnapshotPersist(this.id);
370
+ }
299
371
  this._broadcast({ type: 'meta', title: this.title, cwd: this.cwd, env: this.env, cols, rows });
300
372
  if (this.manager && this.manager.saveSessionState) {
301
373
  this.manager.saveSessionState(this);
302
374
  }
303
375
  }
304
376
 
377
+ async restoreSnapshot(snapshot) {
378
+ if (typeof snapshot !== 'string' || !snapshot) return;
379
+ this.history = snapshot;
380
+ this._appendSnapshotData(snapshot);
381
+ await this.snapshotWritePromise;
382
+ }
383
+
384
+ async serializeSnapshot() {
385
+ await this.snapshotWritePromise;
386
+ let snapshot = this.snapshotSerializeAddon.serialize({
387
+ scrollback: this.snapshotScrollback
388
+ });
389
+ snapshot += this._serializeExtraPrivateModes();
390
+ return snapshot;
391
+ }
392
+
305
393
  _routeIncoming(raw, ws) {
306
394
  let payload;
307
395
  try {
@@ -920,6 +1008,9 @@ export class TerminalSession {
920
1008
  for (const client of this.clients) {
921
1009
  this._send(client, message, data);
922
1010
  }
1011
+ for (const pending of this.pendingClients.values()) {
1012
+ pending.push(data);
1013
+ }
923
1014
  }
924
1015
 
925
1016
  _send(ws, message, preEncoded) {
@@ -929,12 +1020,82 @@ export class TerminalSession {
929
1020
 
930
1021
  _writeToLogAndBroadcast(text) {
931
1022
  if (!text) return;
932
- if (this.manager) {
933
- this.manager.appendLog(this.id, text);
934
- }
1023
+ this._appendSnapshotData(text);
935
1024
  this._appendHistory(text);
1025
+ if (this.manager?.scheduleSnapshotPersist) {
1026
+ this.manager.scheduleSnapshotPersist(this.id);
1027
+ }
936
1028
  this._broadcast({ type: 'output', data: text });
937
1029
  }
1030
+
1031
+ _queueSnapshotMutation(mutate) {
1032
+ const next = this.snapshotWritePromise.then(() => mutate());
1033
+ this.snapshotWritePromise = next.catch(() => {});
1034
+ return next;
1035
+ }
1036
+
1037
+ _appendSnapshotData(text) {
1038
+ if (!text) return;
1039
+ this._trackExtraPrivateModes(text);
1040
+ this._queueSnapshotMutation(() => new Promise((resolve) => {
1041
+ this.snapshotTerminal.write(text, resolve);
1042
+ }));
1043
+ }
1044
+
1045
+ _trackExtraPrivateModes(text) {
1046
+ EXTRA_PRIVATE_MODE_REGEX.lastIndex = 0;
1047
+ let match;
1048
+ while ((match = EXTRA_PRIVATE_MODE_REGEX.exec(text)) !== null) {
1049
+ this.extraPrivateModes[match[1]] = match[2] === 'h';
1050
+ }
1051
+ }
1052
+
1053
+ _serializeExtraPrivateModes() {
1054
+ let output = '';
1055
+ for (const mode of ['1005', '1006', '1015']) {
1056
+ if (this.extraPrivateModes[mode]) {
1057
+ output += `\u001b[?${mode}h`;
1058
+ }
1059
+ }
1060
+ return output;
1061
+ }
1062
+
1063
+ async _sendInitialState(ws) {
1064
+ const pending = this.pendingClients.get(ws);
1065
+ if (!pending) return;
1066
+
1067
+ await this._queueSnapshotMutation(() => undefined);
1068
+ if (ws.readyState !== WS_STATE_OPEN) {
1069
+ this.pendingClients.delete(ws);
1070
+ return;
1071
+ }
1072
+
1073
+ const snapshot = await this.serializeSnapshot();
1074
+ this._send(ws, { type: 'snapshot', data: snapshot });
1075
+ this._send(ws, {
1076
+ type: 'meta',
1077
+ title: this.title,
1078
+ cwd: this.cwd,
1079
+ env: this.env,
1080
+ cols: this.pty.cols,
1081
+ rows: this.pty.rows
1082
+ });
1083
+ if (this.closed) {
1084
+ this._send(ws, { type: 'status', status: 'terminated' });
1085
+ } else {
1086
+ this._send(ws, { type: 'status', status: 'ready' });
1087
+ }
1088
+
1089
+ for (const payload of pending) {
1090
+ if (ws.readyState !== WS_STATE_OPEN) break;
1091
+ ws.send(payload);
1092
+ }
1093
+
1094
+ this.pendingClients.delete(ws);
1095
+ if (ws.readyState === WS_STATE_OPEN) {
1096
+ this.clients.add(ws);
1097
+ }
1098
+ }
938
1099
  }
939
1100
 
940
1101
  function clampDimension(value) {