git-watchtower 2.3.9 → 2.3.11
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 +1 -1
- package/src/server/web.js +80 -14
- package/src/ui/ansi.js +128 -9
package/package.json
CHANGED
package/src/server/web.js
CHANGED
|
@@ -36,6 +36,16 @@ const MAX_PORT_RETRIES = 20;
|
|
|
36
36
|
*/
|
|
37
37
|
const SSE_KEEPALIVE_INTERVAL = 15000;
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Maximum number of consecutive periodic-state pushes a stalled client (one
|
|
41
|
+
* whose internal write buffer hasn't drained) is allowed to miss before we
|
|
42
|
+
* force-close it. At a 500 ms STATE_PUSH_INTERVAL this gives roughly 30 s
|
|
43
|
+
* of grace before eviction — enough for a paused tab to wake up, short
|
|
44
|
+
* enough that a permanently dead socket can't pin memory or keep
|
|
45
|
+
* `clientCount` misleadingly high.
|
|
46
|
+
*/
|
|
47
|
+
const MAX_STALLED_PUSHES = 60;
|
|
48
|
+
|
|
39
49
|
/**
|
|
40
50
|
* Actions the web dashboard is allowed to POST to /api/action. Every entry
|
|
41
51
|
* here MUST be matched by a `case` in `handleWebAction` in bin/git-watchtower.js
|
|
@@ -86,6 +96,15 @@ class WebDashboardServer {
|
|
|
86
96
|
/** @type {Set<import('net').Socket>} Raw TCP sockets, tracked so stop() can force-close them */
|
|
87
97
|
this._sockets = new Set();
|
|
88
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Per-client count of consecutive state pushes skipped because the
|
|
101
|
+
* client's internal buffer hasn't drained. Reset to 0 on a successful
|
|
102
|
+
* write; a paused tab whose count crosses MAX_STALLED_PUSHES gets
|
|
103
|
+
* evicted to bound memory.
|
|
104
|
+
* @type {WeakMap<import('http').ServerResponse, number>}
|
|
105
|
+
*/
|
|
106
|
+
this._stalledPushes = new WeakMap();
|
|
107
|
+
|
|
89
108
|
// Multi-project support (populated by coordinator)
|
|
90
109
|
/** @type {Map<string, Object>} */
|
|
91
110
|
this.projects = new Map();
|
|
@@ -341,11 +360,9 @@ class WebDashboardServer {
|
|
|
341
360
|
*/
|
|
342
361
|
flash(text, type) {
|
|
343
362
|
const data = JSON.stringify({ text, type: type || 'info' });
|
|
363
|
+
const message = 'event: flash\ndata: ' + data + '\n\n';
|
|
344
364
|
for (const client of this.clients) {
|
|
345
|
-
|
|
346
|
-
client.write('event: flash\n');
|
|
347
|
-
client.write('data: ' + data + '\n\n');
|
|
348
|
-
} catch (e) { /* SSE client disconnected — will be pruned when its response closes */ }
|
|
365
|
+
this._writeToClient(client, message);
|
|
349
366
|
}
|
|
350
367
|
}
|
|
351
368
|
|
|
@@ -355,11 +372,9 @@ class WebDashboardServer {
|
|
|
355
372
|
*/
|
|
356
373
|
sendPreview(data) {
|
|
357
374
|
const json = JSON.stringify(data);
|
|
375
|
+
const message = 'event: preview\ndata: ' + json + '\n\n';
|
|
358
376
|
for (const client of this.clients) {
|
|
359
|
-
|
|
360
|
-
client.write('event: preview\n');
|
|
361
|
-
client.write('data: ' + json + '\n\n');
|
|
362
|
-
} catch (e) { /* SSE client disconnected — will be pruned when its response closes */ }
|
|
377
|
+
this._writeToClient(client, message);
|
|
363
378
|
}
|
|
364
379
|
}
|
|
365
380
|
|
|
@@ -369,14 +384,51 @@ class WebDashboardServer {
|
|
|
369
384
|
*/
|
|
370
385
|
sendActionResult(result) {
|
|
371
386
|
const json = JSON.stringify(result);
|
|
387
|
+
const message = 'event: actionResult\ndata: ' + json + '\n\n';
|
|
372
388
|
for (const client of this.clients) {
|
|
373
|
-
|
|
374
|
-
client.write('event: actionResult\n');
|
|
375
|
-
client.write('data: ' + json + '\n\n');
|
|
376
|
-
} catch (e) { /* SSE client disconnected — will be pruned when its response closes */ }
|
|
389
|
+
this._writeToClient(client, message);
|
|
377
390
|
}
|
|
378
391
|
}
|
|
379
392
|
|
|
393
|
+
/**
|
|
394
|
+
* Write a frame to a single SSE client, respecting backpressure.
|
|
395
|
+
* Skips the write when the client's internal buffer hasn't drained
|
|
396
|
+
* (`writableNeedDrain`), so a paused tab can't cause unbounded
|
|
397
|
+
* server-side queueing. Drops the client on synchronous write errors
|
|
398
|
+
* (most often "write after end" on an abruptly-closed socket).
|
|
399
|
+
*
|
|
400
|
+
* @param {import('http').ServerResponse} client
|
|
401
|
+
* @param {string} message - Pre-formatted SSE frame (event + data + \n\n)
|
|
402
|
+
* @returns {boolean} `true` if the write was issued, `false` if skipped or failed
|
|
403
|
+
* @private
|
|
404
|
+
*/
|
|
405
|
+
_writeToClient(client, message) {
|
|
406
|
+
if (client.writableNeedDrain) {
|
|
407
|
+
// Client hasn't acknowledged earlier writes — skip this frame so
|
|
408
|
+
// backpressure doesn't accumulate. The next push retries naturally.
|
|
409
|
+
return false;
|
|
410
|
+
}
|
|
411
|
+
try {
|
|
412
|
+
client.write(message);
|
|
413
|
+
this._stalledPushes.set(client, 0);
|
|
414
|
+
return true;
|
|
415
|
+
} catch (e) {
|
|
416
|
+
this._dropClient(client);
|
|
417
|
+
return false;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Force-close and forget a client. Idempotent.
|
|
423
|
+
* @param {import('http').ServerResponse} client
|
|
424
|
+
* @private
|
|
425
|
+
*/
|
|
426
|
+
_dropClient(client) {
|
|
427
|
+
try { client.end(); } catch (_) { /* already torn down */ }
|
|
428
|
+
this.clients.delete(client);
|
|
429
|
+
this._stalledPushes.delete(client);
|
|
430
|
+
}
|
|
431
|
+
|
|
380
432
|
/**
|
|
381
433
|
* Get the number of connected web clients.
|
|
382
434
|
* @returns {number}
|
|
@@ -586,8 +638,22 @@ class WebDashboardServer {
|
|
|
586
638
|
|
|
587
639
|
const message = 'event: state\ndata: ' + json + '\n\n';
|
|
588
640
|
for (const client of this.clients) {
|
|
641
|
+
if (client.writableNeedDrain) {
|
|
642
|
+
// Backpressure: client's internal buffer hasn't drained from a
|
|
643
|
+
// previous write. Track how many consecutive periodic pushes
|
|
644
|
+
// we've skipped so a paused/dead tab can't accumulate stalled
|
|
645
|
+
// writes forever. The non-periodic emitters (flash/preview/
|
|
646
|
+
// actionResult) skip silently — only `_pushState` evicts.
|
|
647
|
+
const n = (this._stalledPushes.get(client) || 0) + 1;
|
|
648
|
+
this._stalledPushes.set(client, n);
|
|
649
|
+
if (n >= MAX_STALLED_PUSHES) {
|
|
650
|
+
this._dropClient(client);
|
|
651
|
+
}
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
589
654
|
try {
|
|
590
655
|
client.write(message);
|
|
656
|
+
this._stalledPushes.set(client, 0);
|
|
591
657
|
} catch (e) {
|
|
592
658
|
// Write failed — proactively prune the dead client instead of
|
|
593
659
|
// waiting for req.on('close') to fire. On abrupt socket resets
|
|
@@ -595,8 +661,7 @@ class WebDashboardServer {
|
|
|
595
661
|
// hit the same failed write, accumulating exception work and
|
|
596
662
|
// keeping clientCount misleadingly high. Set.delete during
|
|
597
663
|
// iteration is safe; the for-of iterator handles it.
|
|
598
|
-
|
|
599
|
-
this.clients.delete(client);
|
|
664
|
+
this._dropClient(client);
|
|
600
665
|
}
|
|
601
666
|
}
|
|
602
667
|
}
|
|
@@ -606,5 +671,6 @@ module.exports = {
|
|
|
606
671
|
WebDashboardServer,
|
|
607
672
|
DEFAULT_WEB_PORT,
|
|
608
673
|
STATE_PUSH_INTERVAL,
|
|
674
|
+
MAX_STALLED_PUSHES,
|
|
609
675
|
ALLOWED_ACTIONS,
|
|
610
676
|
};
|
package/src/ui/ansi.js
CHANGED
|
@@ -358,12 +358,106 @@ function sanitizeForRender(str) {
|
|
|
358
358
|
}
|
|
359
359
|
|
|
360
360
|
/**
|
|
361
|
-
*
|
|
362
|
-
*
|
|
363
|
-
*
|
|
361
|
+
* Display width of a single Unicode code point in terminal columns.
|
|
362
|
+
* Returns 0 for combining marks / variation selectors / ZWJ, 2 for East
|
|
363
|
+
* Asian Wide / Fullwidth and emoji-presentation code points, 1 otherwise.
|
|
364
|
+
* Used by visibleLength's grapheme iterator.
|
|
365
|
+
* @param {number} cp
|
|
366
|
+
* @returns {number}
|
|
367
|
+
* @private
|
|
368
|
+
*/
|
|
369
|
+
function _codePointWidth(cp) {
|
|
370
|
+
if (cp == null) return 0;
|
|
371
|
+
// C0 / DEL — defensively zero-width (stripAnsi already drops them).
|
|
372
|
+
if (cp < 0x20 || cp === 0x7f) return 0;
|
|
373
|
+
|
|
374
|
+
// Combining marks, variation selectors, ZWJ — zero-width.
|
|
375
|
+
if (
|
|
376
|
+
(cp >= 0x0300 && cp <= 0x036F) || // Combining Diacritical Marks
|
|
377
|
+
(cp >= 0x1AB0 && cp <= 0x1AFF) || // Combining Diacritical Marks Extended
|
|
378
|
+
(cp >= 0x1DC0 && cp <= 0x1DFF) || // Combining Diacritical Marks Supplement
|
|
379
|
+
(cp >= 0x20D0 && cp <= 0x20FF) || // Combining Diacritical Marks for Symbols
|
|
380
|
+
(cp >= 0xFE00 && cp <= 0xFE0F) || // Variation Selectors (incl. emoji presentation)
|
|
381
|
+
(cp >= 0xFE20 && cp <= 0xFE2F) || // Combining Half Marks
|
|
382
|
+
cp === 0x200D // ZWJ
|
|
383
|
+
) return 0;
|
|
384
|
+
|
|
385
|
+
// East Asian Wide / Fullwidth / emoji — two columns.
|
|
386
|
+
if (
|
|
387
|
+
(cp >= 0x1100 && cp <= 0x115F) || // Hangul Jamo
|
|
388
|
+
(cp >= 0x2E80 && cp <= 0x303E) || // CJK Radicals etc
|
|
389
|
+
(cp >= 0x3041 && cp <= 0x33FF) || // CJK Symbols / Hiragana / Katakana / Bopomofo / Hangul / CJK
|
|
390
|
+
(cp >= 0x3400 && cp <= 0x4DBF) || // CJK Unified Ideographs Ext A
|
|
391
|
+
(cp >= 0x4E00 && cp <= 0x9FFF) || // CJK Unified Ideographs
|
|
392
|
+
(cp >= 0xA000 && cp <= 0xA4CF) || // Yi Syllables
|
|
393
|
+
(cp >= 0xAC00 && cp <= 0xD7A3) || // Hangul Syllables
|
|
394
|
+
(cp >= 0xF900 && cp <= 0xFAFF) || // CJK Compatibility Ideographs
|
|
395
|
+
(cp >= 0xFE30 && cp <= 0xFE4F) || // CJK Compatibility Forms
|
|
396
|
+
(cp >= 0xFF00 && cp <= 0xFF60) || // Fullwidth Forms
|
|
397
|
+
(cp >= 0xFFE0 && cp <= 0xFFE6) || // Fullwidth Signs
|
|
398
|
+
(cp >= 0x1F300 && cp <= 0x1F5FF) || // Misc Symbols and Pictographs
|
|
399
|
+
(cp >= 0x1F600 && cp <= 0x1F64F) || // Emoticons
|
|
400
|
+
(cp >= 0x1F680 && cp <= 0x1F6FF) || // Transport
|
|
401
|
+
(cp >= 0x1F900 && cp <= 0x1F9FF) || // Supplemental Symbols and Pictographs
|
|
402
|
+
(cp >= 0x1FA00 && cp <= 0x1FAFF) || // Symbols and Pictographs Extended-A
|
|
403
|
+
(cp >= 0x1F1E6 && cp <= 0x1F1FF) || // Regional indicators (flags)
|
|
404
|
+
(cp >= 0x1F3FB && cp <= 0x1F3FF) || // Emoji modifiers (skin tone)
|
|
405
|
+
(cp >= 0x20000 && cp <= 0x2FFFD) || // CJK Ideographs Extension B-F
|
|
406
|
+
(cp >= 0x30000 && cp <= 0x3FFFD) // CJK Ideographs Extension G-H
|
|
407
|
+
) return 2;
|
|
408
|
+
|
|
409
|
+
return 1;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Reused grapheme segmenter — instantiating per-call would be wasteful for
|
|
413
|
+
// strings hit on every render. Default locale is fine; we only care about
|
|
414
|
+
// boundaries, not script-aware ordering.
|
|
415
|
+
const _graphemeSegmenter = (typeof Intl !== 'undefined' && Intl.Segmenter)
|
|
416
|
+
? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
|
|
417
|
+
: null;
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Get the visible width of a string in terminal columns, excluding ANSI
|
|
421
|
+
* codes and counting wide characters (CJK ideographs, emoji, fullwidth)
|
|
422
|
+
* as 2 columns. Combining marks, variation selectors, and ZWJ are 0-width.
|
|
423
|
+
* Grapheme clusters that contain at least one wide code point clamp to 2
|
|
424
|
+
* — so a ZWJ family (e.g. 👨👩👧, which is 5 code points = 13 UTF-16 units)
|
|
425
|
+
* counts as 2, matching how terminals render it.
|
|
426
|
+
*
|
|
427
|
+
* Without this, branch names with emoji/CJK misalign sparklines, badges
|
|
428
|
+
* and box borders because the renderer's `padRight`/truncation math drifts
|
|
429
|
+
* by one or more columns per non-ASCII character.
|
|
430
|
+
*
|
|
431
|
+
* @param {string} str - String potentially containing ANSI codes and unicode
|
|
432
|
+
* @returns {number} Visible width in terminal columns
|
|
364
433
|
*/
|
|
365
434
|
function visibleLength(str) {
|
|
366
|
-
|
|
435
|
+
const stripped = stripAnsi(str);
|
|
436
|
+
// ASCII fast path — every char is 1 column, regex avoids the Segmenter
|
|
437
|
+
// setup cost on the renderer's hot path.
|
|
438
|
+
if (/^[\x20-\x7e]*$/.test(stripped)) return stripped.length;
|
|
439
|
+
if (!_graphemeSegmenter) {
|
|
440
|
+
// Pre-Intl.Segmenter fallback (shouldn't hit on Node 16+).
|
|
441
|
+
let width = 0;
|
|
442
|
+
for (const ch of stripped) width += _codePointWidth(ch.codePointAt(0));
|
|
443
|
+
return width;
|
|
444
|
+
}
|
|
445
|
+
let total = 0;
|
|
446
|
+
for (const { segment } of _graphemeSegmenter.segment(stripped)) {
|
|
447
|
+
let clusterTotal = 0;
|
|
448
|
+
let clusterHasWide = false;
|
|
449
|
+
for (const ch of segment) {
|
|
450
|
+
const w = _codePointWidth(ch.codePointAt(0));
|
|
451
|
+
if (w === 2) clusterHasWide = true;
|
|
452
|
+
clusterTotal += w;
|
|
453
|
+
}
|
|
454
|
+
// ZWJ-joined emoji clusters (e.g. family / flag / skin-tone) sum to
|
|
455
|
+
// more than 2 by code point, but render as a single 2-column glyph.
|
|
456
|
+
// Cap them at 2; non-wide clusters (combining marks on a base char)
|
|
457
|
+
// still take at least 1 column.
|
|
458
|
+
total += clusterHasWide ? 2 : Math.max(1, clusterTotal);
|
|
459
|
+
}
|
|
460
|
+
return total;
|
|
367
461
|
}
|
|
368
462
|
|
|
369
463
|
/**
|
|
@@ -382,14 +476,39 @@ function truncate(str, maxLen, suffix = '…') {
|
|
|
382
476
|
// raw input with cursor/screen-control sequences in it. SGR survives.
|
|
383
477
|
const safeStr = sanitizeForRender(str);
|
|
384
478
|
const visible = stripAnsi(safeStr);
|
|
385
|
-
|
|
479
|
+
|
|
480
|
+
// Compare in display columns, not UTF-16 code units, so a string of CJK
|
|
481
|
+
// ideographs (e.g. "中文测试" — 4 code units, 8 columns) is correctly
|
|
482
|
+
// recognised as needing truncation in a 4-column slot.
|
|
483
|
+
if (visibleLength(visible) <= maxLen) {
|
|
386
484
|
return safeStr;
|
|
387
485
|
}
|
|
388
486
|
|
|
389
|
-
// Long-string path: drop ALL escapes (including SGR),
|
|
390
|
-
//
|
|
391
|
-
|
|
392
|
-
|
|
487
|
+
// Long-string path: drop ALL escapes (including SGR), accumulate
|
|
488
|
+
// graphemes up to the column budget, append ellipsis + reset. Walking
|
|
489
|
+
// graphemes (not code units) keeps wide characters from being split
|
|
490
|
+
// mid-glyph and keeps the truncated width <= maxLen even for emoji
|
|
491
|
+
// and CJK input.
|
|
492
|
+
const suffixWidth = visibleLength(suffix);
|
|
493
|
+
const budget = Math.max(0, maxLen - suffixWidth);
|
|
494
|
+
let acc = '';
|
|
495
|
+
let used = 0;
|
|
496
|
+
if (_graphemeSegmenter) {
|
|
497
|
+
for (const { segment } of _graphemeSegmenter.segment(visible)) {
|
|
498
|
+
const w = visibleLength(segment);
|
|
499
|
+
if (used + w > budget) break;
|
|
500
|
+
acc += segment;
|
|
501
|
+
used += w;
|
|
502
|
+
}
|
|
503
|
+
} else {
|
|
504
|
+
for (const ch of visible) {
|
|
505
|
+
const w = _codePointWidth(ch.codePointAt(0));
|
|
506
|
+
if (used + w > budget) break;
|
|
507
|
+
acc += ch;
|
|
508
|
+
used += w;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
return acc + suffix + ansi.reset;
|
|
393
512
|
}
|
|
394
513
|
|
|
395
514
|
/**
|