teemux 1.4.0 → 1.6.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/dist/teemux.js CHANGED
@@ -177,10 +177,14 @@ var LogServer = class {
177
177
  port;
178
178
  server = null;
179
179
  tailSize;
180
- constructor(port, tailSize = 1e3) {
180
+ constructor(port, tailSize = 1e4) {
181
181
  this.port = port;
182
182
  this.tailSize = tailSize;
183
183
  }
184
+ clearLogs() {
185
+ this.buffer = [];
186
+ for (const client of this.clients) if (client.isBrowser) client.response.write(`<script>clearLogs()<\/script>\n`);
187
+ }
184
188
  getPort() {
185
189
  if (this.server) {
186
190
  const address = this.server.address();
@@ -191,6 +195,33 @@ var LogServer = class {
191
195
  start() {
192
196
  return new Promise((resolve, reject) => {
193
197
  this.server = http.createServer((request, response) => {
198
+ if (request.method === "GET" && request.url?.startsWith("/search")) {
199
+ const url = new URL(request.url, `http://${request.headers.host}`);
200
+ const includeParameter = url.searchParams.get("include");
201
+ const includes = includeParameter ? includeParameter.split(",").map((term) => term.trim()).filter(Boolean) : [];
202
+ const excludeParameter = url.searchParams.get("exclude");
203
+ const excludes = excludeParameter ? excludeParameter.split(",").map((pattern) => pattern.trim()).filter(Boolean) : [];
204
+ const limit = Math.min(Number.parseInt(url.searchParams.get("limit") ?? "1000", 10), 1e3);
205
+ const sortedBuffer = this.buffer.toSorted((a, b) => a.timestamp - b.timestamp);
206
+ const results = [];
207
+ for (const entry of sortedBuffer) if (matchesFilters(entry.line, includes, excludes)) {
208
+ let html = this.ansiConverter.toHtml(entry.line);
209
+ html = highlightJson(html);
210
+ html = linkifyUrls(html);
211
+ results.push({
212
+ html,
213
+ raw: stripAnsi(entry.line)
214
+ });
215
+ if (results.length >= limit) break;
216
+ }
217
+ response.writeHead(200, {
218
+ "Access-Control-Allow-Origin": "*",
219
+ "Cache-Control": "no-cache",
220
+ "Content-Type": "application/json; charset=utf-8"
221
+ });
222
+ response.end(JSON.stringify(results));
223
+ return;
224
+ }
194
225
  if (request.method === "GET" && request.url?.startsWith("/")) {
195
226
  const url = new URL(request.url, `http://${request.headers.host}`);
196
227
  const includeParameter = url.searchParams.get("include");
@@ -207,7 +238,8 @@ var LogServer = class {
207
238
  "X-Content-Type-Options": "nosniff"
208
239
  });
209
240
  response.write(this.getHtmlHeader());
210
- for (const entry of sortedBuffer) response.write(this.getHtmlLine(entry.line));
241
+ const initialLogs = sortedBuffer.slice(-1e3);
242
+ for (const entry of initialLogs) response.write(this.getHtmlLine(entry.line));
211
243
  } else {
212
244
  const filteredBuffer = sortedBuffer.filter((entry) => matchesFilters(entry.line, includes, excludes));
213
245
  response.writeHead(200, {
@@ -260,6 +292,10 @@ var LogServer = class {
260
292
  } catch {}
261
293
  response.writeHead(200);
262
294
  response.end();
295
+ } else if (request.method === "POST" && request.url === "/clear") {
296
+ this.clearLogs();
297
+ response.writeHead(200);
298
+ response.end();
263
299
  } else {
264
300
  response.writeHead(200);
265
301
  response.end();
@@ -427,6 +463,35 @@ var LogServer = class {
427
463
  #tail-btn svg {
428
464
  flex-shrink: 0;
429
465
  }
466
+ #clear-btn {
467
+ margin-left: auto;
468
+ background: transparent;
469
+ color: #888;
470
+ border: 1px solid #3c3c3c;
471
+ border-radius: 4px;
472
+ padding: 4px 10px;
473
+ font-family: inherit;
474
+ font-size: 12px;
475
+ cursor: pointer;
476
+ display: flex;
477
+ align-items: center;
478
+ gap: 5px;
479
+ transition: all 0.15s;
480
+ }
481
+ #clear-btn:hover {
482
+ background: #3c3c3c;
483
+ color: #d4d4d4;
484
+ border-color: #505050;
485
+ }
486
+ #clear-btn svg {
487
+ flex-shrink: 0;
488
+ }
489
+ #clear-btn.active {
490
+ background: #264f78;
491
+ border-color: #007acc;
492
+ color: #fff;
493
+ box-shadow: 0 0 0 2px rgba(0, 122, 204, 0.3);
494
+ }
430
495
  </style>
431
496
  </head>
432
497
  <body>
@@ -434,6 +499,10 @@ var LogServer = class {
434
499
  <label>Include: <input type="text" id="include" placeholder="error*,warn* (OR, * = wildcard)"></label>
435
500
  <label>Exclude: <input type="text" id="exclude" placeholder="health*,debug (OR, * = wildcard)"></label>
436
501
  <label>Highlight: <input type="text" id="highlight" placeholder="term1,term2"></label>
502
+ <button id="clear-btn" title="Clear all logs (Cmd+K)">
503
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>
504
+ Clear
505
+ </button>
437
506
  </div>
438
507
  <div id="container"></div>
439
508
  <button id="tail-btn" title="Jump to bottom and follow new logs">
@@ -446,8 +515,9 @@ var LogServer = class {
446
515
  const excludeInput = document.getElementById('exclude');
447
516
  const highlightInput = document.getElementById('highlight');
448
517
  const tailBtn = document.getElementById('tail-btn');
518
+ const clearBtn = document.getElementById('clear-btn');
449
519
  const params = new URLSearchParams(window.location.search);
450
- const tailSize = ${this.tailSize};
520
+ const tailSize = Math.min(${this.tailSize}, 1000);
451
521
 
452
522
  includeInput.value = params.get('include') || '';
453
523
  excludeInput.value = params.get('exclude') || '';
@@ -504,7 +574,7 @@ var LogServer = class {
504
574
  return result;
505
575
  };
506
576
 
507
- const applyFilters = () => {
577
+ const applyFiltersLocal = () => {
508
578
  const includes = includeInput.value.split(',').map(s => s.trim()).filter(Boolean);
509
579
  const excludes = excludeInput.value.split(',').map(s => s.trim()).filter(Boolean);
510
580
  const highlights = highlightInput.value.split(',').map(s => s.trim()).filter(Boolean);
@@ -525,6 +595,15 @@ var LogServer = class {
525
595
  contentEl.innerHTML = html;
526
596
  }
527
597
  });
598
+ };
599
+
600
+ let lastSearchQuery = '';
601
+ let searchController = null;
602
+
603
+ const applyFilters = async () => {
604
+ const includes = includeInput.value.split(',').map(s => s.trim()).filter(Boolean);
605
+ const excludes = excludeInput.value.split(',').map(s => s.trim()).filter(Boolean);
606
+ const highlights = highlightInput.value.split(',').map(s => s.trim()).filter(Boolean);
528
607
 
529
608
  // Update URL without reload
530
609
  const newParams = new URLSearchParams();
@@ -534,10 +613,93 @@ var LogServer = class {
534
613
  const newUrl = newParams.toString() ? '?' + newParams.toString() : window.location.pathname;
535
614
  history.replaceState(null, '', newUrl);
536
615
 
537
- // Jump to bottom and resume tailing after filter change
538
- container.scrollTop = container.scrollHeight;
539
- tailing = true;
540
- updateTailButton();
616
+ // Build search query string for comparison
617
+ const searchQuery = includeInput.value + '|' + excludeInput.value;
618
+
619
+ // If only highlight changed, just re-apply local highlighting
620
+ if (searchQuery === lastSearchQuery) {
621
+ applyFiltersLocal();
622
+ return;
623
+ }
624
+
625
+ lastSearchQuery = searchQuery;
626
+
627
+ // Cancel any pending search request
628
+ if (searchController) {
629
+ searchController.abort();
630
+ }
631
+
632
+ // If no filters, just apply local filtering (show all)
633
+ if (includes.length === 0 && excludes.length === 0) {
634
+ applyFiltersLocal();
635
+ container.scrollTop = container.scrollHeight;
636
+ tailing = true;
637
+ updateTailButton();
638
+ return;
639
+ }
640
+
641
+ // Fetch matching logs from server
642
+ searchController = new AbortController();
643
+ const searchParams = new URLSearchParams();
644
+ if (includeInput.value) searchParams.set('include', includeInput.value);
645
+ if (excludeInput.value) searchParams.set('exclude', excludeInput.value);
646
+ searchParams.set('limit', '1000');
647
+
648
+ try {
649
+ const response = await fetch('/search?' + searchParams.toString(), {
650
+ signal: searchController.signal
651
+ });
652
+ const results = await response.json();
653
+
654
+ // Clear non-pinned lines
655
+ document.querySelectorAll('.line').forEach(line => {
656
+ if (!pinnedIds.has(line.dataset.id)) {
657
+ line.remove();
658
+ }
659
+ });
660
+
661
+ // Add search results
662
+ for (const item of results) {
663
+ const id = 'line-' + (lineCounter++);
664
+ const div = document.createElement('div');
665
+ div.className = 'line';
666
+ div.dataset.id = id;
667
+ div.dataset.raw = item.raw;
668
+ div.dataset.html = item.html;
669
+
670
+ let displayHtml = item.html;
671
+ displayHtml = highlightTerms(displayHtml, includes, 'filter');
672
+ displayHtml = highlightTerms(displayHtml, highlights);
673
+
674
+ div.innerHTML = '<span class="line-content">' + displayHtml + '</span><span class="pin-btn" title="Pin">' + pinIcon + '</span>';
675
+
676
+ // Pin button handler
677
+ div.querySelector('.pin-btn').addEventListener('click', (e) => {
678
+ e.stopPropagation();
679
+ if (pinnedIds.has(id)) {
680
+ pinnedIds.delete(id);
681
+ div.classList.remove('pinned');
682
+ } else {
683
+ pinnedIds.add(id);
684
+ div.classList.add('pinned');
685
+ }
686
+ applyFiltersLocal();
687
+ });
688
+
689
+ container.appendChild(div);
690
+ }
691
+
692
+ // Jump to bottom and resume tailing
693
+ container.scrollTop = container.scrollHeight;
694
+ tailing = true;
695
+ updateTailButton();
696
+ } catch (e) {
697
+ if (e.name !== 'AbortError') {
698
+ console.error('Search failed:', e);
699
+ // Fallback to local filtering
700
+ applyFiltersLocal();
701
+ }
702
+ }
541
703
  };
542
704
 
543
705
  const trimBuffer = () => {
@@ -550,6 +712,17 @@ var LogServer = class {
550
712
  }
551
713
  }
552
714
  };
715
+
716
+ const clearLogs = () => {
717
+ // Remove all log lines from the DOM
718
+ container.innerHTML = '';
719
+ // Reset pinned IDs
720
+ pinnedIds.clear();
721
+ // Reset line counter
722
+ lineCounter = 0;
723
+ // Reset search state
724
+ lastSearchQuery = '';
725
+ };
553
726
 
554
727
  let lineCounter = 0;
555
728
  const addLine = (html, raw) => {
@@ -580,7 +753,7 @@ var LogServer = class {
580
753
  pinnedIds.add(id);
581
754
  div.classList.add('pinned');
582
755
  }
583
- applyFilters();
756
+ applyFiltersLocal();
584
757
  });
585
758
 
586
759
  const matches = matchesFilters(raw, includes, excludes);
@@ -602,16 +775,32 @@ var LogServer = class {
602
775
  tailing = true;
603
776
  updateTailButton();
604
777
  });
605
-
778
+
779
+ const triggerClear = () => {
780
+ clearBtn.classList.add('active');
781
+ fetch('/clear', { method: 'POST' });
782
+ setTimeout(() => clearBtn.classList.remove('active'), 150);
783
+ };
784
+
785
+ clearBtn.addEventListener('click', triggerClear);
786
+
787
+ // Cmd+K (Mac) or Ctrl+K (Windows/Linux) to clear logs
788
+ document.addEventListener('keydown', (e) => {
789
+ if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
790
+ e.preventDefault();
791
+ triggerClear();
792
+ }
793
+ });
794
+
606
795
  let debounceTimer;
607
796
  const debounce = (fn, delay) => {
608
797
  clearTimeout(debounceTimer);
609
798
  debounceTimer = setTimeout(fn, delay);
610
799
  };
611
800
 
612
- includeInput.addEventListener('input', () => debounce(applyFilters, 50));
613
- excludeInput.addEventListener('input', () => debounce(applyFilters, 50));
614
- highlightInput.addEventListener('input', () => debounce(applyFilters, 50));
801
+ includeInput.addEventListener('input', () => debounce(applyFilters, 300));
802
+ excludeInput.addEventListener('input', () => debounce(applyFilters, 300));
803
+ highlightInput.addEventListener('input', () => debounce(applyFilters, 150));
615
804
  <\/script>
616
805
  `;
617
806
  }
@@ -619,7 +808,7 @@ var LogServer = class {
619
808
  let html = this.ansiConverter.toHtml(line);
620
809
  html = highlightJson(html);
621
810
  html = linkifyUrls(html);
622
- return `<script>addLine('${html.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}', '${stripAnsi(line).replaceAll("\\", "\\\\").replaceAll("'", "\\'")}')<\/script>\n`;
811
+ return `<script>addLine('${html.replaceAll("\\", "\\\\").replaceAll("'", "\\'").replaceAll("\n", "\\n").replaceAll("\r", "\\r")}', '${stripAnsi(line).replaceAll("\\", "\\\\").replaceAll("'", "\\'").replaceAll("\n", "\\n").replaceAll("\r", "\\r")}')<\/script>\n`;
623
812
  }
624
813
  sendToClients(forWeb, timestamp) {
625
814
  this.buffer.push({
@@ -838,10 +1027,10 @@ const main = async () => {
838
1027
  default: 8336,
839
1028
  description: "Port for the log aggregation server",
840
1029
  type: "number"
841
- }).option("tail", {
842
- alias: "t",
843
- default: 1e3,
844
- description: "Number of log lines to keep in buffer",
1030
+ }).option("buffer", {
1031
+ alias: "b",
1032
+ default: 1e4,
1033
+ description: "Number of log lines to keep in server buffer",
845
1034
  type: "number"
846
1035
  }).help().parse();
847
1036
  const command = argv._;
@@ -852,7 +1041,7 @@ const main = async () => {
852
1041
  }
853
1042
  const name = argv.name ?? command[0] ?? "unknown";
854
1043
  const port = argv.port;
855
- const server = new LogServer(port, argv.tail);
1044
+ const server = new LogServer(port, argv.buffer);
856
1045
  let isServer = false;
857
1046
  const maxRetries = 3;
858
1047
  for (let attempt = 0; attempt < maxRetries; attempt++) try {
@@ -1 +1 @@
1
- {"version":3,"file":"teemux.js","names":["RESET","DIM","RED"],"sources":["../src/utils/stripHtmlTags.ts","../src/utils/unescapeHtml.ts","../src/utils/highlightJson.ts","../src/utils/linkifyUrls.ts","../src/utils/stripAnsi.ts","../src/utils/matchesFilters.ts","../src/LogServer.ts","../src/teemux.ts"],"sourcesContent":["/**\n * Strip HTML tags from a string, leaving only text content.\n */\nexport const stripHtmlTags = (html: string): string => {\n return html.replaceAll(/<[^>]*>/gu, '');\n};\n","/**\n * Unescape HTML entities back to their original characters.\n */\nexport const unescapeHtml = (text: string): string => {\n return text\n .replaceAll('&quot;', '\"')\n .replaceAll('&amp;', '&')\n .replaceAll('&lt;', '<')\n .replaceAll('&gt;', '>')\n .replaceAll('&#x27;', \"'\")\n .replaceAll('&#39;', \"'\");\n};\n","import { stripHtmlTags } from './stripHtmlTags.js';\nimport { unescapeHtml } from './unescapeHtml.js';\n\n/**\n * Apply syntax highlighting to JSON text that uses HTML-escaped quotes (&quot;).\n * Uses placeholder technique to avoid double-wrapping strings.\n */\nexport const highlightJsonText = (text: string): string => {\n // First, extract and mark all JSON strings with placeholders\n const strings: string[] = [];\n let result = text.replaceAll(\n /&quot;((?:(?!&quot;).)*)&quot;/gu,\n (_match, content) => {\n strings.push(content as string);\n return `\\u0000STR${strings.length - 1}\\u0000`;\n },\n );\n\n // Booleans and null\n result = result.replaceAll(\n /\\b(true|false|null)\\b/gu,\n '<span class=\"json-bool\">$1</span>',\n );\n\n // Numbers\n result = result.replaceAll(\n /(?<!\\w)(-?\\d+(?:\\.\\d+)?(?:[eE][+-]?\\d+)?)\\b/gu,\n '<span class=\"json-number\">$1</span>',\n );\n\n // Restore strings with appropriate highlighting\n result = result.replaceAll(\n /\\0STR(\\d+)\\0(\\s*:)?/gu,\n (_match, index, colon) => {\n const content = strings[Number.parseInt(index as string, 10)];\n if (colon) {\n // This is a key\n return `<span class=\"json-key\">&quot;${content}&quot;</span>${colon}`;\n }\n\n // This is a value\n return `<span class=\"json-string\">&quot;${content}&quot;</span>`;\n },\n );\n\n return result;\n};\n\n/**\n * Process HTML text, applying JSON highlighting only to text outside of HTML tags.\n */\nexport const syntaxHighlightJson = (html: string): string => {\n let result = '';\n let index = 0;\n\n while (index < html.length) {\n if (html[index] === '<') {\n // Find end of tag\n const tagEnd = html.indexOf('>', index);\n if (tagEnd === -1) {\n result += html.slice(index);\n break;\n }\n\n result += html.slice(index, tagEnd + 1);\n index = tagEnd + 1;\n } else {\n // Find next tag or end of string\n const nextTag = html.indexOf('<', index);\n const textEnd = nextTag === -1 ? html.length : nextTag;\n const text = html.slice(index, textEnd);\n\n // Highlight JSON syntax in this text segment\n result += highlightJsonText(text);\n index = textEnd;\n }\n }\n\n return result;\n};\n\n/**\n * Detect if the content (after prefix) is valid JSON and apply syntax highlighting.\n * Returns the original HTML if not valid JSON.\n */\nexport const highlightJson = (html: string): string => {\n // Extract the text content (strip HTML tags) to check if it's JSON\n const textContent = stripHtmlTags(html);\n\n // Unescape HTML entities for JSON parsing\n const unescaped = unescapeHtml(textContent);\n\n // Find where the actual log content starts (after the prefix like [name])\n const prefixMatch = /^\\[[\\w-]+\\]\\s*/u.exec(unescaped);\n const prefix = prefixMatch?.[0] ?? '';\n const content = unescaped.slice(prefix.length).trim();\n\n // Check if the content is valid JSON\n if (!content.startsWith('{') && !content.startsWith('[')) {\n return html;\n }\n\n try {\n JSON.parse(content);\n } catch {\n return html;\n }\n\n // It's valid JSON - now highlight it\n // Find the position after the prefix span in the HTML\n const prefixHtmlMatch = /^<span[^>]*>\\[[^\\]]+\\]<\\/span>\\s*/u.exec(html);\n const htmlPrefix = prefixHtmlMatch?.[0] ?? '';\n const jsonHtml = html.slice(htmlPrefix.length);\n\n // Apply syntax highlighting to the JSON portion\n const highlighted = syntaxHighlightJson(jsonHtml);\n\n return htmlPrefix + highlighted;\n};\n","/**\n * Convert URLs in HTML text to clickable anchor tags.\n * Supports http://, https://, and file:// URLs.\n * Avoids double-linking URLs that are already in href attributes.\n */\nexport const linkifyUrls = (html: string): string => {\n // Match URLs that are not already inside href attributes\n // Supports http://, https://, and file:// URLs\n // Exclude common delimiters and HTML entities (&quot; &amp; etc)\n const urlRegex = /(?<!href=[\"'])(?:https?|file):\\/\\/[^\\s<>\"'{}&]+/gu;\n\n return html.replaceAll(urlRegex, (url) => {\n // Remove trailing punctuation that's likely not part of the URL\n const cleanUrl = url.replace(/[.,;:!?)\\]]+$/u, '');\n const trailing = url.slice(cleanUrl.length);\n\n // Escape HTML entities in the URL for the href attribute\n const escapedHref = cleanUrl\n .replaceAll('&', '&amp;')\n .replaceAll('\"', '&quot;');\n\n return `<a href=\"${escapedHref}\" target=\"_blank\" rel=\"noopener\">${cleanUrl}</a>${trailing}`;\n });\n};\n","/**\n * Strip ANSI escape codes from text.\n * Removes color codes and other terminal formatting sequences.\n */\nexport const stripAnsi = (text: string): string => {\n // eslint-disable-next-line no-control-regex\n return text.replaceAll(/\\u001B\\[[\\d;]*m/gu, '');\n};\n","import { stripAnsi } from './stripAnsi.js';\n\n/**\n * Convert a glob pattern (with * wildcards) to a RegExp.\n * - `*` matches any characters (zero or more)\n * - All other characters are escaped for literal matching\n */\nconst globToRegex = (pattern: string): RegExp => {\n // Escape regex special characters except *\n const escaped = pattern.replaceAll(/[$()+.?[\\\\\\]^{|}]/gu, '\\\\$&');\n // Convert * to .*\n const regexPattern = escaped.replaceAll('*', '.*');\n return new RegExp(regexPattern, 'iu');\n};\n\n/**\n * Check if text matches a pattern (supports * glob wildcards).\n * If no wildcards, does a simple substring match for better performance.\n */\nconst matchesPattern = (text: string, pattern: string): boolean => {\n if (pattern.includes('*')) {\n return globToRegex(pattern).test(text);\n }\n\n return text.includes(pattern.toLowerCase());\n};\n\n/**\n * Check if a line matches the given filter criteria.\n * @param line - The line to check (may contain ANSI codes)\n * @param includes - Patterns where ANY match includes the line (OR logic), case-insensitive. Supports * wildcards.\n * @param excludes - Patterns where ANY match excludes the line (OR logic), case-insensitive. Supports * wildcards.\n * @returns true if the line should be included, false if filtered out\n */\nexport const matchesFilters = (\n line: string,\n includes: string[],\n excludes: string[],\n): boolean => {\n const plainText = stripAnsi(line).toLowerCase();\n\n // Any include must match (OR logic) - case insensitive\n if (includes.length > 0) {\n const anyIncludeMatches = includes.some((pattern) =>\n matchesPattern(plainText, pattern),\n );\n\n if (!anyIncludeMatches) {\n return false;\n }\n }\n\n // None of the excludes should match (OR logic for exclusion) - case insensitive\n if (excludes.length > 0) {\n const anyExcludeMatches = excludes.some((pattern) =>\n matchesPattern(plainText, pattern),\n );\n\n if (anyExcludeMatches) {\n return false;\n }\n }\n\n return true;\n};\n","import { highlightJson } from './utils/highlightJson.js';\nimport { linkifyUrls } from './utils/linkifyUrls.js';\nimport { matchesFilters } from './utils/matchesFilters.js';\nimport { stripAnsi } from './utils/stripAnsi.js';\nimport Convert from 'ansi-to-html';\nimport http from 'node:http';\nimport { performance } from 'node:perf_hooks';\nimport { URL } from 'node:url';\n\nconst COLORS = [\n '\\u001B[36m',\n '\\u001B[33m',\n '\\u001B[32m',\n '\\u001B[35m',\n '\\u001B[34m',\n '\\u001B[91m',\n '\\u001B[92m',\n '\\u001B[93m',\n];\nconst RESET = '\\u001B[0m';\nconst DIM = '\\u001B[90m';\nconst RED = '\\u001B[91m';\nconst HOST = '0.0.0.0';\n\ntype BufferedLog = {\n line: string;\n timestamp: number;\n};\n\ntype EventPayload = {\n code?: number;\n event: 'exit' | 'start';\n name: string;\n pid: number;\n timestamp: number;\n};\n\ntype LogPayload = {\n line: string;\n name: string;\n timestamp: number;\n type: LogType;\n};\n\ntype LogType = 'stderr' | 'stdout';\n\ntype StreamClient = {\n excludes: string[];\n includes: string[];\n isBrowser: boolean;\n response: http.ServerResponse;\n};\n\nexport class LogServer {\n private ansiConverter = new Convert({ escapeXML: true, newline: true });\n\n private buffer: BufferedLog[] = [];\n\n private clients = new Set<StreamClient>();\n\n private colorIndex = 0;\n\n private colorMap = new Map<string, string>();\n\n private port: number;\n\n private server: http.Server | null = null;\n\n private tailSize: number;\n\n constructor(port: number, tailSize: number = 1_000) {\n this.port = port;\n this.tailSize = tailSize;\n }\n\n getPort(): number {\n if (this.server) {\n const address = this.server.address();\n if (address && typeof address === 'object') {\n return address.port;\n }\n }\n\n return this.port;\n }\n\n start(): Promise<void> {\n return new Promise((resolve, reject) => {\n this.server = http.createServer((request, response) => {\n // Handle streaming GET request\n if (request.method === 'GET' && request.url?.startsWith('/')) {\n const url = new URL(request.url, `http://${request.headers.host}`);\n const includeParameter = url.searchParams.get('include');\n const includes = includeParameter\n ? includeParameter\n .split(',')\n .map((term) => term.trim())\n .filter(Boolean)\n : [];\n const excludeParameter = url.searchParams.get('exclude');\n const excludes = excludeParameter\n ? excludeParameter\n .split(',')\n .map((pattern) => pattern.trim())\n .filter(Boolean)\n : [];\n\n const userAgent = request.headers['user-agent'] ?? '';\n const isBrowser = userAgent.includes('Mozilla');\n\n // Sort buffer by timestamp\n const sortedBuffer = this.buffer.toSorted(\n (a, b) => a.timestamp - b.timestamp,\n );\n\n if (isBrowser) {\n // Browser: send all logs, filtering is done client-side\n response.writeHead(200, {\n 'Cache-Control': 'no-cache',\n Connection: 'keep-alive',\n 'Content-Type': 'text/html; charset=utf-8',\n 'X-Content-Type-Options': 'nosniff',\n });\n\n // Send HTML header with styling\n response.write(this.getHtmlHeader());\n\n // Send all buffered logs as HTML\n for (const entry of sortedBuffer) {\n response.write(this.getHtmlLine(entry.line));\n }\n } else {\n // Non-browser (curl, etc): apply server-side filtering\n const filteredBuffer = sortedBuffer.filter((entry) =>\n matchesFilters(entry.line, includes, excludes),\n );\n\n response.writeHead(200, {\n 'Cache-Control': 'no-cache',\n Connection: 'keep-alive',\n 'Content-Type': 'text/plain; charset=utf-8',\n 'X-Content-Type-Options': 'nosniff',\n });\n\n // Send filtered logs as plain text (strip ANSI)\n for (const entry of filteredBuffer) {\n response.write(stripAnsi(entry.line) + '\\n');\n }\n }\n\n // Add to clients for streaming\n const client: StreamClient = {\n excludes,\n includes,\n isBrowser,\n response,\n };\n\n this.clients.add(client);\n\n request.on('close', () => {\n this.clients.delete(client);\n });\n\n return;\n }\n\n let body = '';\n\n request.on('data', (chunk: Buffer) => {\n body += chunk.toString();\n });\n request.on('end', () => {\n if (request.method === 'POST' && request.url === '/log') {\n try {\n const { line, name, timestamp, type } = JSON.parse(\n body,\n ) as LogPayload;\n\n this.broadcastLog(name, line, type, timestamp);\n } catch {\n // Ignore parse errors\n }\n\n response.writeHead(200);\n response.end();\n } else if (request.method === 'POST' && request.url === '/event') {\n try {\n const { code, event, name, pid, timestamp } = JSON.parse(\n body,\n ) as EventPayload;\n\n if (event === 'start') {\n this.broadcastEvent(name, `● started (pid ${pid})`, timestamp);\n } else if (event === 'exit') {\n this.broadcastEvent(name, `○ exited (code ${code})`, timestamp);\n }\n } catch {\n // Ignore parse errors\n }\n\n response.writeHead(200);\n response.end();\n } else if (request.method === 'POST' && request.url === '/inject') {\n // Test injection endpoint\n try {\n const data = JSON.parse(body) as {\n event?: 'exit' | 'start';\n message: string;\n name: string;\n pid?: number;\n };\n const timestamp = performance.timeOrigin + performance.now();\n\n if (data.event === 'start') {\n this.broadcastEvent(\n data.name,\n `● started (pid ${data.pid ?? 0})`,\n timestamp,\n );\n } else if (data.event === 'exit') {\n this.broadcastEvent(data.name, `○ exited (code 0)`, timestamp);\n } else {\n this.broadcastLog(data.name, data.message, 'stdout', timestamp);\n }\n } catch {\n // Ignore parse errors\n }\n\n response.writeHead(200);\n response.end();\n } else {\n response.writeHead(200);\n response.end();\n }\n });\n });\n\n this.server.once('error', (error: NodeJS.ErrnoException) => {\n reject(error);\n });\n\n this.server.listen(this.port, '0.0.0.0', () => {\n // eslint-disable-next-line no-console\n console.log(\n `${DIM}[teemux] aggregating logs on http://${HOST}:${this.port}${RESET}`,\n );\n resolve();\n });\n });\n }\n\n stop(): Promise<void> {\n return new Promise((resolve) => {\n // Close all client connections\n for (const client of this.clients) {\n client.response.end();\n }\n\n this.clients.clear();\n\n if (this.server) {\n this.server.close(() => {\n this.server = null;\n resolve();\n });\n } else {\n resolve();\n }\n });\n }\n\n private broadcastEvent(\n name: string,\n message: string,\n timestamp: number,\n ): void {\n const color = this.getColor(name);\n const forWeb = `${DIM}${color}[${name}]${RESET} ${DIM}${message}${RESET}`;\n\n this.sendToClients(forWeb, timestamp);\n }\n\n private broadcastLog(\n name: string,\n line: string,\n type: LogType,\n timestamp: number,\n ): void {\n const color = this.getColor(name);\n const errorPrefix = type === 'stderr' ? `${RED}[ERR]${RESET} ` : '';\n const forWeb = `${color}[${name}]${RESET} ${errorPrefix}${line}`;\n\n this.sendToClients(forWeb, timestamp);\n }\n\n private getColor(name: string): string {\n if (!this.colorMap.has(name)) {\n this.colorMap.set(name, COLORS[this.colorIndex++ % COLORS.length]);\n }\n\n return this.colorMap.get(name) ?? COLORS[0];\n }\n\n private getHtmlHeader(): string {\n return `<!DOCTYPE html>\n<html>\n<head>\n <meta charset=\"utf-8\">\n <title>teemux</title>\n <style>\n * { box-sizing: border-box; }\n html, body {\n height: 100%;\n margin: 0;\n overflow: hidden;\n }\n body {\n background: #1e1e1e;\n color: #d4d4d4;\n font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;\n font-size: 12px;\n line-height: 1.3;\n display: flex;\n flex-direction: column;\n }\n #filter-bar {\n flex-shrink: 0;\n display: flex;\n gap: 8px;\n padding: 8px 12px;\n background: #252526;\n border-bottom: 1px solid #3c3c3c;\n }\n #filter-bar label {\n display: flex;\n align-items: center;\n gap: 6px;\n color: #888;\n }\n #filter-bar input {\n background: #1e1e1e;\n border: 1px solid #3c3c3c;\n border-radius: 3px;\n color: #d4d4d4;\n font-family: inherit;\n font-size: 12px;\n padding: 4px 8px;\n width: 200px;\n }\n #filter-bar input:focus {\n outline: none;\n border-color: #007acc;\n }\n #container {\n flex: 1;\n overflow-y: auto;\n padding: 8px 12px;\n }\n .line {\n white-space: pre-wrap;\n word-break: break-all;\n padding: 1px 4px;\n margin: 0 -4px;\n border-radius: 2px;\n position: relative;\n display: flex;\n align-items: flex-start;\n }\n .line:hover {\n background: rgba(255, 255, 255, 0.05);\n }\n .line.pinned {\n background: rgba(255, 204, 0, 0.1);\n border-left: 2px solid #fc0;\n margin-left: -6px;\n padding-left: 6px;\n }\n .line-content {\n flex: 1;\n }\n .pin-btn {\n opacity: 0;\n cursor: pointer;\n padding: 0 4px;\n color: #888;\n flex-shrink: 0;\n transition: opacity 0.15s;\n }\n .line:hover .pin-btn {\n opacity: 0.5;\n }\n .pin-btn:hover {\n opacity: 1 !important;\n color: #fc0;\n }\n .line.pinned .pin-btn {\n opacity: 1;\n color: #fc0;\n }\n a { color: #4fc1ff; text-decoration: underline; }\n a:hover { text-decoration: none; }\n mark { background: #623800; color: inherit; border-radius: 2px; }\n mark.filter { background: #264f00; }\n .json-key { color: #9cdcfe; }\n .json-string { color: #ce9178; }\n .json-number { color: #b5cea8; }\n .json-bool { color: #569cd6; }\n .json-null { color: #569cd6; }\n #tail-btn {\n position: fixed;\n bottom: 20px;\n right: 20px;\n background: #007acc;\n color: #fff;\n border: none;\n border-radius: 4px;\n padding: 8px 16px;\n font-family: inherit;\n font-size: 12px;\n cursor: pointer;\n display: none;\n align-items: center;\n gap: 6px;\n box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);\n transition: background 0.15s;\n }\n #tail-btn:hover {\n background: #0098ff;\n }\n #tail-btn svg {\n flex-shrink: 0;\n }\n </style>\n</head>\n<body>\n <div id=\"filter-bar\">\n <label>Include: <input type=\"text\" id=\"include\" placeholder=\"error*,warn* (OR, * = wildcard)\"></label>\n <label>Exclude: <input type=\"text\" id=\"exclude\" placeholder=\"health*,debug (OR, * = wildcard)\"></label>\n <label>Highlight: <input type=\"text\" id=\"highlight\" placeholder=\"term1,term2\"></label>\n </div>\n <div id=\"container\"></div>\n <button id=\"tail-btn\" title=\"Jump to bottom and follow new logs\">\n <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 5v14\"/><path d=\"m19 12-7 7-7-7\"/></svg>\n Tail\n </button>\n <script>\n const container = document.getElementById('container');\n const includeInput = document.getElementById('include');\n const excludeInput = document.getElementById('exclude');\n const highlightInput = document.getElementById('highlight');\n const tailBtn = document.getElementById('tail-btn');\n const params = new URLSearchParams(window.location.search);\n const tailSize = ${this.tailSize};\n \n includeInput.value = params.get('include') || '';\n excludeInput.value = params.get('exclude') || '';\n highlightInput.value = params.get('highlight') || '';\n \n let tailing = true;\n let pinnedIds = new Set();\n \n const updateTailButton = () => {\n tailBtn.style.display = tailing ? 'none' : 'flex';\n };\n \n // Lucide pin icon SVG\n const pinIcon = '<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 17v5\"/><path d=\"M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z\"/></svg>';\n \n const stripAnsi = (str) => str.replace(/\\\\u001B\\\\[[\\\\d;]*m/g, '');\n \n const globToRegex = (pattern) => {\n const escaped = pattern.replace(/([.+?^\\${}()|[\\\\]\\\\\\\\])/g, '\\\\\\\\$1');\n const regexPattern = escaped.replace(/\\\\*/g, '.*');\n return new RegExp(regexPattern, 'i');\n };\n \n const matchesPattern = (text, pattern) => {\n if (pattern.includes('*')) {\n return globToRegex(pattern).test(text);\n }\n return text.includes(pattern.toLowerCase());\n };\n \n const matchesFilters = (text, includes, excludes) => {\n const plain = stripAnsi(text).toLowerCase();\n if (includes.length > 0) {\n const anyMatch = includes.some(p => matchesPattern(plain, p));\n if (!anyMatch) return false;\n }\n if (excludes.length > 0) {\n const anyMatch = excludes.some(p => matchesPattern(plain, p));\n if (anyMatch) return false;\n }\n return true;\n };\n \n const highlightTerms = (html, terms, className = '') => {\n if (!terms.length) return html;\n let result = html;\n for (const term of terms) {\n if (!term) continue;\n const escaped = term.replace(/([.*+?^\\${}()|[\\\\]\\\\\\\\])/g, '\\\\\\\\$1');\n const regex = new RegExp('(?![^<]*>)(' + escaped + ')', 'gi');\n const cls = className ? ' class=\"' + className + '\"' : '';\n result = result.replace(regex, '<mark' + cls + '>$1</mark>');\n }\n return result;\n };\n \n const applyFilters = () => {\n const includes = includeInput.value.split(',').map(s => s.trim()).filter(Boolean);\n const excludes = excludeInput.value.split(',').map(s => s.trim()).filter(Boolean);\n const highlights = highlightInput.value.split(',').map(s => s.trim()).filter(Boolean);\n \n document.querySelectorAll('.line').forEach(line => {\n const id = line.dataset.id;\n const isPinned = pinnedIds.has(id);\n const text = line.dataset.raw;\n const matches = matchesFilters(text, includes, excludes);\n line.style.display = (matches || isPinned) ? '' : 'none';\n \n // Re-apply highlighting\n const contentEl = line.querySelector('.line-content');\n if (contentEl) {\n let html = line.dataset.html;\n html = highlightTerms(html, includes, 'filter');\n html = highlightTerms(html, highlights);\n contentEl.innerHTML = html;\n }\n });\n \n // Update URL without reload\n const newParams = new URLSearchParams();\n if (includeInput.value) newParams.set('include', includeInput.value);\n if (excludeInput.value) newParams.set('exclude', excludeInput.value);\n if (highlightInput.value) newParams.set('highlight', highlightInput.value);\n const newUrl = newParams.toString() ? '?' + newParams.toString() : window.location.pathname;\n history.replaceState(null, '', newUrl);\n \n // Jump to bottom and resume tailing after filter change\n container.scrollTop = container.scrollHeight;\n tailing = true;\n updateTailButton();\n };\n \n const trimBuffer = () => {\n const lines = container.querySelectorAll('.line');\n const unpinnedLines = Array.from(lines).filter(l => !pinnedIds.has(l.dataset.id));\n const excess = unpinnedLines.length - tailSize;\n if (excess > 0) {\n for (let i = 0; i < excess; i++) {\n unpinnedLines[i].remove();\n }\n }\n };\n \n let lineCounter = 0;\n const addLine = (html, raw) => {\n const id = 'line-' + (lineCounter++);\n const includes = includeInput.value.split(',').map(s => s.trim()).filter(Boolean);\n const excludes = excludeInput.value.split(',').map(s => s.trim()).filter(Boolean);\n const highlights = highlightInput.value.split(',').map(s => s.trim()).filter(Boolean);\n \n const div = document.createElement('div');\n div.className = 'line';\n div.dataset.id = id;\n div.dataset.raw = raw;\n div.dataset.html = html;\n \n let displayHtml = html;\n displayHtml = highlightTerms(displayHtml, includes, 'filter');\n displayHtml = highlightTerms(displayHtml, highlights);\n \n div.innerHTML = '<span class=\"line-content\">' + displayHtml + '</span><span class=\"pin-btn\" title=\"Pin\">' + pinIcon + '</span>';\n \n // Pin button handler\n div.querySelector('.pin-btn').addEventListener('click', (e) => {\n e.stopPropagation();\n if (pinnedIds.has(id)) {\n pinnedIds.delete(id);\n div.classList.remove('pinned');\n } else {\n pinnedIds.add(id);\n div.classList.add('pinned');\n }\n applyFilters();\n });\n \n const matches = matchesFilters(raw, includes, excludes);\n div.style.display = matches ? '' : 'none';\n \n container.appendChild(div);\n trimBuffer();\n if (tailing) container.scrollTop = container.scrollHeight;\n };\n \n container.addEventListener('scroll', () => {\n const atBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 50;\n tailing = atBottom;\n updateTailButton();\n });\n \n tailBtn.addEventListener('click', () => {\n container.scrollTop = container.scrollHeight;\n tailing = true;\n updateTailButton();\n });\n \n let debounceTimer;\n const debounce = (fn, delay) => {\n clearTimeout(debounceTimer);\n debounceTimer = setTimeout(fn, delay);\n };\n \n includeInput.addEventListener('input', () => debounce(applyFilters, 50));\n excludeInput.addEventListener('input', () => debounce(applyFilters, 50));\n highlightInput.addEventListener('input', () => debounce(applyFilters, 50));\n </script>\n`;\n }\n\n private getHtmlLine(line: string): string {\n let html = this.ansiConverter.toHtml(line);\n html = highlightJson(html);\n html = linkifyUrls(html);\n const escaped = html.replaceAll('\\\\', '\\\\\\\\').replaceAll(\"'\", \"\\\\'\");\n const raw = stripAnsi(line).replaceAll('\\\\', '\\\\\\\\').replaceAll(\"'\", \"\\\\'\");\n return `<script>addLine('${escaped}', '${raw}')</script>\\n`;\n }\n\n private sendToClients(forWeb: string, timestamp: number): void {\n // Add to buffer\n this.buffer.push({ line: forWeb, timestamp });\n\n // Trim buffer to tail size\n if (this.buffer.length > this.tailSize) {\n this.buffer.shift();\n }\n\n // Send to all connected clients\n for (const client of this.clients) {\n if (client.isBrowser) {\n client.response.write(this.getHtmlLine(forWeb));\n } else {\n // Server-side filtering for non-browser clients\n if (!matchesFilters(forWeb, client.includes, client.excludes)) {\n continue;\n }\n\n client.response.write(stripAnsi(forWeb) + '\\n');\n }\n }\n\n // Note: Each client prints its own logs locally, so server doesn't need to\n }\n}\n","#!/usr/bin/env node\n\nimport { LogServer } from './LogServer.js';\nimport { spawn } from 'node:child_process';\nimport http from 'node:http';\nimport { performance } from 'node:perf_hooks';\nimport readline from 'node:readline';\nimport yargs from 'yargs';\nimport { hideBin } from 'yargs/helpers';\n\n// High-precision timestamp (milliseconds with microsecond precision)\nconst getTimestamp = (): number => performance.timeOrigin + performance.now();\n\nconst RESET = '\\u001B[0m';\nconst DIM = '\\u001B[90m';\nconst RED = '\\u001B[91m';\n\n// Leader monitoring configuration\nconst LEADER_CHECK_INTERVAL = 2_000; // Check every 2 seconds\nconst MAX_PROMOTION_RETRIES = 3;\n\ntype LogType = 'stderr' | 'stdout';\n\nclass LogClient {\n private name: string;\n\n private port: number;\n\n private queue: Array<{ line: string; timestamp: number; type: LogType }> = [];\n\n private sending = false;\n\n constructor(name: string, port: number) {\n this.name = name;\n this.port = port;\n }\n\n async event(\n event: 'exit' | 'start',\n pid: number,\n code?: number,\n ): Promise<void> {\n await this.send('/event', {\n code,\n event,\n name: this.name,\n pid,\n timestamp: getTimestamp(),\n });\n }\n\n async flush(): Promise<void> {\n if (this.sending || this.queue.length === 0) {\n return;\n }\n\n this.sending = true;\n\n while (this.queue.length > 0) {\n const item = this.queue.shift();\n\n if (!item) {\n continue;\n }\n\n const success = await this.send('/log', {\n line: item.line,\n name: this.name,\n timestamp: item.timestamp,\n type: item.type,\n });\n\n if (!success) {\n // Fallback to local output if server unreachable\n // eslint-disable-next-line no-console\n console.log(`[${this.name}] ${item.line}`);\n }\n }\n\n this.sending = false;\n }\n\n log(line: string, type: LogType = 'stdout'): void {\n // Always output locally\n const errorPrefix = type === 'stderr' ? `${RED}[ERR]${RESET} ` : '';\n\n // eslint-disable-next-line no-console\n console.log(`${errorPrefix}${line}`);\n\n // Capture timestamp immediately when log is received\n this.queue.push({ line, timestamp: getTimestamp(), type });\n void this.flush();\n }\n\n private async send(endpoint: string, data: object): Promise<boolean> {\n return new Promise((resolve) => {\n const postData = JSON.stringify(data);\n const request = http.request(\n {\n headers: {\n 'Content-Length': Buffer.byteLength(postData),\n 'Content-Type': 'application/json',\n },\n hostname: '127.0.0.1',\n method: 'POST',\n path: endpoint,\n port: this.port,\n timeout: 1_000,\n },\n (response) => {\n response.resume();\n response.on('end', () => resolve(true));\n },\n );\n\n request.on('error', () => resolve(false));\n request.on('timeout', () => {\n request.destroy();\n resolve(false);\n });\n request.write(postData);\n request.end();\n });\n }\n}\n\nconst runProcess = async (\n name: string,\n command: string[],\n client: LogClient,\n): Promise<number> => {\n const [cmd, ...args] = command;\n\n const child = spawn(cmd, args, {\n env: {\n ...process.env,\n FORCE_COLOR: '1',\n },\n shell: process.platform === 'win32',\n stdio: ['inherit', 'pipe', 'pipe'],\n });\n\n const pid = child.pid ?? 0;\n\n await client.event('start', pid);\n\n let rlStdout: null | readline.Interface = null;\n let rlStderr: null | readline.Interface = null;\n\n if (child.stdout) {\n rlStdout = readline.createInterface({ input: child.stdout });\n\n rlStdout.on('line', (line) => client.log(line, 'stdout'));\n }\n\n if (child.stderr) {\n rlStderr = readline.createInterface({ input: child.stderr });\n\n rlStderr.on('line', (line) => client.log(line, 'stderr'));\n }\n\n // Track signal count for force-kill on second signal\n let signalCount = 0;\n\n const onSignal = (): void => {\n signalCount++;\n\n if (signalCount >= 2 && child.pid && !child.killed) {\n // Second signal: force kill\n child.kill('SIGKILL');\n }\n };\n\n process.on('SIGINT', onSignal);\n process.on('SIGTERM', onSignal);\n process.on('SIGHUP', onSignal);\n\n return new Promise((resolve) => {\n child.on('close', async (code) => {\n // Clean up readline interfaces\n rlStdout?.close();\n rlStderr?.close();\n\n // Remove signal handlers\n process.off('SIGINT', onSignal);\n process.off('SIGTERM', onSignal);\n process.off('SIGHUP', onSignal);\n\n await client.flush();\n await client.event('exit', pid, code ?? 0);\n resolve(code ?? 0);\n });\n });\n};\n\nconst sleep = (ms: number): Promise<void> =>\n new Promise((resolve) => {\n setTimeout(resolve, ms);\n });\n\nconst checkServerReady = async (port: number): Promise<boolean> => {\n return new Promise((resolve) => {\n const request = http.request(\n {\n hostname: '127.0.0.1',\n method: 'GET',\n path: '/',\n port,\n timeout: 200,\n },\n (response) => {\n response.resume();\n resolve(true);\n },\n );\n\n request.on('error', () => resolve(false));\n request.on('timeout', () => {\n request.destroy();\n resolve(false);\n });\n request.end();\n });\n};\n\nconst waitForServer = async (\n port: number,\n maxAttempts = 50,\n): Promise<boolean> => {\n for (let index = 0; index < maxAttempts; index++) {\n if (await checkServerReady(port)) {\n return true;\n }\n\n // Exponential backoff: 10ms, 20ms, 40ms, ... capped at 200ms\n const delay = Math.min(10 * 2 ** index, 200);\n\n await sleep(delay);\n }\n\n return false;\n};\n\nconst tryBecomeLeader = async (server: LogServer): Promise<boolean> => {\n for (let attempt = 0; attempt < MAX_PROMOTION_RETRIES; attempt++) {\n try {\n await server.start();\n return true;\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code !== 'EADDRINUSE') {\n throw error;\n }\n\n // Check if another server took over\n if (await checkServerReady(server.getPort())) {\n // Another process became leader\n return false;\n }\n\n // Port in use but server not responding - might be starting up\n // Add random jitter to avoid thundering herd\n const jitter = Math.random() * 100;\n\n await sleep(50 + jitter);\n }\n }\n\n return false;\n};\n\nconst startLeaderMonitoring = (\n server: LogServer,\n port: number,\n): { stop: () => void } => {\n let isRunning = true;\n let timeoutId: null | ReturnType<typeof setTimeout> = null;\n\n const checkAndPromote = async (): Promise<void> => {\n if (!isRunning) {\n return;\n }\n\n const serverAlive = await checkServerReady(port);\n\n if (!serverAlive && isRunning) {\n // Leader might be dead, try to become leader\n // Add random jitter to prevent all clients from trying simultaneously\n const jitter = Math.random() * 500;\n\n await sleep(jitter);\n\n // Double-check server is still down after jitter\n if (isRunning && !(await checkServerReady(port))) {\n const promoted = await tryBecomeLeader(server);\n\n if (promoted) {\n // eslint-disable-next-line no-console\n console.log(\n `${DIM}[teemux] promoted to leader, now aggregating logs${RESET}`,\n );\n // Stop monitoring - we're now the leader\n // eslint-disable-next-line require-atomic-updates -- safe: only modified here or in stop()\n isRunning = false;\n return;\n }\n }\n }\n\n // Schedule next check\n if (isRunning) {\n timeoutId = setTimeout(() => {\n void checkAndPromote();\n }, LEADER_CHECK_INTERVAL);\n }\n };\n\n // Start monitoring after initial delay\n timeoutId = setTimeout(() => {\n void checkAndPromote();\n }, LEADER_CHECK_INTERVAL);\n\n return {\n stop: () => {\n isRunning = false;\n\n if (timeoutId) {\n clearTimeout(timeoutId);\n }\n },\n };\n};\n\nconst main = async (): Promise<void> => {\n const argv = await yargs(hideBin(process.argv))\n .env('TEEMUX')\n .usage('Usage: $0 --name <name> -- <command> [args...]')\n .option('name', {\n alias: 'n',\n description:\n 'Name to identify this process in logs (defaults to command)',\n type: 'string',\n })\n .option('port', {\n alias: 'p',\n default: 8_336,\n description: 'Port for the log aggregation server',\n type: 'number',\n })\n .option('tail', {\n alias: 't',\n default: 1_000,\n description: 'Number of log lines to keep in buffer',\n type: 'number',\n })\n .help()\n .parse();\n\n const command = argv._ as string[];\n\n if (command.length === 0) {\n // eslint-disable-next-line no-console\n console.error('No command specified');\n // eslint-disable-next-line no-console\n console.error('Usage: teemux --name <name> -- <command> [args...]');\n process.exit(1);\n }\n\n const name = argv.name ?? command[0] ?? 'unknown';\n const port = argv.port;\n\n const server = new LogServer(port, argv.tail);\n\n // Try to become server with retries - if port is taken, become client\n let isServer = false;\n const maxRetries = 3;\n\n for (let attempt = 0; attempt < maxRetries; attempt++) {\n try {\n await server.start();\n isServer = true;\n break;\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code !== 'EADDRINUSE') {\n throw error;\n }\n\n // Check if another server is actually running\n if (await checkServerReady(port)) {\n // Server exists, we're a client\n break;\n }\n\n // Port in use but server not responding - might be starting up\n // Add random jitter to avoid thundering herd\n const jitter = Math.random() * 100;\n\n await sleep(50 + jitter);\n }\n }\n\n // If we're not the server, wait for it to be ready and start monitoring\n let leaderMonitor: null | { stop: () => void } = null;\n\n if (!isServer) {\n const serverReady = await waitForServer(port);\n\n if (!serverReady) {\n // eslint-disable-next-line no-console\n console.error(\n '[teemux] Could not connect to server. Is another instance running?',\n );\n }\n\n // Start monitoring for leader failover\n leaderMonitor = startLeaderMonitoring(server, port);\n }\n\n const client = new LogClient(name, port);\n\n // Cleanup function for graceful shutdown\n const cleanup = async (): Promise<void> => {\n leaderMonitor?.stop();\n\n if (isServer) {\n await server.stop();\n }\n };\n\n // Run the process\n const exitCode = await runProcess(name, command, client);\n\n // Stop leader monitoring if running\n await cleanup();\n\n process.exit(exitCode);\n};\n\nmain().catch((error: unknown) => {\n // eslint-disable-next-line no-console\n console.error('Fatal error:', error);\n process.exit(1);\n});\n"],"mappings":"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"}
1
+ {"version":3,"file":"teemux.js","names":["RESET","DIM","RED"],"sources":["../src/utils/stripHtmlTags.ts","../src/utils/unescapeHtml.ts","../src/utils/highlightJson.ts","../src/utils/linkifyUrls.ts","../src/utils/stripAnsi.ts","../src/utils/matchesFilters.ts","../src/LogServer.ts","../src/teemux.ts"],"sourcesContent":["/**\n * Strip HTML tags from a string, leaving only text content.\n */\nexport const stripHtmlTags = (html: string): string => {\n return html.replaceAll(/<[^>]*>/gu, '');\n};\n","/**\n * Unescape HTML entities back to their original characters.\n */\nexport const unescapeHtml = (text: string): string => {\n return text\n .replaceAll('&quot;', '\"')\n .replaceAll('&amp;', '&')\n .replaceAll('&lt;', '<')\n .replaceAll('&gt;', '>')\n .replaceAll('&#x27;', \"'\")\n .replaceAll('&#39;', \"'\");\n};\n","import { stripHtmlTags } from './stripHtmlTags.js';\nimport { unescapeHtml } from './unescapeHtml.js';\n\n/**\n * Apply syntax highlighting to JSON text that uses HTML-escaped quotes (&quot;).\n * Uses placeholder technique to avoid double-wrapping strings.\n */\nexport const highlightJsonText = (text: string): string => {\n // First, extract and mark all JSON strings with placeholders\n const strings: string[] = [];\n let result = text.replaceAll(\n /&quot;((?:(?!&quot;).)*)&quot;/gu,\n (_match, content) => {\n strings.push(content as string);\n return `\\u0000STR${strings.length - 1}\\u0000`;\n },\n );\n\n // Booleans and null\n result = result.replaceAll(\n /\\b(true|false|null)\\b/gu,\n '<span class=\"json-bool\">$1</span>',\n );\n\n // Numbers\n result = result.replaceAll(\n /(?<!\\w)(-?\\d+(?:\\.\\d+)?(?:[eE][+-]?\\d+)?)\\b/gu,\n '<span class=\"json-number\">$1</span>',\n );\n\n // Restore strings with appropriate highlighting\n result = result.replaceAll(\n /\\0STR(\\d+)\\0(\\s*:)?/gu,\n (_match, index, colon) => {\n const content = strings[Number.parseInt(index as string, 10)];\n if (colon) {\n // This is a key\n return `<span class=\"json-key\">&quot;${content}&quot;</span>${colon}`;\n }\n\n // This is a value\n return `<span class=\"json-string\">&quot;${content}&quot;</span>`;\n },\n );\n\n return result;\n};\n\n/**\n * Process HTML text, applying JSON highlighting only to text outside of HTML tags.\n */\nexport const syntaxHighlightJson = (html: string): string => {\n let result = '';\n let index = 0;\n\n while (index < html.length) {\n if (html[index] === '<') {\n // Find end of tag\n const tagEnd = html.indexOf('>', index);\n if (tagEnd === -1) {\n result += html.slice(index);\n break;\n }\n\n result += html.slice(index, tagEnd + 1);\n index = tagEnd + 1;\n } else {\n // Find next tag or end of string\n const nextTag = html.indexOf('<', index);\n const textEnd = nextTag === -1 ? html.length : nextTag;\n const text = html.slice(index, textEnd);\n\n // Highlight JSON syntax in this text segment\n result += highlightJsonText(text);\n index = textEnd;\n }\n }\n\n return result;\n};\n\n/**\n * Detect if the content (after prefix) is valid JSON and apply syntax highlighting.\n * Returns the original HTML if not valid JSON.\n */\nexport const highlightJson = (html: string): string => {\n // Extract the text content (strip HTML tags) to check if it's JSON\n const textContent = stripHtmlTags(html);\n\n // Unescape HTML entities for JSON parsing\n const unescaped = unescapeHtml(textContent);\n\n // Find where the actual log content starts (after the prefix like [name])\n const prefixMatch = /^\\[[\\w-]+\\]\\s*/u.exec(unescaped);\n const prefix = prefixMatch?.[0] ?? '';\n const content = unescaped.slice(prefix.length).trim();\n\n // Check if the content is valid JSON\n if (!content.startsWith('{') && !content.startsWith('[')) {\n return html;\n }\n\n try {\n JSON.parse(content);\n } catch {\n return html;\n }\n\n // It's valid JSON - now highlight it\n // Find the position after the prefix span in the HTML\n const prefixHtmlMatch = /^<span[^>]*>\\[[^\\]]+\\]<\\/span>\\s*/u.exec(html);\n const htmlPrefix = prefixHtmlMatch?.[0] ?? '';\n const jsonHtml = html.slice(htmlPrefix.length);\n\n // Apply syntax highlighting to the JSON portion\n const highlighted = syntaxHighlightJson(jsonHtml);\n\n return htmlPrefix + highlighted;\n};\n","/**\n * Convert URLs in HTML text to clickable anchor tags.\n * Supports http://, https://, and file:// URLs.\n * Avoids double-linking URLs that are already in href attributes.\n */\nexport const linkifyUrls = (html: string): string => {\n // Match URLs that are not already inside href attributes\n // Supports http://, https://, and file:// URLs\n // Exclude common delimiters and HTML entities (&quot; &amp; etc)\n const urlRegex = /(?<!href=[\"'])(?:https?|file):\\/\\/[^\\s<>\"'{}&]+/gu;\n\n return html.replaceAll(urlRegex, (url) => {\n // Remove trailing punctuation that's likely not part of the URL\n const cleanUrl = url.replace(/[.,;:!?)\\]]+$/u, '');\n const trailing = url.slice(cleanUrl.length);\n\n // Escape HTML entities in the URL for the href attribute\n const escapedHref = cleanUrl\n .replaceAll('&', '&amp;')\n .replaceAll('\"', '&quot;');\n\n return `<a href=\"${escapedHref}\" target=\"_blank\" rel=\"noopener\">${cleanUrl}</a>${trailing}`;\n });\n};\n","/**\n * Strip ANSI escape codes from text.\n * Removes color codes and other terminal formatting sequences.\n */\nexport const stripAnsi = (text: string): string => {\n // eslint-disable-next-line no-control-regex\n return text.replaceAll(/\\u001B\\[[\\d;]*m/gu, '');\n};\n","import { stripAnsi } from './stripAnsi.js';\n\n/**\n * Convert a glob pattern (with * wildcards) to a RegExp.\n * - `*` matches any characters (zero or more)\n * - All other characters are escaped for literal matching\n */\nconst globToRegex = (pattern: string): RegExp => {\n // Escape regex special characters except *\n const escaped = pattern.replaceAll(/[$()+.?[\\\\\\]^{|}]/gu, '\\\\$&');\n // Convert * to .*\n const regexPattern = escaped.replaceAll('*', '.*');\n return new RegExp(regexPattern, 'iu');\n};\n\n/**\n * Check if text matches a pattern (supports * glob wildcards).\n * If no wildcards, does a simple substring match for better performance.\n */\nconst matchesPattern = (text: string, pattern: string): boolean => {\n if (pattern.includes('*')) {\n return globToRegex(pattern).test(text);\n }\n\n return text.includes(pattern.toLowerCase());\n};\n\n/**\n * Check if a line matches the given filter criteria.\n * @param line - The line to check (may contain ANSI codes)\n * @param includes - Patterns where ANY match includes the line (OR logic), case-insensitive. Supports * wildcards.\n * @param excludes - Patterns where ANY match excludes the line (OR logic), case-insensitive. Supports * wildcards.\n * @returns true if the line should be included, false if filtered out\n */\nexport const matchesFilters = (\n line: string,\n includes: string[],\n excludes: string[],\n): boolean => {\n const plainText = stripAnsi(line).toLowerCase();\n\n // Any include must match (OR logic) - case insensitive\n if (includes.length > 0) {\n const anyIncludeMatches = includes.some((pattern) =>\n matchesPattern(plainText, pattern),\n );\n\n if (!anyIncludeMatches) {\n return false;\n }\n }\n\n // None of the excludes should match (OR logic for exclusion) - case insensitive\n if (excludes.length > 0) {\n const anyExcludeMatches = excludes.some((pattern) =>\n matchesPattern(plainText, pattern),\n );\n\n if (anyExcludeMatches) {\n return false;\n }\n }\n\n return true;\n};\n","import { highlightJson } from './utils/highlightJson.js';\nimport { linkifyUrls } from './utils/linkifyUrls.js';\nimport { matchesFilters } from './utils/matchesFilters.js';\nimport { stripAnsi } from './utils/stripAnsi.js';\nimport Convert from 'ansi-to-html';\nimport http from 'node:http';\nimport { performance } from 'node:perf_hooks';\nimport { URL } from 'node:url';\n\nconst COLORS = [\n '\\u001B[36m',\n '\\u001B[33m',\n '\\u001B[32m',\n '\\u001B[35m',\n '\\u001B[34m',\n '\\u001B[91m',\n '\\u001B[92m',\n '\\u001B[93m',\n];\nconst RESET = '\\u001B[0m';\nconst DIM = '\\u001B[90m';\nconst RED = '\\u001B[91m';\nconst HOST = '0.0.0.0';\n\ntype BufferedLog = {\n line: string;\n timestamp: number;\n};\n\ntype EventPayload = {\n code?: number;\n event: 'exit' | 'start';\n name: string;\n pid: number;\n timestamp: number;\n};\n\ntype LogPayload = {\n line: string;\n name: string;\n timestamp: number;\n type: LogType;\n};\n\ntype LogType = 'stderr' | 'stdout';\n\ntype StreamClient = {\n excludes: string[];\n includes: string[];\n isBrowser: boolean;\n response: http.ServerResponse;\n};\n\nexport class LogServer {\n private ansiConverter = new Convert({ escapeXML: true, newline: true });\n\n private buffer: BufferedLog[] = [];\n\n private clients = new Set<StreamClient>();\n\n private colorIndex = 0;\n\n private colorMap = new Map<string, string>();\n\n private port: number;\n\n private server: http.Server | null = null;\n\n private tailSize: number;\n\n constructor(port: number, tailSize: number = 10_000) {\n this.port = port;\n this.tailSize = tailSize;\n }\n\n clearLogs(): void {\n // Clear the server buffer\n this.buffer = [];\n\n // Notify all browser clients to clear their logs\n for (const client of this.clients) {\n if (client.isBrowser) {\n client.response.write(`<script>clearLogs()</script>\\n`);\n }\n }\n }\n\n getPort(): number {\n if (this.server) {\n const address = this.server.address();\n if (address && typeof address === 'object') {\n return address.port;\n }\n }\n\n return this.port;\n }\n\n start(): Promise<void> {\n return new Promise((resolve, reject) => {\n this.server = http.createServer((request, response) => {\n // Handle search endpoint - returns matching logs as JSON\n if (request.method === 'GET' && request.url?.startsWith('/search')) {\n const url = new URL(request.url, `http://${request.headers.host}`);\n const includeParameter = url.searchParams.get('include');\n const includes = includeParameter\n ? includeParameter\n .split(',')\n .map((term) => term.trim())\n .filter(Boolean)\n : [];\n const excludeParameter = url.searchParams.get('exclude');\n const excludes = excludeParameter\n ? excludeParameter\n .split(',')\n .map((pattern) => pattern.trim())\n .filter(Boolean)\n : [];\n const limit = Math.min(\n Number.parseInt(url.searchParams.get('limit') ?? '1000', 10),\n 1_000,\n );\n\n // Sort buffer by timestamp\n const sortedBuffer = this.buffer.toSorted(\n (a, b) => a.timestamp - b.timestamp,\n );\n\n // Filter and limit results\n const results: Array<{ html: string; raw: string }> = [];\n\n for (const entry of sortedBuffer) {\n if (matchesFilters(entry.line, includes, excludes)) {\n let html = this.ansiConverter.toHtml(entry.line);\n html = highlightJson(html);\n html = linkifyUrls(html);\n results.push({\n html,\n raw: stripAnsi(entry.line),\n });\n\n if (results.length >= limit) {\n break;\n }\n }\n }\n\n response.writeHead(200, {\n 'Access-Control-Allow-Origin': '*',\n 'Cache-Control': 'no-cache',\n 'Content-Type': 'application/json; charset=utf-8',\n });\n response.end(JSON.stringify(results));\n return;\n }\n\n // Handle streaming GET request\n if (request.method === 'GET' && request.url?.startsWith('/')) {\n const url = new URL(request.url, `http://${request.headers.host}`);\n const includeParameter = url.searchParams.get('include');\n const includes = includeParameter\n ? includeParameter\n .split(',')\n .map((term) => term.trim())\n .filter(Boolean)\n : [];\n const excludeParameter = url.searchParams.get('exclude');\n const excludes = excludeParameter\n ? excludeParameter\n .split(',')\n .map((pattern) => pattern.trim())\n .filter(Boolean)\n : [];\n\n const userAgent = request.headers['user-agent'] ?? '';\n const isBrowser = userAgent.includes('Mozilla');\n\n // Sort buffer by timestamp\n const sortedBuffer = this.buffer.toSorted(\n (a, b) => a.timestamp - b.timestamp,\n );\n\n if (isBrowser) {\n // Browser: send initial batch (limited), more available via /search\n response.writeHead(200, {\n 'Cache-Control': 'no-cache',\n Connection: 'keep-alive',\n 'Content-Type': 'text/html; charset=utf-8',\n 'X-Content-Type-Options': 'nosniff',\n });\n\n // Send HTML header with styling\n response.write(this.getHtmlHeader());\n\n // Send last 1000 logs initially (browser can fetch more via /search)\n const initialLogs = sortedBuffer.slice(-1_000);\n\n for (const entry of initialLogs) {\n response.write(this.getHtmlLine(entry.line));\n }\n } else {\n // Non-browser (curl, etc): apply server-side filtering\n const filteredBuffer = sortedBuffer.filter((entry) =>\n matchesFilters(entry.line, includes, excludes),\n );\n\n response.writeHead(200, {\n 'Cache-Control': 'no-cache',\n Connection: 'keep-alive',\n 'Content-Type': 'text/plain; charset=utf-8',\n 'X-Content-Type-Options': 'nosniff',\n });\n\n // Send filtered logs as plain text (strip ANSI)\n for (const entry of filteredBuffer) {\n response.write(stripAnsi(entry.line) + '\\n');\n }\n }\n\n // Add to clients for streaming\n const client: StreamClient = {\n excludes,\n includes,\n isBrowser,\n response,\n };\n\n this.clients.add(client);\n\n request.on('close', () => {\n this.clients.delete(client);\n });\n\n return;\n }\n\n let body = '';\n\n request.on('data', (chunk: Buffer) => {\n body += chunk.toString();\n });\n request.on('end', () => {\n if (request.method === 'POST' && request.url === '/log') {\n try {\n const { line, name, timestamp, type } = JSON.parse(\n body,\n ) as LogPayload;\n\n this.broadcastLog(name, line, type, timestamp);\n } catch {\n // Ignore parse errors\n }\n\n response.writeHead(200);\n response.end();\n } else if (request.method === 'POST' && request.url === '/event') {\n try {\n const { code, event, name, pid, timestamp } = JSON.parse(\n body,\n ) as EventPayload;\n\n if (event === 'start') {\n this.broadcastEvent(name, `● started (pid ${pid})`, timestamp);\n } else if (event === 'exit') {\n this.broadcastEvent(name, `○ exited (code ${code})`, timestamp);\n }\n } catch {\n // Ignore parse errors\n }\n\n response.writeHead(200);\n response.end();\n } else if (request.method === 'POST' && request.url === '/inject') {\n // Test injection endpoint\n try {\n const data = JSON.parse(body) as {\n event?: 'exit' | 'start';\n message: string;\n name: string;\n pid?: number;\n };\n const timestamp = performance.timeOrigin + performance.now();\n\n if (data.event === 'start') {\n this.broadcastEvent(\n data.name,\n `● started (pid ${data.pid ?? 0})`,\n timestamp,\n );\n } else if (data.event === 'exit') {\n this.broadcastEvent(data.name, `○ exited (code 0)`, timestamp);\n } else {\n this.broadcastLog(data.name, data.message, 'stdout', timestamp);\n }\n } catch {\n // Ignore parse errors\n }\n\n response.writeHead(200);\n response.end();\n } else if (request.method === 'POST' && request.url === '/clear') {\n // Clear all logs from buffer and notify clients\n this.clearLogs();\n\n response.writeHead(200);\n response.end();\n } else {\n response.writeHead(200);\n response.end();\n }\n });\n });\n\n this.server.once('error', (error: NodeJS.ErrnoException) => {\n reject(error);\n });\n\n this.server.listen(this.port, '0.0.0.0', () => {\n // eslint-disable-next-line no-console\n console.log(\n `${DIM}[teemux] aggregating logs on http://${HOST}:${this.port}${RESET}`,\n );\n resolve();\n });\n });\n }\n\n stop(): Promise<void> {\n return new Promise((resolve) => {\n // Close all client connections\n for (const client of this.clients) {\n client.response.end();\n }\n\n this.clients.clear();\n\n if (this.server) {\n this.server.close(() => {\n this.server = null;\n resolve();\n });\n } else {\n resolve();\n }\n });\n }\n\n private broadcastEvent(\n name: string,\n message: string,\n timestamp: number,\n ): void {\n const color = this.getColor(name);\n const forWeb = `${DIM}${color}[${name}]${RESET} ${DIM}${message}${RESET}`;\n\n this.sendToClients(forWeb, timestamp);\n }\n\n private broadcastLog(\n name: string,\n line: string,\n type: LogType,\n timestamp: number,\n ): void {\n const color = this.getColor(name);\n const errorPrefix = type === 'stderr' ? `${RED}[ERR]${RESET} ` : '';\n const forWeb = `${color}[${name}]${RESET} ${errorPrefix}${line}`;\n\n this.sendToClients(forWeb, timestamp);\n }\n\n private getColor(name: string): string {\n if (!this.colorMap.has(name)) {\n this.colorMap.set(name, COLORS[this.colorIndex++ % COLORS.length]);\n }\n\n return this.colorMap.get(name) ?? COLORS[0];\n }\n\n private getHtmlHeader(): string {\n return `<!DOCTYPE html>\n<html>\n<head>\n <meta charset=\"utf-8\">\n <title>teemux</title>\n <style>\n * { box-sizing: border-box; }\n html, body {\n height: 100%;\n margin: 0;\n overflow: hidden;\n }\n body {\n background: #1e1e1e;\n color: #d4d4d4;\n font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;\n font-size: 12px;\n line-height: 1.3;\n display: flex;\n flex-direction: column;\n }\n #filter-bar {\n flex-shrink: 0;\n display: flex;\n gap: 8px;\n padding: 8px 12px;\n background: #252526;\n border-bottom: 1px solid #3c3c3c;\n }\n #filter-bar label {\n display: flex;\n align-items: center;\n gap: 6px;\n color: #888;\n }\n #filter-bar input {\n background: #1e1e1e;\n border: 1px solid #3c3c3c;\n border-radius: 3px;\n color: #d4d4d4;\n font-family: inherit;\n font-size: 12px;\n padding: 4px 8px;\n width: 200px;\n }\n #filter-bar input:focus {\n outline: none;\n border-color: #007acc;\n }\n #container {\n flex: 1;\n overflow-y: auto;\n padding: 8px 12px;\n }\n .line {\n white-space: pre-wrap;\n word-break: break-all;\n padding: 1px 4px;\n margin: 0 -4px;\n border-radius: 2px;\n position: relative;\n display: flex;\n align-items: flex-start;\n }\n .line:hover {\n background: rgba(255, 255, 255, 0.05);\n }\n .line.pinned {\n background: rgba(255, 204, 0, 0.1);\n border-left: 2px solid #fc0;\n margin-left: -6px;\n padding-left: 6px;\n }\n .line-content {\n flex: 1;\n }\n .pin-btn {\n opacity: 0;\n cursor: pointer;\n padding: 0 4px;\n color: #888;\n flex-shrink: 0;\n transition: opacity 0.15s;\n }\n .line:hover .pin-btn {\n opacity: 0.5;\n }\n .pin-btn:hover {\n opacity: 1 !important;\n color: #fc0;\n }\n .line.pinned .pin-btn {\n opacity: 1;\n color: #fc0;\n }\n a { color: #4fc1ff; text-decoration: underline; }\n a:hover { text-decoration: none; }\n mark { background: #623800; color: inherit; border-radius: 2px; }\n mark.filter { background: #264f00; }\n .json-key { color: #9cdcfe; }\n .json-string { color: #ce9178; }\n .json-number { color: #b5cea8; }\n .json-bool { color: #569cd6; }\n .json-null { color: #569cd6; }\n #tail-btn {\n position: fixed;\n bottom: 20px;\n right: 20px;\n background: #007acc;\n color: #fff;\n border: none;\n border-radius: 4px;\n padding: 8px 16px;\n font-family: inherit;\n font-size: 12px;\n cursor: pointer;\n display: none;\n align-items: center;\n gap: 6px;\n box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);\n transition: background 0.15s;\n }\n #tail-btn:hover {\n background: #0098ff;\n }\n #tail-btn svg {\n flex-shrink: 0;\n }\n #clear-btn {\n margin-left: auto;\n background: transparent;\n color: #888;\n border: 1px solid #3c3c3c;\n border-radius: 4px;\n padding: 4px 10px;\n font-family: inherit;\n font-size: 12px;\n cursor: pointer;\n display: flex;\n align-items: center;\n gap: 5px;\n transition: all 0.15s;\n }\n #clear-btn:hover {\n background: #3c3c3c;\n color: #d4d4d4;\n border-color: #505050;\n }\n #clear-btn svg {\n flex-shrink: 0;\n }\n #clear-btn.active {\n background: #264f78;\n border-color: #007acc;\n color: #fff;\n box-shadow: 0 0 0 2px rgba(0, 122, 204, 0.3);\n }\n </style>\n</head>\n<body>\n <div id=\"filter-bar\">\n <label>Include: <input type=\"text\" id=\"include\" placeholder=\"error*,warn* (OR, * = wildcard)\"></label>\n <label>Exclude: <input type=\"text\" id=\"exclude\" placeholder=\"health*,debug (OR, * = wildcard)\"></label>\n <label>Highlight: <input type=\"text\" id=\"highlight\" placeholder=\"term1,term2\"></label>\n <button id=\"clear-btn\" title=\"Clear all logs (Cmd+K)\">\n <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18\"/><path d=\"M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6\"/><path d=\"M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2\"/><line x1=\"10\" x2=\"10\" y1=\"11\" y2=\"17\"/><line x1=\"14\" x2=\"14\" y1=\"11\" y2=\"17\"/></svg>\n Clear\n </button>\n </div>\n <div id=\"container\"></div>\n <button id=\"tail-btn\" title=\"Jump to bottom and follow new logs\">\n <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 5v14\"/><path d=\"m19 12-7 7-7-7\"/></svg>\n Tail\n </button>\n <script>\n const container = document.getElementById('container');\n const includeInput = document.getElementById('include');\n const excludeInput = document.getElementById('exclude');\n const highlightInput = document.getElementById('highlight');\n const tailBtn = document.getElementById('tail-btn');\n const clearBtn = document.getElementById('clear-btn');\n const params = new URLSearchParams(window.location.search);\n const tailSize = Math.min(${this.tailSize}, 1000);\n \n includeInput.value = params.get('include') || '';\n excludeInput.value = params.get('exclude') || '';\n highlightInput.value = params.get('highlight') || '';\n \n let tailing = true;\n let pinnedIds = new Set();\n \n const updateTailButton = () => {\n tailBtn.style.display = tailing ? 'none' : 'flex';\n };\n \n // Lucide pin icon SVG\n const pinIcon = '<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 17v5\"/><path d=\"M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z\"/></svg>';\n \n const stripAnsi = (str) => str.replace(/\\\\u001B\\\\[[\\\\d;]*m/g, '');\n \n const globToRegex = (pattern) => {\n const escaped = pattern.replace(/([.+?^\\${}()|[\\\\]\\\\\\\\])/g, '\\\\\\\\$1');\n const regexPattern = escaped.replace(/\\\\*/g, '.*');\n return new RegExp(regexPattern, 'i');\n };\n \n const matchesPattern = (text, pattern) => {\n if (pattern.includes('*')) {\n return globToRegex(pattern).test(text);\n }\n return text.includes(pattern.toLowerCase());\n };\n \n const matchesFilters = (text, includes, excludes) => {\n const plain = stripAnsi(text).toLowerCase();\n if (includes.length > 0) {\n const anyMatch = includes.some(p => matchesPattern(plain, p));\n if (!anyMatch) return false;\n }\n if (excludes.length > 0) {\n const anyMatch = excludes.some(p => matchesPattern(plain, p));\n if (anyMatch) return false;\n }\n return true;\n };\n \n const highlightTerms = (html, terms, className = '') => {\n if (!terms.length) return html;\n let result = html;\n for (const term of terms) {\n if (!term) continue;\n const escaped = term.replace(/([.*+?^\\${}()|[\\\\]\\\\\\\\])/g, '\\\\\\\\$1');\n const regex = new RegExp('(?![^<]*>)(' + escaped + ')', 'gi');\n const cls = className ? ' class=\"' + className + '\"' : '';\n result = result.replace(regex, '<mark' + cls + '>$1</mark>');\n }\n return result;\n };\n \n const applyFiltersLocal = () => {\n const includes = includeInput.value.split(',').map(s => s.trim()).filter(Boolean);\n const excludes = excludeInput.value.split(',').map(s => s.trim()).filter(Boolean);\n const highlights = highlightInput.value.split(',').map(s => s.trim()).filter(Boolean);\n \n document.querySelectorAll('.line').forEach(line => {\n const id = line.dataset.id;\n const isPinned = pinnedIds.has(id);\n const text = line.dataset.raw;\n const matches = matchesFilters(text, includes, excludes);\n line.style.display = (matches || isPinned) ? '' : 'none';\n \n // Re-apply highlighting\n const contentEl = line.querySelector('.line-content');\n if (contentEl) {\n let html = line.dataset.html;\n html = highlightTerms(html, includes, 'filter');\n html = highlightTerms(html, highlights);\n contentEl.innerHTML = html;\n }\n });\n };\n \n let lastSearchQuery = '';\n let searchController = null;\n \n const applyFilters = async () => {\n const includes = includeInput.value.split(',').map(s => s.trim()).filter(Boolean);\n const excludes = excludeInput.value.split(',').map(s => s.trim()).filter(Boolean);\n const highlights = highlightInput.value.split(',').map(s => s.trim()).filter(Boolean);\n \n // Update URL without reload\n const newParams = new URLSearchParams();\n if (includeInput.value) newParams.set('include', includeInput.value);\n if (excludeInput.value) newParams.set('exclude', excludeInput.value);\n if (highlightInput.value) newParams.set('highlight', highlightInput.value);\n const newUrl = newParams.toString() ? '?' + newParams.toString() : window.location.pathname;\n history.replaceState(null, '', newUrl);\n \n // Build search query string for comparison\n const searchQuery = includeInput.value + '|' + excludeInput.value;\n \n // If only highlight changed, just re-apply local highlighting\n if (searchQuery === lastSearchQuery) {\n applyFiltersLocal();\n return;\n }\n \n lastSearchQuery = searchQuery;\n \n // Cancel any pending search request\n if (searchController) {\n searchController.abort();\n }\n \n // If no filters, just apply local filtering (show all)\n if (includes.length === 0 && excludes.length === 0) {\n applyFiltersLocal();\n container.scrollTop = container.scrollHeight;\n tailing = true;\n updateTailButton();\n return;\n }\n \n // Fetch matching logs from server\n searchController = new AbortController();\n const searchParams = new URLSearchParams();\n if (includeInput.value) searchParams.set('include', includeInput.value);\n if (excludeInput.value) searchParams.set('exclude', excludeInput.value);\n searchParams.set('limit', '1000');\n \n try {\n const response = await fetch('/search?' + searchParams.toString(), {\n signal: searchController.signal\n });\n const results = await response.json();\n \n // Clear non-pinned lines\n document.querySelectorAll('.line').forEach(line => {\n if (!pinnedIds.has(line.dataset.id)) {\n line.remove();\n }\n });\n \n // Add search results\n for (const item of results) {\n const id = 'line-' + (lineCounter++);\n const div = document.createElement('div');\n div.className = 'line';\n div.dataset.id = id;\n div.dataset.raw = item.raw;\n div.dataset.html = item.html;\n \n let displayHtml = item.html;\n displayHtml = highlightTerms(displayHtml, includes, 'filter');\n displayHtml = highlightTerms(displayHtml, highlights);\n \n div.innerHTML = '<span class=\"line-content\">' + displayHtml + '</span><span class=\"pin-btn\" title=\"Pin\">' + pinIcon + '</span>';\n \n // Pin button handler\n div.querySelector('.pin-btn').addEventListener('click', (e) => {\n e.stopPropagation();\n if (pinnedIds.has(id)) {\n pinnedIds.delete(id);\n div.classList.remove('pinned');\n } else {\n pinnedIds.add(id);\n div.classList.add('pinned');\n }\n applyFiltersLocal();\n });\n \n container.appendChild(div);\n }\n \n // Jump to bottom and resume tailing\n container.scrollTop = container.scrollHeight;\n tailing = true;\n updateTailButton();\n } catch (e) {\n if (e.name !== 'AbortError') {\n console.error('Search failed:', e);\n // Fallback to local filtering\n applyFiltersLocal();\n }\n }\n };\n \n const trimBuffer = () => {\n const lines = container.querySelectorAll('.line');\n const unpinnedLines = Array.from(lines).filter(l => !pinnedIds.has(l.dataset.id));\n const excess = unpinnedLines.length - tailSize;\n if (excess > 0) {\n for (let i = 0; i < excess; i++) {\n unpinnedLines[i].remove();\n }\n }\n };\n\n const clearLogs = () => {\n // Remove all log lines from the DOM\n container.innerHTML = '';\n // Reset pinned IDs\n pinnedIds.clear();\n // Reset line counter\n lineCounter = 0;\n // Reset search state\n lastSearchQuery = '';\n };\n \n let lineCounter = 0;\n const addLine = (html, raw) => {\n const id = 'line-' + (lineCounter++);\n const includes = includeInput.value.split(',').map(s => s.trim()).filter(Boolean);\n const excludes = excludeInput.value.split(',').map(s => s.trim()).filter(Boolean);\n const highlights = highlightInput.value.split(',').map(s => s.trim()).filter(Boolean);\n \n const div = document.createElement('div');\n div.className = 'line';\n div.dataset.id = id;\n div.dataset.raw = raw;\n div.dataset.html = html;\n \n let displayHtml = html;\n displayHtml = highlightTerms(displayHtml, includes, 'filter');\n displayHtml = highlightTerms(displayHtml, highlights);\n \n div.innerHTML = '<span class=\"line-content\">' + displayHtml + '</span><span class=\"pin-btn\" title=\"Pin\">' + pinIcon + '</span>';\n \n // Pin button handler\n div.querySelector('.pin-btn').addEventListener('click', (e) => {\n e.stopPropagation();\n if (pinnedIds.has(id)) {\n pinnedIds.delete(id);\n div.classList.remove('pinned');\n } else {\n pinnedIds.add(id);\n div.classList.add('pinned');\n }\n applyFiltersLocal();\n });\n \n const matches = matchesFilters(raw, includes, excludes);\n div.style.display = matches ? '' : 'none';\n \n container.appendChild(div);\n trimBuffer();\n if (tailing) container.scrollTop = container.scrollHeight;\n };\n \n container.addEventListener('scroll', () => {\n const atBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 50;\n tailing = atBottom;\n updateTailButton();\n });\n \n tailBtn.addEventListener('click', () => {\n container.scrollTop = container.scrollHeight;\n tailing = true;\n updateTailButton();\n });\n\n const triggerClear = () => {\n clearBtn.classList.add('active');\n fetch('/clear', { method: 'POST' });\n setTimeout(() => clearBtn.classList.remove('active'), 150);\n };\n\n clearBtn.addEventListener('click', triggerClear);\n\n // Cmd+K (Mac) or Ctrl+K (Windows/Linux) to clear logs\n document.addEventListener('keydown', (e) => {\n if ((e.metaKey || e.ctrlKey) && e.key === 'k') {\n e.preventDefault();\n triggerClear();\n }\n });\n\n let debounceTimer;\n const debounce = (fn, delay) => {\n clearTimeout(debounceTimer);\n debounceTimer = setTimeout(fn, delay);\n };\n \n includeInput.addEventListener('input', () => debounce(applyFilters, 300));\n excludeInput.addEventListener('input', () => debounce(applyFilters, 300));\n highlightInput.addEventListener('input', () => debounce(applyFilters, 150));\n </script>\n`;\n }\n\n private getHtmlLine(line: string): string {\n let html = this.ansiConverter.toHtml(line);\n html = highlightJson(html);\n html = linkifyUrls(html);\n const escaped = html\n .replaceAll('\\\\', '\\\\\\\\')\n .replaceAll(\"'\", \"\\\\'\")\n .replaceAll('\\n', '\\\\n')\n .replaceAll('\\r', '\\\\r');\n const raw = stripAnsi(line)\n .replaceAll('\\\\', '\\\\\\\\')\n .replaceAll(\"'\", \"\\\\'\")\n .replaceAll('\\n', '\\\\n')\n .replaceAll('\\r', '\\\\r');\n return `<script>addLine('${escaped}', '${raw}')</script>\\n`;\n }\n\n private sendToClients(forWeb: string, timestamp: number): void {\n // Add to buffer\n this.buffer.push({ line: forWeb, timestamp });\n\n // Trim buffer to tail size\n if (this.buffer.length > this.tailSize) {\n this.buffer.shift();\n }\n\n // Send to all connected clients\n for (const client of this.clients) {\n if (client.isBrowser) {\n client.response.write(this.getHtmlLine(forWeb));\n } else {\n // Server-side filtering for non-browser clients\n if (!matchesFilters(forWeb, client.includes, client.excludes)) {\n continue;\n }\n\n client.response.write(stripAnsi(forWeb) + '\\n');\n }\n }\n\n // Note: Each client prints its own logs locally, so server doesn't need to\n }\n}\n","#!/usr/bin/env node\n\nimport { LogServer } from './LogServer.js';\nimport { spawn } from 'node:child_process';\nimport http from 'node:http';\nimport { performance } from 'node:perf_hooks';\nimport readline from 'node:readline';\nimport yargs from 'yargs';\nimport { hideBin } from 'yargs/helpers';\n\n// High-precision timestamp (milliseconds with microsecond precision)\nconst getTimestamp = (): number => performance.timeOrigin + performance.now();\n\nconst RESET = '\\u001B[0m';\nconst DIM = '\\u001B[90m';\nconst RED = '\\u001B[91m';\n\n// Leader monitoring configuration\nconst LEADER_CHECK_INTERVAL = 2_000; // Check every 2 seconds\nconst MAX_PROMOTION_RETRIES = 3;\n\ntype LogType = 'stderr' | 'stdout';\n\nclass LogClient {\n private name: string;\n\n private port: number;\n\n private queue: Array<{ line: string; timestamp: number; type: LogType }> = [];\n\n private sending = false;\n\n constructor(name: string, port: number) {\n this.name = name;\n this.port = port;\n }\n\n async event(\n event: 'exit' | 'start',\n pid: number,\n code?: number,\n ): Promise<void> {\n await this.send('/event', {\n code,\n event,\n name: this.name,\n pid,\n timestamp: getTimestamp(),\n });\n }\n\n async flush(): Promise<void> {\n if (this.sending || this.queue.length === 0) {\n return;\n }\n\n this.sending = true;\n\n while (this.queue.length > 0) {\n const item = this.queue.shift();\n\n if (!item) {\n continue;\n }\n\n const success = await this.send('/log', {\n line: item.line,\n name: this.name,\n timestamp: item.timestamp,\n type: item.type,\n });\n\n if (!success) {\n // Fallback to local output if server unreachable\n // eslint-disable-next-line no-console\n console.log(`[${this.name}] ${item.line}`);\n }\n }\n\n this.sending = false;\n }\n\n log(line: string, type: LogType = 'stdout'): void {\n // Always output locally\n const errorPrefix = type === 'stderr' ? `${RED}[ERR]${RESET} ` : '';\n\n // eslint-disable-next-line no-console\n console.log(`${errorPrefix}${line}`);\n\n // Capture timestamp immediately when log is received\n this.queue.push({ line, timestamp: getTimestamp(), type });\n void this.flush();\n }\n\n private async send(endpoint: string, data: object): Promise<boolean> {\n return new Promise((resolve) => {\n const postData = JSON.stringify(data);\n const request = http.request(\n {\n headers: {\n 'Content-Length': Buffer.byteLength(postData),\n 'Content-Type': 'application/json',\n },\n hostname: '127.0.0.1',\n method: 'POST',\n path: endpoint,\n port: this.port,\n timeout: 1_000,\n },\n (response) => {\n response.resume();\n response.on('end', () => resolve(true));\n },\n );\n\n request.on('error', () => resolve(false));\n request.on('timeout', () => {\n request.destroy();\n resolve(false);\n });\n request.write(postData);\n request.end();\n });\n }\n}\n\nconst runProcess = async (\n name: string,\n command: string[],\n client: LogClient,\n): Promise<number> => {\n const [cmd, ...args] = command;\n\n const child = spawn(cmd, args, {\n env: {\n ...process.env,\n FORCE_COLOR: '1',\n },\n shell: process.platform === 'win32',\n stdio: ['inherit', 'pipe', 'pipe'],\n });\n\n const pid = child.pid ?? 0;\n\n await client.event('start', pid);\n\n let rlStdout: null | readline.Interface = null;\n let rlStderr: null | readline.Interface = null;\n\n if (child.stdout) {\n rlStdout = readline.createInterface({ input: child.stdout });\n\n rlStdout.on('line', (line) => client.log(line, 'stdout'));\n }\n\n if (child.stderr) {\n rlStderr = readline.createInterface({ input: child.stderr });\n\n rlStderr.on('line', (line) => client.log(line, 'stderr'));\n }\n\n // Track signal count for force-kill on second signal\n let signalCount = 0;\n\n const onSignal = (): void => {\n signalCount++;\n\n if (signalCount >= 2 && child.pid && !child.killed) {\n // Second signal: force kill\n child.kill('SIGKILL');\n }\n };\n\n process.on('SIGINT', onSignal);\n process.on('SIGTERM', onSignal);\n process.on('SIGHUP', onSignal);\n\n return new Promise((resolve) => {\n child.on('close', async (code) => {\n // Clean up readline interfaces\n rlStdout?.close();\n rlStderr?.close();\n\n // Remove signal handlers\n process.off('SIGINT', onSignal);\n process.off('SIGTERM', onSignal);\n process.off('SIGHUP', onSignal);\n\n await client.flush();\n await client.event('exit', pid, code ?? 0);\n resolve(code ?? 0);\n });\n });\n};\n\nconst sleep = (ms: number): Promise<void> =>\n new Promise((resolve) => {\n setTimeout(resolve, ms);\n });\n\nconst checkServerReady = async (port: number): Promise<boolean> => {\n return new Promise((resolve) => {\n const request = http.request(\n {\n hostname: '127.0.0.1',\n method: 'GET',\n path: '/',\n port,\n timeout: 200,\n },\n (response) => {\n response.resume();\n resolve(true);\n },\n );\n\n request.on('error', () => resolve(false));\n request.on('timeout', () => {\n request.destroy();\n resolve(false);\n });\n request.end();\n });\n};\n\nconst waitForServer = async (\n port: number,\n maxAttempts = 50,\n): Promise<boolean> => {\n for (let index = 0; index < maxAttempts; index++) {\n if (await checkServerReady(port)) {\n return true;\n }\n\n // Exponential backoff: 10ms, 20ms, 40ms, ... capped at 200ms\n const delay = Math.min(10 * 2 ** index, 200);\n\n await sleep(delay);\n }\n\n return false;\n};\n\nconst tryBecomeLeader = async (server: LogServer): Promise<boolean> => {\n for (let attempt = 0; attempt < MAX_PROMOTION_RETRIES; attempt++) {\n try {\n await server.start();\n return true;\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code !== 'EADDRINUSE') {\n throw error;\n }\n\n // Check if another server took over\n if (await checkServerReady(server.getPort())) {\n // Another process became leader\n return false;\n }\n\n // Port in use but server not responding - might be starting up\n // Add random jitter to avoid thundering herd\n const jitter = Math.random() * 100;\n\n await sleep(50 + jitter);\n }\n }\n\n return false;\n};\n\nconst startLeaderMonitoring = (\n server: LogServer,\n port: number,\n): { stop: () => void } => {\n let isRunning = true;\n let timeoutId: null | ReturnType<typeof setTimeout> = null;\n\n const checkAndPromote = async (): Promise<void> => {\n if (!isRunning) {\n return;\n }\n\n const serverAlive = await checkServerReady(port);\n\n if (!serverAlive && isRunning) {\n // Leader might be dead, try to become leader\n // Add random jitter to prevent all clients from trying simultaneously\n const jitter = Math.random() * 500;\n\n await sleep(jitter);\n\n // Double-check server is still down after jitter\n if (isRunning && !(await checkServerReady(port))) {\n const promoted = await tryBecomeLeader(server);\n\n if (promoted) {\n // eslint-disable-next-line no-console\n console.log(\n `${DIM}[teemux] promoted to leader, now aggregating logs${RESET}`,\n );\n // Stop monitoring - we're now the leader\n // eslint-disable-next-line require-atomic-updates -- safe: only modified here or in stop()\n isRunning = false;\n return;\n }\n }\n }\n\n // Schedule next check\n if (isRunning) {\n timeoutId = setTimeout(() => {\n void checkAndPromote();\n }, LEADER_CHECK_INTERVAL);\n }\n };\n\n // Start monitoring after initial delay\n timeoutId = setTimeout(() => {\n void checkAndPromote();\n }, LEADER_CHECK_INTERVAL);\n\n return {\n stop: () => {\n isRunning = false;\n\n if (timeoutId) {\n clearTimeout(timeoutId);\n }\n },\n };\n};\n\nconst main = async (): Promise<void> => {\n const argv = await yargs(hideBin(process.argv))\n .env('TEEMUX')\n .usage('Usage: $0 --name <name> -- <command> [args...]')\n .option('name', {\n alias: 'n',\n description:\n 'Name to identify this process in logs (defaults to command)',\n type: 'string',\n })\n .option('port', {\n alias: 'p',\n default: 8_336,\n description: 'Port for the log aggregation server',\n type: 'number',\n })\n .option('buffer', {\n alias: 'b',\n default: 10_000,\n description: 'Number of log lines to keep in server buffer',\n type: 'number',\n })\n .help()\n .parse();\n\n const command = argv._ as string[];\n\n if (command.length === 0) {\n // eslint-disable-next-line no-console\n console.error('No command specified');\n // eslint-disable-next-line no-console\n console.error('Usage: teemux --name <name> -- <command> [args...]');\n process.exit(1);\n }\n\n const name = argv.name ?? command[0] ?? 'unknown';\n const port = argv.port;\n\n const server = new LogServer(port, argv.buffer);\n\n // Try to become server with retries - if port is taken, become client\n let isServer = false;\n const maxRetries = 3;\n\n for (let attempt = 0; attempt < maxRetries; attempt++) {\n try {\n await server.start();\n isServer = true;\n break;\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code !== 'EADDRINUSE') {\n throw error;\n }\n\n // Check if another server is actually running\n if (await checkServerReady(port)) {\n // Server exists, we're a client\n break;\n }\n\n // Port in use but server not responding - might be starting up\n // Add random jitter to avoid thundering herd\n const jitter = Math.random() * 100;\n\n await sleep(50 + jitter);\n }\n }\n\n // If we're not the server, wait for it to be ready and start monitoring\n let leaderMonitor: null | { stop: () => void } = null;\n\n if (!isServer) {\n const serverReady = await waitForServer(port);\n\n if (!serverReady) {\n // eslint-disable-next-line no-console\n console.error(\n '[teemux] Could not connect to server. Is another instance running?',\n );\n }\n\n // Start monitoring for leader failover\n leaderMonitor = startLeaderMonitoring(server, port);\n }\n\n const client = new LogClient(name, port);\n\n // Cleanup function for graceful shutdown\n const cleanup = async (): Promise<void> => {\n leaderMonitor?.stop();\n\n if (isServer) {\n await server.stop();\n }\n };\n\n // Run the process\n const exitCode = await runProcess(name, command, client);\n\n // Stop leader monitoring if running\n await cleanup();\n\n process.exit(exitCode);\n};\n\nmain().catch((error: unknown) => {\n // eslint-disable-next-line no-console\n console.error('Fatal error:', error);\n process.exit(1);\n});\n"],"mappings":"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"}
package/package.json CHANGED
@@ -86,5 +86,5 @@
86
86
  },
87
87
  "type": "module",
88
88
  "types": "./dist/teemux.d.ts",
89
- "version": "1.4.0"
89
+ "version": "1.6.0"
90
90
  }
@@ -0,0 +1,144 @@
1
+ import { runWithTeemux } from './testing/runWithTeemux.js';
2
+ import http from 'node:http';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ const fetchJson = (port: number, path: string): Promise<unknown> => {
6
+ return new Promise((resolve, reject) => {
7
+ const request = http.request(
8
+ {
9
+ hostname: '127.0.0.1',
10
+ method: 'GET',
11
+ path,
12
+ port,
13
+ },
14
+ (response) => {
15
+ let data = '';
16
+ response.on('data', (chunk: Buffer) => {
17
+ data += chunk.toString();
18
+ });
19
+ response.on('end', () => {
20
+ try {
21
+ resolve(JSON.parse(data));
22
+ } catch {
23
+ reject(new Error(`Failed to parse JSON: ${data}`));
24
+ }
25
+ });
26
+ },
27
+ );
28
+
29
+ request.on('error', reject);
30
+ request.end();
31
+ });
32
+ };
33
+
34
+ describe('LogServer', () => {
35
+ describe('clearLogs', () => {
36
+ it('clears the server buffer', async () => {
37
+ await runWithTeemux({ buffer: 100 }, async (context) => {
38
+ // Inject some logs
39
+ await context.injectLog('app', 'message 1');
40
+ await context.injectLog('app', 'message 2');
41
+ await context.injectLog('app', 'message 3');
42
+
43
+ // Verify logs exist via search
44
+ const beforeClear = (await fetchJson(
45
+ context.port,
46
+ '/search',
47
+ )) as Array<{
48
+ raw: string;
49
+ }>;
50
+ expect(beforeClear.length).toBe(3);
51
+ expect(beforeClear.map((entry) => entry.raw)).toContain(
52
+ '[app] message 1',
53
+ );
54
+ expect(beforeClear.map((entry) => entry.raw)).toContain(
55
+ '[app] message 2',
56
+ );
57
+ expect(beforeClear.map((entry) => entry.raw)).toContain(
58
+ '[app] message 3',
59
+ );
60
+
61
+ // Clear logs
62
+ await context.clearLogs();
63
+
64
+ // Verify buffer is empty
65
+ const afterClear = (await fetchJson(context.port, '/search')) as Array<{
66
+ raw: string;
67
+ }>;
68
+ expect(afterClear.length).toBe(0);
69
+ });
70
+ });
71
+
72
+ it('allows new logs after clearing', async () => {
73
+ await runWithTeemux({ buffer: 100 }, async (context) => {
74
+ // Inject initial logs
75
+ await context.injectLog('app', 'old message');
76
+
77
+ // Clear logs
78
+ await context.clearLogs();
79
+
80
+ // Inject new logs
81
+ await context.injectLog('app', 'new message');
82
+
83
+ // Verify only new log exists
84
+ const results = (await fetchJson(context.port, '/search')) as Array<{
85
+ raw: string;
86
+ }>;
87
+ expect(results.length).toBe(1);
88
+ expect(results[0].raw).toBe('[app] new message');
89
+ });
90
+ });
91
+
92
+ it('clears events as well as regular logs', async () => {
93
+ await runWithTeemux({ buffer: 100 }, async (context) => {
94
+ // Inject logs and events
95
+ await context.injectLog('app', 'message');
96
+ await context.injectEvent('app', 'start', 1_234);
97
+ await context.injectEvent('app', 'exit');
98
+
99
+ // Verify they exist
100
+ const beforeClear = (await fetchJson(
101
+ context.port,
102
+ '/search',
103
+ )) as Array<{
104
+ raw: string;
105
+ }>;
106
+ expect(beforeClear.length).toBe(3);
107
+
108
+ // Clear logs
109
+ await context.clearLogs();
110
+
111
+ // Verify all cleared
112
+ const afterClear = (await fetchJson(context.port, '/search')) as Array<{
113
+ raw: string;
114
+ }>;
115
+ expect(afterClear.length).toBe(0);
116
+ });
117
+ });
118
+
119
+ it('works with filtered search after clearing', async () => {
120
+ await runWithTeemux({ buffer: 100 }, async (context) => {
121
+ // Inject logs from multiple processes
122
+ await context.injectLog('api', 'api message 1');
123
+ await context.injectLog('worker', 'worker message 1');
124
+
125
+ // Clear logs
126
+ await context.clearLogs();
127
+
128
+ // Inject new logs
129
+ await context.injectLog('api', 'api message 2');
130
+ await context.injectLog('worker', 'worker message 2');
131
+
132
+ // Search with filter - should only find new logs
133
+ const results = (await fetchJson(
134
+ context.port,
135
+ '/search?include=api',
136
+ )) as Array<{
137
+ raw: string;
138
+ }>;
139
+ expect(results.length).toBe(1);
140
+ expect(results[0].raw).toBe('[api] api message 2');
141
+ });
142
+ });
143
+ });
144
+ });
package/src/LogServer.ts CHANGED
@@ -68,11 +68,23 @@ export class LogServer {
68
68
 
69
69
  private tailSize: number;
70
70
 
71
- constructor(port: number, tailSize: number = 1_000) {
71
+ constructor(port: number, tailSize: number = 10_000) {
72
72
  this.port = port;
73
73
  this.tailSize = tailSize;
74
74
  }
75
75
 
76
+ clearLogs(): void {
77
+ // Clear the server buffer
78
+ this.buffer = [];
79
+
80
+ // Notify all browser clients to clear their logs
81
+ for (const client of this.clients) {
82
+ if (client.isBrowser) {
83
+ client.response.write(`<script>clearLogs()</script>\n`);
84
+ }
85
+ }
86
+ }
87
+
76
88
  getPort(): number {
77
89
  if (this.server) {
78
90
  const address = this.server.address();
@@ -87,6 +99,61 @@ export class LogServer {
87
99
  start(): Promise<void> {
88
100
  return new Promise((resolve, reject) => {
89
101
  this.server = http.createServer((request, response) => {
102
+ // Handle search endpoint - returns matching logs as JSON
103
+ if (request.method === 'GET' && request.url?.startsWith('/search')) {
104
+ const url = new URL(request.url, `http://${request.headers.host}`);
105
+ const includeParameter = url.searchParams.get('include');
106
+ const includes = includeParameter
107
+ ? includeParameter
108
+ .split(',')
109
+ .map((term) => term.trim())
110
+ .filter(Boolean)
111
+ : [];
112
+ const excludeParameter = url.searchParams.get('exclude');
113
+ const excludes = excludeParameter
114
+ ? excludeParameter
115
+ .split(',')
116
+ .map((pattern) => pattern.trim())
117
+ .filter(Boolean)
118
+ : [];
119
+ const limit = Math.min(
120
+ Number.parseInt(url.searchParams.get('limit') ?? '1000', 10),
121
+ 1_000,
122
+ );
123
+
124
+ // Sort buffer by timestamp
125
+ const sortedBuffer = this.buffer.toSorted(
126
+ (a, b) => a.timestamp - b.timestamp,
127
+ );
128
+
129
+ // Filter and limit results
130
+ const results: Array<{ html: string; raw: string }> = [];
131
+
132
+ for (const entry of sortedBuffer) {
133
+ if (matchesFilters(entry.line, includes, excludes)) {
134
+ let html = this.ansiConverter.toHtml(entry.line);
135
+ html = highlightJson(html);
136
+ html = linkifyUrls(html);
137
+ results.push({
138
+ html,
139
+ raw: stripAnsi(entry.line),
140
+ });
141
+
142
+ if (results.length >= limit) {
143
+ break;
144
+ }
145
+ }
146
+ }
147
+
148
+ response.writeHead(200, {
149
+ 'Access-Control-Allow-Origin': '*',
150
+ 'Cache-Control': 'no-cache',
151
+ 'Content-Type': 'application/json; charset=utf-8',
152
+ });
153
+ response.end(JSON.stringify(results));
154
+ return;
155
+ }
156
+
90
157
  // Handle streaming GET request
91
158
  if (request.method === 'GET' && request.url?.startsWith('/')) {
92
159
  const url = new URL(request.url, `http://${request.headers.host}`);
@@ -114,7 +181,7 @@ export class LogServer {
114
181
  );
115
182
 
116
183
  if (isBrowser) {
117
- // Browser: send all logs, filtering is done client-side
184
+ // Browser: send initial batch (limited), more available via /search
118
185
  response.writeHead(200, {
119
186
  'Cache-Control': 'no-cache',
120
187
  Connection: 'keep-alive',
@@ -125,8 +192,10 @@ export class LogServer {
125
192
  // Send HTML header with styling
126
193
  response.write(this.getHtmlHeader());
127
194
 
128
- // Send all buffered logs as HTML
129
- for (const entry of sortedBuffer) {
195
+ // Send last 1000 logs initially (browser can fetch more via /search)
196
+ const initialLogs = sortedBuffer.slice(-1_000);
197
+
198
+ for (const entry of initialLogs) {
130
199
  response.write(this.getHtmlLine(entry.line));
131
200
  }
132
201
  } else {
@@ -227,6 +296,12 @@ export class LogServer {
227
296
  // Ignore parse errors
228
297
  }
229
298
 
299
+ response.writeHead(200);
300
+ response.end();
301
+ } else if (request.method === 'POST' && request.url === '/clear') {
302
+ // Clear all logs from buffer and notify clients
303
+ this.clearLogs();
304
+
230
305
  response.writeHead(200);
231
306
  response.end();
232
307
  } else {
@@ -431,6 +506,35 @@ export class LogServer {
431
506
  #tail-btn svg {
432
507
  flex-shrink: 0;
433
508
  }
509
+ #clear-btn {
510
+ margin-left: auto;
511
+ background: transparent;
512
+ color: #888;
513
+ border: 1px solid #3c3c3c;
514
+ border-radius: 4px;
515
+ padding: 4px 10px;
516
+ font-family: inherit;
517
+ font-size: 12px;
518
+ cursor: pointer;
519
+ display: flex;
520
+ align-items: center;
521
+ gap: 5px;
522
+ transition: all 0.15s;
523
+ }
524
+ #clear-btn:hover {
525
+ background: #3c3c3c;
526
+ color: #d4d4d4;
527
+ border-color: #505050;
528
+ }
529
+ #clear-btn svg {
530
+ flex-shrink: 0;
531
+ }
532
+ #clear-btn.active {
533
+ background: #264f78;
534
+ border-color: #007acc;
535
+ color: #fff;
536
+ box-shadow: 0 0 0 2px rgba(0, 122, 204, 0.3);
537
+ }
434
538
  </style>
435
539
  </head>
436
540
  <body>
@@ -438,6 +542,10 @@ export class LogServer {
438
542
  <label>Include: <input type="text" id="include" placeholder="error*,warn* (OR, * = wildcard)"></label>
439
543
  <label>Exclude: <input type="text" id="exclude" placeholder="health*,debug (OR, * = wildcard)"></label>
440
544
  <label>Highlight: <input type="text" id="highlight" placeholder="term1,term2"></label>
545
+ <button id="clear-btn" title="Clear all logs (Cmd+K)">
546
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>
547
+ Clear
548
+ </button>
441
549
  </div>
442
550
  <div id="container"></div>
443
551
  <button id="tail-btn" title="Jump to bottom and follow new logs">
@@ -450,8 +558,9 @@ export class LogServer {
450
558
  const excludeInput = document.getElementById('exclude');
451
559
  const highlightInput = document.getElementById('highlight');
452
560
  const tailBtn = document.getElementById('tail-btn');
561
+ const clearBtn = document.getElementById('clear-btn');
453
562
  const params = new URLSearchParams(window.location.search);
454
- const tailSize = ${this.tailSize};
563
+ const tailSize = Math.min(${this.tailSize}, 1000);
455
564
 
456
565
  includeInput.value = params.get('include') || '';
457
566
  excludeInput.value = params.get('exclude') || '';
@@ -508,7 +617,7 @@ export class LogServer {
508
617
  return result;
509
618
  };
510
619
 
511
- const applyFilters = () => {
620
+ const applyFiltersLocal = () => {
512
621
  const includes = includeInput.value.split(',').map(s => s.trim()).filter(Boolean);
513
622
  const excludes = excludeInput.value.split(',').map(s => s.trim()).filter(Boolean);
514
623
  const highlights = highlightInput.value.split(',').map(s => s.trim()).filter(Boolean);
@@ -529,6 +638,15 @@ export class LogServer {
529
638
  contentEl.innerHTML = html;
530
639
  }
531
640
  });
641
+ };
642
+
643
+ let lastSearchQuery = '';
644
+ let searchController = null;
645
+
646
+ const applyFilters = async () => {
647
+ const includes = includeInput.value.split(',').map(s => s.trim()).filter(Boolean);
648
+ const excludes = excludeInput.value.split(',').map(s => s.trim()).filter(Boolean);
649
+ const highlights = highlightInput.value.split(',').map(s => s.trim()).filter(Boolean);
532
650
 
533
651
  // Update URL without reload
534
652
  const newParams = new URLSearchParams();
@@ -538,10 +656,93 @@ export class LogServer {
538
656
  const newUrl = newParams.toString() ? '?' + newParams.toString() : window.location.pathname;
539
657
  history.replaceState(null, '', newUrl);
540
658
 
541
- // Jump to bottom and resume tailing after filter change
542
- container.scrollTop = container.scrollHeight;
543
- tailing = true;
544
- updateTailButton();
659
+ // Build search query string for comparison
660
+ const searchQuery = includeInput.value + '|' + excludeInput.value;
661
+
662
+ // If only highlight changed, just re-apply local highlighting
663
+ if (searchQuery === lastSearchQuery) {
664
+ applyFiltersLocal();
665
+ return;
666
+ }
667
+
668
+ lastSearchQuery = searchQuery;
669
+
670
+ // Cancel any pending search request
671
+ if (searchController) {
672
+ searchController.abort();
673
+ }
674
+
675
+ // If no filters, just apply local filtering (show all)
676
+ if (includes.length === 0 && excludes.length === 0) {
677
+ applyFiltersLocal();
678
+ container.scrollTop = container.scrollHeight;
679
+ tailing = true;
680
+ updateTailButton();
681
+ return;
682
+ }
683
+
684
+ // Fetch matching logs from server
685
+ searchController = new AbortController();
686
+ const searchParams = new URLSearchParams();
687
+ if (includeInput.value) searchParams.set('include', includeInput.value);
688
+ if (excludeInput.value) searchParams.set('exclude', excludeInput.value);
689
+ searchParams.set('limit', '1000');
690
+
691
+ try {
692
+ const response = await fetch('/search?' + searchParams.toString(), {
693
+ signal: searchController.signal
694
+ });
695
+ const results = await response.json();
696
+
697
+ // Clear non-pinned lines
698
+ document.querySelectorAll('.line').forEach(line => {
699
+ if (!pinnedIds.has(line.dataset.id)) {
700
+ line.remove();
701
+ }
702
+ });
703
+
704
+ // Add search results
705
+ for (const item of results) {
706
+ const id = 'line-' + (lineCounter++);
707
+ const div = document.createElement('div');
708
+ div.className = 'line';
709
+ div.dataset.id = id;
710
+ div.dataset.raw = item.raw;
711
+ div.dataset.html = item.html;
712
+
713
+ let displayHtml = item.html;
714
+ displayHtml = highlightTerms(displayHtml, includes, 'filter');
715
+ displayHtml = highlightTerms(displayHtml, highlights);
716
+
717
+ div.innerHTML = '<span class="line-content">' + displayHtml + '</span><span class="pin-btn" title="Pin">' + pinIcon + '</span>';
718
+
719
+ // Pin button handler
720
+ div.querySelector('.pin-btn').addEventListener('click', (e) => {
721
+ e.stopPropagation();
722
+ if (pinnedIds.has(id)) {
723
+ pinnedIds.delete(id);
724
+ div.classList.remove('pinned');
725
+ } else {
726
+ pinnedIds.add(id);
727
+ div.classList.add('pinned');
728
+ }
729
+ applyFiltersLocal();
730
+ });
731
+
732
+ container.appendChild(div);
733
+ }
734
+
735
+ // Jump to bottom and resume tailing
736
+ container.scrollTop = container.scrollHeight;
737
+ tailing = true;
738
+ updateTailButton();
739
+ } catch (e) {
740
+ if (e.name !== 'AbortError') {
741
+ console.error('Search failed:', e);
742
+ // Fallback to local filtering
743
+ applyFiltersLocal();
744
+ }
745
+ }
545
746
  };
546
747
 
547
748
  const trimBuffer = () => {
@@ -554,6 +755,17 @@ export class LogServer {
554
755
  }
555
756
  }
556
757
  };
758
+
759
+ const clearLogs = () => {
760
+ // Remove all log lines from the DOM
761
+ container.innerHTML = '';
762
+ // Reset pinned IDs
763
+ pinnedIds.clear();
764
+ // Reset line counter
765
+ lineCounter = 0;
766
+ // Reset search state
767
+ lastSearchQuery = '';
768
+ };
557
769
 
558
770
  let lineCounter = 0;
559
771
  const addLine = (html, raw) => {
@@ -584,7 +796,7 @@ export class LogServer {
584
796
  pinnedIds.add(id);
585
797
  div.classList.add('pinned');
586
798
  }
587
- applyFilters();
799
+ applyFiltersLocal();
588
800
  });
589
801
 
590
802
  const matches = matchesFilters(raw, includes, excludes);
@@ -606,16 +818,32 @@ export class LogServer {
606
818
  tailing = true;
607
819
  updateTailButton();
608
820
  });
609
-
821
+
822
+ const triggerClear = () => {
823
+ clearBtn.classList.add('active');
824
+ fetch('/clear', { method: 'POST' });
825
+ setTimeout(() => clearBtn.classList.remove('active'), 150);
826
+ };
827
+
828
+ clearBtn.addEventListener('click', triggerClear);
829
+
830
+ // Cmd+K (Mac) or Ctrl+K (Windows/Linux) to clear logs
831
+ document.addEventListener('keydown', (e) => {
832
+ if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
833
+ e.preventDefault();
834
+ triggerClear();
835
+ }
836
+ });
837
+
610
838
  let debounceTimer;
611
839
  const debounce = (fn, delay) => {
612
840
  clearTimeout(debounceTimer);
613
841
  debounceTimer = setTimeout(fn, delay);
614
842
  };
615
843
 
616
- includeInput.addEventListener('input', () => debounce(applyFilters, 50));
617
- excludeInput.addEventListener('input', () => debounce(applyFilters, 50));
618
- highlightInput.addEventListener('input', () => debounce(applyFilters, 50));
844
+ includeInput.addEventListener('input', () => debounce(applyFilters, 300));
845
+ excludeInput.addEventListener('input', () => debounce(applyFilters, 300));
846
+ highlightInput.addEventListener('input', () => debounce(applyFilters, 150));
619
847
  </script>
620
848
  `;
621
849
  }
@@ -624,8 +852,16 @@ export class LogServer {
624
852
  let html = this.ansiConverter.toHtml(line);
625
853
  html = highlightJson(html);
626
854
  html = linkifyUrls(html);
627
- const escaped = html.replaceAll('\\', '\\\\').replaceAll("'", "\\'");
628
- const raw = stripAnsi(line).replaceAll('\\', '\\\\').replaceAll("'", "\\'");
855
+ const escaped = html
856
+ .replaceAll('\\', '\\\\')
857
+ .replaceAll("'", "\\'")
858
+ .replaceAll('\n', '\\n')
859
+ .replaceAll('\r', '\\r');
860
+ const raw = stripAnsi(line)
861
+ .replaceAll('\\', '\\\\')
862
+ .replaceAll("'", "\\'")
863
+ .replaceAll('\n', '\\n')
864
+ .replaceAll('\r', '\\r');
629
865
  return `<script>addLine('${escaped}', '${raw}')</script>\n`;
630
866
  }
631
867
 
package/src/teemux.ts CHANGED
@@ -346,10 +346,10 @@ const main = async (): Promise<void> => {
346
346
  description: 'Port for the log aggregation server',
347
347
  type: 'number',
348
348
  })
349
- .option('tail', {
350
- alias: 't',
351
- default: 1_000,
352
- description: 'Number of log lines to keep in buffer',
349
+ .option('buffer', {
350
+ alias: 'b',
351
+ default: 10_000,
352
+ description: 'Number of log lines to keep in server buffer',
353
353
  type: 'number',
354
354
  })
355
355
  .help()
@@ -368,7 +368,7 @@ const main = async (): Promise<void> => {
368
368
  const name = argv.name ?? command[0] ?? 'unknown';
369
369
  const port = argv.port;
370
370
 
371
- const server = new LogServer(port, argv.tail);
371
+ const server = new LogServer(port, argv.buffer);
372
372
 
373
373
  // Try to become server with retries - if port is taken, become client
374
374
  let isServer = false;
@@ -2,6 +2,10 @@ import { LogServer } from '../LogServer.js';
2
2
  import http from 'node:http';
3
3
 
4
4
  export type TeemuxContext = {
5
+ /**
6
+ * Clear all logs from the server buffer and notify connected clients.
7
+ */
8
+ clearLogs: () => Promise<void>;
5
9
  /**
6
10
  * Inject an event (start/exit) for a named process.
7
11
  */
@@ -26,28 +30,34 @@ export type TeemuxContext = {
26
30
 
27
31
  export type TeemuxOptions = {
28
32
  /**
29
- * Port to run on. If 0 or undefined, auto-assigns an available port.
33
+ * Number of log lines to keep in the server buffer.
30
34
  */
31
- port?: number;
35
+ buffer?: number;
32
36
  /**
33
- * Number of log lines to keep in the buffer.
37
+ * Port to run on. If 0 or undefined, auto-assigns an available port.
34
38
  */
35
- tail?: number;
39
+ port?: number;
36
40
  };
37
41
 
38
42
  const postJson = (
39
43
  port: number,
40
44
  path: string,
41
- data: Record<string, unknown>,
45
+ data?: Record<string, unknown>,
42
46
  ): Promise<void> => {
43
47
  return new Promise((resolve, reject) => {
44
- const postData = JSON.stringify(data);
48
+ const postData = data ? JSON.stringify(data) : '';
49
+ const headers: Record<string, number | string> = {};
50
+
51
+ if (data) {
52
+ headers['Content-Type'] = 'application/json';
53
+ headers['Content-Length'] = Buffer.byteLength(postData);
54
+ } else {
55
+ headers['Content-Length'] = 0;
56
+ }
57
+
45
58
  const request = http.request(
46
59
  {
47
- headers: {
48
- 'Content-Length': Buffer.byteLength(postData),
49
- 'Content-Type': 'application/json',
50
- },
60
+ headers,
51
61
  hostname: '127.0.0.1',
52
62
  method: 'POST',
53
63
  path,
@@ -60,7 +70,10 @@ const postJson = (
60
70
  );
61
71
 
62
72
  request.on('error', reject);
63
- request.write(postData);
73
+ if (postData) {
74
+ request.write(postData);
75
+ }
76
+
64
77
  request.end();
65
78
  });
66
79
  };
@@ -83,7 +96,7 @@ export const runWithTeemux = async (
83
96
  options: TeemuxOptions,
84
97
  callback: (context: TeemuxContext) => Promise<void>,
85
98
  ): Promise<void> => {
86
- const server = new LogServer(options.port ?? 0, options.tail ?? 1_000);
99
+ const server = new LogServer(options.port ?? 0, options.buffer ?? 10_000);
87
100
 
88
101
  await server.start();
89
102
 
@@ -91,6 +104,9 @@ export const runWithTeemux = async (
91
104
  const url = `http://127.0.0.1:${port}`;
92
105
 
93
106
  const context: TeemuxContext = {
107
+ clearLogs: async () => {
108
+ await postJson(port, '/clear');
109
+ },
94
110
  injectEvent: async (
95
111
  name: string,
96
112
  event: 'exit' | 'start',