@askalf/dario 3.38.6 → 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,206 @@
1
+ /**
2
+ * TUI input handling — stdin raw-mode key parser.
3
+ *
4
+ * Why not Node's `readline.emitKeypressEvents`: it works, but the Key
5
+ * shape (`{ name, ctrl, meta, sequence }`) is loosely typed, the
6
+ * legacy event flag is awkward to disable cleanly, and its escape-
7
+ * sequence parser has historically lagged on edge cases (Windows
8
+ * Terminal modifyOtherKeys, Kitty progressive enhancement, etc).
9
+ *
10
+ * Writing ~150 lines that handle the keys we ACTUALLY use is more
11
+ * predictable. The keys we care about:
12
+ *
13
+ * - Printable ASCII (0x20-0x7e)
14
+ * - Enter, Tab, Backspace, Escape
15
+ * - Arrow up/down/left/right
16
+ * - Home, End, PgUp, PgDn
17
+ * - Ctrl+C, Ctrl+D (exit), Ctrl+L (redraw)
18
+ *
19
+ * Standalone Esc vs Esc-led sequence (e.g. arrow): we use the same
20
+ * heuristic xterm uses — if ESC arrives in the same buffer chunk as
21
+ * subsequent bytes, treat as a CSI sequence. If ESC arrives alone in
22
+ * a chunk, treat as a standalone Escape keypress. This is reliable
23
+ * on real terminals and avoids the alternative (a wait-timer + lookahead)
24
+ * which adds complexity and a delay every Esc keypress.
25
+ */
26
+ /**
27
+ * Parse one stdin chunk into zero or more Key events. Pure function;
28
+ * the caller drives stdin and accumulates the result. Most chunks
29
+ * yield exactly one key (interactive typing); paste / IME burst can
30
+ * yield several.
31
+ */
32
+ export function parseKeys(chunk) {
33
+ const keys = [];
34
+ let i = 0;
35
+ while (i < chunk.length) {
36
+ const b = chunk[i];
37
+ // Backspace: ASCII 0x7f on most terminals, 0x08 (BS / Ctrl+H) on
38
+ // Windows console + a few older ttys. Must check 0x08 BEFORE the
39
+ // Ctrl+letter range below, otherwise BS gets reported as Ctrl+H.
40
+ if (b === 0x7f || b === 0x08) {
41
+ keys.push(k('backspace', ''));
42
+ i++;
43
+ continue;
44
+ }
45
+ // Control keys (Ctrl+letter): byte 0x01-0x1a (A-Z masked with 0x1f)
46
+ if (b >= 0x01 && b <= 0x1a) {
47
+ // Special-case the named ones that aren't really "Ctrl+letter".
48
+ if (b === 0x09) {
49
+ keys.push(k('tab', '\t'));
50
+ i++;
51
+ continue;
52
+ }
53
+ if (b === 0x0a || b === 0x0d) {
54
+ keys.push(k('enter', '\n'));
55
+ i++;
56
+ continue;
57
+ }
58
+ // The rest are Ctrl+A through Ctrl+Z (skipping the named ones).
59
+ keys.push({
60
+ name: 'printable', ch: String.fromCharCode(b + 96), // 1 → 'a'
61
+ ctrl: true, shift: false, meta: false,
62
+ });
63
+ i++;
64
+ continue;
65
+ }
66
+ // Escape — either standalone or the start of a CSI sequence.
67
+ if (b === 0x1b) {
68
+ // Look at what follows in this same chunk
69
+ if (i + 1 >= chunk.length) {
70
+ // ESC alone in this chunk → standalone Escape keypress.
71
+ keys.push(k('escape', ''));
72
+ i++;
73
+ continue;
74
+ }
75
+ // ESC + '[' → CSI sequence. Read up to the terminating byte
76
+ // (0x40-0x7e per ECMA-48).
77
+ if (chunk[i + 1] === 0x5b /* '[' */ || chunk[i + 1] === 0x4f /* 'O' for SS3 */) {
78
+ const seqStart = i;
79
+ const ss3 = chunk[i + 1] === 0x4f;
80
+ i += 2;
81
+ let paramBytes = '';
82
+ while (i < chunk.length) {
83
+ const c = chunk[i];
84
+ if (c >= 0x40 && c <= 0x7e) {
85
+ const final = String.fromCharCode(c);
86
+ i++;
87
+ keys.push(parseCsi(final, paramBytes, ss3, chunk.slice(seqStart, i)));
88
+ break;
89
+ }
90
+ paramBytes += String.fromCharCode(c);
91
+ i++;
92
+ }
93
+ continue;
94
+ }
95
+ // ESC + other byte → Alt/Meta + that byte
96
+ const next = chunk[i + 1];
97
+ if (next >= 0x20 && next <= 0x7e) {
98
+ keys.push({
99
+ name: 'printable', ch: String.fromCharCode(next),
100
+ ctrl: false, shift: false, meta: true,
101
+ });
102
+ i += 2;
103
+ continue;
104
+ }
105
+ // Fallback — emit ESC alone, advance one.
106
+ keys.push(k('escape', ''));
107
+ i++;
108
+ continue;
109
+ }
110
+ // Printable ASCII (including space)
111
+ if (b >= 0x20 && b <= 0x7e) {
112
+ keys.push({
113
+ name: 'printable', ch: String.fromCharCode(b),
114
+ ctrl: false, shift: b >= 0x41 && b <= 0x5a, meta: false,
115
+ });
116
+ i++;
117
+ continue;
118
+ }
119
+ // Anything else — pass through as unknown so the caller can see it.
120
+ keys.push({ name: 'unknown', ch: String.fromCharCode(b), ctrl: false, shift: false, meta: false });
121
+ i++;
122
+ }
123
+ return keys;
124
+ }
125
+ function k(name, ch) {
126
+ return { name, ch, ctrl: false, shift: false, meta: false };
127
+ }
128
+ /**
129
+ * Decode the body of a CSI sequence given the terminating byte and
130
+ * the parameter bytes between `[` and the terminator.
131
+ *
132
+ * ESC[A → up
133
+ * ESC[B → down
134
+ * ESC[C → right
135
+ * ESC[D → left
136
+ * ESC[H → home
137
+ * ESC[F → end
138
+ * ESC[5~ → pageup
139
+ * ESC[6~ → pagedown
140
+ * ESC[3~ → delete
141
+ * ESC[1~ → home (alternate)
142
+ * ESC[4~ → end (alternate)
143
+ *
144
+ * Modifier-bearing variants (e.g. `ESC[1;5A` for Ctrl-Up) parse the
145
+ * second parameter as a modifier mask: bit 0 = Shift, bit 1 = Alt,
146
+ * bit 2 = Ctrl. xterm spec §"Modifier-Encoding".
147
+ */
148
+ function parseCsi(final, params, ss3, raw) {
149
+ // Parse semicolon-separated numeric params
150
+ const parts = params.split(';').map(p => parseInt(p, 10) || 0);
151
+ // Modifier byte is the second param when present.
152
+ const mod = parts[1] ?? 1;
153
+ const ctrl = (mod - 1) & 4 ? true : false;
154
+ const shift = (mod - 1) & 1 ? true : false;
155
+ const meta = (mod - 1) & 2 ? true : false;
156
+ // Tilde-terminated: ESC[<n>~ where n is in parts[0]
157
+ if (final === '~') {
158
+ const n = parts[0];
159
+ const map = {
160
+ 1: 'home', 2: 'home', 3: 'delete', 4: 'end',
161
+ 5: 'pageup', 6: 'pagedown', 7: 'home', 8: 'end',
162
+ };
163
+ return { name: map[n] ?? 'unknown', ch: raw.toString(), ctrl, shift, meta };
164
+ }
165
+ // Letter-terminated: arrow / home / end (and SS3 variants)
166
+ void ss3;
167
+ const letterMap = {
168
+ A: 'up', B: 'down', C: 'right', D: 'left',
169
+ H: 'home', F: 'end',
170
+ };
171
+ if (letterMap[final]) {
172
+ return { name: letterMap[final], ch: raw.toString(), ctrl, shift, meta };
173
+ }
174
+ return { name: 'unknown', ch: raw.toString(), ctrl, shift, meta };
175
+ }
176
+ // ── Lifecycle helpers ──────────────────────────────────────────────
177
+ /**
178
+ * Put stdin into raw mode and start emitting Key events to `handler`.
179
+ * Returns a cleanup function that restores stdin's pre-raw mode and
180
+ * unbinds the listener. ALWAYS call the cleanup on exit (including
181
+ * abnormal exits — wire a process exit / signal hook).
182
+ *
183
+ * Defaults are picked so the caller doesn't have to think about them:
184
+ * UTF-8 encoding (we don't see Buffer chunks split mid-character),
185
+ * resume() so paused stdin doesn't drop key events.
186
+ *
187
+ * Throws if stdin isn't a TTY — the TUI doesn't make sense in a pipe.
188
+ */
189
+ export function attachKeyHandler(stdin, handler) {
190
+ if (!stdin.isTTY) {
191
+ throw new Error('TUI requires a TTY on stdin — pipe / redirect not supported');
192
+ }
193
+ const prevRawMode = stdin.isRaw;
194
+ stdin.setRawMode(true);
195
+ stdin.resume();
196
+ const onData = (chunk) => {
197
+ for (const key of parseKeys(chunk))
198
+ handler(key);
199
+ };
200
+ stdin.on('data', onData);
201
+ return () => {
202
+ stdin.off('data', onData);
203
+ stdin.setRawMode(prevRawMode ?? false);
204
+ stdin.pause();
205
+ };
206
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Higher-level layout primitives built on top of `render.ts`.
3
+ *
4
+ * These compose the lower-level ANSI strings into the structural
5
+ * patterns the v4 TUI actually uses: a header bar, a tab strip,
6
+ * a body region, a footer with key hints. Each returns a string
7
+ * (or appends to a Frame) — no terminal state, no side effects.
8
+ */
9
+ /**
10
+ * Render the top header bar: brand + version on the left, contextual
11
+ * status text on the right (e.g. proxy URL when connected).
12
+ *
13
+ * ╭ dario v4.0.0 ───────────────────── http://localhost:3456 ─╮
14
+ *
15
+ * Width is the full terminal columns; the caller positions it at row 1.
16
+ */
17
+ export declare function renderHeader(width: number, opts: {
18
+ version: string;
19
+ status?: string;
20
+ }): string;
21
+ /**
22
+ * Render the bottom footer with key-hint pairs. Wide gaps so it doesn't
23
+ * look mashed-together:
24
+ *
25
+ * [Tab] switch panel [q] quit [?] help
26
+ */
27
+ export declare function renderFooter(width: number, hints: Array<{
28
+ key: string;
29
+ label: string;
30
+ }>): string;
31
+ /**
32
+ * Render a tab strip — one row of clickable-looking tab labels, with
33
+ * the active tab inverse-highlighted. Borders flank both sides.
34
+ *
35
+ * │ Status ▎Analytics▎ Config Hits Accounts Backends │
36
+ *
37
+ * The `activeTab` is the index of the highlighted tab. Caller draws
38
+ * the surrounding box separately.
39
+ */
40
+ export declare function renderTabStrip(width: number, tabs: string[], activeTab: number): string;
41
+ /**
42
+ * Render a vertical scroll indicator on the right edge of a panel.
43
+ * Shows "n / total" + a position blob if there's overflow.
44
+ *
45
+ * ─ 24 / 412
46
+ */
47
+ export declare function renderScrollIndicator(visible: number, total: number, selectedIdx: number): string;
48
+ /**
49
+ * Word-wrap or hard-break `text` to lines of at most `width` visible
50
+ * chars. ANSI sequences are preserved across line boundaries; visible
51
+ * width is what's counted.
52
+ *
53
+ * Used for free-form prose in the Status / About panels.
54
+ */
55
+ export declare function wrap(text: string, width: number): string[];
56
+ /**
57
+ * Render a left-key / right-value row (the shape every config/status
58
+ * line uses).
59
+ *
60
+ * Port: 3456
61
+ * Mode: passthrough
62
+ *
63
+ * Key is left-padded to `keyWidth` so a column of rows aligns. Value
64
+ * is truncated to fit the remaining space.
65
+ */
66
+ export declare function renderKvRow(key: string, value: string, totalWidth: number, keyWidth?: number): string;
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Higher-level layout primitives built on top of `render.ts`.
3
+ *
4
+ * These compose the lower-level ANSI strings into the structural
5
+ * patterns the v4 TUI actually uses: a header bar, a tab strip,
6
+ * a body region, a footer with key hints. Each returns a string
7
+ * (or appends to a Frame) — no terminal state, no side effects.
8
+ */
9
+ import { brand, fg, inverse, BOX, pad, truncate, visibleWidth } from './render.js';
10
+ /**
11
+ * Render the top header bar: brand + version on the left, contextual
12
+ * status text on the right (e.g. proxy URL when connected).
13
+ *
14
+ * ╭ dario v4.0.0 ───────────────────── http://localhost:3456 ─╮
15
+ *
16
+ * Width is the full terminal columns; the caller positions it at row 1.
17
+ */
18
+ export function renderHeader(width, opts) {
19
+ const left = ` ${brand('dario')} v${opts.version} `;
20
+ const right = opts.status ? ` ${opts.status} ` : '';
21
+ const dashWidth = Math.max(0, width - visibleWidth(left) - visibleWidth(right) - 2);
22
+ return BOX.topLeft + left + BOX.horizontal.repeat(dashWidth) + right + BOX.topRight;
23
+ }
24
+ /**
25
+ * Render the bottom footer with key-hint pairs. Wide gaps so it doesn't
26
+ * look mashed-together:
27
+ *
28
+ * [Tab] switch panel [q] quit [?] help
29
+ */
30
+ export function renderFooter(width, hints) {
31
+ const items = hints.map(h => `${fg('cyan', `[${h.key}]`)} ${h.label}`);
32
+ const joined = items.join(' ');
33
+ // Truncate if the hints don't fit (small terminal); never wrap to a
34
+ // second line — the footer is a single row by design.
35
+ return ' ' + truncate(joined, width - 2);
36
+ }
37
+ /**
38
+ * Render a tab strip — one row of clickable-looking tab labels, with
39
+ * the active tab inverse-highlighted. Borders flank both sides.
40
+ *
41
+ * │ Status ▎Analytics▎ Config Hits Accounts Backends │
42
+ *
43
+ * The `activeTab` is the index of the highlighted tab. Caller draws
44
+ * the surrounding box separately.
45
+ */
46
+ export function renderTabStrip(width, tabs, activeTab) {
47
+ const sep = ' ';
48
+ const rendered = tabs.map((label, idx) => {
49
+ if (idx === activeTab) {
50
+ // Active: inverse-highlight with thin side-bars to evoke a
51
+ // selected pill. The ▎ left-eighth-block is visually subtle but
52
+ // unmistakable on every terminal that supports box-drawing.
53
+ return inverse(` ${label} `);
54
+ }
55
+ return ` ${label} `;
56
+ });
57
+ const inner = rendered.join(sep);
58
+ const padded = pad(inner, width, 'left');
59
+ return padded;
60
+ }
61
+ /**
62
+ * Render a vertical scroll indicator on the right edge of a panel.
63
+ * Shows "n / total" + a position blob if there's overflow.
64
+ *
65
+ * ─ 24 / 412
66
+ */
67
+ export function renderScrollIndicator(visible, total, selectedIdx) {
68
+ if (total <= visible)
69
+ return '';
70
+ return ` ${selectedIdx + 1} / ${total} `;
71
+ }
72
+ /**
73
+ * Word-wrap or hard-break `text` to lines of at most `width` visible
74
+ * chars. ANSI sequences are preserved across line boundaries; visible
75
+ * width is what's counted.
76
+ *
77
+ * Used for free-form prose in the Status / About panels.
78
+ */
79
+ export function wrap(text, width) {
80
+ if (width <= 0)
81
+ return [];
82
+ const out = [];
83
+ for (const para of text.split('\n')) {
84
+ if (para === '') {
85
+ out.push('');
86
+ continue;
87
+ }
88
+ let line = '';
89
+ let lineWidth = 0;
90
+ for (const word of para.split(' ')) {
91
+ const w = visibleWidth(word);
92
+ // Word itself longer than width → hard-break it into chunks
93
+ // of `width` chars. Push the current accumulated line first so
94
+ // we don't lose pre-existing content.
95
+ if (w > width) {
96
+ if (lineWidth > 0) {
97
+ out.push(line);
98
+ line = '';
99
+ lineWidth = 0;
100
+ }
101
+ let remaining = word;
102
+ while (visibleWidth(remaining) > width) {
103
+ out.push(remaining.slice(0, width));
104
+ remaining = remaining.slice(width);
105
+ }
106
+ line = remaining;
107
+ lineWidth = visibleWidth(remaining);
108
+ continue;
109
+ }
110
+ // Normal-width word: fit on current line if there's room, else
111
+ // wrap to a new line.
112
+ if (lineWidth === 0) {
113
+ line = word;
114
+ lineWidth = w;
115
+ continue;
116
+ }
117
+ if (lineWidth + 1 + w <= width) {
118
+ line += ' ' + word;
119
+ lineWidth += 1 + w;
120
+ }
121
+ else {
122
+ out.push(line);
123
+ line = word;
124
+ lineWidth = w;
125
+ }
126
+ }
127
+ if (line.length > 0)
128
+ out.push(line);
129
+ }
130
+ return out;
131
+ }
132
+ /**
133
+ * Render a left-key / right-value row (the shape every config/status
134
+ * line uses).
135
+ *
136
+ * Port: 3456
137
+ * Mode: passthrough
138
+ *
139
+ * Key is left-padded to `keyWidth` so a column of rows aligns. Value
140
+ * is truncated to fit the remaining space.
141
+ */
142
+ export function renderKvRow(key, value, totalWidth, keyWidth = 22) {
143
+ // Clamp keyWidth so a narrow terminal (totalWidth < keyWidth default)
144
+ // still produces a well-formed totalWidth-char row. The key gets
145
+ // truncated proportionally; the value column gets the rest.
146
+ const effectiveKeyWidth = Math.min(keyWidth, Math.max(1, totalWidth - 1));
147
+ const keyPart = pad(key + ':', effectiveKeyWidth);
148
+ // Value pad-to-width (not just truncate) so a full row reaches the
149
+ // panel edge — keeps backgrounds + borders aligned.
150
+ const valuePart = pad(value, totalWidth - effectiveKeyWidth);
151
+ return keyPart + valuePart;
152
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Proxy HTTP client for the TUI.
3
+ *
4
+ * The TUI runs as its own process (`dario` with no args). It talks to
5
+ * a separately-running `dario proxy` over HTTP on localhost. This
6
+ * module is the thin client layer: JSON fetch + Server-Sent Events
7
+ * subscription + a `/health` reachability probe.
8
+ *
9
+ * Why not use fetch(): Node 22's global fetch works fine for one-shot
10
+ * JSON, but SSE streaming through it is awkward (the response.body
11
+ * is a WHATWG ReadableStream that needs an extra Node-stream adapter
12
+ * to handle line splitting cleanly across chunks). Using `node:http`
13
+ * directly keeps the SSE flow simple and matches the streaming hot-
14
+ * path the proxy itself uses.
15
+ *
16
+ * Zero deps as ever.
17
+ */
18
+ export interface ProxyClientOpts {
19
+ /** Base URL of the running proxy, e.g. `http://127.0.0.1:3456`. */
20
+ baseUrl: string;
21
+ /** Optional API key — sent as `x-api-key` header. */
22
+ apiKey?: string;
23
+ /** Timeout for one-shot requests (ms). SSE subscriptions ignore this. */
24
+ timeoutMs?: number;
25
+ }
26
+ export declare class ProxyClient {
27
+ private baseUrl;
28
+ private apiKey?;
29
+ private timeoutMs;
30
+ constructor(opts: ProxyClientOpts);
31
+ /**
32
+ * GET a JSON endpoint. Rejects on non-2xx, network failure, JSON
33
+ * parse error, or timeout.
34
+ */
35
+ getJson<T = unknown>(path: string): Promise<T>;
36
+ /**
37
+ * Reachability probe — GET /health, returns parsed payload on
38
+ * success or null on any failure. Never throws; callers use the
39
+ * null as "proxy not running / unreachable".
40
+ */
41
+ health(): Promise<HealthResponse | null>;
42
+ /**
43
+ * Subscribe to /analytics/stream SSE. Calls `onMessage` for each
44
+ * data frame (parsed as JSON). Returns a `close()` function that
45
+ * unsubscribes — the caller MUST call this on unmount or the
46
+ * underlying socket leaks.
47
+ *
48
+ * Auto-reconnect is intentionally NOT included. The Hits tab decides
49
+ * when to retry (and how often) — pushing that policy into here would
50
+ * couple the client to UI semantics.
51
+ */
52
+ subscribeAnalyticsStream<T = unknown>(onMessage: (msg: T) => void, onError?: (err: Error) => void): () => void;
53
+ private headers;
54
+ }
55
+ export interface HealthResponse {
56
+ status: string;
57
+ oauth: string;
58
+ expiresIn?: string;
59
+ requests?: number;
60
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Proxy HTTP client for the TUI.
3
+ *
4
+ * The TUI runs as its own process (`dario` with no args). It talks to
5
+ * a separately-running `dario proxy` over HTTP on localhost. This
6
+ * module is the thin client layer: JSON fetch + Server-Sent Events
7
+ * subscription + a `/health` reachability probe.
8
+ *
9
+ * Why not use fetch(): Node 22's global fetch works fine for one-shot
10
+ * JSON, but SSE streaming through it is awkward (the response.body
11
+ * is a WHATWG ReadableStream that needs an extra Node-stream adapter
12
+ * to handle line splitting cleanly across chunks). Using `node:http`
13
+ * directly keeps the SSE flow simple and matches the streaming hot-
14
+ * path the proxy itself uses.
15
+ *
16
+ * Zero deps as ever.
17
+ */
18
+ import { request as httpRequest } from 'node:http';
19
+ import { URL } from 'node:url';
20
+ export class ProxyClient {
21
+ baseUrl;
22
+ apiKey;
23
+ timeoutMs;
24
+ constructor(opts) {
25
+ this.baseUrl = opts.baseUrl.replace(/\/$/, '');
26
+ this.apiKey = opts.apiKey;
27
+ this.timeoutMs = opts.timeoutMs ?? 3000;
28
+ }
29
+ /**
30
+ * GET a JSON endpoint. Rejects on non-2xx, network failure, JSON
31
+ * parse error, or timeout.
32
+ */
33
+ async getJson(path) {
34
+ const url = new URL(this.baseUrl + path);
35
+ return new Promise((resolve, reject) => {
36
+ const req = httpRequest({
37
+ hostname: url.hostname,
38
+ port: url.port || 80,
39
+ path: url.pathname + url.search,
40
+ method: 'GET',
41
+ headers: this.headers(),
42
+ }, (res) => {
43
+ const chunks = [];
44
+ res.on('data', (c) => chunks.push(c));
45
+ res.on('end', () => {
46
+ const body = Buffer.concat(chunks).toString('utf-8');
47
+ if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
48
+ reject(new Error(`HTTP ${res.statusCode}: ${body.slice(0, 200)}`));
49
+ return;
50
+ }
51
+ try {
52
+ resolve(JSON.parse(body));
53
+ }
54
+ catch (e) {
55
+ reject(new Error(`JSON parse: ${e.message}`));
56
+ }
57
+ });
58
+ res.on('error', reject);
59
+ });
60
+ req.on('error', reject);
61
+ req.setTimeout(this.timeoutMs, () => {
62
+ req.destroy(new Error(`timeout after ${this.timeoutMs}ms`));
63
+ });
64
+ req.end();
65
+ });
66
+ }
67
+ /**
68
+ * Reachability probe — GET /health, returns parsed payload on
69
+ * success or null on any failure. Never throws; callers use the
70
+ * null as "proxy not running / unreachable".
71
+ */
72
+ async health() {
73
+ try {
74
+ return await this.getJson('/health');
75
+ }
76
+ catch {
77
+ return null;
78
+ }
79
+ }
80
+ /**
81
+ * Subscribe to /analytics/stream SSE. Calls `onMessage` for each
82
+ * data frame (parsed as JSON). Returns a `close()` function that
83
+ * unsubscribes — the caller MUST call this on unmount or the
84
+ * underlying socket leaks.
85
+ *
86
+ * Auto-reconnect is intentionally NOT included. The Hits tab decides
87
+ * when to retry (and how often) — pushing that policy into here would
88
+ * couple the client to UI semantics.
89
+ */
90
+ subscribeAnalyticsStream(onMessage, onError) {
91
+ const url = new URL(this.baseUrl + '/analytics/stream');
92
+ let closed = false;
93
+ let res = null;
94
+ const req = httpRequest({
95
+ hostname: url.hostname,
96
+ port: url.port || 80,
97
+ path: url.pathname + url.search,
98
+ method: 'GET',
99
+ headers: { ...this.headers(), 'Accept': 'text/event-stream' },
100
+ }, (response) => {
101
+ if (closed) {
102
+ response.destroy();
103
+ return;
104
+ }
105
+ res = response;
106
+ if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 300) {
107
+ onError?.(new Error(`HTTP ${response.statusCode}`));
108
+ response.destroy();
109
+ return;
110
+ }
111
+ response.setEncoding('utf-8');
112
+ let buf = '';
113
+ response.on('data', (chunk) => {
114
+ if (closed)
115
+ return;
116
+ buf += chunk;
117
+ // SSE frames are separated by a blank line. Each frame can
118
+ // have a `data:` field (possibly across multiple `data:` lines
119
+ // — we concatenate them with \n per the SSE spec).
120
+ let idx;
121
+ while ((idx = buf.indexOf('\n\n')) >= 0) {
122
+ const frame = buf.slice(0, idx);
123
+ buf = buf.slice(idx + 2);
124
+ const dataLines = frame.split('\n')
125
+ .filter(l => l.startsWith('data:'))
126
+ .map(l => l.slice(5).replace(/^ /, ''));
127
+ if (dataLines.length === 0)
128
+ continue;
129
+ const payload = dataLines.join('\n');
130
+ try {
131
+ const parsed = JSON.parse(payload);
132
+ onMessage(parsed);
133
+ }
134
+ catch (e) {
135
+ onError?.(new Error(`SSE parse: ${e.message}`));
136
+ }
137
+ }
138
+ });
139
+ response.on('error', (e) => { if (!closed)
140
+ onError?.(e); });
141
+ response.on('end', () => { if (!closed)
142
+ onError?.(new Error('stream ended')); });
143
+ });
144
+ req.on('error', (e) => { if (!closed)
145
+ onError?.(e); });
146
+ // No timeout on SSE — the heartbeat keeps the connection alive.
147
+ req.end();
148
+ return () => {
149
+ closed = true;
150
+ try {
151
+ req.destroy();
152
+ }
153
+ catch { /* ignored */ }
154
+ try {
155
+ res?.destroy();
156
+ }
157
+ catch { /* ignored */ }
158
+ };
159
+ }
160
+ headers() {
161
+ const h = {};
162
+ if (this.apiKey)
163
+ h['x-api-key'] = this.apiKey;
164
+ return h;
165
+ }
166
+ }