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