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

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