@vincentt-xr/cli 0.1.4 → 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 +94 -0
- package/dist/cli/args.d.ts +13 -0
- package/dist/cli/args.js +41 -0
- package/dist/cli/index.js +362 -92
- package/dist/lib/backend.d.ts +45 -5
- package/dist/lib/backend.js +99 -18
- package/dist/lib/diagnostics.d.ts +27 -2
- package/dist/lib/diagnostics.js +73 -8
- package/dist/lib/initCommand.d.ts +14 -0
- package/dist/lib/initCommand.js +75 -0
- package/dist/lib/lifecycle.d.ts +73 -15
- package/dist/lib/lifecycle.js +111 -114
- package/dist/lib/org.d.ts +29 -0
- package/dist/lib/org.js +45 -0
- package/dist/lib/projects.d.ts +82 -0
- package/dist/lib/projects.js +217 -0
- package/dist/lib/relayInfo.d.ts +41 -3
- package/dist/lib/relayInfo.js +94 -0
- package/dist/lib/whoami.d.ts +11 -0
- package/dist/lib/whoami.js +38 -0
- package/dist/login/authBackend.d.ts +30 -0
- package/dist/login/authBackend.js +80 -0
- package/dist/login/guard.d.ts +13 -0
- package/dist/login/guard.js +17 -0
- package/dist/login/loginCommand.d.ts +39 -0
- package/dist/login/loginCommand.js +72 -0
- package/dist/login/pkce.d.ts +47 -0
- package/dist/login/pkce.js +106 -0
- package/dist/login/pkceLogin.d.ts +53 -0
- package/dist/login/pkceLogin.js +152 -0
- package/dist/mcp/server.js +47 -20
- package/dist/preview/api.d.ts +84 -0
- package/dist/preview/api.js +174 -0
- package/dist/preview/command.d.ts +96 -0
- package/dist/preview/command.js +276 -0
- package/dist/preview/copy.d.ts +209 -0
- package/dist/preview/copy.js +444 -0
- package/dist/preview/index.d.ts +2 -3
- package/dist/preview/index.js +5 -6
- package/dist/preview/proxy.d.ts +12 -1
- package/dist/preview/proxy.js +17 -7
- package/dist/preview/qr.d.ts +30 -0
- package/dist/preview/qr.js +89 -0
- package/dist/preview/runner.d.ts +155 -30
- package/dist/preview/runner.js +219 -68
- package/dist/preview/stream.d.ts +52 -0
- package/dist/preview/stream.js +171 -0
- package/dist/preview/viewers.d.ts +113 -0
- package/dist/preview/viewers.js +242 -0
- package/dist/relay/server.d.ts +32 -0
- package/dist/relay/server.js +80 -12
- package/dist/relay/store.d.ts +32 -3
- package/dist/relay/store.js +76 -22
- package/dist/relay/testers.d.ts +90 -0
- package/dist/relay/testers.js +144 -0
- package/dist/shared/authError.d.ts +1 -0
- package/dist/shared/authError.js +11 -0
- package/dist/shared/config.d.ts +73 -5
- package/dist/shared/config.js +133 -20
- package/package.json +3 -7
- package/dist/cli/deprecated/harness-mcp.d.ts +0 -2
- package/dist/cli/deprecated/harness-mcp.js +0 -7
- package/dist/cli/deprecated/harness-relay.d.ts +0 -2
- package/dist/cli/deprecated/harness-relay.js +0 -7
- package/dist/cli/deprecated/harness.d.ts +0 -2
- package/dist/cli/deprecated/harness.js +0 -7
- package/dist/login/login.d.ts +0 -34
- package/dist/login/login.js +0 -148
- package/dist/preview/cloudflared.d.ts +0 -13
- package/dist/preview/cloudflared.js +0 -46
- package/dist/preview/tunnel.d.ts +0 -14
- package/dist/preview/tunnel.js +0 -28
package/dist/preview/runner.js
CHANGED
|
@@ -1,50 +1,63 @@
|
|
|
1
|
-
// The whole preview stack behind one call. Stands up, in order: the app dev
|
|
2
|
-
// (on an internal port), the harness relay (WS event sink + localhost
|
|
3
|
-
// front proxy unifying them on one origin (`/__harness` → relay,
|
|
4
|
-
//
|
|
5
|
-
// both the app (secure context → live camera)
|
|
1
|
+
// The whole preview stack behind one call. Stands up, in order: the app dev
|
|
2
|
+
// serve (on an internal port), the harness relay (WS event sink + localhost
|
|
3
|
+
// /query), a front proxy unifying them on one origin (`/__harness` → relay,
|
|
4
|
+
// else → app), and the EDGE TUNNEL CLIENT dialing wss://…/tunnel — so a phone
|
|
5
|
+
// gets ONE https URL that serves both the app (secure context → live camera)
|
|
6
|
+
// and the diagnostics socket.
|
|
7
|
+
//
|
|
8
|
+
// The tunnel is the platform's own edge (D-Self-hosted-edge). The Cloudflare
|
|
9
|
+
// named-tunnel implementation this replaced put a third party in the middle of
|
|
10
|
+
// the creator's console output, which a feature whose central promise is "we
|
|
11
|
+
// relay and retain nothing" cannot do and remain honest.
|
|
6
12
|
//
|
|
7
13
|
// Returns the public URL plus a `stop()` that reaps everything. A failure at any
|
|
8
14
|
// step tears down what was already started, so the caller never leaks a child
|
|
9
|
-
// process or a live
|
|
10
|
-
// the MCP verbs (preview_start / preview_stop).
|
|
15
|
+
// process or a live session.
|
|
11
16
|
import { spawn } from "node:child_process";
|
|
12
17
|
import { createServer } from "node:http";
|
|
18
|
+
import { TunnelClient, resolveForwardTarget, } from "@vincentt-xr/harness/tunnel";
|
|
13
19
|
import { startRelay } from "../relay/server.js";
|
|
14
|
-
import { ensureCloudflared } from "./cloudflared.js";
|
|
15
|
-
import { startSessionTunnel } from "./tunnel.js";
|
|
16
20
|
import { proxyWeb, proxyWs } from "./proxy.js";
|
|
17
21
|
import { getFreePort, isPortListening, waitForApp } from "./net.js";
|
|
22
|
+
import { endPreviewSession, heartbeatPreviewSession, mintPreviewSession, workingTreeKey, PreviewEndedError, } from "./api.js";
|
|
23
|
+
import { ProjectUnreachableError } from "../lib/backend.js";
|
|
18
24
|
export async function startPreview(opts) {
|
|
19
|
-
const { projectCwd, harnessPath = "/__harness", devCommand = ["npm", "run", "dev"], appStdio = "ignore",
|
|
20
|
-
// Resolve ports. The app port defaults to 5173; the relay + front ports are
|
|
21
|
-
// harness-internal, so auto-pick free ones (the relay port is returned for the
|
|
22
|
-
// MCP diag_* tools to point at) instead of colliding on fixed defaults.
|
|
25
|
+
const { projectCwd, config, projectId, harnessPath = "/__harness", devCommand = ["npm", "run", "dev"], appStdio = "ignore", appReadyTimeoutMs = 8_000, onLog = () => undefined, fetchImpl = fetch, } = opts;
|
|
23
26
|
const appPort = opts.appPort ?? 5173;
|
|
24
27
|
const relayPort = opts.relayPort ?? (await getFreePort());
|
|
25
28
|
const frontPort = opts.frontPort ?? (await getFreePort());
|
|
26
|
-
// If a dev server is already up on appPort, reuse it — don't spawn a second one
|
|
27
|
-
// that would fail to bind and leave the tunnel serving a broken origin.
|
|
28
29
|
const reuseApp = await isPortListening(appPort);
|
|
29
|
-
// Track every resource so any failure below can unwind exactly what started.
|
|
30
30
|
let app;
|
|
31
31
|
let relay;
|
|
32
32
|
let front;
|
|
33
|
-
let session;
|
|
34
33
|
let tunnel;
|
|
34
|
+
let heartbeat;
|
|
35
|
+
let minted;
|
|
36
|
+
let stopped = false;
|
|
35
37
|
const stop = async () => {
|
|
36
|
-
|
|
38
|
+
if (stopped)
|
|
39
|
+
return;
|
|
40
|
+
stopped = true;
|
|
41
|
+
if (heartbeat !== undefined)
|
|
42
|
+
clearInterval(heartbeat);
|
|
43
|
+
tunnel?.close();
|
|
37
44
|
app?.kill();
|
|
38
45
|
front?.close();
|
|
39
46
|
if (relay)
|
|
40
47
|
await relay.close();
|
|
41
|
-
|
|
42
|
-
|
|
48
|
+
// Ending the session is what makes the LINK stop working, so it is not
|
|
49
|
+
// best-effort in intent even though it is in effect: the 12-hour ceiling
|
|
50
|
+
// and the edge's 60-second fail-closed deadline both reap a session whose
|
|
51
|
+
// DELETE never landed.
|
|
52
|
+
if (minted)
|
|
53
|
+
await endPreviewSession(config, minted.sessionId, fetchImpl);
|
|
43
54
|
};
|
|
44
55
|
try {
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
|
|
56
|
+
// The MINT comes first. Everything below it costs the creator a dev-server
|
|
57
|
+
// start and a port; if the mint is going to fail — rate limit, revoked
|
|
58
|
+
// membership, no binding — it should fail before any of that happens.
|
|
59
|
+
const key = await workingTreeKey(projectCwd);
|
|
60
|
+
minted = await mintPreviewSession(config, projectId, key, fetchImpl);
|
|
48
61
|
if (reuseApp) {
|
|
49
62
|
onLog(`reusing the dev server already on :${appPort}`);
|
|
50
63
|
}
|
|
@@ -62,22 +75,82 @@ export async function startPreview(opts) {
|
|
|
62
75
|
path: harnessPath,
|
|
63
76
|
projectDir: projectCwd,
|
|
64
77
|
onLog: (m) => onLog(`[relay] ${m}`),
|
|
78
|
+
onEvents: opts.onEvents,
|
|
79
|
+
onCapReached: opts.onCapReached,
|
|
80
|
+
});
|
|
81
|
+
front = createServer((req, res) => {
|
|
82
|
+
const toRelay = req.url?.startsWith(harnessPath) === true;
|
|
83
|
+
// Recovery is signalled by normal output resuming, so the re-arm has to
|
|
84
|
+
// happen on a SUCCESSFUL response rather than on a timer. A relay request
|
|
85
|
+
// is not the creator's app and must not clear an app failure.
|
|
86
|
+
if (!toRelay) {
|
|
87
|
+
res.on("finish", () => {
|
|
88
|
+
if (res.statusCode < 500)
|
|
89
|
+
opts.onOriginRecovered?.();
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
proxyWeb(req, res, toRelay ? relayPort : appPort, () => opts.onOriginError?.("", "origin_unreachable"));
|
|
65
93
|
});
|
|
66
|
-
front = createServer((req, res) => proxyWeb(req, res, req.url?.startsWith(harnessPath) ? relayPort : appPort));
|
|
67
94
|
front.on("upgrade", (req, socket, head) => proxyWs(req, socket, head, req.url?.startsWith(harnessPath) ? relayPort : appPort));
|
|
68
95
|
await new Promise((resolve) => front.listen(frontPort, resolve));
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
96
|
+
// ONE configured target, captured HERE from the creator's own config and
|
|
97
|
+
// never from a request (2.15.4 rule 1). The loopback assertion runs at
|
|
98
|
+
// start and is re-checked at every connect.
|
|
99
|
+
const target = await resolveForwardTarget("127.0.0.1", frontPort);
|
|
100
|
+
tunnel = new TunnelClient({
|
|
101
|
+
edgeUrl: minted.edgeUrl,
|
|
102
|
+
grant: minted.grant,
|
|
103
|
+
// The reconnect dial carries the session id as its own header, because
|
|
104
|
+
// that path carries NO GRANT and the grant's signed `sid` claim is what
|
|
105
|
+
// used to name the row (D-The-reconnect-path-carries-no-grant).
|
|
106
|
+
sessionId: minted.sessionId,
|
|
107
|
+
target,
|
|
108
|
+
onViewer: opts.onViewer,
|
|
109
|
+
onViewerGone: opts.onViewerGone,
|
|
110
|
+
onControl: (kind) => {
|
|
111
|
+
const reason = controlToReason(kind);
|
|
112
|
+
if (reason === null) {
|
|
113
|
+
opts.onExpiring?.();
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
opts.onEnded?.(reason);
|
|
117
|
+
},
|
|
118
|
+
onOriginError: opts.onOriginError,
|
|
119
|
+
onStateChange: opts.onTunnelState,
|
|
120
|
+
onTunnelError: (code) => opts.onLog?.(`tunnel dial failed: ${code}`),
|
|
121
|
+
// A REFUSED reconnect is an ending, not noise to retry through. Each
|
|
122
|
+
// status is a different creator action, so they are not collapsed.
|
|
123
|
+
onReconnectRefused: (status) => {
|
|
124
|
+
const reason = refusalToReason(status);
|
|
125
|
+
if (reason !== null)
|
|
126
|
+
opts.onEnded?.(reason);
|
|
127
|
+
},
|
|
72
128
|
});
|
|
73
|
-
|
|
74
|
-
//
|
|
75
|
-
//
|
|
129
|
+
tunnel.start();
|
|
130
|
+
// The heartbeat is keepalive AND re-authorization: every beat re-runs
|
|
131
|
+
// RequireMembership on the API side, so a project transferred out from
|
|
132
|
+
// under a live preview, or a member removed from the org, ends it on the
|
|
133
|
+
// next beat rather than at the 12-hour ceiling.
|
|
134
|
+
heartbeat = setInterval(() => {
|
|
135
|
+
void runHeartbeat(config, minted.sessionId, fetchImpl, opts.onEnded);
|
|
136
|
+
}, minted.heartbeatSec * 1000);
|
|
137
|
+
// Node must not be held alive by the heartbeat alone — the tunnel and the
|
|
138
|
+
// relay are what keep the process up, and a stray interval would outlive a
|
|
139
|
+
// teardown that failed to clear it.
|
|
140
|
+
heartbeat.unref?.();
|
|
76
141
|
const appReady = reuseApp ? true : await waitForApp(appPort, appReadyTimeoutMs);
|
|
77
142
|
if (!appReady) {
|
|
78
143
|
onLog(`app on :${appPort} isn't responding yet — the URL may be blank until it builds`);
|
|
79
144
|
}
|
|
80
|
-
return {
|
|
145
|
+
return {
|
|
146
|
+
url: minted.url,
|
|
147
|
+
sessionId: minted.sessionId,
|
|
148
|
+
expiresAt: minted.expiresAt,
|
|
149
|
+
relayPort,
|
|
150
|
+
appPort,
|
|
151
|
+
appReady,
|
|
152
|
+
stop,
|
|
153
|
+
};
|
|
81
154
|
}
|
|
82
155
|
catch (err) {
|
|
83
156
|
await stop();
|
|
@@ -85,42 +158,120 @@ export async function startPreview(opts) {
|
|
|
85
158
|
}
|
|
86
159
|
}
|
|
87
160
|
/**
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
161
|
+
* Two of the three CONTROL kinds are endings. `expiring` is NOT: it is the
|
|
162
|
+
* hard-max warning, and it arrives on the same channel. Mapping it to an ending
|
|
163
|
+
* would take away a link that has half an hour left, at exactly the moment the
|
|
164
|
+
* creator is being warned to plan around it — so it returns null and the caller
|
|
165
|
+
* renders the warning instead.
|
|
93
166
|
*/
|
|
94
|
-
export function
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
167
|
+
export function controlToReason(kind) {
|
|
168
|
+
if (kind === "superseded")
|
|
169
|
+
return "superseded";
|
|
170
|
+
if (kind === "revoke")
|
|
171
|
+
return "authz_revoked";
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* A refused RECONNECT dial → the ending screen it means.
|
|
176
|
+
*
|
|
177
|
+
* The edge answers a reattach with three distinguishable statuses and they are
|
|
178
|
+
* deliberately not collapsed (`ingress.go:299-310`): the consume is what
|
|
179
|
+
* separates them, and collapsing would cost the CLI its ability to say "another
|
|
180
|
+
* tunnel holds this preview" versus "your preview ended" — different creator
|
|
181
|
+
* actions, and the debugging creator is the common case.
|
|
182
|
+
*
|
|
183
|
+
* A 409 is the grace cap being spent, which is the one ending that explains the
|
|
184
|
+
* bound. A 401 (the token was refused) and a 410 (the session is gone) both mean
|
|
185
|
+
* the link is not coming back, and `grace_exhausted` is that screen.
|
|
186
|
+
*
|
|
187
|
+
* Anything else — 502, 503, a transient edge fault — returns null and keeps the
|
|
188
|
+
* backoff. Ending a preview over a momentarily unavailable edge would take away
|
|
189
|
+
* a link that is about to work again.
|
|
190
|
+
*/
|
|
191
|
+
export function refusalToReason(status) {
|
|
192
|
+
if (status === 409)
|
|
193
|
+
return "grace_cap";
|
|
194
|
+
if (status === 401 || status === 410)
|
|
195
|
+
return "grace_exhausted";
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* One heartbeat. A 410 is the session ending on the server's terms; a 404/403
|
|
200
|
+
* is authorization lost. They are DELIBERATELY NOT MERGED, because the remedies
|
|
201
|
+
* differ: one is "find out who you are", the other is "try again".
|
|
202
|
+
*
|
|
203
|
+
* A transport failure is neither — the API being briefly unreachable must not
|
|
204
|
+
* end a healthy preview, because the edge already fails closed on its own
|
|
205
|
+
* 60-second deadline and will drain if the outage is real.
|
|
206
|
+
*
|
|
207
|
+
* THE 410 IS NOT REPORTED AS `hard_max`. It previously was, unconditionally, and
|
|
208
|
+
* that is a FALSE STATEMENT about the product's own behaviour for every terminal
|
|
209
|
+
* cause that is not the ceiling: a session flipped `abandoned` at minute 30, or
|
|
210
|
+
* superseded, or stopped elsewhere, all answer 410, and the hard-max screen is a
|
|
211
|
+
* `✓` telling the creator the preview did exactly what it promised over 12 hours.
|
|
212
|
+
*
|
|
213
|
+
* The API's 410 body carries ONE opaque code (`preview_session_ended`) for every
|
|
214
|
+
* terminal reason — verified against the running stack: an `abandoned` row and an
|
|
215
|
+
* `expired` row return byte-identical bodies — so the CLI CANNOT tell them apart
|
|
216
|
+
* and must not guess. `session_ended` is the honest, cause-free rendering until
|
|
217
|
+
* the wire carries the discriminator. See the note on PreviewEndedError.
|
|
218
|
+
*/
|
|
219
|
+
export async function runHeartbeat(config, sessionId, fetchImpl, onEnded) {
|
|
220
|
+
try {
|
|
221
|
+
await heartbeatPreviewSession(config, sessionId, fetchImpl);
|
|
222
|
+
}
|
|
223
|
+
catch (err) {
|
|
224
|
+
if (err instanceof ProjectUnreachableError) {
|
|
225
|
+
onEnded?.("authz_revoked");
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (err instanceof PreviewEndedError) {
|
|
229
|
+
onEnded?.(endReasonToPreviewEnd(err.endReason));
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
// A network blip. See above.
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* The API's `endReason` → the CLI's ending screen.
|
|
237
|
+
*
|
|
238
|
+
* `hard_max` is returned ONLY for an explicit `expired`. An UNLABELLED 410 —
|
|
239
|
+
* which is every 410 the API sends today — maps to `grace_exhausted`, the one
|
|
240
|
+
* ending whose copy states only what is actually known: the preview has ended
|
|
241
|
+
* and the link no longer works. It asserts no cause.
|
|
242
|
+
*
|
|
243
|
+
* That default is the conservative choice and it is deliberate. `hard_max` is a
|
|
244
|
+
* `✓` claiming the preview ran its full 12 hours; rendering it for a session the
|
|
245
|
+
* platform reaped at minute 30 tells the creator something untrue about their own
|
|
246
|
+
* session. `grace_exhausted` is a `✗` that says the link is gone and gives the
|
|
247
|
+
* next command, which is true of EVERY terminal cause. Under-claiming a cause
|
|
248
|
+
* costs the creator one sentence of diagnosis; over-claiming one states a fact
|
|
249
|
+
* only the product can see, and states it wrong.
|
|
250
|
+
*
|
|
251
|
+
* EVERY endReason the domain defines must have a case here. The `default` is for
|
|
252
|
+
* a reason a NEWER api invented, not for one this client forgot: a fall-through
|
|
253
|
+
* renders "your network dropped" for a cause that may be nothing of the kind.
|
|
254
|
+
* That is not hypothetical — `stopped` and `disconnected` fell through for a
|
|
255
|
+
* build, so a creator who stopped their own preview from another terminal was
|
|
256
|
+
* told their connection failed. The set is enumerated from the domain by
|
|
257
|
+
* QA-F3-C9(f), which fails when a new member is added rather than mapping it
|
|
258
|
+
* silently to a wrong screen.
|
|
259
|
+
*/
|
|
260
|
+
export function endReasonToPreviewEnd(reason) {
|
|
261
|
+
switch (reason) {
|
|
262
|
+
case "abandoned":
|
|
263
|
+
return "abandoned";
|
|
264
|
+
case "expired":
|
|
265
|
+
return "hard_max";
|
|
266
|
+
case "superseded":
|
|
267
|
+
return "superseded";
|
|
268
|
+
case "revoked":
|
|
269
|
+
return "authz_revoked";
|
|
270
|
+
case "stopped":
|
|
271
|
+
return "stopped_elsewhere";
|
|
272
|
+
case "disconnected":
|
|
273
|
+
return "disconnected";
|
|
274
|
+
default:
|
|
275
|
+
return "grace_exhausted";
|
|
276
|
+
}
|
|
126
277
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { DiagEvent, LogEvent, NetworkEvent, TraceEvent } from "@vincentt-xr/harness/events";
|
|
2
|
+
import type { UAClass } from "@vincentt-xr/harness/tunnel";
|
|
3
|
+
/** One line per viewer per 5s on the terminal. The agent's pull is unthrottled. */
|
|
4
|
+
export declare const PERF_THROTTLE_MS = 5000;
|
|
5
|
+
export declare function uaLabel(c: UAClass): string;
|
|
6
|
+
export declare function uaShort(c: UAClass): string;
|
|
7
|
+
/**
|
|
8
|
+
* Color carries meaning, never identity: a stream where four labels are four
|
|
9
|
+
* colors is a stream where nothing stands out, and it degrades to unreadable in
|
|
10
|
+
* a piped log — which is where an agent reads it. Output must be fully readable
|
|
11
|
+
* with color stripped, so every colorizer here is a pure wrapper the caller can
|
|
12
|
+
* disable wholesale.
|
|
13
|
+
*/
|
|
14
|
+
export interface Palette {
|
|
15
|
+
red(s: string): string;
|
|
16
|
+
yellow(s: string): string;
|
|
17
|
+
dim(s: string): string;
|
|
18
|
+
}
|
|
19
|
+
export declare function palette(color: boolean): Palette;
|
|
20
|
+
export declare function formatBytes(n: number): string;
|
|
21
|
+
export declare function formatDuration(ms: number): string;
|
|
22
|
+
/**
|
|
23
|
+
* The record draws `200 1.2 MB 1.9s`. The event type carries no byte count,
|
|
24
|
+
* so the caller supplies one when it has it and the column stays blank rather
|
|
25
|
+
* than showing a zero that would read as an empty response.
|
|
26
|
+
*/
|
|
27
|
+
export declare function renderNetworkWithSize(label: string, e: NetworkEvent, bytes: number | undefined, p: Palette): string;
|
|
28
|
+
/**
|
|
29
|
+
* displayPath keeps the path and drops the origin. The origin is the preview's
|
|
30
|
+
* own capability URL on every same-origin request, and printing it on every line
|
|
31
|
+
* would put the capability in the creator's scrollback dozens of times a minute
|
|
32
|
+
* — the same reason 2.18 sets Referrer-Policy: no-referrer.
|
|
33
|
+
*/
|
|
34
|
+
export declare function displayPath(url: string): string;
|
|
35
|
+
export declare function renderLog(label: string, e: LogEvent, p: Palette): string;
|
|
36
|
+
export declare function renderTrace(label: string, e: TraceEvent): string;
|
|
37
|
+
/** The dispatcher. Returns null for an event kind the terminal does not print. */
|
|
38
|
+
export declare function renderEvent(label: string, e: DiagEvent, p: Palette): string | null;
|
|
39
|
+
/**
|
|
40
|
+
* A 60 Hz sampler printed raw would bury logs and network under the one signal a
|
|
41
|
+
* creator is least often looking for. This is a per-VIEWER gate, not a global
|
|
42
|
+
* one: four phones each get their own line every 5s, because suppressing the
|
|
43
|
+
* fourth phone's perf because the first phone just reported would hide the exact
|
|
44
|
+
* comparison the creator opened four phones to make.
|
|
45
|
+
*/
|
|
46
|
+
export declare class PerfThrottle {
|
|
47
|
+
private readonly windowMs;
|
|
48
|
+
private readonly last;
|
|
49
|
+
constructor(windowMs?: number);
|
|
50
|
+
allow(label: string, now: number): boolean;
|
|
51
|
+
forget(label: string): void;
|
|
52
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// The live stream's rendering (Flow L15). Pure string functions, no I/O — the
|
|
2
|
+
// terminal is written by the runner, so every line's shape is unit-testable.
|
|
3
|
+
//
|
|
4
|
+
// The design target is not "read four devices", it is "notice the one that is
|
|
5
|
+
// wrong". Every rule below serves that:
|
|
6
|
+
// 1. the label is column one, always, fixed width — the eye scans one column
|
|
7
|
+
// to separate devices, and the shape does NOT change when a second phone
|
|
8
|
+
// arrives;
|
|
9
|
+
// 2. status, size and duration right-align in fixed columns, so a slow asset
|
|
10
|
+
// is found by SHAPE rather than by reading;
|
|
11
|
+
// 3. color carries exactly three things and nothing else;
|
|
12
|
+
// 4. perf is throttled to one line per viewer per 5s.
|
|
13
|
+
/** One line per viewer per 5s on the terminal. The agent's pull is unthrottled. */
|
|
14
|
+
export const PERF_THROTTLE_MS = 5_000;
|
|
15
|
+
/** The colored duration threshold (rule 3). */
|
|
16
|
+
const SLOW_MS = 3_000;
|
|
17
|
+
/** Column one. `[viewer-1] ` — wide enough for `viewer-9999`. */
|
|
18
|
+
const LABEL_COL = 14;
|
|
19
|
+
const STATUS_COL = 5;
|
|
20
|
+
const SIZE_COL = 9;
|
|
21
|
+
const DURATION_COL = 8;
|
|
22
|
+
/** The path column, before the right-aligned trio. */
|
|
23
|
+
const PATH_COL = 40;
|
|
24
|
+
/**
|
|
25
|
+
* The closed enum's six values map to exactly six strings and there is NO
|
|
26
|
+
* SEVENTH. `unknown` renders as the empty string and the caller OMITS the
|
|
27
|
+
* descriptor entirely — a device we cannot classify gets no descriptor rather
|
|
28
|
+
* than the word "unknown", which would read as a fault.
|
|
29
|
+
*/
|
|
30
|
+
const UA_LABELS = {
|
|
31
|
+
ios_safari: "iPhone · Safari",
|
|
32
|
+
android_chrome: "Android · Chrome",
|
|
33
|
+
desktop_chrome: "Desktop · Chrome",
|
|
34
|
+
desktop_safari: "Desktop · Safari",
|
|
35
|
+
desktop_firefox: "Desktop · Firefox",
|
|
36
|
+
unknown: "",
|
|
37
|
+
};
|
|
38
|
+
/** The short form used in the roster, where the browser is redundant. */
|
|
39
|
+
const UA_SHORT = {
|
|
40
|
+
ios_safari: "iPhone",
|
|
41
|
+
android_chrome: "Android",
|
|
42
|
+
desktop_chrome: "Desktop",
|
|
43
|
+
desktop_safari: "Desktop",
|
|
44
|
+
desktop_firefox: "Desktop",
|
|
45
|
+
unknown: "",
|
|
46
|
+
};
|
|
47
|
+
export function uaLabel(c) {
|
|
48
|
+
return UA_LABELS[c] ?? "";
|
|
49
|
+
}
|
|
50
|
+
export function uaShort(c) {
|
|
51
|
+
return UA_SHORT[c] ?? "";
|
|
52
|
+
}
|
|
53
|
+
const ANSI = {
|
|
54
|
+
red: (s) => `[31m${s}[39m`,
|
|
55
|
+
yellow: (s) => `[33m${s}[39m`,
|
|
56
|
+
dim: (s) => `[2m${s}[22m`,
|
|
57
|
+
};
|
|
58
|
+
const PLAIN = { red: (s) => s, yellow: (s) => s, dim: (s) => s };
|
|
59
|
+
export function palette(color) {
|
|
60
|
+
return color ? ANSI : PLAIN;
|
|
61
|
+
}
|
|
62
|
+
// ---- formatting primitives --------------------------------------------------
|
|
63
|
+
export function formatBytes(n) {
|
|
64
|
+
if (!Number.isFinite(n) || n < 0)
|
|
65
|
+
return "";
|
|
66
|
+
if (n < 1024)
|
|
67
|
+
return `${n} B`;
|
|
68
|
+
if (n < 1024 * 1024)
|
|
69
|
+
return `${(n / 1024).toFixed(0)} KB`;
|
|
70
|
+
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
71
|
+
}
|
|
72
|
+
export function formatDuration(ms) {
|
|
73
|
+
if (!Number.isFinite(ms) || ms < 0)
|
|
74
|
+
return "";
|
|
75
|
+
return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`;
|
|
76
|
+
}
|
|
77
|
+
function padEnd(s, n) {
|
|
78
|
+
return s.length >= n ? s : s + " ".repeat(n - s.length);
|
|
79
|
+
}
|
|
80
|
+
function padStart(s, n) {
|
|
81
|
+
return s.length >= n ? s : " ".repeat(n - s.length) + s;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The label is column one, ALWAYS — including when `--viewer` has filtered the
|
|
85
|
+
* stream to one device. Dropping it there would produce a transcript that is
|
|
86
|
+
* ambiguous the moment it is pasted into an agent's context, and pasting is the
|
|
87
|
+
* whole point.
|
|
88
|
+
*/
|
|
89
|
+
function labelCol(label) {
|
|
90
|
+
return padEnd(`[${label}]`, LABEL_COL);
|
|
91
|
+
}
|
|
92
|
+
// ---- the three line kinds ---------------------------------------------------
|
|
93
|
+
/**
|
|
94
|
+
* The record draws `200 1.2 MB 1.9s`. The event type carries no byte count,
|
|
95
|
+
* so the caller supplies one when it has it and the column stays blank rather
|
|
96
|
+
* than showing a zero that would read as an empty response.
|
|
97
|
+
*/
|
|
98
|
+
export function renderNetworkWithSize(label, e, bytes, p) {
|
|
99
|
+
const status = e.status === 0 ? "ERR" : String(e.status);
|
|
100
|
+
const statusCol = e.status === 0 || e.status >= 400
|
|
101
|
+
? p.red(padStart(status, STATUS_COL))
|
|
102
|
+
: padStart(status, STATUS_COL);
|
|
103
|
+
const duration = formatDuration(e.durationMs);
|
|
104
|
+
const durationCol = e.durationMs >= SLOW_MS
|
|
105
|
+
? p.red(padStart(duration, DURATION_COL))
|
|
106
|
+
: padStart(duration, DURATION_COL);
|
|
107
|
+
return (labelCol(label) +
|
|
108
|
+
padEnd(`${e.method} ${displayPath(e.url)}`, PATH_COL) +
|
|
109
|
+
statusCol +
|
|
110
|
+
padStart(bytes === undefined ? "" : formatBytes(bytes), SIZE_COL) +
|
|
111
|
+
durationCol);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* displayPath keeps the path and drops the origin. The origin is the preview's
|
|
115
|
+
* own capability URL on every same-origin request, and printing it on every line
|
|
116
|
+
* would put the capability in the creator's scrollback dozens of times a minute
|
|
117
|
+
* — the same reason 2.18 sets Referrer-Policy: no-referrer.
|
|
118
|
+
*/
|
|
119
|
+
export function displayPath(url) {
|
|
120
|
+
try {
|
|
121
|
+
const u = new URL(url);
|
|
122
|
+
return u.pathname + u.search;
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return url;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
export function renderLog(label, e, p) {
|
|
129
|
+
const level = padEnd(e.level, STATUS_COL);
|
|
130
|
+
const colored = e.level === "error" ? p.red(level) : e.level === "warn" ? p.yellow(level) : level;
|
|
131
|
+
return `${labelCol(label)}${colored} ${e.message}`;
|
|
132
|
+
}
|
|
133
|
+
export function renderTrace(label, e) {
|
|
134
|
+
const fps = e.fps.toFixed(0);
|
|
135
|
+
return `${labelCol(label)}${padEnd("perf", STATUS_COL)} ${fps} fps · ${e.longTaskCount} long tasks · ${e.longestTaskMs}ms peak`;
|
|
136
|
+
}
|
|
137
|
+
/** The dispatcher. Returns null for an event kind the terminal does not print. */
|
|
138
|
+
export function renderEvent(label, e, p) {
|
|
139
|
+
if (e.kind === "network")
|
|
140
|
+
return renderNetworkWithSize(label, e, undefined, p);
|
|
141
|
+
if (e.kind === "log")
|
|
142
|
+
return renderLog(label, e, p);
|
|
143
|
+
if (e.kind === "trace")
|
|
144
|
+
return renderTrace(label, e);
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
// ---- perf throttling --------------------------------------------------------
|
|
148
|
+
/**
|
|
149
|
+
* A 60 Hz sampler printed raw would bury logs and network under the one signal a
|
|
150
|
+
* creator is least often looking for. This is a per-VIEWER gate, not a global
|
|
151
|
+
* one: four phones each get their own line every 5s, because suppressing the
|
|
152
|
+
* fourth phone's perf because the first phone just reported would hide the exact
|
|
153
|
+
* comparison the creator opened four phones to make.
|
|
154
|
+
*/
|
|
155
|
+
export class PerfThrottle {
|
|
156
|
+
windowMs;
|
|
157
|
+
last = new Map();
|
|
158
|
+
constructor(windowMs = PERF_THROTTLE_MS) {
|
|
159
|
+
this.windowMs = windowMs;
|
|
160
|
+
}
|
|
161
|
+
allow(label, now) {
|
|
162
|
+
const prev = this.last.get(label);
|
|
163
|
+
if (prev !== undefined && now - prev < this.windowMs)
|
|
164
|
+
return false;
|
|
165
|
+
this.last.set(label, now);
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
forget(label) {
|
|
169
|
+
this.last.delete(label);
|
|
170
|
+
}
|
|
171
|
+
}
|