@vincentt-xr/cli 0.18.3 → 0.19.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/MIGRATION.md +16 -2
- package/dist/cli/index.js +11 -5
- package/dist/cli/usage-text.js +1 -1
- package/dist/lib/terminalText.d.ts +2 -0
- package/dist/lib/terminalText.js +7 -0
- package/dist/login/pkceLogin.d.ts +7 -1
- package/dist/login/pkceLogin.js +25 -4
- package/dist/preview/api.d.ts +15 -0
- package/dist/preview/api.js +27 -1
- package/dist/preview/command.d.ts +1 -1
- package/dist/preview/copy.d.ts +14 -1
- package/dist/preview/copy.js +37 -3
- package/dist/preview/devPort.d.ts +17 -0
- package/dist/preview/devPort.js +14 -0
- package/dist/preview/proxy.d.ts +16 -9
- package/dist/preview/proxy.js +102 -20
- package/dist/preview/runner.d.ts +10 -2
- package/dist/preview/runner.js +21 -6
- package/dist/preview/stream.d.ts +1 -1
- package/dist/preview/viewers.d.ts +1 -1
- package/dist/relay/server.js +1 -1
- package/dist/tunnel/cbor.d.ts +71 -0
- package/dist/tunnel/cbor.js +403 -0
- package/dist/tunnel/client.d.ts +225 -0
- package/dist/tunnel/client.js +764 -0
- package/dist/tunnel/forwardTarget.d.ts +69 -0
- package/dist/tunnel/forwardTarget.js +179 -0
- package/dist/tunnel/frame.d.ts +126 -0
- package/dist/tunnel/frame.js +244 -0
- package/dist/tunnel/index.d.ts +9 -0
- package/dist/tunnel/index.js +9 -0
- package/package.json +2 -2
package/dist/preview/runner.js
CHANGED
|
@@ -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 "
|
|
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
|
-
|
|
33
|
-
|
|
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;
|
package/dist/preview/stream.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { DiagEvent, LogEvent, NetworkEvent, TraceEvent } from "@vincentt-xr/harness/events";
|
|
2
|
-
import type { UAClass } from "
|
|
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;
|
package/dist/relay/server.js
CHANGED
|
@@ -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 "
|
|
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. */
|
|
@@ -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
|
+
}
|