@netlify/spark-ui 1.31.0-alpha.3 → 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 (100) hide show
  1. package/dist/components/index.d.ts +2 -2
  2. package/dist/components/preact/DropZone/DropZone.d.ts +27 -9
  3. package/dist/components/preact/DropZone/DropZone.js +59 -54
  4. package/dist/components/preact/DropZone/DropZone.test.d.ts +1 -0
  5. package/dist/components/preact/DropZone/index.d.ts +3 -3
  6. package/dist/components/preact/DropZone/index.js +29 -27
  7. package/dist/components/preact/DropZone/useDropDeploy.d.ts +9 -6
  8. package/dist/components/preact/DropZone/useDropDeploy.js +182 -131
  9. package/dist/components/preact/DropZone/useDropDeploy.test.d.ts +1 -0
  10. package/dist/components/preact/index.d.ts +2 -2
  11. package/dist/components/preact/index.js +24 -18
  12. package/dist/drop-core/analytics.d.ts +107 -0
  13. package/dist/drop-core/analytics.js +21 -0
  14. package/dist/drop-core/analytics.test.d.ts +1 -0
  15. package/dist/drop-core/authedApi.d.ts +45 -0
  16. package/dist/drop-core/authedApi.js +30 -0
  17. package/dist/drop-core/authedApi.test.d.ts +1 -0
  18. package/dist/{components/preact/DropZone/drop-core → drop-core}/authedDropClient.d.ts +4 -11
  19. package/dist/drop-core/authedDropClient.js +100 -0
  20. package/dist/drop-core/authedDropClient.test.d.ts +1 -0
  21. package/dist/{components/preact/DropZone/drop-core → drop-core}/buildClient.d.ts +5 -13
  22. package/dist/drop-core/buildClient.js +85 -0
  23. package/dist/drop-core/buildClient.test.d.ts +1 -0
  24. package/dist/drop-core/buildStash.d.ts +37 -0
  25. package/dist/drop-core/buildStash.js +84 -0
  26. package/dist/drop-core/buildStash.test.d.ts +0 -0
  27. package/dist/{components/preact/DropZone/drop-core → drop-core}/client.d.ts +3 -1
  28. package/dist/drop-core/client.js +47 -0
  29. package/dist/drop-core/constants.d.ts +29 -0
  30. package/dist/drop-core/constants.js +10 -0
  31. package/dist/{components/preact/DropZone/drop-core → drop-core}/deploy.d.ts +9 -0
  32. package/dist/drop-core/deploy.js +66 -0
  33. package/dist/drop-core/deploy.test.d.ts +1 -0
  34. package/dist/drop-core/detectBuild.test.d.ts +1 -0
  35. package/dist/drop-core/digest.test.d.ts +1 -0
  36. package/dist/{components/preact/DropZone/drop-core → drop-core}/errors.d.ts +29 -0
  37. package/dist/drop-core/errors.js +119 -0
  38. package/dist/drop-core/errors.test.d.ts +1 -0
  39. package/dist/drop-core/fileTypes.test.d.ts +1 -0
  40. package/dist/drop-core/index.d.ts +25 -0
  41. package/dist/drop-core/index.js +47 -0
  42. package/dist/{components/preact/DropZone/drop-core → drop-core}/readFiles.js +2 -2
  43. package/dist/drop-core/readFiles.test.d.ts +1 -0
  44. package/dist/{components/preact/DropZone/drop-core → drop-core}/types.d.ts +10 -0
  45. package/dist/{components/preact/DropZone/drop-core → drop-core}/zip.js +1 -1
  46. package/dist/drop-core/zip.test.d.ts +1 -0
  47. package/package.json +4 -1
  48. package/packages/components/index.ts +19 -0
  49. package/packages/components/preact/DropZone/DropZone.test.ts +71 -0
  50. package/packages/components/preact/DropZone/DropZone.tsx +29 -9
  51. package/packages/components/preact/DropZone/index.tsx +14 -3
  52. package/packages/components/preact/DropZone/useDropDeploy.test.ts +376 -0
  53. package/packages/components/preact/DropZone/useDropDeploy.ts +301 -145
  54. package/packages/components/preact/index.ts +19 -2
  55. package/packages/drop-core/README.md +91 -0
  56. package/packages/drop-core/analytics.test.ts +17 -0
  57. package/packages/drop-core/analytics.ts +122 -0
  58. package/packages/drop-core/authedApi.test.ts +100 -0
  59. package/packages/drop-core/authedApi.ts +81 -0
  60. package/packages/drop-core/authedDropClient.test.ts +196 -0
  61. package/packages/{components/preact/DropZone/drop-core → drop-core}/authedDropClient.ts +30 -72
  62. package/packages/drop-core/buildClient.test.ts +112 -0
  63. package/packages/{components/preact/DropZone/drop-core → drop-core}/buildClient.ts +27 -57
  64. package/packages/drop-core/buildStash.test.ts +85 -0
  65. package/packages/drop-core/buildStash.ts +185 -0
  66. package/packages/{components/preact/DropZone/drop-core → drop-core}/client.ts +18 -6
  67. package/packages/drop-core/constants.ts +39 -0
  68. package/packages/drop-core/deploy.test.ts +177 -0
  69. package/packages/{components/preact/DropZone/drop-core → drop-core}/deploy.ts +59 -9
  70. package/packages/drop-core/detectBuild.test.ts +139 -0
  71. package/packages/drop-core/digest.test.ts +24 -0
  72. package/packages/drop-core/errors.test.ts +98 -0
  73. package/packages/{components/preact/DropZone/drop-core → drop-core}/errors.ts +68 -11
  74. package/packages/drop-core/fileTypes.test.ts +37 -0
  75. package/packages/drop-core/index.ts +44 -0
  76. package/packages/drop-core/readFiles.test.ts +124 -0
  77. package/packages/{components/preact/DropZone/drop-core → drop-core}/types.ts +12 -6
  78. package/packages/drop-core/zip.test.ts +92 -0
  79. package/dist/components/preact/DropZone/drop-core/authedDropClient.js +0 -123
  80. package/dist/components/preact/DropZone/drop-core/buildClient.js +0 -99
  81. package/dist/components/preact/DropZone/drop-core/client.js +0 -45
  82. package/dist/components/preact/DropZone/drop-core/deploy.js +0 -48
  83. package/dist/components/preact/DropZone/drop-core/errors.js +0 -82
  84. package/dist/components/preact/DropZone/drop-core/index.d.ts +0 -11
  85. package/dist/components/preact/DropZone/drop-core/index.js +0 -36
  86. package/packages/components/preact/DropZone/drop-core/index.ts +0 -11
  87. /package/dist/{components/preact/DropZone/drop-core → drop-core}/detectBuild.d.ts +0 -0
  88. /package/dist/{components/preact/DropZone/drop-core → drop-core}/detectBuild.js +0 -0
  89. /package/dist/{components/preact/DropZone/drop-core → drop-core}/digest.d.ts +0 -0
  90. /package/dist/{components/preact/DropZone/drop-core → drop-core}/digest.js +0 -0
  91. /package/dist/{components/preact/DropZone/drop-core → drop-core}/fileTypes.d.ts +0 -0
  92. /package/dist/{components/preact/DropZone/drop-core → drop-core}/fileTypes.js +0 -0
  93. /package/dist/{components/preact/DropZone/drop-core → drop-core}/readFiles.d.ts +0 -0
  94. /package/dist/{components/preact/DropZone/drop-core → drop-core}/types.js +0 -0
  95. /package/dist/{components/preact/DropZone/drop-core → drop-core}/zip.d.ts +0 -0
  96. /package/packages/{components/preact/DropZone/drop-core → drop-core}/detectBuild.ts +0 -0
  97. /package/packages/{components/preact/DropZone/drop-core → drop-core}/digest.ts +0 -0
  98. /package/packages/{components/preact/DropZone/drop-core → drop-core}/fileTypes.ts +0 -0
  99. /package/packages/{components/preact/DropZone/drop-core → drop-core}/readFiles.ts +0 -0
  100. /package/packages/{components/preact/DropZone/drop-core → drop-core}/zip.ts +0 -0
@@ -1,27 +1,38 @@
1
- import { useRef, useState } from 'preact/hooks';
1
+ import { useEffect, useRef, useState } from 'preact/hooks';
2
2
  import {
3
3
  AnonymousDropClient,
4
+ clearDropStash,
5
+ deployZippedBuild,
6
+ DROPZONE_EVENTS,
7
+ hasDropStashMarker,
8
+ loadDropStash,
9
+ saveDropStash,
10
+ zipFiles,
4
11
  AuthenticatedBuildClient,
5
12
  AuthenticatedDropClient,
6
- deployBuild,
7
13
  deployFiles,
8
14
  detectBuild,
9
15
  getSingleNonIndexHtmlFileName,
10
16
  hasMultipleHtmlFilesWithoutIndex,
11
17
  humanizeDropError,
12
- NotAuthenticatedError,
18
+ isNotAuthenticatedError,
13
19
  renameSingleNonIndexHtmlToIndex,
14
20
  type BuildClient,
15
21
  type BuildDetection,
16
22
  type DeployProgress,
17
23
  type DeployResult,
18
24
  type DropClient,
25
+ type DropMethod,
26
+ type DropZoneEventName,
27
+ type DropZoneEventProperties,
28
+ type TrackFn,
19
29
  type InferredBuildSettings,
20
30
  type ProcessedFile,
21
- } from './drop-core';
31
+ type ZipResult,
32
+ } from '../../../drop-core';
22
33
 
23
34
  /** How the files reached the zone — carried through to analytics. */
24
- export type DeploySource = 'drag' | 'browse';
35
+ export type DeploySource = DropMethod;
25
36
 
26
37
  /** The live site handed back once a deploy succeeds. */
27
38
  export interface DeployedSite {
@@ -77,8 +88,7 @@ export type DeployStatus =
77
88
  | { kind: 'redirecting'; target: 'claim' | 'dashboard' | 'signup' | 'build' }
78
89
  | { kind: 'error'; message: string };
79
90
 
80
- /** Provider-agnostic analytics sink. */
81
- export type TrackFn = (event: string, properties?: Record<string, unknown>) => void;
91
+ export type { TrackFn };
82
92
 
83
93
  interface UseDropDeployOptions {
84
94
  apiBase?: string;
@@ -94,6 +104,8 @@ interface UseDropDeployOptions {
94
104
  buildDeployUrl: (build: DeployedBuild) => string;
95
105
  waitForBuild?: boolean;
96
106
  onBuildRequired?: (info: BuildDetection) => void;
107
+ onBuildSignupHandoff?: (info: { stashed: boolean; detection: BuildDetection }) => void;
108
+ resumeStashedBuilds?: boolean;
97
109
  onDeploy?: (site: DeployedSite) => void;
98
110
  onBuildDeploy?: (build: DeployedBuild) => void;
99
111
  onError?: (error: DeployError) => void;
@@ -129,6 +141,23 @@ function indexHtmlWarning(files: ProcessedFile[]): string | null {
129
141
  return null;
130
142
  }
131
143
 
144
+ /**
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.
151
+ */
152
+ function persistAnonymousClaimLocally(token: string, siteName: string): void {
153
+ try {
154
+ localStorage.setItem('drop-token', token);
155
+ localStorage.setItem('dropSiteName', siteName);
156
+ } catch {
157
+ /* private mode / storage disabled — non-fatal */
158
+ }
159
+ }
160
+
132
161
  /**
133
162
  * Owns the deploy state machine and its side effects (upload, local persistence,
134
163
  * analytics, redirect), keeping DropZone a thin view over the returned `status`.
@@ -147,11 +176,22 @@ export function useDropDeploy({
147
176
  buildDeployUrl,
148
177
  waitForBuild,
149
178
  onBuildRequired,
179
+ onBuildSignupHandoff,
180
+ resumeStashedBuilds = true,
150
181
  onDeploy,
151
182
  onBuildDeploy,
152
183
  onError,
153
184
  track,
154
185
  }: 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
+ */
191
+ const emit = <E extends DropZoneEventName>(event: E, props: DropZoneEventProperties[E]) =>
192
+ // The event interfaces carry no index signature, hence the cast to TrackFn's record.
193
+ track(event, props as Record<string, unknown>);
194
+
155
195
  const [status, setStatus] = useState<DeployStatus>({ kind: 'idle' });
156
196
  const [warning, setWarning] = useState<string | null>(null);
157
197
  const dropClient = useRef(client ?? new AnonymousDropClient(apiBase)).current;
@@ -180,17 +220,17 @@ export function useDropDeploy({
180
220
  const busy = status.kind !== 'idle' && status.kind !== 'error';
181
221
 
182
222
  /**
183
- * Zip the drop, create a site and enqueue a build. Returns `null` when the
184
- * visitor turns out not to be logged in — nothing is created in that case, so
185
- * the caller can still fall back to the signup hand-off. Every other failure
186
- * throws and is reported by the caller's `catch`.
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`.
187
227
  */
188
- async function buildProject(
228
+ async function enqueueZippedBuild(
189
229
  client: BuildClient,
190
- files: ProcessedFile[]
230
+ archive: ZipResult
191
231
  ): Promise<DeployedBuild | null> {
192
232
  try {
193
- const { site, build, buildSettings, url } = await deployBuild(client, files, {
233
+ const { site, build, buildSettings, url } = await deployZippedBuild(client, archive, {
194
234
  onProgress: e => setStatus(progressToStatus(e)),
195
235
  waitForBuild,
196
236
  });
@@ -203,11 +243,19 @@ export function useDropDeploy({
203
243
  buildSettings,
204
244
  };
205
245
  } catch (err) {
206
- if (err instanceof NotAuthenticatedError) return null;
246
+ if (isNotAuthenticatedError(err)) return null;
207
247
  throw err;
208
248
  }
209
249
  }
210
250
 
251
+ /** One attempt's analytics context, threaded to the branch handlers below. */
252
+ interface AttemptContext {
253
+ source: DeploySource;
254
+ fileCount: number;
255
+ /** `{ file_types }` when the drop boundary captured any, `{}` otherwise — spread into every event. */
256
+ typeProps: { file_types?: string[] };
257
+ }
258
+
211
259
  /**
212
260
  * `fileTypes` is the MIME types of the selection, read at the drop boundary
213
261
  * (see `getFileTypes`) and passed in rather than derived here — it has to be
@@ -224,28 +272,18 @@ export function useDropDeploy({
224
272
 
225
273
  const typeProps = fileTypes.length ? { file_types: fileTypes } : {};
226
274
 
227
- // Surface a failure: move to the error state and emit the matching
228
- // analytics event. Centralised so every failure path (empty selection, a
229
- // read/deploy throw) reports uniformly — the mirror of the
230
- // `dropzone_deploy_succeeded` event on the happy path. `file_count` is only
231
- // included where the file set is known (it isn't if `read()` itself threw);
232
- // `file_types` rides along on every failure precisely because it *is* known
233
- // in that case — a rejected single file is the main thing we want to see.
234
- // `reason` goes to analytics as-is (raw, e.g. "drop failed: 429"); `message`
235
- // is what the user sees, which for a caught error is humanized separately.
236
- // `onError` gets both so a consumer (e.g. with `showStatus={false}`) can show
237
- // 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.
238
281
  const fail = (message: string, reason: string, extra?: Record<string, unknown>) => {
239
282
  setStatus({ kind: 'error', message });
240
- track('dropzone_deploy_failed', { method: source, reason, ...typeProps, ...extra });
283
+ emit(DROPZONE_EVENTS.DEPLOY_FAILED, { method: source, reason, ...typeProps, ...extra });
241
284
  onError?.({ message, reason });
242
285
  };
243
286
 
244
- const failFromError = (err: unknown, extra?: Record<string, unknown>) => {
245
- const reason = err instanceof Error ? err.message : String(err);
246
- fail(humanizeDropError(err), reason, extra);
247
- };
248
-
249
287
  try {
250
288
  // Auto-rename a lone non-index HTML file to index.html so it is served at
251
289
  // the site root. Detection (getSingleNonIndexHtmlFileName) sits on the
@@ -260,140 +298,258 @@ export function useDropDeploy({
260
298
  // A drop is the "user provided files" signal; the browse path is already
261
299
  // tracked when the picker opens.
262
300
  if (source === 'drag') {
263
- track('dropzone_files_dropped', {
301
+ emit(DROPZONE_EVENTS.FILES_DROPPED, {
264
302
  method: source,
265
303
  file_count: files.length,
266
304
  ...typeProps,
267
305
  });
268
306
  }
269
307
 
270
- // Build-required projects can't deploy through the anonymous drop API —
271
- // it only performs static deploys, so building one this way yields a
272
- // broken site. Detect them up front and route them to the build path
273
- // (which needs a logged-in user) or, failing that, hand off to the
274
- // authenticated Drop page via signup. Consumers can override the hand-off
275
- // entirely with `onBuildRequired`.
276
- const build = detectBuild(files);
277
- if (build.buildRequired) {
278
- track('dropzone_build_required', {
308
+ const ctx: AttemptContext = { source, fileCount: files.length, typeProps };
309
+ const detection = detectBuild(files);
310
+ if (detection.buildRequired) {
311
+ await handleBuildRequiredDrop(files, detection, ctx);
312
+ } else {
313
+ await deployStaticDrop(files, ctx);
314
+ }
315
+ } catch (err) {
316
+ const reason = err instanceof Error ? err.message : String(err);
317
+ fail(humanizeDropError(err), reason);
318
+ }
319
+ }
320
+
321
+ /**
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`.
328
+ */
329
+ async function handleBuildRequiredDrop(
330
+ files: ProcessedFile[],
331
+ detection: BuildDetection,
332
+ { source, fileCount, typeProps }: AttemptContext
333
+ ) {
334
+ emit(DROPZONE_EVENTS.BUILD_REQUIRED, {
335
+ method: source,
336
+ reason: detection.reason,
337
+ file_count: fileCount,
338
+ ...typeProps,
339
+ });
340
+
341
+ if (onBuildRequired) {
342
+ // The consumer owns the hand-off now; drop back to idle so the zone
343
+ // stays usable rather than sitting on a spinner.
344
+ setStatus({ kind: 'idle' });
345
+ onBuildRequired(detection);
346
+ return;
347
+ }
348
+
349
+ let stashed = false;
350
+ if (authedBuildClient) {
351
+ // Zip before anything is created: the archive serves whichever path
352
+ // runs — an authenticated build right now, or the stash that carries
353
+ // the drop across signup.
354
+ setStatus({ kind: 'zipping' });
355
+ const archive = await zipFiles(files);
356
+
357
+ const built = await enqueueZippedBuild(authedBuildClient, archive);
358
+ if (built) {
359
+ setStatus({ kind: 'redirecting', target: 'build' });
360
+ emit(DROPZONE_EVENTS.BUILD_DEPLOY_SUCCEEDED, {
279
361
  method: source,
280
- reason: build.reason,
281
- file_count: files.length,
362
+ reason: detection.reason,
363
+ site_name: built.siteName,
364
+ file_count: fileCount,
365
+ ...(built.buildSettings ? { framework: built.buildSettings.framework } : {}),
282
366
  ...typeProps,
283
367
  });
284
- if (onBuildRequired) {
285
- // The consumer owns the hand-off now; drop back to idle so the zone
286
- // stays usable rather than sitting on a spinner.
287
- setStatus({ kind: 'idle' });
288
- onBuildRequired(build);
289
- return;
290
- }
291
- if (authedBuildClient) {
292
- const built = await buildProject(authedBuildClient, files);
293
- // No session after all (the auth hint cookie can outlive the session).
294
- // Nothing was created, so fall through to the signup hand-off rather
295
- // than dead-ending on an error the visitor can't act on from here.
296
- if (built) {
297
- setStatus({ kind: 'redirecting', target: 'build' });
298
- track('dropzone_build_deploy_succeeded', {
299
- method: source,
300
- reason: build.reason,
301
- site_name: built.siteName,
302
- file_count: files.length,
303
- ...(built.buildSettings ? { framework: built.buildSettings.framework } : {}),
304
- ...typeProps,
305
- });
306
- onBuildDeploy?.(built);
307
- window.location.assign(buildDeployUrl(built));
308
- return;
309
- }
310
- }
311
- setStatus({ kind: 'redirecting', target: 'signup' });
312
- window.location.assign(buildSignupUrl);
368
+ onBuildDeploy?.(built);
369
+ window.location.assign(buildDeployUrl(built));
313
370
  return;
314
371
  }
315
372
 
316
- setWarning(indexHtmlWarning(files));
317
-
318
- // Deploy into the visitor's account when we can. NotAuthenticatedError is
319
- // the one failure that falls back to the anonymous flow — it's thrown
320
- // before anything is created (the auth hint cookie can outlive the
321
- // session), so retrying anonymously can't strand a half-made site. Any
322
- // later failure is real and surfaces as an error instead: falling back
323
- // then would leave an empty site in the account and deploy a duplicate.
324
- let deployed: DeployResult | null = null;
325
- let authenticated = false;
326
- let authFallback = false;
327
- if (authedDropClient) {
328
- try {
329
- deployed = await deployFiles(authedDropClient, files, {
330
- onProgress: e => setStatus(progressToStatus(e)),
331
- });
332
- authenticated = true;
333
- } catch (err) {
334
- if (!(err instanceof NotAuthenticatedError)) throw err;
335
- authFallback = true;
336
- }
337
- }
338
- if (!deployed) {
339
- deployed = await deployFiles(dropClient, files, {
340
- onProgress: e => setStatus(progressToStatus(e)),
373
+ // No session after all (the auth hint cookie can outlive the session).
374
+ // 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.
377
+ stashed = await saveDropStash(archive.file, archive.buildSettings);
378
+ if (stashed) {
379
+ emit(DROPZONE_EVENTS.PROJECT_STASHED, {
380
+ method: source,
381
+ reason: detection.reason,
382
+ file_count: fileCount,
383
+ ...typeProps,
341
384
  });
342
385
  }
343
- const { url, token, deploy: result } = deployed;
344
-
345
- if (!authenticated) {
346
- // Best-effort local persistence. Note this does NOT reach the dashboard:
347
- // localStorage is per-origin, so a token written here (e.g. www.netlify.com)
348
- // is invisible to app.netlify.com. The cross-origin handoff is the URL
349
- // fragment below; this only helps if the drop and claim share an origin.
350
- // Authenticated deploys skip this entirely — there is no claim token,
351
- // and the upload bearer must never be persisted.
352
- try {
353
- localStorage.setItem('drop-token', token);
354
- localStorage.setItem('dropSiteName', result.subdomain);
355
- } catch {
356
- /* private mode / storage disabled — non-fatal */
357
- }
358
- }
386
+ }
387
+ // Without build wiring nothing here could deploy a stash later, so nothing
388
+ // is stashed — the hand-off is all that's left.
359
389
 
360
- setStatus({ kind: 'redirecting', target: authenticated ? 'dashboard' : 'claim' });
361
- // Fired before the redirect below. Note: the navigation can cut a
362
- // fire-and-forget analytics request short — see the docs for delivery
363
- // hardening (sendBeacon / deferring the redirect). `auth_fallback` is only
364
- // sent when an authenticated attempt bounced (stale auth hint) — omitted
365
- // otherwise, like `file_types`.
366
- track('dropzone_deploy_succeeded', {
367
- method: source,
368
- site_name: result.subdomain,
369
- file_count: files.length,
370
- authenticated,
371
- ...(authFallback ? { auth_fallback: true } : {}),
372
- ...typeProps,
373
- });
374
- onDeploy?.({
375
- url,
376
- ...(authenticated ? {} : { token }),
377
- siteName: result.subdomain,
378
- siteId: result.id,
379
- authenticated,
390
+ if (onBuildSignupHandoff) {
391
+ // The consumer owns the hand-off (e.g. an auth popup that keeps the
392
+ // visitor on this page); back to idle so the zone stays usable.
393
+ setStatus({ kind: 'idle' });
394
+ onBuildSignupHandoff({ stashed, detection });
395
+ return;
396
+ }
397
+ setStatus({ kind: 'redirecting', target: 'signup' });
398
+ window.location.assign(buildSignupUrl);
399
+ }
400
+
401
+ /**
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.
409
+ */
410
+ async function attemptAuthenticatedStaticDeploy(
411
+ client: DropClient,
412
+ files: ProcessedFile[]
413
+ ): Promise<DeployResult | null> {
414
+ try {
415
+ return await deployFiles(client, files, {
416
+ onProgress: e => setStatus(progressToStatus(e)),
380
417
  });
418
+ } catch (err) {
419
+ if (isNotAuthenticatedError(err)) return null;
420
+ throw err;
421
+ }
422
+ }
381
423
 
382
- if (authenticated) {
383
- // The site already lives in the visitor's account — send them straight
384
- // to its dashboard page. No claim step, no token in the URL.
385
- window.location.assign(siteDashboardUrl(result.subdomain));
386
- } else {
387
- // Hand the drop token to the dashboard via the URL fragment — localStorage
388
- // does not cross origins. The dashboard's /drop/:name page reads
389
- // `#drop_token=` and persists it for the claim + post-signup steps.
390
- window.location.assign(`${claimUrl(result.subdomain)}#drop_token=${token}`);
391
- }
424
+ /** A static drop: into the visitor's account when possible, anonymous otherwise. */
425
+ async function deployStaticDrop(
426
+ files: ProcessedFile[],
427
+ { source, fileCount, typeProps }: AttemptContext
428
+ ) {
429
+ setWarning(indexHtmlWarning(files));
430
+
431
+ const authedResult = authedDropClient
432
+ ? await attemptAuthenticatedStaticDeploy(authedDropClient, files)
433
+ : null;
434
+ const authenticated = authedResult !== null;
435
+ // A fallback is an authed attempt that bounced — distinct from never trying.
436
+ const authFallback = authedDropClient !== null && !authenticated;
437
+
438
+ const deployed =
439
+ authedResult ??
440
+ (await deployFiles(dropClient, files, {
441
+ onProgress: e => setStatus(progressToStatus(e)),
442
+ }));
443
+ const { url, token, deploy: result } = deployed;
444
+
445
+ if (!authenticated) {
446
+ persistAnonymousClaimLocally(token, result.subdomain);
447
+ }
448
+
449
+ 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`.
455
+ emit(DROPZONE_EVENTS.DEPLOY_SUCCEEDED, {
456
+ method: source,
457
+ site_name: result.subdomain,
458
+ file_count: fileCount,
459
+ authenticated,
460
+ ...(authFallback ? { auth_fallback: true } : {}),
461
+ ...typeProps,
462
+ });
463
+ onDeploy?.({
464
+ url,
465
+ ...(authenticated ? {} : { token }),
466
+ siteName: result.subdomain,
467
+ siteId: result.id,
468
+ authenticated,
469
+ });
470
+
471
+ redirectAfterStaticDeploy(authenticated, result.subdomain, token);
472
+ }
473
+
474
+ /** Hand the visitor to their new site: its dashboard page, or the claim page. */
475
+ function redirectAfterStaticDeploy(authenticated: boolean, siteName: string, token: string) {
476
+ if (authenticated) {
477
+ // The site already lives in the visitor's account — send them straight
478
+ // to its dashboard page. No claim step, no token in the URL.
479
+ window.location.assign(siteDashboardUrl(siteName));
480
+ } else {
481
+ // Hand the drop token to the dashboard via the URL fragment — localStorage
482
+ // does not cross origins. The dashboard's /drop/:name page reads
483
+ // `#drop_token=` and persists it for the claim + post-signup steps.
484
+ window.location.assign(`${claimUrl(siteName)}#drop_token=${token}`);
485
+ }
486
+ }
487
+
488
+ /**
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.
496
+ */
497
+ async function resumeStashedBuild(client: BuildClient) {
498
+ const stash = await loadDropStash();
499
+ if (!stash) {
500
+ // Expired or unavailable — clear the dangling marker and stay idle.
501
+ await clearDropStash();
502
+ return;
503
+ }
504
+
505
+ emit(DROPZONE_EVENTS.RESUMED, {});
506
+ setStatus({ kind: 'creating' });
507
+
508
+ const archive: ZipResult = { file: stash.zipFile, buildSettings: stash.buildSettings };
509
+ let built: DeployedBuild | null;
510
+ try {
511
+ built = await enqueueZippedBuild(client, archive);
392
512
  } catch (err) {
393
- failFromError(err);
513
+ await clearDropStash();
514
+ failResume(err instanceof Error ? err.message : String(err));
515
+ return;
516
+ }
517
+
518
+ if (!built) {
519
+ // Still no session — the visitor came back logged out. Keep the stash for
520
+ // their next authenticated visit and return to idle without fuss.
521
+ setStatus({ kind: 'idle' });
522
+ return;
394
523
  }
524
+
525
+ await clearDropStash();
526
+ setStatus({ kind: 'redirecting', target: 'build' });
527
+ onBuildDeploy?.(built);
528
+ window.location.assign(buildDeployUrl(built));
529
+ }
530
+
531
+ /** Resume failures re-use the normal error surface, with re-drop copy. */
532
+ function failResume(reason: string) {
533
+ const message =
534
+ 'We couldn\u2019t automatically deploy your project. Drop it again to publish it.';
535
+ setStatus({ kind: 'error', message });
536
+ // No `method`: the failure comes from storage, not a live drop gesture.
537
+ emit(DROPZONE_EVENTS.DEPLOY_FAILED, { reason: 'resume_failed' });
538
+ onError?.({ message, reason });
395
539
  }
396
540
 
541
+ // Attempt a resume once per mount — guarded by the cheap synchronous marker
542
+ // check so ordinary page loads never open IndexedDB, and only when this zone
543
+ // has the wiring to build (a zone without it could not deploy the stash).
544
+ const resumeAttempted = useRef(false);
545
+ useEffect(() => {
546
+ if (resumeAttempted.current || !resumeStashedBuilds) return;
547
+ if (!authedBuildClient || !hasDropStashMarker()) return;
548
+ resumeAttempted.current = true;
549
+ void resumeStashedBuild(authedBuildClient);
550
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- deliberately mount-only
551
+ }, []);
552
+
397
553
  function reset() {
398
554
  setWarning(null);
399
555
  setStatus({ kind: 'idle' });
@@ -6,7 +6,24 @@ export { default as SearchHeading } from './SearchModal/components/SearchHeading
6
6
  export { default as SearchToggle } from './SearchModal/components/SearchToggle/SearchToggle';
7
7
  export { default as AskNetlify } from './AskNetlify/AskNetlify';
8
8
  export { SearchModal } from './SearchModal';
9
- export { DropZone, AuthenticatedBuildClient, AuthenticatedDropClient } from './DropZone';
10
- export type { BuildClient, DeployedBuild, DeployedSite, DropClient } from './DropZone';
9
+ export {
10
+ DropZone,
11
+ AuthenticatedBuildClient,
12
+ AuthenticatedDropClient,
13
+ DROPZONE_EVENTS,
14
+ getFileTypes,
15
+ humanizeDropError,
16
+ } from './DropZone';
17
+ export type {
18
+ BuildClient,
19
+ DeployedBuild,
20
+ DeployedSite,
21
+ DeployError,
22
+ DeployStatus,
23
+ DropClient,
24
+ DropZoneEventName,
25
+ DropZoneEventProperties,
26
+ TrackFn,
27
+ } from './DropZone';
11
28
 
12
29
  export { type IconName } from './Icon';