@netlify/spark-ui 1.31.0-alpha.4 → 1.31.0-alpha.5

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 (36) hide show
  1. package/dist/components/preact/DropZone/DropZone.d.ts +6 -7
  2. package/dist/components/preact/DropZone/DropZone.js +5 -5
  3. package/dist/components/preact/DropZone/index.js +29 -29
  4. package/dist/components/preact/DropZone/useDropDeploy.d.ts +0 -1
  5. package/dist/components/preact/DropZone/useDropDeploy.js +25 -27
  6. package/dist/components/preact/index.js +10 -10
  7. package/dist/drop-core/authedApi.d.ts +0 -10
  8. package/dist/drop-core/authedApi.js +15 -20
  9. package/dist/drop-core/authedDropClient.d.ts +4 -11
  10. package/dist/drop-core/authedDropClient.js +24 -33
  11. package/dist/drop-core/buildClient.js +17 -16
  12. package/dist/drop-core/client.d.ts +3 -1
  13. package/dist/drop-core/client.js +19 -17
  14. package/dist/drop-core/constants.d.ts +29 -0
  15. package/dist/drop-core/constants.js +10 -0
  16. package/dist/drop-core/deploy.d.ts +2 -0
  17. package/dist/drop-core/deploy.js +56 -43
  18. package/dist/drop-core/errors.d.ts +29 -0
  19. package/dist/drop-core/errors.js +70 -33
  20. package/dist/drop-core/index.d.ts +22 -11
  21. package/dist/drop-core/index.js +45 -49
  22. package/package.json +1 -1
  23. package/packages/components/preact/DropZone/DropZone.tsx +6 -7
  24. package/packages/components/preact/DropZone/useDropDeploy.ts +13 -22
  25. package/packages/drop-core/README.md +91 -0
  26. package/packages/drop-core/authedApi.ts +0 -13
  27. package/packages/drop-core/authedDropClient.ts +12 -33
  28. package/packages/drop-core/buildClient.ts +2 -3
  29. package/packages/drop-core/client.ts +18 -6
  30. package/packages/drop-core/constants.ts +39 -0
  31. package/packages/drop-core/deploy.test.ts +42 -0
  32. package/packages/drop-core/deploy.ts +41 -5
  33. package/packages/drop-core/errors.test.ts +32 -3
  34. package/packages/drop-core/errors.ts +67 -10
  35. package/packages/drop-core/index.ts +41 -11
  36. package/packages/drop-core/readFiles.test.ts +0 -1
@@ -15,7 +15,7 @@ import {
15
15
  getSingleNonIndexHtmlFileName,
16
16
  hasMultipleHtmlFilesWithoutIndex,
17
17
  humanizeDropError,
18
- NotAuthenticatedError,
18
+ isNotAuthenticatedError,
19
19
  renameSingleNonIndexHtmlToIndex,
20
20
  type BuildClient,
21
21
  type BuildDetection,
@@ -88,7 +88,6 @@ export type DeployStatus =
88
88
  | { kind: 'redirecting'; target: 'claim' | 'dashboard' | 'signup' | 'build' }
89
89
  | { kind: 'error'; message: string };
90
90
 
91
- /** Re-exported from the drop-core analytics contract, where the shape now lives. */
92
91
  export type { TrackFn };
93
92
 
94
93
  interface UseDropDeployOptions {
@@ -185,15 +184,12 @@ export function useDropDeploy({
185
184
  track,
186
185
  }: UseDropDeployOptions) {
187
186
  /**
188
- * Typed veneer over the consumer's `track`: event names come from the
189
- * DROPZONE_EVENTS contract and each payload is checked against its shape,
190
- * so a renamed property fails to compile instead of silently forking the
191
- * analytics history.
187
+ * Typed veneer over the consumer's `track`: each payload is checked against
188
+ * the DROPZONE_EVENTS contract, so a renamed property fails to compile
189
+ * instead of silently forking the analytics history.
192
190
  */
193
191
  const emit = <E extends DropZoneEventName>(event: E, props: DropZoneEventProperties[E]) =>
194
- // The cast bridges to TrackFn's loose record: the interfaces have no index
195
- // signature (so payloads stay closed for consumers), and the checking that
196
- // matters happened against DropZoneEventProperties[E] above.
192
+ // The event interfaces carry no index signature, hence the cast to TrackFn's record.
197
193
  track(event, props as Record<string, unknown>);
198
194
 
199
195
  const [status, setStatus] = useState<DeployStatus>({ kind: 'idle' });
@@ -247,7 +243,7 @@ export function useDropDeploy({
247
243
  buildSettings,
248
244
  };
249
245
  } catch (err) {
250
- if (err instanceof NotAuthenticatedError) return null;
246
+ if (isNotAuthenticatedError(err)) return null;
251
247
  throw err;
252
248
  }
253
249
  }
@@ -276,17 +272,12 @@ export function useDropDeploy({
276
272
 
277
273
  const typeProps = fileTypes.length ? { file_types: fileTypes } : {};
278
274
 
279
- // Surface a failure: move to the error state and emit the matching
280
- // analytics event. Centralised so every failure path (empty selection, a
281
- // read/deploy throw) reports uniformly — the mirror of the
282
- // `dropzone_deploy_succeeded` event on the happy path. `file_count` is only
283
- // included where the file set is known (it isn't if `read()` itself threw);
284
- // `file_types` rides along on every failure precisely because it *is* known
285
- // in that case — a rejected single file is the main thing we want to see.
286
- // `reason` goes to analytics as-is (raw, e.g. "drop failed: 429"); `message`
287
- // is what the user sees, which for a caught error is humanized separately.
288
- // `onError` gets both so a consumer (e.g. with `showStatus={false}`) can show
289
- // the humanized message while keeping the raw reason for their own logging.
275
+ // Every failure path reports through here so the failure event stays
276
+ // uniform. `file_count` is only included where the file set is known (it
277
+ // isn't if `read()` threw); `file_types` always rides along because it *is*
278
+ // known then — a rejected single file is the main thing worth seeing.
279
+ // `reason` is raw (e.g. "drop failed: 429") for analytics; `message` is the
280
+ // humanized copy the user sees; `onError` gets both.
290
281
  const fail = (message: string, reason: string, extra?: Record<string, unknown>) => {
291
282
  setStatus({ kind: 'error', message });
292
283
  emit(DROPZONE_EVENTS.DEPLOY_FAILED, { method: source, reason, ...typeProps, ...extra });
@@ -425,7 +416,7 @@ export function useDropDeploy({
425
416
  onProgress: e => setStatus(progressToStatus(e)),
426
417
  });
427
418
  } catch (err) {
428
- if (err instanceof NotAuthenticatedError) return null;
419
+ if (isNotAuthenticatedError(err)) return null;
429
420
  throw err;
430
421
  }
431
422
  }
@@ -0,0 +1,91 @@
1
+ # drop-core
2
+
3
+ The framework-free engine behind the Drop Zone: reading a dropped folder/zip
4
+ into deployable files, deciding whether it needs a build, and deploying it —
5
+ anonymously (claim flow) or into a logged-in visitor's account — plus the
6
+ stash that carries a build drop across the signup round-trip.
7
+
8
+ Consumed three ways: by the Preact `DropZone` in this repo, by anything
9
+ importing `@netlify/spark-ui/drop-core`, and eventually by other Netlify
10
+ surfaces implementing their own Drop UI over the same core.
11
+
12
+ ## Conventions
13
+
14
+ These follow the practices shared by the libraries doing our two jobs —
15
+ react-dropzone/Uppy/FilePond on the component side, netlify-cli's deploy
16
+ engine, @vercel/client, and tus-js-client on the pipeline side. Where we
17
+ deliberately differ, that's recorded too, so it isn't re-litigated by accident.
18
+
19
+ ### Boundaries
20
+
21
+ - **Nothing in `drop-core` may import from `preact`** (or any UI framework).
22
+ Anything that imports preact may not contain deploy/read/detect logic — it
23
+ adapts this package to a view, nothing more.
24
+ - **The barrel is the public API.** Nothing is public unless `index.ts` names
25
+ it; `export *` is reserved for modules that are contracts in their entirety
26
+ (`types`, `errors`, `analytics`). New helpers default to private.
27
+ - **Granular pure modules, one cohesive orchestrator.** Pure helpers get their
28
+ own small file named after what they export; the deploy orchestration stays
29
+ together in `deploy.ts` rather than fragmenting a state machine.
30
+ - **Shared tunables live in `constants.ts`** with a unit-bearing comment each
31
+ (`// 10 minutes`, not `// the timeout`). Module-specific constants stay with
32
+ their module. No magic numbers inline.
33
+
34
+ ### Naming
35
+
36
+ | Kind | Convention | Examples here |
37
+ | ------------------ | ---------------------------------- | ------------------------------------------------------------- |
38
+ | Outcome callbacks | `onVerb` / past participle | `onDeploy`, `onBuildRequired`, `onError` |
39
+ | Boolean state | `isX` | `busy` (exception, pre-dates rule), `isNotAuthenticatedError` |
40
+ | Strategy injection | bare noun | `client`, `buildClient`, `validator`-style |
41
+ | Analytics events | `dropzone_snake_case` — **frozen** | `DROPZONE_EVENTS` (test-pinned wire names) |
42
+ | Error predicates | `isXError(err)` | `isNotAuthenticatedError` |
43
+ | Files | filename = main export | `detectBuild.ts`, `buildStash.ts` |
44
+
45
+ ### Comments
46
+
47
+ - **WHY over WHAT.** A comment earns its place by stating something the code
48
+ can't: an invariant, an observed API behavior, a browser quirk, a
49
+ don't-simplify warning. Never narrate the next line.
50
+ - **Record observed API behavior, with receipts.** The model comment in this
51
+ package: readiness polling keys off `deploy_id` (BSON), not `deploy.id` (site
52
+ UUID), "or the request 401s misleadingly" — naming the server-side file. Say
53
+ what the server _actually does_, and link the server source when you can.
54
+ - **Repeat load-bearing quirk notes at every call site.** The `deploy_id` note
55
+ appears in the client _and_ the orchestrator on purpose — a note that only
56
+ exists where nobody is reading protects nothing.
57
+ - **"Don't simplify" notes name what breaks** — the invariant, and ideally the
58
+ test that pins it (e.g. the `NotAuthenticatedError` boundary below).
59
+ - **No `@param`/`@returns`.** Types carry the shape; JSDoc carries rationale
60
+ and edge cases. (react-dropzone: zero `@param` across its entire source.)
61
+ - File-header purpose blocks only where the filename undersells the contents.
62
+
63
+ ### Errors
64
+
65
+ - **Class per failure, each with a readonly kebab-case `code` brand.** This
66
+ package ships as raw TS (`./components`) _and_ compiled dist (`./preact`,
67
+ `./drop-core`) — two copies of a class can coexist in one app and defeat
68
+ `instanceof`. Detection that steers control flow must go through a
69
+ code-checking predicate (`isNotAuthenticatedError`), never bare `instanceof`.
70
+ - **`NotAuthenticatedError` is pre-mutation only.** It may be thrown solely
71
+ before anything is created (token gate, site-create 401) — it is the signal
72
+ that an anonymous retry is safe. Throwing it after a site exists would strand
73
+ an empty project and deploy a duplicate. Pinned by tests.
74
+ - **Humanized copy lives in `humanizeDropError`, in this package.** A
75
+ deliberate divergence from @vercel/client (which leaves rendering to the
76
+ CLI): our consumers render our copy directly, and the docs promise it.
77
+ - Transient upload failures retry on `UPLOAD_RETRY_DELAYS_MS` (backoff as
78
+ data, the tus idiom); **400/422 never retry** — the request itself is wrong.
79
+
80
+ ### Considered and rejected
81
+
82
+ - **Async-generator lifecycles** (@vercel/client): our phase-callback design is
83
+ equivalent and churn-free; `DeployPhase` is a closed union, which netlify-cli's
84
+ own author wished theirs was.
85
+ - **Kebab-case event renames**: `dropzone_*` wire names are pinned analytics
86
+ history; `analytics.test.ts` fails on any rename. Append-only.
87
+ - **`getRootProps`/`getInputProps` attribute getters** (react-dropzone, Uppy):
88
+ the right shape for a future framework-free UI tier shared with the app —
89
+ not something to bolt onto the shipped wrapper component.
90
+ - **A `defaultOptions` object** (tus, Uppy): our destructured constructor
91
+ defaults are react-dropzone's pattern; either is fine, we have this one.
@@ -9,19 +9,6 @@ import { NotAuthenticatedError, SiteCreateError } from './errors';
9
9
  * ~6MB — and each client handles its own, bearer-authorized.)
10
10
  */
11
11
 
12
- /**
13
- * Default root for proxied JSON calls. On netlify.com the `/access-control/*`
14
- * rewrite targets the app's access-control function, making this path the API
15
- * root. The paths a given origin may call are allowlisted server-side.
16
- */
17
- export const DEFAULT_PROXY_BASE = '/access-control/bb-api/api/v1';
18
-
19
- /** Direct api.netlify.com root for the uploads the proxy can't carry. */
20
- export const DEFAULT_API_BASE = 'https://api.netlify.com/api/v1';
21
-
22
- export const DEFAULT_POLL_INTERVAL_MS = 1000;
23
- export const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
24
-
25
12
  /**
26
13
  * Create a site in the visitor's account via `POST {proxyBase}/sites` (scoped
27
14
  * to `accountSlug` when given, else the user's default account), attributed
@@ -1,13 +1,13 @@
1
+ import { createSiteInAccount, pollDeployUntilSettled } from './authedApi';
1
2
  import {
2
- createSiteInAccount,
3
3
  DEFAULT_API_BASE,
4
4
  DEFAULT_POLL_INTERVAL_MS,
5
5
  DEFAULT_PROXY_BASE,
6
6
  DEFAULT_TIMEOUT_MS,
7
- pollDeployUntilSettled,
8
- } from './authedApi';
7
+ TOKEN_REUSE_MS,
8
+ } from './constants';
9
9
  import { apiError, type DropClient } from './client';
10
- import { NotAuthenticatedError } from './errors';
10
+ import { DeployFailedError, DeployTimeoutError, NotAuthenticatedError } from './errors';
11
11
  import type { BuildSite, Digest, DropResponse } from './types';
12
12
 
13
13
  export interface AuthenticatedDropClientConfig {
@@ -42,13 +42,6 @@ export interface AuthenticatedDropClientConfig {
42
42
  timeoutMs?: number;
43
43
  }
44
44
 
45
- /**
46
- * How long a minted upload token is reused before minting another. The server
47
- * issues non-app origins a 30s token, so this leaves a margin for the request
48
- * it authorizes to finish.
49
- */
50
- const TOKEN_REUSE_MS = 20 * 1000;
51
-
52
45
  /** Response body of `POST /sites/{id}/deploys` — the slice the drop flow needs. */
53
46
  interface SiteDeployResponse {
54
47
  /** deploy id (BSON) */
@@ -104,11 +97,8 @@ export class AuthenticatedDropClient implements DropClient {
104
97
  }
105
98
 
106
99
  /**
107
- * The auth gate. Minting here — before anything is created — is what lets a
108
- * logged-out visitor fail cleanly and fall back to the anonymous flow. The
109
- * returned token is threaded through `deployFiles`, but only the uploads
110
- * actually need one, and they re-mint rather than trusting this to still be
111
- * valid by the time they run.
100
+ * The auth gate: minting here, before anything is created, is what lets a
101
+ * logged-out visitor fail cleanly and fall back to the anonymous flow.
112
102
  */
113
103
  async getToken(): Promise<string> {
114
104
  const token = await this.getUploadToken();
@@ -119,12 +109,8 @@ export class AuthenticatedDropClient implements DropClient {
119
109
 
120
110
  /**
121
111
  * A token young enough to authorize an upload, minting a new one once the
122
- * cached one nears its 30s expiry.
123
- *
124
- * Deliberately NOT `NotAuthenticatedError` when minting fails: by the time
125
- * uploads run the site already exists, and that error would send the drop
126
- * back through the anonymous path — leaving an empty project behind and
127
- * deploying a duplicate. See the class doc.
112
+ * cached one nears its 30s expiry. A mint failure here is a plain `Error`,
113
+ * never `NotAuthenticatedError` — the site exists by now (class doc).
128
114
  */
129
115
  private async freshToken(): Promise<string> {
130
116
  const now = Date.now();
@@ -143,8 +129,6 @@ export class AuthenticatedDropClient implements DropClient {
143
129
  * `deployFiles` runs unchanged over either client.
144
130
  */
145
131
  async createDeploy(files: Digest, _token: string): Promise<DropResponse> {
146
- // A logged-out visitor fails inside createSiteInAccount, before anything is
147
- // created — the one point an anonymous fallback is still safe (class doc).
148
132
  const site: BuildSite = await createSiteInAccount(this.proxyBase, this.accountSlug);
149
133
 
150
134
  const deployRes = await fetch(`${this.proxyBase}/sites/${site.id}/deploys`, {
@@ -197,14 +181,9 @@ export class AuthenticatedDropClient implements DropClient {
197
181
  timeoutMs: this.timeoutMs,
198
182
  });
199
183
  if (settled.outcome === 'ready') return;
200
- // Plain errors on purpose: humanizeDropError maps them, and nothing here
201
- // may look like NotAuthenticatedError once the site exists (class doc).
202
- if (settled.outcome === 'failed') {
203
- throw new Error(
204
- settled.errorMessage ? `deploy failed: ${settled.errorMessage}` : 'deploy failed'
205
- );
206
- }
207
- // Same message the anonymous client uses, so humanizeDropError maps it.
208
- throw new Error('Deploy did not become ready in time');
184
+ // Neither class is NotAuthenticatedError-shaped on purpose: once the site
185
+ // exists, nothing here may trigger the anonymous fallback (class doc).
186
+ if (settled.outcome === 'failed') throw new DeployFailedError(settled.errorMessage);
187
+ throw new DeployTimeoutError();
209
188
  }
210
189
  }
@@ -1,11 +1,10 @@
1
+ import { createSiteInAccount, pollDeployUntilSettled } from './authedApi';
1
2
  import {
2
- createSiteInAccount,
3
3
  DEFAULT_API_BASE,
4
4
  DEFAULT_POLL_INTERVAL_MS,
5
5
  DEFAULT_PROXY_BASE,
6
6
  DEFAULT_TIMEOUT_MS,
7
- pollDeployUntilSettled,
8
- } from './authedApi';
7
+ } from './constants';
9
8
  import {
10
9
  BuildCreateError,
11
10
  BuildFailedError,
@@ -1,3 +1,12 @@
1
+ /**
2
+ * The `DropClient` seam: the four calls a static deploy needs — mint a token,
3
+ * create the deploy, upload a file, wait until ready — behind one interface,
4
+ * so `deployFiles` runs the same pipeline whether the deploy is anonymous
5
+ * (this file's `AnonymousDropClient`, claimed later by the visitor) or lands
6
+ * directly in an account (`authedDropClient.ts`).
7
+ */
8
+ import { DEFAULT_API_BASE, DEFAULT_POLL_INTERVAL_MS, DEFAULT_TIMEOUT_MS } from './constants';
9
+ import { DeployTimeoutError } from './errors';
1
10
  import type { DropResponse, Digest } from './types';
2
11
 
3
12
  /** A normal Error with the response's HTTP status attached, so callers can branch on it directly. */
@@ -12,14 +21,16 @@ export interface DropClient {
12
21
  waitUntilReady(deploy: DropResponse, token: string): Promise<void>;
13
22
  }
14
23
 
15
- const API = 'https://api.netlify.com/api/v1';
16
-
17
24
  /**
18
25
  * Unauthenticated drop client. Authorized server-side by the Referer header,
19
26
  * so it only works from an allowlisted origin (see backend checklist).
20
27
  */
21
28
  export class AnonymousDropClient implements DropClient {
22
- constructor(private apiBase = API) {}
29
+ constructor(
30
+ private apiBase = DEFAULT_API_BASE,
31
+ private pollIntervalMs = DEFAULT_POLL_INTERVAL_MS,
32
+ private timeoutMs = DEFAULT_TIMEOUT_MS
33
+ ) {}
23
34
 
24
35
  async getToken(): Promise<string> {
25
36
  const res = await fetch(`${this.apiBase}/drop/token`, { method: 'POST' });
@@ -57,13 +68,14 @@ export class AnonymousDropClient implements DropClient {
57
68
  // Note: `/deploys/{id}` expects the deploy_id (BSON), not the site id (UUID)
58
69
  // that lives at `deploy.id`. See drop_controller.rb; Deploy.find(uuid) is nil
59
70
  // and the request 401s misleadingly via require_current_actor!.
60
- for (let i = 0; i < 600; i++) {
71
+ const deadline = this.timeoutMs / this.pollIntervalMs;
72
+ for (let i = 0; i < deadline; i++) {
61
73
  const res = await fetch(`${this.apiBase}/deploys/${deploy.deploy_id}`, {
62
74
  headers: { Authorization: `Bearer ${token}` },
63
75
  });
64
76
  if (res.ok && (await res.json()).state === 'ready') return;
65
- await new Promise(r => setTimeout(r, 1000));
77
+ await new Promise(r => setTimeout(r, this.pollIntervalMs));
66
78
  }
67
- throw new Error('Deploy did not become ready in time');
79
+ throw new DeployTimeoutError();
68
80
  }
69
81
  }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The tunables shared across drop-core, one place and one comment each.
3
+ * Module-specific constants (e.g. buildStash's storage caps) stay with their
4
+ * module; anything two modules read belongs here.
5
+ */
6
+
7
+ /**
8
+ * Root for session-cookie JSON calls, via the consumer's access-control
9
+ * rewrite. On netlify.com, `/access-control/*` targets the app's
10
+ * access-control function; the paths an origin may call are allowlisted
11
+ * server-side.
12
+ */
13
+ export const DEFAULT_PROXY_BASE = '/access-control/bb-api/api/v1';
14
+
15
+ /** Direct api.netlify.com root for the uploads the proxy's ~6MB Lambda body limit can't carry. */
16
+ export const DEFAULT_API_BASE = 'https://api.netlify.com/api/v1';
17
+
18
+ // How often a waiting deploy/build is re-read: 1 second
19
+ export const DEFAULT_POLL_INTERVAL_MS = 1000;
20
+
21
+ // How long to keep polling before giving up: 10 minutes
22
+ export const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
23
+
24
+ // Concurrent file uploads (matches netlify-cli's deploy engine)
25
+ export const MAX_CONCURRENT_UPLOADS = 5;
26
+
27
+ /**
28
+ * How long a minted upload token is reused before minting another: 20 seconds.
29
+ * The server issues non-app origins a 30s token; the gap leaves margin for the
30
+ * request the token authorizes to finish.
31
+ */
32
+ export const TOKEN_REUSE_MS = 20 * 1000;
33
+
34
+ /**
35
+ * Waits between attempts of a failed file upload, as data so the whole curve
36
+ * can be overridden (the tus-js-client `retryDelays` idiom). Three retries,
37
+ * linear backoff; 400/422 responses never retry — see `uploadWithRetry`.
38
+ */
39
+ export const UPLOAD_RETRY_DELAYS_MS: readonly number[] = [1_000, 2_000, 3_000];
@@ -71,6 +71,48 @@ describe('deployFiles', () => {
71
71
  expect(phases[1]).toEqual({ phase: 'uploading', uploaded: 0, total: 1 });
72
72
  });
73
73
 
74
+ it('retries a transient upload failure and succeeds', async () => {
75
+ const { client } = stubDropClient(['aaa']);
76
+ let attempts = 0;
77
+ (client.uploadFile as ReturnType<typeof vi.fn>).mockImplementation(async () => {
78
+ if (++attempts === 1) throw Object.assign(new Error('upload failed: 502'), { status: 502 });
79
+ });
80
+ await deployFiles(client, files, { retryDelays: [0, 0] });
81
+ expect(attempts).toBe(2);
82
+ });
83
+
84
+ it('gives up once the retry curve is exhausted', async () => {
85
+ const { client } = stubDropClient(['aaa']);
86
+ let attempts = 0;
87
+ (client.uploadFile as ReturnType<typeof vi.fn>).mockImplementation(async () => {
88
+ attempts++;
89
+ throw Object.assign(new Error('upload failed: 503'), { status: 503 });
90
+ });
91
+ await expect(deployFiles(client, files, { retryDelays: [0, 0] })).rejects.toThrow('503');
92
+ expect(attempts).toBe(3); // initial try + one per delay
93
+ });
94
+
95
+ it('never retries a 400/422 — the request itself is wrong', async () => {
96
+ const { client } = stubDropClient(['aaa']);
97
+ let attempts = 0;
98
+ (client.uploadFile as ReturnType<typeof vi.fn>).mockImplementation(async () => {
99
+ attempts++;
100
+ throw Object.assign(new Error('upload failed: 422'), { status: 422 });
101
+ });
102
+ await expect(deployFiles(client, files, { retryDelays: [0, 0] })).rejects.toThrow('422');
103
+ expect(attempts).toBe(1);
104
+ });
105
+
106
+ it('retries statusless network failures too', async () => {
107
+ const { client } = stubDropClient(['aaa']);
108
+ let attempts = 0;
109
+ (client.uploadFile as ReturnType<typeof vi.fn>).mockImplementation(async () => {
110
+ if (++attempts === 1) throw new TypeError('fetch failed');
111
+ });
112
+ await deployFiles(client, files, { retryDelays: [0] });
113
+ expect(attempts).toBe(2);
114
+ });
115
+
74
116
  it('throws on an empty file set before any network call', async () => {
75
117
  const { client } = stubDropClient([]);
76
118
  await expect(deployFiles(client, [])).rejects.toThrow('No files to deploy');
@@ -1,3 +1,4 @@
1
+ import { MAX_CONCURRENT_UPLOADS, UPLOAD_RETRY_DELAYS_MS } from './constants';
1
2
  import { getDigest } from './digest';
2
3
  import type { BuildClient, BuildResponse, BuildSite } from './buildClient';
3
4
  import type { DropClient } from './client';
@@ -5,7 +6,33 @@ import type { InferredBuildSettings } from './detectBuild';
5
6
  import type { DeployProgress, DeployResult, ProcessedFile } from './types';
6
7
  import { zipFiles, type ZipResult } from './zip';
7
8
 
8
- const MAX_CONCURRENT_UPLOADS = 5;
9
+ /** Responses that mean the request itself is wrong — retrying can only repeat the answer. */
10
+ const NON_RETRIABLE_STATUSES = new Set([400, 422]);
11
+
12
+ /**
13
+ * Upload one file, retrying transient failures (5xx, 429, network errors) on
14
+ * the given delay curve. A single flaky PUT among hundreds shouldn't kill the
15
+ * whole deploy — every deploy client we know of retries here.
16
+ */
17
+ async function uploadWithRetry(
18
+ client: DropClient,
19
+ deployId: string,
20
+ file: ProcessedFile,
21
+ token: string,
22
+ retryDelays: readonly number[]
23
+ ): Promise<void> {
24
+ for (let attempt = 0; ; attempt++) {
25
+ try {
26
+ await client.uploadFile(deployId, file.path, file.content, token);
27
+ return;
28
+ } catch (err) {
29
+ const status = (err as { status?: number }).status;
30
+ if (status !== undefined && NON_RETRIABLE_STATUSES.has(status)) throw err;
31
+ if (attempt >= retryDelays.length) throw err;
32
+ await new Promise(r => setTimeout(r, retryDelays[attempt]));
33
+ }
34
+ }
35
+ }
9
36
 
10
37
  async function uploadRequired(
11
38
  client: DropClient,
@@ -13,6 +40,7 @@ async function uploadRequired(
13
40
  files: ProcessedFile[],
14
41
  required: Set<string>,
15
42
  token: string,
43
+ retryDelays: readonly number[],
16
44
  onUploaded?: (uploaded: number) => void
17
45
  ) {
18
46
  const queue = files.filter(f => required.has(f.sha));
@@ -21,7 +49,7 @@ async function uploadRequired(
21
49
  const worker = async () => {
22
50
  while (cursor < queue.length) {
23
51
  const f = queue[cursor++];
24
- await client.uploadFile(deployId, f.path, f.content, token);
52
+ await uploadWithRetry(client, deployId, f, token, retryDelays);
25
53
  onUploaded?.(++done);
26
54
  }
27
55
  };
@@ -30,6 +58,8 @@ async function uploadRequired(
30
58
 
31
59
  export interface DeployOptions {
32
60
  onProgress?: (e: DeployProgress) => void;
61
+ /** Waits between retries of a failed file upload. Override for tests or a different curve. */
62
+ retryDelays?: readonly number[];
33
63
  }
34
64
 
35
65
  export async function deployFiles(
@@ -38,7 +68,7 @@ export async function deployFiles(
38
68
  opts: DeployOptions = {}
39
69
  ): Promise<DeployResult> {
40
70
  if (!files.length) throw new Error('No files to deploy');
41
- const { onProgress } = opts;
71
+ const { onProgress, retryDelays = UPLOAD_RETRY_DELAYS_MS } = opts;
42
72
 
43
73
  onProgress?.({ phase: 'creating' });
44
74
  const token = await client.getToken();
@@ -48,8 +78,14 @@ export async function deployFiles(
48
78
  onProgress?.({ phase: 'uploading', uploaded: 0, total });
49
79
  // File PUTs and readiness polling both key off `deploy_id` (BSON), NOT
50
80
  // `deploy.id` (the site UUID) — see the note in client.waitUntilReady.
51
- await uploadRequired(client, deploy.deploy_id, files, new Set(deploy.required), token, uploaded =>
52
- onProgress?.({ phase: 'uploading', uploaded, total })
81
+ await uploadRequired(
82
+ client,
83
+ deploy.deploy_id,
84
+ files,
85
+ new Set(deploy.required),
86
+ token,
87
+ retryDelays,
88
+ uploaded => onProgress?.({ phase: 'uploading', uploaded, total })
53
89
  );
54
90
 
55
91
  onProgress?.({ phase: 'processing' });
@@ -4,12 +4,39 @@ import {
4
4
  BuildCreateError,
5
5
  BuildFailedError,
6
6
  BuildTimeoutError,
7
+ DeployFailedError,
8
+ DeployTimeoutError,
7
9
  humanizeDropError,
10
+ isNotAuthenticatedError,
8
11
  NotAuthenticatedError,
9
12
  SiteCreateError,
10
13
  UnsupportedDropError,
11
14
  } from './errors';
12
15
 
16
+ describe('error codes', () => {
17
+ it('pins each class to its copy-proof code brand', () => {
18
+ expect(new UnsupportedDropError().code).toBe('unsupported-drop');
19
+ expect(new NotAuthenticatedError().code).toBe('not-authenticated');
20
+ expect(new SiteCreateError().code).toBe('site-create-failed');
21
+ expect(new BuildCreateError().code).toBe('build-create-failed');
22
+ expect(new BuildFailedError().code).toBe('build-failed');
23
+ expect(new BuildTimeoutError().code).toBe('build-timeout');
24
+ expect(new DeployFailedError().code).toBe('deploy-failed');
25
+ expect(new DeployTimeoutError().code).toBe('deploy-timeout');
26
+ });
27
+
28
+ it('recognizes a foreign-copy NotAuthenticatedError by its code', () => {
29
+ // Raw-TS and dist entrypoints can each load their own class copy;
30
+ // instanceof fails across copies, the code brand does not.
31
+ const foreignCopy = Object.assign(new Error('not authenticated'), {
32
+ code: 'not-authenticated',
33
+ });
34
+ expect(isNotAuthenticatedError(foreignCopy)).toBe(true);
35
+ expect(isNotAuthenticatedError(new Error('nope'))).toBe(false);
36
+ expect(isNotAuthenticatedError('not-authenticated')).toBe(false);
37
+ });
38
+ });
39
+
13
40
  describe('humanizeDropError', () => {
14
41
  it('maps each typed error to its user-facing copy', () => {
15
42
  expect(humanizeDropError(new UnsupportedDropError())).toBe(
@@ -44,10 +71,12 @@ describe('humanizeDropError', () => {
44
71
  expect(humanizeDropError(new BuildCreateError(413))).toContain('too large');
45
72
  });
46
73
 
47
- it('maps the shared deploy-timeout message', () => {
48
- expect(humanizeDropError(new Error('Deploy did not become ready in time'))).toContain(
49
- 'taking longer than expected'
74
+ it('maps the deploy timeout and failure classes', () => {
75
+ expect(humanizeDropError(new DeployTimeoutError())).toContain('taking longer than expected');
76
+ expect(humanizeDropError(new DeployFailedError('no index.html'))).toBe(
77
+ 'Your deploy failed: no index.html'
50
78
  );
79
+ expect(humanizeDropError(new DeployFailedError())).toBe('Your deploy failed. Please try again.');
51
80
  });
52
81
 
53
82
  it('reads a fetch TypeError as a connectivity problem', () => {