@netlify/spark-ui 1.31.0-alpha.5 → 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 (37) hide show
  1. package/README.md +3 -4
  2. package/dist/components/preact/DropZone/DropZone.d.ts +23 -97
  3. package/dist/drop-core/analytics.d.ts +4 -15
  4. package/dist/drop-core/authedApi.d.ts +9 -22
  5. package/dist/drop-core/authedDropClient.d.ts +13 -55
  6. package/dist/drop-core/authedDropClient.js +5 -18
  7. package/dist/drop-core/buildClient.d.ts +19 -53
  8. package/dist/drop-core/buildClient.js +27 -40
  9. package/dist/drop-core/buildStash.d.ts +6 -20
  10. package/dist/drop-core/client.d.ts +1 -1
  11. package/dist/drop-core/constants.d.ts +1 -21
  12. package/dist/drop-core/deploy.d.ts +5 -17
  13. package/dist/drop-core/detectBuild.d.ts +8 -26
  14. package/dist/drop-core/errors.d.ts +8 -27
  15. package/dist/drop-core/fileTypes.d.ts +4 -14
  16. package/dist/drop-core/readFiles.d.ts +5 -11
  17. package/dist/drop-core/types.d.ts +2 -4
  18. package/dist/drop-core/zip.d.ts +7 -30
  19. package/package.json +1 -1
  20. package/packages/components/preact/DropZone/DropZone.tsx +27 -121
  21. package/packages/components/preact/DropZone/useDropDeploy.ts +27 -60
  22. package/packages/drop-core/analytics.ts +4 -15
  23. package/packages/drop-core/authedApi.ts +9 -22
  24. package/packages/drop-core/authedDropClient.ts +13 -55
  25. package/packages/drop-core/buildClient.test.ts +19 -4
  26. package/packages/drop-core/buildClient.ts +21 -63
  27. package/packages/drop-core/buildStash.ts +9 -25
  28. package/packages/drop-core/client.ts +6 -10
  29. package/packages/drop-core/constants.ts +7 -21
  30. package/packages/drop-core/deploy.ts +6 -22
  31. package/packages/drop-core/detectBuild.ts +15 -50
  32. package/packages/drop-core/errors.test.ts +3 -1
  33. package/packages/drop-core/errors.ts +9 -30
  34. package/packages/drop-core/fileTypes.ts +5 -19
  35. package/packages/drop-core/readFiles.ts +5 -12
  36. package/packages/drop-core/types.ts +2 -4
  37. package/packages/drop-core/zip.ts +7 -30
@@ -25,26 +25,13 @@ const DRAG_INACTIVE_MS = 120;
25
25
 
26
26
  const defaultClaimUrl = (siteName: string) => `https://app.netlify.com/drop/${siteName}`;
27
27
 
28
- /**
29
- * Where an authenticated static deploy lands: the new site's project overview,
30
- * the same place the app's own logged-in drop sends you.
31
- */
28
+ /** The project overview — where the app's own logged-in drop lands too. */
32
29
  const defaultSiteDashboardUrl = (siteName: string) =>
33
30
  `https://app.netlify.com/projects/${siteName}`;
34
31
 
35
- /**
36
- * Where a build-required drop is sent by default. Mirrors the app's own Drop
37
- * page: signup with `next=/drop`, so after signup + email verification the user
38
- * lands back on the authenticated Drop page and can build there.
39
- */
32
+ // Mirrors the app's own Drop page hand-off.
40
33
  const defaultBuildSignupUrl = 'https://app.netlify.com/signup?next=/drop&utm_campaign=drop';
41
34
 
42
- /**
43
- * Where an authenticated build drop lands: the new site's project overview —
44
- * the same destination as a static deploy, and where the app's own Drop page
45
- * sends build drops. The overview surfaces the in-progress deploy; consumers
46
- * who want the deploy-log page instead can override with `build.deployId`.
47
- */
48
35
  const defaultBuildDeployUrl = ({ siteName }: DeployedBuild) =>
49
36
  `https://app.netlify.com/projects/${siteName}`;
50
37
 
@@ -57,29 +44,17 @@ interface Props {
57
44
  client?: DropClient;
58
45
  /** Build the "claim this site" URL from the assigned site name (subdomain). */
59
46
  claimUrl?: (siteName: string) => string;
60
- /**
61
- * Where an authenticated static deploy sends the visitor — the site already
62
- * lives in their account, so there is no claim step. Defaults to the new
63
- * site's project overview on app.netlify.com.
64
- */
47
+ /** Destination after an authenticated static deploy (no claim step). Default: the project overview. */
65
48
  siteDashboardUrl?: (siteName: string) => string;
66
49
  /**
67
- * The signup hand-off for a build-required drop that can't be built here (no
68
- * session, or no build wiring). The drop is stashed on this origin first, so
69
- * a flow that returns the visitor to this page deploys it automatically —
70
- * see `resumeStashedBuilds`. Defaults to the app signup page with
71
- * `next=/drop`.
50
+ * Signup hand-off for a build drop with no session. The drop is stashed on
51
+ * this origin first — return the visitor here and it deploys automatically.
72
52
  */
73
53
  buildSignupUrl?: string;
74
54
  /**
75
- * Mint a short-lived bearer token for direct api.netlify.com uploads — pass it
76
- * to switch on the whole authenticated path for logged-in visitors: static
77
- * drops deploy straight into their account (then `siteDashboardUrl`), and
78
- * build-required drops build in place instead of being handed off to the app.
79
- * On netlify.com this reads `GET /access-control/generate-access-control-token`.
80
- * Resolving `null` means "no session": static drops fall back to the anonymous
81
- * claim flow; build drops are stashed and handed to `buildSignupUrl`.
82
- * See [Logged-in visitors](#logged-in-visitors).
55
+ * Mint the upload bearer — passing this switches on the whole authenticated
56
+ * path. `null` means "no session": static drops fall back to the anonymous
57
+ * claim flow; build drops stash and hand off. See the docs' Logged-in visitors.
83
58
  */
84
59
  getUploadToken?: () => Promise<string | null>;
85
60
  /**
@@ -87,108 +62,46 @@ interface Props {
87
62
  * Takes precedence over `getUploadToken` for the build path.
88
63
  */
89
64
  buildClient?: BuildClient;
90
- /**
91
- * Inject a custom authenticated `DropClient` for static drops (tests, staging,
92
- * a different account flow). Takes precedence over `getUploadToken` for the
93
- * static path. Distinct from `client`, which is the anonymous fallback.
94
- */
65
+ /** Custom authenticated static-drop client; wins over `getUploadToken`. (`client` is the anonymous fallback.) */
95
66
  authenticatedDropClient?: DropClient;
96
- /**
97
- * API root for the authenticated paths' JSON calls, which go through your
98
- * access-control rewrite so the session cookie authorizes them. Defaults to
99
- * `/access-control/bb-api/api/v1`. (Uploads always go direct to
100
- * `api.netlify.com` — the proxy can't carry bodies that large.)
101
- */
67
+ /** Root for the session-cookie JSON calls, via your access-control rewrite. Uploads always go direct. */
102
68
  proxyBase?: string;
103
69
  /** Create the visitor's new project in this account. Omit for their default account. */
104
70
  accountSlug?: string;
105
- /**
106
- * Where an enqueued build sends the visitor. Defaults to the new site's
107
- * project overview, same as a static deploy; `build.deployId` is available for
108
- * consumers who want the deploy-log page instead.
109
- */
71
+ /** Destination after a build is enqueued. Default: the project overview (`build.deployId` for the logs page). */
110
72
  buildDeployUrl?: (build: DeployedBuild) => string;
111
73
  /**
112
- * Poll until the build finishes before handing off, instead of redirecting as
113
- * soon as it's enqueued. The trade-off: waiting means a failing build surfaces
114
- * its own error message here (via `onError` and the overlay) rather than the
115
- * visitor discovering it on the deploy page — at the cost of holding them on a
116
- * spinner for the length of the build, which is minutes. Off by default.
74
+ * Hold the visitor until the build finishes (off by default — builds take
75
+ * minutes). On is the only way a failing build's own message reaches `onError`.
117
76
  */
118
77
  waitForBuild?: boolean;
119
- /**
120
- * Called when a build-required drop is detected, instead of building it or
121
- * redirecting. Use it to own the hand-off yourself (custom routing, persisting
122
- * the drop, gating on auth). When set, the component neither builds nor
123
- * redirects. The static drop path is unaffected either way.
124
- */
78
+ /** Take over build-required drops entirely — when set, the component neither builds nor redirects. */
125
79
  onBuildRequired?: (info: BuildDetection) => void;
126
80
  /**
127
- * Called instead of the `buildSignupUrl` redirect when a build-required drop
128
- * can't be built here (no session). By this point the drop has been stashed
129
- * on this origin (`stashed` says whether that succeeded) — own the hand-off
130
- * however fits your auth flow, e.g. open a signup popup that keeps the
131
- * visitor on the page. Once they're back authenticated, a remount of the
132
- * zone deploys the stash automatically.
81
+ * Own the signup hand-off instead of the `buildSignupUrl` redirect (e.g. an
82
+ * auth popup). The drop is already stashed; a remount while authenticated
83
+ * deploys it.
133
84
  */
134
85
  onBuildSignupHandoff?: (info: { stashed: boolean; detection: BuildDetection }) => void;
135
- /**
136
- * Deploy a previously stashed build drop automatically when the zone mounts
137
- * with an authenticated visitor (default `true`). The stash is written when a
138
- * logged-out build drop is handed to signup; see
139
- * [Signup hand-off and auto-resume](#signup-hand-off-and-auto-resume).
140
- */
86
+ /** Auto-deploy a stashed build drop on mount when authenticated (default true). */
141
87
  resumeStashedBuilds?: boolean;
142
88
  /** Called once the deploy is live. */
143
89
  onDeploy?: (result: DeployedSite) => void;
144
- /**
145
- * Called once a build-required drop has been zipped, uploaded and enqueued —
146
- * before the redirect to `buildDeployUrl`. The site exists at this point but
147
- * won't serve content until the build finishes.
148
- */
90
+ /** Called once a build is enqueued, before the `buildDeployUrl` redirect. */
149
91
  onBuildDeploy?: (build: DeployedBuild) => void;
150
- /**
151
- * Called when a deploy fails, with a humanized `message` (ready to show a
152
- * user — the same copy the built-in overlay uses) and the raw `reason`. Pair
153
- * this with `showStatus={false}` to render your own error UI.
154
- */
92
+ /** Deploy failures: humanized `message` (the overlay's copy) + raw `reason`. Pair with showStatus={false}. */
155
93
  onError?: (error: DeployError) => void;
156
94
  /**
157
- * Render the built-in status overlay (drag tint, spinner, progress bar, error).
158
- * Set to `false` to suppress it entirely and keep your children visible, then
159
- * drive your own feedback from the `data-state` attribute on the root element.
160
- * Note: in this mode you're responsible for surfacing errors yourself — use
161
- * `onError` to get the message.
95
+ * Render the built-in overlay. `false` keeps children visible — drive your
96
+ * own feedback from `data-state`, and surface errors yourself via `onError`.
162
97
  */
163
98
  showStatus?: boolean;
164
99
  /**
165
- * Analytics hook. Called at each tracked moment with an event name and
166
- * properties. Stays provider-agnostic — wire it to Segment/Amplitude/GA in the
167
- * consuming app, e.g. `onTrack={(name, props) => new Analytics().track(name, props)}`.
168
- * Emitted events (all include `dropzone_id` when `analyticsId` is set):
169
- * - `dropzone_files_dropped` — files dropped via drag ({ method: 'drag', file_count, file_types })
170
- * - `dropzone_browse_opened` — file picker opened ({ method: 'keyboard' | 'click' })
171
- * - `dropzone_build_required` — drop needs a build (built here, or handed off) ({ method, reason, file_count, file_types })
172
- * - `dropzone_deploy_succeeded` — static deploy went live ({ method, site_name, file_count, authenticated, auth_fallback?, file_types })
173
- * - `dropzone_build_deploy_succeeded` — build enqueued for a logged-in visitor ({ method, reason, site_name, file_count, framework?, file_types })
174
- * - `dropzone_deploy_failed` — deploy or build errored out ({ method, reason, file_count?, file_types })
175
- *
176
- * `authenticated` says whether the deploy landed in the visitor's account;
177
- * `auth_fallback: true` is sent only when an authenticated attempt bounced
178
- * (stale auth hint) and the drop completed anonymously — omitted otherwise.
179
- * `file_types` is the sorted, de-duplicated list of MIME types in the
180
- * selection, with `application/octet-stream` standing in wherever the browser
181
- * reports none (a dragged folder, an `.exe`). It is omitted when the browser
182
- * exposes no files at all. Note `dropzone_files_dropped` only fires for drag —
183
- * a file-picker selection reports its types on the outcome events, as
184
- * `dropzone_browse_opened` fires before any file exists.
100
+ * Provider-agnostic analytics hook. Event names and payload shapes live in
101
+ * DROPZONE_EVENTS / DropZoneEventProperties (drop-core/analytics.ts).
185
102
  */
186
103
  onTrack?: TrackFn;
187
- /**
188
- * Stable identifier for this instance, attached to every tracked event as
189
- * `dropzone_id`. Set it when a page renders more than one Drop Zone so the
190
- * events can be told apart in Amplitude.
191
- */
104
+ /** Stamped on every event as `dropzone_id` — set when a page has more than one zone. */
192
105
  analyticsId?: string;
193
106
  className?: string;
194
107
  }
@@ -218,8 +131,6 @@ export default function DropZone({
218
131
  analyticsId,
219
132
  className,
220
133
  }: Props) {
221
- // Tag every event with this instance's id so multiple zones on a page can be
222
- // told apart, then hand it off to the consumer's analytics.
223
134
  const track: TrackFn = (event, properties = {}) =>
224
135
  onTrack?.(event, analyticsId ? { dropzone_id: analyticsId, ...properties } : properties);
225
136
 
@@ -253,8 +164,6 @@ export default function DropZone({
253
164
  inputRef.current?.click();
254
165
  };
255
166
 
256
- // The overlay only paints when there's something to show — dragging, deploying,
257
- // or an error. With `showStatus` off, the consumer drives their own feedback.
258
167
  const showOverlay = showStatus && (active || busy || status.kind === 'error');
259
168
  const rootClass = [
260
169
  'n-dropzone',
@@ -343,11 +252,8 @@ export default function DropZone({
343
252
  }
344
253
 
345
254
  /**
346
- * Track whether a drag is currently over the zone. A drag has no reliable
347
- * "cancelled" event — pressing Escape or dropping elsewhere just stops the
348
- * stream of `dragover` events — so we stay active only while they keep arriving
349
- * and clear shortly after they stop. This also handles drags over children,
350
- * where `dragleave` targets a child rather than the zone.
255
+ * Drag-over tracking by dragover decay: drags have no reliable cancel event,
256
+ * and dragleave targets children — so stay active while events keep arriving.
351
257
  */
352
258
  function useDragActive() {
353
259
  const [active, setActive] = useState(false);
@@ -112,7 +112,6 @@ interface UseDropDeployOptions {
112
112
  track: TrackFn;
113
113
  }
114
114
 
115
- /** Map a low-level deploy progress event onto our UI status. */
116
115
  function progressToStatus(e: DeployProgress): DeployStatus {
117
116
  switch (e.phase) {
118
117
  case 'zipping':
@@ -129,7 +128,7 @@ function progressToStatus(e: DeployProgress): DeployStatus {
129
128
  }
130
129
  }
131
130
 
132
- /** Inspect the file set and return a non-blocking index.html warning, if any. */
131
+ /** Advisory only — a missing index.html never fails the deploy. */
133
132
  function indexHtmlWarning(files: ProcessedFile[]): string | null {
134
133
  const singleNonIndex = getSingleNonIndexHtmlFileName(files);
135
134
  if (singleNonIndex) {
@@ -142,12 +141,9 @@ function indexHtmlWarning(files: ProcessedFile[]): string | null {
142
141
  }
143
142
 
144
143
  /**
145
- * Best-effort local persistence of the anonymous claim handoff. Note this does
146
- * NOT reach the dashboard: localStorage is per-origin, so a token written here
147
- * (e.g. www.netlify.com) is invisible to app.netlify.com. The cross-origin
148
- * handoff is the URL fragment on the claim redirect; this only helps if the
149
- * drop and claim share an origin. Authenticated deploys never call this —
150
- * there is no claim token, and the upload bearer must never be persisted.
144
+ * Best-effort claim persistence. localStorage is per-origin, so this never
145
+ * reaches app.netlify.com — the URL fragment is the real cross-origin handoff.
146
+ * Authenticated deploys skip it: no claim token, and bearers are never persisted.
151
147
  */
152
148
  function persistAnonymousClaimLocally(token: string, siteName: string): void {
153
149
  try {
@@ -183,11 +179,7 @@ export function useDropDeploy({
183
179
  onError,
184
180
  track,
185
181
  }: UseDropDeployOptions) {
186
- /**
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.
190
- */
182
+ /** Typed veneer over `track` — a renamed property fails to compile instead of forking analytics history. */
191
183
  const emit = <E extends DropZoneEventName>(event: E, props: DropZoneEventProperties[E]) =>
192
184
  // The event interfaces carry no index signature, hence the cast to TrackFn's record.
193
185
  track(event, props as Record<string, unknown>);
@@ -220,10 +212,8 @@ export function useDropDeploy({
220
212
  const busy = status.kind !== 'idle' && status.kind !== 'error';
221
213
 
222
214
  /**
223
- * Create a site and enqueue a build for an already-zipped drop. Returns
224
- * `null` when the visitor turns out not to be logged in — nothing is created
225
- * in that case, so the caller can still stash the archive and hand off to
226
- * signup. Every other failure throws and is reported by the caller's `catch`.
215
+ * Site + build for a zipped drop. `null` = not logged in (nothing created,
216
+ * so the caller can still stash and hand off); other failures throw.
227
217
  */
228
218
  async function enqueueZippedBuild(
229
219
  client: BuildClient,
@@ -257,10 +247,8 @@ export function useDropDeploy({
257
247
  }
258
248
 
259
249
  /**
260
- * `fileTypes` is the MIME types of the selection, read at the drop boundary
261
- * (see `getFileTypes`) and passed in rather than derived here — it has to be
262
- * captured before `read()` runs so it survives a read that throws. Omitted
263
- * from events when empty rather than sent as `[]`.
250
+ * `fileTypes` is captured at the drop boundary, before `read()` runs, so it
251
+ * survives a read that throws. Omitted from events when empty, never `[]`.
264
252
  */
265
253
  async function deploy(
266
254
  read: () => Promise<ProcessedFile[]>,
@@ -272,12 +260,8 @@ export function useDropDeploy({
272
260
 
273
261
  const typeProps = fileTypes.length ? { file_types: fileTypes } : {};
274
262
 
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.
263
+ // Single failure funnel. `file_count` only when the file set is known;
264
+ // `reason` is raw for analytics, `message` is the humanized copy; `onError` gets both.
281
265
  const fail = (message: string, reason: string, extra?: Record<string, unknown>) => {
282
266
  setStatus({ kind: 'error', message });
283
267
  emit(DROPZONE_EVENTS.DEPLOY_FAILED, { method: source, reason, ...typeProps, ...extra });
@@ -285,10 +269,9 @@ export function useDropDeploy({
285
269
  };
286
270
 
287
271
  try {
288
- // Auto-rename a lone non-index HTML file to index.html so it is served at
289
- // the site root. Detection (getSingleNonIndexHtmlFileName) sits on the
290
- // read side; recompute the warning on the renamed set so the fixable
291
- // single-file case no longer warns and only the multi-HTML case does.
272
+ // Rename a lone non-index HTML file so it is served at the site root.
273
+ // Warnings are computed on the renamed set, so the fixable single-file
274
+ // case no longer warns and only the multi-HTML case does.
292
275
  const files = renameSingleNonIndexHtmlToIndex(await read());
293
276
  if (!files.length) {
294
277
  fail('No files found to deploy.', 'No files found to deploy.', { file_count: 0 });
@@ -319,12 +302,8 @@ export function useDropDeploy({
319
302
  }
320
303
 
321
304
  /**
322
- * A drop that needs a build. Build-required projects can't deploy through the
323
- * anonymous drop API — it only performs static deploys, so building one this
324
- * way yields a broken site. They're routed to the authenticated build path
325
- * instead, or stashed and handed off to signup when there is no session.
326
- * Consumers can take over entirely with `onBuildRequired`, or own just the
327
- * signup hand-off with `onBuildSignupHandoff`.
305
+ * Build-required drops can't use the anonymous API (static-only): build in
306
+ * the account, or stash + hand off to signup when there's no session.
328
307
  */
329
308
  async function handleBuildRequiredDrop(
330
309
  files: ProcessedFile[],
@@ -372,8 +351,7 @@ export function useDropDeploy({
372
351
 
373
352
  // No session after all (the auth hint cookie can outlive the session).
374
353
  // Nothing was created — stash the archive so the drop survives the
375
- // signup round-trip. When the visitor is back on this origin
376
- // authenticated, the mount-time resume deploys it for them.
354
+ // signup round-trip.
377
355
  stashed = await saveDropStash(archive.file, archive.buildSettings);
378
356
  if (stashed) {
379
357
  emit(DROPZONE_EVENTS.PROJECT_STASHED, {
@@ -399,13 +377,9 @@ export function useDropDeploy({
399
377
  }
400
378
 
401
379
  /**
402
- * Try the authenticated static deploy, or report that the visitor has no
403
- * session. NotAuthenticatedError is the one failure that returns null and
404
- * lets the caller fall back to the anonymous flow — it's thrown before
405
- * anything is created (the auth hint cookie can outlive the session), so
406
- * retrying anonymously can't strand a half-made site. Any later failure is
407
- * real and rethrows: falling back then would leave an empty site in the
408
- * account and deploy a duplicate.
380
+ * `null` = no session (pre-mutation, so the anonymous fallback is safe).
381
+ * Anything else rethrows — falling back after the site exists would strand
382
+ * an empty site and deploy a duplicate.
409
383
  */
410
384
  async function attemptAuthenticatedStaticDeploy(
411
385
  client: DropClient,
@@ -447,11 +421,10 @@ export function useDropDeploy({
447
421
  }
448
422
 
449
423
  setStatus({ kind: 'redirecting', target: authenticated ? 'dashboard' : 'claim' });
450
- // Fired before the redirect below. Note: the navigation can cut a
451
- // fire-and-forget analytics request short — see the docs for delivery
452
- // hardening (sendBeacon / deferring the redirect). `auth_fallback` is only
453
- // sent when an authenticated attempt bounced (stale auth hint) — omitted
454
- // otherwise, like `file_types`.
424
+ // The redirect below can cut a fire-and-forget analytics request short;
425
+ // harden delivery with sendBeacon or by deferring the redirect.
426
+ // `auth_fallback` is only sent when an authenticated attempt bounced
427
+ // (stale auth hint) — omitted otherwise, like `file_types`.
455
428
  emit(DROPZONE_EVENTS.DEPLOY_SUCCEEDED, {
456
429
  method: source,
457
430
  site_name: result.subdomain,
@@ -471,7 +444,6 @@ export function useDropDeploy({
471
444
  redirectAfterStaticDeploy(authenticated, result.subdomain, token);
472
445
  }
473
446
 
474
- /** Hand the visitor to their new site: its dashboard page, or the claim page. */
475
447
  function redirectAfterStaticDeploy(authenticated: boolean, siteName: string, token: string) {
476
448
  if (authenticated) {
477
449
  // The site already lives in the visitor's account — send them straight
@@ -486,13 +458,8 @@ export function useDropDeploy({
486
458
  }
487
459
 
488
460
  /**
489
- * Deploy a drop stashed before the signup hand-off (see `buildStash`).
490
- *
491
- * The stash is consumed only once the visitor is provably authenticated —
492
- * the marker (or the auth hint behind `getUploadToken`) can outlive the
493
- * session, and consuming on a bounced attempt would quietly lose the drop.
494
- * Once past that gate it is consumed whether the deploy succeeds or fails,
495
- * so a broken project can never retry-loop.
461
+ * Deploy the stashed drop. Consumed only once provably authenticated (a
462
+ * bounced attempt keeps it), then consumed win or lose — no retry loops.
496
463
  */
497
464
  async function resumeStashedBuild(client: BuildClient) {
498
465
  const stash = await loadDropStash();
@@ -517,7 +484,7 @@ export function useDropDeploy({
517
484
 
518
485
  if (!built) {
519
486
  // Still no session — the visitor came back logged out. Keep the stash for
520
- // their next authenticated visit and return to idle without fuss.
487
+ // their next authenticated visit.
521
488
  setStatus({ kind: 'idle' });
522
489
  return;
523
490
  }
@@ -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,22 +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
- * Create a site in the visitor's account via `POST {proxyBase}/sites` (scoped
14
- * to `accountSlug` when given, else the user's default account), attributed
15
- * `created_via: 'drop'`.
16
- *
17
- * A logged-out visitor 401s here, before anything is created — the one
18
- * pre-mutation point where callers may still safely fall back to an anonymous
19
- * flow, which is why it maps to `NotAuthenticatedError`. Any other failure is
20
- * 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.
21
14
  */
22
15
  export async function createSiteInAccount(
23
16
  proxyBase: string,
@@ -48,15 +41,9 @@ interface DeployState {
48
41
  }
49
42
 
50
43
  /**
51
- * Poll `GET {proxyBase}/deploys/{deployId}` on the session cookie until the
52
- * deploy settles, and report how. Purely mechanical on purpose: each caller
53
- * maps the outcome onto its own error vocabulary (the drop client throws plain
54
- * errors `humanizeDropError` understands; the build client throws
55
- * `BuildFailedError`/`BuildTimeoutError`), so that knowledge stays next to the
56
- * class it belongs to.
57
- *
58
- * A non-ok poll response is skipped, not fatal — a transient proxy hiccup
59
- * 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.
60
47
  */
61
48
  export async function pollDeployUntilSettled(options: {
62
49
  proxyBase: string;
@@ -11,27 +11,13 @@ import { DeployFailedError, DeployTimeoutError, NotAuthenticatedError } from './
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. */
@@ -51,25 +37,10 @@ interface SiteDeployResponse {
51
37
  }
52
38
 
53
39
  /**
54
- * Authenticated drop client for logged-in visitors. Unlike `AnonymousDropClient`
55
- * — which creates an account-less site the visitor must later claim — this
56
- * deploys straight into the visitor's account via the sites/deploys API,
57
- * mirroring what app.netlify.com does for a logged-in drag-and-drop deploy.
58
- * There is no claim step, so the caller should hand off to the site's dashboard
59
- * page rather than the claim page.
60
- *
61
- * Requests split across two hosts by necessity. JSON calls (create the site,
62
- * create the deploy, poll it) go through the consumer's access-control proxy on
63
- * the session cookie — no token involved, and nothing that can expire mid-deploy.
64
- * File uploads can't: the proxy caps bodies at ~6MB, so they go direct to
65
- * `api.netlify.com` with a bearer, re-minted as it ages (see `TOKEN_REUSE_MS`).
66
- *
67
- * Error-mapping invariant: only pre-mutation failures — a null upload token at
68
- * the gate, or a 401/403 creating the site — throw `NotAuthenticatedError`,
69
- * which is the signal the drop flow may safely retry anonymously. Once the site
70
- * exists, any auth failure throws a plain error instead: falling back at that
71
- * point would strand an empty site in the account *and* deploy a duplicate
72
- * 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.
73
44
  */
74
45
  export class AuthenticatedDropClient implements DropClient {
75
46
  private proxyBase: string;
@@ -108,9 +79,8 @@ export class AuthenticatedDropClient implements DropClient {
108
79
  }
109
80
 
110
81
  /**
111
- * A token young enough to authorize an upload, minting a new one once the
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).
82
+ * A token younger than its 30s expiry. Mint failures here are plain Errors,
83
+ * never NotAuthenticatedError — the site exists by now (class doc).
114
84
  */
115
85
  private async freshToken(): Promise<string> {
116
86
  const now = Date.now();
@@ -123,11 +93,7 @@ export class AuthenticatedDropClient implements DropClient {
123
93
  return value;
124
94
  }
125
95
 
126
- /**
127
- * Two proxied calls: create the site in the visitor's account, then the
128
- * deploy on it. Mapped into the anonymous `DropResponse` shape so
129
- * `deployFiles` runs unchanged over either client.
130
- */
96
+ /** Site + deploy via the proxy, mapped into `DropResponse` so `deployFiles` runs unchanged. */
131
97
  async createDeploy(files: Digest, _token: string): Promise<DropResponse> {
132
98
  const site: BuildSite = await createSiteInAccount(this.proxyBase, this.accountSlug);
133
99
 
@@ -148,11 +114,7 @@ export class AuthenticatedDropClient implements DropClient {
148
114
  };
149
115
  }
150
116
 
151
- /**
152
- * Direct to `api.netlify.com` on a freshly-aged bearer — the threaded `token`
153
- * is ignored because an upload queue easily outlives the 30 seconds it was
154
- * minted with. PUT (not POST) so paths with "&" etc. work.
155
- */
117
+ /** Direct on a fresh bearer (the threaded one has expired). PUT so paths with "&" work. */
156
118
  async uploadFile(
157
119
  deployId: string,
158
120
  path: string,
@@ -168,11 +130,7 @@ export class AuthenticatedDropClient implements DropClient {
168
130
  if (!res.ok) throw apiError(`upload ${path}`, res.status);
169
131
  }
170
132
 
171
- /**
172
- * Poll the deploy through the proxy until it settles: resolve on `ready`,
173
- * throw on `error`/`rejected`, throw on timeout. On the session cookie rather
174
- * than a bearer, so a deploy taking minutes can't outlive its own credentials.
175
- */
133
+ /** Polls on the session cookie, so a slow deploy can't outlive its own credentials. */
176
134
  async waitUntilReady(deploy: DropResponse, _token: string): Promise<void> {
177
135
  const settled = await pollDeployUntilSettled({
178
136
  proxyBase: this.proxyBase,
@@ -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 () => {