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.
@@ -0,0 +1,612 @@
1
+ import { highlightJson } from './utils/highlightJson.js';
2
+ import { linkifyUrls } from './utils/linkifyUrls.js';
3
+ import { matchesFilters } from './utils/matchesFilters.js';
4
+ import { stripAnsi } from './utils/stripAnsi.js';
5
+ import Convert from 'ansi-to-html';
6
+ import http from 'node:http';
7
+ import { performance } from 'node:perf_hooks';
8
+ import { URL } from 'node:url';
9
+
10
+ const COLORS = [
11
+ '\u001B[36m',
12
+ '\u001B[33m',
13
+ '\u001B[32m',
14
+ '\u001B[35m',
15
+ '\u001B[34m',
16
+ '\u001B[91m',
17
+ '\u001B[92m',
18
+ '\u001B[93m',
19
+ ];
20
+ const RESET = '\u001B[0m';
21
+ const DIM = '\u001B[90m';
22
+ const RED = '\u001B[91m';
23
+ const HOST = '0.0.0.0';
24
+
25
+ type BufferedLog = {
26
+ line: string;
27
+ timestamp: number;
28
+ };
29
+
30
+ type EventPayload = {
31
+ code?: number;
32
+ event: 'exit' | 'start';
33
+ name: string;
34
+ pid: number;
35
+ timestamp: number;
36
+ };
37
+
38
+ type LogPayload = {
39
+ line: string;
40
+ name: string;
41
+ timestamp: number;
42
+ type: LogType;
43
+ };
44
+
45
+ type LogType = 'stderr' | 'stdout';
46
+
47
+ type StreamClient = {
48
+ excludes: string[];
49
+ includes: string[];
50
+ isBrowser: boolean;
51
+ response: http.ServerResponse;
52
+ };
53
+
54
+ export class LogServer {
55
+ private ansiConverter = new Convert({ escapeXML: true, newline: true });
56
+
57
+ private buffer: BufferedLog[] = [];
58
+
59
+ private clients = new Set<StreamClient>();
60
+
61
+ private colorIndex = 0;
62
+
63
+ private colorMap = new Map<string, string>();
64
+
65
+ private port: number;
66
+
67
+ private server: http.Server | null = null;
68
+
69
+ private tailSize: number;
70
+
71
+ constructor(port: number, tailSize: number = 1_000) {
72
+ this.port = port;
73
+ this.tailSize = tailSize;
74
+ }
75
+
76
+ getPort(): number {
77
+ if (this.server) {
78
+ const address = this.server.address();
79
+ if (address && typeof address === 'object') {
80
+ return address.port;
81
+ }
82
+ }
83
+
84
+ return this.port;
85
+ }
86
+
87
+ start(): Promise<void> {
88
+ return new Promise((resolve, reject) => {
89
+ this.server = http.createServer((request, response) => {
90
+ // Handle streaming GET request
91
+ if (request.method === 'GET' && request.url?.startsWith('/')) {
92
+ const url = new URL(request.url, `http://${request.headers.host}`);
93
+ const includeParameter = url.searchParams.get('include');
94
+ const includes = includeParameter
95
+ ? includeParameter
96
+ .split(',')
97
+ .map((term) => term.trim())
98
+ .filter(Boolean)
99
+ : [];
100
+ const excludeParameter = url.searchParams.get('exclude');
101
+ const excludes = excludeParameter
102
+ ? excludeParameter
103
+ .split(',')
104
+ .map((pattern) => pattern.trim())
105
+ .filter(Boolean)
106
+ : [];
107
+
108
+ const userAgent = request.headers['user-agent'] ?? '';
109
+ const isBrowser = userAgent.includes('Mozilla');
110
+
111
+ // Sort buffer by timestamp
112
+ const sortedBuffer = this.buffer.toSorted(
113
+ (a, b) => a.timestamp - b.timestamp,
114
+ );
115
+
116
+ if (isBrowser) {
117
+ // Browser: send all logs, filtering is done client-side
118
+ response.writeHead(200, {
119
+ 'Cache-Control': 'no-cache',
120
+ Connection: 'keep-alive',
121
+ 'Content-Type': 'text/html; charset=utf-8',
122
+ 'X-Content-Type-Options': 'nosniff',
123
+ });
124
+
125
+ // Send HTML header with styling
126
+ response.write(this.getHtmlHeader());
127
+
128
+ // Send all buffered logs as HTML
129
+ for (const entry of sortedBuffer) {
130
+ response.write(this.getHtmlLine(entry.line));
131
+ }
132
+ } else {
133
+ // Non-browser (curl, etc): apply server-side filtering
134
+ const filteredBuffer = sortedBuffer.filter((entry) =>
135
+ matchesFilters(entry.line, includes, excludes),
136
+ );
137
+
138
+ response.writeHead(200, {
139
+ 'Cache-Control': 'no-cache',
140
+ Connection: 'keep-alive',
141
+ 'Content-Type': 'text/plain; charset=utf-8',
142
+ 'X-Content-Type-Options': 'nosniff',
143
+ });
144
+
145
+ // Send filtered logs as plain text (strip ANSI)
146
+ for (const entry of filteredBuffer) {
147
+ response.write(stripAnsi(entry.line) + '\n');
148
+ }
149
+ }
150
+
151
+ // Add to clients for streaming
152
+ const client: StreamClient = {
153
+ excludes,
154
+ includes,
155
+ isBrowser,
156
+ response,
157
+ };
158
+
159
+ this.clients.add(client);
160
+
161
+ request.on('close', () => {
162
+ this.clients.delete(client);
163
+ });
164
+
165
+ return;
166
+ }
167
+
168
+ let body = '';
169
+
170
+ request.on('data', (chunk: Buffer) => {
171
+ body += chunk.toString();
172
+ });
173
+ request.on('end', () => {
174
+ if (request.method === 'POST' && request.url === '/log') {
175
+ try {
176
+ const { line, name, timestamp, type } = JSON.parse(
177
+ body,
178
+ ) as LogPayload;
179
+
180
+ this.broadcastLog(name, line, type, timestamp);
181
+ } catch {
182
+ // Ignore parse errors
183
+ }
184
+
185
+ response.writeHead(200);
186
+ response.end();
187
+ } else if (request.method === 'POST' && request.url === '/event') {
188
+ try {
189
+ const { code, event, name, pid, timestamp } = JSON.parse(
190
+ body,
191
+ ) as EventPayload;
192
+
193
+ if (event === 'start') {
194
+ this.broadcastEvent(name, `● started (pid ${pid})`, timestamp);
195
+ } else if (event === 'exit') {
196
+ this.broadcastEvent(name, `○ exited (code ${code})`, timestamp);
197
+ }
198
+ } catch {
199
+ // Ignore parse errors
200
+ }
201
+
202
+ response.writeHead(200);
203
+ response.end();
204
+ } else if (request.method === 'POST' && request.url === '/inject') {
205
+ // Test injection endpoint
206
+ try {
207
+ const data = JSON.parse(body) as {
208
+ event?: 'exit' | 'start';
209
+ message: string;
210
+ name: string;
211
+ pid?: number;
212
+ };
213
+ const timestamp = performance.timeOrigin + performance.now();
214
+
215
+ if (data.event === 'start') {
216
+ this.broadcastEvent(
217
+ data.name,
218
+ `● started (pid ${data.pid ?? 0})`,
219
+ timestamp,
220
+ );
221
+ } else if (data.event === 'exit') {
222
+ this.broadcastEvent(data.name, `○ exited (code 0)`, timestamp);
223
+ } else {
224
+ this.broadcastLog(data.name, data.message, 'stdout', timestamp);
225
+ }
226
+ } catch {
227
+ // Ignore parse errors
228
+ }
229
+
230
+ response.writeHead(200);
231
+ response.end();
232
+ } else {
233
+ response.writeHead(200);
234
+ response.end();
235
+ }
236
+ });
237
+ });
238
+
239
+ this.server.once('error', (error: NodeJS.ErrnoException) => {
240
+ reject(error);
241
+ });
242
+
243
+ this.server.listen(this.port, '0.0.0.0', () => {
244
+ // eslint-disable-next-line no-console
245
+ console.log(
246
+ `${DIM}[teemux] aggregating logs on http://${HOST}:${this.port}${RESET}`,
247
+ );
248
+ resolve();
249
+ });
250
+ });
251
+ }
252
+
253
+ stop(): Promise<void> {
254
+ return new Promise((resolve) => {
255
+ // Close all client connections
256
+ for (const client of this.clients) {
257
+ client.response.end();
258
+ }
259
+
260
+ this.clients.clear();
261
+
262
+ if (this.server) {
263
+ this.server.close(() => {
264
+ this.server = null;
265
+ resolve();
266
+ });
267
+ } else {
268
+ resolve();
269
+ }
270
+ });
271
+ }
272
+
273
+ private broadcastEvent(
274
+ name: string,
275
+ message: string,
276
+ timestamp: number,
277
+ ): void {
278
+ const color = this.getColor(name);
279
+ const forWeb = `${DIM}${color}[${name}]${RESET} ${DIM}${message}${RESET}`;
280
+
281
+ this.sendToClients(forWeb, timestamp);
282
+ }
283
+
284
+ private broadcastLog(
285
+ name: string,
286
+ line: string,
287
+ type: LogType,
288
+ timestamp: number,
289
+ ): void {
290
+ const color = this.getColor(name);
291
+ const errorPrefix = type === 'stderr' ? `${RED}[ERR]${RESET} ` : '';
292
+ const forWeb = `${color}[${name}]${RESET} ${errorPrefix}${line}`;
293
+
294
+ this.sendToClients(forWeb, timestamp);
295
+ }
296
+
297
+ private getColor(name: string): string {
298
+ if (!this.colorMap.has(name)) {
299
+ this.colorMap.set(name, COLORS[this.colorIndex++ % COLORS.length]);
300
+ }
301
+
302
+ return this.colorMap.get(name) ?? COLORS[0];
303
+ }
304
+
305
+ private getHtmlHeader(): string {
306
+ return `<!DOCTYPE html>
307
+ <html>
308
+ <head>
309
+ <meta charset="utf-8">
310
+ <title>teemux</title>
311
+ <style>
312
+ * { box-sizing: border-box; }
313
+ html, body {
314
+ height: 100%;
315
+ margin: 0;
316
+ overflow: hidden;
317
+ }
318
+ body {
319
+ background: #1e1e1e;
320
+ color: #d4d4d4;
321
+ font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
322
+ font-size: 12px;
323
+ line-height: 1.3;
324
+ display: flex;
325
+ flex-direction: column;
326
+ }
327
+ #filter-bar {
328
+ flex-shrink: 0;
329
+ display: flex;
330
+ gap: 8px;
331
+ padding: 8px 12px;
332
+ background: #252526;
333
+ border-bottom: 1px solid #3c3c3c;
334
+ }
335
+ #filter-bar label {
336
+ display: flex;
337
+ align-items: center;
338
+ gap: 6px;
339
+ color: #888;
340
+ }
341
+ #filter-bar input {
342
+ background: #1e1e1e;
343
+ border: 1px solid #3c3c3c;
344
+ border-radius: 3px;
345
+ color: #d4d4d4;
346
+ font-family: inherit;
347
+ font-size: 12px;
348
+ padding: 4px 8px;
349
+ width: 200px;
350
+ }
351
+ #filter-bar input:focus {
352
+ outline: none;
353
+ border-color: #007acc;
354
+ }
355
+ #container {
356
+ flex: 1;
357
+ overflow-y: auto;
358
+ padding: 8px 12px;
359
+ }
360
+ .line {
361
+ white-space: pre-wrap;
362
+ word-break: break-all;
363
+ padding: 1px 4px;
364
+ margin: 0 -4px;
365
+ border-radius: 2px;
366
+ position: relative;
367
+ display: flex;
368
+ align-items: flex-start;
369
+ }
370
+ .line:hover {
371
+ background: rgba(255, 255, 255, 0.05);
372
+ }
373
+ .line.pinned {
374
+ background: rgba(255, 204, 0, 0.1);
375
+ border-left: 2px solid #fc0;
376
+ margin-left: -6px;
377
+ padding-left: 6px;
378
+ }
379
+ .line-content {
380
+ flex: 1;
381
+ }
382
+ .pin-btn {
383
+ opacity: 0;
384
+ cursor: pointer;
385
+ padding: 0 4px;
386
+ color: #888;
387
+ flex-shrink: 0;
388
+ transition: opacity 0.15s;
389
+ }
390
+ .line:hover .pin-btn {
391
+ opacity: 0.5;
392
+ }
393
+ .pin-btn:hover {
394
+ opacity: 1 !important;
395
+ color: #fc0;
396
+ }
397
+ .line.pinned .pin-btn {
398
+ opacity: 1;
399
+ color: #fc0;
400
+ }
401
+ a { color: #4fc1ff; text-decoration: underline; }
402
+ a:hover { text-decoration: none; }
403
+ mark { background: #623800; color: inherit; border-radius: 2px; }
404
+ mark.filter { background: #264f00; }
405
+ .json-key { color: #9cdcfe; }
406
+ .json-string { color: #ce9178; }
407
+ .json-number { color: #b5cea8; }
408
+ .json-bool { color: #569cd6; }
409
+ .json-null { color: #569cd6; }
410
+ </style>
411
+ </head>
412
+ <body>
413
+ <div id="filter-bar">
414
+ <label>Include: <input type="text" id="include" placeholder="error*,warn* (OR, * = wildcard)"></label>
415
+ <label>Exclude: <input type="text" id="exclude" placeholder="health*,debug (OR, * = wildcard)"></label>
416
+ <label>Highlight: <input type="text" id="highlight" placeholder="term1,term2"></label>
417
+ </div>
418
+ <div id="container"></div>
419
+ <script>
420
+ const container = document.getElementById('container');
421
+ const includeInput = document.getElementById('include');
422
+ const excludeInput = document.getElementById('exclude');
423
+ const highlightInput = document.getElementById('highlight');
424
+ const params = new URLSearchParams(window.location.search);
425
+ const tailSize = ${this.tailSize};
426
+
427
+ includeInput.value = params.get('include') || '';
428
+ excludeInput.value = params.get('exclude') || '';
429
+ highlightInput.value = params.get('highlight') || '';
430
+
431
+ let tailing = true;
432
+ let pinnedIds = new Set();
433
+
434
+ // Lucide pin icon SVG
435
+ 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>';
436
+
437
+ const stripAnsi = (str) => str.replace(/\\u001B\\[[\\d;]*m/g, '');
438
+
439
+ const globToRegex = (pattern) => {
440
+ const escaped = pattern.replace(/([.+?^\${}()|[\\]\\\\])/g, '\\\\$1');
441
+ const regexPattern = escaped.replace(/\\*/g, '.*');
442
+ return new RegExp(regexPattern, 'i');
443
+ };
444
+
445
+ const matchesPattern = (text, pattern) => {
446
+ if (pattern.includes('*')) {
447
+ return globToRegex(pattern).test(text);
448
+ }
449
+ return text.includes(pattern.toLowerCase());
450
+ };
451
+
452
+ const matchesFilters = (text, includes, excludes) => {
453
+ const plain = stripAnsi(text).toLowerCase();
454
+ if (includes.length > 0) {
455
+ const anyMatch = includes.some(p => matchesPattern(plain, p));
456
+ if (!anyMatch) return false;
457
+ }
458
+ if (excludes.length > 0) {
459
+ const anyMatch = excludes.some(p => matchesPattern(plain, p));
460
+ if (anyMatch) return false;
461
+ }
462
+ return true;
463
+ };
464
+
465
+ const highlightTerms = (html, terms, className = '') => {
466
+ if (!terms.length) return html;
467
+ let result = html;
468
+ for (const term of terms) {
469
+ if (!term) continue;
470
+ const escaped = term.replace(/([.*+?^\${}()|[\\]\\\\])/g, '\\\\$1');
471
+ const regex = new RegExp('(?![^<]*>)(' + escaped + ')', 'gi');
472
+ const cls = className ? ' class="' + className + '"' : '';
473
+ result = result.replace(regex, '<mark' + cls + '>$1</mark>');
474
+ }
475
+ return result;
476
+ };
477
+
478
+ const applyFilters = () => {
479
+ const includes = includeInput.value.split(',').map(s => s.trim()).filter(Boolean);
480
+ const excludes = excludeInput.value.split(',').map(s => s.trim()).filter(Boolean);
481
+ const highlights = highlightInput.value.split(',').map(s => s.trim()).filter(Boolean);
482
+
483
+ document.querySelectorAll('.line').forEach(line => {
484
+ const id = line.dataset.id;
485
+ const isPinned = pinnedIds.has(id);
486
+ const text = line.dataset.raw;
487
+ const matches = matchesFilters(text, includes, excludes);
488
+ line.style.display = (matches || isPinned) ? '' : 'none';
489
+
490
+ // Re-apply highlighting
491
+ const contentEl = line.querySelector('.line-content');
492
+ if (contentEl) {
493
+ let html = line.dataset.html;
494
+ html = highlightTerms(html, includes, 'filter');
495
+ html = highlightTerms(html, highlights);
496
+ contentEl.innerHTML = html;
497
+ }
498
+ });
499
+
500
+ // Update URL without reload
501
+ const newParams = new URLSearchParams();
502
+ if (includeInput.value) newParams.set('include', includeInput.value);
503
+ if (excludeInput.value) newParams.set('exclude', excludeInput.value);
504
+ if (highlightInput.value) newParams.set('highlight', highlightInput.value);
505
+ const newUrl = newParams.toString() ? '?' + newParams.toString() : window.location.pathname;
506
+ history.replaceState(null, '', newUrl);
507
+ };
508
+
509
+ const trimBuffer = () => {
510
+ const lines = container.querySelectorAll('.line');
511
+ const unpinnedLines = Array.from(lines).filter(l => !pinnedIds.has(l.dataset.id));
512
+ const excess = unpinnedLines.length - tailSize;
513
+ if (excess > 0) {
514
+ for (let i = 0; i < excess; i++) {
515
+ unpinnedLines[i].remove();
516
+ }
517
+ }
518
+ };
519
+
520
+ let lineCounter = 0;
521
+ const addLine = (html, raw) => {
522
+ const id = 'line-' + (lineCounter++);
523
+ const includes = includeInput.value.split(',').map(s => s.trim()).filter(Boolean);
524
+ const excludes = excludeInput.value.split(',').map(s => s.trim()).filter(Boolean);
525
+ const highlights = highlightInput.value.split(',').map(s => s.trim()).filter(Boolean);
526
+
527
+ const div = document.createElement('div');
528
+ div.className = 'line';
529
+ div.dataset.id = id;
530
+ div.dataset.raw = raw;
531
+ div.dataset.html = html;
532
+
533
+ let displayHtml = html;
534
+ displayHtml = highlightTerms(displayHtml, includes, 'filter');
535
+ displayHtml = highlightTerms(displayHtml, highlights);
536
+
537
+ div.innerHTML = '<span class="line-content">' + displayHtml + '</span><span class="pin-btn" title="Pin">' + pinIcon + '</span>';
538
+
539
+ // Pin button handler
540
+ div.querySelector('.pin-btn').addEventListener('click', (e) => {
541
+ e.stopPropagation();
542
+ if (pinnedIds.has(id)) {
543
+ pinnedIds.delete(id);
544
+ div.classList.remove('pinned');
545
+ } else {
546
+ pinnedIds.add(id);
547
+ div.classList.add('pinned');
548
+ }
549
+ applyFilters();
550
+ });
551
+
552
+ const matches = matchesFilters(raw, includes, excludes);
553
+ div.style.display = matches ? '' : 'none';
554
+
555
+ container.appendChild(div);
556
+ trimBuffer();
557
+ if (tailing) container.scrollTop = container.scrollHeight;
558
+ };
559
+
560
+ container.addEventListener('scroll', () => {
561
+ const atBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 50;
562
+ tailing = atBottom;
563
+ });
564
+
565
+ let debounceTimer;
566
+ const debounce = (fn, delay) => {
567
+ clearTimeout(debounceTimer);
568
+ debounceTimer = setTimeout(fn, delay);
569
+ };
570
+
571
+ includeInput.addEventListener('input', () => debounce(applyFilters, 50));
572
+ excludeInput.addEventListener('input', () => debounce(applyFilters, 50));
573
+ highlightInput.addEventListener('input', () => debounce(applyFilters, 50));
574
+ </script>
575
+ `;
576
+ }
577
+
578
+ private getHtmlLine(line: string): string {
579
+ let html = this.ansiConverter.toHtml(line);
580
+ html = highlightJson(html);
581
+ html = linkifyUrls(html);
582
+ const escaped = html.replaceAll('\\', '\\\\').replaceAll("'", "\\'");
583
+ const raw = stripAnsi(line).replaceAll('\\', '\\\\').replaceAll("'", "\\'");
584
+ return `<script>addLine('${escaped}', '${raw}')</script>\n`;
585
+ }
586
+
587
+ private sendToClients(forWeb: string, timestamp: number): void {
588
+ // Add to buffer
589
+ this.buffer.push({ line: forWeb, timestamp });
590
+
591
+ // Trim buffer to tail size
592
+ if (this.buffer.length > this.tailSize) {
593
+ this.buffer.shift();
594
+ }
595
+
596
+ // Send to all connected clients
597
+ for (const client of this.clients) {
598
+ if (client.isBrowser) {
599
+ client.response.write(this.getHtmlLine(forWeb));
600
+ } else {
601
+ // Server-side filtering for non-browser clients
602
+ if (!matchesFilters(forWeb, client.includes, client.excludes)) {
603
+ continue;
604
+ }
605
+
606
+ client.response.write(stripAnsi(forWeb) + '\n');
607
+ }
608
+ }
609
+
610
+ // Note: Each client prints its own logs locally, so server doesn't need to
611
+ }
612
+ }
@@ -0,0 +1,17 @@
1
+ declare module 'ansi-to-html' {
2
+ interface Options {
3
+ bg?: string;
4
+ colors?: Record<number, string> | string[];
5
+ escapeXML?: boolean;
6
+ fg?: string;
7
+ newline?: boolean;
8
+ stream?: boolean;
9
+ }
10
+
11
+ class Convert {
12
+ constructor(options?: Options);
13
+ toHtml(input: string): string;
14
+ }
15
+
16
+ export default Convert;
17
+ }