teemux 1.0.0 → 1.1.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/README.md +13 -1
- package/dist/teemux.js +51 -8
- package/dist/teemux.js.map +1 -1
- package/package.json +27 -5
- package/src/ansi-to-html.d.ts +2 -2
- package/src/teemux.ts +103 -1
- package/src/utils/highlightJson.test.ts +9 -16
- package/src/utils/highlightJson.ts +3 -2
- package/src/utils/linkifyUrls.test.ts +1 -1
- package/src/utils/matchesFilters.ts +4 -4
- package/src/utils/stripAnsi.test.ts +1 -1
package/README.md
CHANGED
|
@@ -140,6 +140,18 @@ GET /health 200
|
|
|
140
140
|
|
|
141
141
|
## FAQ
|
|
142
142
|
|
|
143
|
+
### How does teemux work?
|
|
144
|
+
|
|
145
|
+
teemux uses automatic leader discovery to coordinate log aggregation across multiple processes:
|
|
146
|
+
|
|
147
|
+
1. **Leader Discovery**: When the first teemux process starts, it attempts to bind to the configured port (default 8336). If successful, it becomes the **leader** and starts the log aggregation server.
|
|
148
|
+
|
|
149
|
+
2. **Client Registration**: When subsequent teemux processes start, they detect the port is already in use, verify a server is responding, and automatically become **clients** that forward their logs to the leader.
|
|
150
|
+
|
|
151
|
+
3. **Leader Election**: If the leader process exits, clients detect this through periodic health checks (every 2 seconds). When a client detects the leader is gone, it attempts to become the new leader. Random jitter prevents multiple clients from racing to claim leadership simultaneously.
|
|
152
|
+
|
|
153
|
+
This design requires no configuration – just run multiple `teemux` commands and they automatically coordinate.
|
|
154
|
+
|
|
143
155
|
### Docker output appears corrupted with strange spacing
|
|
144
156
|
|
|
145
157
|
When running Docker with the `-t` flag, output may appear corrupted:
|
|
@@ -164,4 +176,4 @@ teemux --name db -- docker run --rm my-database
|
|
|
164
176
|
|
|
165
177
|
The flags:
|
|
166
178
|
- `-i` = keep stdin open (for interactive input) ✅
|
|
167
|
-
- `-t` = allocate pseudo-TTY (adds terminal formatting) ❌
|
|
179
|
+
- `-t` = allocate pseudo-TTY (adds terminal formatting) ❌
|
package/dist/teemux.js
CHANGED
|
@@ -75,7 +75,7 @@ const syntaxHighlightJson = (html) => {
|
|
|
75
75
|
*/
|
|
76
76
|
const highlightJson = (html) => {
|
|
77
77
|
const unescaped = unescapeHtml(stripHtmlTags(html));
|
|
78
|
-
const prefix =
|
|
78
|
+
const prefix = /^\[[\w-]+\]\s*/u.exec(unescaped)?.[0] ?? "";
|
|
79
79
|
const content = unescaped.slice(prefix.length).trim();
|
|
80
80
|
if (!content.startsWith("{") && !content.startsWith("[")) return html;
|
|
81
81
|
try {
|
|
@@ -83,7 +83,7 @@ const highlightJson = (html) => {
|
|
|
83
83
|
} catch {
|
|
84
84
|
return html;
|
|
85
85
|
}
|
|
86
|
-
const htmlPrefix =
|
|
86
|
+
const htmlPrefix = /^<span[^>]*>\[[^\]]+\]<\/span>\s*/u.exec(html)?.[0] ?? "";
|
|
87
87
|
return htmlPrefix + syntaxHighlightJson(html.slice(htmlPrefix.length));
|
|
88
88
|
};
|
|
89
89
|
|
|
@@ -120,8 +120,8 @@ const stripAnsi = (text) => {
|
|
|
120
120
|
* - All other characters are escaped for literal matching
|
|
121
121
|
*/
|
|
122
122
|
const globToRegex = (pattern) => {
|
|
123
|
-
const regexPattern = pattern.
|
|
124
|
-
return new RegExp(regexPattern, "
|
|
123
|
+
const regexPattern = pattern.replaceAll(/[$()+.?[\\\]^{|}]/gu, "\\$&").replaceAll("*", ".*");
|
|
124
|
+
return new RegExp(regexPattern, "iu");
|
|
125
125
|
};
|
|
126
126
|
/**
|
|
127
127
|
* Check if text matches a pattern (supports * glob wildcards).
|
|
@@ -133,7 +133,6 @@ const matchesPattern = (text, pattern) => {
|
|
|
133
133
|
};
|
|
134
134
|
/**
|
|
135
135
|
* Check if a line matches the given filter criteria.
|
|
136
|
-
*
|
|
137
136
|
* @param line - The line to check (may contain ANSI codes)
|
|
138
137
|
* @param includes - Patterns where ANY match includes the line (OR logic), case-insensitive. Supports * wildcards.
|
|
139
138
|
* @param excludes - Patterns where ANY match excludes the line (OR logic), case-insensitive. Supports * wildcards.
|
|
@@ -163,7 +162,7 @@ const COLORS = [
|
|
|
163
162
|
"\x1B[93m"
|
|
164
163
|
];
|
|
165
164
|
const RESET$1 = "\x1B[0m";
|
|
166
|
-
const DIM = "\x1B[90m";
|
|
165
|
+
const DIM$1 = "\x1B[90m";
|
|
167
166
|
const RED$1 = "\x1B[91m";
|
|
168
167
|
const HOST = "0.0.0.0";
|
|
169
168
|
var LogServer = class {
|
|
@@ -271,7 +270,7 @@ var LogServer = class {
|
|
|
271
270
|
reject(error);
|
|
272
271
|
});
|
|
273
272
|
this.server.listen(this.port, "0.0.0.0", () => {
|
|
274
|
-
console.log(`${DIM}[teemux] aggregating logs on http://${HOST}:${this.port}${RESET$1}`);
|
|
273
|
+
console.log(`${DIM$1}[teemux] aggregating logs on http://${HOST}:${this.port}${RESET$1}`);
|
|
275
274
|
resolve();
|
|
276
275
|
});
|
|
277
276
|
});
|
|
@@ -288,7 +287,7 @@ var LogServer = class {
|
|
|
288
287
|
});
|
|
289
288
|
}
|
|
290
289
|
broadcastEvent(name, message, timestamp) {
|
|
291
|
-
const forWeb = `${DIM}${this.getColor(name)}[${name}]${RESET$1} ${DIM}${message}${RESET$1}`;
|
|
290
|
+
const forWeb = `${DIM$1}${this.getColor(name)}[${name}]${RESET$1} ${DIM$1}${message}${RESET$1}`;
|
|
292
291
|
this.sendToClients(forWeb, timestamp);
|
|
293
292
|
}
|
|
294
293
|
broadcastLog(name, line, type, timestamp) {
|
|
@@ -595,7 +594,10 @@ var LogServer = class {
|
|
|
595
594
|
//#region src/teemux.ts
|
|
596
595
|
const getTimestamp = () => performance.timeOrigin + performance.now();
|
|
597
596
|
const RESET = "\x1B[0m";
|
|
597
|
+
const DIM = "\x1B[90m";
|
|
598
598
|
const RED = "\x1B[91m";
|
|
599
|
+
const LEADER_CHECK_INTERVAL = 2e3;
|
|
600
|
+
const MAX_PROMOTION_RETRIES = 3;
|
|
599
601
|
var LogClient = class {
|
|
600
602
|
name;
|
|
601
603
|
port;
|
|
@@ -722,6 +724,44 @@ const waitForServer = async (port, maxAttempts = 50) => {
|
|
|
722
724
|
}
|
|
723
725
|
return false;
|
|
724
726
|
};
|
|
727
|
+
const tryBecomeLeader = async (server) => {
|
|
728
|
+
for (let attempt = 0; attempt < MAX_PROMOTION_RETRIES; attempt++) try {
|
|
729
|
+
await server.start();
|
|
730
|
+
return true;
|
|
731
|
+
} catch (error) {
|
|
732
|
+
if (error.code !== "EADDRINUSE") throw error;
|
|
733
|
+
if (await checkServerReady(server.getPort())) return false;
|
|
734
|
+
await sleep(50 + Math.random() * 100);
|
|
735
|
+
}
|
|
736
|
+
return false;
|
|
737
|
+
};
|
|
738
|
+
const startLeaderMonitoring = (server, port) => {
|
|
739
|
+
let isRunning = true;
|
|
740
|
+
let timeoutId = null;
|
|
741
|
+
const checkAndPromote = async () => {
|
|
742
|
+
if (!isRunning) return;
|
|
743
|
+
if (!await checkServerReady(port) && isRunning) {
|
|
744
|
+
await sleep(Math.random() * 500);
|
|
745
|
+
if (isRunning && !await checkServerReady(port)) {
|
|
746
|
+
if (await tryBecomeLeader(server)) {
|
|
747
|
+
console.log(`${DIM}[teemux] promoted to leader, now aggregating logs${RESET}`);
|
|
748
|
+
isRunning = false;
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
if (isRunning) timeoutId = setTimeout(() => {
|
|
754
|
+
checkAndPromote();
|
|
755
|
+
}, LEADER_CHECK_INTERVAL);
|
|
756
|
+
};
|
|
757
|
+
timeoutId = setTimeout(() => {
|
|
758
|
+
checkAndPromote();
|
|
759
|
+
}, LEADER_CHECK_INTERVAL);
|
|
760
|
+
return { stop: () => {
|
|
761
|
+
isRunning = false;
|
|
762
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
763
|
+
} };
|
|
764
|
+
};
|
|
725
765
|
const main = async () => {
|
|
726
766
|
const argv = await yargs(hideBin(process.argv)).env("TEEMUX").usage("Usage: $0 --name <name> -- <command> [args...]").option("name", {
|
|
727
767
|
alias: "n",
|
|
@@ -758,10 +798,13 @@ const main = async () => {
|
|
|
758
798
|
if (await checkServerReady(port)) break;
|
|
759
799
|
await sleep(50 + Math.random() * 100);
|
|
760
800
|
}
|
|
801
|
+
let leaderMonitor = null;
|
|
761
802
|
if (!isServer) {
|
|
762
803
|
if (!await waitForServer(port)) console.error("[teemux] Could not connect to server. Is another instance running?");
|
|
804
|
+
leaderMonitor = startLeaderMonitoring(server, port);
|
|
763
805
|
}
|
|
764
806
|
const exitCode = await runProcess(name, command, new LogClient(name, port));
|
|
807
|
+
leaderMonitor?.stop();
|
|
765
808
|
process.exit(exitCode);
|
|
766
809
|
};
|
|
767
810
|
main().catch((error) => {
|
package/dist/teemux.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"teemux.js","names":["RESET","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('"', '\"')\n .replaceAll('&', '&')\n .replaceAll('<', '<')\n .replaceAll('>', '>')\n .replaceAll(''', \"'\")\n .replaceAll(''', \"'\");\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 (").\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 /"((?:(?!").)*)"/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\">"${content}"</span>${colon}`;\n }\n\n // This is a value\n return `<span class=\"json-string\">"${content}"</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 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 (" & 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('&', '&')\n .replaceAll('\"', '"');\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.replace(/[.+?^${}()|[\\]\\\\]/g, '\\\\$&');\n // Convert * to .*\n const regexPattern = escaped.replace(/\\*/g, '.*');\n return new RegExp(regexPattern, 'i');\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 return text.includes(pattern.toLowerCase());\n};\n\n/**\n * Check if a line matches the given filter criteria.\n *\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 </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 <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 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 // 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 \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 });\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 RED = '\\u001B[91m';\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 if (child.stdout) {\n const rlStdout = readline.createInterface({ input: child.stdout });\n\n rlStdout.on('line', (line) => client.log(line, 'stdout'));\n }\n\n if (child.stderr) {\n const rlStderr = readline.createInterface({ input: child.stderr });\n\n rlStderr.on('line', (line) => client.log(line, 'stderr'));\n }\n\n return new Promise((resolve) => {\n child.on('close', async (code) => {\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 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\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\n const client = new LogClient(name, port);\n\n // Run the process\n const exitCode = await runProcess(name, command, client);\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('"', '\"')\n .replaceAll('&', '&')\n .replaceAll('<', '<')\n .replaceAll('>', '>')\n .replaceAll(''', \"'\")\n .replaceAll(''', \"'\");\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 (").\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 /"((?:(?!").)*)"/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\">"${content}"</span>${colon}`;\n }\n\n // This is a value\n return `<span class=\"json-string\">"${content}"</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 (" & 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('&', '&')\n .replaceAll('\"', '"');\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 </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 <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 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 // 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 \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 });\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 if (child.stdout) {\n const rlStdout = readline.createInterface({ input: child.stdout });\n\n rlStdout.on('line', (line) => client.log(line, 'stdout'));\n }\n\n if (child.stderr) {\n const rlStderr = readline.createInterface({ input: child.stderr });\n\n rlStderr.on('line', (line) => client.log(line, 'stderr'));\n }\n\n return new Promise((resolve) => {\n child.on('close', async (code) => {\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 // Run the process\n const exitCode = await runProcess(name, command, client);\n\n // Stop leader monitoring if running\n leaderMonitor?.stop();\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":";;;;;;;;;;;;;;AAGA,MAAa,iBAAiB,SAAyB;AACrD,QAAO,KAAK,WAAW,aAAa,GAAG;;;;;;;;ACDzC,MAAa,gBAAgB,SAAyB;AACpD,QAAO,KACJ,WAAW,UAAU,KAAI,CACzB,WAAW,SAAS,IAAI,CACxB,WAAW,QAAQ,IAAI,CACvB,WAAW,QAAQ,IAAI,CACvB,WAAW,UAAU,IAAI,CACzB,WAAW,SAAS,IAAI;;;;;;;;;ACH7B,MAAa,qBAAqB,SAAyB;CAEzD,MAAM,UAAoB,EAAE;CAC5B,IAAI,SAAS,KAAK,WAChB,qCACC,QAAQ,YAAY;AACnB,UAAQ,KAAK,QAAkB;AAC/B,SAAO,YAAY,QAAQ,SAAS,EAAE;GAEzC;AAGD,UAAS,OAAO,WACd,2BACA,sCACD;AAGD,UAAS,OAAO,WACd,iDACA,wCACD;AAGD,UAAS,OAAO,WACd,0BACC,QAAQ,OAAO,UAAU;EACxB,MAAM,UAAU,QAAQ,OAAO,SAAS,OAAiB,GAAG;AAC5D,MAAI,MAEF,QAAO,gCAAgC,QAAQ,eAAe;AAIhE,SAAO,mCAAmC,QAAQ;GAErD;AAED,QAAO;;;;;AAMT,MAAa,uBAAuB,SAAyB;CAC3D,IAAI,SAAS;CACb,IAAI,QAAQ;AAEZ,QAAO,QAAQ,KAAK,OAClB,KAAI,KAAK,WAAW,KAAK;EAEvB,MAAM,SAAS,KAAK,QAAQ,KAAK,MAAM;AACvC,MAAI,WAAW,IAAI;AACjB,aAAU,KAAK,MAAM,MAAM;AAC3B;;AAGF,YAAU,KAAK,MAAM,OAAO,SAAS,EAAE;AACvC,UAAQ,SAAS;QACZ;EAEL,MAAM,UAAU,KAAK,QAAQ,KAAK,MAAM;EACxC,MAAM,UAAU,YAAY,KAAK,KAAK,SAAS;EAC/C,MAAM,OAAO,KAAK,MAAM,OAAO,QAAQ;AAGvC,YAAU,kBAAkB,KAAK;AACjC,UAAQ;;AAIZ,QAAO;;;;;;AAOT,MAAa,iBAAiB,SAAyB;CAKrD,MAAM,YAAY,aAHE,cAAc,KAAK,CAGI;CAI3C,MAAM,SADc,kBAAkB,KAAK,UAAU,GACxB,MAAM;CACnC,MAAM,UAAU,UAAU,MAAM,OAAO,OAAO,CAAC,MAAM;AAGrD,KAAI,CAAC,QAAQ,WAAW,IAAI,IAAI,CAAC,QAAQ,WAAW,IAAI,CACtD,QAAO;AAGT,KAAI;AACF,OAAK,MAAM,QAAQ;SACb;AACN,SAAO;;CAMT,MAAM,aADkB,qCAAqC,KAAK,KAAK,GAClC,MAAM;AAM3C,QAAO,aAFa,oBAHH,KAAK,MAAM,WAAW,OAAO,CAGG;;;;;;;;;;AC9GnD,MAAa,eAAe,SAAyB;AAMnD,QAAO,KAAK,WAFK,sDAEiB,QAAQ;EAExC,MAAM,WAAW,IAAI,QAAQ,kBAAkB,GAAG;EAClD,MAAM,WAAW,IAAI,MAAM,SAAS,OAAO;AAO3C,SAAO,YAJa,SACjB,WAAW,KAAK,QAAQ,CACxB,WAAW,MAAK,SAAS,CAEG,mCAAmC,SAAS,MAAM;GACjF;;;;;;;;;AClBJ,MAAa,aAAa,SAAyB;AAEjD,QAAO,KAAK,WAAW,qBAAqB,GAAG;;;;;;;;;;ACCjD,MAAM,eAAe,YAA4B;CAI/C,MAAM,eAFU,QAAQ,WAAW,uBAAuB,OAAO,CAEpC,WAAW,KAAK,KAAK;AAClD,QAAO,IAAI,OAAO,cAAc,KAAK;;;;;;AAOvC,MAAM,kBAAkB,MAAc,YAA6B;AACjE,KAAI,QAAQ,SAAS,IAAI,CACvB,QAAO,YAAY,QAAQ,CAAC,KAAK,KAAK;AAGxC,QAAO,KAAK,SAAS,QAAQ,aAAa,CAAC;;;;;;;;;AAU7C,MAAa,kBACX,MACA,UACA,aACY;CACZ,MAAM,YAAY,UAAU,KAAK,CAAC,aAAa;AAG/C,KAAI,SAAS,SAAS,GAKpB;MAAI,CAJsB,SAAS,MAAM,YACvC,eAAe,WAAW,QAAQ,CACnC,CAGC,QAAO;;AAKX,KAAI,SAAS,SAAS,GAKpB;MAJ0B,SAAS,MAAM,YACvC,eAAe,WAAW,QAAQ,CACnC,CAGC,QAAO;;AAIX,QAAO;;;;;ACtDT,MAAM,SAAS;CACb;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACD;AACD,MAAMA,UAAQ;AACd,MAAMC,QAAM;AACZ,MAAMC,QAAM;AACZ,MAAM,OAAO;AA+Bb,IAAa,YAAb,MAAuB;CACrB,AAAQ,gBAAgB,IAAI,QAAQ;EAAE,WAAW;EAAM,SAAS;EAAM,CAAC;CAEvE,AAAQ,SAAwB,EAAE;CAElC,AAAQ,0BAAU,IAAI,KAAmB;CAEzC,AAAQ,aAAa;CAErB,AAAQ,2BAAW,IAAI,KAAqB;CAE5C,AAAQ;CAER,AAAQ,SAA6B;CAErC,AAAQ;CAER,YAAY,MAAc,WAAmB,KAAO;AAClD,OAAK,OAAO;AACZ,OAAK,WAAW;;CAGlB,UAAkB;AAChB,MAAI,KAAK,QAAQ;GACf,MAAM,UAAU,KAAK,OAAO,SAAS;AACrC,OAAI,WAAW,OAAO,YAAY,SAChC,QAAO,QAAQ;;AAInB,SAAO,KAAK;;CAGd,QAAuB;AACrB,SAAO,IAAI,SAAS,SAAS,WAAW;AACtC,QAAK,SAAS,KAAK,cAAc,SAAS,aAAa;AAErD,QAAI,QAAQ,WAAW,SAAS,QAAQ,KAAK,WAAW,IAAI,EAAE;KAC5D,MAAM,MAAM,IAAI,IAAI,QAAQ,KAAK,UAAU,QAAQ,QAAQ,OAAO;KAClE,MAAM,mBAAmB,IAAI,aAAa,IAAI,UAAU;KACxD,MAAM,WAAW,mBACb,iBACG,MAAM,IAAI,CACV,KAAK,SAAS,KAAK,MAAM,CAAC,CAC1B,OAAO,QAAQ,GAClB,EAAE;KACN,MAAM,mBAAmB,IAAI,aAAa,IAAI,UAAU;KACxD,MAAM,WAAW,mBACb,iBACG,MAAM,IAAI,CACV,KAAK,YAAY,QAAQ,MAAM,CAAC,CAChC,OAAO,QAAQ,GAClB,EAAE;KAGN,MAAM,aADY,QAAQ,QAAQ,iBAAiB,IACvB,SAAS,UAAU;KAG/C,MAAM,eAAe,KAAK,OAAO,UAC9B,GAAG,MAAM,EAAE,YAAY,EAAE,UAC3B;AAED,SAAI,WAAW;AAEb,eAAS,UAAU,KAAK;OACtB,iBAAiB;OACjB,YAAY;OACZ,gBAAgB;OAChB,0BAA0B;OAC3B,CAAC;AAGF,eAAS,MAAM,KAAK,eAAe,CAAC;AAGpC,WAAK,MAAM,SAAS,aAClB,UAAS,MAAM,KAAK,YAAY,MAAM,KAAK,CAAC;YAEzC;MAEL,MAAM,iBAAiB,aAAa,QAAQ,UAC1C,eAAe,MAAM,MAAM,UAAU,SAAS,CAC/C;AAED,eAAS,UAAU,KAAK;OACtB,iBAAiB;OACjB,YAAY;OACZ,gBAAgB;OAChB,0BAA0B;OAC3B,CAAC;AAGF,WAAK,MAAM,SAAS,eAClB,UAAS,MAAM,UAAU,MAAM,KAAK,GAAG,KAAK;;KAKhD,MAAM,SAAuB;MAC3B;MACA;MACA;MACA;MACD;AAED,UAAK,QAAQ,IAAI,OAAO;AAExB,aAAQ,GAAG,eAAe;AACxB,WAAK,QAAQ,OAAO,OAAO;OAC3B;AAEF;;IAGF,IAAI,OAAO;AAEX,YAAQ,GAAG,SAAS,UAAkB;AACpC,aAAQ,MAAM,UAAU;MACxB;AACF,YAAQ,GAAG,aAAa;AACtB,SAAI,QAAQ,WAAW,UAAU,QAAQ,QAAQ,QAAQ;AACvD,UAAI;OACF,MAAM,EAAE,MAAM,MAAM,WAAW,SAAS,KAAK,MAC3C,KACD;AAED,YAAK,aAAa,MAAM,MAAM,MAAM,UAAU;cACxC;AAIR,eAAS,UAAU,IAAI;AACvB,eAAS,KAAK;gBACL,QAAQ,WAAW,UAAU,QAAQ,QAAQ,UAAU;AAChE,UAAI;OACF,MAAM,EAAE,MAAM,OAAO,MAAM,KAAK,cAAc,KAAK,MACjD,KACD;AAED,WAAI,UAAU,QACZ,MAAK,eAAe,MAAM,kBAAkB,IAAI,IAAI,UAAU;gBACrD,UAAU,OACnB,MAAK,eAAe,MAAM,kBAAkB,KAAK,IAAI,UAAU;cAE3D;AAIR,eAAS,UAAU,IAAI;AACvB,eAAS,KAAK;gBACL,QAAQ,WAAW,UAAU,QAAQ,QAAQ,WAAW;AAEjE,UAAI;OACF,MAAM,OAAO,KAAK,MAAM,KAAK;OAM7B,MAAM,YAAY,YAAY,aAAa,YAAY,KAAK;AAE5D,WAAI,KAAK,UAAU,QACjB,MAAK,eACH,KAAK,MACL,kBAAkB,KAAK,OAAO,EAAE,IAChC,UACD;gBACQ,KAAK,UAAU,OACxB,MAAK,eAAe,KAAK,MAAM,qBAAqB,UAAU;WAE9D,MAAK,aAAa,KAAK,MAAM,KAAK,SAAS,UAAU,UAAU;cAE3D;AAIR,eAAS,UAAU,IAAI;AACvB,eAAS,KAAK;YACT;AACL,eAAS,UAAU,IAAI;AACvB,eAAS,KAAK;;MAEhB;KACF;AAEF,QAAK,OAAO,KAAK,UAAU,UAAiC;AAC1D,WAAO,MAAM;KACb;AAEF,QAAK,OAAO,OAAO,KAAK,MAAM,iBAAiB;AAE7C,YAAQ,IACN,GAAGD,MAAI,sCAAsC,KAAK,GAAG,KAAK,OAAOD,UAClE;AACD,aAAS;KACT;IACF;;CAGJ,OAAsB;AACpB,SAAO,IAAI,SAAS,YAAY;AAE9B,QAAK,MAAM,UAAU,KAAK,QACxB,QAAO,SAAS,KAAK;AAGvB,QAAK,QAAQ,OAAO;AAEpB,OAAI,KAAK,OACP,MAAK,OAAO,YAAY;AACtB,SAAK,SAAS;AACd,aAAS;KACT;OAEF,UAAS;IAEX;;CAGJ,AAAQ,eACN,MACA,SACA,WACM;EAEN,MAAM,SAAS,GAAGC,QADJ,KAAK,SAAS,KAAK,CACH,GAAG,KAAK,GAAGD,QAAM,GAAGC,QAAM,UAAUD;AAElE,OAAK,cAAc,QAAQ,UAAU;;CAGvC,AAAQ,aACN,MACA,MACA,MACA,WACM;EAGN,MAAM,SAAS,GAFD,KAAK,SAAS,KAAK,CAET,GAAG,KAAK,GAAGA,QAAM,GADrB,SAAS,WAAW,GAAGE,MAAI,OAAOF,QAAM,KAAK,KACP;AAE1D,OAAK,cAAc,QAAQ,UAAU;;CAGvC,AAAQ,SAAS,MAAsB;AACrC,MAAI,CAAC,KAAK,SAAS,IAAI,KAAK,CAC1B,MAAK,SAAS,IAAI,MAAM,OAAO,KAAK,eAAe,OAAO,QAAQ;AAGpE,SAAO,KAAK,SAAS,IAAI,KAAK,IAAI,OAAO;;CAG3C,AAAQ,gBAAwB;AAC9B,SAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;uBAuHY,KAAK,SAAS;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAyJnC,AAAQ,YAAY,MAAsB;EACxC,IAAI,OAAO,KAAK,cAAc,OAAO,KAAK;AAC1C,SAAO,cAAc,KAAK;AAC1B,SAAO,YAAY,KAAK;AAGxB,SAAO,oBAFS,KAAK,WAAW,MAAM,OAAO,CAAC,WAAW,KAAK,MAAM,CAEjC,MADvB,UAAU,KAAK,CAAC,WAAW,MAAM,OAAO,CAAC,WAAW,KAAK,MAAM,CAC9B;;CAG/C,AAAQ,cAAc,QAAgB,WAAyB;AAE7D,OAAK,OAAO,KAAK;GAAE,MAAM;GAAQ;GAAW,CAAC;AAG7C,MAAI,KAAK,OAAO,SAAS,KAAK,SAC5B,MAAK,OAAO,OAAO;AAIrB,OAAK,MAAM,UAAU,KAAK,QACxB,KAAI,OAAO,UACT,QAAO,SAAS,MAAM,KAAK,YAAY,OAAO,CAAC;OAC1C;AAEL,OAAI,CAAC,eAAe,QAAQ,OAAO,UAAU,OAAO,SAAS,CAC3D;AAGF,UAAO,SAAS,MAAM,UAAU,OAAO,GAAG,KAAK;;;;;;;ACllBvD,MAAM,qBAA6B,YAAY,aAAa,YAAY,KAAK;AAE7E,MAAM,QAAQ;AACd,MAAM,MAAM;AACZ,MAAM,MAAM;AAGZ,MAAM,wBAAwB;AAC9B,MAAM,wBAAwB;AAI9B,IAAM,YAAN,MAAgB;CACd,AAAQ;CAER,AAAQ;CAER,AAAQ,QAAmE,EAAE;CAE7E,AAAQ,UAAU;CAElB,YAAY,MAAc,MAAc;AACtC,OAAK,OAAO;AACZ,OAAK,OAAO;;CAGd,MAAM,MACJ,OACA,KACA,MACe;AACf,QAAM,KAAK,KAAK,UAAU;GACxB;GACA;GACA,MAAM,KAAK;GACX;GACA,WAAW,cAAc;GAC1B,CAAC;;CAGJ,MAAM,QAAuB;AAC3B,MAAI,KAAK,WAAW,KAAK,MAAM,WAAW,EACxC;AAGF,OAAK,UAAU;AAEf,SAAO,KAAK,MAAM,SAAS,GAAG;GAC5B,MAAM,OAAO,KAAK,MAAM,OAAO;AAE/B,OAAI,CAAC,KACH;AAUF,OAAI,CAPY,MAAM,KAAK,KAAK,QAAQ;IACtC,MAAM,KAAK;IACX,MAAM,KAAK;IACX,WAAW,KAAK;IAChB,MAAM,KAAK;IACZ,CAAC,CAKA,SAAQ,IAAI,IAAI,KAAK,KAAK,IAAI,KAAK,OAAO;;AAI9C,OAAK,UAAU;;CAGjB,IAAI,MAAc,OAAgB,UAAgB;EAEhD,MAAM,cAAc,SAAS,WAAW,GAAG,IAAI,OAAO,MAAM,KAAK;AAGjE,UAAQ,IAAI,GAAG,cAAc,OAAO;AAGpC,OAAK,MAAM,KAAK;GAAE;GAAM,WAAW,cAAc;GAAE;GAAM,CAAC;AAC1D,EAAK,KAAK,OAAO;;CAGnB,MAAc,KAAK,UAAkB,MAAgC;AACnE,SAAO,IAAI,SAAS,YAAY;GAC9B,MAAM,WAAW,KAAK,UAAU,KAAK;GACrC,MAAM,UAAU,KAAK,QACnB;IACE,SAAS;KACP,kBAAkB,OAAO,WAAW,SAAS;KAC7C,gBAAgB;KACjB;IACD,UAAU;IACV,QAAQ;IACR,MAAM;IACN,MAAM,KAAK;IACX,SAAS;IACV,GACA,aAAa;AACZ,aAAS,QAAQ;AACjB,aAAS,GAAG,aAAa,QAAQ,KAAK,CAAC;KAE1C;AAED,WAAQ,GAAG,eAAe,QAAQ,MAAM,CAAC;AACzC,WAAQ,GAAG,iBAAiB;AAC1B,YAAQ,SAAS;AACjB,YAAQ,MAAM;KACd;AACF,WAAQ,MAAM,SAAS;AACvB,WAAQ,KAAK;IACb;;;AAIN,MAAM,aAAa,OACjB,MACA,SACA,WACoB;CACpB,MAAM,CAAC,KAAK,GAAG,QAAQ;CAEvB,MAAM,QAAQ,MAAM,KAAK,MAAM;EAC7B,KAAK;GACH,GAAG,QAAQ;GACX,aAAa;GACd;EACD,OAAO,QAAQ,aAAa;EAC5B,OAAO;GAAC;GAAW;GAAQ;GAAO;EACnC,CAAC;CAEF,MAAM,MAAM,MAAM,OAAO;AAEzB,OAAM,OAAO,MAAM,SAAS,IAAI;AAEhC,KAAI,MAAM,OAGR,CAFiB,SAAS,gBAAgB,EAAE,OAAO,MAAM,QAAQ,CAAC,CAEzD,GAAG,SAAS,SAAS,OAAO,IAAI,MAAM,SAAS,CAAC;AAG3D,KAAI,MAAM,OAGR,CAFiB,SAAS,gBAAgB,EAAE,OAAO,MAAM,QAAQ,CAAC,CAEzD,GAAG,SAAS,SAAS,OAAO,IAAI,MAAM,SAAS,CAAC;AAG3D,QAAO,IAAI,SAAS,YAAY;AAC9B,QAAM,GAAG,SAAS,OAAO,SAAS;AAChC,SAAM,OAAO,OAAO;AACpB,SAAM,OAAO,MAAM,QAAQ,KAAK,QAAQ,EAAE;AAC1C,WAAQ,QAAQ,EAAE;IAClB;GACF;;AAGJ,MAAM,SAAS,OACb,IAAI,SAAS,YAAY;AACvB,YAAW,SAAS,GAAG;EACvB;AAEJ,MAAM,mBAAmB,OAAO,SAAmC;AACjE,QAAO,IAAI,SAAS,YAAY;EAC9B,MAAM,UAAU,KAAK,QACnB;GACE,UAAU;GACV,QAAQ;GACR,MAAM;GACN;GACA,SAAS;GACV,GACA,aAAa;AACZ,YAAS,QAAQ;AACjB,WAAQ,KAAK;IAEhB;AAED,UAAQ,GAAG,eAAe,QAAQ,MAAM,CAAC;AACzC,UAAQ,GAAG,iBAAiB;AAC1B,WAAQ,SAAS;AACjB,WAAQ,MAAM;IACd;AACF,UAAQ,KAAK;GACb;;AAGJ,MAAM,gBAAgB,OACpB,MACA,cAAc,OACO;AACrB,MAAK,IAAI,QAAQ,GAAG,QAAQ,aAAa,SAAS;AAChD,MAAI,MAAM,iBAAiB,KAAK,CAC9B,QAAO;AAMT,QAAM,MAFQ,KAAK,IAAI,KAAK,KAAK,OAAO,IAAI,CAE1B;;AAGpB,QAAO;;AAGT,MAAM,kBAAkB,OAAO,WAAwC;AACrE,MAAK,IAAI,UAAU,GAAG,UAAU,uBAAuB,UACrD,KAAI;AACF,QAAM,OAAO,OAAO;AACpB,SAAO;UACA,OAAO;AACd,MAAK,MAAgC,SAAS,aAC5C,OAAM;AAIR,MAAI,MAAM,iBAAiB,OAAO,SAAS,CAAC,CAE1C,QAAO;AAOT,QAAM,MAAM,KAFG,KAAK,QAAQ,GAAG,IAEP;;AAI5B,QAAO;;AAGT,MAAM,yBACJ,QACA,SACyB;CACzB,IAAI,YAAY;CAChB,IAAI,YAAkD;CAEtD,MAAM,kBAAkB,YAA2B;AACjD,MAAI,CAAC,UACH;AAKF,MAAI,CAFgB,MAAM,iBAAiB,KAAK,IAE5B,WAAW;AAK7B,SAAM,MAFS,KAAK,QAAQ,GAAG,IAEZ;AAGnB,OAAI,aAAa,CAAE,MAAM,iBAAiB,KAAK,EAG7C;QAFiB,MAAM,gBAAgB,OAAO,EAEhC;AAEZ,aAAQ,IACN,GAAG,IAAI,mDAAmD,QAC3D;AAGD,iBAAY;AACZ;;;;AAMN,MAAI,UACF,aAAY,iBAAiB;AAC3B,GAAK,iBAAiB;KACrB,sBAAsB;;AAK7B,aAAY,iBAAiB;AAC3B,EAAK,iBAAiB;IACrB,sBAAsB;AAEzB,QAAO,EACL,YAAY;AACV,cAAY;AAEZ,MAAI,UACF,cAAa,UAAU;IAG5B;;AAGH,MAAM,OAAO,YAA2B;CACtC,MAAM,OAAO,MAAM,MAAM,QAAQ,QAAQ,KAAK,CAAC,CAC5C,IAAI,SAAS,CACb,MAAM,iDAAiD,CACvD,OAAO,QAAQ;EACd,OAAO;EACP,aACE;EACF,MAAM;EACP,CAAC,CACD,OAAO,QAAQ;EACd,OAAO;EACP,SAAS;EACT,aAAa;EACb,MAAM;EACP,CAAC,CACD,OAAO,QAAQ;EACd,OAAO;EACP,SAAS;EACT,aAAa;EACb,MAAM;EACP,CAAC,CACD,MAAM,CACN,OAAO;CAEV,MAAM,UAAU,KAAK;AAErB,KAAI,QAAQ,WAAW,GAAG;AAExB,UAAQ,MAAM,uBAAuB;AAErC,UAAQ,MAAM,qDAAqD;AACnE,UAAQ,KAAK,EAAE;;CAGjB,MAAM,OAAO,KAAK,QAAQ,QAAQ,MAAM;CACxC,MAAM,OAAO,KAAK;CAElB,MAAM,SAAS,IAAI,UAAU,MAAM,KAAK,KAAK;CAG7C,IAAI,WAAW;CACf,MAAM,aAAa;AAEnB,MAAK,IAAI,UAAU,GAAG,UAAU,YAAY,UAC1C,KAAI;AACF,QAAM,OAAO,OAAO;AACpB,aAAW;AACX;UACO,OAAO;AACd,MAAK,MAAgC,SAAS,aAC5C,OAAM;AAIR,MAAI,MAAM,iBAAiB,KAAK,CAE9B;AAOF,QAAM,MAAM,KAFG,KAAK,QAAQ,GAAG,IAEP;;CAK5B,IAAI,gBAA6C;AAEjD,KAAI,CAAC,UAAU;AAGb,MAAI,CAFgB,MAAM,cAAc,KAAK,CAI3C,SAAQ,MACN,qEACD;AAIH,kBAAgB,sBAAsB,QAAQ,KAAK;;CAMrD,MAAM,WAAW,MAAM,WAAW,MAAM,SAHzB,IAAI,UAAU,MAAM,KAAK,CAGgB;AAGxD,gBAAe,MAAM;AAErB,SAAQ,KAAK,SAAS;;AAGxB,MAAM,CAAC,OAAO,UAAmB;AAE/B,SAAQ,MAAM,gBAAgB,MAAM;AACpC,SAAQ,KAAK,EAAE;EACf"}
|
package/package.json
CHANGED
|
@@ -7,6 +7,28 @@
|
|
|
7
7
|
"bin": {
|
|
8
8
|
"teemux": "./dist/teemux.js"
|
|
9
9
|
},
|
|
10
|
+
"cspell": {
|
|
11
|
+
"words": [
|
|
12
|
+
"teemux",
|
|
13
|
+
"linkify",
|
|
14
|
+
"linkified",
|
|
15
|
+
"linkification",
|
|
16
|
+
"nosniff",
|
|
17
|
+
"noopener",
|
|
18
|
+
"Inconsolata",
|
|
19
|
+
"Fira",
|
|
20
|
+
"healthcheck",
|
|
21
|
+
"mred",
|
|
22
|
+
"mgreen",
|
|
23
|
+
"mbold",
|
|
24
|
+
"mdim",
|
|
25
|
+
"mcolor",
|
|
26
|
+
"mbright",
|
|
27
|
+
"minfo",
|
|
28
|
+
"merror",
|
|
29
|
+
"vitest"
|
|
30
|
+
]
|
|
31
|
+
},
|
|
10
32
|
"dependencies": {
|
|
11
33
|
"ansi-to-html": "^0.7.2",
|
|
12
34
|
"yargs": "^18.0.0"
|
|
@@ -52,9 +74,6 @@
|
|
|
52
74
|
"type": "git",
|
|
53
75
|
"url": "https://github.com/gajus/teemux"
|
|
54
76
|
},
|
|
55
|
-
"type": "module",
|
|
56
|
-
"types": "./dist/teemux.d.ts",
|
|
57
|
-
"version": "1.0.0",
|
|
58
77
|
"scripts": {
|
|
59
78
|
"build": "tsdown",
|
|
60
79
|
"dev": "tsdown --watch",
|
|
@@ -64,5 +83,8 @@
|
|
|
64
83
|
"lint:tsc": "tsc",
|
|
65
84
|
"test:playwright": "playwright test",
|
|
66
85
|
"test:vitest": "vitest --run --passWithNoTests"
|
|
67
|
-
}
|
|
68
|
-
|
|
86
|
+
},
|
|
87
|
+
"type": "module",
|
|
88
|
+
"types": "./dist/teemux.d.ts",
|
|
89
|
+
"version": "1.1.0"
|
|
90
|
+
}
|
package/src/ansi-to-html.d.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
declare module 'ansi-to-html' {
|
|
2
|
-
|
|
2
|
+
type Options = {
|
|
3
3
|
bg?: string;
|
|
4
4
|
colors?: Record<number, string> | string[];
|
|
5
5
|
escapeXML?: boolean;
|
|
6
6
|
fg?: string;
|
|
7
7
|
newline?: boolean;
|
|
8
8
|
stream?: boolean;
|
|
9
|
-
}
|
|
9
|
+
};
|
|
10
10
|
|
|
11
11
|
class Convert {
|
|
12
12
|
constructor(options?: Options);
|
package/src/teemux.ts
CHANGED
|
@@ -12,8 +12,13 @@ import { hideBin } from 'yargs/helpers';
|
|
|
12
12
|
const getTimestamp = (): number => performance.timeOrigin + performance.now();
|
|
13
13
|
|
|
14
14
|
const RESET = '\u001B[0m';
|
|
15
|
+
const DIM = '\u001B[90m';
|
|
15
16
|
const RED = '\u001B[91m';
|
|
16
17
|
|
|
18
|
+
// Leader monitoring configuration
|
|
19
|
+
const LEADER_CHECK_INTERVAL = 2_000; // Check every 2 seconds
|
|
20
|
+
const MAX_PROMOTION_RETRIES = 3;
|
|
21
|
+
|
|
17
22
|
type LogType = 'stderr' | 'stdout';
|
|
18
23
|
|
|
19
24
|
class LogClient {
|
|
@@ -208,6 +213,95 @@ const waitForServer = async (
|
|
|
208
213
|
return false;
|
|
209
214
|
};
|
|
210
215
|
|
|
216
|
+
const tryBecomeLeader = async (server: LogServer): Promise<boolean> => {
|
|
217
|
+
for (let attempt = 0; attempt < MAX_PROMOTION_RETRIES; attempt++) {
|
|
218
|
+
try {
|
|
219
|
+
await server.start();
|
|
220
|
+
return true;
|
|
221
|
+
} catch (error) {
|
|
222
|
+
if ((error as NodeJS.ErrnoException).code !== 'EADDRINUSE') {
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Check if another server took over
|
|
227
|
+
if (await checkServerReady(server.getPort())) {
|
|
228
|
+
// Another process became leader
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Port in use but server not responding - might be starting up
|
|
233
|
+
// Add random jitter to avoid thundering herd
|
|
234
|
+
const jitter = Math.random() * 100;
|
|
235
|
+
|
|
236
|
+
await sleep(50 + jitter);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return false;
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const startLeaderMonitoring = (
|
|
244
|
+
server: LogServer,
|
|
245
|
+
port: number,
|
|
246
|
+
): { stop: () => void } => {
|
|
247
|
+
let isRunning = true;
|
|
248
|
+
let timeoutId: null | ReturnType<typeof setTimeout> = null;
|
|
249
|
+
|
|
250
|
+
const checkAndPromote = async (): Promise<void> => {
|
|
251
|
+
if (!isRunning) {
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const serverAlive = await checkServerReady(port);
|
|
256
|
+
|
|
257
|
+
if (!serverAlive && isRunning) {
|
|
258
|
+
// Leader might be dead, try to become leader
|
|
259
|
+
// Add random jitter to prevent all clients from trying simultaneously
|
|
260
|
+
const jitter = Math.random() * 500;
|
|
261
|
+
|
|
262
|
+
await sleep(jitter);
|
|
263
|
+
|
|
264
|
+
// Double-check server is still down after jitter
|
|
265
|
+
if (isRunning && !(await checkServerReady(port))) {
|
|
266
|
+
const promoted = await tryBecomeLeader(server);
|
|
267
|
+
|
|
268
|
+
if (promoted) {
|
|
269
|
+
// eslint-disable-next-line no-console
|
|
270
|
+
console.log(
|
|
271
|
+
`${DIM}[teemux] promoted to leader, now aggregating logs${RESET}`,
|
|
272
|
+
);
|
|
273
|
+
// Stop monitoring - we're now the leader
|
|
274
|
+
// eslint-disable-next-line require-atomic-updates -- safe: only modified here or in stop()
|
|
275
|
+
isRunning = false;
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Schedule next check
|
|
282
|
+
if (isRunning) {
|
|
283
|
+
timeoutId = setTimeout(() => {
|
|
284
|
+
void checkAndPromote();
|
|
285
|
+
}, LEADER_CHECK_INTERVAL);
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
// Start monitoring after initial delay
|
|
290
|
+
timeoutId = setTimeout(() => {
|
|
291
|
+
void checkAndPromote();
|
|
292
|
+
}, LEADER_CHECK_INTERVAL);
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
stop: () => {
|
|
296
|
+
isRunning = false;
|
|
297
|
+
|
|
298
|
+
if (timeoutId) {
|
|
299
|
+
clearTimeout(timeoutId);
|
|
300
|
+
}
|
|
301
|
+
},
|
|
302
|
+
};
|
|
303
|
+
};
|
|
304
|
+
|
|
211
305
|
const main = async (): Promise<void> => {
|
|
212
306
|
const argv = await yargs(hideBin(process.argv))
|
|
213
307
|
.env('TEEMUX')
|
|
@@ -276,7 +370,9 @@ const main = async (): Promise<void> => {
|
|
|
276
370
|
}
|
|
277
371
|
}
|
|
278
372
|
|
|
279
|
-
// If we're not the server, wait for it to be ready
|
|
373
|
+
// If we're not the server, wait for it to be ready and start monitoring
|
|
374
|
+
let leaderMonitor: null | { stop: () => void } = null;
|
|
375
|
+
|
|
280
376
|
if (!isServer) {
|
|
281
377
|
const serverReady = await waitForServer(port);
|
|
282
378
|
|
|
@@ -286,6 +382,9 @@ const main = async (): Promise<void> => {
|
|
|
286
382
|
'[teemux] Could not connect to server. Is another instance running?',
|
|
287
383
|
);
|
|
288
384
|
}
|
|
385
|
+
|
|
386
|
+
// Start monitoring for leader failover
|
|
387
|
+
leaderMonitor = startLeaderMonitoring(server, port);
|
|
289
388
|
}
|
|
290
389
|
|
|
291
390
|
const client = new LogClient(name, port);
|
|
@@ -293,6 +392,9 @@ const main = async (): Promise<void> => {
|
|
|
293
392
|
// Run the process
|
|
294
393
|
const exitCode = await runProcess(name, command, client);
|
|
295
394
|
|
|
395
|
+
// Stop leader monitoring if running
|
|
396
|
+
leaderMonitor?.stop();
|
|
397
|
+
|
|
296
398
|
process.exit(exitCode);
|
|
297
399
|
};
|
|
298
400
|
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
1
|
import {
|
|
3
2
|
highlightJson,
|
|
4
3
|
highlightJsonText,
|
|
5
4
|
syntaxHighlightJson,
|
|
6
5
|
} from './highlightJson.js';
|
|
6
|
+
import { describe, expect, it } from 'vitest';
|
|
7
7
|
|
|
8
8
|
describe('highlightJsonText', () => {
|
|
9
9
|
it('highlights string keys', () => {
|
|
@@ -103,9 +103,7 @@ describe('highlightJsonText', () => {
|
|
|
103
103
|
const input = '{"empty":""}';
|
|
104
104
|
const result = highlightJsonText(input);
|
|
105
105
|
|
|
106
|
-
expect(result).toContain(
|
|
107
|
-
'<span class="json-string">""</span>',
|
|
108
|
-
);
|
|
106
|
+
expect(result).toContain('<span class="json-string">""</span>');
|
|
109
107
|
});
|
|
110
108
|
|
|
111
109
|
it('handles arrays', () => {
|
|
@@ -151,9 +149,7 @@ describe('highlightJson', () => {
|
|
|
151
149
|
expect(result).toContain(
|
|
152
150
|
'<span class="json-key">"status"</span>',
|
|
153
151
|
);
|
|
154
|
-
expect(result).toContain(
|
|
155
|
-
'<span class="json-string">"ok"</span>',
|
|
156
|
-
);
|
|
152
|
+
expect(result).toContain('<span class="json-string">"ok"</span>');
|
|
157
153
|
});
|
|
158
154
|
|
|
159
155
|
it('returns original HTML for non-JSON content', () => {
|
|
@@ -183,21 +179,18 @@ describe('highlightJson', () => {
|
|
|
183
179
|
const input = '{"direct":true}';
|
|
184
180
|
const result = highlightJson(input);
|
|
185
181
|
|
|
186
|
-
expect(result).toContain(
|
|
182
|
+
expect(result).toContain(
|
|
183
|
+
'<span class="json-key">"direct"</span>',
|
|
184
|
+
);
|
|
187
185
|
expect(result).toContain('<span class="json-bool">true</span>');
|
|
188
186
|
});
|
|
189
187
|
|
|
190
188
|
it('handles nested JSON objects', () => {
|
|
191
|
-
const input =
|
|
192
|
-
'{"outer":{"inner":"value"}}';
|
|
189
|
+
const input = '{"outer":{"inner":"value"}}';
|
|
193
190
|
const result = highlightJson(input);
|
|
194
191
|
|
|
195
|
-
expect(result).toContain(
|
|
196
|
-
|
|
197
|
-
);
|
|
198
|
-
expect(result).toContain(
|
|
199
|
-
'<span class="json-key">"inner"</span>',
|
|
200
|
-
);
|
|
192
|
+
expect(result).toContain('<span class="json-key">"outer"</span>');
|
|
193
|
+
expect(result).toContain('<span class="json-key">"inner"</span>');
|
|
201
194
|
expect(result).toContain(
|
|
202
195
|
'<span class="json-string">"value"</span>',
|
|
203
196
|
);
|
|
@@ -61,6 +61,7 @@ export const syntaxHighlightJson = (html: string): string => {
|
|
|
61
61
|
result += html.slice(index);
|
|
62
62
|
break;
|
|
63
63
|
}
|
|
64
|
+
|
|
64
65
|
result += html.slice(index, tagEnd + 1);
|
|
65
66
|
index = tagEnd + 1;
|
|
66
67
|
} else {
|
|
@@ -90,7 +91,7 @@ export const highlightJson = (html: string): string => {
|
|
|
90
91
|
const unescaped = unescapeHtml(textContent);
|
|
91
92
|
|
|
92
93
|
// Find where the actual log content starts (after the prefix like [name])
|
|
93
|
-
const prefixMatch =
|
|
94
|
+
const prefixMatch = /^\[[\w-]+\]\s*/u.exec(unescaped);
|
|
94
95
|
const prefix = prefixMatch?.[0] ?? '';
|
|
95
96
|
const content = unescaped.slice(prefix.length).trim();
|
|
96
97
|
|
|
@@ -107,7 +108,7 @@ export const highlightJson = (html: string): string => {
|
|
|
107
108
|
|
|
108
109
|
// It's valid JSON - now highlight it
|
|
109
110
|
// Find the position after the prefix span in the HTML
|
|
110
|
-
const prefixHtmlMatch =
|
|
111
|
+
const prefixHtmlMatch = /^<span[^>]*>\[[^\]]+\]<\/span>\s*/u.exec(html);
|
|
111
112
|
const htmlPrefix = prefixHtmlMatch?.[0] ?? '';
|
|
112
113
|
const jsonHtml = html.slice(htmlPrefix.length);
|
|
113
114
|
|
|
@@ -7,10 +7,10 @@ import { stripAnsi } from './stripAnsi.js';
|
|
|
7
7
|
*/
|
|
8
8
|
const globToRegex = (pattern: string): RegExp => {
|
|
9
9
|
// Escape regex special characters except *
|
|
10
|
-
const escaped = pattern.
|
|
10
|
+
const escaped = pattern.replaceAll(/[$()+.?[\\\]^{|}]/gu, '\\$&');
|
|
11
11
|
// Convert * to .*
|
|
12
|
-
const regexPattern = escaped.
|
|
13
|
-
return new RegExp(regexPattern, '
|
|
12
|
+
const regexPattern = escaped.replaceAll('*', '.*');
|
|
13
|
+
return new RegExp(regexPattern, 'iu');
|
|
14
14
|
};
|
|
15
15
|
|
|
16
16
|
/**
|
|
@@ -21,12 +21,12 @@ const matchesPattern = (text: string, pattern: string): boolean => {
|
|
|
21
21
|
if (pattern.includes('*')) {
|
|
22
22
|
return globToRegex(pattern).test(text);
|
|
23
23
|
}
|
|
24
|
+
|
|
24
25
|
return text.includes(pattern.toLowerCase());
|
|
25
26
|
};
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
29
|
* Check if a line matches the given filter criteria.
|
|
29
|
-
*
|
|
30
30
|
* @param line - The line to check (may contain ANSI codes)
|
|
31
31
|
* @param includes - Patterns where ANY match includes the line (OR logic), case-insensitive. Supports * wildcards.
|
|
32
32
|
* @param excludes - Patterns where ANY match excludes the line (OR logic), case-insensitive. Supports * wildcards.
|