@shibbirweb/mcp-db-read-only 0.1.0 → 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.
Files changed (40) hide show
  1. package/CHANGELOG.md +21 -1
  2. package/README.dockerhub.md +233 -234
  3. package/README.md +221 -269
  4. package/dist/ApplicationFactory.js +111 -14
  5. package/dist/cli/ViewerCommand.js +117 -0
  6. package/dist/config/EnvironmentConfigLoader.js +58 -0
  7. package/dist/drivers/BaseDriver.js +10 -1
  8. package/dist/drivers/document/MongoDriver.js +27 -30
  9. package/dist/drivers/keyvalue/RedisDriver.js +20 -5
  10. package/dist/drivers/search/ElasticsearchDriver.js +13 -9
  11. package/dist/drivers/sql/ClickHouseDriver.js +12 -13
  12. package/dist/drivers/sql/MsSqlDriver.js +6 -6
  13. package/dist/drivers/sql/MySqlDriver.js +7 -5
  14. package/dist/drivers/sql/MySqlSessionInitializer.js +17 -2
  15. package/dist/drivers/sql/PostgresDriver.js +6 -6
  16. package/dist/drivers/sql/SqliteDriver.js +3 -3
  17. package/dist/formatting/JsonSerializer.js +3 -2
  18. package/dist/index.js +16 -7
  19. package/dist/logging/CallLogger.js +139 -0
  20. package/dist/logging/LogChannel.js +18 -0
  21. package/dist/logging/LogFormatter.js +80 -0
  22. package/dist/logging/LogRecords.js +7 -0
  23. package/dist/logging/LogSink.js +38 -0
  24. package/dist/logging/RecordJson.js +39 -0
  25. package/dist/logging/Redactor.js +95 -0
  26. package/dist/logging/StatementTracer.js +9 -0
  27. package/dist/logging/ToolCallObserver.js +6 -0
  28. package/dist/logging/store/FolderLogChannel.js +56 -0
  29. package/dist/logging/store/FolderLogStore.js +214 -0
  30. package/dist/logging/store/LogFileNames.js +57 -0
  31. package/dist/logging/store/LogStore.js +18 -0
  32. package/dist/logging/store/MemoryLogStore.js +70 -0
  33. package/dist/logging/viewer/LiveLogViewer.js +264 -0
  34. package/dist/logging/viewer/LiveViewerObserver.js +62 -0
  35. package/dist/logging/viewer/ViewerAssets.js +625 -0
  36. package/dist/server/BackgroundService.js +1 -0
  37. package/dist/server/McpDbServer.js +16 -2
  38. package/dist/tools/BaseTool.js +8 -2
  39. package/dist/tools/connection/CurrentConnectionTool.js +11 -2
  40. package/package.json +1 -1
@@ -0,0 +1,56 @@
1
+ import { mkdirSync, renameSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { JsonSerializer } from "../../formatting/JsonSerializer.js";
4
+ import { RecordJson } from "../RecordJson.js";
5
+ import { LogFileNames } from "./LogFileNames.js";
6
+ /**
7
+ * Saves every entry as its own JSON file in the permanent log folder.
8
+ *
9
+ * Each file is written under a temporary name and renamed into place, which
10
+ * is atomic on one filesystem, so a reader (this process's viewer, another
11
+ * copy's, or a person with `ls`) never sees a half-written file. Temporaries
12
+ * end in `.tmp` and are ignored by LogFileNames.
13
+ *
14
+ * The folder is created readable by the owner only, and each file likewise:
15
+ * with logging on they hold every query and every result.
16
+ *
17
+ * Synchronous for the same reason as FileSink: shutdown ends with
18
+ * `process.exit`, which would lose buffered writes.
19
+ *
20
+ * Nothing is ever deleted or rotated here. The folder is permanent by design.
21
+ */
22
+ export class FolderLogChannel {
23
+ directory;
24
+ onWritten;
25
+ serializer;
26
+ sequence = 0;
27
+ /**
28
+ * @param onWritten told of each entry just saved, so this process's own
29
+ * viewer can show it at once instead of waiting for its next scan.
30
+ */
31
+ constructor(directory, onWritten = () => undefined, serializer = new JsonSerializer()) {
32
+ this.directory = directory;
33
+ this.onWritten = onWritten;
34
+ this.serializer = serializer;
35
+ }
36
+ get description() {
37
+ return this.directory;
38
+ }
39
+ onCall(call) {
40
+ const name = LogFileNames.build(call.at, call.pid, "call", ++this.sequence, call.tool, call.failed);
41
+ this.save(name, "call", RecordJson.call(call));
42
+ }
43
+ onStatement(statement) {
44
+ const name = LogFileNames.build(statement.at, statement.pid, "statement", ++this.sequence, null, statement.failed);
45
+ this.save(name, "statement", RecordJson.statement(statement));
46
+ }
47
+ save(relativePath, kind, data) {
48
+ const target = join(this.directory, relativePath);
49
+ const day = join(this.directory, relativePath.split("/")[0]);
50
+ mkdirSync(day, { recursive: true, mode: 0o700 });
51
+ const temporary = `${target}.${process.pid}.tmp`;
52
+ writeFileSync(temporary, `${this.serializer.stringify(data)}\n`, { mode: 0o600 });
53
+ renameSync(temporary, target);
54
+ this.onWritten({ key: relativePath, kind, data });
55
+ }
56
+ }
@@ -0,0 +1,214 @@
1
+ import { existsSync, readdirSync } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { LogFileNames } from "./LogFileNames.js";
5
+ import { Paging } from "./LogStore.js";
6
+ /**
7
+ * Reads the permanent log folder for the viewer: every entry saved by every
8
+ * copy of the server that shares it, newest first.
9
+ *
10
+ * **An index of file names, not contents.** On first use the folder is
11
+ * listed once, and each name parsed (LogFileNames puts time, pid, tool and
12
+ * outcome in it), so paging, the tool filter and "failed only" never open a
13
+ * file. Only the entries on the page being shown are read, plus, for a text
14
+ * search, the candidates it has to look inside.
15
+ *
16
+ * **New entries.** This process's own writes arrive at once through
17
+ * `noteWritten`. Other copies' writes are found by rescanning today's folder
18
+ * once a second: one small directory listing, dependable on every platform,
19
+ * where `fs.watch` is not (it drops and duplicates events, and behaves
20
+ * differently on network and bind-mounted folders). The timer is unref'd, so
21
+ * it never keeps the process alive.
22
+ *
23
+ * Files deleted by hand disappear from the index the next time they would
24
+ * have been read.
25
+ */
26
+ export class FolderLogStore {
27
+ directory;
28
+ static SCAN_INTERVAL_MS = 1000;
29
+ index = null;
30
+ known = new Set();
31
+ /** Entries already pushed to the viewer, so the scan and noteWritten never push one twice. */
32
+ emitted = new Set();
33
+ listeners = new Set();
34
+ timer = null;
35
+ constructor(directory) {
36
+ this.directory = directory;
37
+ }
38
+ start() {
39
+ if (this.timer) {
40
+ return;
41
+ }
42
+ this.load();
43
+ this.timer = setInterval(() => void this.scanRecent(), FolderLogStore.SCAN_INTERVAL_MS);
44
+ this.timer.unref();
45
+ }
46
+ stop() {
47
+ if (this.timer) {
48
+ clearInterval(this.timer);
49
+ this.timer = null;
50
+ }
51
+ }
52
+ subscribe(listener) {
53
+ this.listeners.add(listener);
54
+ return () => this.listeners.delete(listener);
55
+ }
56
+ /** An entry this process has just saved: index it and tell the viewer now. */
57
+ noteWritten(entry) {
58
+ const [day, file] = entry.key.split("/");
59
+ const info = LogFileNames.parse(day, file);
60
+ if (!info) {
61
+ return;
62
+ }
63
+ // Loaded first, so a write before the first query cannot leave the index
64
+ // holding only this one entry and the rest of the folder unscanned.
65
+ this.load();
66
+ this.add(info);
67
+ this.emit(entry);
68
+ }
69
+ async tools() {
70
+ const names = new Set();
71
+ for (const info of this.load()) {
72
+ if (info.tool) {
73
+ names.add(info.tool);
74
+ }
75
+ }
76
+ return Array.from(names).sort();
77
+ }
78
+ async query(query) {
79
+ let candidates = this.load().filter((info) => (!query.tool || info.tool === query.tool) && (!query.failedOnly || info.failed));
80
+ const needle = query.text?.trim().toLowerCase();
81
+ if (needle) {
82
+ const matching = [];
83
+ for (const info of candidates) {
84
+ const text = await this.readText(info);
85
+ if (text !== null && text.toLowerCase().includes(needle)) {
86
+ matching.push(info);
87
+ }
88
+ }
89
+ candidates = matching;
90
+ }
91
+ const paged = Paging.page(candidates, query);
92
+ const entries = [];
93
+ for (const info of paged.slice) {
94
+ const entry = await this.read(info);
95
+ if (entry) {
96
+ entries.push(entry);
97
+ }
98
+ }
99
+ return { entries, total: paged.total, page: paged.page, size: paged.size, pages: paged.pages };
100
+ }
101
+ /** The index, built on first use: every day folder, every file, newest first. */
102
+ load() {
103
+ if (this.index) {
104
+ return this.index;
105
+ }
106
+ // Collected, then sorted once: inserting one at a time would be
107
+ // quadratic in a folder that has been filling for months.
108
+ const all = [];
109
+ for (const day of this.listDays()) {
110
+ for (const info of this.listDay(day)) {
111
+ if (!this.known.has(info.relativePath)) {
112
+ this.known.add(info.relativePath);
113
+ all.push(info);
114
+ }
115
+ }
116
+ }
117
+ this.index = all.sort((a, b) => (this.newer(a, b) ? -1 : 1));
118
+ return this.index;
119
+ }
120
+ /**
121
+ * Today's folder, and yesterday's around midnight UTC, since a copy may
122
+ * still be finishing a call dated the day before.
123
+ */
124
+ async scanRecent() {
125
+ const now = new Date();
126
+ const days = new Set([LogFileNames.dayFolder(now), LogFileNames.dayFolder(new Date(now.getTime() - 60000))]);
127
+ for (const day of days) {
128
+ for (const info of this.listDay(day)) {
129
+ if (this.add(info)) {
130
+ const entry = await this.read(info);
131
+ if (entry) {
132
+ this.emit(entry);
133
+ }
134
+ }
135
+ }
136
+ }
137
+ }
138
+ /** @returns true when the entry was new. Keeps the index newest first. */
139
+ add(info) {
140
+ if (this.known.has(info.relativePath)) {
141
+ return false;
142
+ }
143
+ this.known.add(info.relativePath);
144
+ const index = this.load();
145
+ let at = 0;
146
+ while (at < index.length && this.newer(index[at], info)) {
147
+ at += 1;
148
+ }
149
+ index.splice(at, 0, info);
150
+ return true;
151
+ }
152
+ /** Newest first by time; the name breaks ties, so the order is total and stable. */
153
+ newer(a, b) {
154
+ const difference = a.at.getTime() - b.at.getTime();
155
+ return difference !== 0 ? difference > 0 : a.relativePath > b.relativePath;
156
+ }
157
+ listDays() {
158
+ if (!existsSync(this.directory)) {
159
+ return [];
160
+ }
161
+ return readdirSync(this.directory).filter((name) => LogFileNames.isDayFolder(name));
162
+ }
163
+ listDay(day) {
164
+ const folder = join(this.directory, day);
165
+ if (!existsSync(folder)) {
166
+ return [];
167
+ }
168
+ const found = [];
169
+ for (const file of readdirSync(folder)) {
170
+ const info = LogFileNames.parse(day, file);
171
+ if (info) {
172
+ found.push(info);
173
+ }
174
+ }
175
+ return found;
176
+ }
177
+ async read(info) {
178
+ const text = await this.readText(info);
179
+ if (text === null) {
180
+ return null;
181
+ }
182
+ try {
183
+ return { key: info.relativePath, kind: info.kind, data: JSON.parse(text) };
184
+ }
185
+ catch {
186
+ return null;
187
+ }
188
+ }
189
+ /** A file that has gone, deleted by hand, is dropped from the index. */
190
+ async readText(info) {
191
+ try {
192
+ return await readFile(join(this.directory, info.relativePath), "utf8");
193
+ }
194
+ catch {
195
+ this.forget(info);
196
+ return null;
197
+ }
198
+ }
199
+ forget(info) {
200
+ this.known.delete(info.relativePath);
201
+ if (this.index) {
202
+ this.index = this.index.filter((entry) => entry.relativePath !== info.relativePath);
203
+ }
204
+ }
205
+ emit(entry) {
206
+ if (this.emitted.has(entry.key)) {
207
+ return;
208
+ }
209
+ this.emitted.add(entry.key);
210
+ for (const listener of this.listeners) {
211
+ listener(entry);
212
+ }
213
+ }
214
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Names log files so that everything the viewer lists and filters on is in
3
+ * the name itself.
4
+ *
5
+ * `YYYY-MM-DD/HHMMSS-mmmZ_p<pid>_<c|s><sequence>_<tool>_<ok|failed>.json`
6
+ *
7
+ * - **A folder per UTC day**, so no single folder grows to tens of thousands
8
+ * of entries, and old days can be archived or deleted as a whole.
9
+ * - **Time first**, so names sort chronologically; the `Z` says it is UTC.
10
+ * - **The pid and a per-process sequence**, so several copies of the server
11
+ * writing to one folder can never produce the same name.
12
+ * - **The tool and outcome last**, so paging, the tool filter and "failed
13
+ * only" read directory listings and never open a file. Only a text search
14
+ * reads contents.
15
+ */
16
+ export class LogFileNames {
17
+ static DAY = /^\d{4}-\d{2}-\d{2}$/;
18
+ static FILE = /^(\d{2})(\d{2})(\d{2})-(\d{3})Z_p(\d+)_([cs])(\d+)_([A-Za-z0-9_-]+?)_(ok|failed)\.json$/;
19
+ static dayFolder(at) {
20
+ return at.toISOString().slice(0, 10);
21
+ }
22
+ static build(at, pid, kind, sequence, tool, failed) {
23
+ const iso = at.toISOString();
24
+ const time = `${iso.slice(11, 13)}${iso.slice(14, 16)}${iso.slice(17, 19)}-${iso.slice(20, 23)}Z`;
25
+ const marker = kind === "call" ? "c" : "s";
26
+ const label = kind === "call" ? LogFileNames.safe(tool ?? "call") : "statement";
27
+ return `${LogFileNames.dayFolder(at)}/${time}_p${pid}_${marker}${String(sequence).padStart(6, "0")}_${label}_${failed ? "failed" : "ok"}.json`;
28
+ }
29
+ static isDayFolder(name) {
30
+ return LogFileNames.DAY.test(name);
31
+ }
32
+ /** @returns null for anything that is not one of our files, including half-written temporaries. */
33
+ static parse(day, file) {
34
+ const match = LogFileNames.FILE.exec(file);
35
+ if (!match || !LogFileNames.isDayFolder(day)) {
36
+ return null;
37
+ }
38
+ const [, hh, mm, ss, ms, pid, marker, , label, outcome] = match;
39
+ const at = new Date(`${day}T${hh}:${mm}:${ss}.${ms}Z`);
40
+ if (Number.isNaN(at.getTime())) {
41
+ return null;
42
+ }
43
+ const kind = marker === "c" ? "call" : "statement";
44
+ return {
45
+ relativePath: `${day}/${file}`,
46
+ at,
47
+ pid: Number(pid),
48
+ kind,
49
+ tool: kind === "call" ? label : null,
50
+ failed: outcome === "failed",
51
+ };
52
+ }
53
+ /** Tool names are already safe; this keeps any future one from reaching the path as-is. */
54
+ static safe(name) {
55
+ return name.replace(/[^A-Za-z0-9_-]/g, "-") || "call";
56
+ }
57
+ }
@@ -0,0 +1,18 @@
1
+ /** Pages and page counts, shared by both stores so they cannot disagree. */
2
+ export class Paging {
3
+ static DEFAULT_SIZE = 20;
4
+ static MAX_SIZE = 100;
5
+ static normalise(query) {
6
+ const size = Math.min(Math.max(Math.trunc(query.size) || Paging.DEFAULT_SIZE, 1), Paging.MAX_SIZE);
7
+ const page = Math.max(Math.trunc(query.page) || 1, 1);
8
+ return { page, size };
9
+ }
10
+ static page(matching, query) {
11
+ const { page, size } = Paging.normalise(query);
12
+ const total = matching.length;
13
+ const pages = Math.max(Math.ceil(total / size), 1);
14
+ const current = Math.min(page, pages);
15
+ const start = (current - 1) * size;
16
+ return { slice: matching.slice(start, start + size), total, page: current, size, pages };
17
+ }
18
+ }
@@ -0,0 +1,70 @@
1
+ import { RecordJson } from "../RecordJson.js";
2
+ import { Paging } from "./LogStore.js";
3
+ /**
4
+ * The viewer's store when no log folder is configured: this process's most
5
+ * recent entries, in memory only, oldest dropped first past the capacity.
6
+ *
7
+ * A LogChannel as well, since without a folder there is nothing else to read
8
+ * entries back from: it is fed directly by the call logger.
9
+ */
10
+ export class MemoryLogStore {
11
+ capacity;
12
+ description = "the live viewer's memory";
13
+ entries = [];
14
+ listeners = new Set();
15
+ constructor(capacity) {
16
+ this.capacity = capacity;
17
+ }
18
+ onCall(call) {
19
+ this.add({ key: `m:${call.pid}:c${call.id}`, kind: "call", data: RecordJson.call(call) });
20
+ }
21
+ onStatement(statement) {
22
+ this.add({ key: `m:${statement.pid}:s${statement.at.getTime()}:${this.entries.length}`, kind: "statement", data: RecordJson.statement(statement) });
23
+ }
24
+ start() {
25
+ return;
26
+ }
27
+ stop() {
28
+ return;
29
+ }
30
+ subscribe(listener) {
31
+ this.listeners.add(listener);
32
+ return () => this.listeners.delete(listener);
33
+ }
34
+ async tools() {
35
+ const names = new Set();
36
+ for (const entry of this.entries) {
37
+ if (entry.kind === "call") {
38
+ names.add(String(entry.data.tool));
39
+ }
40
+ }
41
+ return Array.from(names).sort();
42
+ }
43
+ async query(query) {
44
+ const needle = query.text?.trim().toLowerCase();
45
+ const matching = this.entries
46
+ .filter((entry) => (!query.tool || (entry.kind === "call" && entry.data.tool === query.tool)) &&
47
+ (!query.failedOnly || entry.data.failed === true) &&
48
+ (!needle || entry.search.includes(needle)))
49
+ .reverse();
50
+ const paged = Paging.page(matching, query);
51
+ return {
52
+ entries: paged.slice.map(({ key, kind, data }) => ({ key, kind, data })),
53
+ total: paged.total,
54
+ page: paged.page,
55
+ size: paged.size,
56
+ pages: paged.pages,
57
+ };
58
+ }
59
+ add(entry) {
60
+ if (this.capacity > 0) {
61
+ this.entries.push({ ...entry, search: JSON.stringify(entry.data).toLowerCase() });
62
+ if (this.entries.length > this.capacity) {
63
+ this.entries.splice(0, this.entries.length - this.capacity);
64
+ }
65
+ }
66
+ for (const listener of this.listeners) {
67
+ listener(entry);
68
+ }
69
+ }
70
+ }
@@ -0,0 +1,264 @@
1
+ import { execFile } from "node:child_process";
2
+ import { createServer } from "node:http";
3
+ import { JsonSerializer } from "../../formatting/JsonSerializer.js";
4
+ import { Paging } from "../store/LogStore.js";
5
+ import { ViewerAssets } from "./ViewerAssets.js";
6
+ /**
7
+ * `lsof`, present on macOS and most Linux hosts. Absent in the Alpine image,
8
+ * where the message simply says the port is in use without naming anyone.
9
+ */
10
+ export const lsofPortOwner = (port) => new Promise((resolve) => {
11
+ execFile("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-Fpc"], { timeout: 2000 }, (error, stdout) => {
12
+ if (error) {
13
+ resolve(null);
14
+ return;
15
+ }
16
+ const pid = /^p(\d+)$/m.exec(stdout)?.[1];
17
+ const command = /^c(.+)$/m.exec(stdout)?.[1];
18
+ resolve(pid ? `${command ?? "a process"} (pid ${pid})` : null);
19
+ });
20
+ });
21
+ /**
22
+ * The live log viewer: a page in the browser listing logged calls, a page at
23
+ * a time, newest first, and updating as new ones arrive.
24
+ *
25
+ * It reads a LogStore: the permanent log folder when DB_LOG_DIR is set, which
26
+ * holds the calls of every copy of the server sharing it, or this process's
27
+ * recent calls in memory otherwise. It never sees a record that has not been
28
+ * through the redactor, because the stores only ever hold what the call
29
+ * logger produced.
30
+ *
31
+ * It serves, read-only, with Node's own `http` module:
32
+ *
33
+ * - `/`, `/viewer.js`, `/viewer.css`: the page, self-contained, fetching
34
+ * nothing from anywhere else;
35
+ * - `/api/entries?page=&size=&tool=&failed=1&q=`: one page of entries, with
36
+ * the filters applied across everything stored;
37
+ * - `/api/tools`: the tool names present, for the tool filter;
38
+ * - `/events`: Server-Sent Events announcing each new entry.
39
+ *
40
+ * It binds its port **on the first tool call, not at startup**. An MCP client
41
+ * such as Claude Desktop starts one copy of the server per chat surface, and
42
+ * most of those copies never receive a call. Binding lazily means the copy
43
+ * actually in use gets the port, and idle copies never hold one.
44
+ *
45
+ * It must never become the reason the process misbehaves:
46
+ *
47
+ * - **A port that is taken is reported, not fatal.** `ensureRunning` returns
48
+ * why, naming the process that holds the port where it can, and every
49
+ * later call retries, so freeing the port brings the viewer up without a
50
+ * restart.
51
+ * - **It never keeps the process alive.** The listening socket and every
52
+ * connected browser are unref'd, so the process lives exactly as long as it
53
+ * would with no viewer. Without this, a client that died without sending
54
+ * SIGTERM would leave an orphan holding the port, and the next session's
55
+ * viewer would find it taken.
56
+ */
57
+ export class LiveLogViewer {
58
+ host;
59
+ port;
60
+ store;
61
+ logger;
62
+ portOwner;
63
+ assets;
64
+ serializer;
65
+ /** Keeps idle connections open through proxies and sleeping laptops. */
66
+ static HEARTBEAT_MS = 15000;
67
+ clients = new Set();
68
+ server = null;
69
+ heartbeat = null;
70
+ unsubscribe = null;
71
+ current = { state: "idle" };
72
+ binding = null;
73
+ stopped = false;
74
+ constructor(host, port, store, logger, portOwner = lsofPortOwner, assets = new ViewerAssets(), serializer = new JsonSerializer()) {
75
+ this.host = host;
76
+ this.port = port;
77
+ this.store = store;
78
+ this.logger = logger;
79
+ this.portOwner = portOwner;
80
+ this.assets = assets;
81
+ this.serializer = serializer;
82
+ }
83
+ get status() {
84
+ return this.current;
85
+ }
86
+ /** The port actually bound, which differs from the requested one only when that was 0. */
87
+ get boundPort() {
88
+ const address = this.server?.address();
89
+ return address && typeof address === "object" ? address.port : null;
90
+ }
91
+ /** Binding is deferred to the first tool call; see the class comment. */
92
+ async start() {
93
+ return;
94
+ }
95
+ /**
96
+ * Serve, if not already serving. Called before every tool call; cheap once
97
+ * running, and a quick retry while the port is taken. Never throws.
98
+ * Concurrent calls share one attempt.
99
+ */
100
+ ensureRunning() {
101
+ if (this.stopped || this.current.state === "running") {
102
+ return Promise.resolve(this.current);
103
+ }
104
+ if (!this.binding) {
105
+ this.binding = this.bind().finally(() => {
106
+ this.binding = null;
107
+ });
108
+ }
109
+ return this.binding;
110
+ }
111
+ /**
112
+ * Keep the process alive for as long as the viewer listens. Only the
113
+ * standalone `viewer` command wants this; inside an MCP server the viewer
114
+ * must never be what keeps the process running.
115
+ */
116
+ holdProcessOpen() {
117
+ this.server?.ref();
118
+ }
119
+ /** Never throws: shutdown calls it. */
120
+ async stop() {
121
+ this.stopped = true;
122
+ if (this.heartbeat) {
123
+ clearInterval(this.heartbeat);
124
+ this.heartbeat = null;
125
+ }
126
+ this.unsubscribe?.();
127
+ this.unsubscribe = null;
128
+ this.store.stop();
129
+ for (const client of this.clients) {
130
+ client.end();
131
+ }
132
+ this.clients.clear();
133
+ const server = this.server;
134
+ this.server = null;
135
+ if (server) {
136
+ await new Promise((resolve) => server.close(() => resolve()));
137
+ }
138
+ }
139
+ async bind() {
140
+ const server = createServer((request, response) => void this.handle(request, response));
141
+ const failure = await new Promise((resolve) => {
142
+ server.once("error", (error) => resolve(error));
143
+ server.listen(this.port, this.host, () => resolve(null));
144
+ });
145
+ if (failure) {
146
+ server.close();
147
+ this.current = { state: "unavailable", port: this.port, reason: await this.describeFailure(failure) };
148
+ return this.current;
149
+ }
150
+ server.unref();
151
+ server.on("error", (error) => this.logger(`live log viewer: ${error.message}`));
152
+ this.server = server;
153
+ this.store.start();
154
+ this.unsubscribe = this.store.subscribe((entry) => this.broadcast(this.frame(entry)));
155
+ this.heartbeat = setInterval(() => this.broadcast(": heartbeat\n\n"), LiveLogViewer.HEARTBEAT_MS);
156
+ this.heartbeat.unref();
157
+ this.current = { state: "running", url: `http://${this.displayHost()}:${this.boundPort}/` };
158
+ // Said plainly, because with no access control anyone who can reach
159
+ // this address can read every query and every result.
160
+ this.logger(`live log viewer at ${this.current.url} (listening on ${this.host}, no access control: anyone who can reach this port can read the full call log)`);
161
+ return this.current;
162
+ }
163
+ async describeFailure(error) {
164
+ if (error.code !== "EADDRINUSE") {
165
+ return `port ${this.port} could not be opened: ${error.message}`;
166
+ }
167
+ const owner = await this.portOwner(this.port).catch(() => null);
168
+ return owner ? `port ${this.port} is used by ${owner}` : `port ${this.port} is already in use`;
169
+ }
170
+ broadcast(frame) {
171
+ for (const client of this.clients) {
172
+ client.write(frame);
173
+ }
174
+ }
175
+ /**
176
+ * One SSE message. The data is compact JSON, which escapes every newline
177
+ * inside its strings, so it is always a single `data:` line and cannot be
178
+ * split or spoofed by content containing blank lines.
179
+ */
180
+ frame(entry) {
181
+ return `event: entry\ndata: ${this.serializer.stringify(entry, 0)}\n\n`;
182
+ }
183
+ async handle(request, response) {
184
+ // Reads only. Nothing on this server accepts input.
185
+ if (request.method !== "GET" && request.method !== "HEAD") {
186
+ this.send(response, 405, "text/plain; charset=utf-8", "Method not allowed", { allow: "GET, HEAD" });
187
+ return;
188
+ }
189
+ const url = new URL(request.url ?? "/", "http://viewer.invalid");
190
+ try {
191
+ switch (url.pathname) {
192
+ case "/":
193
+ this.send(response, 200, "text/html; charset=utf-8", this.assets.html);
194
+ return;
195
+ case "/viewer.js":
196
+ this.send(response, 200, "text/javascript; charset=utf-8", this.assets.script);
197
+ return;
198
+ case "/viewer.css":
199
+ this.send(response, 200, "text/css; charset=utf-8", this.assets.stylesheet);
200
+ return;
201
+ case "/api/entries":
202
+ this.sendJson(response, await this.store.query(this.parseQuery(url.searchParams)));
203
+ return;
204
+ case "/api/tools":
205
+ this.sendJson(response, { tools: await this.store.tools() });
206
+ return;
207
+ case "/events":
208
+ this.subscribe(request, response);
209
+ return;
210
+ default:
211
+ this.send(response, 404, "text/plain; charset=utf-8", "Not found");
212
+ }
213
+ }
214
+ catch (error) {
215
+ this.send(response, 500, "text/plain; charset=utf-8", `Could not read the log: ${error instanceof Error ? error.message : String(error)}`);
216
+ }
217
+ }
218
+ parseQuery(params) {
219
+ return {
220
+ page: Number(params.get("page") ?? 1),
221
+ size: Number(params.get("size") ?? Paging.DEFAULT_SIZE),
222
+ tool: params.get("tool") || undefined,
223
+ failedOnly: params.get("failed") === "1",
224
+ text: params.get("q") || undefined,
225
+ };
226
+ }
227
+ subscribe(request, response) {
228
+ response.writeHead(200, {
229
+ ...this.securityHeaders(),
230
+ "content-type": "text/event-stream; charset=utf-8",
231
+ "cache-control": "no-store",
232
+ connection: "keep-alive",
233
+ });
234
+ response.socket?.unref();
235
+ // Tell the browser to retry after 2 s if the server restarts.
236
+ response.write("retry: 2000\n\nevent: ready\ndata: {}\n\n");
237
+ this.clients.add(response);
238
+ request.on("close", () => this.clients.delete(response));
239
+ }
240
+ sendJson(response, value) {
241
+ this.send(response, 200, "application/json; charset=utf-8", this.serializer.stringify(value, 0));
242
+ }
243
+ send(response, status, contentType, body, extra = {}) {
244
+ response.writeHead(status, { ...this.securityHeaders(), "content-type": contentType, ...extra });
245
+ response.end(body);
246
+ }
247
+ /**
248
+ * The page runs with a policy allowing only its own script, stylesheet,
249
+ * API and event stream, so even a result that somehow reached the page as
250
+ * markup could not load or run anything.
251
+ */
252
+ securityHeaders() {
253
+ return {
254
+ "content-security-policy": "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
255
+ "x-content-type-options": "nosniff",
256
+ "referrer-policy": "no-referrer",
257
+ "cache-control": "no-store",
258
+ };
259
+ }
260
+ /** 0.0.0.0 is where it listens, not an address a browser can open. */
261
+ displayHost() {
262
+ return this.host === "0.0.0.0" || this.host === "::" ? "127.0.0.1" : this.host;
263
+ }
264
+ }