@vincentt-xr/cli 0.18.0 → 0.18.1

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 CHANGED
@@ -3,6 +3,13 @@
3
3
  Per version, newest first. Read by the platform's own version surface and served to
4
4
  creators' agents. Accurate or absent, never speculative (B-F5-2).
5
5
 
6
+ ## 0.18.1
7
+
8
+ required: false
9
+ No action required. Behaviour that changed:
10
+ - `publish` re-opens the upload when an upload link expires (at most twice) instead of failing. Network errors and S3 5xx are retried with backoff. A refused upload reports "changed while uploading" only when S3 says the bytes do not match; other refusals name the failed file.
11
+ - A publish that fails on the network leaves its upload open, so running `vincentt publish` again sends only the files that have not landed yet (on an API that supports it). Files stream from disk instead of being read into memory.
12
+
6
13
  ## 0.18.0
7
14
 
8
15
  required: false
@@ -1,3 +1,5 @@
1
+ /** Re-opens after an expired upload link, per publish, before giving up. */
2
+ export declare const MAX_REOPENS = 2;
1
3
  import type { ResolvedConfig } from "../shared/config.js";
2
4
  import { type BuiltWith } from "../platform/resolve.js";
3
5
  import { type VincenttJson } from "../lib/settings.js";
@@ -15,7 +15,9 @@ 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 { UploadFailed, uploadAll } from "./upload.js";
18
+ import { UploadExpired, UploadFailed, uploadAll } from "./upload.js";
19
+ /** Re-opens after an expired upload link, per publish, before giving up. */
20
+ export const MAX_REOPENS = 2;
19
21
  import { waitUntilServing } from "./visibility.js";
20
22
  import * as copy from "./copy.js";
21
23
  import { publishedLimitRefused, resolveCountLimit, storageCeilingRefused, } from "../shared/countLimit.js";
@@ -257,16 +259,20 @@ async function runPublish(deps, opts, onCommitted) {
257
259
  });
258
260
  }
259
261
  async function runUpload(deps, cfg, i) {
260
- let opened;
261
- try {
262
- opened = await openUpload(cfg, i.projectId, i.manifest.files, i.note, i.fetchImpl, i.builtWith);
263
- }
264
- catch (err) {
262
+ const open = () => openUpload(cfg, i.projectId, i.manifest.files, i.note, i.fetchImpl, i.builtWith);
263
+ const openFailed = (err) => {
265
264
  if (err instanceof AuthExpiredError)
266
265
  return authExpired(deps);
267
266
  for (const l of openErrorLines(err, i.address, i.liveVersion, i.org))
268
267
  deps.print(l);
269
268
  return 1;
269
+ };
270
+ let opened;
271
+ try {
272
+ opened = await open();
273
+ }
274
+ catch (err) {
275
+ return openFailed(err);
270
276
  }
271
277
  deps.print(copy.uploadStart(i.manifest.files.length));
272
278
  const progress = createProgress({
@@ -276,24 +282,53 @@ async function runUpload(deps, cfg, i) {
276
282
  now: deps.now,
277
283
  });
278
284
  const startedAt = deps.now();
279
- try {
280
- await uploadAll({
281
- distDir: i.distDir,
282
- targets: opened.uploads,
283
- manifest: i.manifest.files,
284
- onProgress: (p) => progress.update(p),
285
- fetchImpl: i.uploadFetch,
286
- });
285
+ let reopens = 0;
286
+ let uploadErr;
287
+ for (;;) {
288
+ try {
289
+ await uploadAll({
290
+ distDir: i.distDir,
291
+ targets: opened.uploads,
292
+ manifest: i.manifest.files,
293
+ onProgress: (p) => progress.update(p),
294
+ fetchImpl: i.uploadFetch,
295
+ sleep: deps.sleep,
296
+ });
297
+ break;
298
+ }
299
+ catch (err) {
300
+ if (!(err instanceof UploadExpired) || reopens >= MAX_REOPENS) {
301
+ uploadErr = err;
302
+ break;
303
+ }
304
+ }
305
+ // An expired link: re-open for fresh URLs. An api that resumes returns the
306
+ // same upload with only the files still missing; one that does not returns
307
+ // a new upload and every file is sent again.
308
+ reopens += 1;
309
+ try {
310
+ opened = await open();
311
+ }
312
+ catch (err) {
313
+ progress.finish();
314
+ return openFailed(err);
315
+ }
287
316
  }
288
- catch (err) {
317
+ if (uploadErr !== undefined) {
318
+ const err = uploadErr;
289
319
  progress.finish();
290
- await abandonUpload(cfg, i.projectId, opened.uploadId, i.fetchImpl);
320
+ // A retryable failure (the network, S3 5xx, links that kept expiring) keeps
321
+ // the upload open so the next `vincentt publish` resumes it and sends only
322
+ // what is missing. Anything else is abandoned.
323
+ const resumable = err instanceof UploadExpired || (err instanceof UploadFailed && err.retryable);
324
+ if (!resumable)
325
+ await abandonUpload(cfg, i.projectId, opened.uploadId, i.fetchImpl);
291
326
  if (err instanceof UploadFailed && err.badDigest) {
292
327
  for (const l of copy.badDigest(err.path, i.address, i.liveVersion))
293
328
  deps.print(l);
294
329
  return 1;
295
330
  }
296
- const failedPath = err instanceof UploadFailed ? err.path : "a file";
331
+ const failedPath = err instanceof UploadFailed || err instanceof UploadExpired ? err.path : "a file";
297
332
  deps.print("");
298
333
  deps.print(`✗ Uploading ${failedPath} failed.`);
299
334
  deps.print("");
@@ -4,7 +4,23 @@ export declare class UploadFailed extends Error {
4
4
  readonly path: string;
5
5
  readonly status: number;
6
6
  readonly badDigest: boolean;
7
- constructor(path: string, status: number, badDigest: boolean);
7
+ /** S3's own error code, when the response carried one. */
8
+ readonly s3Code?: string | undefined;
9
+ /** A transport error or 5xx outlasted every retry: re-running is the remedy. */
10
+ readonly retryable: boolean;
11
+ constructor(path: string, status: number, badDigest: boolean,
12
+ /** S3's own error code, when the response carried one. */
13
+ s3Code?: string | undefined,
14
+ /** A transport error or 5xx outlasted every retry: re-running is the remedy. */
15
+ retryable?: boolean);
16
+ }
17
+ /**
18
+ * A presigned URL was refused because it expired. The upload has to be
19
+ * re-opened for fresh URLs; nothing about the file is wrong.
20
+ */
21
+ export declare class UploadExpired extends Error {
22
+ readonly path: string;
23
+ constructor(path: string);
8
24
  }
9
25
  export interface UploadProgress {
10
26
  filesDone: number;
@@ -19,18 +35,50 @@ export interface UploadOptions {
19
35
  concurrency?: number;
20
36
  onProgress?: (p: UploadProgress) => void;
21
37
  fetchImpl?: typeof fetch;
22
- /** Per-file retries for a transient network/5xx failure. A BadDigest is retried once. */
38
+ /** Attempts per file for a transport error or 5xx. A BadDigest is retried once. */
23
39
  maxAttempts?: number;
40
+ /** Test seams for the backoff. */
41
+ sleep?: (ms: number) => Promise<void>;
42
+ random?: () => number;
24
43
  }
25
44
  /**
26
45
  * One PUT's deadline: a minute, plus the time the file takes at 64 KB/s. A fixed
27
46
  * deadline would fail every large file on a slow uplink; none at all lets a
28
- * stalled connection hang the publish forever.
47
+ * stalled connection hang the publish forever. The api sizes the upload window
48
+ * at the same rate, so a PUT this deadline still waits for is never refused by
49
+ * an expired link.
29
50
  */
30
51
  export declare function putDeadlineMs(bytes: number): number;
52
+ /** Exponential backoff with full jitter: uniform in [0, min(cap, base * 2^(n-1))). */
53
+ export declare function backoffMs(attempt: number, random?: () => number): number;
54
+ export type S3Outcome = {
55
+ kind: "ok";
56
+ } | {
57
+ kind: "expired";
58
+ } | {
59
+ kind: "bad_digest";
60
+ code: string;
61
+ } | {
62
+ kind: "retry";
63
+ code?: string;
64
+ } | {
65
+ kind: "fatal";
66
+ code?: string;
67
+ };
68
+ /**
69
+ * What a PUT's response means, read from S3's own error code rather than the
70
+ * status alone. A 400 is many things — S3 answers a stalled body with 400
71
+ * RequestTimeout — and only BadDigest means the bytes on disk are not the bytes
72
+ * that were hashed.
73
+ */
74
+ export declare function classifyS3Response(status: number, body: string): S3Outcome;
31
75
  /**
32
- * PUT every file. A re-run that finds files already landed simply uploads
33
- * fewer — resumption is silent, because the creator did not ask for a resume,
34
- * they asked to publish.
76
+ * PUT every target. The api returns targets only for files not yet landed when
77
+ * it resumes an open upload, so a re-run simply uploads fewer — resumption is
78
+ * silent, because the creator did not ask for a resume, they asked to publish.
79
+ *
80
+ * On the first file that cannot land, no new PUT starts; the ones in flight
81
+ * finish, and the first failure is thrown (UploadExpired only when nothing
82
+ * worse happened, since re-opening cannot fix a changed file).
35
83
  */
36
84
  export declare function uploadAll(opts: UploadOptions): Promise<void>;
@@ -7,44 +7,109 @@
7
7
  // makes every published index.html download instead of render in the other
8
8
  // (§2.7's contract traps 3 and 4).
9
9
  //
10
- // The CLI never synthesizes a MIME type and never parses the presigned URL.
11
- import { promises as fs } from "node:fs";
10
+ // The CLI never synthesizes a MIME type and never parses the presigned URL. The
11
+ // one header it adds is Content-Length, which S3 requires on a PUT and which a
12
+ // streamed body does not carry on its own.
13
+ import { createReadStream, promises as fs } from "node:fs";
12
14
  import path from "node:path";
15
+ import { Readable } from "node:stream";
13
16
  import { withDeadline } from "../shared/http.js";
14
17
  export class UploadFailed extends Error {
15
18
  path;
16
19
  status;
17
20
  badDigest;
18
- constructor(path, status, badDigest) {
19
- super(`upload failed for ${path} (${status})`);
21
+ s3Code;
22
+ retryable;
23
+ constructor(path, status, badDigest,
24
+ /** S3's own error code, when the response carried one. */
25
+ s3Code,
26
+ /** A transport error or 5xx outlasted every retry: re-running is the remedy. */
27
+ retryable = false) {
28
+ super(`upload failed for ${path} (${status}${s3Code ? ` ${s3Code}` : ""})`);
20
29
  this.path = path;
21
30
  this.status = status;
22
31
  this.badDigest = badDigest;
32
+ this.s3Code = s3Code;
33
+ this.retryable = retryable;
23
34
  this.name = "UploadFailed";
24
35
  }
25
36
  }
37
+ /**
38
+ * A presigned URL was refused because it expired. The upload has to be
39
+ * re-opened for fresh URLs; nothing about the file is wrong.
40
+ */
41
+ export class UploadExpired extends Error {
42
+ path;
43
+ constructor(path) {
44
+ super(`the upload link for ${path} expired`);
45
+ this.path = path;
46
+ this.name = "UploadExpired";
47
+ }
48
+ }
26
49
  const DEFAULT_CONCURRENCY = 6;
50
+ const DEFAULT_MAX_ATTEMPTS = 5;
51
+ const BACKOFF_BASE_MS = 500;
52
+ const BACKOFF_CAP_MS = 15_000;
27
53
  /**
28
54
  * One PUT's deadline: a minute, plus the time the file takes at 64 KB/s. A fixed
29
55
  * deadline would fail every large file on a slow uplink; none at all lets a
30
- * stalled connection hang the publish forever.
56
+ * stalled connection hang the publish forever. The api sizes the upload window
57
+ * at the same rate, so a PUT this deadline still waits for is never refused by
58
+ * an expired link.
31
59
  */
32
60
  export function putDeadlineMs(bytes) {
33
61
  return 60_000 + Math.ceil(bytes / 64_000) * 1000;
34
62
  }
63
+ /** Exponential backoff with full jitter: uniform in [0, min(cap, base * 2^(n-1))). */
64
+ export function backoffMs(attempt, random = Math.random) {
65
+ const ceiling = Math.min(BACKOFF_CAP_MS, BACKOFF_BASE_MS * 2 ** Math.max(0, attempt - 1));
66
+ return Math.floor(random() * ceiling);
67
+ }
68
+ /**
69
+ * What a PUT's response means, read from S3's own error code rather than the
70
+ * status alone. A 400 is many things — S3 answers a stalled body with 400
71
+ * RequestTimeout — and only BadDigest means the bytes on disk are not the bytes
72
+ * that were hashed.
73
+ */
74
+ export function classifyS3Response(status, body) {
75
+ if (status >= 200 && status < 300)
76
+ return { kind: "ok" };
77
+ const code = /<Code>([^<]+)<\/Code>/.exec(body)?.[1];
78
+ const message = /<Message>([^<]*)<\/Message>/.exec(body)?.[1] ?? "";
79
+ if (status === 403 && code === "AccessDenied" && /request has expired/i.test(message)) {
80
+ return { kind: "expired" };
81
+ }
82
+ if (code === "BadDigest")
83
+ return { kind: "bad_digest", code };
84
+ if (code === "RequestTimeout" || code === "IncompleteBody")
85
+ return { kind: "retry", code };
86
+ if (status >= 500 || status === 408 || status === 429)
87
+ return { kind: "retry", code };
88
+ return { kind: "fatal", code };
89
+ }
35
90
  /**
36
- * PUT every file. A re-run that finds files already landed simply uploads
37
- * fewer — resumption is silent, because the creator did not ask for a resume,
38
- * they asked to publish.
91
+ * PUT every target. The api returns targets only for files not yet landed when
92
+ * it resumes an open upload, so a re-run simply uploads fewer — resumption is
93
+ * silent, because the creator did not ask for a resume, they asked to publish.
94
+ *
95
+ * On the first file that cannot land, no new PUT starts; the ones in flight
96
+ * finish, and the first failure is thrown (UploadExpired only when nothing
97
+ * worse happened, since re-opening cannot fix a changed file).
39
98
  */
40
99
  export async function uploadAll(opts) {
41
100
  const fetchImpl = opts.fetchImpl ?? fetch;
42
101
  const sizes = new Map(opts.manifest.map((f) => [f.path, f.size]));
43
- const bytesTotal = opts.manifest.reduce((n, f) => n + f.size, 0);
102
+ const bytesTotal = opts.targets.reduce((n, t) => n + (sizes.get(t.path) ?? 0), 0);
44
103
  const queue = [...opts.targets];
45
- const maxAttempts = opts.maxAttempts ?? 3;
104
+ const retry = {
105
+ maxAttempts: opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
106
+ sleep: opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))),
107
+ random: opts.random ?? Math.random,
108
+ };
46
109
  let filesDone = 0;
47
110
  let bytesDone = 0;
111
+ let failed = false;
112
+ let failure;
48
113
  const report = () => opts.onProgress?.({
49
114
  filesDone,
50
115
  filesTotal: opts.targets.length,
@@ -53,11 +118,19 @@ export async function uploadAll(opts) {
53
118
  });
54
119
  report();
55
120
  const worker = async () => {
56
- for (;;) {
121
+ while (!failed) {
57
122
  const target = queue.shift();
58
123
  if (!target)
59
124
  return;
60
- await putOne(target, opts.distDir, fetchImpl, maxAttempts);
125
+ try {
126
+ await putOne(target, opts.distDir, fetchImpl, retry);
127
+ }
128
+ catch (err) {
129
+ if (!failed || failure instanceof UploadExpired)
130
+ failure = err;
131
+ failed = true;
132
+ return;
133
+ }
61
134
  filesDone += 1;
62
135
  bytesDone += sizes.get(target.path) ?? 0;
63
136
  report();
@@ -65,39 +138,64 @@ export async function uploadAll(opts) {
65
138
  };
66
139
  const workers = Array.from({ length: Math.min(opts.concurrency ?? DEFAULT_CONCURRENCY, queue.length || 1) }, () => worker());
67
140
  await Promise.all(workers);
141
+ if (failed)
142
+ throw failure;
68
143
  }
69
- async function putOne(target, distDir, fetchImpl, maxAttempts) {
144
+ async function putOne(target, distDir, fetchImpl, retry) {
70
145
  const abs = path.join(distDir, ...target.path.split("/"));
71
- const body = await fs.readFile(abs);
72
- const put = withDeadline(fetchImpl, putDeadlineMs(body.byteLength));
146
+ const { size } = await fs.stat(abs);
147
+ const put = withDeadline(fetchImpl, putDeadlineMs(size));
73
148
  let lastStatus = 0;
149
+ let lastCode;
74
150
  let sawBadDigest = false;
75
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
151
+ for (let attempt = 1; attempt <= retry.maxAttempts; attempt++) {
152
+ if (attempt > 1)
153
+ await retry.sleep(backoffMs(attempt - 1, retry.random));
154
+ // A fresh stream per attempt: a body cannot be replayed once read.
155
+ const stream = createReadStream(abs);
76
156
  let res;
157
+ let text;
77
158
  try {
78
159
  res = await put(target.url, {
79
160
  method: "PUT",
80
- // VERBATIM. No key dropped, no key added, no key renamed.
81
- headers: { ...target.headers },
82
- body: new Uint8Array(body),
161
+ // VERBATIM. No key dropped, no key renamed; Content-Length is the one
162
+ // addition, required by S3 and absent from a streamed body.
163
+ headers: { ...target.headers, "content-length": String(size) },
164
+ body: Readable.toWeb(stream),
165
+ duplex: "half",
83
166
  });
167
+ text = res.ok ? "" : await res.text().catch(() => "");
84
168
  }
85
169
  catch {
86
170
  lastStatus = 0;
171
+ lastCode = undefined;
87
172
  continue;
88
173
  }
89
- if (res.ok)
90
- return;
91
- lastStatus = res.status;
92
- const text = await res.text().catch(() => "");
93
- if (text.includes("BadDigest") || res.status === 400) {
94
- // The bytes on disk differ from what was hashed. One silent re-PUT covers
95
- // a torn read; a repeat means the file genuinely changed under us.
96
- if (sawBadDigest)
97
- throw new UploadFailed(target.path, res.status, true);
98
- sawBadDigest = true;
99
- continue;
174
+ finally {
175
+ stream.destroy();
176
+ }
177
+ const outcome = classifyS3Response(res.status, text);
178
+ switch (outcome.kind) {
179
+ case "ok":
180
+ return;
181
+ case "expired":
182
+ throw new UploadExpired(target.path);
183
+ case "bad_digest":
184
+ // The bytes on disk differ from what was hashed. One re-PUT covers a
185
+ // torn read; a repeat means the file genuinely changed under us.
186
+ if (sawBadDigest)
187
+ throw new UploadFailed(target.path, res.status, true, outcome.code);
188
+ sawBadDigest = true;
189
+ lastStatus = res.status;
190
+ lastCode = outcome.code;
191
+ continue;
192
+ case "retry":
193
+ lastStatus = res.status;
194
+ lastCode = outcome.code;
195
+ continue;
196
+ case "fatal":
197
+ throw new UploadFailed(target.path, res.status, false, outcome.code);
100
198
  }
101
199
  }
102
- throw new UploadFailed(target.path, lastStatus, sawBadDigest);
200
+ throw new UploadFailed(target.path, lastStatus, sawBadDigest, lastCode, !sawBadDigest);
103
201
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincentt-xr/cli",
3
- "version": "0.18.0",
3
+ "version": "0.18.1",
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,7 +28,7 @@
28
28
  "qrcode": "^1.5.4",
29
29
  "ws": "^8.18.0",
30
30
  "zod": "^3.23.8",
31
- "@vincentt-xr/harness": "1.6.0"
31
+ "@vincentt-xr/harness": "1.6.1"
32
32
  },
33
33
  "devDependencies": {
34
34
  "@types/qrcode": "^1.5.5",