@gravitylabsllc/porthole 0.1.0 → 0.2.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.
Files changed (55) hide show
  1. package/README.md +123 -0
  2. package/dist/adb.js +430 -21
  3. package/dist/adb.js.map +1 -1
  4. package/dist/args.js +144 -0
  5. package/dist/args.js.map +1 -0
  6. package/dist/capture.js +139 -30
  7. package/dist/capture.js.map +1 -1
  8. package/dist/cli.js +221 -62
  9. package/dist/cli.js.map +1 -1
  10. package/dist/device.js +337 -4
  11. package/dist/device.js.map +1 -1
  12. package/dist/index.js +2030 -377
  13. package/dist/index.js.map +1 -1
  14. package/dist/moment.js +240 -0
  15. package/dist/moment.js.map +1 -0
  16. package/dist/perfetto.js +826 -0
  17. package/dist/perfetto.js.map +1 -0
  18. package/dist/report.js +68 -7
  19. package/dist/report.js.map +1 -1
  20. package/dist/save.js +252 -0
  21. package/dist/save.js.map +1 -0
  22. package/dist/sessions.js +704 -0
  23. package/dist/sessions.js.map +1 -0
  24. package/dist/system.js +169 -0
  25. package/dist/system.js.map +1 -0
  26. package/dist/systrace.js +198 -0
  27. package/dist/systrace.js.map +1 -0
  28. package/dist/timeline.js +731 -29
  29. package/dist/timeline.js.map +1 -1
  30. package/dist/trace.js +317 -27
  31. package/dist/trace.js.map +1 -1
  32. package/dist/watermark.js +220 -0
  33. package/dist/watermark.js.map +1 -0
  34. package/package.json +10 -4
  35. package/src/adb.ts +583 -0
  36. package/src/args.ts +177 -0
  37. package/src/capture.ts +292 -0
  38. package/src/cli.ts +367 -0
  39. package/src/device.ts +635 -0
  40. package/src/index.ts +2545 -0
  41. package/src/moment.ts +306 -0
  42. package/src/perfetto.ts +972 -0
  43. package/src/report.ts +285 -0
  44. package/src/save.ts +322 -0
  45. package/src/sessions.ts +894 -0
  46. package/src/system.ts +221 -0
  47. package/src/systrace.ts +258 -0
  48. package/src/timeline.ts +1036 -0
  49. package/src/trace.ts +769 -0
  50. package/src/watermark.ts +337 -0
  51. package/ui/dist/assets/index-DtnyBXCM.css +1 -0
  52. package/ui/dist/assets/index-h7VNB9Fl.js +70 -0
  53. package/ui/dist/index.html +2 -2
  54. package/ui/dist/assets/index--1mlZuNZ.css +0 -1
  55. package/ui/dist/assets/index-BeVGHRFm.js +0 -68
@@ -0,0 +1,1036 @@
1
+ // Copyright 2026 Gravity Labs
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { restartApp, resolveProjectRoot } from "./adb.js";
4
+ import http from "node:http";
5
+ import { readFile } from "node:fs/promises";
6
+ import { readdirSync, statSync } from "node:fs";
7
+ import { fileURLToPath } from "node:url";
8
+ import { extname, resolve, sep } from "node:path";
9
+ import { WebSocketServer, WebSocket } from "ws";
10
+ import { isConnected, isHandshaking, type ConnectionState, type DeviceClient, type DeviceEvent } from "./device.js";
11
+ import { askTrace, findTraceProcessor, parseRows, runScript, why, QUESTIONS, type RunResult } from "./perfetto.js";
12
+ import { buildTrace, resolveProfile } from "./trace.js";
13
+ import { fromBootMs, fromTraceClockSnapshot, toBootNs } from "./moment.js";
14
+ import { UNKNOWN_DEVICE_ID, fillWindowFromDisk, sessionsRoot, type SessionEvent } from "./sessions.js";
15
+ import { InvalidScenarioError, buildSavedTrace, defaultOutPath, defaultScenarioName, validateScenario, writeSavedTrace } from "./save.js";
16
+
17
+ const UI_DIR = fileURLToPath(new URL("../ui/dist/", import.meta.url));
18
+
19
+ const CONTENT_TYPES: Record<string, string> = {
20
+ ".html": "text/html; charset=utf-8",
21
+ ".js": "text/javascript; charset=utf-8",
22
+ ".css": "text/css; charset=utf-8",
23
+ ".svg": "image/svg+xml",
24
+ ".woff2": "font/woff2",
25
+ ".json": "application/json",
26
+ ".map": "application/json",
27
+ };
28
+
29
+ /** Roughly ten minutes of a busy app. The device ring is smaller; this is the wider view. */
30
+ const BUFFER_LIMIT = 20_000;
31
+
32
+ /**
33
+ * The database calls the inspector may make, by exact path.
34
+ *
35
+ * A map rather than a chain of comparisons ending in `db_query`, because that
36
+ * chain had a fall-through: every path under `/api/db/` that was not `tables`
37
+ * or `rows` — `/api/db/`, `/api/db/nonsense`, anything — became a `db_query`
38
+ * carrying whatever `sql` the query string held. Unknown paths are now a 404.
39
+ */
40
+ const DB_ROUTES: Record<string, string> = {
41
+ "/api/db/tables": "db_tables",
42
+ "/api/db/rows": "db_rows",
43
+ "/api/db/query": "db_query",
44
+ };
45
+
46
+ /**
47
+ * Vite's dev port, which this server also answers to. See `authorities`.
48
+ *
49
+ * `npm run dev` in `mcp/ui` serves the UI from :5273 and proxies `/api` and
50
+ * `/ws` here. Vite's proxy leaves `Host` and `Origin` exactly as the browser
51
+ * wrote them (`changeOrigin` is off by default), so a same-origin request from
52
+ * the dev UI arrives here addressed to :5273. Verified, not assumed: with the
53
+ * repo's own vite.config.ts, `Host: localhost:5273` and
54
+ * `Origin: http://localhost:5273` are what land on the target socket, for the
55
+ * WebSocket upgrade as well as for `/api`.
56
+ *
57
+ * The port is safe to allow only because `refuse` also requires the two halves
58
+ * to agree. As a bare entry in an allowlist it was a hole, and the hole was
59
+ * the WebSocket: nothing stops some other page from being served on :5273 —
60
+ * the dev server is not the only thing that can hold a port, and a developer
61
+ * who visits it has given that page this origin. Such a page opening
62
+ * `ws://127.0.0.1:8678/ws` produced an upgrade carrying this server's own
63
+ * `Host` and that page's allowed `Origin`, which passed, and the socket
64
+ * answered with the entire event buffer. Requiring agreement refuses that
65
+ * pair — the two halves name different ports — while still admitting the
66
+ * proxy's, where both say :5273.
67
+ */
68
+ const VITE_DEV_PORT = 5273;
69
+
70
+ /** Loopback spellings of one port, as they appear in a `Host` header. */
71
+ function loopbackAuthorities(port: number): string[] {
72
+ return [`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`];
73
+ }
74
+
75
+ /**
76
+ * Where captures live: flat, under the project root. GRA-113's own open
77
+ * question asked whether a `.pftrace` should instead file under the session
78
+ * it covers once sessions are on disk (GRA-53) — answered "stay flat" for
79
+ * now; `/api/traces` is the one place that would have to change.
80
+ */
81
+ function tracesDir(): string {
82
+ return resolve(resolveProjectRoot().directory, ".porthole", "traces");
83
+ }
84
+
85
+ /**
86
+ * A `trace=` value has to look like a name this server minted itself —
87
+ * `id`s come only from what `/api/traces` just listed — before anything
88
+ * touches the filesystem or a subprocess with it. No path separator of
89
+ * either flavour is even a legal character, which is what refuses
90
+ * `../../../etc/passwd` and `..\..\..\Windows\win.ini` alike: neither `/`
91
+ * nor `\` is in the class below, so both fail here and never reach `resolve`.
92
+ */
93
+ const TRACE_ID_PATTERN = /^[A-Za-z0-9._-]+$/;
94
+
95
+ /**
96
+ * `id` → a real `.pftrace` inside the traces directory, or null for anything
97
+ * that is not exactly that (GRA-113 AC5). The regex above is already enough
98
+ * to refuse a traversal outright, since it admits no separator to traverse
99
+ * with; the `resolve`-and-prefix check is the same belt-and-suspenders
100
+ * pattern this file already uses for the static UI (see `target` below), and
101
+ * catches the id of a file that simply does not exist, which the regex alone
102
+ * cannot.
103
+ */
104
+ function resolveTraceFile(id: string): string | null {
105
+ if (!TRACE_ID_PATTERN.test(id)) return null;
106
+ const dir = resolve(tracesDir());
107
+ const resolved = resolve(dir, `${id}.pftrace`);
108
+ if (resolved !== `${dir}${sep}${id}.pftrace`) return null;
109
+ try {
110
+ if (!statSync(resolved).isFile()) return null;
111
+ } catch {
112
+ return null;
113
+ }
114
+ return resolved;
115
+ }
116
+
117
+ interface TraceListing {
118
+ id: string;
119
+ bytes: number;
120
+ recordedAt: string;
121
+ coverage: { from: number; to: number } | null;
122
+ reason?: string;
123
+ }
124
+
125
+ /** A trace's coverage never changes for a given `(path, mtime)`, so it is computed at most once per file no matter how many times `/api/traces` is polled — GRA-82's pattern applied to a new endpoint rather than re-litigated for it. */
126
+ const coverageCache = new Map<string, { coverage: { from: number; to: number } | null; reason?: string }>();
127
+
128
+ /** trace_processor's own budget for this: bounds and a clock snapshot are cheap next to loading a whole trace to answer the five questions, so a much shorter fuse than `askTrace`'s 60s is enough to call a hang a hang. */
129
+ const COVERAGE_TIMEOUT_MS = 20_000;
130
+
131
+ /**
132
+ * `start_ts`/`end_ts` and a clock snapshot, in one invocation — one trace
133
+ * load, whatever `/api/traces` asks about next. Two plain `SELECT`s of
134
+ * nothing but numbers, so splitting the output on the blank line between
135
+ * statements (which `matchBatch` in perfetto.ts deliberately does NOT do for
136
+ * the five questions, because a slice name can contain one) is safe here:
137
+ * nothing in this query's result can contain an embedded newline.
138
+ */
139
+ const COVERAGE_SQL =
140
+ 'SELECT start_ts, end_ts FROM trace_bounds;\n' +
141
+ 'SELECT clock_id, clock_value, ts FROM clock_snapshot WHERE clock_id IN (3, 6) ORDER BY snapshot_id LIMIT 2;';
142
+
143
+ function readCoverage(result: RunResult): { coverage: { from: number; to: number } | null; reason?: string } {
144
+ if (result.spawnError) {
145
+ return { coverage: null, reason: `trace_processor could not run: ${result.spawnError.message}` };
146
+ }
147
+ if (result.timedOut) {
148
+ return {
149
+ coverage: null,
150
+ reason: `trace_processor did not answer within ${result.elapsedMs}ms; it may be wedged`,
151
+ };
152
+ }
153
+ if (result.code !== 0) {
154
+ return { coverage: null, reason: `trace_processor could not read this file: ${why(result.stderr, undefined)}` };
155
+ }
156
+
157
+ const [boundsBlock, clockBlock] = result.stdout.trim().split(/\r?\n\r?\n/);
158
+ const bounds = parseRows(boundsBlock ?? "");
159
+ const clocks = parseRows(clockBlock ?? "");
160
+ const boot = clocks.find((r) => String(r.clock_id) === "6");
161
+ const monotonic = clocks.find((r) => String(r.clock_id) === "3");
162
+ const startTs = Number(bounds[0]?.start_ts);
163
+ const endTs = Number(bounds[0]?.end_ts);
164
+
165
+ if (!boot || !monotonic) {
166
+ return {
167
+ coverage: null,
168
+ reason: "the trace has no clock snapshot, so its window cannot be placed on the device's uptime clock",
169
+ };
170
+ }
171
+ if (!Number.isFinite(startTs) || !Number.isFinite(endTs) || (startTs === 0 && endTs === 0)) {
172
+ return { coverage: null, reason: "the trace has no bounds — trace_processor read it as empty" };
173
+ }
174
+
175
+ const snapshot = { bootNs: Number(boot.clock_value), monotonicNs: Number(monotonic.clock_value) };
176
+ return {
177
+ coverage: {
178
+ from: fromTraceClockSnapshot(snapshot, startTs),
179
+ to: fromTraceClockSnapshot(snapshot, endTs),
180
+ },
181
+ };
182
+ }
183
+
184
+ async function coverageOf(
185
+ binary: string,
186
+ tracePath: string,
187
+ mtimeMs: number,
188
+ ): Promise<{ coverage: { from: number; to: number } | null; reason?: string }> {
189
+ const key = `${tracePath} ${mtimeMs}`;
190
+ const cached = coverageCache.get(key);
191
+ if (cached) return cached;
192
+
193
+ const result = await runScript(binary, ["query", "-f", "-", tracePath], COVERAGE_SQL, COVERAGE_TIMEOUT_MS);
194
+ const answer = readCoverage(result);
195
+ coverageCache.set(key, answer);
196
+ return answer;
197
+ }
198
+
199
+ /**
200
+ * `<project root>/.porthole/traces/*.pftrace`, as `id`/`bytes`/`recordedAt`/
201
+ * `coverage` (GRA-113). Missing or unreadable directory, or one with nothing
202
+ * `.pftrace` in it, is an empty list rather than an error — the same
203
+ * "absence is not exceptional" choice `findTraceProcessor` and
204
+ * `resolveSdkDir` already make elsewhere in this codebase.
205
+ *
206
+ * Sequential, not `Promise.all` over the list: `coverageOf` spawns
207
+ * trace_processor, and running several at once against a socket-driven
208
+ * server that also has a live device attached is exactly the kind of
209
+ * resource spike GRA-82 exists to avoid. One at a time also means the cache
210
+ * above is never asked to answer for a file mid-computation from a second
211
+ * concurrent request.
212
+ */
213
+ async function listTraces(): Promise<TraceListing[]> {
214
+ const dir = tracesDir();
215
+ let names: string[];
216
+ try {
217
+ names = readdirSync(dir);
218
+ } catch {
219
+ return [];
220
+ }
221
+
222
+ const files = names
223
+ .filter((name) => extname(name) === ".pftrace")
224
+ .map((name) => {
225
+ const path = resolve(dir, name);
226
+ const stats = statSync(path);
227
+ return { id: name.slice(0, -".pftrace".length), path, bytes: stats.size, mtimeMs: stats.mtimeMs };
228
+ });
229
+
230
+ const binary = findTraceProcessor();
231
+ const out: TraceListing[] = [];
232
+ for (const file of files) {
233
+ const { coverage, reason } = binary
234
+ ? await coverageOf(binary, file.path, file.mtimeMs)
235
+ : {
236
+ coverage: null,
237
+ reason:
238
+ "trace_processor_shell was not found, so this trace's coverage could not be read. " +
239
+ "`./gradlew portholeTraceProcessor` fetches it.",
240
+ };
241
+ out.push({
242
+ id: file.id,
243
+ bytes: file.bytes,
244
+ recordedAt: new Date(file.mtimeMs).toISOString(),
245
+ coverage,
246
+ ...(reason ? { reason } : {}),
247
+ });
248
+ }
249
+ // Newest first: the trace someone just captured is the one they are asking about.
250
+ return out.sort((a, b) => b.recordedAt.localeCompare(a.recordedAt));
251
+ }
252
+
253
+ /**
254
+ * Serves the timeline UI and streams events to it.
255
+ *
256
+ * Kept separate from the MCP transport on purpose: MCP talks stdio to the agent,
257
+ * this talks HTTP and WebSocket to a browser, and the two never cross. The event
258
+ * buffer is shared so the UI and the `timeline` tool see the same history.
259
+ */
260
+ interface OtherTimeline {
261
+ connected: boolean;
262
+ app: string | null;
263
+ device: string | null;
264
+ devicePort: number;
265
+ }
266
+
267
+ /** An EADDRINUSE that knows whether the squatter is one of ours and alive. */
268
+ export interface PortInUse extends Error {
269
+ portholeAlreadyRunning: boolean;
270
+ url: string;
271
+ }
272
+
273
+ export class TimelineServer {
274
+ private server: http.Server | null = null;
275
+ private wss: WebSocketServer | null = null;
276
+ private events: DeviceEvent[] = [];
277
+ private started = false;
278
+
279
+ /**
280
+ * Every authority this server will answer to. Compared whole, lower-cased,
281
+ * never parsed. There is no matching list of origins: an `Origin` is checked
282
+ * against the one authority `Host` names, not against a list of its own.
283
+ *
284
+ * `Host` and `Origin` are text the caller chose, and every parser of them has
285
+ * a seam — `user@host`, a trailing dot, an embedded slash, a bracketed v6
286
+ * literal — where two readers disagree about which part is the name. A fixed
287
+ * list of strings has no seam: `127.0.0.1.evil.com` is simply not in it.
288
+ */
289
+ private readonly authorities: Set<string>;
290
+
291
+ constructor(
292
+ private readonly device: DeviceClient,
293
+ private readonly port: number,
294
+ private readonly serial?: string,
295
+ ) {
296
+ this.authorities = new Set([
297
+ ...loopbackAuthorities(port),
298
+ ...loopbackAuthorities(VITE_DEV_PORT),
299
+ ]);
300
+
301
+ device.on("event", (event: DeviceEvent) => this.record(event));
302
+ device.on("state", (state: ConnectionState) => this.broadcast({ type: "state", state }));
303
+ device.on("hello", (hello: unknown) => {
304
+ // A process is a session. Sequence numbers restart with it, so keeping the
305
+ // previous process's events would put two timelines on one axis — and,
306
+ // worse, make the new process's first events look like ones already seen.
307
+ const startedAt = (hello as { startedAt?: number } | null)?.startedAt;
308
+ if (startedAt !== undefined && startedAt !== this.startedAt) {
309
+ this.startedAt = startedAt;
310
+ this.events = [];
311
+ }
312
+ this.broadcast({ type: "hello", hello });
313
+ void this.backfill();
314
+ });
315
+ }
316
+
317
+ /** Uptime the connected process started at; identifies the session. */
318
+ private startedAt: number | undefined;
319
+
320
+ /** Everything we have seen, newest last. Used by the `timeline` tool too. */
321
+ buffer(): DeviceEvent[] {
322
+ return this.events;
323
+ }
324
+
325
+ private record(event: DeviceEvent): void {
326
+ this.events.push(event);
327
+ if (this.events.length > BUFFER_LIMIT) {
328
+ this.events.splice(0, this.events.length - BUFFER_LIMIT);
329
+ }
330
+ this.broadcast({ type: "event", event });
331
+ }
332
+
333
+ /**
334
+ * Pulls the device's own ring after a (re)connect, so events emitted while
335
+ * nothing was listening are not lost — which is most of them, since the app
336
+ * usually starts before anyone attaches.
337
+ */
338
+ private async backfill(): Promise<void> {
339
+ try {
340
+ const page = await this.device.request<{ events: DeviceEvent[] }>("timeline", {
341
+ limit: 2000,
342
+ });
343
+ const known = new Set(this.events.map((e) => e.seq));
344
+ const fresh = page.events.filter((e) => !known.has(e.seq));
345
+ if (fresh.length === 0) return;
346
+ this.events = [...fresh, ...this.events].sort((a, b) => a.seq - b.seq).slice(-BUFFER_LIMIT);
347
+ this.broadcast({ type: "reset", events: this.events });
348
+ } catch {
349
+ // A failed backfill is not worth surfacing; live events still flow.
350
+ }
351
+ }
352
+
353
+ /**
354
+ * Why this request is not ours to answer, or null if it is.
355
+ *
356
+ * The socket is loopback-only, which keeps the network out but not the
357
+ * browser: every page the developer has open can reach 127.0.0.1, and a
358
+ * cross-origin POST — or a WebSocket upgrade — lands whether or not the
359
+ * attacker can read the reply. Three questions close that, and all three are
360
+ * about the browser's own account of the request rather than about its body:
361
+ *
362
+ * `Host` is what the URL said, and a page on the open web cannot forge it —
363
+ * which is also the answer to DNS rebinding, where a name the attacker owns
364
+ * resolves to 127.0.0.1 and arrives here as `Host: evil.example.com`.
365
+ *
366
+ * `Origin` is who asked, sent on every cross-origin request and on
367
+ * same-origin POSTs, and it must name the authority `Host` already named: a
368
+ * caller may only claim an origin it also claims to have been addressed to.
369
+ * That is a stronger rule than membership of a list, and a cheaper one to
370
+ * check by eye. It is also what makes the dev-port allowance safe rather than
371
+ * merely convenient, since :5273 is then admitted only when both halves say
372
+ * :5273 — the Vite proxy — and refused when `Origin` alone does, which is a
373
+ * page that happens to have been served from that port.
374
+ *
375
+ * `Sec-Fetch-Site` is the browser's own verdict, and it backstops nothing on
376
+ * the path that matters. Current Chrome — 152, observed, not assumed — sends
377
+ * no `Sec-Fetch-*` header of any kind on a WebSocket handshake, and the
378
+ * handshake is the request that answers with the whole event buffer. Every
379
+ * ordinary fetch from the same page carried `sec-fetch-site: cross-site`; the
380
+ * upgrade beside it carried nothing, so this check simply did not run. Its
381
+ * absence has to pass anyway, or curl, the MCP server's own health probe and
382
+ * anything older than 2020 stop working. Take it as a third opinion where a
383
+ * browser offers one, never as a defence the other two can lean on.
384
+ */
385
+ private refuse(req: http.IncomingMessage): string | null {
386
+ const host = req.headers.host?.toLowerCase();
387
+ if (host === undefined || !this.authorities.has(host)) {
388
+ return "Refused: this is a loopback debug server, and that is not one of its own addresses.";
389
+ }
390
+
391
+ // `http://` and nothing else: this server has no certificate and never
392
+ // will, so an https origin claiming to be us is someone else.
393
+ const origin = req.headers.origin?.toLowerCase();
394
+ if (origin !== undefined && origin !== `http://${host}`) {
395
+ return "Refused: this debug server answers only its own page, and that request came from elsewhere.";
396
+ }
397
+
398
+ const site = req.headers["sec-fetch-site"]?.toString().toLowerCase();
399
+ if (site !== undefined && site !== "same-origin" && site !== "none") {
400
+ return "Refused: the browser reports this request did not come from this server's own page.";
401
+ }
402
+
403
+ return null;
404
+ }
405
+
406
+ /**
407
+ * GRA-116: the identity `fillWindowFromDisk` should look up sessions
408
+ * under, mirroring `index.ts`'s own `currentIdentity()` (the function this
409
+ * one is a deliberate copy of, not an import of — `TimelineServer` has no
410
+ * dependency on `index.ts` today and one new route is not reason enough to
411
+ * start one). Falls back to `lastExited`'s own `hello` so a save can still
412
+ * find the right session after the app has already exited — the same
413
+ * post-mortem case `save_moment` exists for. Null only when neither has
414
+ * ever existed.
415
+ */
416
+ private currentIdentity(): { packageName: string; deviceId: string } | null {
417
+ const hello = this.device.hello ?? this.device.lastExited?.hello ?? null;
418
+ if (!hello) return null;
419
+ return {
420
+ packageName: hello.packageName,
421
+ deviceId: (hello as { deviceId?: string }).deviceId ?? UNKNOWN_DEVICE_ID,
422
+ };
423
+ }
424
+
425
+ /** A refusal, as a response. */
426
+ private refused(res: http.ServerResponse, reason: string): void {
427
+ // The reason names the rule and never the header that broke it. Everything
428
+ // this server holds is captured from someone's running app, and a 403 that
429
+ // quoted the caller's own text back would be one more place where a value
430
+ // that went in comes out again.
431
+ res.writeHead(403, { "content-type": "text/plain; charset=utf-8" });
432
+ res.end(`${reason}\n`);
433
+ }
434
+
435
+ async start(): Promise<string> {
436
+ if (this.started) return this.url();
437
+ this.started = true;
438
+
439
+ const server = http.createServer(async (req, res) => {
440
+ // Admission first, before any routing, so that adding an endpoint below
441
+ // cannot accidentally add one that is reachable from a web page.
442
+ const refusal = this.refuse(req);
443
+ if (refusal) {
444
+ this.refused(res, refusal);
445
+ return;
446
+ }
447
+
448
+ const path = (req.url ?? "/").split("?")[0];
449
+
450
+ // Identifies this server to another instance that finds the port
451
+ // taken. Sniffing the HTML would answer 'a web server' and not
452
+ // 'a Porthole, attached to this device, still alive'.
453
+ /**
454
+ * Both halves of the answer, in one list.
455
+ *
456
+ * Porthole says what the app was doing and that it hurt; a system trace
457
+ * says what the rest of the device was doing, and mostly rules causes
458
+ * out. They only belong in one list because they already share a shape —
459
+ * the same severities, and the same distinction between what was
460
+ * observed and what was merely adjacent. Each carries its source so a
461
+ * reader can tell which tool is making the claim.
462
+ */
463
+ // GRA-113: what captures exist, and the uptime window each one covers,
464
+ // so a caller can ask "does any trace have anything to say about what
465
+ // is on screen right now" without opening one. The ids this returns
466
+ // are the only ones `/api/findings?trace=` will accept.
467
+ if (path === "/api/traces") {
468
+ const traces = await listTraces();
469
+ res.writeHead(200, { "content-type": "application/json" });
470
+ res.end(JSON.stringify({ traces }));
471
+ return;
472
+ }
473
+
474
+ if (path === "/api/findings") {
475
+ const query = new URL(req.url ?? "/", "http://localhost");
476
+ const events = this.events;
477
+ const to = numberParam(query.searchParams.get("to")) ?? events[events.length - 1]?.t ?? 0;
478
+ const from = numberParam(query.searchParams.get("from")) ?? events[0]?.t ?? 0;
479
+ const within = events.filter((e) => e.t >= from && e.t <= to);
480
+
481
+ // GRA-113 AC5: `trace` is an id from `GET /api/traces`, never a
482
+ // filesystem path handed straight to trace_processor_shell. An id
483
+ // that does not resolve to a real file inside the traces directory —
484
+ // a traversal shape, an unknown name, an empty value — is refused
485
+ // right here, before anything else in this handler runs and in
486
+ // particular before anything is spawned.
487
+ const traceId = query.searchParams.get("trace");
488
+ const traceFile = traceId === null ? null : resolveTraceFile(traceId);
489
+ if (traceId !== null && traceFile === null) {
490
+ res.writeHead(400, { "content-type": "application/json" });
491
+ res.end(
492
+ JSON.stringify({
493
+ error: `Not a known trace id: ${JSON.stringify(traceId)}. See GET /api/traces.`,
494
+ }),
495
+ );
496
+ return;
497
+ }
498
+
499
+ // GRA-185: searched over the *unwindowed* `events` (the whole live
500
+ // ring), not `within` — a profile event before `from` still counts,
501
+ // which is the entire point of this ticket.
502
+ const profile = resolveProfile({
503
+ liveEvents: events,
504
+ windowTo: to,
505
+ sessionProfile: this.device.sessions?.currentMeta()?.profile ?? null,
506
+ hello: (this.device.hello as unknown as Record<string, unknown>) ?? null,
507
+ });
508
+ const live = buildTrace({
509
+ scenario: "live",
510
+ events: within,
511
+ hello: (this.device.hello as unknown as Record<string, unknown>) ?? null,
512
+ durationMs: Math.max(0, to - from),
513
+ withEvents: false,
514
+ profile,
515
+ });
516
+
517
+ type Sourced = (typeof live.findings)[number] & { source: "porthole" | "trace" };
518
+ const findings: Sourced[] = live.findings.map((f) => ({ ...f, source: "porthole" }));
519
+ const notes: string[] = [];
520
+ // GRA-115 ruling 4: which of the five questions trace_processor
521
+ // actually answered, so a UI asking "what did the trace rule out"
522
+ // does not have to infer it from the absence of a `trace-*` finding
523
+ // -- that guess is wrong for `thread_states`, which always produces
524
+ // a finding (even a reassuring one) whenever it is answered at all.
525
+ // Left undefined when no trace was even queried (no `trace=`, no
526
+ // binary, not attached): "asked nothing" and "asked and got nothing
527
+ // back" are different states, and this is the field that tells them
528
+ // apart.
529
+ let askedQuestions: { id: string; answered: boolean }[] | undefined;
530
+
531
+ if (traceFile) {
532
+ const binary = findTraceProcessor();
533
+ const app = this.device.hello?.packageName;
534
+ if (!binary) {
535
+ notes.push(
536
+ "trace_processor_shell was not found, so the trace could not be read. " +
537
+ "`./gradlew portholeTraceProcessor` fetches it; the trace itself already " +
538
+ "opens at ui.perfetto.dev.",
539
+ );
540
+ } else if (!app) {
541
+ // GRA-157: "Not attached" is wrong during the handshake window —
542
+ // the socket is up and a hello is already on its way, so telling
543
+ // the caller to attach is misleading advice for something
544
+ // already in progress. Name the wait instead when we can.
545
+ notes.push(
546
+ // GRA-162: isHandshaking() instead of `=== "handshaking"`.
547
+ isHandshaking(this.device.state)
548
+ ? "Still waiting on the app's first check-in, so there is no process yet to scope " +
549
+ "the trace to. Try again in a moment."
550
+ : "Not attached to an app, so there is no process to scope the trace to.",
551
+ );
552
+ } else {
553
+ // GRA-113: the one conversion, both directions, both through
554
+ // moment.ts — `toBootNs` to scope the query in the trace's own
555
+ // clock, `fromBootMs` (wrapped as `toUptimeMs` below) to place
556
+ // whatever it answers back on Porthole's. This replaced an
557
+ // open-coded version here that read whichever `clocks` sample it
558
+ // found first rather than the one in force at `from`/`to`.
559
+ const toUptimeMs = (bootNs: number) => fromBootMs(events, bootNs / 1e6)?.at ?? null;
560
+ // askTrace now spawns asynchronously (GRA-82), so this await is new
561
+ // here. It is safe: the admission gate above runs to completion
562
+ // synchronously, before this handler's first await of any kind, so
563
+ // making this one call asynchronous does not move it earlier than
564
+ // a check that already finished.
565
+ const asked = await askTrace({
566
+ binary,
567
+ trace: traceFile,
568
+ packageName: app,
569
+ fromNs: toBootNs(events, from),
570
+ toNs: toBootNs(events, to),
571
+ toUptimeMs,
572
+ });
573
+ findings.push(...asked.findings.map((f): Sourced => ({ ...f, source: "trace" })));
574
+ notes.push(...asked.unanswered);
575
+ // Matched against `unanswered`'s own text rather than a second
576
+ // field threaded out of askTrace: every unanswered reason
577
+ // already begins with the question's own `asks` sentence
578
+ // (`runBatch`'s three push sites all format it
579
+ // `${question.asks} — <reason>`), so this reads an existing
580
+ // contract instead of adding a new one to perfetto.ts.
581
+ askedQuestions = QUESTIONS.map((question) => ({
582
+ id: question.id,
583
+ answered: !asked.unanswered.some((line) => line.startsWith(`${question.asks} — `)),
584
+ }));
585
+ }
586
+ }
587
+
588
+ res.writeHead(200, { "content-type": "application/json" });
589
+ res.end(
590
+ JSON.stringify({
591
+ window: { from, to, ms: Math.max(0, to - from) },
592
+ eventsExamined: within.length,
593
+ metrics: live.metrics,
594
+ findings,
595
+ ...(askedQuestions ? { asked: askedQuestions } : {}),
596
+ notes,
597
+ }),
598
+ );
599
+ return;
600
+ }
601
+
602
+ if (path === "/api/health") {
603
+ res.writeHead(200, { "content-type": "application/json" });
604
+ res.end(
605
+ JSON.stringify({
606
+ name: "porthole-timeline",
607
+ uiPort: this.port,
608
+ devicePort: this.device.port,
609
+ // Strict on purpose (GRA-157): this used to read exactly this
610
+ // way, but under the old model "connected" was true the instant
611
+ // the socket connected — this is what produced the
612
+ // `connected: true, app: null, device: null` combo the ticket
613
+ // that generalised the fix names as the bug for this endpoint.
614
+ // Now that "connected" implies `hello` is set, that combo cannot
615
+ // happen: during a handshake this reports connected: false with
616
+ // app/device null, which is a coherent "not yet" instead of a
617
+ // self-contradicting one. explainPortInUse()'s "stale, restart
618
+ // it" wording is technically a beat early for the ~2s handshake
619
+ // window itself, which is a smaller, pre-existing gap this
620
+ // ticket does not close.
621
+ // GRA-162: isConnected() instead of `=== "connected"`.
622
+ connected: isConnected(this.device.state),
623
+ app: this.device.hello?.packageName ?? null,
624
+ device: this.device.hello?.device ?? null,
625
+ bufferedEvents: this.events.length,
626
+ }),
627
+ );
628
+ return;
629
+ }
630
+
631
+ // The inspector proxies to the device rather than holding a copy: the
632
+ // app's tables are the app's, and a cached mirror would go stale the
633
+ // moment it mattered.
634
+ if (path.startsWith("/api/db/")) {
635
+ const method = DB_ROUTES[path];
636
+ if (!method) {
637
+ res.writeHead(404, { "content-type": "application/json" });
638
+ res.end(JSON.stringify({ error: "No such database endpoint." }));
639
+ return;
640
+ }
641
+ const query = new URL(req.url ?? "/", "http://localhost");
642
+ const params: Record<string, unknown> = {
643
+ database: query.searchParams.get("database") ?? undefined,
644
+ table: query.searchParams.get("table") ?? undefined,
645
+ sql: query.searchParams.get("sql") ?? undefined,
646
+ limit: numberParam(query.searchParams.get("limit")),
647
+ offset: numberParam(query.searchParams.get("offset")),
648
+ count: query.searchParams.get("count") === "0" ? false : undefined,
649
+ };
650
+ try {
651
+ const result = await this.device.request(method, params);
652
+ res.writeHead(200, { "content-type": "application/json" });
653
+ res.end(JSON.stringify(result));
654
+ } catch (error) {
655
+ res.writeHead(503, { "content-type": "application/json" });
656
+ res.end(JSON.stringify({ error: (error as Error).message }));
657
+ }
658
+ return;
659
+ }
660
+
661
+ // Everything under /api/tools does something to the device, so none of it
662
+ // may be reachable by a method a page can issue without meaning to. A GET
663
+ // here used to fall through to the static handler and quietly return
664
+ // index.html, which made a typo look like a working link.
665
+ if (path.startsWith("/api/tools/")) {
666
+ if (req.method !== "POST") {
667
+ res.writeHead(405, { "content-type": "application/json", allow: "POST" });
668
+ res.end(JSON.stringify({ ok: false, output: "This endpoint takes POST." }));
669
+ return;
670
+ }
671
+
672
+ // A session token belongs here: minted at start, printed by the CLI in
673
+ // the URL it opens, required on every call below. It would add nothing
674
+ // against another origin — the admission check already refuses those —
675
+ // and everything against a same-origin page loaded by accident, which
676
+ // is the one caller a same-origin check cannot tell from the real UI.
677
+ // It needs cli.ts to print it and the UI to carry it, so it is deferred
678
+ // to the tickets that own those files.
679
+
680
+ if (path === "/api/tools/restart") {
681
+ const packageName = (this.device.hello as { packageName?: string } | null)?.packageName;
682
+ if (!packageName) {
683
+ // GRA-157: distinguish "still handshaking, this will resolve
684
+ // itself shortly" from "no device at all" the same way the
685
+ // findings endpoint above now does, rather than one generic
686
+ // sentence for both.
687
+ // GRA-162: isHandshaking() instead of `=== "handshaking"`.
688
+ const output =
689
+ isHandshaking(this.device.state)
690
+ ? "Still waiting on the app's first check-in. Try again in a moment."
691
+ : "The app has not said hello yet.";
692
+ res.writeHead(409, { "content-type": "application/json" });
693
+ res.end(JSON.stringify({ ok: false, output }));
694
+ return;
695
+ }
696
+ const result = restartApp(packageName, this.serial);
697
+ res.writeHead(result.ok ? 200 : 502, { "content-type": "application/json" });
698
+ res.end(JSON.stringify(result));
699
+ return;
700
+ }
701
+
702
+ res.writeHead(404, { "content-type": "application/json" });
703
+ res.end(JSON.stringify({ ok: false, output: "No such tool." }));
704
+ return;
705
+ }
706
+
707
+ // GRA-116: "keep the last N seconds, from where you are already
708
+ // looking" — the timeline UI's own save gesture, POST and
709
+ // state-changing exactly like /api/tools/*, hardened the same way
710
+ // (GRA-78's origin/host check already ran above, before routing; this
711
+ // adds the POST-only refusal that route also carries). Deliberately
712
+ // not nested under /api/tools/: that prefix's own comment defers a
713
+ // session token to whichever ticket owns cli.ts and the UI's token
714
+ // plumbing, and this route has no more business waiting on that than
715
+ // /api/findings or /api/traces do.
716
+ if (path === "/api/save") {
717
+ if (req.method !== "POST") {
718
+ res.writeHead(405, { "content-type": "application/json", allow: "POST" });
719
+ res.end(JSON.stringify({ error: "This endpoint takes POST." }));
720
+ return;
721
+ }
722
+
723
+ let raw: string;
724
+ try {
725
+ raw = await readRequestBody(req);
726
+ } catch (error) {
727
+ res.writeHead(400, { "content-type": "application/json" });
728
+ res.end(JSON.stringify({ error: `Could not read the request body: ${(error as Error).message}` }));
729
+ return;
730
+ }
731
+
732
+ let parsed: unknown;
733
+ try {
734
+ // An empty body is not malformed JSON — it is the shape a caller
735
+ // sends when it means "just from/to", so this reads the same as
736
+ // `{}` rather than failing the JSON.parse a literal empty string
737
+ // would.
738
+ parsed = raw.trim() === "" ? {} : JSON.parse(raw);
739
+ } catch {
740
+ res.writeHead(400, { "content-type": "application/json" });
741
+ res.end(JSON.stringify({ error: "Malformed JSON body." }));
742
+ return;
743
+ }
744
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
745
+ res.writeHead(400, { "content-type": "application/json" });
746
+ res.end(JSON.stringify({ error: "The request body must be a JSON object with `from` and `to`." }));
747
+ return;
748
+ }
749
+
750
+ const body = parsed as { from?: unknown; to?: unknown; scenario?: unknown };
751
+ const from = Number(body.from);
752
+ const to = Number(body.to);
753
+ if (body.from === undefined || body.to === undefined || !Number.isFinite(from) || !Number.isFinite(to)) {
754
+ res.writeHead(400, { "content-type": "application/json" });
755
+ res.end(JSON.stringify({ error: "`from` and `to` are required and must be numbers (device uptime ms)." }));
756
+ return;
757
+ }
758
+ if (from >= to) {
759
+ res.writeHead(400, { "content-type": "application/json" });
760
+ res.end(JSON.stringify({ error: "`from` must be less than `to`." }));
761
+ return;
762
+ }
763
+ const scenarioInput =
764
+ typeof body.scenario === "string" && body.scenario.trim() !== "" ? body.scenario.trim() : undefined;
765
+ // Refused before anything is read or written: the scenario becomes
766
+ // a file name, and QA round 1 showed "../../../../tmp/evil" escaping
767
+ // .porthole/traces/ through this route.
768
+ if (scenarioInput !== undefined) {
769
+ try {
770
+ validateScenario(scenarioInput);
771
+ } catch (error) {
772
+ if (error instanceof InvalidScenarioError) {
773
+ res.writeHead(400, { "content-type": "application/json" });
774
+ res.end(JSON.stringify({ error: error.message }));
775
+ return;
776
+ }
777
+ throw error;
778
+ }
779
+ }
780
+
781
+ // The same merged view every window-taking tool reads (GRA-53's
782
+ // `fillWindowFromDisk`) and the same trace builder `save_moment`
783
+ // calls (GRA-54's `buildSavedTrace`) — no second implementation of
784
+ // either, per this ticket's own ruling.
785
+ const merged = await fillWindowFromDisk({
786
+ root: this.device.sessions?.root ?? sessionsRoot(resolveProjectRoot().directory),
787
+ identity: this.currentIdentity(),
788
+ buffered: this.events as unknown as SessionEvent[],
789
+ currentSessionDir: this.device.sessions?.currentDir() ?? null,
790
+ from,
791
+ to,
792
+ });
793
+ const events = merged.events as unknown as DeviceEvent[];
794
+ const helloLike = this.device.hello ?? this.device.lastExited?.hello ?? null;
795
+ const hello = (helloLike as unknown as Record<string, unknown>) ?? null;
796
+
797
+ const scenario = scenarioInput ?? defaultScenarioName(from, to);
798
+ const outPath = defaultOutPath(resolveProjectRoot().directory, scenario);
799
+
800
+ // GRA-185: same resolution `/api/findings` above uses — the whole
801
+ // live ring (`this.events`), not the window-restricted `events`.
802
+ const profile = resolveProfile({
803
+ liveEvents: this.events,
804
+ windowTo: to,
805
+ sessionProfile: this.device.sessions?.currentMeta()?.profile ?? null,
806
+ hello,
807
+ });
808
+ const trace = buildSavedTrace({
809
+ events,
810
+ hello,
811
+ window: { from, to },
812
+ coveredFrom: merged.coveredFrom,
813
+ coveredTo: merged.coveredTo,
814
+ scenario,
815
+ profile,
816
+ });
817
+ await writeSavedTrace(trace, outPath);
818
+
819
+ res.writeHead(200, { "content-type": "application/json" });
820
+ res.end(
821
+ JSON.stringify({
822
+ out: outPath,
823
+ scenario,
824
+ clippedMs: trace.clippedMs,
825
+ findings: trace.findings.length,
826
+ }),
827
+ );
828
+ return;
829
+ }
830
+
831
+ // What the app wired up, and what it has on its classpath but did not.
832
+ // The UI uses it to tell an empty lane apart from a missing integration.
833
+ if (path === "/api/setup") {
834
+ try {
835
+ const result = await this.device.request("setup", {});
836
+ res.writeHead(200, { "content-type": "application/json" });
837
+ res.end(JSON.stringify(result));
838
+ } catch (error) {
839
+ res.writeHead(503, { "content-type": "application/json" });
840
+ res.end(JSON.stringify({ error: (error as Error).message }));
841
+ }
842
+ return;
843
+ }
844
+
845
+ if (path === "/api/events") {
846
+ res.writeHead(200, { "content-type": "application/json" });
847
+ res.end(JSON.stringify({ events: this.events, hello: this.device.hello }));
848
+ return;
849
+ }
850
+
851
+ // The UI is a built Vite bundle: an entry document plus hashed assets.
852
+ // Anything that is not a real file falls back to index.html, so a deep
853
+ // link still boots the app rather than 404ing.
854
+ const requested = path === "/" ? "index.html" : path.replace(/^\/+/, "");
855
+ const resolved = resolve(UI_DIR, requested);
856
+ // The separator matters: `resolve` strips the trailing one, so a bare
857
+ // prefix test would also accept a sibling whose name merely starts with
858
+ // the UI directory's — `dist-backup` next to `dist`. No such sibling
859
+ // exists today, which is exactly the kind of fact that stops being true
860
+ // without anyone noticing.
861
+ const target = resolved.startsWith(resolve(UI_DIR) + sep)
862
+ ? resolved
863
+ : resolve(UI_DIR, "index.html");
864
+
865
+ try {
866
+ const body = await readFile(target);
867
+ res.writeHead(200, {
868
+ "content-type": CONTENT_TYPES[extname(target)] ?? "application/octet-stream",
869
+ });
870
+ res.end(body);
871
+ } catch {
872
+ try {
873
+ const html = await readFile(resolve(UI_DIR, "index.html"));
874
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
875
+ res.end(html);
876
+ } catch (error) {
877
+ res.writeHead(500, { "content-type": "text/plain" });
878
+ res.end(
879
+ "The timeline UI has not been built. Run `npm run build` in the package root.\n" +
880
+ (error as Error).message,
881
+ );
882
+ }
883
+ }
884
+ });
885
+
886
+ try {
887
+ await new Promise<void>((resolve, reject) => {
888
+ server.once("error", reject);
889
+ // Loopback only. The UI is a dev tool and has no business being routable.
890
+ server.listen(this.port, "127.0.0.1", () => {
891
+ server.removeListener("error", reject);
892
+ resolve();
893
+ });
894
+ });
895
+ } catch (error) {
896
+ this.started = false;
897
+ if ((error as NodeJS.ErrnoException).code !== "EADDRINUSE") throw error;
898
+ const other = await this.probeHealth();
899
+ const problem = new Error(this.explainPortInUse(other)) as PortInUse;
900
+ // Lets a caller tell "someone else has the port" from "yours is already
901
+ // open", which want different answers: one is a failure, one is a URL.
902
+ problem.portholeAlreadyRunning = other !== null && other.connected;
903
+ problem.url = this.url();
904
+ throw problem;
905
+ }
906
+
907
+ // After the bind, not before. Attached to a server that has not
908
+ // listened, the socket server re-emits the bind failure as its own
909
+ // unhandled error — which is what turned a taken port into a raw
910
+ // stack trace, escaping the handler written to explain it.
911
+ //
912
+ // `noServer` rather than handing it the server, because letting ws own the
913
+ // upgrade would leave the upgrade ungated: a WebSocket handshake is a
914
+ // request a page can make cross-origin with no preflight, and this one
915
+ // answers with the whole event buffer. The HTTP gate would be closed and
916
+ // the socket beside it open.
917
+ const wss = new WebSocketServer({ noServer: true });
918
+ this.wss = wss;
919
+ server.on("upgrade", (req, socket, head) => {
920
+ const refusal = this.refuse(req);
921
+ if (refusal) {
922
+ socket.end(
923
+ `HTTP/1.1 403 Forbidden\r\ncontent-type: text/plain; charset=utf-8\r\nconnection: close\r\n\r\n${refusal}\n`,
924
+ );
925
+ return;
926
+ }
927
+ if ((req.url ?? "/").split("?")[0] !== "/ws") {
928
+ socket.end("HTTP/1.1 404 Not Found\r\nconnection: close\r\n\r\n");
929
+ return;
930
+ }
931
+ wss.handleUpgrade(req, socket, head, (client) => wss.emit("connection", client, req));
932
+ });
933
+ this.wss.on("connection", (socket: WebSocket) => {
934
+ socket.send(
935
+ JSON.stringify({
936
+ type: "init",
937
+ state: this.device.state,
938
+ hello: this.device.hello,
939
+ events: this.events,
940
+ }),
941
+ );
942
+ });
943
+
944
+ this.server = server;
945
+ return this.url();
946
+ }
947
+
948
+ /**
949
+ * Who has the port, in a sentence someone can act on.
950
+ *
951
+ * Nearly always an older instance of this server that outlived the session
952
+ * that started it — and it will answer requests, so the failure otherwise
953
+ * looks like the port being busy when the real problem is that the thing
954
+ * answering is attached to a device that went away hours ago.
955
+ */
956
+ private explainPortInUse(other: OtherTimeline | null): string {
957
+ if (!other) {
958
+ return (
959
+ `Port ${this.port} is already in use by something that is not a Porthole timeline. ` +
960
+ `Stop it, or start this one on another port.`
961
+ );
962
+ }
963
+ const attached = other.connected
964
+ ? `attached to ${other.app ?? "an app"}` +
965
+ (other.device ? ` on ${other.device}` : "") +
966
+ ` via device port ${other.devicePort}`
967
+ : "not attached to any device";
968
+ return (
969
+ `A Porthole timeline is already running at ${this.url()}, ${attached}. ` +
970
+ (other.connected
971
+ ? "Open it rather than starting a second one."
972
+ : "It is stale — stop it and start again, or it will show nothing.")
973
+ );
974
+ }
975
+
976
+ /** Null when nothing answers, or answers as something else. */
977
+ private async probeHealth(): Promise<OtherTimeline | null> {
978
+ try {
979
+ const response = await fetch(`http://127.0.0.1:${this.port}/api/health`, {
980
+ signal: AbortSignal.timeout(1_500),
981
+ });
982
+ if (!response.ok) return null;
983
+ const body = (await response.json()) as Record<string, unknown>;
984
+ if (body.name !== "porthole-timeline") return null;
985
+ return {
986
+ connected: body.connected === true,
987
+ app: (body.app as string) ?? null,
988
+ device: (body.device as string) ?? null,
989
+ devicePort: Number(body.devicePort) || 0,
990
+ };
991
+ } catch {
992
+ // Holding a port without answering HTTP is still "something else".
993
+ return null;
994
+ }
995
+ }
996
+
997
+ stop(): void {
998
+ this.wss?.close();
999
+ this.server?.close();
1000
+ this.server = null;
1001
+ this.wss = null;
1002
+ this.started = false;
1003
+ }
1004
+
1005
+ isRunning(): boolean {
1006
+ return this.started;
1007
+ }
1008
+
1009
+ url(): string {
1010
+ return `http://127.0.0.1:${this.port}/`;
1011
+ }
1012
+
1013
+ private broadcast(message: unknown): void {
1014
+ if (!this.wss) return;
1015
+ const payload = JSON.stringify(message);
1016
+ for (const client of this.wss.clients) {
1017
+ if (client.readyState === WebSocket.OPEN) client.send(payload);
1018
+ }
1019
+ }
1020
+ }
1021
+
1022
+ function numberParam(value: string | null): number | undefined {
1023
+ if (value === null) return undefined;
1024
+ const parsed = Number(value);
1025
+ return Number.isFinite(parsed) ? parsed : undefined;
1026
+ }
1027
+
1028
+ /** The raw request body, as text. A request with no body at all resolves to `""`, not a rejection. */
1029
+ function readRequestBody(req: http.IncomingMessage): Promise<string> {
1030
+ return new Promise((resolve, reject) => {
1031
+ const chunks: Buffer[] = [];
1032
+ req.on("data", (chunk: Buffer) => chunks.push(chunk));
1033
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
1034
+ req.on("error", reject);
1035
+ });
1036
+ }