@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
@@ -0,0 +1,764 @@
1
+ /**
2
+ * The tunnel client: the creator's side of the wire.
3
+ *
4
+ * It opens ONE outbound WSS connection to the edge and multiplexes every
5
+ * viewer's request onto it. Nothing dials INTO the creator's machine; the
6
+ * client never opens a stream, and the edge rejects an even streamId from it as
7
+ * a protocol violation.
8
+ *
9
+ * What this file does NOT do, deliberately:
10
+ * - it does not parse a DATA payload (they are opaque here too);
11
+ * - it does not follow a redirect on the creator's behalf;
12
+ * - it does not trust the edge's viewerLabel or ua-class without
13
+ * re-validating both against the same regex and the same closed enum.
14
+ */
15
+ import { request as httpRequest } from "node:http";
16
+ import { connect as netConnect } from "node:net";
17
+ import { WebSocket } from "ws";
18
+ import { FrameDecoder, FrameType, MAX_FRAME_LEN, ProtocolError, ResetReason, RELAY_BUF_SIZE, INITIAL_CREDIT, TUNNEL_PING_INTERVAL_MS, TUNNEL_PING_TIMEOUT_MS, decodeCredit, encodeCredit, encodeFrame, } from "./frame.js";
19
+ import { decodeControl, decodeOpen, encodeHead, parseUAClass, parseViewerLabel, } from "./cbor.js";
20
+ import { FOLLOW_REDIRECTS, ForwardTargetError, assertStillLoopback, normalizeRequestTarget, sanitizeHeaders, } from "./forwardTarget.js";
21
+ /** RECONNECT_BACKOFF_MS is 0.5s -> 8s with jitter (2.2). */
22
+ const BACKOFF_BASE_MS = 500;
23
+ const BACKOFF_MAX_MS = 8_000;
24
+ const DIAL_HANDSHAKE_TIMEOUT_MS = 15_000;
25
+ /**
26
+ * The dial statuses no retry can fix, so the client stops rather than looping.
27
+ *
28
+ * 401 the resume token was refused (consumed, expired, or the spend window
29
+ * is shut) — `preview.resume_token_rejected` fired at the edge
30
+ * 409 another tunnel holds this preview, or the grace cap is spent
31
+ * 410 the session is gone
32
+ *
33
+ * A 503 is deliberately ABSENT: it is the edge's admission refusal
34
+ * (`ingress.go:152-156`, the 2001st session) and it is genuinely transient, so
35
+ * it keeps the backoff. Treating it as terminal would kill a healthy preview
36
+ * over a momentarily full edge.
37
+ */
38
+ const TERMINAL_DIAL_STATUS = new Set([401, 409, 410]);
39
+ /**
40
+ * VIEWER_STAMP_HEADER carries the VALIDATED label to the creator's own local
41
+ * server, so the relay can key its ring buffer by viewer.
42
+ *
43
+ * The stamp happens HERE, on the creator's side of the wire, which is the whole
44
+ * point of 2.8: the device never chooses its own label, and the value written
45
+ * has already passed parseViewerLabel. It is added AFTER sanitizeHeaders, so a
46
+ * viewer-supplied header of the same name cannot survive to shadow it.
47
+ */
48
+ export const VIEWER_STAMP_HEADER = "x-vincentt-viewer";
49
+ export const UA_CLASS_STAMP_HEADER = "x-vincentt-ua-class";
50
+ /**
51
+ * RESUME_HEADER is ONE spelling for both halves of the wire: the edge writes it
52
+ * on the 101 (`ResumeHeader` in `internal/edge/websocket.go`), and this client
53
+ * sends it back on the next dial.
54
+ *
55
+ * It is lower-case here because Node lower-cases every response header name,
56
+ * and `res.headers["X-Vincentt-Resume"]` is silently `undefined`. That mistake
57
+ * would reproduce the exact defect this constant exists to fix — a client that
58
+ * never learns its next credential — with a fully green suite, so the casing is
59
+ * asserted by a test rather than trusted.
60
+ */
61
+ export const RESUME_HEADER = "x-vincentt-resume";
62
+ /**
63
+ * SESSION_HEADER carries the sessionId hex on the RECONNECT dial only.
64
+ *
65
+ * It exists because the reconnect path carries NO GRANT
66
+ * (`D-The-reconnect-path-carries-no-grant`), and the grant is what used to carry
67
+ * the session id in its signed `sid` claim. With the grant gone the edge has no
68
+ * other way to name the row, so the id travels as its own header.
69
+ *
70
+ * It is NOT a secret — it is an ObjectID the edge immediately parses with
71
+ * `primitive.ObjectIDFromHex` before it touches anything (`ingress.go:288`) —
72
+ * but exactly one must be sent: the edge refuses duplicates on all three
73
+ * credential-bearing headers with `Header.Values()` rather than `Get`, because
74
+ * `Get` silently returns the first value and that is the bypass being closed
75
+ * (`ingress.go:120-126`).
76
+ *
77
+ * Lower-case for the same reason as RESUME_HEADER: Node normalizes header names.
78
+ */
79
+ export const SESSION_HEADER = "x-vincentt-session";
80
+ /** The Authorization header, named once so the never-send-both rule cites it. */
81
+ export const AUTHORIZATION_HEADER = "authorization";
82
+ /**
83
+ * RESUME_TOKEN_LEN is base64url(32 random bytes), unpadded. It mirrors
84
+ * `resumeTokenLen` in the Go edge so the two ends of this wire state the same
85
+ * fact rather than each carrying a magic number.
86
+ */
87
+ export const RESUME_TOKEN_LEN = 43;
88
+ const RESUME_TOKEN_RE = /^[A-Za-z0-9_-]{43}$/;
89
+ /**
90
+ * parseResumeToken accepts only the grammar the API's issuer produces.
91
+ *
92
+ * ABSENCE IS LEGITIMATE, not an error: the edge OMITS the header rather than
93
+ * emitting it empty when it has no token to hand over, and the handshake still
94
+ * succeeds. The cost is one reconnect, which is recoverable — treating it as
95
+ * fatal would turn a token problem into a dead preview.
96
+ *
97
+ * A malformed value is discarded for the same reason it is validated at the
98
+ * edge before being written: this value is concatenated into an outbound header
99
+ * on the next dial, and a CR or LF in it would not be a bad credential, it
100
+ * would be a second request.
101
+ */
102
+ export function parseResumeToken(raw) {
103
+ if (typeof raw !== "string")
104
+ return null;
105
+ return RESUME_TOKEN_RE.test(raw) ? raw : null;
106
+ }
107
+ /**
108
+ * Whether the edge will stream a body for this request. The edge pumps DATA and
109
+ * a closing END only when the request has one (`pumpRequestBody`, taken when
110
+ * ContentLength != 0), so a request without a positive content-length gets no
111
+ * END at all and must be ended locally at once, as before.
112
+ */
113
+ function declaresBody(headers) {
114
+ return declaredLength(headers) > 0;
115
+ }
116
+ /** The forwarded content-length as a whole number, or 0 when absent or unusable. */
117
+ function declaredLength(headers) {
118
+ const raw = headers.find(([k]) => k.toLowerCase() === "content-length")?.[1];
119
+ if (raw === undefined || !/^\s*\d+\s*$/.test(raw))
120
+ return 0;
121
+ const n = Number(raw.trim());
122
+ return Number.isSafeInteger(n) ? n : 0;
123
+ }
124
+ export class TunnelClient {
125
+ opts;
126
+ ws = null;
127
+ decoder = new FrameDecoder(true);
128
+ streams = new Map();
129
+ /**
130
+ * resumeToken is 32 random bytes, single-use, rotated on every attach, bound
131
+ * to the session, 5-minute TTL. It is held in memory only and NEVER logged:
132
+ * "single-use" is enforced by the API's atomic consume, and a token in a log
133
+ * is a credential in a log.
134
+ */
135
+ resumeToken = null;
136
+ attempt = 0;
137
+ closed = false;
138
+ state = "connecting";
139
+ pingTimer = null;
140
+ lastPong = Date.now();
141
+ constructor(opts) {
142
+ this.opts = opts;
143
+ }
144
+ start() {
145
+ this.closed = false;
146
+ this.open();
147
+ }
148
+ close() {
149
+ this.closed = true;
150
+ this.setState("closed");
151
+ this.stopPing();
152
+ this.ws?.close();
153
+ this.ws = null;
154
+ }
155
+ setState(s) {
156
+ if (this.state === s)
157
+ return;
158
+ this.state = s;
159
+ this.opts.onStateChange?.(s);
160
+ }
161
+ /**
162
+ * THE DIAL RULE. One boolean, two disjoint states, one credential each.
163
+ *
164
+ * first dial (resumeToken === null): Authorization: Bearer <mint grant>
165
+ * every redial (resumeToken !== null): X-Vincentt-Resume + X-Vincentt-Session
166
+ *
167
+ * This function is the WIRING of `D-Client-holds-one-credential-per-state`:
168
+ * every dial's header set is built here and nowhere else, so the never-send-both
169
+ * rule is a property of one `if/else` rather than of a convention.
170
+ *
171
+ * NEVER BOTH, and this is the rule most likely to be "helpfully" broken later.
172
+ * The edge REFUSES a request carrying a grant alongside a valid resume token
173
+ * (`ingress.go:144-148`) — it does not ignore the grant — so adding
174
+ * `Authorization` here "for robustness" would break every reconnect while
175
+ * passing every functional test in the suite except the negative that exists
176
+ * for exactly this. The reason it is a refusal rather than a preference: a
177
+ * header that is sometimes meaningful and sometimes ignored is a branch waiting
178
+ * to be written.
179
+ *
180
+ * There is no per-door judgement to get wrong because the states are disjoint:
181
+ * the grant is spent by the attach CAS the moment the first dial succeeds, and
182
+ * a resume token only ever exists after a 101 has delivered one.
183
+ */
184
+ dialHeaders() {
185
+ if (this.resumeToken === null) {
186
+ // FIRST ATTACH. The grant is a BEARER credential on the upgrade request,
187
+ // never in the URL: a capability in a query string lands in every
188
+ // intermediary's log.
189
+ return { [AUTHORIZATION_HEADER]: `Bearer ${this.opts.grant}` };
190
+ }
191
+ // RECONNECT. The resume pair, and no Authorization header at all.
192
+ return {
193
+ [RESUME_HEADER]: this.resumeToken,
194
+ [SESSION_HEADER]: this.opts.sessionId,
195
+ };
196
+ }
197
+ open() {
198
+ const headers = this.dialHeaders();
199
+ const ws = this.opts.connect
200
+ ? this.opts.connect(this.opts.edgeUrl, headers)
201
+ : new WebSocket(this.opts.edgeUrl, {
202
+ headers,
203
+ maxPayload: MAX_FRAME_LEN + 64,
204
+ // A dial the edge never answers fails into the normal redial backoff
205
+ // instead of hanging with no socket and no retry.
206
+ handshakeTimeout: DIAL_HANDSHAKE_TIMEOUT_MS,
207
+ });
208
+ this.ws = ws;
209
+ this.decoder = new FrameDecoder(true);
210
+ // `ws` emits BOTH `error` and `close` for a single failed connection, so
211
+ // the handler is bound once per socket and guarded: without the guard each
212
+ // failure schedules TWO retries, the next failure four, and the backoff
213
+ // inverts into an accelerating storm against the platform's only
214
+ // unauthenticated listener. Observed against a real edge, not theorised.
215
+ const closeOnce = () => this.onClosed(ws);
216
+ /**
217
+ * THE ROTATION. `ws` hands us the raw 101 here, which is the only place the
218
+ * edge's next resume token is available.
219
+ *
220
+ * Without this assignment the field stayed `null` forever, the request
221
+ * header above was never sent, and every reconnect presented its
222
+ * already-consumed single-use grant — so the edge refused it at grant
223
+ * verification and `/reattached` was never reached. Both repos' suites
224
+ * stayed green across that gap for the whole build, because each asserted
225
+ * its own half: the Go side that `/reattached` works when a token arrives,
226
+ * this side that the header is sent when the field is set. Nothing asserted
227
+ * that the field is ever ASSIGNED.
228
+ */
229
+ ws.on("upgrade", (res) => {
230
+ const next = parseResumeToken(res.headers[RESUME_HEADER]);
231
+ // A missing or malformed token leaves the PREVIOUS one in place rather
232
+ // than clearing it: the old token is already spent after a successful
233
+ // attach, but keeping it costs nothing and dropping a good one because a
234
+ // later handshake omitted the header would forfeit a working reconnect.
235
+ if (next !== null)
236
+ this.resumeToken = next;
237
+ });
238
+ /**
239
+ * A REFUSED RECONNECT — the edge answered with a status instead of a 101.
240
+ *
241
+ * This is a REAL SIGNAL, not routine noise. The edge emits
242
+ * `preview.resume_token_rejected` here, which QA and security made an
243
+ * alertable theft detector, and an honest client is not supposed to fire it.
244
+ * Retrying silently forever against a 401/409/410 both buries that signal
245
+ * under the client's own backoff and leaves the creator watching
246
+ * "Reconnecting…" for a preview that can never come back.
247
+ *
248
+ * The statuses are NOT collapsed, because the creator's action differs:
249
+ * 409 means another tunnel holds this preview, 410 means it is gone.
250
+ *
251
+ * REGISTERING THIS LISTENER CHANGES `ws`'s BEHAVIOUR, which is why the
252
+ * retry is scheduled here explicitly. With no `unexpected-response` handler
253
+ * `ws` emits `error` and the close path below schedules the next dial; with
254
+ * one, it emits NEITHER `error` NOR `close` and hands the response over
255
+ * instead. Adding this handler without the `closeOnce()` below therefore
256
+ * silently kills the backoff for EVERY non-101 status — including the
257
+ * transient ones — which is a worse failure than the one it fixes, and it
258
+ * was caught by asserting the dial COUNT rather than the callback.
259
+ */
260
+ ws.on("unexpected-response", (_req, res) => {
261
+ const status = res.statusCode ?? 0;
262
+ this.opts.onReconnectRefused?.(status);
263
+ // TERMINAL statuses stop the loop. A refused credential does not become
264
+ // valid by being presented again, and re-dialling a 401 is what turns
265
+ // one theft signal into an unbounded stream of them.
266
+ if (TERMINAL_DIAL_STATUS.has(status))
267
+ this.closed = true;
268
+ res.destroy?.();
269
+ // A transient status (503 admission refusal, 502) still gets the
270
+ // backoff; a terminal one lands on the `this.closed` guard inside.
271
+ closeOnce();
272
+ });
273
+ ws.on("open", () => {
274
+ this.attempt = 0;
275
+ this.lastPong = Date.now();
276
+ this.setState("live");
277
+ this.startPing();
278
+ });
279
+ ws.on("message", (data) => this.onBytes(new Uint8Array(data)));
280
+ ws.on("close", closeOnce);
281
+ ws.on("error", (err) => {
282
+ // The CODE only, never the message or the stack: an error string from a
283
+ // TLS or HTTP layer can carry a URL, and this URL is the capability.
284
+ //
285
+ // It is surfaced at all because a tunnel that cannot re-establish
286
+ // otherwise produces NOTHING — not for the creator, not for triage. That
287
+ // silence cost a full rig investigation: "the client stopped dialling"
288
+ // with no reason attached is the least actionable failure in the feature.
289
+ this.opts.onTunnelError?.(err.code ?? "tunnel_error");
290
+ closeOnce();
291
+ });
292
+ }
293
+ onClosed(ws) {
294
+ // A late event from a socket we have already replaced must not schedule a
295
+ // second retry, nor tear down the stream table of its successor.
296
+ if (this.ws !== ws)
297
+ return;
298
+ this.stopPing();
299
+ // Streams do NOT survive a reconnect: in-flight requests are RESET and the
300
+ // browser retries. That is the correct trade — an AR page reload is already
301
+ // the accepted cost of this loop.
302
+ for (const s of this.streams.values()) {
303
+ s.quiet = true;
304
+ s.abort?.();
305
+ }
306
+ this.streams.clear();
307
+ this.ws = null;
308
+ if (this.closed)
309
+ return;
310
+ this.setState("reconnecting");
311
+ // 0.5s -> 8s with jitter.
312
+ const base = Math.min(BACKOFF_BASE_MS * 2 ** this.attempt, BACKOFF_MAX_MS);
313
+ const delay = base / 2 + Math.random() * (base / 2);
314
+ this.attempt++;
315
+ setTimeout(() => {
316
+ if (!this.closed)
317
+ this.open();
318
+ }, delay);
319
+ }
320
+ startPing() {
321
+ this.stopPing();
322
+ this.pingTimer = setInterval(() => {
323
+ if (Date.now() - this.lastPong > TUNNEL_PING_TIMEOUT_MS) {
324
+ // A dead tunnel is detected BEFORE the edge's grace window opens.
325
+ this.ws?.terminate?.();
326
+ return;
327
+ }
328
+ this.send(FrameType.PING, 0);
329
+ }, TUNNEL_PING_INTERVAL_MS);
330
+ }
331
+ stopPing() {
332
+ if (this.pingTimer !== null)
333
+ clearInterval(this.pingTimer);
334
+ this.pingTimer = null;
335
+ }
336
+ send(type, streamId, payload) {
337
+ const ws = this.ws;
338
+ if (ws === null || ws.readyState !== WebSocket.OPEN)
339
+ return;
340
+ try {
341
+ ws.send(encodeFrame(type, streamId, payload));
342
+ }
343
+ catch {
344
+ // An encode refusal is our own bug, not the peer's. Drop the frame rather
345
+ // than crashing the tunnel; the stream's idle timeout collects it.
346
+ }
347
+ }
348
+ onBytes(chunk) {
349
+ let frames;
350
+ try {
351
+ frames = this.decoder.push(chunk);
352
+ }
353
+ catch (err) {
354
+ // A FATAL protocol error closes the tunnel; a stream-level one cannot
355
+ // reach here (the decoder only throws fatal ones).
356
+ if (err instanceof ProtocolError && err.fatal) {
357
+ this.ws?.close();
358
+ return;
359
+ }
360
+ this.ws?.close();
361
+ return;
362
+ }
363
+ for (const f of frames)
364
+ this.dispatch(f.type, f.streamId, f.payload);
365
+ }
366
+ dispatch(type, streamId, payload) {
367
+ switch (type) {
368
+ case FrameType.PING:
369
+ this.send(FrameType.PONG, 0);
370
+ return;
371
+ case FrameType.PONG:
372
+ this.lastPong = Date.now();
373
+ return;
374
+ case FrameType.CONTROL: {
375
+ try {
376
+ const ctrl = decodeControl(payload);
377
+ if (ctrl.kind === "viewer_gone") {
378
+ // Routed AWAY from onControl deliberately. The other three kinds
379
+ // all mean "this session is ending" and the CLI prints an ending
380
+ // screen for them; a viewer leaving means the opposite — the
381
+ // session is fine and one device went away.
382
+ this.opts.onViewerGone?.(ctrl.viewerLabel);
383
+ }
384
+ else {
385
+ this.opts.onControl?.(ctrl.kind);
386
+ }
387
+ }
388
+ catch {
389
+ // A malformed CONTROL is a stream-level fault on stream 0, which
390
+ // means it is simply ignored rather than killing a live tunnel.
391
+ }
392
+ return;
393
+ }
394
+ case FrameType.OPEN: {
395
+ let open;
396
+ try {
397
+ open = decodeOpen(payload);
398
+ }
399
+ catch {
400
+ this.send(FrameType.RESET, streamId, new Uint8Array([ResetReason.PROTOCOL_ERROR]));
401
+ return;
402
+ }
403
+ void this.serveOpen(streamId, open);
404
+ return;
405
+ }
406
+ case FrameType.DATA: {
407
+ const s = this.streams.get(streamId);
408
+ if (s === undefined) {
409
+ this.send(FrameType.RESET, streamId, new Uint8Array([ResetReason.STREAM_CLOSED]));
410
+ return;
411
+ }
412
+ // A stream that expects no body (a GET, or an edge pumping a body with no
413
+ // length) has no writer. The bytes are refused quietly and their credit
414
+ // returned: a viewer must not be able to make the creator's terminal
415
+ // report a dev server failure.
416
+ if (s.writeBody === undefined) {
417
+ this.quietReset(streamId, s, payload.length, ResetReason.STREAM_CLOSED);
418
+ return;
419
+ }
420
+ s.inbound += payload.length;
421
+ if (s.inbound > INITIAL_CREDIT) {
422
+ this.quietReset(streamId, s, 0, ResetReason.PROTOCOL_ERROR);
423
+ return;
424
+ }
425
+ // OPAQUE. Written straight through to the local server; nothing here
426
+ // reads it, branches on it, or keeps it.
427
+ try {
428
+ s.writeBody(payload);
429
+ }
430
+ catch {
431
+ // More bytes than the declared content-length; none were written.
432
+ this.quietReset(streamId, s, 0, ResetReason.PROTOCOL_ERROR);
433
+ }
434
+ return;
435
+ }
436
+ case FrameType.END: {
437
+ const s = this.streams.get(streamId);
438
+ if (s === undefined)
439
+ return;
440
+ if (payload.length === 3) {
441
+ // A credit replenishment for bytes we sent.
442
+ s.credit = Math.min(s.credit + decodeCredit(payload), MAX_FRAME_LEN);
443
+ const w = s.waiters.shift();
444
+ w?.();
445
+ return;
446
+ }
447
+ s.ended = true;
448
+ try {
449
+ s.endBody?.();
450
+ }
451
+ catch {
452
+ // Fewer bytes than the declared content-length: reset, never a hang.
453
+ this.quietReset(streamId, s, 0, ResetReason.PROTOCOL_ERROR);
454
+ }
455
+ return;
456
+ }
457
+ case FrameType.RESET: {
458
+ const s = this.streams.get(streamId);
459
+ if (s !== undefined)
460
+ s.quiet = true;
461
+ s?.abort?.();
462
+ this.streams.delete(streamId);
463
+ return;
464
+ }
465
+ }
466
+ }
467
+ /**
468
+ * End a stream on purpose: return any credit owed, RESET it, and tear the
469
+ * local request down WITHOUT the origin-error advisory, which is reserved for
470
+ * the creator's dev server actually failing.
471
+ */
472
+ quietReset(streamId, s, returnCredit, reason) {
473
+ s.quiet = true;
474
+ if (returnCredit > 0)
475
+ this.send(FrameType.END, streamId, encodeCredit(returnCredit));
476
+ this.send(FrameType.RESET, streamId, new Uint8Array([reason]));
477
+ this.streams.delete(streamId);
478
+ s.abort?.();
479
+ }
480
+ /**
481
+ * serveOpen forwards ONE viewer request to the single configured target.
482
+ *
483
+ * Every 2.15.4 rule executes here, in order: the target is the configured one
484
+ * (never from the request), the request target is normalized-then-checked,
485
+ * the viewer's routing headers are dropped, loopback is RE-CHECKED at connect,
486
+ * and no redirect is followed.
487
+ */
488
+ async serveOpen(streamId, open) {
489
+ // RE-VALIDATE the label and the class before either reaches the relay
490
+ // handshake. This is the second of three closures, and a Go-only check
491
+ // would pass on a client that trusted the edge.
492
+ const label = parseViewerLabel(open.viewerLabel);
493
+ if (label === null) {
494
+ this.send(FrameType.RESET, streamId, new Uint8Array([ResetReason.PROTOCOL_ERROR]));
495
+ return;
496
+ }
497
+ const uaClass = parseUAClass(open.headers.find(([k]) => k.toLowerCase() === "x-vincentt-ua-class")?.[1] ??
498
+ "unknown");
499
+ this.opts.onViewer?.(label, uaClass);
500
+ // A request with a body is registered BEFORE the loopback await below: the
501
+ // edge sends DATA right behind OPEN, and a frame for an unknown stream is
502
+ // answered with RESET. Bytes that arrive before the local request exists
503
+ // are held here. They are bounded by the edge's credit window, because
504
+ // credit is returned only once the local server has taken them.
505
+ const expectsBody = open.upgrade === undefined && declaresBody(open.headers);
506
+ const early = {
507
+ pending: [],
508
+ ended: false,
509
+ aborted: false,
510
+ };
511
+ const stream = {
512
+ id: streamId,
513
+ label,
514
+ credit: MAX_FRAME_LEN,
515
+ waiters: [],
516
+ ended: false,
517
+ inbound: 0,
518
+ };
519
+ if (expectsBody) {
520
+ stream.writeBody = (b) => early.pending.push(b);
521
+ stream.endBody = () => {
522
+ early.ended = true;
523
+ };
524
+ stream.abort = () => {
525
+ early.aborted = true;
526
+ };
527
+ this.streams.set(streamId, stream);
528
+ }
529
+ let path;
530
+ try {
531
+ path = normalizeRequestTarget(open.path);
532
+ await assertStillLoopback(this.opts.target);
533
+ }
534
+ catch (err) {
535
+ if (expectsBody)
536
+ this.streams.delete(streamId);
537
+ const code = err instanceof ForwardTargetError ? err.code : "invalid_target";
538
+ // The DETAIL goes to the creator's terminal, which is the only safe place
539
+ // for it and the place the person who can fix it is already looking. The
540
+ // viewer gets the edge's fixed 502.
541
+ this.opts.onOriginError?.(label, code);
542
+ this.send(FrameType.RESET, streamId, new Uint8Array([ResetReason.PROTOCOL_ERROR]));
543
+ return;
544
+ }
545
+ // Reset or torn down while the loopback check ran: nothing to open.
546
+ if (early.aborted)
547
+ return;
548
+ const headers = sanitizeHeaders(open.headers, this.opts.target);
549
+ // Stamped AFTER sanitizeHeaders so a viewer-supplied header of the same
550
+ // name cannot shadow ours. Both values are already validated: the label
551
+ // against VIEWER_LABEL_RE, the class against the closed enum of six.
552
+ headers.push([VIEWER_STAMP_HEADER, label], [UA_CLASS_STAMP_HEADER, uaClass]);
553
+ this.streams.set(streamId, stream);
554
+ if (open.upgrade !== undefined) {
555
+ // `upgrade` is CARRIED, NOT INTERPRETED, by the protocol — but the local
556
+ // hop still has to BE an upgrade, or the diagnostics WebSocket for
557
+ // /__harness never establishes and the whole diagnostics half of the
558
+ // feature is dead. The bytes stay opaque on both sides of this splice.
559
+ this.serveUpgrade(streamId, stream, open, path, headers);
560
+ return;
561
+ }
562
+ const req = httpRequest({
563
+ host: this.opts.target.address,
564
+ port: this.opts.target.port,
565
+ method: open.method,
566
+ path,
567
+ headers: Object.fromEntries(headers),
568
+ // The BROWSER follows a redirect, on the viewer's own origin. There is
569
+ // no option to change this.
570
+ ...(FOLLOW_REDIRECTS ? {} : {}),
571
+ }, (res) => {
572
+ const respHeaders = [];
573
+ for (const [k, v] of Object.entries(res.headers)) {
574
+ if (v === undefined)
575
+ continue;
576
+ for (const one of Array.isArray(v) ? v : [v])
577
+ respHeaders.push([k, one]);
578
+ }
579
+ try {
580
+ this.send(FrameType.HEAD, streamId, encodeHead({ status: res.statusCode ?? 502, headers: respHeaders }));
581
+ }
582
+ catch {
583
+ this.send(FrameType.RESET, streamId, new Uint8Array([ResetReason.PROTOCOL_ERROR]));
584
+ res.destroy();
585
+ return;
586
+ }
587
+ res.on("data", (chunk) => {
588
+ // Chunked at MAX_FRAME_LEN so one frame can never exceed the window.
589
+ for (let off = 0; off < chunk.length; off += RELAY_BUF_SIZE) {
590
+ this.send(FrameType.DATA, streamId, new Uint8Array(chunk.subarray(off, Math.min(off + RELAY_BUF_SIZE, chunk.length))));
591
+ }
592
+ });
593
+ res.on("end", () => {
594
+ this.send(FrameType.END, streamId);
595
+ this.streams.delete(streamId);
596
+ });
597
+ });
598
+ // A body that disagrees with its content-length is enforced by the counter
599
+ // below, not by Node's `strictContentLength`: that skips a request's first
600
+ // write, and on Node 22.11 it compares the (string) header with the byte
601
+ // count strictly, refusing every correct body.
602
+ stream.abort = () => req.destroy();
603
+ req.on("error", (e) => {
604
+ if (stream.quiet)
605
+ return;
606
+ // The creator's dev server is down. The code goes to THEIR terminal; the
607
+ // viewer gets a fixed 502 generated by the edge with no upstream detail.
608
+ this.opts.onOriginError?.(label, e.code ?? "origin_error");
609
+ this.send(FrameType.RESET, streamId, new Uint8Array([ResetReason.PROTOCOL_ERROR]));
610
+ this.streams.delete(streamId);
611
+ });
612
+ if (!expectsBody) {
613
+ // No writer: any DATA on this stream is answered by a quiet RESET.
614
+ req.end();
615
+ return;
616
+ }
617
+ const credit = (n) => {
618
+ stream.inbound -= n;
619
+ this.send(FrameType.END, streamId, encodeCredit(n));
620
+ };
621
+ // A body that disagrees with its content-length, long or short, ends in a
622
+ // quiet RESET: extra bytes never reach the socket (they could be smuggled
623
+ // onto a reused connection), and a short body never leaves the local server
624
+ // waiting for bytes that will not come.
625
+ const declared = declaredLength(open.headers);
626
+ let written = 0;
627
+ stream.writeBody = (b) => {
628
+ if (written + b.length > declared) {
629
+ // Thrown to the DATA handler, which resets quietly; nothing is written.
630
+ throw new Error("body exceeds content-length");
631
+ }
632
+ written += b.length;
633
+ // Credit goes back to the edge only once the local server has taken the
634
+ // bytes: at once when the write was accepted, else on drain. That is the
635
+ // backpressure: the edge cannot send more than the window we return.
636
+ if (req.write(b))
637
+ credit(b.length);
638
+ else
639
+ req.once("drain", () => credit(b.length));
640
+ };
641
+ // The body ends on the edge's half-close, never at OPEN, and only when every
642
+ // declared byte arrived. Thrown to the caller, which resets quietly.
643
+ stream.endBody = () => {
644
+ if (written !== declared)
645
+ throw new Error("body shorter than content-length");
646
+ req.end();
647
+ };
648
+ try {
649
+ for (const b of early.pending)
650
+ stream.writeBody(b);
651
+ early.pending = [];
652
+ if (early.ended)
653
+ stream.endBody();
654
+ }
655
+ catch {
656
+ this.quietReset(streamId, stream, 0, ResetReason.PROTOCOL_ERROR);
657
+ }
658
+ }
659
+ /**
660
+ * serveUpgrade splices ONE viewer's WebSocket to the creator's local server.
661
+ *
662
+ * It re-issues the handshake over a raw socket rather than using an HTTP
663
+ * client, because Node's `http` module owns the connection after a 101 and
664
+ * will not hand back a usable duplex without the same hijack this does
665
+ * explicitly. The response's status line and headers are parsed only far
666
+ * enough to build the HEAD frame; every byte after the blank line is OPAQUE
667
+ * and is forwarded without being read.
668
+ */
669
+ serveUpgrade(streamId, stream, open, path, headers) {
670
+ const sock = netConnect(this.opts.target.port, this.opts.target.address);
671
+ let handshakeDone = false;
672
+ let buffered = Buffer.alloc(0);
673
+ stream.abort = () => sock.destroy();
674
+ stream.writeBody = (b) => {
675
+ sock.write(b);
676
+ stream.inbound -= b.length;
677
+ this.send(FrameType.END, streamId, encodeCredit(b.length));
678
+ };
679
+ sock.on("connect", () => {
680
+ const lines = [`${open.method} ${path} HTTP/1.1`];
681
+ for (const [k, v] of headers)
682
+ lines.push(`${k}: ${v}`);
683
+ // The hop-by-hop pair sanitizeHeaders dropped has to be reinstated here,
684
+ // because on THIS hop the upgrade is real rather than relayed.
685
+ lines.push(`Upgrade: ${open.upgrade ?? "websocket"}`, "Connection: Upgrade", "", "");
686
+ sock.write(lines.join("\r\n"));
687
+ });
688
+ sock.on("data", (chunk) => {
689
+ if (handshakeDone) {
690
+ for (let off = 0; off < chunk.length; off += RELAY_BUF_SIZE) {
691
+ this.send(FrameType.DATA, streamId, new Uint8Array(chunk.subarray(off, Math.min(off + RELAY_BUF_SIZE, chunk.length))));
692
+ }
693
+ return;
694
+ }
695
+ buffered = Buffer.concat([buffered, chunk]);
696
+ const end = buffered.indexOf("\r\n\r\n");
697
+ if (end === -1) {
698
+ // A response head larger than one control payload is not a handshake
699
+ // this client will complete; refusing bounds the buffer.
700
+ if (buffered.length > MAX_FRAME_LEN) {
701
+ this.send(FrameType.RESET, streamId, new Uint8Array([ResetReason.PROTOCOL_ERROR]));
702
+ sock.destroy();
703
+ }
704
+ return;
705
+ }
706
+ const head = parseResponseHead(buffered.subarray(0, end).toString("latin1"));
707
+ if (head === null) {
708
+ this.send(FrameType.RESET, streamId, new Uint8Array([ResetReason.PROTOCOL_ERROR]));
709
+ sock.destroy();
710
+ return;
711
+ }
712
+ handshakeDone = true;
713
+ try {
714
+ this.send(FrameType.HEAD, streamId, encodeHead(head));
715
+ }
716
+ catch {
717
+ this.send(FrameType.RESET, streamId, new Uint8Array([ResetReason.PROTOCOL_ERROR]));
718
+ sock.destroy();
719
+ return;
720
+ }
721
+ const rest = buffered.subarray(end + 4);
722
+ buffered = Buffer.alloc(0);
723
+ for (let off = 0; off < rest.length; off += RELAY_BUF_SIZE) {
724
+ this.send(FrameType.DATA, streamId, new Uint8Array(rest.subarray(off, Math.min(off + RELAY_BUF_SIZE, rest.length))));
725
+ }
726
+ });
727
+ sock.on("error", (e) => {
728
+ if (stream.quiet)
729
+ return;
730
+ this.opts.onOriginError?.(stream.label, e.code ?? "origin_error");
731
+ this.send(FrameType.RESET, streamId, new Uint8Array([ResetReason.PROTOCOL_ERROR]));
732
+ this.streams.delete(streamId);
733
+ });
734
+ sock.on("close", () => {
735
+ this.send(FrameType.END, streamId);
736
+ this.streams.delete(streamId);
737
+ });
738
+ }
739
+ }
740
+ /**
741
+ * parseResponseHead reads a status line and headers. It returns null rather
742
+ * than throwing on anything it does not recognise, because the caller's only
743
+ * response is a RESET either way and an exception here would carry the
744
+ * creator's server's bytes into a stack trace.
745
+ */
746
+ function parseResponseHead(raw) {
747
+ const lines = raw.split("\r\n");
748
+ const statusLine = lines[0] ?? "";
749
+ const m = /^HTTP\/1\.[01] (\d{3})/.exec(statusLine);
750
+ if (m === null)
751
+ return null;
752
+ const status = Number(m[1]);
753
+ if (status < 100 || status > 599)
754
+ return null;
755
+ const headers = [];
756
+ for (const line of lines.slice(1)) {
757
+ const i = line.indexOf(":");
758
+ if (i <= 0)
759
+ continue;
760
+ headers.push([line.slice(0, i).trim(), line.slice(i + 1).trim()]);
761
+ }
762
+ return { status, headers };
763
+ }
764
+ export { encodeCredit };