@vincentt-xr/cli 0.21.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.
@@ -338,6 +338,8 @@ export function endingLines(reason, orgName, refusal = {}) {
338
338
  return previewTooOldLines(refusal);
339
339
  case "auth_expired":
340
340
  return copy.endedAuthExpired();
341
+ case "protocol_violation":
342
+ return copy.endedProtocolViolation();
341
343
  case "egress_cap":
342
344
  return copy.egressHard();
343
345
  case "stopped":
@@ -219,6 +219,11 @@ export declare function endedAuthExpired(): string[];
219
219
  * link, and a silent re-mint would break every link already shared.
220
220
  */
221
221
  export declare function endedAuthzLapsed(): string[];
222
+ /**
223
+ * The edge broke the agreed tunnel wire. Only an edge bug causes it, so the
224
+ * remedy is a newer CLI or a later attempt, never anything in the project.
225
+ */
226
+ export declare function endedProtocolViolation(): string[];
222
227
  /**
223
228
  * e. Abandoned — this machine stopped checking in (the 30-minute heartbeat-
224
229
  * silence flip, `D-Abandoned-sessions-are-judged-on-heartbeat-silence-alone`).
@@ -18,6 +18,7 @@
18
18
  // product, so f1's "a token is never printed" extends to it verbatim.
19
19
  /** Flow L13 — the start block. */
20
20
  import { escapeControl, terminalSafe } from "../lib/terminalText.js";
21
+ import { INSTALL_COMMAND } from "../shared/upgradeError.js";
21
22
  export function startBlock(i) {
22
23
  const out = [
23
24
  "",
@@ -477,6 +478,20 @@ export function endedAuthzLapsed() {
477
478
  "",
478
479
  ];
479
480
  }
481
+ /**
482
+ * The edge broke the agreed tunnel wire. Only an edge bug causes it, so the
483
+ * remedy is a newer CLI or a later attempt, never anything in the project.
484
+ */
485
+ export function endedProtocolViolation() {
486
+ return [
487
+ "",
488
+ "✗ Preview stopped: the preview server sent an invalid reply. Update the CLI or try again later.",
489
+ "",
490
+ ` Upgrade: ${INSTALL_COMMAND}`,
491
+ " Then try again: vincentt preview",
492
+ "",
493
+ ];
494
+ }
480
495
  /**
481
496
  * e. Abandoned — this machine stopped checking in (the 30-minute heartbeat-
482
497
  * silence flip, `D-Abandoned-sessions-are-judged-on-heartbeat-silence-alone`).
@@ -45,6 +45,12 @@ export type PreviewEndReason = "stopped"
45
45
  * both. No further platform call is made, the DELETE included.
46
46
  */
47
47
  | "upgrade_required"
48
+ /**
49
+ * The edge broke the agreed tunnel wire (a malformed 101 caps header, a
50
+ * CONTROL frame this connection cannot read, or a fatal OPEN). Final: a redial reaches the
51
+ * same edge, so the tunnel client has already stopped dialling.
52
+ */
53
+ | "protocol_violation"
48
54
  /**
49
55
  * The 30-minute heartbeat-silence flip. NEVER reported as `hard_max`: a
50
56
  * session ended at minute 30 has not reached its 12-hour ceiling, and saying
@@ -256,6 +256,7 @@ export async function startPreview(opts) {
256
256
  if (reason !== null)
257
257
  ended(reason);
258
258
  },
259
+ onProtocolViolation: () => ended("protocol_violation"),
259
260
  onRefused: (_reason, minimum, declared) => ended("upgrade_required", {
260
261
  minimum: parseMinimum(minimum),
261
262
  declared: parseDeclared(declared),
@@ -15,7 +15,7 @@ import { buildManifest, ManifestError } from "./manifest.js";
15
15
  import { checkBounds } from "./bounds.js";
16
16
  import { classifyDist } from "./classify.js";
17
17
  import { createProgress } from "./progress.js";
18
- import { UploadExpired, UploadFailed, UploadTargetRejected, uploadAll, } from "./upload.js";
18
+ import { UploadExpired, UploadFailed, UploadTargetRejected, UploadChecksumMismatch, uploadAll, } from "./upload.js";
19
19
  import { escapeTerminal } from "../lib/terminalText.js";
20
20
  /** Re-opens after an expired upload link, per publish, before giving up. */
21
21
  export const MAX_REOPENS = 2;
@@ -342,7 +342,8 @@ async function runUpload(deps, cfg, i) {
342
342
  }
343
343
  const failedPath = err instanceof UploadFailed ||
344
344
  err instanceof UploadExpired ||
345
- err instanceof UploadTargetRejected
345
+ err instanceof UploadTargetRejected ||
346
+ err instanceof UploadChecksumMismatch
346
347
  ? escapeTerminal(err.path)
347
348
  : "a file";
348
349
  deps.print("");
@@ -563,6 +564,8 @@ function outputRefusal(err, output, buildCommand) {
563
564
  switch (err.reason) {
564
565
  case "secret_path":
565
566
  return copy.secretFiles(err.paths, output);
567
+ case "path_escaped":
568
+ return copy.escapedFiles(err.paths, output);
566
569
  case "credential_file":
567
570
  return copy.credentialFiles(err.paths, output);
568
571
  case "file_unreadable":
@@ -176,6 +176,12 @@ export declare function outputOutside(shown: string): string[];
176
176
  export declare function outputInHidden(shown: string, hidden: string): string[];
177
177
  /** Flow U1 — hidden names or symlinks that would leak. After the header and reading line. */
178
178
  export declare function secretFiles(paths: Offender[], output?: OutputLabel): string[];
179
+ /**
180
+ * Flow U1 — files whose folder resolved outside the output after the walk: a
181
+ * folder was swapped for a link while publish read it. The whole publish is
182
+ * refused, never just the file. After the header and reading line.
183
+ */
184
+ export declare function escapedFiles(paths: Offender[], output?: OutputLabel): string[];
179
185
  /** Flow U1 — credential files, from the early checks or the hash step. After the header and reading line. */
180
186
  export declare function credentialFiles(paths: Offender[], output?: OutputLabel): string[];
181
187
  /** Flow U1 — a file that changed, became a link, or could not be read while checked or hashed. */
@@ -404,7 +404,7 @@ export function tooLarge(v) {
404
404
  "",
405
405
  "✗ One file is too large to publish.",
406
406
  "",
407
- ...alignedPair([v.path ?? "", formatBytes(v.observed)], ["the per-file limit", formatLimit(v.limit)]),
407
+ ...alignedPair([escapeTerminal(v.path ?? ""), formatBytes(v.observed)], ["the per-file limit", formatLimit(v.limit)]),
408
408
  "",
409
409
  " Compress or externalise it, rebuild, then publish again.",
410
410
  "",
@@ -453,9 +453,10 @@ function alignedPair(a, b) {
453
453
  ];
454
454
  }
455
455
  function largestBlock(v) {
456
- const width = Math.max(0, ...v.largest.map((f) => f.path.length));
457
- const sizeWidth = Math.max(0, ...v.largest.map((f) => formatBytes(f.size).length));
458
- return v.largest.map((f) => ` ${f.path.padEnd(width)} ${formatBytes(f.size).padStart(sizeWidth)}`);
456
+ const largest = v.largest.map((f) => ({ path: escapeTerminal(f.path), size: f.size }));
457
+ const width = Math.max(0, ...largest.map((f) => f.path.length));
458
+ const sizeWidth = Math.max(0, ...largest.map((f) => formatBytes(f.size).length));
459
+ return largest.map((f) => ` ${f.path.padEnd(width)} ${formatBytes(f.size).padStart(sizeWidth)}`);
459
460
  }
460
461
  /** No dist at all, or no index.html at its root. Zero network calls. */
461
462
  /**
@@ -532,7 +533,7 @@ export function outputOutside(shown) {
532
533
  export function outputInHidden(shown, hidden) {
533
534
  return [
534
535
  "",
535
- `✗ The build output ${escapeTerminal(shown)} is inside ${hidden}/, not a build.`,
536
+ `✗ The build output ${escapeTerminal(shown)} is inside ${escapeTerminal(hidden)}/, not a build.`,
536
537
  "",
537
538
  " Publishing it would put your repository's history or its Vincentt link at a",
538
539
  " public address. Set --dist, or build.output in vincentt.json, to the folder",
@@ -585,6 +586,26 @@ export function secretFiles(paths, output = DEFAULT_OUTPUT) {
585
586
  "",
586
587
  ];
587
588
  }
589
+ /**
590
+ * Flow U1 — files whose folder resolved outside the output after the walk: a
591
+ * folder was swapped for a link while publish read it. The whole publish is
592
+ * refused, never just the file. After the header and reading line.
593
+ */
594
+ export function escapedFiles(paths, output = DEFAULT_OUTPUT) {
595
+ return [
596
+ "",
597
+ `✗ These files resolved outside ${escapeTerminal(output.folder)} while publish was reading them.`,
598
+ "",
599
+ ...offenderList(paths, output.folder),
600
+ "",
601
+ " A folder in the build output became a link to somewhere else, so publish",
602
+ " cannot vouch for what it would upload. Let your build finish, then publish",
603
+ " again.",
604
+ "",
605
+ " Nothing was uploaded.",
606
+ "",
607
+ ];
608
+ }
588
609
  /** Flow U1 — credential files, from the early checks or the hash step. After the header and reading line. */
589
610
  export function credentialFiles(paths, output = DEFAULT_OUTPUT) {
590
611
  return [
@@ -914,7 +935,7 @@ export function publishIncomplete(address, counts, liveVersion) {
914
935
  export function badDigest(filePath, address, liveVersion) {
915
936
  return [
916
937
  "",
917
- `✗ ${filePath} changed while uploading. Rebuild and publish again.`,
938
+ `✗ ${escapeTerminal(filePath)} changed while uploading. Rebuild and publish again.`,
918
939
  "",
919
940
  ...unchangedLines(address, liveVersion),
920
941
  "",
@@ -11,7 +11,7 @@ export interface Manifest {
11
11
  files: ManifestEntry[];
12
12
  totalBytes: number;
13
13
  }
14
- export type ManifestReason = PathRejection | "no_dist" | "output_is_repo_root" | "output_outside_repo" | "output_in_hidden" | "secret_path" | "credential_file" | "lfs_pointer" | "file_unreadable";
14
+ export type ManifestReason = PathRejection | "no_dist" | "output_is_repo_root" | "output_outside_repo" | "output_in_hidden" | "secret_path" | "path_escaped" | "credential_file" | "lfs_pointer" | "file_unreadable";
15
15
  export type CredentialWhy = "name" | "private_key" | CredentialJsonType;
16
16
  /** An output-relative path the leak, credential or LFS check refused. */
17
17
  export interface Offender {
@@ -64,6 +64,15 @@ export interface BuildManifestOptions {
64
64
  * change a checked file. Never set by the command.
65
65
  */
66
66
  beforeHash?: (files: readonly WalkedFile[]) => Promise<void> | void;
67
+ /**
68
+ * Test-only: runs before the walk reads each folder, so a test can swap a
69
+ * folder mid-walk. Never set by the command.
70
+ */
71
+ beforeReadDir?: (dir: string) => Promise<void> | void;
72
+ }
73
+ /** Test-only hooks into the checks. */
74
+ export interface CheckOutputHooks {
75
+ beforeReadDir?: (dir: string) => Promise<void> | void;
67
76
  }
68
77
  /** A Git LFS pointer file: at most 1 KB, the spec's exact first line, and an oid line. */
69
78
  export declare function isLfsPointer(head: Uint8Array, size: number): boolean;
@@ -77,7 +86,7 @@ export declare function readHead(file: WalkedFile): Promise<Buffer>;
77
86
  * or sent. Returns the file list the checks passed, which is exactly the list
78
87
  * the manifest is built from: what is checked is what is uploaded.
79
88
  */
80
- export declare function checkOutput(distDir: string, repoRoot: string): Promise<WalkedFile[]>;
89
+ export declare function checkOutput(distDir: string, repoRoot: string, hooks?: CheckOutputHooks): Promise<WalkedFile[]>;
81
90
  export interface HashResult {
82
91
  /** base64 std, the exact form `x-amz-checksum-sha256` wants. */
83
92
  sha256: string;
@@ -55,15 +55,23 @@ function isJsonName(rel) {
55
55
  * symlink is resolved and judged against the real repo root, and a name on the
56
56
  * credential list is recorded apart (a folder once, never descended).
57
57
  */
58
- async function walkOutput(outReal, rootReal) {
58
+ async function walkOutput(outReal, rootReal, hooks) {
59
59
  const walk = { files: [], offenders: [], credentialNames: [] };
60
60
  let seq = 0;
61
+ const relOf = (full) => path.relative(outReal, full).split(path.sep).join("/");
62
+ // A read error's message carries the raw absolute path. It becomes a refusal
63
+ // naming the output-relative path, which the copy escapes.
64
+ const unreadable = (rel) => new ManifestError("file_unreadable", rel, "a path could not be read");
61
65
  const visit = async (dir) => {
62
- const entries = await fs.readdir(dir, { withFileTypes: true });
66
+ await hooks.beforeReadDir?.(dir);
67
+ const dirRel = dir === outReal ? "" : `${relOf(dir)}/`;
68
+ const entries = await fs
69
+ .readdir(dir, { withFileTypes: true })
70
+ .catch(() => Promise.reject(unreadable(dirRel)));
63
71
  entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
64
72
  for (const entry of entries) {
65
73
  const full = path.join(dir, entry.name);
66
- const rel = path.relative(outReal, full).split(path.sep).join("/");
74
+ const rel = relOf(full);
67
75
  const atRoot = dir === outReal;
68
76
  const lower = entry.name.toLowerCase();
69
77
  if (entry.name.startsWith(".")) {
@@ -101,7 +109,7 @@ async function walkOutput(outReal, rootReal) {
101
109
  await visit(full);
102
110
  }
103
111
  else if (entry.isFile()) {
104
- const stat = await fs.lstat(full);
112
+ const stat = await fs.lstat(full).catch(() => Promise.reject(unreadable(rel)));
105
113
  const at = seq++;
106
114
  if (nameMatched) {
107
115
  walk.credentialNames.push({ seq: at, offender: { path: rel, why: "name" } });
@@ -121,6 +129,25 @@ async function walkOutput(outReal, rootReal) {
121
129
  await visit(outReal);
122
130
  return walk;
123
131
  }
132
+ /**
133
+ * Files whose real parent folder is not inside the real output. The walk reads
134
+ * a folder by path, so a folder swapped for a symlink mid-walk is walked through
135
+ * the link; this catches it after the fact. A parent that cannot be resolved
136
+ * counts as escaped: it is never assumed inside.
137
+ */
138
+ async function escapedFiles(files, outReal) {
139
+ const parents = new Map();
140
+ const inside = (dir) => {
141
+ let p = parents.get(dir);
142
+ if (p === undefined) {
143
+ p = fs.realpath(dir).then((real) => real === outReal || isInside(real, outReal), () => false);
144
+ parents.set(dir, p);
145
+ }
146
+ return p;
147
+ };
148
+ const verdicts = await mapLimit(files, READ_CONCURRENCY, (f) => inside(path.dirname(f.abs)));
149
+ return files.filter((_, i) => !verdicts[i]).map((f) => ({ path: f.rel }));
150
+ }
124
151
  /** Where a symlink lands: its realpath, or its lexical target when it dangles. */
125
152
  async function symlinkTarget(link) {
126
153
  try {
@@ -235,7 +262,7 @@ function forbiddenSegmentIn(rel) {
235
262
  * or sent. Returns the file list the checks passed, which is exactly the list
236
263
  * the manifest is built from: what is checked is what is uploaded.
237
264
  */
238
- export async function checkOutput(distDir, repoRoot) {
265
+ export async function checkOutput(distDir, repoRoot, hooks = {}) {
239
266
  const rootReal = await fs.realpath(repoRoot).catch(() => path.resolve(repoRoot));
240
267
  const lexical = path.resolve(distDir);
241
268
  const lexicalRoot = path.resolve(repoRoot);
@@ -253,7 +280,7 @@ export async function checkOutput(distDir, repoRoot) {
253
280
  if (err.code === "ENOENT") {
254
281
  throw new ManifestError("no_dist", distDir, `no dist at ${distDir}`);
255
282
  }
256
- throw err;
283
+ throw new ManifestError("file_unreadable", "", "the output could not be read");
257
284
  }
258
285
  if (outReal === rootReal) {
259
286
  throw new ManifestError("output_is_repo_root", distDir, `${distDir} resolves to the repo root`);
@@ -266,14 +293,21 @@ export async function checkOutput(distDir, repoRoot) {
266
293
  if (hiddenParent !== undefined) {
267
294
  throw new ManifestError("output_in_hidden", hiddenParent, `${distDir} is inside ${hiddenParent}`);
268
295
  }
269
- if (!(await fs.stat(outReal)).isDirectory()) {
296
+ const outStat = await fs.stat(outReal).catch(() => {
297
+ throw new ManifestError("file_unreadable", "", "the output could not be read");
298
+ });
299
+ if (!outStat.isDirectory()) {
270
300
  throw new ManifestError("no_dist", distDir, `no dist at ${distDir}`);
271
301
  }
272
- const walk = await walkOutput(outReal, rootReal);
302
+ const walk = await walkOutput(outReal, rootReal, hooks);
273
303
  const stats = {
274
304
  files: walk.files.length,
275
305
  bytes: walk.files.reduce((n, f) => n + f.size, 0),
276
306
  };
307
+ const escaped = await escapedFiles(walk.files, outReal);
308
+ if (escaped.length > 0) {
309
+ throw new ManifestError("path_escaped", escaped[0].path, "files resolved outside the output", { paths: escaped, stats });
310
+ }
277
311
  if (walk.offenders.length > 0) {
278
312
  throw new ManifestError("secret_path", walk.offenders[0].path, "hidden paths in the output", {
279
313
  paths: walk.offenders,
@@ -385,7 +419,9 @@ export async function hashAndScan(absPath, walked) {
385
419
  export async function buildManifest(distDir, opts) {
386
420
  // The list the checks passed IS the upload list. A second walk could see a
387
421
  // file created after the checks ran.
388
- const walked = await checkOutput(distDir, opts.repoRoot);
422
+ const walked = await checkOutput(distDir, opts.repoRoot, {
423
+ beforeReadDir: opts.beforeReadDir,
424
+ });
389
425
  const normalized = [];
390
426
  for (const file of walked) {
391
427
  const result = normalizePath(file.rel);
@@ -30,6 +30,16 @@ export declare class UploadTargetRejected extends Error {
30
30
  readonly path: string;
31
31
  constructor(path: string);
32
32
  }
33
+ /**
34
+ * A target's signed checksum is not the one this run's manifest holds for that
35
+ * path. The PUT reopens the file by path, so the signed checksum is what makes
36
+ * "checked is uploaded" hold; a target that would sign anything else is refused
37
+ * before any byte is sent.
38
+ */
39
+ export declare class UploadChecksumMismatch extends Error {
40
+ readonly path: string;
41
+ constructor(path: string);
42
+ }
33
43
  export interface UploadProgress {
34
44
  filesDone: number;
35
45
  filesTotal: number;
@@ -58,6 +58,25 @@ export class UploadTargetRejected extends Error {
58
58
  this.name = "UploadTargetRejected";
59
59
  }
60
60
  }
61
+ /**
62
+ * A target's signed checksum is not the one this run's manifest holds for that
63
+ * path. The PUT reopens the file by path, so the signed checksum is what makes
64
+ * "checked is uploaded" hold; a target that would sign anything else is refused
65
+ * before any byte is sent.
66
+ */
67
+ export class UploadChecksumMismatch extends Error {
68
+ path;
69
+ constructor(path) {
70
+ super(`the upload target ${path} does not carry this build's checksum`);
71
+ this.path = path;
72
+ this.name = "UploadChecksumMismatch";
73
+ }
74
+ }
75
+ const CHECKSUM_HEADER = "x-amz-checksum-sha256";
76
+ function signedChecksum(headers) {
77
+ const key = Object.keys(headers).find((k) => k.toLowerCase() === CHECKSUM_HEADER);
78
+ return key === undefined ? undefined : headers[key];
79
+ }
61
80
  const DEFAULT_CONCURRENCY = 6;
62
81
  const DEFAULT_MAX_ATTEMPTS = 5;
63
82
  const BACKOFF_BASE_MS = 500;
@@ -114,6 +133,10 @@ export async function uploadAll(opts) {
114
133
  const foreign = opts.targets.find((t) => !sizes.has(t.path));
115
134
  if (foreign !== undefined)
116
135
  throw new UploadTargetRejected(foreign.path);
136
+ const sums = new Map(opts.manifest.map((f) => [f.path, f.sha256]));
137
+ const mismatched = opts.targets.find((t) => signedChecksum(t.headers) !== sums.get(t.path));
138
+ if (mismatched !== undefined)
139
+ throw new UploadChecksumMismatch(mismatched.path);
117
140
  const bytesTotal = opts.targets.reduce((n, t) => n + (sizes.get(t.path) ?? 0), 0);
118
141
  const queue = [...opts.targets];
119
142
  const retry = {
@@ -8,14 +8,14 @@ export interface MachineConfig {
8
8
  /** Org slug commands act in when no --org flag or VINCENTT_ORG env is given. */
9
9
  activeOrg: string;
10
10
  /**
11
- * Durable authorization for unattended `dx --send`. Written only by
12
- * `vincentt dx enable`, which requires a TTY. Cleared by `disable` and by `logout`.
11
+ * This machine's copy of the account's unattended-send grant. Written by
12
+ * `vincentt dx enable` only after the account grant succeeded, on either path.
13
+ * Authorizes a send on its own ONLY when GET /me cannot be read. Cleared by
14
+ * `disable`, by `logout`, and by any /me answer that is not a grant.
13
15
  */
14
16
  dxSendEnabled?: boolean;
15
- /**
16
- * ISO8601 UTC, when enablement was granted. Its AGE is the mitigation for having
17
- * no expiry, so an unparseable value fails closed rather than printing.
18
- */
17
+ /** ISO8601 UTC: the account's dxReportsConsentAt on the terminal path; the CLI's
18
+ * clock on the browser path, whose callback carries no date. */
19
19
  dxSendEnabledAt?: string;
20
20
  }
21
21
  /**
@@ -157,6 +157,13 @@ export interface TunnelClientOptions {
157
157
  * this preview, 410 the session is gone. They map to different creator actions.
158
158
  */
159
159
  readonly onReconnectRefused?: (status: number) => void;
160
+ /**
161
+ * The edge broke the agreed wire: a malformed 101 caps header, a CONTROL
162
+ * frame this connection cannot read, or a fatal OPEN. Only an edge bug causes
163
+ * it, and a redial
164
+ * reaches the same edge, so the client makes no further dial after this.
165
+ */
166
+ readonly onProtocolViolation?: () => void;
160
167
  /** Injectable for tests; never a real socket in a unit test. */
161
168
  readonly connect?: (url: string, headers: Record<string, string>) => WebSocket;
162
169
  }
@@ -232,10 +239,11 @@ export declare class TunnelClient {
232
239
  private open;
233
240
  private onClosed;
234
241
  /**
235
- * Close this connection for a violation the edge alone could cause. The
236
- * ordinary close path then decides whether to redial.
242
+ * End the preview for a violation the edge alone could cause. A redial reaches
243
+ * the same edge and gets the same answer: before this was terminal, a
244
+ * persistently malformed 101 redialled every 8 seconds indefinitely.
237
245
  */
238
- private protocolViolation;
246
+ private fatalViolation;
239
247
  private startPing;
240
248
  private stopPing;
241
249
  private send;
@@ -281,10 +281,10 @@ export class TunnelClient {
281
281
  // The agreed set fixes this connection's grammar. Absent is the base
282
282
  // protocol (an edge that predates negotiation); a malformed answer, or
283
283
  // one naming a token this client did not offer, is the edge
284
- // misbehaving and closes the connection.
284
+ // misbehaving and ends the preview.
285
285
  const agreed = parseAgreedCaps(res.headers[CAPS_HEADER], res.rawHeaders);
286
286
  if (agreed === null) {
287
- this.protocolViolation(ws);
287
+ this.fatalViolation(ws);
288
288
  return;
289
289
  }
290
290
  this.caps = agreed;
@@ -382,12 +382,15 @@ export class TunnelClient {
382
382
  }, delay);
383
383
  }
384
384
  /**
385
- * Close this connection for a violation the edge alone could cause. The
386
- * ordinary close path then decides whether to redial.
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.
387
388
  */
388
- protocolViolation(ws) {
389
- if (this.ws !== ws)
389
+ fatalViolation(ws) {
390
+ if (ws === null || this.ws !== ws || this.closed)
390
391
  return;
392
+ this.closed = true;
393
+ this.opts.onProtocolViolation?.();
391
394
  ws.close();
392
395
  }
393
396
  startPing() {
@@ -453,7 +456,7 @@ export class TunnelClient {
453
456
  catch {
454
457
  // A CONTROL this connection cannot read (malformed, unknown, or a
455
458
  // kind it did not agree) is fatal: only the edge writes stream 0.
456
- this.ws?.close();
459
+ this.fatalViolation(this.ws);
457
460
  return;
458
461
  }
459
462
  if (ctrl.kind === "viewer_gone") {
@@ -478,7 +481,7 @@ export class TunnelClient {
478
481
  }
479
482
  catch (err) {
480
483
  if (err instanceof ProtocolError && err.fatal) {
481
- this.ws?.close();
484
+ this.fatalViolation(this.ws);
482
485
  return;
483
486
  }
484
487
  this.send(FrameType.RESET, streamId, new Uint8Array([ResetReason.PROTOCOL_ERROR]));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincentt-xr/cli",
3
- "version": "0.21.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",