@slack/radar-mcp 1.4.0 → 1.6.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 +10 -2
- package/dist/mcp/db-tools.d.ts +46 -0
- package/dist/mcp/db-tools.js +160 -0
- package/dist/mcp/index.js +34 -2
- package/dist/mcp/tools.js +52 -2
- package/dist/shared/android.d.ts +8 -0
- package/dist/shared/android.js +25 -3
- package/dist/shared/constants.d.ts +29 -0
- package/dist/shared/constants.js +38 -0
- package/dist/shared/db.d.ts +92 -0
- package/dist/shared/db.js +415 -0
- package/dist/shared/debug-log.d.ts +50 -0
- package/dist/shared/debug-log.js +108 -0
- package/dist/shared/screen.d.ts +155 -0
- package/dist/shared/screen.js +633 -0
- package/dist/shared/transport.d.ts +10 -0
- package/dist/web/bin.d.ts +16 -1
- package/dist/web/bin.js +153 -20
- package/dist/web/log-session.d.ts +89 -0
- package/dist/web/log-session.js +144 -0
- package/dist/web/public/index.html +381 -14
- package/dist/web/public/vendor/README.md +25 -0
- package/dist/web/public/vendor/hooks.module.js +2 -0
- package/dist/web/public/vendor/htm.LICENSE +202 -0
- package/dist/web/public/vendor/htm.module.js +1 -0
- package/dist/web/public/vendor/preact.LICENSE +21 -0
- package/dist/web/public/vendor/preact.module.js +2 -0
- package/dist/web/server.d.ts +30 -11
- package/dist/web/server.js +575 -52
- package/dist/web/spec.d.ts +13 -5
- package/dist/web/spec.js +23 -5
- package/package.json +5 -2
package/dist/web/bin.js
CHANGED
|
@@ -1,23 +1,149 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { execSync } from "child_process";
|
|
3
|
-
import {
|
|
3
|
+
import { realpathSync } from "fs";
|
|
4
|
+
import http from "http";
|
|
5
|
+
import { fileURLToPath } from "url";
|
|
6
|
+
import { cleanupOnBind, createServer, seedInitialSpec, WEB_PORT } from "./server.js";
|
|
7
|
+
import { RADAR_VERSION } from "../shared/constants.js";
|
|
8
|
+
/**
|
|
9
|
+
* One loopback GET against the dashboard's own port, used by the adopt path. Resolves with the
|
|
10
|
+
* status, body, and headers, or null on error/timeout. Centralizes the timeout + 127.0.0.1
|
|
11
|
+
* binding the probes share (the MCP side has its own copy in mcp/index.ts; this is the web
|
|
12
|
+
* bin's). Never throws.
|
|
13
|
+
*/
|
|
14
|
+
function loopbackGet(path, timeoutMs) {
|
|
15
|
+
return new Promise((resolve) => {
|
|
16
|
+
const req = http.request({ host: "127.0.0.1", port: WEB_PORT, path, method: "GET", timeout: timeoutMs }, (res) => {
|
|
17
|
+
let body = "";
|
|
18
|
+
res.on("data", (c) => (body += c));
|
|
19
|
+
res.on("end", () => resolve({ status: res.statusCode, body, headers: res.headers }));
|
|
20
|
+
});
|
|
21
|
+
req.on("error", () => resolve(null));
|
|
22
|
+
req.on("timeout", () => {
|
|
23
|
+
req.destroy();
|
|
24
|
+
resolve(null);
|
|
25
|
+
});
|
|
26
|
+
req.end();
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Decide whether a /spec response body identifies a Radar dashboard. A Radar /spec
|
|
31
|
+
* is always a plain object carrying a `viz` array (even a blank spec is
|
|
32
|
+
* { title, blank, viz: [] }). Requiring both excludes a foreign service that happens
|
|
33
|
+
* to 200 with arbitrary JSON (a bare array, a string, an unrelated object). Exported
|
|
34
|
+
* for unit testing. statusCode must be 200.
|
|
35
|
+
*/
|
|
36
|
+
export function isRadarSpecBody(statusCode, body) {
|
|
37
|
+
if (statusCode !== 200)
|
|
38
|
+
return false;
|
|
39
|
+
let spec;
|
|
40
|
+
try {
|
|
41
|
+
spec = JSON.parse(body);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
// typeof [] is "object" in JS, hence the explicit Array.isArray exclusion.
|
|
47
|
+
return (spec !== null &&
|
|
48
|
+
typeof spec === "object" &&
|
|
49
|
+
!Array.isArray(spec) &&
|
|
50
|
+
Array.isArray(spec.viz));
|
|
51
|
+
}
|
|
52
|
+
/** Open the dashboard in the default browser. Best-effort; never throws. */
|
|
53
|
+
function openBrowser(url) {
|
|
54
|
+
try {
|
|
55
|
+
if (process.platform === "darwin") {
|
|
56
|
+
execSync(`open ${url}`, { stdio: "ignore" });
|
|
57
|
+
}
|
|
58
|
+
else if (process.platform === "linux") {
|
|
59
|
+
execSync(`xdg-open ${url}`, { stdio: "ignore" });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// Browser open is best-effort
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The port is busy. Decide whether the thing holding it is an existing Radar
|
|
68
|
+
* dashboard (adopt it: open the browser and exit cleanly) or something else
|
|
69
|
+
* (print the manual options and exit non-zero). We do NOT kill the holder:
|
|
70
|
+
* `lsof -ti :PORT` could be a browser, a teammate's server, or any unrelated
|
|
71
|
+
* process, and killing by port from a published tool is a footgun.
|
|
72
|
+
*
|
|
73
|
+
* Detection probes GET /spec on the loopback port. /spec is instant (it returns
|
|
74
|
+
* the current dashboard spec, no device round-trip) and is unique to a Radar
|
|
75
|
+
* dashboard, so a 200 with a JSON body is a reliable fingerprint. The response's
|
|
76
|
+
* X-Radar-Version header lets us notice when the running dashboard is a DIFFERENT
|
|
77
|
+
* Radar version than this launcher and say so — we still adopt it (never kill the
|
|
78
|
+
* holder), the note just explains why a feature you expect might be missing.
|
|
79
|
+
*/
|
|
80
|
+
function handlePortInUse() {
|
|
81
|
+
const url = `http://localhost:${WEB_PORT}`;
|
|
82
|
+
void loopbackGet("/spec", 2000).then((r) => {
|
|
83
|
+
if (r && isRadarSpecBody(r.status, r.body)) {
|
|
84
|
+
const running = r.headers["x-radar-version"];
|
|
85
|
+
if (typeof running === "string" && running !== RADAR_VERSION) {
|
|
86
|
+
console.log(`Note: the running dashboard is Radar ${running}, this launcher is ${RADAR_VERSION}. ` +
|
|
87
|
+
`Adopting the running one as-is (it owns the port). Restart it to pick up ${RADAR_VERSION}.`);
|
|
88
|
+
}
|
|
89
|
+
console.log(`Slack Radar is already running at ${url}. Opening it.`);
|
|
90
|
+
if (!process.env.SLACK_RADAR_NO_OPEN)
|
|
91
|
+
openBrowser(url);
|
|
92
|
+
process.exit(0);
|
|
93
|
+
}
|
|
94
|
+
reportForeignPort();
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
/** Port is held by something that is not a Radar dashboard. Never kill it for the user. */
|
|
98
|
+
function reportForeignPort() {
|
|
99
|
+
console.error(`Port ${WEB_PORT} is already in use by another program (not a Radar dashboard).\n` +
|
|
100
|
+
`Free it, or run Radar on another port: SLACK_RADAR_WEB_PORT=8101 slack-radar web`);
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
4
103
|
/**
|
|
5
104
|
* Start the web dashboard server, open the browser, and wire shutdown signals.
|
|
6
105
|
* Extracted so both the dedicated `slack-radar-web` bin and the default bin's
|
|
7
|
-
* `web` subcommand dispatcher launch the dashboard the same way.
|
|
106
|
+
* `web` subcommand dispatcher launch the dashboard the same way. Exported so the
|
|
107
|
+
* dispatcher (mcp/index.ts) calls it explicitly rather than relying on an import
|
|
108
|
+
* side-effect, which keeps importing this module (e.g. in a test) side-effect-free.
|
|
8
109
|
*/
|
|
9
|
-
function startWebServer() {
|
|
110
|
+
export function startWebServer() {
|
|
111
|
+
// Safety net: a single route handler must never take the whole dashboard down. An async
|
|
112
|
+
// callback (e.g. a device-probe that both ends and errors) can otherwise throw
|
|
113
|
+
// ERR_HTTP_HEADERS_SENT outside any try/catch and kill the process. Log and keep serving;
|
|
114
|
+
// handlers still guard their own writes, this is the backstop for anything they miss.
|
|
115
|
+
process.on("uncaughtException", (err) => {
|
|
116
|
+
console.error("uncaughtException (kept alive):", err);
|
|
117
|
+
});
|
|
118
|
+
process.on("unhandledRejection", (err) => {
|
|
119
|
+
console.error("unhandledRejection (kept alive):", err);
|
|
120
|
+
});
|
|
10
121
|
const server = createServer();
|
|
11
122
|
server.on("error", (err) => {
|
|
12
123
|
if (err.code === "EADDRINUSE") {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
124
|
+
// Likely our own dashboard from an earlier launch. Adopt it if so; only error
|
|
125
|
+
// if the port is held by something else. Never kill the holder for the user.
|
|
126
|
+
handlePortInUse();
|
|
127
|
+
return;
|
|
17
128
|
}
|
|
18
129
|
throw err;
|
|
19
130
|
});
|
|
20
|
-
|
|
131
|
+
// Bind loopback-only. The dashboard serves sensitive device data with no auth:
|
|
132
|
+
// live ring buffers, the on-device message store via the DB browser, and the live
|
|
133
|
+
// screen mirror (open DMs, notifications). Omitting the host arg binds all
|
|
134
|
+
// interfaces (0.0.0.0/::), which would let any same-network peer hit /dbquery or
|
|
135
|
+
// grant screen consent (a single unauthenticated process boolean) and watch the
|
|
136
|
+
// device. The consent gate only raises the bar for the local user; 127.0.0.1 is
|
|
137
|
+
// what actually closes the network. The device-side connection is already
|
|
138
|
+
// loopback-scoped (constants.ts); the dashboard's own listener must match. Remote
|
|
139
|
+
// access, if ever needed, belongs in a separate token-gated feature.
|
|
140
|
+
server.listen(WEB_PORT, "127.0.0.1", () => {
|
|
141
|
+
// Seed the starting spec AND wipe stale pulled DBs only now that THIS process owns the
|
|
142
|
+
// port. Doing both here (not at server.js import) means a second launcher that loses the
|
|
143
|
+
// bind never touches the running dashboard's spec file or its port-scoped pulled-DB cache,
|
|
144
|
+
// so adopting it leaves its view and open DB-browser tab intact.
|
|
145
|
+
seedInitialSpec();
|
|
146
|
+
cleanupOnBind();
|
|
21
147
|
const url = `http://localhost:${WEB_PORT}`;
|
|
22
148
|
console.log(`Slack Radar Web running at ${url}`);
|
|
23
149
|
// Auto-open the browser for the HUMAN launcher (`slack-radar web` / `slack-radar-web`),
|
|
@@ -27,17 +153,7 @@ function startWebServer() {
|
|
|
27
153
|
// /radar-ui. The tool returns the URL instead, for the Claude Code session to surface.
|
|
28
154
|
if (process.env.SLACK_RADAR_NO_OPEN)
|
|
29
155
|
return;
|
|
30
|
-
|
|
31
|
-
if (process.platform === "darwin") {
|
|
32
|
-
execSync(`open ${url}`, { stdio: "ignore" });
|
|
33
|
-
}
|
|
34
|
-
else if (process.platform === "linux") {
|
|
35
|
-
execSync(`xdg-open ${url}`, { stdio: "ignore" });
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
catch {
|
|
39
|
-
// Browser open is best-effort
|
|
40
|
-
}
|
|
156
|
+
openBrowser(url);
|
|
41
157
|
});
|
|
42
158
|
const shutdown = () => {
|
|
43
159
|
console.log("\nShutting down…");
|
|
@@ -47,4 +163,21 @@ function startWebServer() {
|
|
|
47
163
|
process.on("SIGINT", shutdown);
|
|
48
164
|
process.on("SIGTERM", shutdown);
|
|
49
165
|
}
|
|
50
|
-
|
|
166
|
+
// Only launch when run as the bin (`slack-radar-web` / `slack-radar web`), not when
|
|
167
|
+
// imported (e.g. by a test that exercises isRadarSpecBody). argv[1] is the script path
|
|
168
|
+
// node was invoked with; compare it to this module's own path. realpathSync resolves the
|
|
169
|
+
// npm bin symlink (node_modules/.bin) to the real dist path so the comparison holds.
|
|
170
|
+
function invokedDirectly() {
|
|
171
|
+
const arg = process.argv[1];
|
|
172
|
+
if (!arg)
|
|
173
|
+
return false;
|
|
174
|
+
try {
|
|
175
|
+
return fileURLToPath(import.meta.url) === realpathSync(arg);
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (invokedDirectly()) {
|
|
182
|
+
startWebServer();
|
|
183
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-long host-side logcat capture for the web dashboard.
|
|
3
|
+
*
|
|
4
|
+
* The device does not buffer logcat for us (the on-device radar server captures
|
|
5
|
+
* network/RTM/clog into ring buffers, but logcat is a host-side `adb logcat`
|
|
6
|
+
* stream with no replayable device buffer). So when a browser switches dashboard
|
|
7
|
+
* tabs, the SSE `/stream` connection is torn down and reopened, and anything that
|
|
8
|
+
* used to drive logcat off the per-connection lifecycle would lose every line
|
|
9
|
+
* emitted while no stream was attached — the user sees their log count shrink on
|
|
10
|
+
* a tab switch.
|
|
11
|
+
*
|
|
12
|
+
* This module fixes that the way Android Studio does: ONE logcat process per
|
|
13
|
+
* device for the whole server session, feeding a bounded session ring buffer.
|
|
14
|
+
* Each `/stream` connection subscribes as a listener and is first replayed the
|
|
15
|
+
* entire retained buffer, then receives live lines. Switching tabs only
|
|
16
|
+
* adds/removes a listener; the underlying capture and its history are untouched.
|
|
17
|
+
*
|
|
18
|
+
* The class takes its spawn and parse functions by injection so it is unit
|
|
19
|
+
* testable without spawning a real `adb logcat` (the integration path is covered
|
|
20
|
+
* by the smoke test on a real device).
|
|
21
|
+
*/
|
|
22
|
+
import type { ChildProcess } from "child_process";
|
|
23
|
+
/** A parsed logcat frame, as delivered to the dashboard over SSE. */
|
|
24
|
+
export interface LogFrame {
|
|
25
|
+
id: number;
|
|
26
|
+
timestamp: number;
|
|
27
|
+
log_ts: string;
|
|
28
|
+
pid: number;
|
|
29
|
+
tid: number;
|
|
30
|
+
level: string;
|
|
31
|
+
tag: string;
|
|
32
|
+
message: string;
|
|
33
|
+
package?: string;
|
|
34
|
+
}
|
|
35
|
+
/** Spawn a process producing logcat lines on stdout. Injectable for tests. */
|
|
36
|
+
export type SpawnLogcat = () => ChildProcess;
|
|
37
|
+
/** Parse one raw logcat line into a frame, or null if it is not a log line. */
|
|
38
|
+
export type ParseLine = (line: string) => LogFrame | null;
|
|
39
|
+
/** Receives one frame. `replay` is true for buffered history, false for live. */
|
|
40
|
+
export type LogListener = (frame: LogFrame, replay: boolean) => void;
|
|
41
|
+
/**
|
|
42
|
+
* A single session-long logcat capture with a bounded ring buffer and listener
|
|
43
|
+
* fan-out. One instance is shared across every dashboard stream connection.
|
|
44
|
+
*/
|
|
45
|
+
export declare class LogSession {
|
|
46
|
+
private readonly spawnLogcat;
|
|
47
|
+
private readonly parseLine;
|
|
48
|
+
private readonly cap;
|
|
49
|
+
private child;
|
|
50
|
+
private lineBuf;
|
|
51
|
+
private readonly ring;
|
|
52
|
+
private readonly listeners;
|
|
53
|
+
private cleanupRegistered;
|
|
54
|
+
constructor(spawnLogcat: SpawnLogcat, parseLine: ParseLine, cap?: number);
|
|
55
|
+
/** True while a logcat child is running. */
|
|
56
|
+
isRunning(): boolean;
|
|
57
|
+
/** Number of frames currently retained (for tests / diagnostics). */
|
|
58
|
+
bufferSize(): number;
|
|
59
|
+
/** Number of attached stream listeners (for tests / diagnostics). */
|
|
60
|
+
listenerCount(): number;
|
|
61
|
+
/**
|
|
62
|
+
* Start the capture if it is not already running. Idempotent: a second call
|
|
63
|
+
* while a child is alive is a no-op, so concurrent tab opens cannot fan out
|
|
64
|
+
* duplicate logcat processes. Returns true if a capture is running after the
|
|
65
|
+
* call (already-running counts as success), false if the spawn failed.
|
|
66
|
+
*/
|
|
67
|
+
start(): boolean;
|
|
68
|
+
/** Stop the capture and drop the child. The ring buffer is preserved. */
|
|
69
|
+
stop(): void;
|
|
70
|
+
/**
|
|
71
|
+
* Install process-exit handlers (once) so the persistent `adb logcat` child is
|
|
72
|
+
* killed when the server process exits. Without this, the capture orphans when
|
|
73
|
+
* the server is terminated by signal — notably when `open_radar_dashboard`
|
|
74
|
+
* spawns the dashboard as a child and later kills it by PID — and orphaned adb
|
|
75
|
+
* logcat readers accumulate across dashboard open/close cycles, holding the
|
|
76
|
+
* device's logcat stream. Mirrors logcat-capture.ts's registerCleanup().
|
|
77
|
+
*/
|
|
78
|
+
registerCleanup(): void;
|
|
79
|
+
/**
|
|
80
|
+
* Subscribe a stream to the session. The listener is FIRST replayed the entire
|
|
81
|
+
* retained ring (replay=true) so a freshly opened tab shows full history, then
|
|
82
|
+
* receives every subsequent live frame (replay=false). Returns an unsubscribe
|
|
83
|
+
* function; call it when the stream closes (this does NOT stop the capture).
|
|
84
|
+
*/
|
|
85
|
+
subscribe(listener: LogListener): () => void;
|
|
86
|
+
private onChildGone;
|
|
87
|
+
private onData;
|
|
88
|
+
private push;
|
|
89
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { MAX_LOG_SESSION_BUFFER } from "../shared/constants.js";
|
|
2
|
+
/**
|
|
3
|
+
* A single session-long logcat capture with a bounded ring buffer and listener
|
|
4
|
+
* fan-out. One instance is shared across every dashboard stream connection.
|
|
5
|
+
*/
|
|
6
|
+
export class LogSession {
|
|
7
|
+
spawnLogcat;
|
|
8
|
+
parseLine;
|
|
9
|
+
cap;
|
|
10
|
+
child = null;
|
|
11
|
+
lineBuf = "";
|
|
12
|
+
ring = [];
|
|
13
|
+
listeners = new Set();
|
|
14
|
+
cleanupRegistered = false;
|
|
15
|
+
constructor(spawnLogcat, parseLine, cap = MAX_LOG_SESSION_BUFFER) {
|
|
16
|
+
this.spawnLogcat = spawnLogcat;
|
|
17
|
+
this.parseLine = parseLine;
|
|
18
|
+
this.cap = cap > 0 ? cap : MAX_LOG_SESSION_BUFFER;
|
|
19
|
+
}
|
|
20
|
+
/** True while a logcat child is running. */
|
|
21
|
+
isRunning() {
|
|
22
|
+
return this.child !== null;
|
|
23
|
+
}
|
|
24
|
+
/** Number of frames currently retained (for tests / diagnostics). */
|
|
25
|
+
bufferSize() {
|
|
26
|
+
return this.ring.length;
|
|
27
|
+
}
|
|
28
|
+
/** Number of attached stream listeners (for tests / diagnostics). */
|
|
29
|
+
listenerCount() {
|
|
30
|
+
return this.listeners.size;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Start the capture if it is not already running. Idempotent: a second call
|
|
34
|
+
* while a child is alive is a no-op, so concurrent tab opens cannot fan out
|
|
35
|
+
* duplicate logcat processes. Returns true if a capture is running after the
|
|
36
|
+
* call (already-running counts as success), false if the spawn failed.
|
|
37
|
+
*/
|
|
38
|
+
start() {
|
|
39
|
+
if (this.child)
|
|
40
|
+
return true;
|
|
41
|
+
let child;
|
|
42
|
+
try {
|
|
43
|
+
child = this.spawnLogcat();
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
this.child = child;
|
|
49
|
+
this.lineBuf = "";
|
|
50
|
+
child.stdout?.on("data", (c) => this.onData(c));
|
|
51
|
+
// A crashed/killed logcat (device unplugged, adb server restart) clears the
|
|
52
|
+
// child so the next connect() can restart it. The retained ring survives so
|
|
53
|
+
// history is not lost across a transient device drop.
|
|
54
|
+
child.on("error", () => this.onChildGone(child));
|
|
55
|
+
child.on("exit", () => this.onChildGone(child));
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
/** Stop the capture and drop the child. The ring buffer is preserved. */
|
|
59
|
+
stop() {
|
|
60
|
+
const child = this.child;
|
|
61
|
+
this.child = null;
|
|
62
|
+
this.lineBuf = "";
|
|
63
|
+
if (child) {
|
|
64
|
+
try {
|
|
65
|
+
child.kill();
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// already dead
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Install process-exit handlers (once) so the persistent `adb logcat` child is
|
|
74
|
+
* killed when the server process exits. Without this, the capture orphans when
|
|
75
|
+
* the server is terminated by signal — notably when `open_radar_dashboard`
|
|
76
|
+
* spawns the dashboard as a child and later kills it by PID — and orphaned adb
|
|
77
|
+
* logcat readers accumulate across dashboard open/close cycles, holding the
|
|
78
|
+
* device's logcat stream. Mirrors logcat-capture.ts's registerCleanup().
|
|
79
|
+
*/
|
|
80
|
+
registerCleanup() {
|
|
81
|
+
if (this.cleanupRegistered)
|
|
82
|
+
return;
|
|
83
|
+
this.cleanupRegistered = true;
|
|
84
|
+
process.on("exit", () => this.stop());
|
|
85
|
+
process.on("SIGINT", () => {
|
|
86
|
+
this.stop();
|
|
87
|
+
process.exit(130);
|
|
88
|
+
});
|
|
89
|
+
process.on("SIGTERM", () => {
|
|
90
|
+
this.stop();
|
|
91
|
+
process.exit(143);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Subscribe a stream to the session. The listener is FIRST replayed the entire
|
|
96
|
+
* retained ring (replay=true) so a freshly opened tab shows full history, then
|
|
97
|
+
* receives every subsequent live frame (replay=false). Returns an unsubscribe
|
|
98
|
+
* function; call it when the stream closes (this does NOT stop the capture).
|
|
99
|
+
*/
|
|
100
|
+
subscribe(listener) {
|
|
101
|
+
// Replay a snapshot of the current ring. Snapshot first so a frame arriving
|
|
102
|
+
// mid-replay is not delivered twice (once via replay, once live).
|
|
103
|
+
const snapshot = this.ring.slice();
|
|
104
|
+
for (const frame of snapshot)
|
|
105
|
+
listener(frame, true);
|
|
106
|
+
this.listeners.add(listener);
|
|
107
|
+
return () => {
|
|
108
|
+
this.listeners.delete(listener);
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
onChildGone(child) {
|
|
112
|
+
// Guard against a late event from an already-replaced child.
|
|
113
|
+
if (this.child === child) {
|
|
114
|
+
this.child = null;
|
|
115
|
+
this.lineBuf = "";
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
onData(chunk) {
|
|
119
|
+
this.lineBuf += chunk.toString();
|
|
120
|
+
let nl;
|
|
121
|
+
while ((nl = this.lineBuf.indexOf("\n")) >= 0) {
|
|
122
|
+
const line = this.lineBuf.slice(0, nl);
|
|
123
|
+
this.lineBuf = this.lineBuf.slice(nl + 1);
|
|
124
|
+
const frame = this.parseLine(line);
|
|
125
|
+
if (frame)
|
|
126
|
+
this.push(frame);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
push(frame) {
|
|
130
|
+
this.ring.push(frame);
|
|
131
|
+
if (this.ring.length > this.cap)
|
|
132
|
+
this.ring.shift();
|
|
133
|
+
for (const listener of this.listeners) {
|
|
134
|
+
// A throwing listener (e.g. a closed response) must not kill the capture
|
|
135
|
+
// or starve the other listeners.
|
|
136
|
+
try {
|
|
137
|
+
listener(frame, false);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// best-effort fan-out
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|