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