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

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 (53) hide show
  1. package/README.md +3 -4
  2. package/dist/components/preact/DropZone/DropZone.d.ts +23 -98
  3. package/dist/components/preact/DropZone/DropZone.js +5 -5
  4. package/dist/components/preact/DropZone/index.js +29 -29
  5. package/dist/components/preact/DropZone/useDropDeploy.d.ts +0 -1
  6. package/dist/components/preact/DropZone/useDropDeploy.js +25 -27
  7. package/dist/components/preact/index.js +10 -10
  8. package/dist/drop-core/analytics.d.ts +4 -15
  9. package/dist/drop-core/authedApi.d.ts +9 -32
  10. package/dist/drop-core/authedApi.js +15 -20
  11. package/dist/drop-core/authedDropClient.d.ts +15 -64
  12. package/dist/drop-core/authedDropClient.js +27 -49
  13. package/dist/drop-core/buildClient.d.ts +19 -53
  14. package/dist/drop-core/buildClient.js +32 -44
  15. package/dist/drop-core/buildStash.d.ts +6 -20
  16. package/dist/drop-core/client.d.ts +4 -2
  17. package/dist/drop-core/client.js +19 -17
  18. package/dist/drop-core/constants.d.ts +9 -0
  19. package/dist/drop-core/constants.js +10 -0
  20. package/dist/drop-core/deploy.d.ts +7 -17
  21. package/dist/drop-core/deploy.js +56 -43
  22. package/dist/drop-core/detectBuild.d.ts +8 -26
  23. package/dist/drop-core/errors.d.ts +27 -17
  24. package/dist/drop-core/errors.js +70 -33
  25. package/dist/drop-core/fileTypes.d.ts +4 -14
  26. package/dist/drop-core/index.d.ts +22 -11
  27. package/dist/drop-core/index.js +45 -49
  28. package/dist/drop-core/readFiles.d.ts +5 -11
  29. package/dist/drop-core/types.d.ts +2 -4
  30. package/dist/drop-core/zip.d.ts +7 -30
  31. package/package.json +1 -1
  32. package/packages/components/preact/DropZone/DropZone.tsx +27 -122
  33. package/packages/components/preact/DropZone/useDropDeploy.ts +31 -73
  34. package/packages/drop-core/README.md +91 -0
  35. package/packages/drop-core/analytics.ts +4 -15
  36. package/packages/drop-core/authedApi.ts +9 -35
  37. package/packages/drop-core/authedDropClient.ts +23 -86
  38. package/packages/drop-core/buildClient.test.ts +19 -4
  39. package/packages/drop-core/buildClient.ts +23 -66
  40. package/packages/drop-core/buildStash.ts +9 -25
  41. package/packages/drop-core/client.ts +19 -11
  42. package/packages/drop-core/constants.ts +25 -0
  43. package/packages/drop-core/deploy.test.ts +42 -0
  44. package/packages/drop-core/deploy.ts +42 -22
  45. package/packages/drop-core/detectBuild.ts +15 -50
  46. package/packages/drop-core/errors.test.ts +34 -3
  47. package/packages/drop-core/errors.ts +63 -27
  48. package/packages/drop-core/fileTypes.ts +5 -19
  49. package/packages/drop-core/index.ts +41 -11
  50. package/packages/drop-core/readFiles.test.ts +0 -1
  51. package/packages/drop-core/readFiles.ts +5 -12
  52. package/packages/drop-core/types.ts +2 -4
  53. package/packages/drop-core/zip.ts +7 -30
@@ -1,20 +1,13 @@
1
1
  import type { BuildReason } from './detectBuild';
2
2
 
3
- /**
4
- * The Drop Zone's analytics contract — the canonical event names and property
5
- * shapes, exported so no consumer (this package's own component, a host site's
6
- * GTM wiring, or another Netlify surface implementing its own Drop UI over
7
- * drop-core) ever hand-writes the strings.
8
- */
3
+ /** The analytics contract: canonical event names + property shapes, so no consumer hand-writes them. */
9
4
 
10
5
  /** How the files reached the zone — carried on every drop-initiated event as `method`. */
11
6
  export type DropMethod = 'drag' | 'browse';
12
7
 
13
8
  /**
14
- * Canonical Drop Zone lifecycle events. The values are the wire names:
15
- * analytics history in Amplitude/Segment keys off these exact strings, so
16
- * renaming a value silently forks every funnel built on it — treat them as
17
- * append-only.
9
+ * The wire names. Amplitude/Segment history keys off these exact strings —
10
+ * append-only, never rename.
18
11
  */
19
12
  export const DROPZONE_EVENTS = {
20
13
  /** Files arrived via drag — the picker path reports its files on outcome events instead. */
@@ -114,9 +107,5 @@ export interface DropZoneEventProperties {
114
107
  dropzone_resumed: ResumedProps;
115
108
  }
116
109
 
117
- /**
118
- * Provider-agnostic analytics sink. Deliberately loose (string, not the event
119
- * union): hosts forward these to Segment/GA/GTM and often pipe their own
120
- * events through the same function.
121
- */
110
+ /** Provider-agnostic sink — loose on purpose, since hosts pipe their own events through it too. */
122
111
  export type TrackFn = (event: string, properties?: Record<string, unknown>) => void;
@@ -2,35 +2,15 @@ import type { BuildSite } from './types';
2
2
  import { NotAuthenticatedError, SiteCreateError } from './errors';
3
3
 
4
4
  /**
5
- * The transport both authenticated clients share: JSON calls ride the
6
- * consumer's access-control rewrite on the session cookie, so nothing
7
- * credential-shaped lives in the browser and nothing can expire mid-deploy.
8
- * (Uploads are the exception — the proxy's Lambda caps request bodies at
9
- * ~6MB — and each client handles its own, bearer-authorized.)
5
+ * Transport shared by both authenticated clients: JSON rides the consumer's
6
+ * access-control rewrite on the session cookie. Uploads are each client's own
7
+ * (bearer-direct — the proxy can't carry them).
10
8
  */
11
9
 
12
10
  /**
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
- /**
26
- * Create a site in the visitor's account via `POST {proxyBase}/sites` (scoped
27
- * to `accountSlug` when given, else the user's default account), attributed
28
- * `created_via: 'drop'`.
29
- *
30
- * A logged-out visitor 401s here, before anything is created — the one
31
- * pre-mutation point where callers may still safely fall back to an anonymous
32
- * flow, which is why it maps to `NotAuthenticatedError`. Any other failure is
33
- * a `SiteCreateError` carrying the status.
11
+ * `POST {proxyBase}/sites`, attributed `created_via: 'drop'`. A logged-out
12
+ * visitor 401s here, before anything is created — the one point an anonymous
13
+ * fallback is still safe, hence NotAuthenticatedError.
34
14
  */
35
15
  export async function createSiteInAccount(
36
16
  proxyBase: string,
@@ -61,15 +41,9 @@ interface DeployState {
61
41
  }
62
42
 
63
43
  /**
64
- * Poll `GET {proxyBase}/deploys/{deployId}` on the session cookie until the
65
- * deploy settles, and report how. Purely mechanical on purpose: each caller
66
- * maps the outcome onto its own error vocabulary (the drop client throws plain
67
- * errors `humanizeDropError` understands; the build client throws
68
- * `BuildFailedError`/`BuildTimeoutError`), so that knowledge stays next to the
69
- * class it belongs to.
70
- *
71
- * A non-ok poll response is skipped, not fatal — a transient proxy hiccup
72
- * shouldn't kill a deploy that is still progressing.
44
+ * Polls the deploy on the session cookie and reports the terminal outcome —
45
+ * callers map it onto their own error vocabulary. Non-ok reads are skipped,
46
+ * not fatal: a proxy hiccup shouldn't kill a progressing deploy.
73
47
  */
74
48
  export async function pollDeployUntilSettled(options: {
75
49
  proxyBase: string;
@@ -1,37 +1,23 @@
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 {
14
- /**
15
- * Base for JSON API calls, routed through the consumer's access-control
16
- * rewrite so the session cookie authenticates them. On netlify.com that
17
- * rewrite is `/access-control/*` → the app's access-control function, which
18
- * makes `/access-control/bb-api/api/v1` the API root (the default here).
19
- */
14
+ /** Root for session-cookie JSON calls, via the consumer's access-control rewrite. */
20
15
  proxyBase?: string;
21
- /**
22
- * Direct API base for the file uploads. The proxy above runs in a Lambda with
23
- * a ~6MB request body limit, so file PUTs go straight to `api.netlify.com`
24
- * (whose `/api/*` CORS policy allows any origin).
25
- */
16
+ /** Direct base for file PUTs — the proxy's ~6MB Lambda body limit can't carry uploads. */
26
17
  apiBase?: string;
27
18
  /**
28
- * Mint a bearer token for those direct uploads. On netlify.com this is
29
- * `GET /access-control/generate-access-control-token`, whose
30
- * `accessControlToken` the API decrypts back into the user's access token.
31
- * Resolving `null` means "no session" and surfaces as `NotAuthenticatedError`.
32
- *
33
- * Called repeatedly, not once: the token issued to a non-app origin lives
34
- * only 30 seconds, which a multi-file upload routinely outlasts.
19
+ * Mint the upload bearer; `null` means "no session". Called repeatedly —
20
+ * non-app origins get 30-second tokens, which an upload queue outlasts.
35
21
  */
36
22
  getUploadToken: () => Promise<string | null>;
37
23
  /** Create the site in this account. Omit to use the user's default account. */
@@ -42,13 +28,6 @@ export interface AuthenticatedDropClientConfig {
42
28
  timeoutMs?: number;
43
29
  }
44
30
 
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
31
  /** Response body of `POST /sites/{id}/deploys` — the slice the drop flow needs. */
53
32
  interface SiteDeployResponse {
54
33
  /** deploy id (BSON) */
@@ -58,25 +37,10 @@ interface SiteDeployResponse {
58
37
  }
59
38
 
60
39
  /**
61
- * Authenticated drop client for logged-in visitors. Unlike `AnonymousDropClient`
62
- * — which creates an account-less site the visitor must later claim — this
63
- * deploys straight into the visitor's account via the sites/deploys API,
64
- * mirroring what app.netlify.com does for a logged-in drag-and-drop deploy.
65
- * There is no claim step, so the caller should hand off to the site's dashboard
66
- * page rather than the claim page.
67
- *
68
- * Requests split across two hosts by necessity. JSON calls (create the site,
69
- * create the deploy, poll it) go through the consumer's access-control proxy on
70
- * the session cookie — no token involved, and nothing that can expire mid-deploy.
71
- * File uploads can't: the proxy caps bodies at ~6MB, so they go direct to
72
- * `api.netlify.com` with a bearer, re-minted as it ages (see `TOKEN_REUSE_MS`).
73
- *
74
- * Error-mapping invariant: only pre-mutation failures — a null upload token at
75
- * the gate, or a 401/403 creating the site — throw `NotAuthenticatedError`,
76
- * which is the signal the drop flow may safely retry anonymously. Once the site
77
- * exists, any auth failure throws a plain error instead: falling back at that
78
- * point would strand an empty site in the account *and* deploy a duplicate
79
- * anonymously.
40
+ * Deploys a static drop straight into a logged-in visitor's account — no claim
41
+ * step. JSON rides the cookie proxy; file PUTs go direct on a re-minted bearer.
42
+ * Invariant: NotAuthenticatedError only pre-mutation — after the site exists, a
43
+ * fallback would strand an empty site and deploy a duplicate.
80
44
  */
81
45
  export class AuthenticatedDropClient implements DropClient {
82
46
  private proxyBase: string;
@@ -104,11 +68,8 @@ export class AuthenticatedDropClient implements DropClient {
104
68
  }
105
69
 
106
70
  /**
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.
71
+ * The auth gate: minting here, before anything is created, is what lets a
72
+ * logged-out visitor fail cleanly and fall back to the anonymous flow.
112
73
  */
113
74
  async getToken(): Promise<string> {
114
75
  const token = await this.getUploadToken();
@@ -118,13 +79,8 @@ export class AuthenticatedDropClient implements DropClient {
118
79
  }
119
80
 
120
81
  /**
121
- * 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.
82
+ * A token younger than its 30s expiry. Mint failures here are plain Errors,
83
+ * never NotAuthenticatedError — the site exists by now (class doc).
128
84
  */
129
85
  private async freshToken(): Promise<string> {
130
86
  const now = Date.now();
@@ -137,14 +93,8 @@ export class AuthenticatedDropClient implements DropClient {
137
93
  return value;
138
94
  }
139
95
 
140
- /**
141
- * Two proxied calls: create the site in the visitor's account, then the
142
- * deploy on it. Mapped into the anonymous `DropResponse` shape so
143
- * `deployFiles` runs unchanged over either client.
144
- */
96
+ /** Site + deploy via the proxy, mapped into `DropResponse` so `deployFiles` runs unchanged. */
145
97
  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
98
  const site: BuildSite = await createSiteInAccount(this.proxyBase, this.accountSlug);
149
99
 
150
100
  const deployRes = await fetch(`${this.proxyBase}/sites/${site.id}/deploys`, {
@@ -164,11 +114,7 @@ export class AuthenticatedDropClient implements DropClient {
164
114
  };
165
115
  }
166
116
 
167
- /**
168
- * Direct to `api.netlify.com` on a freshly-aged bearer — the threaded `token`
169
- * is ignored because an upload queue easily outlives the 30 seconds it was
170
- * minted with. PUT (not POST) so paths with "&" etc. work.
171
- */
117
+ /** Direct on a fresh bearer (the threaded one has expired). PUT so paths with "&" work. */
172
118
  async uploadFile(
173
119
  deployId: string,
174
120
  path: string,
@@ -184,11 +130,7 @@ export class AuthenticatedDropClient implements DropClient {
184
130
  if (!res.ok) throw apiError(`upload ${path}`, res.status);
185
131
  }
186
132
 
187
- /**
188
- * Poll the deploy through the proxy until it settles: resolve on `ready`,
189
- * throw on `error`/`rejected`, throw on timeout. On the session cookie rather
190
- * than a bearer, so a deploy taking minutes can't outlive its own credentials.
191
- */
133
+ /** Polls on the session cookie, so a slow deploy can't outlive its own credentials. */
192
134
  async waitUntilReady(deploy: DropResponse, _token: string): Promise<void> {
193
135
  const settled = await pollDeployUntilSettled({
194
136
  proxyBase: this.proxyBase,
@@ -197,14 +139,9 @@ export class AuthenticatedDropClient implements DropClient {
197
139
  timeoutMs: this.timeoutMs,
198
140
  });
199
141
  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');
142
+ // Neither class is NotAuthenticatedError-shaped on purpose: once the site
143
+ // exists, nothing here may trigger the anonymous fallback (class doc).
144
+ if (settled.outcome === 'failed') throw new DeployFailedError(settled.errorMessage);
145
+ throw new DeployTimeoutError();
209
146
  }
210
147
  }
@@ -56,10 +56,25 @@ describe('AuthenticatedBuildClient.createBuildFromZip', () => {
56
56
  expect(body.get('title')).toBe('Build from drop deployment');
57
57
  });
58
58
 
59
- it('throws NotAuthenticatedError when no upload token can be minted', async () => {
60
- await expect(client(async () => null).createBuildFromZip('s', zip)).rejects.toBeInstanceOf(
61
- NotAuthenticatedError
62
- );
59
+ // The site exists by the time this method runs, so a NotAuthenticatedError
60
+ // here would read as "nothing was created" and send the drop back to the
61
+ // stash — leaving an empty project behind, and another on the next resume.
62
+ it('reports a missing upload token as BuildCreateError, never NotAuthenticatedError', async () => {
63
+ const failure = await client(async () => null)
64
+ .createBuildFromZip('s', zip)
65
+ .catch(e => e);
66
+ expect(failure).toBeInstanceOf(BuildCreateError);
67
+ expect(failure).not.toBeInstanceOf(NotAuthenticatedError);
68
+ });
69
+
70
+ it('reports a 401 from an expired upload token as BuildCreateError', async () => {
71
+ vi.stubGlobal('fetch', async () => ({ ok: false, status: 401, json: async () => ({}) }));
72
+ const failure = await client()
73
+ .createBuildFromZip('s', zip)
74
+ .catch(e => e);
75
+ expect(failure).toBeInstanceOf(BuildCreateError);
76
+ expect(failure).not.toBeInstanceOf(NotAuthenticatedError);
77
+ expect(failure.status).toBe(401);
63
78
  });
64
79
 
65
80
  it('wraps other upload failures in BuildCreateError with the status', async () => {
@@ -1,24 +1,17 @@
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';
9
- import {
10
- BuildCreateError,
11
- BuildFailedError,
12
- BuildTimeoutError,
13
- NotAuthenticatedError,
14
- } from './errors';
7
+ } from './constants';
8
+ import { BuildCreateError, BuildFailedError, BuildTimeoutError } from './errors';
15
9
  import type { BuildSite } from './types';
16
10
 
17
11
  export type { BuildSite } from './types';
18
12
 
19
13
  /** The subset of a Netlify Build returned by `POST /sites/{id}/builds`. */
20
14
  export interface BuildResponse {
21
- /** build id */
22
15
  id: string;
23
16
  /** the deploy the build produces — present as soon as the build is enqueued */
24
17
  deploy_id?: string;
@@ -31,28 +24,14 @@ export interface BuildClient {
31
24
  }
32
25
 
33
26
  export interface AuthenticatedBuildClientConfig {
34
- /**
35
- * Base for JSON API calls, routed through the consumer's access-control
36
- * rewrite so the session cookie authenticates them. On netlify.com that
37
- * rewrite is `/access-control/*` → the app's access-control function, which
38
- * makes `/access-control/bb-api/api/v1` the API root (the default here).
39
- */
27
+ /** Root for session-cookie JSON calls, via the consumer's access-control rewrite. */
40
28
  proxyBase?: string;
41
- /**
42
- * Direct API base for the zip upload. The proxy above runs in a Lambda with a
43
- * ~6MB request body limit, so the multipart POST has to go straight to
44
- * `api.netlify.com` (whose `/api/*` CORS policy allows any origin).
45
- */
29
+ /** Direct base for the zip upload — the proxy's ~6MB Lambda body limit can't carry it. */
46
30
  apiBase?: string;
47
31
  /**
48
- * Mint a bearer token for that direct upload. On netlify.com this is
49
- * `GET /access-control/generate-access-control-token`, whose
50
- * `accessControlToken` the API decrypts back into the user's access token.
51
- * Resolving `null` means "no session" and surfaces as `NotAuthenticatedError`.
52
- *
53
- * Note the token issued to a non-app origin lives only 30 seconds. It is
54
- * minted immediately before the upload, but a zip that takes longer than that
55
- * to transfer will still be rejected mid-flight — see `createBuildFromZip`.
32
+ * Mint the upload bearer; `null` means "no session". Non-app origins get
33
+ * 30-second tokens — a zip slower than that fails mid-flight (see
34
+ * `createBuildFromZip`).
56
35
  */
57
36
  getUploadToken: () => Promise<string | null>;
58
37
  /** Create the site in this account. Omit to use the user's default account. */
@@ -66,16 +45,9 @@ export interface AuthenticatedBuildClientConfig {
66
45
  }
67
46
 
68
47
  /**
69
- * Authenticated drop client for projects that need a build. Unlike
70
- * `AnonymousDropClient` — which creates an account-less static site and can only
71
- * upload pre-built files — this creates a real site in the user's account and
72
- * hands buildbot a source archive to build, mirroring what app.netlify.com does
73
- * for a drag-and-drop build.
74
- *
75
- * Requests split across two hosts by necessity: JSON calls go through the
76
- * consumer's access-control proxy (cookie session, no token handling in the
77
- * browser), while the zip upload goes direct to `api.netlify.com` with a
78
- * short-lived bearer token because the proxy can't carry a body that large.
48
+ * Builds a drop in a logged-in visitor's account: create the site, hand
49
+ * buildbot the source zip. JSON rides the cookie proxy; the zip goes direct
50
+ * with a bearer (the proxy can't carry it).
79
51
  */
80
52
  export class AuthenticatedBuildClient implements BuildClient {
81
53
  private proxyBase: string;
@@ -104,29 +76,24 @@ export class AuthenticatedBuildClient implements BuildClient {
104
76
  this.timeoutMs = timeoutMs;
105
77
  }
106
78
 
107
- /**
108
- * Create the site the build deploys into. A logged-out visitor fails here,
109
- * before anything is created — see `createSiteInAccount` for the error
110
- * mapping that makes the signup fallback safe.
111
- */
79
+ /** A logged-out visitor fails here, before anything is created (see `createSiteInAccount`). */
112
80
  async createSite(): Promise<BuildSite> {
113
81
  return createSiteInAccount(this.proxyBase, this.accountSlug);
114
82
  }
115
83
 
116
84
  /**
117
- * Upload the source archive and enqueue a build. Direct to `api.netlify.com`
118
- * (see the note on `apiBase`), authorized by a freshly minted bearer token.
119
- * `Content-Type` is left unset on purpose so the browser adds the multipart
120
- * boundary itself.
85
+ * One multipart request on a fresh 30s bearer — a slower zip fails mid-flight
86
+ * (the app origin gets 300s; raising ours is a platform-side change).
87
+ * Content-Type stays unset so the browser adds the multipart boundary.
121
88
  *
122
- * Known ceiling: this is one request on a 30s token, and re-minting can't
123
- * help once it is in flight. A zip that takes longer than that to upload will
124
- * fail — the app origin gets 300s for exactly this reason, and lifting the
125
- * limit for other origins is a platform-side change.
89
+ * Never NotAuthenticatedError, including for a 401: the site exists by the
90
+ * time this runs, so signalling "no session" would strand it and re-stash
91
+ * the drop for the next visit to strand another. `createSite` is the auth
92
+ * gate for this path.
126
93
  */
127
94
  async createBuildFromZip(siteId: string, zip: File): Promise<BuildResponse> {
128
95
  const token = await this.getUploadToken();
129
- if (!token) throw new NotAuthenticatedError();
96
+ if (!token) throw new BuildCreateError();
130
97
 
131
98
  const body = new FormData();
132
99
  body.append('zip', zip);
@@ -137,22 +104,14 @@ export class AuthenticatedBuildClient implements BuildClient {
137
104
  headers: { Authorization: `Bearer ${token}` },
138
105
  body,
139
106
  });
140
- if (res.status === 401 || res.status === 403) throw new NotAuthenticatedError();
141
107
  if (!res.ok) throw new BuildCreateError(res.status);
142
108
  return res.json();
143
109
  }
144
110
 
145
111
  /**
146
- * Poll the build's deploy until it settles, mirroring
147
- * `AnonymousDropClient.waitUntilReady` — resolve on `ready`, throw on
148
- * `error`/`rejected`, throw on timeout. A build that hasn't been given a
149
- * deploy id yet has nothing pollable, so it resolves immediately and the
150
- * caller falls back to the site page.
151
- *
152
- * Optional in the drop flow, and off by default: a build takes minutes, so
153
- * the useful hand-off is usually the project page rather than a spinner.
154
- * Polling rides the session cookie, so it cannot outlive its credentials the
155
- * way a 30s bearer would.
112
+ * Optional (a build takes minutes; the useful hand-off is the project page).
113
+ * Polls on the cookie, so it can't outlive its credentials; no deploy id yet
114
+ * means nothing pollable, so it resolves immediately.
156
115
  */
157
116
  async waitUntilBuildReady(build: BuildResponse): Promise<void> {
158
117
  if (!build.deploy_id) return;
@@ -164,8 +123,6 @@ export class AuthenticatedBuildClient implements BuildClient {
164
123
  timeoutMs: this.timeoutMs,
165
124
  });
166
125
  if (settled.outcome === 'ready') return;
167
- // The build's own error message is the only actionable part of a failure,
168
- // so it travels on the typed error rather than being flattened away.
169
126
  if (settled.outcome === 'failed') throw new BuildFailedError(settled.errorMessage);
170
127
  throw new BuildTimeoutError();
171
128
  }
@@ -1,21 +1,11 @@
1
1
  import type { InferredBuildSettings } from './detectBuild';
2
2
 
3
3
  /**
4
- * Carry a build-required drop across the signup round-trip.
5
- *
6
- * A logged-out visitor's drop can't be built (builds have no anonymous
7
- * equivalent), so before handing them to signup the zone stashes the source
8
- * archive here — IndexedDB, because the zip is far beyond localStorage's
9
- * budget. When they return to this origin authenticated, the zone deploys the
10
- * stash automatically instead of asking them to drop again.
11
- *
12
- * Storage is per-origin: a stash written on www.netlify.com can only ever be
13
- * resumed on www.netlify.com. That is the design, not a limitation — it is
14
- * exactly why the hand-off page must bring the visitor back here.
15
- *
16
- * Every operation is failure-swallowing: the stash is an enhancement, and a
17
- * browser without IndexedDB (or with a full quota) degrades to the plain
18
- * signup redirect the zone always supported.
4
+ * Carries a build drop across the signup round-trip: stash the zip in
5
+ * IndexedDB before the hand-off, deploy it when the visitor returns
6
+ * authenticated. Storage is per-origin by design — the hand-off must bring
7
+ * them back here. Every operation swallows failures: no storage means the
8
+ * plain signup redirect the zone always supported.
19
9
  */
20
10
 
21
11
  export interface DropBuildStash {
@@ -92,11 +82,7 @@ const clearMarker = () => {
92
82
  localStorage.removeItem(MARKER_KEY);
93
83
  };
94
84
 
95
- /**
96
- * Stash a drop's source archive for an automatic deploy after signup. Returns
97
- * whether it was actually stored — callers use that only to phrase the
98
- * hand-off, never to block it.
99
- */
85
+ /** Stash the archive for auto-deploy after signup. The result phrases the hand-off, never blocks it. */
100
86
  export async function saveDropStash(
101
87
  zipFile: File,
102
88
  buildSettings: InferredBuildSettings | null = null
@@ -139,11 +125,9 @@ export async function loadDropStash(): Promise<DropBuildStash | null> {
139
125
  }
140
126
 
141
127
  /**
142
- * The archive back out of storage as a real `File`. Structured clone should
143
- * preserve File identity, but implementations vary in whether the name and
144
- * prototype survive — and the build upload needs both (the multipart field is
145
- * named by the File). A Blob-shaped survivor is rewrapped; anything else is
146
- * treated as a corrupt record.
128
+ * Rewraps a Blob-shaped survivor into a named File — structured-clone
129
+ * implementations vary in preserving File identity, and the multipart upload
130
+ * needs the name. Anything less Blob-shaped is a corrupt record.
147
131
  */
148
132
  function rehydrateZipFile(stored: unknown): File | null {
149
133
  if (stored instanceof File && stored.name) return stored;
@@ -1,3 +1,9 @@
1
+ /**
2
+ * The DropClient seam: the four calls a static deploy needs, behind one
3
+ * interface, so `deployFiles` runs the same pipeline anonymous or authenticated.
4
+ */
5
+ import { DEFAULT_API_BASE, DEFAULT_POLL_INTERVAL_MS, DEFAULT_TIMEOUT_MS } from './constants';
6
+ import { DeployTimeoutError } from './errors';
1
7
  import type { DropResponse, Digest } from './types';
2
8
 
3
9
  /** A normal Error with the response's HTTP status attached, so callers can branch on it directly. */
@@ -12,14 +18,16 @@ export interface DropClient {
12
18
  waitUntilReady(deploy: DropResponse, token: string): Promise<void>;
13
19
  }
14
20
 
15
- const API = 'https://api.netlify.com/api/v1';
16
-
17
21
  /**
18
22
  * Unauthenticated drop client. Authorized server-side by the Referer header,
19
- * so it only works from an allowlisted origin (see backend checklist).
23
+ * so it only works from an allowlisted origin.
20
24
  */
21
25
  export class AnonymousDropClient implements DropClient {
22
- constructor(private apiBase = API) {}
26
+ constructor(
27
+ private apiBase = DEFAULT_API_BASE,
28
+ private pollIntervalMs = DEFAULT_POLL_INTERVAL_MS,
29
+ private timeoutMs = DEFAULT_TIMEOUT_MS
30
+ ) {}
23
31
 
24
32
  async getToken(): Promise<string> {
25
33
  const res = await fetch(`${this.apiBase}/drop/token`, { method: 'POST' });
@@ -53,17 +61,17 @@ export class AnonymousDropClient implements DropClient {
53
61
  }
54
62
 
55
63
  async waitUntilReady(deploy: DropResponse, token: string): Promise<void> {
56
- // Poll the deploy with the drop token until ready.
57
- // Note: `/deploys/{id}` expects the deploy_id (BSON), not the site id (UUID)
58
- // that lives at `deploy.id`. See drop_controller.rb; Deploy.find(uuid) is nil
59
- // and the request 401s misleadingly via require_current_actor!.
60
- for (let i = 0; i < 600; i++) {
64
+ // `/deploys/{id}` expects the deploy_id (BSON), not the site id (UUID) that
65
+ // lives at `deploy.id`. See drop_controller.rb; Deploy.find(uuid) is nil and
66
+ // the request 401s misleadingly via require_current_actor!.
67
+ const deadline = this.timeoutMs / this.pollIntervalMs;
68
+ for (let i = 0; i < deadline; i++) {
61
69
  const res = await fetch(`${this.apiBase}/deploys/${deploy.deploy_id}`, {
62
70
  headers: { Authorization: `Bearer ${token}` },
63
71
  });
64
72
  if (res.ok && (await res.json()).state === 'ready') return;
65
- await new Promise(r => setTimeout(r, 1000));
73
+ await new Promise(r => setTimeout(r, this.pollIntervalMs));
66
74
  }
67
- throw new Error('Deploy did not become ready in time');
75
+ throw new DeployTimeoutError();
68
76
  }
69
77
  }
@@ -0,0 +1,25 @@
1
+ /** Tunables shared across drop-core. Module-specific constants stay with their module. */
2
+
3
+ // Root for session-cookie JSON calls via the consumer's access-control rewrite
4
+ // (paths per origin are allowlisted server-side)
5
+ export const DEFAULT_PROXY_BASE = '/access-control/bb-api/api/v1';
6
+
7
+ /** Direct api.netlify.com root for the uploads the proxy's ~6MB Lambda body limit can't carry. */
8
+ export const DEFAULT_API_BASE = 'https://api.netlify.com/api/v1';
9
+
10
+ // How often a waiting deploy/build is re-read: 1 second
11
+ export const DEFAULT_POLL_INTERVAL_MS = 1000;
12
+
13
+ // How long to keep polling before giving up: 10 minutes
14
+ export const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
15
+
16
+ // Concurrent file uploads (matches netlify-cli's deploy engine)
17
+ export const MAX_CONCURRENT_UPLOADS = 5;
18
+
19
+ // Reuse window for a minted upload token: 20s (server TTL is 30s; the gap is
20
+ // margin for the request it authorizes to finish)
21
+ export const TOKEN_REUSE_MS = 20 * 1000;
22
+
23
+ // Waits between upload retries, as data so the curve is overridable.
24
+ // 400/422 never retry — see uploadWithRetry.
25
+ export const UPLOAD_RETRY_DELAYS_MS: readonly number[] = [1_000, 2_000, 3_000];