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/src/teemux.ts ADDED
@@ -0,0 +1,303 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { LogServer } from './LogServer.js';
4
+ import { spawn } from 'node:child_process';
5
+ import http from 'node:http';
6
+ import { performance } from 'node:perf_hooks';
7
+ import readline from 'node:readline';
8
+ import yargs from 'yargs';
9
+ import { hideBin } from 'yargs/helpers';
10
+
11
+ // High-precision timestamp (milliseconds with microsecond precision)
12
+ const getTimestamp = (): number => performance.timeOrigin + performance.now();
13
+
14
+ const RESET = '\u001B[0m';
15
+ const RED = '\u001B[91m';
16
+
17
+ type LogType = 'stderr' | 'stdout';
18
+
19
+ class LogClient {
20
+ private name: string;
21
+
22
+ private port: number;
23
+
24
+ private queue: Array<{ line: string; timestamp: number; type: LogType }> = [];
25
+
26
+ private sending = false;
27
+
28
+ constructor(name: string, port: number) {
29
+ this.name = name;
30
+ this.port = port;
31
+ }
32
+
33
+ async event(
34
+ event: 'exit' | 'start',
35
+ pid: number,
36
+ code?: number,
37
+ ): Promise<void> {
38
+ await this.send('/event', {
39
+ code,
40
+ event,
41
+ name: this.name,
42
+ pid,
43
+ timestamp: getTimestamp(),
44
+ });
45
+ }
46
+
47
+ async flush(): Promise<void> {
48
+ if (this.sending || this.queue.length === 0) {
49
+ return;
50
+ }
51
+
52
+ this.sending = true;
53
+
54
+ while (this.queue.length > 0) {
55
+ const item = this.queue.shift();
56
+
57
+ if (!item) {
58
+ continue;
59
+ }
60
+
61
+ const success = await this.send('/log', {
62
+ line: item.line,
63
+ name: this.name,
64
+ timestamp: item.timestamp,
65
+ type: item.type,
66
+ });
67
+
68
+ if (!success) {
69
+ // Fallback to local output if server unreachable
70
+ // eslint-disable-next-line no-console
71
+ console.log(`[${this.name}] ${item.line}`);
72
+ }
73
+ }
74
+
75
+ this.sending = false;
76
+ }
77
+
78
+ log(line: string, type: LogType = 'stdout'): void {
79
+ // Always output locally
80
+ const errorPrefix = type === 'stderr' ? `${RED}[ERR]${RESET} ` : '';
81
+
82
+ // eslint-disable-next-line no-console
83
+ console.log(`${errorPrefix}${line}`);
84
+
85
+ // Capture timestamp immediately when log is received
86
+ this.queue.push({ line, timestamp: getTimestamp(), type });
87
+ void this.flush();
88
+ }
89
+
90
+ private async send(endpoint: string, data: object): Promise<boolean> {
91
+ return new Promise((resolve) => {
92
+ const postData = JSON.stringify(data);
93
+ const request = http.request(
94
+ {
95
+ headers: {
96
+ 'Content-Length': Buffer.byteLength(postData),
97
+ 'Content-Type': 'application/json',
98
+ },
99
+ hostname: '127.0.0.1',
100
+ method: 'POST',
101
+ path: endpoint,
102
+ port: this.port,
103
+ timeout: 1_000,
104
+ },
105
+ (response) => {
106
+ response.resume();
107
+ response.on('end', () => resolve(true));
108
+ },
109
+ );
110
+
111
+ request.on('error', () => resolve(false));
112
+ request.on('timeout', () => {
113
+ request.destroy();
114
+ resolve(false);
115
+ });
116
+ request.write(postData);
117
+ request.end();
118
+ });
119
+ }
120
+ }
121
+
122
+ const runProcess = async (
123
+ name: string,
124
+ command: string[],
125
+ client: LogClient,
126
+ ): Promise<number> => {
127
+ const [cmd, ...args] = command;
128
+
129
+ const child = spawn(cmd, args, {
130
+ env: {
131
+ ...process.env,
132
+ FORCE_COLOR: '1',
133
+ },
134
+ shell: process.platform === 'win32',
135
+ stdio: ['inherit', 'pipe', 'pipe'],
136
+ });
137
+
138
+ const pid = child.pid ?? 0;
139
+
140
+ await client.event('start', pid);
141
+
142
+ if (child.stdout) {
143
+ const rlStdout = readline.createInterface({ input: child.stdout });
144
+
145
+ rlStdout.on('line', (line) => client.log(line, 'stdout'));
146
+ }
147
+
148
+ if (child.stderr) {
149
+ const rlStderr = readline.createInterface({ input: child.stderr });
150
+
151
+ rlStderr.on('line', (line) => client.log(line, 'stderr'));
152
+ }
153
+
154
+ return new Promise((resolve) => {
155
+ child.on('close', async (code) => {
156
+ await client.flush();
157
+ await client.event('exit', pid, code ?? 0);
158
+ resolve(code ?? 0);
159
+ });
160
+ });
161
+ };
162
+
163
+ const sleep = (ms: number): Promise<void> =>
164
+ new Promise((resolve) => {
165
+ setTimeout(resolve, ms);
166
+ });
167
+
168
+ const checkServerReady = async (port: number): Promise<boolean> => {
169
+ return new Promise((resolve) => {
170
+ const request = http.request(
171
+ {
172
+ hostname: '127.0.0.1',
173
+ method: 'GET',
174
+ path: '/',
175
+ port,
176
+ timeout: 200,
177
+ },
178
+ (response) => {
179
+ response.resume();
180
+ resolve(true);
181
+ },
182
+ );
183
+
184
+ request.on('error', () => resolve(false));
185
+ request.on('timeout', () => {
186
+ request.destroy();
187
+ resolve(false);
188
+ });
189
+ request.end();
190
+ });
191
+ };
192
+
193
+ const waitForServer = async (
194
+ port: number,
195
+ maxAttempts = 50,
196
+ ): Promise<boolean> => {
197
+ for (let index = 0; index < maxAttempts; index++) {
198
+ if (await checkServerReady(port)) {
199
+ return true;
200
+ }
201
+
202
+ // Exponential backoff: 10ms, 20ms, 40ms, ... capped at 200ms
203
+ const delay = Math.min(10 * 2 ** index, 200);
204
+
205
+ await sleep(delay);
206
+ }
207
+
208
+ return false;
209
+ };
210
+
211
+ const main = async (): Promise<void> => {
212
+ const argv = await yargs(hideBin(process.argv))
213
+ .env('TEEMUX')
214
+ .usage('Usage: $0 --name <name> -- <command> [args...]')
215
+ .option('name', {
216
+ alias: 'n',
217
+ description:
218
+ 'Name to identify this process in logs (defaults to command)',
219
+ type: 'string',
220
+ })
221
+ .option('port', {
222
+ alias: 'p',
223
+ default: 8_336,
224
+ description: 'Port for the log aggregation server',
225
+ type: 'number',
226
+ })
227
+ .option('tail', {
228
+ alias: 't',
229
+ default: 1_000,
230
+ description: 'Number of log lines to keep in buffer',
231
+ type: 'number',
232
+ })
233
+ .help()
234
+ .parse();
235
+
236
+ const command = argv._ as string[];
237
+
238
+ if (command.length === 0) {
239
+ // eslint-disable-next-line no-console
240
+ console.error('No command specified');
241
+ // eslint-disable-next-line no-console
242
+ console.error('Usage: teemux --name <name> -- <command> [args...]');
243
+ process.exit(1);
244
+ }
245
+
246
+ const name = argv.name ?? command[0] ?? 'unknown';
247
+ const port = argv.port;
248
+
249
+ const server = new LogServer(port, argv.tail);
250
+
251
+ // Try to become server with retries - if port is taken, become client
252
+ let isServer = false;
253
+ const maxRetries = 3;
254
+
255
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
256
+ try {
257
+ await server.start();
258
+ isServer = true;
259
+ break;
260
+ } catch (error) {
261
+ if ((error as NodeJS.ErrnoException).code !== 'EADDRINUSE') {
262
+ throw error;
263
+ }
264
+
265
+ // Check if another server is actually running
266
+ if (await checkServerReady(port)) {
267
+ // Server exists, we're a client
268
+ break;
269
+ }
270
+
271
+ // Port in use but server not responding - might be starting up
272
+ // Add random jitter to avoid thundering herd
273
+ const jitter = Math.random() * 100;
274
+
275
+ await sleep(50 + jitter);
276
+ }
277
+ }
278
+
279
+ // If we're not the server, wait for it to be ready
280
+ if (!isServer) {
281
+ const serverReady = await waitForServer(port);
282
+
283
+ if (!serverReady) {
284
+ // eslint-disable-next-line no-console
285
+ console.error(
286
+ '[teemux] Could not connect to server. Is another instance running?',
287
+ );
288
+ }
289
+ }
290
+
291
+ const client = new LogClient(name, port);
292
+
293
+ // Run the process
294
+ const exitCode = await runProcess(name, command, client);
295
+
296
+ process.exit(exitCode);
297
+ };
298
+
299
+ main().catch((error: unknown) => {
300
+ // eslint-disable-next-line no-console
301
+ console.error('Fatal error:', error);
302
+ process.exit(1);
303
+ });
@@ -0,0 +1,113 @@
1
+ import { LogServer } from '../LogServer.js';
2
+ import http from 'node:http';
3
+
4
+ export type TeemuxContext = {
5
+ /**
6
+ * Inject an event (start/exit) for a named process.
7
+ */
8
+ injectEvent: (
9
+ name: string,
10
+ event: 'exit' | 'start',
11
+ pid?: number,
12
+ ) => Promise<void>;
13
+ /**
14
+ * Inject a log message for a named process.
15
+ */
16
+ injectLog: (name: string, message: string) => Promise<void>;
17
+ /**
18
+ * The port the server is running on.
19
+ */
20
+ port: number;
21
+ /**
22
+ * The full URL to the teemux server.
23
+ */
24
+ url: string;
25
+ };
26
+
27
+ export type TeemuxOptions = {
28
+ /**
29
+ * Port to run on. If 0 or undefined, auto-assigns an available port.
30
+ */
31
+ port?: number;
32
+ /**
33
+ * Number of log lines to keep in the buffer.
34
+ */
35
+ tail?: number;
36
+ };
37
+
38
+ const postJson = (
39
+ port: number,
40
+ path: string,
41
+ data: Record<string, unknown>,
42
+ ): Promise<void> => {
43
+ return new Promise((resolve, reject) => {
44
+ const postData = JSON.stringify(data);
45
+ const request = http.request(
46
+ {
47
+ headers: {
48
+ 'Content-Length': Buffer.byteLength(postData),
49
+ 'Content-Type': 'application/json',
50
+ },
51
+ hostname: '127.0.0.1',
52
+ method: 'POST',
53
+ path,
54
+ port,
55
+ },
56
+ (response) => {
57
+ response.resume();
58
+ response.on('end', () => resolve());
59
+ },
60
+ );
61
+
62
+ request.on('error', reject);
63
+ request.write(postData);
64
+ request.end();
65
+ });
66
+ };
67
+
68
+ /**
69
+ * Run a test with a teemux server.
70
+ *
71
+ * Starts a LogServer, provides a context for injecting logs,
72
+ * and ensures cleanup after the callback completes.
73
+ * @example
74
+ * ```typescript
75
+ * await runWithTeemux({ port: 9950 }, async (ctx) => {
76
+ * await ctx.injectLog('app', 'Hello world');
77
+ * await page.goto(ctx.url);
78
+ * // ... assertions
79
+ * });
80
+ * ```
81
+ */
82
+ export const runWithTeemux = async (
83
+ options: TeemuxOptions,
84
+ callback: (context: TeemuxContext) => Promise<void>,
85
+ ): Promise<void> => {
86
+ const server = new LogServer(options.port ?? 0, options.tail ?? 1_000);
87
+
88
+ await server.start();
89
+
90
+ const port = server.getPort();
91
+ const url = `http://127.0.0.1:${port}`;
92
+
93
+ const context: TeemuxContext = {
94
+ injectEvent: async (
95
+ name: string,
96
+ event: 'exit' | 'start',
97
+ pid?: number,
98
+ ) => {
99
+ await postJson(port, '/inject', { event, name, pid });
100
+ },
101
+ injectLog: async (name: string, message: string) => {
102
+ await postJson(port, '/inject', { message, name });
103
+ },
104
+ port,
105
+ url,
106
+ };
107
+
108
+ try {
109
+ await callback(context);
110
+ } finally {
111
+ await server.stop();
112
+ }
113
+ };
@@ -0,0 +1,205 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ highlightJson,
4
+ highlightJsonText,
5
+ syntaxHighlightJson,
6
+ } from './highlightJson.js';
7
+
8
+ describe('highlightJsonText', () => {
9
+ it('highlights string keys', () => {
10
+ const input = '{&quot;name&quot;:&quot;value&quot;}';
11
+ const result = highlightJsonText(input);
12
+
13
+ expect(result).toContain('<span class="json-key">&quot;name&quot;</span>');
14
+ });
15
+
16
+ it('highlights string values', () => {
17
+ const input = '{&quot;name&quot;:&quot;value&quot;}';
18
+ const result = highlightJsonText(input);
19
+
20
+ expect(result).toContain(
21
+ '<span class="json-string">&quot;value&quot;</span>',
22
+ );
23
+ });
24
+
25
+ it('highlights numbers', () => {
26
+ const input = '{&quot;count&quot;:42}';
27
+ const result = highlightJsonText(input);
28
+
29
+ expect(result).toContain('<span class="json-number">42</span>');
30
+ });
31
+
32
+ it('highlights negative numbers', () => {
33
+ const input = '{&quot;temp&quot;:-10}';
34
+ const result = highlightJsonText(input);
35
+
36
+ expect(result).toContain('<span class="json-number">-10</span>');
37
+ });
38
+
39
+ it('highlights decimal numbers', () => {
40
+ const input = '{&quot;price&quot;:19.99}';
41
+ const result = highlightJsonText(input);
42
+
43
+ expect(result).toContain('<span class="json-number">19.99</span>');
44
+ });
45
+
46
+ it('highlights exponential numbers', () => {
47
+ const input = '{&quot;big&quot;:1e10}';
48
+ const result = highlightJsonText(input);
49
+
50
+ expect(result).toContain('<span class="json-number">1e10</span>');
51
+ });
52
+
53
+ it('highlights boolean true', () => {
54
+ const input = '{&quot;active&quot;:true}';
55
+ const result = highlightJsonText(input);
56
+
57
+ expect(result).toContain('<span class="json-bool">true</span>');
58
+ });
59
+
60
+ it('highlights boolean false', () => {
61
+ const input = '{&quot;active&quot;:false}';
62
+ const result = highlightJsonText(input);
63
+
64
+ expect(result).toContain('<span class="json-bool">false</span>');
65
+ });
66
+
67
+ it('highlights null', () => {
68
+ const input = '{&quot;value&quot;:null}';
69
+ const result = highlightJsonText(input);
70
+
71
+ expect(result).toContain('<span class="json-bool">null</span>');
72
+ });
73
+
74
+ it('handles complex JSON with multiple types', () => {
75
+ const input =
76
+ '{&quot;name&quot;:&quot;test&quot;,&quot;count&quot;:42,&quot;active&quot;:true,&quot;data&quot;:null}';
77
+ const result = highlightJsonText(input);
78
+
79
+ expect(result).toContain('<span class="json-key">&quot;name&quot;</span>');
80
+ expect(result).toContain(
81
+ '<span class="json-string">&quot;test&quot;</span>',
82
+ );
83
+ expect(result).toContain('<span class="json-key">&quot;count&quot;</span>');
84
+ expect(result).toContain('<span class="json-number">42</span>');
85
+ expect(result).toContain(
86
+ '<span class="json-key">&quot;active&quot;</span>',
87
+ );
88
+ expect(result).toContain('<span class="json-bool">true</span>');
89
+ expect(result).toContain('<span class="json-key">&quot;data&quot;</span>');
90
+ expect(result).toContain('<span class="json-bool">null</span>');
91
+ });
92
+
93
+ it('does not double-wrap keys as strings', () => {
94
+ const input = '{&quot;key&quot;:&quot;value&quot;}';
95
+ const result = highlightJsonText(input);
96
+
97
+ // Key should only have json-key class, not json-string
98
+ expect(result).not.toContain('json-key"><span class="json-string"');
99
+ expect(result).not.toContain('json-string"><span class="json-key"');
100
+ });
101
+
102
+ it('handles empty string values', () => {
103
+ const input = '{&quot;empty&quot;:&quot;&quot;}';
104
+ const result = highlightJsonText(input);
105
+
106
+ expect(result).toContain(
107
+ '<span class="json-string">&quot;&quot;</span>',
108
+ );
109
+ });
110
+
111
+ it('handles arrays', () => {
112
+ const input = '[&quot;a&quot;,&quot;b&quot;,1,true]';
113
+ const result = highlightJsonText(input);
114
+
115
+ expect(result).toContain('<span class="json-string">&quot;a&quot;</span>');
116
+ expect(result).toContain('<span class="json-string">&quot;b&quot;</span>');
117
+ expect(result).toContain('<span class="json-number">1</span>');
118
+ expect(result).toContain('<span class="json-bool">true</span>');
119
+ });
120
+ });
121
+
122
+ describe('syntaxHighlightJson', () => {
123
+ it('preserves existing HTML tags while highlighting text', () => {
124
+ const input = '<span style="color:red">{&quot;key&quot;:42}</span>';
125
+ const result = syntaxHighlightJson(input);
126
+
127
+ expect(result).toContain('<span style="color:red">');
128
+ expect(result).toContain('</span>');
129
+ expect(result).toContain('<span class="json-key">');
130
+ expect(result).toContain('<span class="json-number">42</span>');
131
+ });
132
+
133
+ it('handles text without HTML tags', () => {
134
+ const input = '{&quot;key&quot;:&quot;value&quot;}';
135
+ const result = syntaxHighlightJson(input);
136
+
137
+ expect(result).toContain('<span class="json-key">&quot;key&quot;</span>');
138
+ expect(result).toContain(
139
+ '<span class="json-string">&quot;value&quot;</span>',
140
+ );
141
+ });
142
+ });
143
+
144
+ describe('highlightJson', () => {
145
+ it('highlights valid JSON with prefix', () => {
146
+ const input =
147
+ '<span style="color:#0AA">[app]</span> {&quot;status&quot;:&quot;ok&quot;}';
148
+ const result = highlightJson(input);
149
+
150
+ expect(result).toContain('<span style="color:#0AA">[app]</span>');
151
+ expect(result).toContain(
152
+ '<span class="json-key">&quot;status&quot;</span>',
153
+ );
154
+ expect(result).toContain(
155
+ '<span class="json-string">&quot;ok&quot;</span>',
156
+ );
157
+ });
158
+
159
+ it('returns original HTML for non-JSON content', () => {
160
+ const input = '<span>[app]</span> This is not JSON';
161
+ const result = highlightJson(input);
162
+
163
+ expect(result).toBe(input);
164
+ });
165
+
166
+ it('returns original HTML for invalid JSON', () => {
167
+ const input = '<span>[app]</span> {invalid json}';
168
+ const result = highlightJson(input);
169
+
170
+ expect(result).toBe(input);
171
+ });
172
+
173
+ it('highlights JSON array', () => {
174
+ const input = '<span>[app]</span> [1, 2, 3]';
175
+ const result = highlightJson(input);
176
+
177
+ expect(result).toContain('<span class="json-number">1</span>');
178
+ expect(result).toContain('<span class="json-number">2</span>');
179
+ expect(result).toContain('<span class="json-number">3</span>');
180
+ });
181
+
182
+ it('handles JSON without prefix', () => {
183
+ const input = '{&quot;direct&quot;:true}';
184
+ const result = highlightJson(input);
185
+
186
+ expect(result).toContain('<span class="json-key">&quot;direct&quot;</span>');
187
+ expect(result).toContain('<span class="json-bool">true</span>');
188
+ });
189
+
190
+ it('handles nested JSON objects', () => {
191
+ const input =
192
+ '{&quot;outer&quot;:{&quot;inner&quot;:&quot;value&quot;}}';
193
+ const result = highlightJson(input);
194
+
195
+ expect(result).toContain(
196
+ '<span class="json-key">&quot;outer&quot;</span>',
197
+ );
198
+ expect(result).toContain(
199
+ '<span class="json-key">&quot;inner&quot;</span>',
200
+ );
201
+ expect(result).toContain(
202
+ '<span class="json-string">&quot;value&quot;</span>',
203
+ );
204
+ });
205
+ });