@vincentt-xr/cli 0.18.3 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/MIGRATION.md +26 -2
  2. package/README.md +3 -2
  3. package/dist/cli/index.js +43 -12
  4. package/dist/cli/usage-text.js +4 -3
  5. package/dist/lib/terminalText.d.ts +2 -0
  6. package/dist/lib/terminalText.js +7 -0
  7. package/dist/login/authBackend.d.ts +27 -2
  8. package/dist/login/authBackend.js +79 -13
  9. package/dist/login/loginCommand.d.ts +35 -3
  10. package/dist/login/loginCommand.js +52 -3
  11. package/dist/login/machineLabel.d.ts +46 -0
  12. package/dist/login/machineLabel.js +119 -0
  13. package/dist/login/pkceLogin.d.ts +14 -2
  14. package/dist/login/pkceLogin.js +31 -8
  15. package/dist/preview/api.d.ts +15 -0
  16. package/dist/preview/api.js +27 -1
  17. package/dist/preview/command.d.ts +1 -1
  18. package/dist/preview/copy.d.ts +14 -1
  19. package/dist/preview/copy.js +38 -4
  20. package/dist/preview/devPort.d.ts +17 -0
  21. package/dist/preview/devPort.js +14 -0
  22. package/dist/preview/proxy.d.ts +16 -9
  23. package/dist/preview/proxy.js +102 -20
  24. package/dist/preview/runner.d.ts +10 -2
  25. package/dist/preview/runner.js +21 -6
  26. package/dist/preview/stream.d.ts +1 -1
  27. package/dist/preview/viewers.d.ts +1 -1
  28. package/dist/relay/server.js +1 -1
  29. package/dist/shared/authError.d.ts +1 -1
  30. package/dist/shared/authError.js +3 -1
  31. package/dist/tunnel/cbor.d.ts +71 -0
  32. package/dist/tunnel/cbor.js +403 -0
  33. package/dist/tunnel/client.d.ts +225 -0
  34. package/dist/tunnel/client.js +764 -0
  35. package/dist/tunnel/forwardTarget.d.ts +69 -0
  36. package/dist/tunnel/forwardTarget.js +179 -0
  37. package/dist/tunnel/frame.d.ts +126 -0
  38. package/dist/tunnel/frame.js +244 -0
  39. package/dist/tunnel/index.d.ts +9 -0
  40. package/dist/tunnel/index.js +9 -0
  41. package/package.json +2 -2
@@ -16,7 +16,7 @@
16
16
  import { spawn, spawnSync } from "node:child_process";
17
17
  import { realpath } from "node:fs/promises";
18
18
  import { createServer } from "node:http";
19
- import { TunnelClient, resolveForwardTarget, } from "@vincentt-xr/harness/tunnel";
19
+ import { TunnelClient, resolveForwardTarget, } from "../tunnel/index.js";
20
20
  import { startRelay } from "../relay/server.js";
21
21
  import { mintRelayToken } from "../relay/guard.js";
22
22
  import { isHiddenAppPath, proxyWeb, proxyWs, relayRoute } from "./proxy.js";
@@ -26,20 +26,35 @@ import { withoutCliCredentials } from "../shared/config.js";
26
26
  import { ProjectUnreachableError } from "../lib/backend.js";
27
27
  import { AuthExpiredError } from "../shared/authError.js";
28
28
  import { apiFetch } from "../shared/http.js";
29
+ import { terminalSafe } from "../lib/terminalText.js";
29
30
  /** The path a 404 advisory names: the request's own path, without its query. */
30
31
  export function requestPathOf(url) {
31
- const raw = (url ?? "/").split("?")[0] ?? "/";
32
- // Control characters never reach the terminal from a request line.
33
- return raw.replace(/[\u0000-\u001f\u007f-\u009f]/g, "");
32
+ const raw = ((url ?? "/").split("#")[0] ?? "/").split("?")[0] ?? "/";
33
+ let decoded = raw;
34
+ try {
35
+ decoded = decodeURIComponent(raw);
36
+ }
37
+ catch {
38
+ /* an undecodable path is printed as sent */
39
+ }
40
+ return terminalSafe(decoded);
41
+ }
42
+ /**
43
+ * The project root as the `/@fs/` check compares it: Vite writes `/@fs/C:/...`
44
+ * with forward slashes, so a Windows root holding backslashes would never match
45
+ * as inside, and a project under a dotted parent folder would get false 404s.
46
+ */
47
+ export function hiddenPathRoot(realRoot) {
48
+ return realRoot.replaceAll("\\", "/");
34
49
  }
35
50
  export async function startPreview(opts) {
36
51
  const { projectCwd, projectRoot = projectCwd, config, projectId, harnessPath = "/__harness", devCommand = ["npm", "run", "dev"], appStdio = "ignore", appReadyTimeoutMs = 8_000, onLog = () => undefined, fetchImpl = apiFetch, } = opts;
37
52
  const appPort = opts.appPort ?? 5173;
38
53
  const relayPort = opts.relayPort ?? (await getFreePort());
39
54
  const frontPort = opts.frontPort ?? (await getFreePort());
40
- const reuseApp = await isPortListening(appPort);
55
+ const reuseApp = opts.appPortFresh ? false : await isPortListening(appPort);
41
56
  const relayToken = mintRelayToken();
42
- const hiddenRoot = await realpath(projectRoot).catch(() => projectRoot);
57
+ const hiddenRoot = hiddenPathRoot(await realpath(projectRoot).catch(() => projectRoot));
43
58
  let app;
44
59
  let killApp;
45
60
  let relay;
@@ -1,5 +1,5 @@
1
1
  import type { DiagEvent, LogEvent, NetworkEvent, TraceEvent } from "@vincentt-xr/harness/events";
2
- import type { UAClass } from "@vincentt-xr/harness/tunnel";
2
+ import type { UAClass } from "../tunnel/index.js";
3
3
  /** One line per viewer per 5s on the terminal. The agent's pull is unthrottled. */
4
4
  export declare const PERF_THROTTLE_MS = 5000;
5
5
  export declare function uaLabel(c: UAClass): string;
@@ -1,4 +1,4 @@
1
- import type { UAClass } from "@vincentt-xr/harness/tunnel";
1
+ import type { UAClass } from "../tunnel/index.js";
2
2
  /** The roster block prints once, on the FOURTH TESTER's arrival, and never again. */
3
3
  export declare const ROSTER_AT = 4;
4
4
  /**
@@ -10,7 +10,7 @@ import { EventStore } from "./store.js";
10
10
  import { TesterRegistry, isTesterId } from "./testers.js";
11
11
  import { AnnotationStore } from "./annotations.js";
12
12
  import { RELAY_TOKEN_HEADER, hasRelayToken, isAllowedHost, isAllowedOrigin, isJsonContentType, } from "./guard.js";
13
- import { VIEWER_LABEL_RE, VIEWER_STAMP_HEADER } from "@vincentt-xr/harness/tunnel";
13
+ import { VIEWER_LABEL_RE, VIEWER_STAMP_HEADER } from "../tunnel/index.js";
14
14
  /** Cap an annotation POST body so a bad/huge upload can't exhaust memory. */
15
15
  const MAX_ANNOTATION_BYTES = 25 * 1024 * 1024;
16
16
  /** Clamp the long-poll hold so a request can't park forever. */
@@ -1,4 +1,4 @@
1
- export declare const AUTH_EXPIRED_MESSAGE = "Your Vincentt access token is no longer valid. Run `vincentt login` to sign in again.";
1
+ export declare const AUTH_EXPIRED_MESSAGE = "This machine is no longer signed in to Vincentt. Run `vincentt login` to sign in again.";
2
2
  export declare function authFailureMessage(label: string, status: number, detail: string): string;
3
3
  /**
4
4
  * The api refused the token. Distinct from every other refusal because the
@@ -3,7 +3,9 @@
3
3
  // "(401): <body>" is unactionable noise for every user at once. Name the cause and give
4
4
  // the one instruction that fixes it. Any other status keeps its existing "(<n>): <body>"
5
5
  // shape: a 500/timeout is a different problem and must not be mislabeled a stale token.
6
- export const AUTH_EXPIRED_MESSAGE = "Your Vincentt access token is no longer valid. Run `vincentt login` to sign in again.";
6
+ // A machine's sign-in ends when it is signed out in the console, signed out at the
7
+ // 10-machine cap, or left unused for 90 days; all three reach the CLI as this 401.
8
+ export const AUTH_EXPIRED_MESSAGE = "This machine is no longer signed in to Vincentt. Run `vincentt login` to sign in again.";
7
9
  export function authFailureMessage(label, status, detail) {
8
10
  if (status === 401)
9
11
  return AUTH_EXPIRED_MESSAGE;
@@ -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;
@@ -0,0 +1,403 @@
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
+ import { MAX_CBOR_DEPTH, MAX_HEADERS, MAX_HEADER_BYTES, MAX_OPEN_PAYLOAD, MAX_PATH_LEN, ProtocolError, } from "./frame.js";
19
+ const MAX_METHOD_LEN = 16;
20
+ const MAX_VIEWER_LABEL_LEN = 11;
21
+ /** Every CBOR error is STREAM-level: the stream dies, the tunnel SURVIVES. */
22
+ function fail(code) {
23
+ throw new ProtocolError(code, false);
24
+ }
25
+ /**
26
+ * VIEWER_LABEL_RE is the SAME regex the edge assigns against, and this client
27
+ * RE-VALIDATES rather than trusting the edge. That is closure (c) of the three
28
+ * that keep viewer-supplied bytes out of the creator's agent's context: the
29
+ * label flows viewerLabel -> the relay ring-buffer key -> CLI terminal output
30
+ * and MCP tool output, which is a terminal/log-injection path INTO the agent,
31
+ * and a Go-only check would pass on a client that trusted the edge.
32
+ */
33
+ export const VIEWER_LABEL_RE = /^viewer-[0-9]{1,4}$/;
34
+ export function parseViewerLabel(s) {
35
+ return VIEWER_LABEL_RE.test(s) ? s : null;
36
+ }
37
+ /**
38
+ * UA_CLASSES is the CLOSED ENUM OF SIX, mirroring the Go edge. `unknown` is the
39
+ * TOTAL fallback. This client validates the class it receives against this set
40
+ * before stamping the relay handshake, so an unmatched or forged class becomes
41
+ * `unknown` rather than an echo.
42
+ */
43
+ export const UA_CLASSES = [
44
+ "ios_safari",
45
+ "android_chrome",
46
+ "desktop_chrome",
47
+ "desktop_safari",
48
+ "desktop_firefox",
49
+ "unknown",
50
+ ];
51
+ export function parseUAClass(s) {
52
+ return UA_CLASSES.includes(s) ? s : "unknown";
53
+ }
54
+ /**
55
+ * CONTROL_ARITY is the EXACT map length per kind, mirroring controlArity in the
56
+ * Go edge. The grammar stays closed per-kind rather than becoming "one or two
57
+ * keys, whatever arrives": a decoder that accepts a viewerLabel on `revoke`, or
58
+ * omits it on `viewer_gone`, is a decoder with a shape the encoder never emits.
59
+ */
60
+ const CONTROL_ARITY = new Map([
61
+ ["revoke", 1],
62
+ ["expiring", 1],
63
+ ["superseded", 1],
64
+ ["viewer_gone", 2],
65
+ ]);
66
+ // ---- decoding ---------------------------------------------------------------
67
+ class Reader {
68
+ b;
69
+ i = 0;
70
+ depth = 1;
71
+ constructor(b) {
72
+ this.b = b;
73
+ }
74
+ /**
75
+ * head reads a CBOR initial byte and its argument. Indefinite-length
76
+ * (ai === 31) is REFUSED outright: it is the one CBOR shape whose length is
77
+ * not knowable before reading, which is the property every limit depends on.
78
+ */
79
+ head() {
80
+ if (this.i >= this.b.length)
81
+ fail("cbor_malformed");
82
+ const c = this.b[this.i++];
83
+ const major = c >> 5;
84
+ const ai = c & 0x1f;
85
+ if (ai < 24)
86
+ return { major, arg: ai };
87
+ if (ai === 24) {
88
+ if (this.i >= this.b.length)
89
+ fail("cbor_malformed");
90
+ return { major, arg: this.b[this.i++] };
91
+ }
92
+ if (ai === 25) {
93
+ if (this.i + 2 > this.b.length)
94
+ fail("cbor_malformed");
95
+ const v = (this.b[this.i] << 8) + this.b[this.i + 1];
96
+ this.i += 2;
97
+ return { major, arg: v };
98
+ }
99
+ if (ai === 26) {
100
+ if (this.i + 4 > this.b.length)
101
+ fail("cbor_malformed");
102
+ const v = this.b[this.i] * 0x1000000 +
103
+ (this.b[this.i + 1] << 16) +
104
+ (this.b[this.i + 2] << 8) +
105
+ this.b[this.i + 3];
106
+ this.i += 4;
107
+ return { major, arg: v };
108
+ }
109
+ if (ai === 27) {
110
+ if (this.i + 8 > this.b.length)
111
+ fail("cbor_malformed");
112
+ let v = 0;
113
+ for (let k = 0; k < 8; k++)
114
+ v = v * 256 + this.b[this.i + k];
115
+ this.i += 8;
116
+ return { major, arg: v };
117
+ }
118
+ // 28..30 reserved; 31 indefinite-length. Both refused.
119
+ return fail("cbor_malformed");
120
+ }
121
+ /**
122
+ * text reads a definite-length UTF-8 string, bounded by `max` BEFORE the
123
+ * slice is taken — so a declared 4 GiB string costs a comparison, not a copy.
124
+ */
125
+ text(max) {
126
+ const { major, arg } = this.head();
127
+ if (major !== 3)
128
+ fail("cbor_malformed");
129
+ if (arg > max)
130
+ fail("cbor_malformed");
131
+ if (this.i + arg > this.b.length)
132
+ fail("cbor_malformed");
133
+ const s = new TextDecoder("utf-8", { fatal: false }).decode(this.b.subarray(this.i, this.i + arg));
134
+ this.i += arg;
135
+ return s;
136
+ }
137
+ uint(max) {
138
+ const { major, arg } = this.head();
139
+ if (major !== 0 || arg > max)
140
+ fail("cbor_malformed");
141
+ return arg;
142
+ }
143
+ /**
144
+ * headerPairs reads a map of text->text, enforcing MAX_HEADERS and
145
+ * MAX_HEADER_BYTES as it goes. The COUNT bound is checked against the
146
+ * DECLARED map size before a single pair is read, so a payload claiming 2^32
147
+ * headers allocates nothing.
148
+ */
149
+ headerPairs() {
150
+ this.depth++;
151
+ if (this.depth > MAX_CBOR_DEPTH)
152
+ fail("cbor_depth");
153
+ try {
154
+ const { major, arg } = this.head();
155
+ if (major !== 5)
156
+ fail("cbor_malformed");
157
+ if (arg > MAX_HEADERS)
158
+ fail("cbor_headers");
159
+ const out = [];
160
+ let total = 0;
161
+ for (let k = 0; k < arg; k++) {
162
+ const name = this.text(MAX_HEADER_BYTES);
163
+ const val = this.text(MAX_HEADER_BYTES);
164
+ total += name.length + val.length;
165
+ if (total > MAX_HEADER_BYTES)
166
+ fail("cbor_header_bytes");
167
+ out.push([name, val]);
168
+ }
169
+ return out;
170
+ }
171
+ finally {
172
+ this.depth--;
173
+ }
174
+ }
175
+ }
176
+ export function decodeOpen(b) {
177
+ if (b.length > MAX_OPEN_PAYLOAD)
178
+ fail("open_payload_too_large");
179
+ const r = new Reader(b);
180
+ const { major, arg } = r.head();
181
+ if (major !== 5 || arg > 8)
182
+ fail("cbor_malformed");
183
+ let viewerLabel;
184
+ let method;
185
+ let path;
186
+ let headers = [];
187
+ let upgrade;
188
+ for (let k = 0; k < arg; k++) {
189
+ const key = r.text(32);
190
+ switch (key) {
191
+ case "viewerLabel": {
192
+ const raw = r.text(MAX_VIEWER_LABEL_LEN);
193
+ // RE-VALIDATED here, not merely trusted because the edge assigned it.
194
+ const parsed = parseViewerLabel(raw);
195
+ if (parsed === null)
196
+ fail("cbor_viewer");
197
+ viewerLabel = parsed;
198
+ break;
199
+ }
200
+ case "method":
201
+ method = r.text(MAX_METHOD_LEN);
202
+ break;
203
+ case "path": {
204
+ const p = r.text(MAX_PATH_LEN + 1);
205
+ if (p.length > MAX_PATH_LEN)
206
+ fail("cbor_path_len");
207
+ path = p;
208
+ break;
209
+ }
210
+ case "headers":
211
+ headers = r.headerPairs();
212
+ break;
213
+ case "upgrade":
214
+ upgrade = r.text(32);
215
+ break;
216
+ default:
217
+ // An unknown key is a MALFORMED payload, not a skipped field. Skipping
218
+ // would mean accepting bytes we do not understand on the one payload
219
+ // this client parses.
220
+ fail("cbor_malformed");
221
+ }
222
+ }
223
+ if (viewerLabel === undefined || method === undefined || path === undefined) {
224
+ fail("cbor_malformed");
225
+ }
226
+ // Trailing bytes after a complete payload are a second message hiding in one
227
+ // frame.
228
+ if (r.i !== b.length)
229
+ fail("cbor_malformed");
230
+ const out = { viewerLabel, method, path, headers };
231
+ if (upgrade !== undefined)
232
+ out.upgrade = upgrade;
233
+ return out;
234
+ }
235
+ export function decodeHead(b) {
236
+ if (b.length > MAX_OPEN_PAYLOAD)
237
+ fail("open_payload_too_large");
238
+ const r = new Reader(b);
239
+ const { major, arg } = r.head();
240
+ if (major !== 5 || arg > 4)
241
+ fail("cbor_malformed");
242
+ let status;
243
+ let headers = [];
244
+ for (let k = 0; k < arg; k++) {
245
+ const key = r.text(32);
246
+ switch (key) {
247
+ case "status": {
248
+ const v = r.uint(0xffff);
249
+ // A status outside 100..599 is a protocol violation and never a
250
+ // relayed status.
251
+ if (v < 100 || v > 599)
252
+ fail("cbor_status");
253
+ status = v;
254
+ break;
255
+ }
256
+ case "headers":
257
+ headers = r.headerPairs();
258
+ break;
259
+ default:
260
+ fail("cbor_malformed");
261
+ }
262
+ }
263
+ if (status === undefined)
264
+ fail("cbor_malformed");
265
+ if (r.i !== b.length)
266
+ fail("cbor_malformed");
267
+ return { status, headers };
268
+ }
269
+ /**
270
+ * The map arity is asserted AGAINST THE KIND, so a payload claiming `revoke`
271
+ * with a trailing label is refused as malformed rather than quietly accepted
272
+ * with the extra key ignored.
273
+ */
274
+ export function decodeControl(b) {
275
+ if (b.length > MAX_OPEN_PAYLOAD)
276
+ fail("open_payload_too_large");
277
+ const r = new Reader(b);
278
+ const { major, arg } = r.head();
279
+ if (major !== 5)
280
+ fail("cbor_malformed");
281
+ if (r.text(32) !== "kind")
282
+ fail("cbor_malformed");
283
+ const v = r.text(32);
284
+ const want = CONTROL_ARITY.get(v);
285
+ if (want === undefined || arg !== want)
286
+ fail("cbor_malformed");
287
+ const kind = v;
288
+ let viewerLabel;
289
+ if (kind === "viewer_gone") {
290
+ if (r.text(32) !== "viewerLabel")
291
+ fail("cbor_malformed");
292
+ // Parsed, not copied: the label reaches the creator's terminal.
293
+ const raw = r.text(MAX_VIEWER_LABEL_LEN);
294
+ if (parseViewerLabel(raw) === null)
295
+ fail("cbor_viewer");
296
+ viewerLabel = raw;
297
+ }
298
+ if (r.i !== b.length)
299
+ fail("cbor_malformed");
300
+ return viewerLabel === undefined ? { kind } : { kind, viewerLabel };
301
+ }
302
+ // ---- encoding ---------------------------------------------------------------
303
+ /**
304
+ * The encoder emits CANONICAL CBOR for the subset: definite lengths, shortest
305
+ * integer encoding, and map keys in a fixed order, with header pairs SORTED.
306
+ *
307
+ * Canonicality is what makes the checked-in golden fixture comparable
308
+ * byte-for-byte against BOTH implementations' output. A non-deterministic
309
+ * encoder would make the fixture assertable in one direction only.
310
+ */
311
+ function head(major, arg) {
312
+ const m = major << 5;
313
+ if (arg < 24)
314
+ return [m | arg];
315
+ if (arg <= 0xff)
316
+ return [m | 24, arg];
317
+ if (arg <= 0xffff)
318
+ return [m | 25, (arg >>> 8) & 0xff, arg & 0xff];
319
+ if (arg <= 0xffffffff) {
320
+ return [
321
+ m | 26,
322
+ (arg >>> 24) & 0xff,
323
+ (arg >>> 16) & 0xff,
324
+ (arg >>> 8) & 0xff,
325
+ arg & 0xff,
326
+ ];
327
+ }
328
+ const out = [m | 27];
329
+ let v = arg;
330
+ const bytes = [];
331
+ for (let k = 0; k < 8; k++) {
332
+ bytes.unshift(v % 256);
333
+ v = Math.floor(v / 256);
334
+ }
335
+ return out.concat(bytes);
336
+ }
337
+ function text(s) {
338
+ const b = Array.from(new TextEncoder().encode(s));
339
+ return head(3, b.length).concat(b);
340
+ }
341
+ function headerMap(h) {
342
+ // Sorted by name then value, so two encoders handed the same headers in a
343
+ // different order still produce the same bytes.
344
+ const sorted = [...h].sort((a, b) => a[0] === b[0] ? (a[1] < b[1] ? -1 : 1) : a[0] < b[0] ? -1 : 1);
345
+ let out = head(5, sorted.length);
346
+ for (const [k, v] of sorted)
347
+ out = out.concat(text(k), text(v));
348
+ return out;
349
+ }
350
+ /** Canonical key order: viewerLabel, method, path, headers, [upgrade]. */
351
+ export function encodeOpen(p) {
352
+ if (parseViewerLabel(p.viewerLabel) === null)
353
+ fail("cbor_viewer");
354
+ if (p.path.length > MAX_PATH_LEN)
355
+ fail("cbor_path_len");
356
+ if (p.headers.length > MAX_HEADERS)
357
+ fail("cbor_headers");
358
+ const n = p.upgrade ? 5 : 4;
359
+ let out = head(5, n);
360
+ out = out.concat(text("viewerLabel"), text(p.viewerLabel));
361
+ out = out.concat(text("method"), text(p.method));
362
+ out = out.concat(text("path"), text(p.path));
363
+ out = out.concat(text("headers"), headerMap(p.headers));
364
+ if (p.upgrade)
365
+ out = out.concat(text("upgrade"), text(p.upgrade));
366
+ const b = new Uint8Array(out);
367
+ if (b.length > MAX_OPEN_PAYLOAD)
368
+ fail("open_payload_too_large");
369
+ return b;
370
+ }
371
+ /** Canonical key order: status, headers. */
372
+ export function encodeHead(p) {
373
+ if (p.status < 100 || p.status > 599)
374
+ fail("cbor_status");
375
+ if (p.headers.length > MAX_HEADERS)
376
+ fail("cbor_headers");
377
+ let out = head(5, 2);
378
+ out = out.concat(text("status"), head(0, p.status));
379
+ out = out.concat(text("headers"), headerMap(p.headers));
380
+ const b = new Uint8Array(out);
381
+ if (b.length > MAX_OPEN_PAYLOAD)
382
+ fail("open_payload_too_large");
383
+ return b;
384
+ }
385
+ export function encodeControl(kind, viewerLabel) {
386
+ const arity = CONTROL_ARITY.get(kind);
387
+ if (arity === undefined)
388
+ fail("cbor_malformed");
389
+ if (kind === "viewer_gone") {
390
+ if (viewerLabel === undefined || parseViewerLabel(viewerLabel) === null) {
391
+ fail("cbor_viewer");
392
+ }
393
+ }
394
+ else if (viewerLabel !== undefined) {
395
+ // A label on a terminal kind is a caller bug, refused rather than dropped.
396
+ fail("cbor_malformed");
397
+ }
398
+ const out = head(5, arity).concat(text("kind"), text(kind));
399
+ if (kind === "viewer_gone") {
400
+ out.push(...text("viewerLabel"), ...text(viewerLabel));
401
+ }
402
+ return new Uint8Array(out);
403
+ }