@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.
@@ -0,0 +1,174 @@
1
+ /**
2
+ * The forward target — what the tunnel client is allowed to reach (2.15.4).
3
+ *
4
+ * READ THIS BEFORE CHANGING ANYTHING HERE.
5
+ *
6
+ * THIS FILE IS A DEFENSE, NOT A SECURITY CONTROL. Every rule in it executes on
7
+ * the CREATOR'S MACHINE, in software the creator can replace. It stops the
8
+ * ACCIDENT — a viewer-controlled path reaching a second local service — and it
9
+ * stops NOTHING a hostile creator wants to do. The controls that bind a hostile
10
+ * creator are at the edge: the abuse budget and the header set.
11
+ *
12
+ * The earlier draft's argument that "one outbound connection, nothing dials into
13
+ * the laptop" made this safe was about CONNECTION DIRECTION, which is not an
14
+ * authorization boundary for what the laptop does with a request once it
15
+ * arrives.
16
+ */
17
+ import { isIP } from "node:net";
18
+ import { lookup } from "node:dns/promises";
19
+ export class ForwardTargetError extends Error {
20
+ code;
21
+ constructor(code) {
22
+ // The code and nothing else. A rejected path in an error message would put
23
+ // a viewer-controlled string into the creator's terminal.
24
+ super(code);
25
+ this.code = code;
26
+ this.name = "ForwardTargetError";
27
+ }
28
+ }
29
+ /**
30
+ * LOOPBACK is the only address family the client will connect to. It is checked
31
+ * at START and RE-CHECKED AT CONNECT TIME, so a DNS rebind cannot move the
32
+ * target between the two.
33
+ */
34
+ function isLoopback(addr) {
35
+ if (isIP(addr) === 4)
36
+ return addr.startsWith("127.");
37
+ if (isIP(addr) === 6)
38
+ return addr === "::1" || addr === "::ffff:127.0.0.1";
39
+ return false;
40
+ }
41
+ const defaultResolver = async (host) => (await lookup(host)).address;
42
+ /**
43
+ * resolveForwardTarget captures the ONE configured target at
44
+ * `vincentt preview` start, from the creator's own flag or config — NEVER from
45
+ * a request.
46
+ */
47
+ export async function resolveForwardTarget(host, port, resolve = defaultResolver) {
48
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
49
+ throw new ForwardTargetError("invalid_target");
50
+ }
51
+ const address = isIP(host) ? host : await resolve(host);
52
+ if (!isLoopback(address))
53
+ throw new ForwardTargetError("non_loopback_target");
54
+ return { host, port, address };
55
+ }
56
+ /**
57
+ * assertStillLoopback is the CONNECT-TIME re-check. Resolving once at start and
58
+ * trusting it forever is exactly the window a DNS rebind uses.
59
+ */
60
+ export async function assertStillLoopback(t, resolve = defaultResolver) {
61
+ // An IP literal cannot be rebound, so it needs no second lookup — which also
62
+ // keeps the common case (127.0.0.1) off the resolver entirely.
63
+ const address = isIP(t.host) ? t.host : await resolve(t.host);
64
+ if (!isLoopback(address))
65
+ throw new ForwardTargetError("non_loopback_target");
66
+ }
67
+ /**
68
+ * normalizeRequestTarget applies rules 2 and 3.
69
+ *
70
+ * Rule 2: an ABSOLUTE-FORM target (`GET http://... HTTP/1.1`) is refused
71
+ * outright. That is the shape that turns a forwarding proxy into an open relay.
72
+ *
73
+ * Rule 3: NORMALIZE, THEN CHECK — never check-then-normalize. Percent-encoded
74
+ * traversal (`%2e%2e%2f`) passes a naive `includes('..')` check and then decodes
75
+ * into one, so the decode has to happen FIRST, and it has to happen repeatedly
76
+ * because double-encoding (`%252e`) decodes into `%2e`.
77
+ */
78
+ export function normalizeRequestTarget(target) {
79
+ if (target === "" || !target.startsWith("/")) {
80
+ // Anything not origin-form: absolute-form, authority-form, asterisk-form.
81
+ throw new ForwardTargetError("absolute_form_target");
82
+ }
83
+ // A scheme-relative target (`//evil.example/x`) is origin-form by the leading
84
+ // slash but is treated as a network path by many URL parsers.
85
+ if (target.startsWith("//"))
86
+ throw new ForwardTargetError("absolute_form_target");
87
+ const [rawPath = "", ...rest] = target.split("?");
88
+ const query = rest.length > 0 ? rest.join("?") : undefined;
89
+ // Decode repeatedly until it stops changing, so a double-encoded traversal
90
+ // cannot survive by decoding one level less than we check.
91
+ let decoded = rawPath;
92
+ for (let i = 0; i < 4; i++) {
93
+ let next;
94
+ try {
95
+ next = decodeURIComponent(decoded);
96
+ }
97
+ catch {
98
+ // A malformed escape sequence. Refuse rather than guess.
99
+ throw new ForwardTargetError("path_traversal");
100
+ }
101
+ if (next === decoded)
102
+ break;
103
+ decoded = next;
104
+ }
105
+ // Backslash is a path separator on Windows and is normalized by some servers.
106
+ const probe = decoded.replace(/\\/g, "/");
107
+ for (const segment of probe.split("/")) {
108
+ if (segment === "..")
109
+ throw new ForwardTargetError("path_traversal");
110
+ }
111
+ // NUL and control bytes in a path are a request-smuggling primitive.
112
+ // eslint-disable-next-line no-control-regex
113
+ if (/[\x00-\x1f\x7f]/.test(probe))
114
+ throw new ForwardTargetError("path_traversal");
115
+ // The ORIGINAL (still-encoded) path is what is forwarded: re-encoding could
116
+ // change the creator's app's routing. Only the CHECK used the decoded form.
117
+ return query === undefined ? rawPath : `${rawPath}?${query}`;
118
+ }
119
+ /**
120
+ * VIEWER_ROUTING_HEADERS are DROPPED and replaced with the client's own
121
+ * (rule 4). A Host-honoring proxy is a routing primitive, and the forwarded-for
122
+ * family would carry the viewer's IP onto the creator's machine — a viewer
123
+ * identity the platform has no business handing over.
124
+ *
125
+ * The edge already drops these; dropping them again here is deliberate defense
126
+ * in depth, because this list is the one that runs closest to the local server.
127
+ */
128
+ const VIEWER_ROUTING_HEADERS = new Set([
129
+ "host",
130
+ "forwarded",
131
+ "x-forwarded-for",
132
+ "x-forwarded-host",
133
+ "x-forwarded-proto",
134
+ "x-forwarded-port",
135
+ "x-forwarded-server",
136
+ "x-real-ip",
137
+ ]);
138
+ const HOP_BY_HOP = new Set([
139
+ "connection",
140
+ "keep-alive",
141
+ "proxy-authenticate",
142
+ "proxy-authorization",
143
+ "te",
144
+ "trailer",
145
+ "transfer-encoding",
146
+ "upgrade",
147
+ ]);
148
+ /**
149
+ * sanitizeHeaders drops the viewer's routing headers and every hop-by-hop
150
+ * header, and substitutes the client's own Host.
151
+ */
152
+ export function sanitizeHeaders(headers, target) {
153
+ const out = [];
154
+ for (const [name, value] of headers) {
155
+ const lower = name.toLowerCase();
156
+ if (VIEWER_ROUTING_HEADERS.has(lower))
157
+ continue;
158
+ if (lower.startsWith("x-forwarded-"))
159
+ continue;
160
+ if (HOP_BY_HOP.has(lower))
161
+ continue;
162
+ out.push([name, value]);
163
+ }
164
+ // ITS OWN Host, never the viewer's.
165
+ out.push(["host", `${target.host}:${target.port}`]);
166
+ return out;
167
+ }
168
+ /**
169
+ * FOLLOW_REDIRECTS is false and there is no option to change it (rule 6). The
170
+ * BROWSER follows a redirect, on the viewer's own origin, where the viewer's
171
+ * own security context applies. A proxy that followed one on the creator's
172
+ * behalf would resolve a Location against the LOCAL machine.
173
+ */
174
+ export const FOLLOW_REDIRECTS = false;
@@ -0,0 +1,126 @@
1
+ /**
2
+ * The tunnel protocol's Node half.
3
+ *
4
+ * This is the SECOND implementation of one binary protocol, in a second
5
+ * language — the highest-risk contract in the product to date. Two green
6
+ * per-language suites do not prove a contract, so every limit and every
7
+ * refusal here is pinned against a SHARED CHECKED-IN FIXTURE that neither
8
+ * implementation generates (`fixtures/`), and the malformed fixture's row count
9
+ * is asserted in both languages: a row present in one runner and absent in the
10
+ * other must FAIL.
11
+ *
12
+ * A Go/Node divergence on what to REFUSE is exactly how one implementation
13
+ * becomes a bypass for the other's limits.
14
+ *
15
+ * As on the Go side, DATA IS OPAQUE. Nothing in this file reads a DATA payload,
16
+ * branches on it, or keeps it.
17
+ */
18
+ export declare enum FrameType {
19
+ /** edge->client. payload: CBOR {viewerLabel, method, path, headers, upgrade?} */
20
+ OPEN = 1,
21
+ /** both. payload: OPAQUE bytes. Never parsed. */
22
+ DATA = 2,
23
+ /** both. half-close a stream (or, with a u24 payload, a credit replenishment) */
24
+ END = 3,
25
+ /** both. payload: u8 reason */
26
+ RESET = 4,
27
+ /** client->edge. payload: CBOR {status, headers} */
28
+ HEAD = 5,
29
+ /** both, streamId MUST be 0 */
30
+ PING = 16,
31
+ PONG = 17,
32
+ /** edge->client, streamId MUST be 0 */
33
+ CONTROL = 18
34
+ }
35
+ export declare enum ResetReason {
36
+ PROTOCOL_ERROR = 1,
37
+ STREAM_CLOSED = 2,
38
+ REFUSED = 3,
39
+ IDLE = 4,
40
+ SESSION_ENDED = 5,
41
+ UNKNOWN = 255
42
+ }
43
+ /**
44
+ * MAX_FRAME_LEN is checked against the HEADER, before any allocation. The
45
+ * `length` field is u24 — 16 MiB of attacker-controlled allocation per frame if
46
+ * a decoder does `Buffer.alloc(length)` before dispatch. It equals the initial
47
+ * credit window so a single frame can never exceed a stream's credit.
48
+ */
49
+ export declare const MAX_FRAME_LEN: number;
50
+ /** The ONE decode path: OPEN/HEAD CBOR control payloads. Never DATA. */
51
+ export declare const MAX_OPEN_PAYLOAD: number;
52
+ export declare const FRAME_HEADER_LEN = 8;
53
+ export declare const MAX_HEADERS = 64;
54
+ export declare const MAX_HEADER_BYTES: number;
55
+ export declare const MAX_PATH_LEN: number;
56
+ export declare const MAX_CBOR_DEPTH = 4;
57
+ export declare const MAX_CONCURRENT_STREAMS = 256;
58
+ export declare const INITIAL_CREDIT: number;
59
+ export declare const RELAY_BUF_SIZE: number;
60
+ export declare const IDLE_STREAM_TIMEOUT_MS = 60000;
61
+ export declare const TUNNEL_PING_INTERVAL_MS = 15000;
62
+ export declare const TUNNEL_PING_TIMEOUT_MS = 45000;
63
+ /** Protocol errors. They are for the CLIENT's own log, never for a wire body. */
64
+ export declare class ProtocolError extends Error {
65
+ readonly code: ProtocolErrorCode;
66
+ readonly fatal: boolean;
67
+ /**
68
+ * `fatal` is the 2.15.3 behavior table expressed ONCE, so the Go edge and
69
+ * this client cannot drift on which violations close the tunnel and which
70
+ * only reset a stream.
71
+ */
72
+ constructor(code: ProtocolErrorCode, fatal: boolean);
73
+ }
74
+ export type ProtocolErrorCode = "frame_too_large" | "unknown_frame_type" | "even_stream_id" | "zero_stream_id" | "nonzero_stream_id" | "short_payload" | "open_payload_too_large" | "cbor_malformed" | "cbor_depth" | "cbor_headers" | "cbor_header_bytes" | "cbor_path_len" | "cbor_status" | "cbor_viewer";
75
+ export interface FrameHeader {
76
+ type: FrameType;
77
+ streamId: number;
78
+ length: number;
79
+ }
80
+ export interface Frame extends FrameHeader {
81
+ payload: Uint8Array;
82
+ }
83
+ /**
84
+ * validateFrameShape is the single place the 2.15.2 invariants live, shared by
85
+ * the encoder and the decoder so the two cannot disagree about what is legal.
86
+ *
87
+ * `fromEdge` selects the parity rule: a frame arriving FROM the edge may carry
88
+ * any id, but a frame this client WRITES must carry an odd one, because the
89
+ * edge assigns odd ids and the client never opens a stream. There is no path
90
+ * from the platform into this machine that this machine did not open.
91
+ */
92
+ export declare function validateFrameShape(type: number, streamId: number, length: number, fromEdge: boolean): void;
93
+ /** encodeHeader writes the 8-byte header: type u8 | streamId u32 | length u24. */
94
+ export declare function encodeHeader(type: FrameType, streamId: number, length: number): Uint8Array;
95
+ /** encodeFrame serializes one whole frame. */
96
+ export declare function encodeFrame(type: FrameType, streamId: number, payload?: Uint8Array): Uint8Array;
97
+ /**
98
+ * decodeHeader reads and VALIDATES the header. Every limit the declared length
99
+ * can trip is checked here — this is the "before the byte that triggers them
100
+ * can allocate" boundary.
101
+ */
102
+ export declare function decodeHeader(b: Uint8Array, fromEdge: boolean): FrameHeader;
103
+ /**
104
+ * FrameDecoder is a streaming demultiplexer over a byte stream.
105
+ *
106
+ * It NEVER sizes a buffer from a declared length before that length has passed
107
+ * decodeHeader — the accumulator only ever grows by bytes that actually
108
+ * arrived, so a frame declaring 16 MiB costs a comparison, not 16 MiB.
109
+ */
110
+ export declare class FrameDecoder {
111
+ private readonly fromEdge;
112
+ private buf;
113
+ private header;
114
+ constructor(fromEdge: boolean);
115
+ /** push appends bytes and returns every complete frame they produced. */
116
+ push(chunk: Uint8Array): Frame[];
117
+ /**
118
+ * end reports whether the stream closed mid-frame. A declared length longer
119
+ * than the bytes that arrived is a violation, and THE PARTIAL IS DROPPED —
120
+ * never returned, never logged.
121
+ */
122
+ end(): void;
123
+ }
124
+ /** encodeCredit/decodeCredit carry a u24 byte count on an END frame. */
125
+ export declare function encodeCredit(n: number): Uint8Array;
126
+ export declare function decodeCredit(b: Uint8Array): number;
@@ -0,0 +1,244 @@
1
+ /**
2
+ * The tunnel protocol's Node half.
3
+ *
4
+ * This is the SECOND implementation of one binary protocol, in a second
5
+ * language — the highest-risk contract in the product to date. Two green
6
+ * per-language suites do not prove a contract, so every limit and every
7
+ * refusal here is pinned against a SHARED CHECKED-IN FIXTURE that neither
8
+ * implementation generates (`fixtures/`), and the malformed fixture's row count
9
+ * is asserted in both languages: a row present in one runner and absent in the
10
+ * other must FAIL.
11
+ *
12
+ * A Go/Node divergence on what to REFUSE is exactly how one implementation
13
+ * becomes a bypass for the other's limits.
14
+ *
15
+ * As on the Go side, DATA IS OPAQUE. Nothing in this file reads a DATA payload,
16
+ * branches on it, or keeps it.
17
+ */
18
+ export var FrameType;
19
+ (function (FrameType) {
20
+ /** edge->client. payload: CBOR {viewerLabel, method, path, headers, upgrade?} */
21
+ FrameType[FrameType["OPEN"] = 1] = "OPEN";
22
+ /** both. payload: OPAQUE bytes. Never parsed. */
23
+ FrameType[FrameType["DATA"] = 2] = "DATA";
24
+ /** both. half-close a stream (or, with a u24 payload, a credit replenishment) */
25
+ FrameType[FrameType["END"] = 3] = "END";
26
+ /** both. payload: u8 reason */
27
+ FrameType[FrameType["RESET"] = 4] = "RESET";
28
+ /** client->edge. payload: CBOR {status, headers} */
29
+ FrameType[FrameType["HEAD"] = 5] = "HEAD";
30
+ /** both, streamId MUST be 0 */
31
+ FrameType[FrameType["PING"] = 16] = "PING";
32
+ FrameType[FrameType["PONG"] = 17] = "PONG";
33
+ /** edge->client, streamId MUST be 0 */
34
+ FrameType[FrameType["CONTROL"] = 18] = "CONTROL";
35
+ })(FrameType || (FrameType = {}));
36
+ export var ResetReason;
37
+ (function (ResetReason) {
38
+ ResetReason[ResetReason["PROTOCOL_ERROR"] = 1] = "PROTOCOL_ERROR";
39
+ ResetReason[ResetReason["STREAM_CLOSED"] = 2] = "STREAM_CLOSED";
40
+ ResetReason[ResetReason["REFUSED"] = 3] = "REFUSED";
41
+ ResetReason[ResetReason["IDLE"] = 4] = "IDLE";
42
+ ResetReason[ResetReason["SESSION_ENDED"] = 5] = "SESSION_ENDED";
43
+ ResetReason[ResetReason["UNKNOWN"] = 255] = "UNKNOWN";
44
+ })(ResetReason || (ResetReason = {}));
45
+ /**
46
+ * MAX_FRAME_LEN is checked against the HEADER, before any allocation. The
47
+ * `length` field is u24 — 16 MiB of attacker-controlled allocation per frame if
48
+ * a decoder does `Buffer.alloc(length)` before dispatch. It equals the initial
49
+ * credit window so a single frame can never exceed a stream's credit.
50
+ */
51
+ export const MAX_FRAME_LEN = 64 * 1024;
52
+ /** The ONE decode path: OPEN/HEAD CBOR control payloads. Never DATA. */
53
+ export const MAX_OPEN_PAYLOAD = 4 * 1024;
54
+ export const FRAME_HEADER_LEN = 8;
55
+ export const MAX_HEADERS = 64;
56
+ export const MAX_HEADER_BYTES = 8 * 1024;
57
+ export const MAX_PATH_LEN = 2 * 1024;
58
+ export const MAX_CBOR_DEPTH = 4;
59
+ export const MAX_CONCURRENT_STREAMS = 256;
60
+ export const INITIAL_CREDIT = 64 * 1024;
61
+ export const RELAY_BUF_SIZE = 32 * 1024;
62
+ export const IDLE_STREAM_TIMEOUT_MS = 60_000;
63
+ export const TUNNEL_PING_INTERVAL_MS = 15_000;
64
+ export const TUNNEL_PING_TIMEOUT_MS = 45_000;
65
+ /** Protocol errors. They are for the CLIENT's own log, never for a wire body. */
66
+ export class ProtocolError extends Error {
67
+ code;
68
+ fatal;
69
+ /**
70
+ * `fatal` is the 2.15.3 behavior table expressed ONCE, so the Go edge and
71
+ * this client cannot drift on which violations close the tunnel and which
72
+ * only reset a stream.
73
+ */
74
+ constructor(code, fatal) {
75
+ // NOTE: the message is the CODE and nothing else. It never carries a
76
+ // payload, a length echo, or an offset — a payload in an error string
77
+ // reaches the process log and any error-reporting integration.
78
+ super(code);
79
+ this.code = code;
80
+ this.fatal = fatal;
81
+ this.name = "ProtocolError";
82
+ }
83
+ }
84
+ /** The fatal set, matching Go's TunnelFatal exactly. */
85
+ const FATAL = new Set([
86
+ "frame_too_large",
87
+ "unknown_frame_type",
88
+ "even_stream_id",
89
+ "zero_stream_id",
90
+ "nonzero_stream_id",
91
+ "short_payload",
92
+ ]);
93
+ function fail(code) {
94
+ throw new ProtocolError(code, FATAL.has(code));
95
+ }
96
+ const KNOWN_TYPES = new Set([
97
+ FrameType.OPEN,
98
+ FrameType.DATA,
99
+ FrameType.END,
100
+ FrameType.RESET,
101
+ FrameType.HEAD,
102
+ FrameType.PING,
103
+ FrameType.PONG,
104
+ FrameType.CONTROL,
105
+ ]);
106
+ /** streamTyped frames address a STREAM and therefore require a non-zero id. */
107
+ function streamTyped(t) {
108
+ return (t === FrameType.OPEN ||
109
+ t === FrameType.DATA ||
110
+ t === FrameType.END ||
111
+ t === FrameType.RESET ||
112
+ t === FrameType.HEAD);
113
+ }
114
+ /**
115
+ * validateFrameShape is the single place the 2.15.2 invariants live, shared by
116
+ * the encoder and the decoder so the two cannot disagree about what is legal.
117
+ *
118
+ * `fromEdge` selects the parity rule: a frame arriving FROM the edge may carry
119
+ * any id, but a frame this client WRITES must carry an odd one, because the
120
+ * edge assigns odd ids and the client never opens a stream. There is no path
121
+ * from the platform into this machine that this machine did not open.
122
+ */
123
+ export function validateFrameShape(type, streamId, length, fromEdge) {
124
+ // FIRST. Nothing downstream may allocate from a length that failed here.
125
+ if (length > MAX_FRAME_LEN)
126
+ fail("frame_too_large");
127
+ if (!KNOWN_TYPES.has(type)) {
128
+ // NOT ignored. "Ignore unknown for forward-compat" is explicitly REJECTED:
129
+ // this is a closed protocol between two implementations we both own, and
130
+ // tolerance here would be a place to smuggle bytes.
131
+ fail("unknown_frame_type");
132
+ }
133
+ if (streamTyped(type)) {
134
+ if (streamId === 0)
135
+ fail("zero_stream_id");
136
+ if (!fromEdge && streamId % 2 === 0)
137
+ fail("even_stream_id");
138
+ }
139
+ else if (streamId !== 0) {
140
+ fail("nonzero_stream_id");
141
+ }
142
+ if ((type === FrameType.OPEN || type === FrameType.HEAD) && length > MAX_OPEN_PAYLOAD) {
143
+ // Refused BEFORE any CBOR decode is attempted.
144
+ fail("open_payload_too_large");
145
+ }
146
+ }
147
+ /** encodeHeader writes the 8-byte header: type u8 | streamId u32 | length u24. */
148
+ export function encodeHeader(type, streamId, length) {
149
+ if (length < 0 || length > MAX_FRAME_LEN)
150
+ fail("frame_too_large");
151
+ const h = new Uint8Array(FRAME_HEADER_LEN);
152
+ h[0] = type;
153
+ h[1] = (streamId >>> 24) & 0xff;
154
+ h[2] = (streamId >>> 16) & 0xff;
155
+ h[3] = (streamId >>> 8) & 0xff;
156
+ h[4] = streamId & 0xff;
157
+ h[5] = (length >>> 16) & 0xff;
158
+ h[6] = (length >>> 8) & 0xff;
159
+ h[7] = length & 0xff;
160
+ return h;
161
+ }
162
+ /** encodeFrame serializes one whole frame. */
163
+ export function encodeFrame(type, streamId, payload = new Uint8Array(0)) {
164
+ validateFrameShape(type, streamId, payload.length, false);
165
+ const out = new Uint8Array(FRAME_HEADER_LEN + payload.length);
166
+ out.set(encodeHeader(type, streamId, payload.length), 0);
167
+ out.set(payload, FRAME_HEADER_LEN);
168
+ return out;
169
+ }
170
+ /**
171
+ * decodeHeader reads and VALIDATES the header. Every limit the declared length
172
+ * can trip is checked here — this is the "before the byte that triggers them
173
+ * can allocate" boundary.
174
+ */
175
+ export function decodeHeader(b, fromEdge) {
176
+ if (b.length < FRAME_HEADER_LEN)
177
+ fail("short_payload");
178
+ const type = b[0];
179
+ const streamId = ((b[1] << 24) >>> 0) + (b[2] << 16) + (b[3] << 8) + b[4];
180
+ const length = (b[5] << 16) + (b[6] << 8) + b[7];
181
+ validateFrameShape(type, streamId, length, fromEdge);
182
+ return { type: type, streamId, length };
183
+ }
184
+ /**
185
+ * FrameDecoder is a streaming demultiplexer over a byte stream.
186
+ *
187
+ * It NEVER sizes a buffer from a declared length before that length has passed
188
+ * decodeHeader — the accumulator only ever grows by bytes that actually
189
+ * arrived, so a frame declaring 16 MiB costs a comparison, not 16 MiB.
190
+ */
191
+ export class FrameDecoder {
192
+ fromEdge;
193
+ buf = new Uint8Array(0);
194
+ header = null;
195
+ constructor(fromEdge) {
196
+ this.fromEdge = fromEdge;
197
+ }
198
+ /** push appends bytes and returns every complete frame they produced. */
199
+ push(chunk) {
200
+ // The accumulator is bounded by one header plus one max-size frame. It can
201
+ // never hold a declared length, only bytes that arrived.
202
+ const next = new Uint8Array(this.buf.length + chunk.length);
203
+ next.set(this.buf, 0);
204
+ next.set(chunk, this.buf.length);
205
+ this.buf = next;
206
+ const out = [];
207
+ for (;;) {
208
+ if (this.header === null) {
209
+ if (this.buf.length < FRAME_HEADER_LEN)
210
+ return out;
211
+ // Throws on any 2.15.2 violation, BEFORE the payload is read at all.
212
+ this.header = decodeHeader(this.buf, this.fromEdge);
213
+ this.buf = this.buf.subarray(FRAME_HEADER_LEN);
214
+ }
215
+ if (this.buf.length < this.header.length)
216
+ return out;
217
+ const payload = this.buf.subarray(0, this.header.length);
218
+ this.buf = this.buf.subarray(this.header.length);
219
+ out.push({ ...this.header, payload });
220
+ this.header = null;
221
+ }
222
+ }
223
+ /**
224
+ * end reports whether the stream closed mid-frame. A declared length longer
225
+ * than the bytes that arrived is a violation, and THE PARTIAL IS DROPPED —
226
+ * never returned, never logged.
227
+ */
228
+ end() {
229
+ if (this.header !== null || this.buf.length > 0) {
230
+ this.buf = new Uint8Array(0);
231
+ this.header = null;
232
+ fail("short_payload");
233
+ }
234
+ }
235
+ }
236
+ /** encodeCredit/decodeCredit carry a u24 byte count on an END frame. */
237
+ export function encodeCredit(n) {
238
+ return new Uint8Array([(n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]);
239
+ }
240
+ export function decodeCredit(b) {
241
+ if (b.length !== 3)
242
+ return 0;
243
+ return (b[0] << 16) + (b[1] << 8) + b[2];
244
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * `@vincentt-xr/harness/tunnel` — the Node half of the tunnel protocol.
3
+ *
4
+ * It is a SUBPATH EXPORT rather than part of the package root deliberately: the
5
+ * root entry is the browser-side diagnostics provider and must stay free of
6
+ * `node:` imports. Nothing under `src/client` imports anything here.
7
+ */
8
+ export { FrameDecoder, FrameType, ProtocolError, ResetReason, FRAME_HEADER_LEN, IDLE_STREAM_TIMEOUT_MS, INITIAL_CREDIT, MAX_CBOR_DEPTH, MAX_CONCURRENT_STREAMS, MAX_FRAME_LEN, MAX_HEADERS, MAX_HEADER_BYTES, MAX_OPEN_PAYLOAD, MAX_PATH_LEN, RELAY_BUF_SIZE, TUNNEL_PING_INTERVAL_MS, TUNNEL_PING_TIMEOUT_MS, decodeCredit, decodeHeader, encodeCredit, encodeFrame, encodeHeader, validateFrameShape, type Frame, type FrameHeader, type ProtocolErrorCode, } from "./frame.js";
9
+ export { UA_CLASSES, VIEWER_LABEL_RE, decodeControl, decodeHead, decodeOpen, encodeControl, encodeHead, encodeOpen, parseUAClass, parseViewerLabel, type ControlKind, type ControlPayload, type HeadPayload, type OpenPayload, type UAClass, } from "./cbor.js";
10
+ export { FOLLOW_REDIRECTS, ForwardTargetError, assertStillLoopback, normalizeRequestTarget, resolveForwardTarget, sanitizeHeaders, type ForwardTarget, type ForwardTargetErrorCode, } from "./forwardTarget.js";
11
+ export { TunnelClient, RESUME_HEADER, RESUME_TOKEN_LEN, UA_CLASS_STAMP_HEADER, VIEWER_STAMP_HEADER, parseResumeToken, type TunnelClientOptions, type TunnelState, } from "./client.js";
@@ -0,0 +1,11 @@
1
+ /**
2
+ * `@vincentt-xr/harness/tunnel` — the Node half of the tunnel protocol.
3
+ *
4
+ * It is a SUBPATH EXPORT rather than part of the package root deliberately: the
5
+ * root entry is the browser-side diagnostics provider and must stay free of
6
+ * `node:` imports. Nothing under `src/client` imports anything here.
7
+ */
8
+ export { FrameDecoder, FrameType, ProtocolError, ResetReason, FRAME_HEADER_LEN, IDLE_STREAM_TIMEOUT_MS, INITIAL_CREDIT, MAX_CBOR_DEPTH, MAX_CONCURRENT_STREAMS, MAX_FRAME_LEN, MAX_HEADERS, MAX_HEADER_BYTES, MAX_OPEN_PAYLOAD, MAX_PATH_LEN, RELAY_BUF_SIZE, TUNNEL_PING_INTERVAL_MS, TUNNEL_PING_TIMEOUT_MS, decodeCredit, decodeHeader, encodeCredit, encodeFrame, encodeHeader, validateFrameShape, } from "./frame.js";
9
+ export { UA_CLASSES, VIEWER_LABEL_RE, decodeControl, decodeHead, decodeOpen, encodeControl, encodeHead, encodeOpen, parseUAClass, parseViewerLabel, } from "./cbor.js";
10
+ export { FOLLOW_REDIRECTS, ForwardTargetError, assertStillLoopback, normalizeRequestTarget, resolveForwardTarget, sanitizeHeaders, } from "./forwardTarget.js";
11
+ export { TunnelClient, RESUME_HEADER, RESUME_TOKEN_LEN, UA_CLASS_STAMP_HEADER, VIEWER_STAMP_HEADER, parseResumeToken, } from "./client.js";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@vincentt-xr/harness",
3
- "version": "1.0.0",
4
- "description": "Vincentt AR dev-loop harness in-app diagnostics provider + wire contract",
3
+ "version": "1.2.0",
4
+ "description": "Vincentt AR dev-loop harness - in-app diagnostics provider + wire contract",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
7
7
  "sideEffects": false,
@@ -19,13 +19,25 @@
19
19
  "./events": {
20
20
  "types": "./dist/shared/events.d.ts",
21
21
  "import": "./dist/shared/events.js"
22
+ },
23
+ "./tunnel": {
24
+ "types": "./dist/tunnel/index.d.ts",
25
+ "import": "./dist/tunnel/index.js"
22
26
  }
23
27
  },
24
28
  "peerDependencies": {
25
29
  "react": "^18.2.0"
26
30
  },
27
31
  "devDependencies": {
28
- "@types/react": "^18.3.12"
32
+ "@types/react": "^18.3.12",
33
+ "@types/ws": "^8.5.13",
34
+ "@types/node": "^22.9.0",
35
+ "@types/qrcode": "^1.5.5",
36
+ "jsdom": "^25.0.1"
37
+ },
38
+ "dependencies": {
39
+ "ws": "^8.18.0",
40
+ "qrcode": "^1.5.4"
29
41
  },
30
42
  "scripts": {
31
43
  "build": "tsc -p tsconfig.build.json",