teemux 1.0.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 ADDED
@@ -0,0 +1,774 @@
1
+ #!/usr/bin/env node
2
+ import Convert from "ansi-to-html";
3
+ import http from "node:http";
4
+ import { performance } from "node:perf_hooks";
5
+ import { URL } from "node:url";
6
+ import { spawn } from "node:child_process";
7
+ import readline from "node:readline";
8
+ import yargs from "yargs";
9
+ import { hideBin } from "yargs/helpers";
10
+
11
+ //#region src/utils/stripHtmlTags.ts
12
+ /**
13
+ * Strip HTML tags from a string, leaving only text content.
14
+ */
15
+ const stripHtmlTags = (html) => {
16
+ return html.replaceAll(/<[^>]*>/gu, "");
17
+ };
18
+
19
+ //#endregion
20
+ //#region src/utils/unescapeHtml.ts
21
+ /**
22
+ * Unescape HTML entities back to their original characters.
23
+ */
24
+ const unescapeHtml = (text) => {
25
+ return text.replaceAll("&quot;", "\"").replaceAll("&amp;", "&").replaceAll("&lt;", "<").replaceAll("&gt;", ">").replaceAll("&#x27;", "'").replaceAll("&#39;", "'");
26
+ };
27
+
28
+ //#endregion
29
+ //#region src/utils/highlightJson.ts
30
+ /**
31
+ * Apply syntax highlighting to JSON text that uses HTML-escaped quotes (&quot;).
32
+ * Uses placeholder technique to avoid double-wrapping strings.
33
+ */
34
+ const highlightJsonText = (text) => {
35
+ const strings = [];
36
+ let result = text.replaceAll(/&quot;((?:(?!&quot;).)*)&quot;/gu, (_match, content) => {
37
+ strings.push(content);
38
+ return `\u0000STR${strings.length - 1}\u0000`;
39
+ });
40
+ result = result.replaceAll(/\b(true|false|null)\b/gu, "<span class=\"json-bool\">$1</span>");
41
+ result = result.replaceAll(/(?<!\w)(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)\b/gu, "<span class=\"json-number\">$1</span>");
42
+ result = result.replaceAll(/\0STR(\d+)\0(\s*:)?/gu, (_match, index, colon) => {
43
+ const content = strings[Number.parseInt(index, 10)];
44
+ if (colon) return `<span class="json-key">&quot;${content}&quot;</span>${colon}`;
45
+ return `<span class="json-string">&quot;${content}&quot;</span>`;
46
+ });
47
+ return result;
48
+ };
49
+ /**
50
+ * Process HTML text, applying JSON highlighting only to text outside of HTML tags.
51
+ */
52
+ const syntaxHighlightJson = (html) => {
53
+ let result = "";
54
+ let index = 0;
55
+ while (index < html.length) if (html[index] === "<") {
56
+ const tagEnd = html.indexOf(">", index);
57
+ if (tagEnd === -1) {
58
+ result += html.slice(index);
59
+ break;
60
+ }
61
+ result += html.slice(index, tagEnd + 1);
62
+ index = tagEnd + 1;
63
+ } else {
64
+ const nextTag = html.indexOf("<", index);
65
+ const textEnd = nextTag === -1 ? html.length : nextTag;
66
+ const text = html.slice(index, textEnd);
67
+ result += highlightJsonText(text);
68
+ index = textEnd;
69
+ }
70
+ return result;
71
+ };
72
+ /**
73
+ * Detect if the content (after prefix) is valid JSON and apply syntax highlighting.
74
+ * Returns the original HTML if not valid JSON.
75
+ */
76
+ const highlightJson = (html) => {
77
+ const unescaped = unescapeHtml(stripHtmlTags(html));
78
+ const prefix = /^(\[[\w-]+\]\s*)/u.exec(unescaped)?.[0] ?? "";
79
+ const content = unescaped.slice(prefix.length).trim();
80
+ if (!content.startsWith("{") && !content.startsWith("[")) return html;
81
+ try {
82
+ JSON.parse(content);
83
+ } catch {
84
+ return html;
85
+ }
86
+ const htmlPrefix = /^(<span[^>]*>\[[^\]]+\]<\/span>\s*)/u.exec(html)?.[0] ?? "";
87
+ return htmlPrefix + syntaxHighlightJson(html.slice(htmlPrefix.length));
88
+ };
89
+
90
+ //#endregion
91
+ //#region src/utils/linkifyUrls.ts
92
+ /**
93
+ * Convert URLs in HTML text to clickable anchor tags.
94
+ * Supports http://, https://, and file:// URLs.
95
+ * Avoids double-linking URLs that are already in href attributes.
96
+ */
97
+ const linkifyUrls = (html) => {
98
+ return html.replaceAll(/(?<!href=["'])(?:https?|file):\/\/[^\s<>"'{}&]+/gu, (url) => {
99
+ const cleanUrl = url.replace(/[.,;:!?)\]]+$/u, "");
100
+ const trailing = url.slice(cleanUrl.length);
101
+ return `<a href="${cleanUrl.replaceAll("&", "&amp;").replaceAll("\"", "&quot;")}" target="_blank" rel="noopener">${cleanUrl}</a>${trailing}`;
102
+ });
103
+ };
104
+
105
+ //#endregion
106
+ //#region src/utils/stripAnsi.ts
107
+ /**
108
+ * Strip ANSI escape codes from text.
109
+ * Removes color codes and other terminal formatting sequences.
110
+ */
111
+ const stripAnsi = (text) => {
112
+ return text.replaceAll(/\u001B\[[\d;]*m/gu, "");
113
+ };
114
+
115
+ //#endregion
116
+ //#region src/utils/matchesFilters.ts
117
+ /**
118
+ * Convert a glob pattern (with * wildcards) to a RegExp.
119
+ * - `*` matches any characters (zero or more)
120
+ * - All other characters are escaped for literal matching
121
+ */
122
+ const globToRegex = (pattern) => {
123
+ const regexPattern = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
124
+ return new RegExp(regexPattern, "i");
125
+ };
126
+ /**
127
+ * Check if text matches a pattern (supports * glob wildcards).
128
+ * If no wildcards, does a simple substring match for better performance.
129
+ */
130
+ const matchesPattern = (text, pattern) => {
131
+ if (pattern.includes("*")) return globToRegex(pattern).test(text);
132
+ return text.includes(pattern.toLowerCase());
133
+ };
134
+ /**
135
+ * Check if a line matches the given filter criteria.
136
+ *
137
+ * @param line - The line to check (may contain ANSI codes)
138
+ * @param includes - Patterns where ANY match includes the line (OR logic), case-insensitive. Supports * wildcards.
139
+ * @param excludes - Patterns where ANY match excludes the line (OR logic), case-insensitive. Supports * wildcards.
140
+ * @returns true if the line should be included, false if filtered out
141
+ */
142
+ const matchesFilters = (line, includes, excludes) => {
143
+ const plainText = stripAnsi(line).toLowerCase();
144
+ if (includes.length > 0) {
145
+ if (!includes.some((pattern) => matchesPattern(plainText, pattern))) return false;
146
+ }
147
+ if (excludes.length > 0) {
148
+ if (excludes.some((pattern) => matchesPattern(plainText, pattern))) return false;
149
+ }
150
+ return true;
151
+ };
152
+
153
+ //#endregion
154
+ //#region src/LogServer.ts
155
+ const COLORS = [
156
+ "\x1B[36m",
157
+ "\x1B[33m",
158
+ "\x1B[32m",
159
+ "\x1B[35m",
160
+ "\x1B[34m",
161
+ "\x1B[91m",
162
+ "\x1B[92m",
163
+ "\x1B[93m"
164
+ ];
165
+ const RESET$1 = "\x1B[0m";
166
+ const DIM = "\x1B[90m";
167
+ const RED$1 = "\x1B[91m";
168
+ const HOST = "0.0.0.0";
169
+ var LogServer = class {
170
+ ansiConverter = new Convert({
171
+ escapeXML: true,
172
+ newline: true
173
+ });
174
+ buffer = [];
175
+ clients = /* @__PURE__ */ new Set();
176
+ colorIndex = 0;
177
+ colorMap = /* @__PURE__ */ new Map();
178
+ port;
179
+ server = null;
180
+ tailSize;
181
+ constructor(port, tailSize = 1e3) {
182
+ this.port = port;
183
+ this.tailSize = tailSize;
184
+ }
185
+ getPort() {
186
+ if (this.server) {
187
+ const address = this.server.address();
188
+ if (address && typeof address === "object") return address.port;
189
+ }
190
+ return this.port;
191
+ }
192
+ start() {
193
+ return new Promise((resolve, reject) => {
194
+ this.server = http.createServer((request, response) => {
195
+ if (request.method === "GET" && request.url?.startsWith("/")) {
196
+ const url = new URL(request.url, `http://${request.headers.host}`);
197
+ const includeParameter = url.searchParams.get("include");
198
+ const includes = includeParameter ? includeParameter.split(",").map((term) => term.trim()).filter(Boolean) : [];
199
+ const excludeParameter = url.searchParams.get("exclude");
200
+ const excludes = excludeParameter ? excludeParameter.split(",").map((pattern) => pattern.trim()).filter(Boolean) : [];
201
+ const isBrowser = (request.headers["user-agent"] ?? "").includes("Mozilla");
202
+ const sortedBuffer = this.buffer.toSorted((a, b) => a.timestamp - b.timestamp);
203
+ if (isBrowser) {
204
+ response.writeHead(200, {
205
+ "Cache-Control": "no-cache",
206
+ Connection: "keep-alive",
207
+ "Content-Type": "text/html; charset=utf-8",
208
+ "X-Content-Type-Options": "nosniff"
209
+ });
210
+ response.write(this.getHtmlHeader());
211
+ for (const entry of sortedBuffer) response.write(this.getHtmlLine(entry.line));
212
+ } else {
213
+ const filteredBuffer = sortedBuffer.filter((entry) => matchesFilters(entry.line, includes, excludes));
214
+ response.writeHead(200, {
215
+ "Cache-Control": "no-cache",
216
+ Connection: "keep-alive",
217
+ "Content-Type": "text/plain; charset=utf-8",
218
+ "X-Content-Type-Options": "nosniff"
219
+ });
220
+ for (const entry of filteredBuffer) response.write(stripAnsi(entry.line) + "\n");
221
+ }
222
+ const client = {
223
+ excludes,
224
+ includes,
225
+ isBrowser,
226
+ response
227
+ };
228
+ this.clients.add(client);
229
+ request.on("close", () => {
230
+ this.clients.delete(client);
231
+ });
232
+ return;
233
+ }
234
+ let body = "";
235
+ request.on("data", (chunk) => {
236
+ body += chunk.toString();
237
+ });
238
+ request.on("end", () => {
239
+ if (request.method === "POST" && request.url === "/log") {
240
+ try {
241
+ const { line, name, timestamp, type } = JSON.parse(body);
242
+ this.broadcastLog(name, line, type, timestamp);
243
+ } catch {}
244
+ response.writeHead(200);
245
+ response.end();
246
+ } else if (request.method === "POST" && request.url === "/event") {
247
+ try {
248
+ const { code, event, name, pid, timestamp } = JSON.parse(body);
249
+ if (event === "start") this.broadcastEvent(name, `● started (pid ${pid})`, timestamp);
250
+ else if (event === "exit") this.broadcastEvent(name, `○ exited (code ${code})`, timestamp);
251
+ } catch {}
252
+ response.writeHead(200);
253
+ response.end();
254
+ } else if (request.method === "POST" && request.url === "/inject") {
255
+ try {
256
+ const data = JSON.parse(body);
257
+ const timestamp = performance.timeOrigin + performance.now();
258
+ if (data.event === "start") this.broadcastEvent(data.name, `● started (pid ${data.pid ?? 0})`, timestamp);
259
+ else if (data.event === "exit") this.broadcastEvent(data.name, `○ exited (code 0)`, timestamp);
260
+ else this.broadcastLog(data.name, data.message, "stdout", timestamp);
261
+ } catch {}
262
+ response.writeHead(200);
263
+ response.end();
264
+ } else {
265
+ response.writeHead(200);
266
+ response.end();
267
+ }
268
+ });
269
+ });
270
+ this.server.once("error", (error) => {
271
+ reject(error);
272
+ });
273
+ this.server.listen(this.port, "0.0.0.0", () => {
274
+ console.log(`${DIM}[teemux] aggregating logs on http://${HOST}:${this.port}${RESET$1}`);
275
+ resolve();
276
+ });
277
+ });
278
+ }
279
+ stop() {
280
+ return new Promise((resolve) => {
281
+ for (const client of this.clients) client.response.end();
282
+ this.clients.clear();
283
+ if (this.server) this.server.close(() => {
284
+ this.server = null;
285
+ resolve();
286
+ });
287
+ else resolve();
288
+ });
289
+ }
290
+ broadcastEvent(name, message, timestamp) {
291
+ const forWeb = `${DIM}${this.getColor(name)}[${name}]${RESET$1} ${DIM}${message}${RESET$1}`;
292
+ this.sendToClients(forWeb, timestamp);
293
+ }
294
+ broadcastLog(name, line, type, timestamp) {
295
+ const forWeb = `${this.getColor(name)}[${name}]${RESET$1} ${type === "stderr" ? `${RED$1}[ERR]${RESET$1} ` : ""}${line}`;
296
+ this.sendToClients(forWeb, timestamp);
297
+ }
298
+ getColor(name) {
299
+ if (!this.colorMap.has(name)) this.colorMap.set(name, COLORS[this.colorIndex++ % COLORS.length]);
300
+ return this.colorMap.get(name) ?? COLORS[0];
301
+ }
302
+ getHtmlHeader() {
303
+ return `<!DOCTYPE html>
304
+ <html>
305
+ <head>
306
+ <meta charset="utf-8">
307
+ <title>teemux</title>
308
+ <style>
309
+ * { box-sizing: border-box; }
310
+ html, body {
311
+ height: 100%;
312
+ margin: 0;
313
+ overflow: hidden;
314
+ }
315
+ body {
316
+ background: #1e1e1e;
317
+ color: #d4d4d4;
318
+ font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
319
+ font-size: 12px;
320
+ line-height: 1.3;
321
+ display: flex;
322
+ flex-direction: column;
323
+ }
324
+ #filter-bar {
325
+ flex-shrink: 0;
326
+ display: flex;
327
+ gap: 8px;
328
+ padding: 8px 12px;
329
+ background: #252526;
330
+ border-bottom: 1px solid #3c3c3c;
331
+ }
332
+ #filter-bar label {
333
+ display: flex;
334
+ align-items: center;
335
+ gap: 6px;
336
+ color: #888;
337
+ }
338
+ #filter-bar input {
339
+ background: #1e1e1e;
340
+ border: 1px solid #3c3c3c;
341
+ border-radius: 3px;
342
+ color: #d4d4d4;
343
+ font-family: inherit;
344
+ font-size: 12px;
345
+ padding: 4px 8px;
346
+ width: 200px;
347
+ }
348
+ #filter-bar input:focus {
349
+ outline: none;
350
+ border-color: #007acc;
351
+ }
352
+ #container {
353
+ flex: 1;
354
+ overflow-y: auto;
355
+ padding: 8px 12px;
356
+ }
357
+ .line {
358
+ white-space: pre-wrap;
359
+ word-break: break-all;
360
+ padding: 1px 4px;
361
+ margin: 0 -4px;
362
+ border-radius: 2px;
363
+ position: relative;
364
+ display: flex;
365
+ align-items: flex-start;
366
+ }
367
+ .line:hover {
368
+ background: rgba(255, 255, 255, 0.05);
369
+ }
370
+ .line.pinned {
371
+ background: rgba(255, 204, 0, 0.1);
372
+ border-left: 2px solid #fc0;
373
+ margin-left: -6px;
374
+ padding-left: 6px;
375
+ }
376
+ .line-content {
377
+ flex: 1;
378
+ }
379
+ .pin-btn {
380
+ opacity: 0;
381
+ cursor: pointer;
382
+ padding: 0 4px;
383
+ color: #888;
384
+ flex-shrink: 0;
385
+ transition: opacity 0.15s;
386
+ }
387
+ .line:hover .pin-btn {
388
+ opacity: 0.5;
389
+ }
390
+ .pin-btn:hover {
391
+ opacity: 1 !important;
392
+ color: #fc0;
393
+ }
394
+ .line.pinned .pin-btn {
395
+ opacity: 1;
396
+ color: #fc0;
397
+ }
398
+ a { color: #4fc1ff; text-decoration: underline; }
399
+ a:hover { text-decoration: none; }
400
+ mark { background: #623800; color: inherit; border-radius: 2px; }
401
+ mark.filter { background: #264f00; }
402
+ .json-key { color: #9cdcfe; }
403
+ .json-string { color: #ce9178; }
404
+ .json-number { color: #b5cea8; }
405
+ .json-bool { color: #569cd6; }
406
+ .json-null { color: #569cd6; }
407
+ </style>
408
+ </head>
409
+ <body>
410
+ <div id="filter-bar">
411
+ <label>Include: <input type="text" id="include" placeholder="error*,warn* (OR, * = wildcard)"></label>
412
+ <label>Exclude: <input type="text" id="exclude" placeholder="health*,debug (OR, * = wildcard)"></label>
413
+ <label>Highlight: <input type="text" id="highlight" placeholder="term1,term2"></label>
414
+ </div>
415
+ <div id="container"></div>
416
+ <script>
417
+ const container = document.getElementById('container');
418
+ const includeInput = document.getElementById('include');
419
+ const excludeInput = document.getElementById('exclude');
420
+ const highlightInput = document.getElementById('highlight');
421
+ const params = new URLSearchParams(window.location.search);
422
+ const tailSize = ${this.tailSize};
423
+
424
+ includeInput.value = params.get('include') || '';
425
+ excludeInput.value = params.get('exclude') || '';
426
+ highlightInput.value = params.get('highlight') || '';
427
+
428
+ let tailing = true;
429
+ let pinnedIds = new Set();
430
+
431
+ // Lucide pin icon SVG
432
+ 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>';
433
+
434
+ const stripAnsi = (str) => str.replace(/\\u001B\\[[\\d;]*m/g, '');
435
+
436
+ const globToRegex = (pattern) => {
437
+ const escaped = pattern.replace(/([.+?^\${}()|[\\]\\\\])/g, '\\\\$1');
438
+ const regexPattern = escaped.replace(/\\*/g, '.*');
439
+ return new RegExp(regexPattern, 'i');
440
+ };
441
+
442
+ const matchesPattern = (text, pattern) => {
443
+ if (pattern.includes('*')) {
444
+ return globToRegex(pattern).test(text);
445
+ }
446
+ return text.includes(pattern.toLowerCase());
447
+ };
448
+
449
+ const matchesFilters = (text, includes, excludes) => {
450
+ const plain = stripAnsi(text).toLowerCase();
451
+ if (includes.length > 0) {
452
+ const anyMatch = includes.some(p => matchesPattern(plain, p));
453
+ if (!anyMatch) return false;
454
+ }
455
+ if (excludes.length > 0) {
456
+ const anyMatch = excludes.some(p => matchesPattern(plain, p));
457
+ if (anyMatch) return false;
458
+ }
459
+ return true;
460
+ };
461
+
462
+ const highlightTerms = (html, terms, className = '') => {
463
+ if (!terms.length) return html;
464
+ let result = html;
465
+ for (const term of terms) {
466
+ if (!term) continue;
467
+ const escaped = term.replace(/([.*+?^\${}()|[\\]\\\\])/g, '\\\\$1');
468
+ const regex = new RegExp('(?![^<]*>)(' + escaped + ')', 'gi');
469
+ const cls = className ? ' class="' + className + '"' : '';
470
+ result = result.replace(regex, '<mark' + cls + '>$1</mark>');
471
+ }
472
+ return result;
473
+ };
474
+
475
+ const applyFilters = () => {
476
+ const includes = includeInput.value.split(',').map(s => s.trim()).filter(Boolean);
477
+ const excludes = excludeInput.value.split(',').map(s => s.trim()).filter(Boolean);
478
+ const highlights = highlightInput.value.split(',').map(s => s.trim()).filter(Boolean);
479
+
480
+ document.querySelectorAll('.line').forEach(line => {
481
+ const id = line.dataset.id;
482
+ const isPinned = pinnedIds.has(id);
483
+ const text = line.dataset.raw;
484
+ const matches = matchesFilters(text, includes, excludes);
485
+ line.style.display = (matches || isPinned) ? '' : 'none';
486
+
487
+ // Re-apply highlighting
488
+ const contentEl = line.querySelector('.line-content');
489
+ if (contentEl) {
490
+ let html = line.dataset.html;
491
+ html = highlightTerms(html, includes, 'filter');
492
+ html = highlightTerms(html, highlights);
493
+ contentEl.innerHTML = html;
494
+ }
495
+ });
496
+
497
+ // Update URL without reload
498
+ const newParams = new URLSearchParams();
499
+ if (includeInput.value) newParams.set('include', includeInput.value);
500
+ if (excludeInput.value) newParams.set('exclude', excludeInput.value);
501
+ if (highlightInput.value) newParams.set('highlight', highlightInput.value);
502
+ const newUrl = newParams.toString() ? '?' + newParams.toString() : window.location.pathname;
503
+ history.replaceState(null, '', newUrl);
504
+ };
505
+
506
+ const trimBuffer = () => {
507
+ const lines = container.querySelectorAll('.line');
508
+ const unpinnedLines = Array.from(lines).filter(l => !pinnedIds.has(l.dataset.id));
509
+ const excess = unpinnedLines.length - tailSize;
510
+ if (excess > 0) {
511
+ for (let i = 0; i < excess; i++) {
512
+ unpinnedLines[i].remove();
513
+ }
514
+ }
515
+ };
516
+
517
+ let lineCounter = 0;
518
+ const addLine = (html, raw) => {
519
+ const id = 'line-' + (lineCounter++);
520
+ const includes = includeInput.value.split(',').map(s => s.trim()).filter(Boolean);
521
+ const excludes = excludeInput.value.split(',').map(s => s.trim()).filter(Boolean);
522
+ const highlights = highlightInput.value.split(',').map(s => s.trim()).filter(Boolean);
523
+
524
+ const div = document.createElement('div');
525
+ div.className = 'line';
526
+ div.dataset.id = id;
527
+ div.dataset.raw = raw;
528
+ div.dataset.html = html;
529
+
530
+ let displayHtml = html;
531
+ displayHtml = highlightTerms(displayHtml, includes, 'filter');
532
+ displayHtml = highlightTerms(displayHtml, highlights);
533
+
534
+ div.innerHTML = '<span class="line-content">' + displayHtml + '</span><span class="pin-btn" title="Pin">' + pinIcon + '</span>';
535
+
536
+ // Pin button handler
537
+ div.querySelector('.pin-btn').addEventListener('click', (e) => {
538
+ e.stopPropagation();
539
+ if (pinnedIds.has(id)) {
540
+ pinnedIds.delete(id);
541
+ div.classList.remove('pinned');
542
+ } else {
543
+ pinnedIds.add(id);
544
+ div.classList.add('pinned');
545
+ }
546
+ applyFilters();
547
+ });
548
+
549
+ const matches = matchesFilters(raw, includes, excludes);
550
+ div.style.display = matches ? '' : 'none';
551
+
552
+ container.appendChild(div);
553
+ trimBuffer();
554
+ if (tailing) container.scrollTop = container.scrollHeight;
555
+ };
556
+
557
+ container.addEventListener('scroll', () => {
558
+ const atBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 50;
559
+ tailing = atBottom;
560
+ });
561
+
562
+ let debounceTimer;
563
+ const debounce = (fn, delay) => {
564
+ clearTimeout(debounceTimer);
565
+ debounceTimer = setTimeout(fn, delay);
566
+ };
567
+
568
+ includeInput.addEventListener('input', () => debounce(applyFilters, 50));
569
+ excludeInput.addEventListener('input', () => debounce(applyFilters, 50));
570
+ highlightInput.addEventListener('input', () => debounce(applyFilters, 50));
571
+ <\/script>
572
+ `;
573
+ }
574
+ getHtmlLine(line) {
575
+ let html = this.ansiConverter.toHtml(line);
576
+ html = highlightJson(html);
577
+ html = linkifyUrls(html);
578
+ return `<script>addLine('${html.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}', '${stripAnsi(line).replaceAll("\\", "\\\\").replaceAll("'", "\\'")}')<\/script>\n`;
579
+ }
580
+ sendToClients(forWeb, timestamp) {
581
+ this.buffer.push({
582
+ line: forWeb,
583
+ timestamp
584
+ });
585
+ if (this.buffer.length > this.tailSize) this.buffer.shift();
586
+ for (const client of this.clients) if (client.isBrowser) client.response.write(this.getHtmlLine(forWeb));
587
+ else {
588
+ if (!matchesFilters(forWeb, client.includes, client.excludes)) continue;
589
+ client.response.write(stripAnsi(forWeb) + "\n");
590
+ }
591
+ }
592
+ };
593
+
594
+ //#endregion
595
+ //#region src/teemux.ts
596
+ const getTimestamp = () => performance.timeOrigin + performance.now();
597
+ const RESET = "\x1B[0m";
598
+ const RED = "\x1B[91m";
599
+ var LogClient = class {
600
+ name;
601
+ port;
602
+ queue = [];
603
+ sending = false;
604
+ constructor(name, port) {
605
+ this.name = name;
606
+ this.port = port;
607
+ }
608
+ async event(event, pid, code) {
609
+ await this.send("/event", {
610
+ code,
611
+ event,
612
+ name: this.name,
613
+ pid,
614
+ timestamp: getTimestamp()
615
+ });
616
+ }
617
+ async flush() {
618
+ if (this.sending || this.queue.length === 0) return;
619
+ this.sending = true;
620
+ while (this.queue.length > 0) {
621
+ const item = this.queue.shift();
622
+ if (!item) continue;
623
+ if (!await this.send("/log", {
624
+ line: item.line,
625
+ name: this.name,
626
+ timestamp: item.timestamp,
627
+ type: item.type
628
+ })) console.log(`[${this.name}] ${item.line}`);
629
+ }
630
+ this.sending = false;
631
+ }
632
+ log(line, type = "stdout") {
633
+ const errorPrefix = type === "stderr" ? `${RED}[ERR]${RESET} ` : "";
634
+ console.log(`${errorPrefix}${line}`);
635
+ this.queue.push({
636
+ line,
637
+ timestamp: getTimestamp(),
638
+ type
639
+ });
640
+ this.flush();
641
+ }
642
+ async send(endpoint, data) {
643
+ return new Promise((resolve) => {
644
+ const postData = JSON.stringify(data);
645
+ const request = http.request({
646
+ headers: {
647
+ "Content-Length": Buffer.byteLength(postData),
648
+ "Content-Type": "application/json"
649
+ },
650
+ hostname: "127.0.0.1",
651
+ method: "POST",
652
+ path: endpoint,
653
+ port: this.port,
654
+ timeout: 1e3
655
+ }, (response) => {
656
+ response.resume();
657
+ response.on("end", () => resolve(true));
658
+ });
659
+ request.on("error", () => resolve(false));
660
+ request.on("timeout", () => {
661
+ request.destroy();
662
+ resolve(false);
663
+ });
664
+ request.write(postData);
665
+ request.end();
666
+ });
667
+ }
668
+ };
669
+ const runProcess = async (name, command, client) => {
670
+ const [cmd, ...args] = command;
671
+ const child = spawn(cmd, args, {
672
+ env: {
673
+ ...process.env,
674
+ FORCE_COLOR: "1"
675
+ },
676
+ shell: process.platform === "win32",
677
+ stdio: [
678
+ "inherit",
679
+ "pipe",
680
+ "pipe"
681
+ ]
682
+ });
683
+ const pid = child.pid ?? 0;
684
+ await client.event("start", pid);
685
+ if (child.stdout) readline.createInterface({ input: child.stdout }).on("line", (line) => client.log(line, "stdout"));
686
+ if (child.stderr) readline.createInterface({ input: child.stderr }).on("line", (line) => client.log(line, "stderr"));
687
+ return new Promise((resolve) => {
688
+ child.on("close", async (code) => {
689
+ await client.flush();
690
+ await client.event("exit", pid, code ?? 0);
691
+ resolve(code ?? 0);
692
+ });
693
+ });
694
+ };
695
+ const sleep = (ms) => new Promise((resolve) => {
696
+ setTimeout(resolve, ms);
697
+ });
698
+ const checkServerReady = async (port) => {
699
+ return new Promise((resolve) => {
700
+ const request = http.request({
701
+ hostname: "127.0.0.1",
702
+ method: "GET",
703
+ path: "/",
704
+ port,
705
+ timeout: 200
706
+ }, (response) => {
707
+ response.resume();
708
+ resolve(true);
709
+ });
710
+ request.on("error", () => resolve(false));
711
+ request.on("timeout", () => {
712
+ request.destroy();
713
+ resolve(false);
714
+ });
715
+ request.end();
716
+ });
717
+ };
718
+ const waitForServer = async (port, maxAttempts = 50) => {
719
+ for (let index = 0; index < maxAttempts; index++) {
720
+ if (await checkServerReady(port)) return true;
721
+ await sleep(Math.min(10 * 2 ** index, 200));
722
+ }
723
+ return false;
724
+ };
725
+ const main = async () => {
726
+ const argv = await yargs(hideBin(process.argv)).env("TEEMUX").usage("Usage: $0 --name <name> -- <command> [args...]").option("name", {
727
+ alias: "n",
728
+ description: "Name to identify this process in logs (defaults to command)",
729
+ type: "string"
730
+ }).option("port", {
731
+ alias: "p",
732
+ default: 8336,
733
+ description: "Port for the log aggregation server",
734
+ type: "number"
735
+ }).option("tail", {
736
+ alias: "t",
737
+ default: 1e3,
738
+ description: "Number of log lines to keep in buffer",
739
+ type: "number"
740
+ }).help().parse();
741
+ const command = argv._;
742
+ if (command.length === 0) {
743
+ console.error("No command specified");
744
+ console.error("Usage: teemux --name <name> -- <command> [args...]");
745
+ process.exit(1);
746
+ }
747
+ const name = argv.name ?? command[0] ?? "unknown";
748
+ const port = argv.port;
749
+ const server = new LogServer(port, argv.tail);
750
+ let isServer = false;
751
+ const maxRetries = 3;
752
+ for (let attempt = 0; attempt < maxRetries; attempt++) try {
753
+ await server.start();
754
+ isServer = true;
755
+ break;
756
+ } catch (error) {
757
+ if (error.code !== "EADDRINUSE") throw error;
758
+ if (await checkServerReady(port)) break;
759
+ await sleep(50 + Math.random() * 100);
760
+ }
761
+ if (!isServer) {
762
+ if (!await waitForServer(port)) console.error("[teemux] Could not connect to server. Is another instance running?");
763
+ }
764
+ const exitCode = await runProcess(name, command, new LogClient(name, port));
765
+ process.exit(exitCode);
766
+ };
767
+ main().catch((error) => {
768
+ console.error("Fatal error:", error);
769
+ process.exit(1);
770
+ });
771
+
772
+ //#endregion
773
+ export { };
774
+ //# sourceMappingURL=teemux.js.map