@vincentt-xr/harness 1.0.0 → 1.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/dist/client/HarnessProvider.d.ts +47 -2
- package/dist/client/HarnessProvider.js +71 -12
- package/dist/client/annotate.js +8 -6
- package/dist/client/cluster.d.ts +12 -0
- package/dist/client/cluster.js +51 -0
- package/dist/client/index.d.ts +2 -0
- package/dist/client/index.js +2 -0
- package/dist/client/instrument.js +13 -3
- package/dist/client/share.d.ts +55 -0
- package/dist/client/share.js +318 -0
- package/dist/shared/events.d.ts +110 -4
- package/dist/shared/events.js +25 -1
- package/dist/tunnel/cbor.d.ts +71 -0
- package/dist/tunnel/cbor.js +403 -0
- package/dist/tunnel/client.d.ts +219 -0
- package/dist/tunnel/client.js +620 -0
- package/dist/tunnel/forwardTarget.d.ts +69 -0
- package/dist/tunnel/forwardTarget.js +174 -0
- package/dist/tunnel/frame.d.ts +126 -0
- package/dist/tunnel/frame.js +244 -0
- package/dist/tunnel/index.d.ts +11 -0
- package/dist/tunnel/index.js +11 -0
- package/package.json +15 -3
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
// The in-app Share control — the creator's desktop-to-phone handoff, rendered
|
|
2
|
+
// inside the running preview. An icon-only QR-glyph chip in the shared top-right
|
|
3
|
+
// cluster; clicking it drops a popover with an inline SVG QR of the preview origin,
|
|
4
|
+
// a heading + instruction, and the URL as selectable text (no Copy button).
|
|
5
|
+
//
|
|
6
|
+
// It re-presents ONLY `window.location.origin + "/"` — the capability URL the
|
|
7
|
+
// link-holder already has (B17). It makes NO network call and sends nothing to the
|
|
8
|
+
// relay (B19): pure client-side rendering of a string already in the page.
|
|
9
|
+
//
|
|
10
|
+
// DOM-only, no React. Loaded through the dynamic import in HarnessProvider, so the
|
|
11
|
+
// whole module — and `qrcode`, reached only from here — tree-shakes out of prod.
|
|
12
|
+
import QRCode from "qrcode";
|
|
13
|
+
import { getClusterContainer, releaseClusterContainer } from "./cluster.js";
|
|
14
|
+
/**
|
|
15
|
+
* The compiled-in preview-apex allowlist. Previews sit on their own registrable
|
|
16
|
+
* domain, `vincentt.dev` (f3's `D-Preview-is-its-own-registrable-domain`); staging
|
|
17
|
+
* and dev are subdomains of it (`*.staging.vincentt.dev`, `*.local.vincentt.dev`),
|
|
18
|
+
* so a single `.vincentt.dev` suffix matches every environment. The leading dot is a
|
|
19
|
+
* real subdomain-label boundary, so `<label>.vincentt.dev` matches but a bare
|
|
20
|
+
* `vincentt.dev` or a look-alike (`evilvincentt.dev`) does not. This is a public DNS
|
|
21
|
+
* name, not a secret — the same host the viewer's address bar already shows.
|
|
22
|
+
*/
|
|
23
|
+
export const PREVIEW_APEXES = [".vincentt.dev"];
|
|
24
|
+
/**
|
|
25
|
+
* The canonical preview URL to encode — `origin + "/"`, stable across in-app SPA
|
|
26
|
+
* navigation. Reads ONLY the origin, so a deep path, query-param secret, or hash is
|
|
27
|
+
* never encoded into the QR or the text field.
|
|
28
|
+
*/
|
|
29
|
+
export function deriveShareUrl(location) {
|
|
30
|
+
return location.origin + "/";
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Is the viewer a phone? True iff BOTH `(pointer: coarse)` and `(max-width: 820px)`
|
|
34
|
+
* match — a coarse-pointer narrow viewer is the device you would hand off to, so the
|
|
35
|
+
* button is pointless there. A missing or THROWING `matchMedia`, or a viewer we cannot
|
|
36
|
+
* classify, folds to `false` ⇒ the button shows (the "unknown shows" rule: a missing
|
|
37
|
+
* handoff on a desktop is worse than a stray, send-nothing button on an odd device).
|
|
38
|
+
* `matchMedia` is injected so the predicate is pure and testable without a DOM.
|
|
39
|
+
*/
|
|
40
|
+
export function isPhone(matchMedia) {
|
|
41
|
+
if (typeof matchMedia !== "function")
|
|
42
|
+
return false;
|
|
43
|
+
try {
|
|
44
|
+
return (matchMedia("(pointer: coarse)").matches && matchMedia("(max-width: 820px)").matches);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// A throwing matchMedia is "unknown", never an error to propagate ⇒ show.
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Is this a real, phone-reachable preview origin? `https:` AND the hostname ends with
|
|
53
|
+
* a `PREVIEW_APEXES` suffix (`.vincentt.dev`). On the creator's plain local loop
|
|
54
|
+
* (`http://localhost`) or a self-signed LAN origin (`https://192.168.1.20`, `foo.local`)
|
|
55
|
+
* there is no phone-reachable preview link to encode, so the button does not appear.
|
|
56
|
+
*/
|
|
57
|
+
export function isPreviewOrigin(loc) {
|
|
58
|
+
return (loc.protocol === "https:" &&
|
|
59
|
+
PREVIEW_APEXES.some((apex) => loc.hostname.endsWith(apex)));
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Render the preview URL as an inline SVG QR. Same params as the CLI's `qrPage.ts`
|
|
63
|
+
* (`margin:2`, ECC `M`) so the on-screen QR is identical to the proven-scannable
|
|
64
|
+
* terminal one.
|
|
65
|
+
*/
|
|
66
|
+
export function renderQr(url) {
|
|
67
|
+
return QRCode.toString(url, {
|
|
68
|
+
type: "svg",
|
|
69
|
+
margin: 2,
|
|
70
|
+
errorCorrectionLevel: "M",
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
const HEADING = "Open on your phone";
|
|
74
|
+
const INSTRUCTION = "Point your phone's camera at the code, or type in the link below.";
|
|
75
|
+
// A single QR-code glyph (three finder squares + a scattering of modules), ~18px,
|
|
76
|
+
// `currentColor` so it inherits the chip's `#f5f2ef`. Chosen over a share arrow
|
|
77
|
+
// because a QR square most directly says "get this onto a phone".
|
|
78
|
+
const QR_GLYPH_SVG = `<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false"><path d="M3 3h8v8H3V3Zm2 2v4h4V5H5Zm-2 8h8v8H3v-8Zm2 2v4h4v-4H5ZM13 3h8v8h-8V3Zm2 2v4h4V5h-4Zm-2 8h2v2h-2v-2Zm2 2h2v2h-2v-2Zm2-2h2v2h-2v-2Zm0 4h2v2h-2v-2Zm2-2h2v2h-2v-2Zm0 4h2v2h-2v-2Zm-4 0h2v2h-2v-2Z"/></svg>`;
|
|
79
|
+
/**
|
|
80
|
+
* Mount the icon-only Share chip into the shared cluster and wire its popover.
|
|
81
|
+
*
|
|
82
|
+
* The show-gate is evaluated ONCE, here at mount: render iff the viewer is not a
|
|
83
|
+
* phone AND the origin is a real preview origin. If the gate fails, nothing is
|
|
84
|
+
* appended to the DOM (no disabled state) and the returned unmount is a no-op.
|
|
85
|
+
*
|
|
86
|
+
* Returns an unmount function that removes the chip + popover, tears down listeners,
|
|
87
|
+
* and releases the shared cluster.
|
|
88
|
+
*/
|
|
89
|
+
export function mountShareButton(opts = {}) {
|
|
90
|
+
if (typeof document === "undefined")
|
|
91
|
+
return () => undefined;
|
|
92
|
+
const matchMedia = opts.matchMedia ?? (typeof window !== "undefined" ? window.matchMedia : undefined);
|
|
93
|
+
const location = opts.location ?? (typeof window !== "undefined" ? window.location : undefined);
|
|
94
|
+
if (!location)
|
|
95
|
+
return () => undefined;
|
|
96
|
+
const show = !isPhone(matchMedia) && isPreviewOrigin(location);
|
|
97
|
+
if (!show)
|
|
98
|
+
return () => undefined;
|
|
99
|
+
const url = deriveShareUrl(location);
|
|
100
|
+
const container = getClusterContainer();
|
|
101
|
+
let released = false;
|
|
102
|
+
const btn = document.createElement("button");
|
|
103
|
+
btn.type = "button";
|
|
104
|
+
btn.setAttribute("aria-label", "Share preview");
|
|
105
|
+
btn.title = "Share preview";
|
|
106
|
+
btn.setAttribute("aria-haspopup", "dialog");
|
|
107
|
+
btn.setAttribute("aria-controls", "vt-popover");
|
|
108
|
+
btn.setAttribute("aria-expanded", "false");
|
|
109
|
+
btn.innerHTML = QR_GLYPH_SVG;
|
|
110
|
+
Object.assign(btn.style, {
|
|
111
|
+
width: "36px",
|
|
112
|
+
height: "36px",
|
|
113
|
+
padding: "0",
|
|
114
|
+
display: "inline-flex",
|
|
115
|
+
alignItems: "center",
|
|
116
|
+
justifyContent: "center",
|
|
117
|
+
borderRadius: "10px",
|
|
118
|
+
border: "1px solid rgba(255,255,255,.14)",
|
|
119
|
+
background: "rgba(28,27,26,.92)",
|
|
120
|
+
color: "#f5f2ef",
|
|
121
|
+
boxShadow: "0 1px 4px rgba(0,0,0,.5)",
|
|
122
|
+
cursor: "pointer",
|
|
123
|
+
transition: "background 100ms, border-color 100ms",
|
|
124
|
+
});
|
|
125
|
+
const IDLE_BG = "rgba(28,27,26,.92)";
|
|
126
|
+
const IDLE_BORDER = "rgba(255,255,255,.14)";
|
|
127
|
+
const HOVER_BG = "rgba(40,38,36,.95)";
|
|
128
|
+
const HOVER_BORDER = "rgba(255,255,255,.24)";
|
|
129
|
+
const ACTIVE_BG = "rgba(40,38,36,.95)";
|
|
130
|
+
const ACTIVE_BORDER = "rgba(147,184,240,.5)";
|
|
131
|
+
const applyIdle = () => {
|
|
132
|
+
btn.style.background = IDLE_BG;
|
|
133
|
+
btn.style.borderColor = IDLE_BORDER;
|
|
134
|
+
};
|
|
135
|
+
const applyActive = () => {
|
|
136
|
+
btn.style.background = ACTIVE_BG;
|
|
137
|
+
btn.style.borderColor = ACTIVE_BORDER;
|
|
138
|
+
};
|
|
139
|
+
btn.addEventListener("mouseenter", () => {
|
|
140
|
+
if (popover)
|
|
141
|
+
return; // active state wins over hover
|
|
142
|
+
btn.style.background = HOVER_BG;
|
|
143
|
+
btn.style.borderColor = HOVER_BORDER;
|
|
144
|
+
});
|
|
145
|
+
btn.addEventListener("mouseleave", () => {
|
|
146
|
+
if (popover)
|
|
147
|
+
return;
|
|
148
|
+
applyIdle();
|
|
149
|
+
});
|
|
150
|
+
// Focus ring: a brand-blue outline that reads on any background. Set inline on
|
|
151
|
+
// focus/blur since there is no stylesheet on this surface for :focus-visible.
|
|
152
|
+
btn.addEventListener("focus", () => {
|
|
153
|
+
btn.style.outline = "2px solid #93b8f0";
|
|
154
|
+
btn.style.outlineOffset = "2px";
|
|
155
|
+
});
|
|
156
|
+
btn.addEventListener("blur", () => {
|
|
157
|
+
btn.style.outline = "none";
|
|
158
|
+
});
|
|
159
|
+
let popover = null;
|
|
160
|
+
let outsideHandler = null;
|
|
161
|
+
let keyHandler = null;
|
|
162
|
+
const prefersReducedMotion = typeof matchMedia === "function"
|
|
163
|
+
? (() => {
|
|
164
|
+
try {
|
|
165
|
+
return matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
})()
|
|
171
|
+
: false;
|
|
172
|
+
const closePopover = (returnFocus) => {
|
|
173
|
+
if (!popover)
|
|
174
|
+
return;
|
|
175
|
+
popover.remove();
|
|
176
|
+
popover = null;
|
|
177
|
+
if (outsideHandler) {
|
|
178
|
+
document.removeEventListener("mousedown", outsideHandler, true);
|
|
179
|
+
outsideHandler = null;
|
|
180
|
+
}
|
|
181
|
+
if (keyHandler) {
|
|
182
|
+
document.removeEventListener("keydown", keyHandler);
|
|
183
|
+
keyHandler = null;
|
|
184
|
+
}
|
|
185
|
+
btn.setAttribute("aria-expanded", "false");
|
|
186
|
+
applyIdle();
|
|
187
|
+
if (returnFocus)
|
|
188
|
+
btn.focus();
|
|
189
|
+
};
|
|
190
|
+
const buildPopover = () => {
|
|
191
|
+
const pop = document.createElement("div");
|
|
192
|
+
pop.id = "vt-popover";
|
|
193
|
+
pop.setAttribute("role", "dialog");
|
|
194
|
+
pop.setAttribute("aria-label", "Open this preview on your phone");
|
|
195
|
+
Object.assign(pop.style, {
|
|
196
|
+
position: "fixed",
|
|
197
|
+
top: "56px",
|
|
198
|
+
right: "12px",
|
|
199
|
+
zIndex: "2147483647",
|
|
200
|
+
width: "260px",
|
|
201
|
+
padding: "16px",
|
|
202
|
+
borderRadius: "12px",
|
|
203
|
+
border: "1px solid rgba(255,255,255,.14)",
|
|
204
|
+
background: "rgba(23,22,21,.86)",
|
|
205
|
+
backdropFilter: "blur(16px) saturate(1.1)",
|
|
206
|
+
// @ts-expect-error vendor-prefixed for Safari; not in the typed CSSStyleDeclaration
|
|
207
|
+
WebkitBackdropFilter: "blur(16px) saturate(1.1)",
|
|
208
|
+
boxShadow: "0 12px 32px rgba(0,0,0,.45)",
|
|
209
|
+
boxSizing: "border-box",
|
|
210
|
+
transformOrigin: "top right",
|
|
211
|
+
});
|
|
212
|
+
const qrTile = document.createElement("div");
|
|
213
|
+
qrTile.className = "vt-qr";
|
|
214
|
+
Object.assign(qrTile.style, {
|
|
215
|
+
width: "100%",
|
|
216
|
+
aspectRatio: "1 / 1",
|
|
217
|
+
background: "#fff",
|
|
218
|
+
padding: "8px",
|
|
219
|
+
borderRadius: "8px",
|
|
220
|
+
border: "1px solid rgba(0,0,0,.06)",
|
|
221
|
+
boxSizing: "border-box",
|
|
222
|
+
});
|
|
223
|
+
// Async QR render; the tile holds its space via aspect-ratio until it lands.
|
|
224
|
+
void renderQr(url).then((svg) => {
|
|
225
|
+
// Guard against a close-before-resolve race.
|
|
226
|
+
if (qrTile.isConnected)
|
|
227
|
+
qrTile.innerHTML = svg;
|
|
228
|
+
});
|
|
229
|
+
const heading = document.createElement("div");
|
|
230
|
+
heading.className = "vt-heading";
|
|
231
|
+
heading.textContent = HEADING;
|
|
232
|
+
Object.assign(heading.style, {
|
|
233
|
+
font: "600 14px/1.35 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif",
|
|
234
|
+
color: "#f5f2ef",
|
|
235
|
+
margin: "12px 0 2px",
|
|
236
|
+
});
|
|
237
|
+
const instr = document.createElement("div");
|
|
238
|
+
instr.className = "vt-instr";
|
|
239
|
+
instr.textContent = INSTRUCTION;
|
|
240
|
+
Object.assign(instr.style, {
|
|
241
|
+
font: "400 13px/1.45 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif",
|
|
242
|
+
color: "#b8b2a9",
|
|
243
|
+
margin: "0 0 10px",
|
|
244
|
+
});
|
|
245
|
+
const urlField = document.createElement("div");
|
|
246
|
+
urlField.className = "vt-url";
|
|
247
|
+
urlField.textContent = url;
|
|
248
|
+
Object.assign(urlField.style, {
|
|
249
|
+
font: "500 12px/1.5 ui-monospace,monospace",
|
|
250
|
+
color: "#93b8f0",
|
|
251
|
+
background: "rgba(255,255,255,.06)",
|
|
252
|
+
border: "1px solid rgba(255,255,255,.10)",
|
|
253
|
+
borderRadius: "8px",
|
|
254
|
+
padding: "8px 10px",
|
|
255
|
+
wordBreak: "break-all",
|
|
256
|
+
cursor: "text",
|
|
257
|
+
userSelect: "all",
|
|
258
|
+
// @ts-expect-error vendor-prefixed for Safari
|
|
259
|
+
WebkitUserSelect: "all",
|
|
260
|
+
boxSizing: "border-box",
|
|
261
|
+
});
|
|
262
|
+
pop.append(qrTile, heading, instr, urlField);
|
|
263
|
+
if (!prefersReducedMotion) {
|
|
264
|
+
pop.style.opacity = "0";
|
|
265
|
+
pop.style.transform = "translateY(-4px) scale(.98)";
|
|
266
|
+
pop.style.transition =
|
|
267
|
+
"opacity 160ms cubic-bezier(0.16,1,0.3,1), transform 160ms cubic-bezier(0.16,1,0.3,1)";
|
|
268
|
+
// Next frame: animate to the resting state.
|
|
269
|
+
requestAnimationFrame(() => {
|
|
270
|
+
pop.style.opacity = "1";
|
|
271
|
+
pop.style.transform = "translateY(0) scale(1)";
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
return pop;
|
|
275
|
+
};
|
|
276
|
+
const openPopover = () => {
|
|
277
|
+
if (popover)
|
|
278
|
+
return;
|
|
279
|
+
popover = buildPopover();
|
|
280
|
+
container.appendChild(popover);
|
|
281
|
+
btn.setAttribute("aria-expanded", "true");
|
|
282
|
+
applyActive();
|
|
283
|
+
// Click-outside: a capture-phase mousedown outside both popover and button
|
|
284
|
+
// closes it. Registered only while open, removed on close.
|
|
285
|
+
outsideHandler = (e) => {
|
|
286
|
+
const target = e.target;
|
|
287
|
+
if (!target)
|
|
288
|
+
return;
|
|
289
|
+
if (popover && (popover.contains(target) || btn.contains(target)))
|
|
290
|
+
return;
|
|
291
|
+
closePopover(false);
|
|
292
|
+
};
|
|
293
|
+
document.addEventListener("mousedown", outsideHandler, true);
|
|
294
|
+
// Escape closes and returns focus to the Share button.
|
|
295
|
+
keyHandler = (e) => {
|
|
296
|
+
if (e.key === "Escape") {
|
|
297
|
+
e.stopPropagation();
|
|
298
|
+
closePopover(true);
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
document.addEventListener("keydown", keyHandler);
|
|
302
|
+
};
|
|
303
|
+
btn.addEventListener("click", () => {
|
|
304
|
+
if (popover)
|
|
305
|
+
closePopover(false);
|
|
306
|
+
else
|
|
307
|
+
openPopover();
|
|
308
|
+
});
|
|
309
|
+
container.appendChild(btn);
|
|
310
|
+
return () => {
|
|
311
|
+
closePopover(false);
|
|
312
|
+
btn.remove();
|
|
313
|
+
if (!released) {
|
|
314
|
+
released = true;
|
|
315
|
+
releaseClusterContainer();
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
}
|
package/dist/shared/events.d.ts
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
/** Monotonic-ish wall-clock ms since epoch, stamped by the client at capture. */
|
|
2
2
|
export type Timestamp = number;
|
|
3
|
+
/**
|
|
4
|
+
* The grammar of the client's session id — the key the relay groups a TESTER by.
|
|
5
|
+
*
|
|
6
|
+
* It lives here, in the file all three parts import, for the same reason
|
|
7
|
+
* VIEWER_LABEL_RE lives beside the codec that produces the label: the party that
|
|
8
|
+
* mints the value and the party that validates it must not be able to disagree
|
|
9
|
+
* about its shape. The client writes this shape; the relay accepts this shape and
|
|
10
|
+
* nothing else, with no sanitize-and-keep branch.
|
|
11
|
+
*
|
|
12
|
+
* `s2_` is generation two. Generation one was `s_<base36 time>_<base36 rand>` over
|
|
13
|
+
* ~10^6 of Math.random, which grouped a RUN. Grouping a TESTER makes a collision a
|
|
14
|
+
* silent merge of two people's streams, so the space is now 128 bits and the
|
|
15
|
+
* prefix is what lets a relay tell the two generations apart on sight.
|
|
16
|
+
*/
|
|
17
|
+
export declare const CLIENT_SESSION_ID_RE: RegExp;
|
|
18
|
+
/**
|
|
19
|
+
* A relay-assigned tester id: `t1`, `t2`, … Minted on the CREATOR'S machine and
|
|
20
|
+
* never on the wire from the device — it is a local relabeling of a key the
|
|
21
|
+
* device supplied, which is what keeps the platform free of a correlator (B22).
|
|
22
|
+
*
|
|
23
|
+
* No leading zero. Ids are counted from 1 and never zero-padded, so `t01` is a
|
|
24
|
+
* string the registry cannot have produced; accepting it at the filter boundary
|
|
25
|
+
* would admit a second spelling for a value with one canonical form.
|
|
26
|
+
*/
|
|
27
|
+
export declare const TESTER_ID_RE: RegExp;
|
|
3
28
|
/** Kinds of thing the diagnostics limb observes. */
|
|
4
29
|
export type DiagEventKind = "log" | "network" | "trace";
|
|
5
30
|
interface DiagEventBase {
|
|
@@ -60,20 +85,92 @@ export interface ClientToRelay {
|
|
|
60
85
|
sessionId: string;
|
|
61
86
|
events: DiagEvent[];
|
|
62
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* The FIRST message on the diagnostics WS, sent at WS-open before HarnessProvider
|
|
90
|
+
* mounts and independent of the EventBuffer having pending events. It carries the
|
|
91
|
+
* same `sessionStorage` key the events batch carries, so the relay can resolve the
|
|
92
|
+
* TESTER on connect rather than waiting for a batch — which is why a SILENT app
|
|
93
|
+
* (no console/network/trace events) and an app that crashes during mount are both
|
|
94
|
+
* attributed instead of reading as an absence.
|
|
95
|
+
*
|
|
96
|
+
* It is a relay-layer message in the `{type:"events"}` family, NOT a tunnel
|
|
97
|
+
* control frame: the edge splices the tunnel as opaque bytes and never reads it.
|
|
98
|
+
* The `sessionId` is grammar-guarded by the relay against CLIENT_SESSION_ID_RE
|
|
99
|
+
* with no sanitize-and-keep branch, exactly as the batch key is — a malformed key
|
|
100
|
+
* is ingested un-keyed, never a crash.
|
|
101
|
+
*/
|
|
102
|
+
export interface ClientHello {
|
|
103
|
+
type: "hello";
|
|
104
|
+
/** The client's `sessionStorage` key — same value the events batch carries. */
|
|
105
|
+
sessionId: string;
|
|
106
|
+
}
|
|
107
|
+
/** Every message the client sends up the diagnostics WS. Discriminated on `type`. */
|
|
108
|
+
export type ClientMessage = ClientToRelay | ClientHello;
|
|
63
109
|
/** What the MCP server asks the relay for. `since` is an exclusive seq cursor. */
|
|
64
110
|
export interface RelayQuery {
|
|
65
|
-
sessionId?: string;
|
|
66
111
|
kind?: DiagEventKind;
|
|
67
112
|
since?: number;
|
|
68
113
|
/** Cap the returned count (newest-biased); relay clamps to its buffer size. */
|
|
69
114
|
limit?: number;
|
|
115
|
+
/**
|
|
116
|
+
* Filter to one TESTER — one browser tab, recognised across refreshes. This is
|
|
117
|
+
* the filter that answers "it breaks on their phone but not mine", because it
|
|
118
|
+
* survives the reload the tester does first.
|
|
119
|
+
*
|
|
120
|
+
* A malformed id filters to NOTHING rather than being ignored: silently
|
|
121
|
+
* widening a filter shows the creator several testers' events under a heading
|
|
122
|
+
* naming one.
|
|
123
|
+
*/
|
|
124
|
+
tester?: string;
|
|
125
|
+
/**
|
|
126
|
+
* Filter to one viewer — one CONNECTION (2.8), not an identity (B22). The
|
|
127
|
+
* sub-coordinate below the tester: a single tab opens several of these under
|
|
128
|
+
* HTTP/1.1, so this narrows to one of them and is for connection-level
|
|
129
|
+
* debugging rather than for "whose phone".
|
|
130
|
+
*/
|
|
131
|
+
viewer?: string;
|
|
70
132
|
}
|
|
71
133
|
export interface RelayResult {
|
|
72
134
|
events: DiagEvent[];
|
|
135
|
+
/**
|
|
136
|
+
* `eventViewers[i]` is the label that produced `events[i]`, or undefined when
|
|
137
|
+
* the relay could not attribute it.
|
|
138
|
+
*
|
|
139
|
+
* It rides ALONGSIDE the events rather than on them because a viewer label is
|
|
140
|
+
* a fact about the CONNECTION that carried an observation, not a property of
|
|
141
|
+
* the observation — putting it on the event would make it look like something
|
|
142
|
+
* the device reported about itself, which is the identity promotion B22
|
|
143
|
+
* forbids.
|
|
144
|
+
*/
|
|
145
|
+
eventViewers?: (string | undefined)[];
|
|
146
|
+
/**
|
|
147
|
+
* `eventTesters[i]` is the tester that produced `events[i]`, or undefined when
|
|
148
|
+
* the relay could not attribute it (the pre-mount window, a crashed app, a
|
|
149
|
+
* non-app request, or a session past the cap).
|
|
150
|
+
*
|
|
151
|
+
* Rides alongside for the same reason as `eventViewers`: which tester an
|
|
152
|
+
* observation came from is a fact about the CONNECTION that carried it, not a
|
|
153
|
+
* property of the observation.
|
|
154
|
+
*/
|
|
155
|
+
eventTesters?: (string | undefined)[];
|
|
73
156
|
/** Highest seq the relay currently holds, so the caller can advance `since`. */
|
|
74
157
|
latestSeq: number;
|
|
75
|
-
/**
|
|
76
|
-
|
|
158
|
+
/**
|
|
159
|
+
* Tester ids the relay has assigned, in arrival order. Every member matches
|
|
160
|
+
* TESTER_ID_RE.
|
|
161
|
+
*
|
|
162
|
+
* This REPLACED a `sessions` array that carried the raw client-supplied key to
|
|
163
|
+
* the terminal and to MCP tool output. That was a viewer-controlled string
|
|
164
|
+
* with no validation reaching the creator's coding agent — do not reintroduce
|
|
165
|
+
* it. The raw key is an opaque map key inside the relay and nothing else.
|
|
166
|
+
*/
|
|
167
|
+
testers: string[];
|
|
168
|
+
/**
|
|
169
|
+
* Viewer labels the relay has seen. Labels churn — a reconnecting device gets
|
|
170
|
+
* a NEW label and the old one is never reused — so the caller needs the live
|
|
171
|
+
* set rather than remembering one.
|
|
172
|
+
*/
|
|
173
|
+
viewers: string[];
|
|
77
174
|
}
|
|
78
175
|
/** A freehand stroke over the frame, in normalized [0,1] frame coordinates. */
|
|
79
176
|
export interface AnnotationStroke {
|
|
@@ -105,7 +202,12 @@ export interface AnnotationInput {
|
|
|
105
202
|
/** Annotated frame as a data URL (image/png). */
|
|
106
203
|
screenshot: string;
|
|
107
204
|
spec?: AnnotationSpec;
|
|
108
|
-
/**
|
|
205
|
+
/**
|
|
206
|
+
* The client's session key, so the annotation can be correlated with the diag
|
|
207
|
+
* events from the same tab. Client-supplied and therefore hostile input: the
|
|
208
|
+
* relay REPLACES it with the tester id it resolves to, or drops it. It is
|
|
209
|
+
* never persisted or forwarded verbatim — this record reaches an agent.
|
|
210
|
+
*/
|
|
109
211
|
sessionId?: string;
|
|
110
212
|
}
|
|
111
213
|
/** A persisted annotation the agent consumes (via `vincentt feedback --wait`). */
|
|
@@ -123,6 +225,10 @@ export interface Annotation {
|
|
|
123
225
|
/** Absolute path to the full-res annotated screenshot on disk. */
|
|
124
226
|
screenshotPath: string;
|
|
125
227
|
spec: AnnotationSpec;
|
|
228
|
+
/**
|
|
229
|
+
* The TESTER this annotation came from, or absent when the relay could not
|
|
230
|
+
* attribute it. Never the raw client key — see AnnotationInput.sessionId.
|
|
231
|
+
*/
|
|
126
232
|
sessionId?: string;
|
|
127
233
|
}
|
|
128
234
|
export {};
|
package/dist/shared/events.js
CHANGED
|
@@ -3,4 +3,28 @@
|
|
|
3
3
|
// server (which serves them to the agent). All three import THIS file, so the
|
|
4
4
|
// three can never drift on the shape of an event. Nothing here imports React,
|
|
5
5
|
// node, or MCP — it is pure data.
|
|
6
|
-
|
|
6
|
+
/**
|
|
7
|
+
* The grammar of the client's session id — the key the relay groups a TESTER by.
|
|
8
|
+
*
|
|
9
|
+
* It lives here, in the file all three parts import, for the same reason
|
|
10
|
+
* VIEWER_LABEL_RE lives beside the codec that produces the label: the party that
|
|
11
|
+
* mints the value and the party that validates it must not be able to disagree
|
|
12
|
+
* about its shape. The client writes this shape; the relay accepts this shape and
|
|
13
|
+
* nothing else, with no sanitize-and-keep branch.
|
|
14
|
+
*
|
|
15
|
+
* `s2_` is generation two. Generation one was `s_<base36 time>_<base36 rand>` over
|
|
16
|
+
* ~10^6 of Math.random, which grouped a RUN. Grouping a TESTER makes a collision a
|
|
17
|
+
* silent merge of two people's streams, so the space is now 128 bits and the
|
|
18
|
+
* prefix is what lets a relay tell the two generations apart on sight.
|
|
19
|
+
*/
|
|
20
|
+
export const CLIENT_SESSION_ID_RE = /^s2_[0-9a-f]{32}$/;
|
|
21
|
+
/**
|
|
22
|
+
* A relay-assigned tester id: `t1`, `t2`, … Minted on the CREATOR'S machine and
|
|
23
|
+
* never on the wire from the device — it is a local relabeling of a key the
|
|
24
|
+
* device supplied, which is what keeps the platform free of a correlator (B22).
|
|
25
|
+
*
|
|
26
|
+
* No leading zero. Ids are counted from 1 and never zero-padded, so `t01` is a
|
|
27
|
+
* string the registry cannot have produced; accepting it at the filter boundary
|
|
28
|
+
* would admit a second spelling for a value with one canonical form.
|
|
29
|
+
*/
|
|
30
|
+
export const TESTER_ID_RE = /^t[1-9][0-9]{0,2}$/;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CBOR SUBSET, Node half. It mirrors `api/internal/edge/cbor.go` decision
|
|
3
|
+
* for decision, because two implementations of one wire format that were
|
|
4
|
+
* written independently are two implementations that drift.
|
|
5
|
+
*
|
|
6
|
+
* It decodes CONTROL payloads only — OPEN and HEAD. It NEVER touches a DATA
|
|
7
|
+
* payload.
|
|
8
|
+
*
|
|
9
|
+
* It is hand-written rather than a library for the same reason the Go one is: a
|
|
10
|
+
* general CBOR library's allocation behavior on hostile input would be someone
|
|
11
|
+
* else's decision, and the limits below are the whole point.
|
|
12
|
+
*
|
|
13
|
+
* Everything indefinite-length, every tag, every float, every byte string and
|
|
14
|
+
* every negative integer is REFUSED — not because they are dangerous in
|
|
15
|
+
* themselves, but because the encoder we own never emits them, so accepting
|
|
16
|
+
* them would only widen the input this parser has to be correct about.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* VIEWER_LABEL_RE is the SAME regex the edge assigns against, and this client
|
|
20
|
+
* RE-VALIDATES rather than trusting the edge. That is closure (c) of the three
|
|
21
|
+
* that keep viewer-supplied bytes out of the creator's agent's context: the
|
|
22
|
+
* label flows viewerLabel -> the relay ring-buffer key -> CLI terminal output
|
|
23
|
+
* and MCP tool output, which is a terminal/log-injection path INTO the agent,
|
|
24
|
+
* and a Go-only check would pass on a client that trusted the edge.
|
|
25
|
+
*/
|
|
26
|
+
export declare const VIEWER_LABEL_RE: RegExp;
|
|
27
|
+
export declare function parseViewerLabel(s: string): string | null;
|
|
28
|
+
/**
|
|
29
|
+
* UA_CLASSES is the CLOSED ENUM OF SIX, mirroring the Go edge. `unknown` is the
|
|
30
|
+
* TOTAL fallback. This client validates the class it receives against this set
|
|
31
|
+
* before stamping the relay handshake, so an unmatched or forged class becomes
|
|
32
|
+
* `unknown` rather than an echo.
|
|
33
|
+
*/
|
|
34
|
+
export declare const UA_CLASSES: readonly ["ios_safari", "android_chrome", "desktop_chrome", "desktop_safari", "desktop_firefox", "unknown"];
|
|
35
|
+
export type UAClass = (typeof UA_CLASSES)[number];
|
|
36
|
+
export declare function parseUAClass(s: string): UAClass;
|
|
37
|
+
export interface OpenPayload {
|
|
38
|
+
viewerLabel: string;
|
|
39
|
+
method: string;
|
|
40
|
+
path: string;
|
|
41
|
+
headers: [string, string][];
|
|
42
|
+
upgrade?: string;
|
|
43
|
+
}
|
|
44
|
+
export interface HeadPayload {
|
|
45
|
+
status: number;
|
|
46
|
+
headers: [string, string][];
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* `viewer_gone` is the ONE non-terminal kind: the other three mean the session
|
|
50
|
+
* is ending, this one means a viewer's connection closed while the session
|
|
51
|
+
* continues. It is also the only kind carrying a second key.
|
|
52
|
+
*/
|
|
53
|
+
export type ControlKind = "revoke" | "expiring" | "superseded" | "viewer_gone";
|
|
54
|
+
/** A decoded CONTROL payload. `viewerLabel` is present only on `viewer_gone`. */
|
|
55
|
+
export interface ControlPayload {
|
|
56
|
+
kind: ControlKind;
|
|
57
|
+
viewerLabel?: string;
|
|
58
|
+
}
|
|
59
|
+
export declare function decodeOpen(b: Uint8Array): OpenPayload;
|
|
60
|
+
export declare function decodeHead(b: Uint8Array): HeadPayload;
|
|
61
|
+
/**
|
|
62
|
+
* The map arity is asserted AGAINST THE KIND, so a payload claiming `revoke`
|
|
63
|
+
* with a trailing label is refused as malformed rather than quietly accepted
|
|
64
|
+
* with the extra key ignored.
|
|
65
|
+
*/
|
|
66
|
+
export declare function decodeControl(b: Uint8Array): ControlPayload;
|
|
67
|
+
/** Canonical key order: viewerLabel, method, path, headers, [upgrade]. */
|
|
68
|
+
export declare function encodeOpen(p: OpenPayload): Uint8Array;
|
|
69
|
+
/** Canonical key order: status, headers. */
|
|
70
|
+
export declare function encodeHead(p: HeadPayload): Uint8Array;
|
|
71
|
+
export declare function encodeControl(kind: ControlKind, viewerLabel?: string): Uint8Array;
|