@vincentt-xr/cli 0.20.0 → 0.22.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 +30 -0
- package/dist/cli/index.js +86 -12
- package/dist/domain/api.js +16 -12
- package/dist/domain/command.js +9 -0
- package/dist/dx/command.d.ts +74 -18
- package/dist/dx/command.js +140 -86
- package/dist/dx/consent.d.ts +25 -6
- package/dist/dx/consent.js +64 -35
- package/dist/dx/enablement.d.ts +63 -11
- package/dist/dx/enablement.js +93 -21
- package/dist/dx/report.d.ts +4 -24
- package/dist/dx/report.js +32 -25
- package/dist/dx/reportIo.js +6 -4
- package/dist/dx/send.js +6 -4
- package/dist/lib/backend.js +5 -10
- package/dist/lib/exitCodes.d.ts +6 -0
- package/dist/lib/exitCodes.js +6 -0
- package/dist/lib/terminalText.d.ts +6 -0
- package/dist/lib/terminalText.js +10 -0
- package/dist/lib/version.d.ts +28 -2
- package/dist/lib/version.js +30 -3
- package/dist/lib/whoami.js +3 -0
- package/dist/library/assetsCommand.js +4 -0
- package/dist/library/client.js +6 -8
- package/dist/login/authBackend.js +18 -15
- package/dist/login/loginCommand.js +31 -4
- package/dist/login/pkceLogin.d.ts +10 -0
- package/dist/login/pkceLogin.js +24 -1
- package/dist/platform/releaseFetch.js +36 -69
- package/dist/preview/api.js +9 -12
- package/dist/preview/command.d.ts +3 -2
- package/dist/preview/command.js +10 -5
- package/dist/preview/copy.d.ts +13 -1
- package/dist/preview/copy.js +31 -3
- package/dist/preview/proxy.js +5 -39
- package/dist/preview/runner.d.ts +31 -9
- package/dist/preview/runner.js +43 -15
- package/dist/publish/api.js +14 -14
- package/dist/publish/command.d.ts +3 -0
- package/dist/publish/command.js +74 -7
- package/dist/publish/copy.d.ts +15 -0
- package/dist/publish/copy.js +109 -19
- package/dist/publish/keyScan.d.ts +25 -0
- package/dist/publish/keyScan.js +88 -0
- package/dist/publish/manifest.d.ts +60 -19
- package/dist/publish/manifest.js +290 -71
- package/dist/publish/upload.d.ts +18 -0
- package/dist/publish/upload.js +38 -0
- package/dist/publish/weight.d.ts +23 -0
- package/dist/publish/weight.js +113 -0
- package/dist/shared/config.d.ts +6 -6
- package/dist/shared/credentialNames.d.ts +17 -0
- package/dist/shared/credentialNames.js +72 -0
- package/dist/shared/http.d.ts +34 -0
- package/dist/shared/http.js +98 -2
- package/dist/shared/upgradeError.d.ts +62 -0
- package/dist/shared/upgradeError.js +215 -0
- package/dist/tunnel/caps.d.ts +19 -0
- package/dist/tunnel/caps.js +52 -0
- package/dist/tunnel/cbor.d.ts +39 -12
- package/dist/tunnel/cbor.js +84 -10
- package/dist/tunnel/client.d.ts +56 -2
- package/dist/tunnel/client.js +160 -44
- package/dist/tunnel/frame.d.ts +1 -1
- package/dist/tunnel/frame.js +8 -4
- package/dist/tunnel/index.d.ts +3 -2
- package/dist/tunnel/index.js +3 -2
- package/package.json +4 -3
package/dist/tunnel/client.js
CHANGED
|
@@ -16,7 +16,9 @@ import { request as httpRequest } from "node:http";
|
|
|
16
16
|
import { connect as netConnect } from "node:net";
|
|
17
17
|
import { WebSocket } from "ws";
|
|
18
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";
|
|
19
|
+
import { BASE_CAPS, decodeControl, decodeOpen, encodeHead, parseUAClass, parseViewerLabel, } from "./cbor.js";
|
|
20
|
+
import { CAPS_HEADER, OFFERED_CAPS_VALUE, parseAgreedCaps } from "./caps.js";
|
|
21
|
+
import { CLIENT_HEADER, clientHeaderValue } from "../shared/clientHeader.js";
|
|
20
22
|
import { FOLLOW_REDIRECTS, ForwardTargetError, assertStillLoopback, normalizeRequestTarget, sanitizeHeaders, } from "./forwardTarget.js";
|
|
21
23
|
/** RECONNECT_BACKOFF_MS is 0.5s -> 8s with jitter (2.2). */
|
|
22
24
|
const BACKOFF_BASE_MS = 500;
|
|
@@ -79,6 +81,19 @@ export const RESUME_HEADER = "x-vincentt-resume";
|
|
|
79
81
|
export const SESSION_HEADER = "x-vincentt-session";
|
|
80
82
|
/** The Authorization header, named once so the never-send-both rule cites it. */
|
|
81
83
|
export const AUTHORIZATION_HEADER = "authorization";
|
|
84
|
+
/**
|
|
85
|
+
* The version refusal on a dial: `410` with these headers (f3 §2.2.3). 410 is
|
|
86
|
+
* in a released client's stop set; only a client that reads the reason header
|
|
87
|
+
* knows it means "upgrade".
|
|
88
|
+
*/
|
|
89
|
+
export const REFUSAL_HEADER = "x-vincentt-refusal";
|
|
90
|
+
export const CLI_MINIMUM_HEADER = "x-vincentt-cli-minimum";
|
|
91
|
+
export const CLI_DECLARED_HEADER = "x-vincentt-cli-declared";
|
|
92
|
+
export const UPGRADE_REQUIRED_REFUSAL = "cli_upgrade_required";
|
|
93
|
+
/** One header value, or undefined when absent or not a single string. */
|
|
94
|
+
function single(v) {
|
|
95
|
+
return typeof v === "string" ? v : undefined;
|
|
96
|
+
}
|
|
82
97
|
/**
|
|
83
98
|
* RESUME_TOKEN_LEN is base64url(32 random bytes), unpadded. It mirrors
|
|
84
99
|
* `resumeTokenLen` in the Go edge so the two ends of this wire state the same
|
|
@@ -104,22 +119,40 @@ export function parseResumeToken(raw) {
|
|
|
104
119
|
return null;
|
|
105
120
|
return RESUME_TOKEN_RE.test(raw) ? raw : null;
|
|
106
121
|
}
|
|
122
|
+
/** The forwarded content-length, parsed as digits only. */
|
|
123
|
+
function declaredLength(headers) {
|
|
124
|
+
const values = headers
|
|
125
|
+
.filter(([k]) => k.toLowerCase() === "content-length")
|
|
126
|
+
.map(([, v]) => v);
|
|
127
|
+
if (values.length === 0)
|
|
128
|
+
return { kind: "none" };
|
|
129
|
+
if (values.length > 1 || !/^\d{1,15}$/.test(values[0]))
|
|
130
|
+
return { kind: "invalid" };
|
|
131
|
+
return { kind: "length", n: Number(values[0]) };
|
|
132
|
+
}
|
|
107
133
|
/**
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
134
|
+
* With `open-body` agreed, the edge's `body` decides and a content-length must
|
|
135
|
+
* agree with it (the edge writes at most one, positive). Without it, the base
|
|
136
|
+
* rule: a positive content-length streams, anything else ends at OPEN. An
|
|
137
|
+
* upgrade has no body whatever it declares.
|
|
112
138
|
*/
|
|
113
|
-
function
|
|
114
|
-
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
if (
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
139
|
+
export function bodyPlan(open, caps) {
|
|
140
|
+
if (open.upgrade !== undefined)
|
|
141
|
+
return { kind: "none" };
|
|
142
|
+
const cl = declaredLength(open.headers);
|
|
143
|
+
if (cl.kind === "invalid")
|
|
144
|
+
return { kind: "reset" };
|
|
145
|
+
if (caps.has("open-body")) {
|
|
146
|
+
if (open.body === true) {
|
|
147
|
+
if (cl.kind === "none")
|
|
148
|
+
return { kind: "chunked" };
|
|
149
|
+
return cl.n > 0 ? { kind: "declared", length: cl.n } : { kind: "reset" };
|
|
150
|
+
}
|
|
151
|
+
return cl.kind === "length" && cl.n > 0 ? { kind: "reset" } : { kind: "none" };
|
|
152
|
+
}
|
|
153
|
+
return cl.kind === "length" && cl.n > 0
|
|
154
|
+
? { kind: "declared", length: cl.n }
|
|
155
|
+
: { kind: "none" };
|
|
123
156
|
}
|
|
124
157
|
export class TunnelClient {
|
|
125
158
|
opts;
|
|
@@ -133,6 +166,8 @@ export class TunnelClient {
|
|
|
133
166
|
* is a credential in a log.
|
|
134
167
|
*/
|
|
135
168
|
resumeToken = null;
|
|
169
|
+
/** This connection's agreed set. Fixed at its 101, reset on every dial. */
|
|
170
|
+
caps = BASE_CAPS;
|
|
136
171
|
attempt = 0;
|
|
137
172
|
closed = false;
|
|
138
173
|
state = "connecting";
|
|
@@ -182,14 +217,21 @@ export class TunnelClient {
|
|
|
182
217
|
* a resume token only ever exists after a 101 has delivered one.
|
|
183
218
|
*/
|
|
184
219
|
dialHeaders() {
|
|
220
|
+
// The client tag and the caps offer ride BOTH branches. Neither is a
|
|
221
|
+
// credential, so the never-send-both rule below is untouched by them.
|
|
222
|
+
const offer = {
|
|
223
|
+
[CLIENT_HEADER]: clientHeaderValue(),
|
|
224
|
+
[CAPS_HEADER]: OFFERED_CAPS_VALUE,
|
|
225
|
+
};
|
|
185
226
|
if (this.resumeToken === null) {
|
|
186
227
|
// FIRST ATTACH. The grant is a BEARER credential on the upgrade request,
|
|
187
228
|
// never in the URL: a capability in a query string lands in every
|
|
188
229
|
// intermediary's log.
|
|
189
|
-
return { [AUTHORIZATION_HEADER]: `Bearer ${this.opts.grant}` };
|
|
230
|
+
return { ...offer, [AUTHORIZATION_HEADER]: `Bearer ${this.opts.grant}` };
|
|
190
231
|
}
|
|
191
232
|
// RECONNECT. The resume pair, and no Authorization header at all.
|
|
192
233
|
return {
|
|
234
|
+
...offer,
|
|
193
235
|
[RESUME_HEADER]: this.resumeToken,
|
|
194
236
|
[SESSION_HEADER]: this.opts.sessionId,
|
|
195
237
|
};
|
|
@@ -207,6 +249,8 @@ export class TunnelClient {
|
|
|
207
249
|
});
|
|
208
250
|
this.ws = ws;
|
|
209
251
|
this.decoder = new FrameDecoder(true);
|
|
252
|
+
// Until a 101 says otherwise, this connection speaks the base protocol.
|
|
253
|
+
this.caps = BASE_CAPS;
|
|
210
254
|
// `ws` emits BOTH `error` and `close` for a single failed connection, so
|
|
211
255
|
// the handler is bound once per socket and guarded: without the guard each
|
|
212
256
|
// failure schedules TWO retries, the next failure four, and the backoff
|
|
@@ -234,6 +278,16 @@ export class TunnelClient {
|
|
|
234
278
|
// later handshake omitted the header would forfeit a working reconnect.
|
|
235
279
|
if (next !== null)
|
|
236
280
|
this.resumeToken = next;
|
|
281
|
+
// The agreed set fixes this connection's grammar. Absent is the base
|
|
282
|
+
// protocol (an edge that predates negotiation); a malformed answer, or
|
|
283
|
+
// one naming a token this client did not offer, is the edge
|
|
284
|
+
// misbehaving and ends the preview.
|
|
285
|
+
const agreed = parseAgreedCaps(res.headers[CAPS_HEADER], res.rawHeaders);
|
|
286
|
+
if (agreed === null) {
|
|
287
|
+
this.fatalViolation(ws);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
this.caps = agreed;
|
|
237
291
|
});
|
|
238
292
|
/**
|
|
239
293
|
* A REFUSED RECONNECT — the edge answered with a status instead of a 101.
|
|
@@ -259,6 +313,16 @@ export class TunnelClient {
|
|
|
259
313
|
*/
|
|
260
314
|
ws.on("unexpected-response", (_req, res) => {
|
|
261
315
|
const status = res.statusCode ?? 0;
|
|
316
|
+
// The version refusal: final, one dial, no backoff. It is reported
|
|
317
|
+
// instead of the generic refused-reconnect, whose 410 means "gone".
|
|
318
|
+
if (status === 410 &&
|
|
319
|
+
single(res.headers?.[REFUSAL_HEADER]) === UPGRADE_REQUIRED_REFUSAL) {
|
|
320
|
+
this.closed = true;
|
|
321
|
+
res.destroy?.();
|
|
322
|
+
this.opts.onRefused?.("upgrade_required", single(res.headers?.[CLI_MINIMUM_HEADER]), single(res.headers?.[CLI_DECLARED_HEADER]));
|
|
323
|
+
closeOnce();
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
262
326
|
this.opts.onReconnectRefused?.(status);
|
|
263
327
|
// TERMINAL statuses stop the loop. A refused credential does not become
|
|
264
328
|
// valid by being presented again, and re-dialling a 401 is what turns
|
|
@@ -317,6 +381,18 @@ export class TunnelClient {
|
|
|
317
381
|
this.open();
|
|
318
382
|
}, delay);
|
|
319
383
|
}
|
|
384
|
+
/**
|
|
385
|
+
* End the preview for a violation the edge alone could cause. A redial reaches
|
|
386
|
+
* the same edge and gets the same answer: before this was terminal, a
|
|
387
|
+
* persistently malformed 101 redialled every 8 seconds indefinitely.
|
|
388
|
+
*/
|
|
389
|
+
fatalViolation(ws) {
|
|
390
|
+
if (ws === null || this.ws !== ws || this.closed)
|
|
391
|
+
return;
|
|
392
|
+
this.closed = true;
|
|
393
|
+
this.opts.onProtocolViolation?.();
|
|
394
|
+
ws.close();
|
|
395
|
+
}
|
|
320
396
|
startPing() {
|
|
321
397
|
this.stopPing();
|
|
322
398
|
this.pingTimer = setInterval(() => {
|
|
@@ -350,18 +426,19 @@ export class TunnelClient {
|
|
|
350
426
|
try {
|
|
351
427
|
frames = this.decoder.push(chunk);
|
|
352
428
|
}
|
|
353
|
-
catch
|
|
354
|
-
//
|
|
355
|
-
// reach here (the decoder only throws fatal ones).
|
|
356
|
-
if (err instanceof ProtocolError && err.fatal) {
|
|
357
|
-
this.ws?.close();
|
|
358
|
-
return;
|
|
359
|
-
}
|
|
429
|
+
catch {
|
|
430
|
+
// The frame decoder throws only fatal errors: the tunnel closes.
|
|
360
431
|
this.ws?.close();
|
|
361
432
|
return;
|
|
362
433
|
}
|
|
363
|
-
|
|
434
|
+
const ws = this.ws;
|
|
435
|
+
for (const f of frames) {
|
|
436
|
+
// A fatal frame earlier in this chunk closed the connection; nothing
|
|
437
|
+
// after it is acted on.
|
|
438
|
+
if (this.ws !== ws || ws === null || ws.readyState !== WebSocket.OPEN)
|
|
439
|
+
return;
|
|
364
440
|
this.dispatch(f.type, f.streamId, f.payload);
|
|
441
|
+
}
|
|
365
442
|
}
|
|
366
443
|
dispatch(type, streamId, payload) {
|
|
367
444
|
switch (type) {
|
|
@@ -372,31 +449,41 @@ export class TunnelClient {
|
|
|
372
449
|
this.lastPong = Date.now();
|
|
373
450
|
return;
|
|
374
451
|
case FrameType.CONTROL: {
|
|
452
|
+
let ctrl;
|
|
375
453
|
try {
|
|
376
|
-
|
|
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
|
-
}
|
|
454
|
+
ctrl = decodeControl(payload, this.caps);
|
|
387
455
|
}
|
|
388
456
|
catch {
|
|
389
|
-
// A
|
|
390
|
-
//
|
|
457
|
+
// A CONTROL this connection cannot read (malformed, unknown, or a
|
|
458
|
+
// kind it did not agree) is fatal: only the edge writes stream 0.
|
|
459
|
+
this.fatalViolation(this.ws);
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
if (ctrl.kind === "viewer_gone") {
|
|
463
|
+
// Routed AWAY from onControl deliberately. The other kinds all mean
|
|
464
|
+
// "this session is ending" and the CLI prints an ending screen for
|
|
465
|
+
// them; a viewer leaving means the opposite — the session is fine
|
|
466
|
+
// and one device went away.
|
|
467
|
+
this.opts.onViewerGone?.(ctrl.viewerLabel);
|
|
468
|
+
return;
|
|
391
469
|
}
|
|
470
|
+
// The edge drained this session and is about to close the socket. The
|
|
471
|
+
// session will not come back, so no redial may follow that close.
|
|
472
|
+
if (ctrl.kind === "authz_expired")
|
|
473
|
+
this.closed = true;
|
|
474
|
+
this.opts.onControl?.(ctrl.kind);
|
|
392
475
|
return;
|
|
393
476
|
}
|
|
394
477
|
case FrameType.OPEN: {
|
|
395
478
|
let open;
|
|
396
479
|
try {
|
|
397
|
-
open = decodeOpen(payload);
|
|
480
|
+
open = decodeOpen(payload, this.caps);
|
|
398
481
|
}
|
|
399
|
-
catch {
|
|
482
|
+
catch (err) {
|
|
483
|
+
if (err instanceof ProtocolError && err.fatal) {
|
|
484
|
+
this.fatalViolation(this.ws);
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
400
487
|
this.send(FrameType.RESET, streamId, new Uint8Array([ResetReason.PROTOCOL_ERROR]));
|
|
401
488
|
return;
|
|
402
489
|
}
|
|
@@ -435,6 +522,17 @@ export class TunnelClient {
|
|
|
435
522
|
}
|
|
436
523
|
case FrameType.END: {
|
|
437
524
|
const s = this.streams.get(streamId);
|
|
525
|
+
// An END is bare (half-close) or a u24 credit grant. Any other length
|
|
526
|
+
// resets that stream; the tunnel survives.
|
|
527
|
+
if (payload.length !== 0 && payload.length !== 3) {
|
|
528
|
+
if (s !== undefined) {
|
|
529
|
+
this.quietReset(streamId, s, 0, ResetReason.PROTOCOL_ERROR);
|
|
530
|
+
}
|
|
531
|
+
else {
|
|
532
|
+
this.send(FrameType.RESET, streamId, new Uint8Array([ResetReason.PROTOCOL_ERROR]));
|
|
533
|
+
}
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
438
536
|
if (s === undefined)
|
|
439
537
|
return;
|
|
440
538
|
if (payload.length === 3) {
|
|
@@ -497,12 +595,19 @@ export class TunnelClient {
|
|
|
497
595
|
const uaClass = parseUAClass(open.headers.find(([k]) => k.toLowerCase() === "x-vincentt-ua-class")?.[1] ??
|
|
498
596
|
"unknown");
|
|
499
597
|
this.opts.onViewer?.(label, uaClass);
|
|
598
|
+
// A content-length that disagrees with what the edge said is viewer-made,
|
|
599
|
+
// so it resets this stream only. No local request is opened for it.
|
|
600
|
+
const plan = bodyPlan(open, this.caps);
|
|
601
|
+
if (plan.kind === "reset") {
|
|
602
|
+
this.send(FrameType.RESET, streamId, new Uint8Array([ResetReason.PROTOCOL_ERROR]));
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
500
605
|
// A request with a body is registered BEFORE the loopback await below: the
|
|
501
606
|
// edge sends DATA right behind OPEN, and a frame for an unknown stream is
|
|
502
607
|
// answered with RESET. Bytes that arrive before the local request exists
|
|
503
608
|
// are held here. They are bounded by the edge's credit window, because
|
|
504
609
|
// credit is returned only once the local server has taken them.
|
|
505
|
-
const expectsBody =
|
|
610
|
+
const expectsBody = plan.kind !== "none";
|
|
506
611
|
const early = {
|
|
507
612
|
pending: [],
|
|
508
613
|
ended: false,
|
|
@@ -545,11 +650,18 @@ export class TunnelClient {
|
|
|
545
650
|
// Reset or torn down while the loopback check ran: nothing to open.
|
|
546
651
|
if (early.aborted)
|
|
547
652
|
return;
|
|
653
|
+
// sanitizeHeaders drops every viewer `x-vincentt-*` and `transfer-encoding`
|
|
654
|
+
// before anything of ours is stamped.
|
|
548
655
|
const headers = sanitizeHeaders(open.headers, this.opts.target);
|
|
549
656
|
// Stamped AFTER sanitizeHeaders so a viewer-supplied header of the same
|
|
550
657
|
// name cannot shadow ours. Both values are already validated: the label
|
|
551
658
|
// against VIEWER_LABEL_RE, the class against the closed enum of six.
|
|
552
659
|
headers.push([VIEWER_STAMP_HEADER, label], [UA_CLASS_STAMP_HEADER, uaClass]);
|
|
660
|
+
// Explicit, whatever the method: Node frames no GET, HEAD, DELETE or
|
|
661
|
+
// OPTIONS body on its own, and unframed bytes on the loopback hop would be
|
|
662
|
+
// parsed as a second request that bypassed every rule above.
|
|
663
|
+
if (plan.kind === "chunked")
|
|
664
|
+
headers.push(["transfer-encoding", "chunked"]);
|
|
553
665
|
this.streams.set(streamId, stream);
|
|
554
666
|
if (open.upgrade !== undefined) {
|
|
555
667
|
// `upgrade` is CARRIED, NOT INTERPRETED, by the protocol — but the local
|
|
@@ -565,6 +677,9 @@ export class TunnelClient {
|
|
|
565
677
|
method: open.method,
|
|
566
678
|
path,
|
|
567
679
|
headers: Object.fromEntries(headers),
|
|
680
|
+
// One loopback connection per stream: no connection, and so no
|
|
681
|
+
// leftover bytes, outlives the stream that opened it.
|
|
682
|
+
agent: false,
|
|
568
683
|
// The BROWSER follows a redirect, on the viewer's own origin. There is
|
|
569
684
|
// no option to change this.
|
|
570
685
|
...(FOLLOW_REDIRECTS ? {} : {}),
|
|
@@ -621,11 +736,11 @@ export class TunnelClient {
|
|
|
621
736
|
// A body that disagrees with its content-length, long or short, ends in a
|
|
622
737
|
// quiet RESET: extra bytes never reach the socket (they could be smuggled
|
|
623
738
|
// onto a reused connection), and a short body never leaves the local server
|
|
624
|
-
// waiting for bytes that will not come.
|
|
625
|
-
const declared =
|
|
739
|
+
// waiting for bytes that will not come. A chunked body has no count to hold.
|
|
740
|
+
const declared = plan.kind === "declared" ? plan.length : undefined;
|
|
626
741
|
let written = 0;
|
|
627
742
|
stream.writeBody = (b) => {
|
|
628
|
-
if (written + b.length > declared) {
|
|
743
|
+
if (declared !== undefined && written + b.length > declared) {
|
|
629
744
|
// Thrown to the DATA handler, which resets quietly; nothing is written.
|
|
630
745
|
throw new Error("body exceeds content-length");
|
|
631
746
|
}
|
|
@@ -641,8 +756,9 @@ export class TunnelClient {
|
|
|
641
756
|
// The body ends on the edge's half-close, never at OPEN, and only when every
|
|
642
757
|
// declared byte arrived. Thrown to the caller, which resets quietly.
|
|
643
758
|
stream.endBody = () => {
|
|
644
|
-
if (written !== declared)
|
|
759
|
+
if (declared !== undefined && written !== declared) {
|
|
645
760
|
throw new Error("body shorter than content-length");
|
|
761
|
+
}
|
|
646
762
|
req.end();
|
|
647
763
|
};
|
|
648
764
|
try {
|
package/dist/tunnel/frame.d.ts
CHANGED
|
@@ -71,7 +71,7 @@ export declare class ProtocolError extends Error {
|
|
|
71
71
|
*/
|
|
72
72
|
constructor(code: ProtocolErrorCode, fatal: boolean);
|
|
73
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";
|
|
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" | "not_negotiated" | "open_body_missing" | "open_body_with_upgrade";
|
|
75
75
|
export interface FrameHeader {
|
|
76
76
|
type: FrameType;
|
|
77
77
|
streamId: number;
|
package/dist/tunnel/frame.js
CHANGED
|
@@ -81,7 +81,7 @@ export class ProtocolError extends Error {
|
|
|
81
81
|
this.name = "ProtocolError";
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
|
-
/**
|
|
84
|
+
/** Every code that closes the tunnel rather than one stream. */
|
|
85
85
|
const FATAL = new Set([
|
|
86
86
|
"frame_too_large",
|
|
87
87
|
"unknown_frame_type",
|
|
@@ -89,6 +89,9 @@ const FATAL = new Set([
|
|
|
89
89
|
"zero_stream_id",
|
|
90
90
|
"nonzero_stream_id",
|
|
91
91
|
"short_payload",
|
|
92
|
+
"not_negotiated",
|
|
93
|
+
"open_body_missing",
|
|
94
|
+
"open_body_with_upgrade",
|
|
92
95
|
]);
|
|
93
96
|
function fail(code) {
|
|
94
97
|
throw new ProtocolError(code, FATAL.has(code));
|
|
@@ -125,9 +128,10 @@ export function validateFrameShape(type, streamId, length, fromEdge) {
|
|
|
125
128
|
if (length > MAX_FRAME_LEN)
|
|
126
129
|
fail("frame_too_large");
|
|
127
130
|
if (!KNOWN_TYPES.has(type)) {
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
+
// A frame type outside the set AGREED FOR THIS CONNECTION closes it. No
|
|
132
|
+
// negotiated element is a frame type, so the agreed set of types is the base
|
|
133
|
+
// set on every connection. Negotiation narrows what each side may send; it
|
|
134
|
+
// never widens what a receiver accepts, so an unknown type is never ignored.
|
|
131
135
|
fail("unknown_frame_type");
|
|
132
136
|
}
|
|
133
137
|
if (streamTyped(type)) {
|
package/dist/tunnel/index.d.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* fix ships as a CLI release.
|
|
5
5
|
*/
|
|
6
6
|
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";
|
|
7
|
-
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";
|
|
7
|
+
export { BASE_CAPS, TUNNEL_CAPS, UA_CLASSES, VIEWER_LABEL_RE, decodeControl, decodeHead, decodeOpen, encodeControl, encodeHead, encodeOpen, parseUAClass, parseViewerLabel, type CapSet, type ControlKind, type ControlPayload, type HeadPayload, type OpenPayload, type TunnelCap, type UAClass, } from "./cbor.js";
|
|
8
|
+
export { CAPS_HEADER, OFFERED_CAPS, OFFERED_CAPS_VALUE, parseAgreedCaps } from "./caps.js";
|
|
8
9
|
export { FOLLOW_REDIRECTS, ForwardTargetError, assertStillLoopback, normalizeRequestTarget, resolveForwardTarget, sanitizeHeaders, type ForwardTarget, type ForwardTargetErrorCode, } from "./forwardTarget.js";
|
|
9
|
-
export { TunnelClient, RESUME_HEADER, RESUME_TOKEN_LEN, UA_CLASS_STAMP_HEADER, VIEWER_STAMP_HEADER, parseResumeToken, type TunnelClientOptions, type TunnelState, } from "./client.js";
|
|
10
|
+
export { TunnelClient, CLI_DECLARED_HEADER, CLI_MINIMUM_HEADER, REFUSAL_HEADER, RESUME_HEADER, RESUME_TOKEN_LEN, UPGRADE_REQUIRED_REFUSAL, bodyPlan, type BodyPlan, UA_CLASS_STAMP_HEADER, VIEWER_STAMP_HEADER, parseResumeToken, type TunnelClientOptions, type TunnelState, } from "./client.js";
|
package/dist/tunnel/index.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* fix ships as a CLI release.
|
|
5
5
|
*/
|
|
6
6
|
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";
|
|
7
|
-
export { UA_CLASSES, VIEWER_LABEL_RE, decodeControl, decodeHead, decodeOpen, encodeControl, encodeHead, encodeOpen, parseUAClass, parseViewerLabel, } from "./cbor.js";
|
|
7
|
+
export { BASE_CAPS, TUNNEL_CAPS, UA_CLASSES, VIEWER_LABEL_RE, decodeControl, decodeHead, decodeOpen, encodeControl, encodeHead, encodeOpen, parseUAClass, parseViewerLabel, } from "./cbor.js";
|
|
8
|
+
export { CAPS_HEADER, OFFERED_CAPS, OFFERED_CAPS_VALUE, parseAgreedCaps } from "./caps.js";
|
|
8
9
|
export { FOLLOW_REDIRECTS, ForwardTargetError, assertStillLoopback, normalizeRequestTarget, resolveForwardTarget, sanitizeHeaders, } from "./forwardTarget.js";
|
|
9
|
-
export { TunnelClient, RESUME_HEADER, RESUME_TOKEN_LEN, UA_CLASS_STAMP_HEADER, VIEWER_STAMP_HEADER, parseResumeToken, } from "./client.js";
|
|
10
|
+
export { TunnelClient, CLI_DECLARED_HEADER, CLI_MINIMUM_HEADER, REFUSAL_HEADER, RESUME_HEADER, RESUME_TOKEN_LEN, UPGRADE_REQUIRED_REFUSAL, bodyPlan, UA_CLASS_STAMP_HEADER, VIEWER_STAMP_HEADER, parseResumeToken, } from "./client.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vincentt-xr/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.0",
|
|
4
4
|
"description": "Vincentt CLI — the vincentt command-line dev loop (login, create, publish, preview, diagnostics, feedback, relay)",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
@@ -28,11 +28,12 @@
|
|
|
28
28
|
"qrcode": "^1.5.4",
|
|
29
29
|
"ws": "^8.18.0",
|
|
30
30
|
"zod": "^3.23.8",
|
|
31
|
-
"@vincentt-xr/harness": "
|
|
31
|
+
"@vincentt-xr/harness": "3.0.0"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
34
|
"@types/qrcode": "^1.5.5",
|
|
35
|
-
"@types/ws": "^8.5.13"
|
|
35
|
+
"@types/ws": "^8.5.13",
|
|
36
|
+
"@vincentt-xr/cli-released": "npm:@vincentt-xr/cli@0.20.0"
|
|
36
37
|
},
|
|
37
38
|
"scripts": {
|
|
38
39
|
"build": "tsc -p tsconfig.build.json",
|