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

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 (42) hide show
  1. package/README.md +3 -4
  2. package/dist/components/preact/DropZone/DropZone.d.ts +23 -97
  3. package/dist/components/preact/DropZone/useDropDeploy.js +110 -105
  4. package/dist/drop-core/analytics.d.ts +4 -15
  5. package/dist/drop-core/authedApi.d.ts +9 -22
  6. package/dist/drop-core/authedDropClient.d.ts +13 -55
  7. package/dist/drop-core/authedDropClient.js +5 -18
  8. package/dist/drop-core/buildClient.d.ts +19 -53
  9. package/dist/drop-core/buildClient.js +27 -40
  10. package/dist/drop-core/buildStash.d.ts +6 -20
  11. package/dist/drop-core/buildStash.js +36 -24
  12. package/dist/drop-core/client.d.ts +1 -1
  13. package/dist/drop-core/constants.d.ts +1 -21
  14. package/dist/drop-core/deploy.d.ts +5 -17
  15. package/dist/drop-core/detectBuild.d.ts +8 -26
  16. package/dist/drop-core/errors.d.ts +8 -27
  17. package/dist/drop-core/fileTypes.d.ts +4 -14
  18. package/dist/drop-core/readFiles.d.ts +5 -11
  19. package/dist/drop-core/types.d.ts +2 -4
  20. package/dist/drop-core/zip.d.ts +7 -30
  21. package/package.json +1 -1
  22. package/packages/components/preact/DropZone/DropZone.tsx +27 -121
  23. package/packages/components/preact/DropZone/useDropDeploy.test.ts +12 -0
  24. package/packages/components/preact/DropZone/useDropDeploy.ts +39 -60
  25. package/packages/drop-core/README.md +311 -39
  26. package/packages/drop-core/analytics.ts +4 -15
  27. package/packages/drop-core/authedApi.ts +9 -22
  28. package/packages/drop-core/authedDropClient.ts +13 -55
  29. package/packages/drop-core/buildClient.test.ts +19 -4
  30. package/packages/drop-core/buildClient.ts +21 -63
  31. package/packages/drop-core/buildStash.test.ts +19 -0
  32. package/packages/drop-core/buildStash.ts +38 -29
  33. package/packages/drop-core/client.ts +6 -10
  34. package/packages/drop-core/constants.ts +8 -22
  35. package/packages/drop-core/deploy.ts +6 -22
  36. package/packages/drop-core/detectBuild.ts +15 -50
  37. package/packages/drop-core/errors.test.ts +3 -1
  38. package/packages/drop-core/errors.ts +9 -30
  39. package/packages/drop-core/fileTypes.ts +5 -19
  40. package/packages/drop-core/readFiles.ts +5 -12
  41. package/packages/drop-core/types.ts +2 -4
  42. package/packages/drop-core/zip.ts +7 -30
@@ -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, {
@@ -387,6 +365,18 @@ export function useDropDeploy({
387
365
  // Without build wiring nothing here could deploy a stash later, so nothing
388
366
  // is stashed — the hand-off is all that's left.
389
367
 
368
+ if (!stashed) {
369
+ // Nothing survived the hand-off, so this drop is genuinely lost rather
370
+ // than deferred. Reported with the app's own bounce reason so both
371
+ // surfaces count it the same way.
372
+ emit(DROPZONE_EVENTS.DEPLOY_FAILED, {
373
+ method: source,
374
+ reason: 'anonymous_build_not_supported',
375
+ file_count: fileCount,
376
+ ...typeProps,
377
+ });
378
+ }
379
+
390
380
  if (onBuildSignupHandoff) {
391
381
  // The consumer owns the hand-off (e.g. an auth popup that keeps the
392
382
  // visitor on this page); back to idle so the zone stays usable.
@@ -399,13 +389,9 @@ export function useDropDeploy({
399
389
  }
400
390
 
401
391
  /**
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.
392
+ * `null` = no session (pre-mutation, so the anonymous fallback is safe).
393
+ * Anything else rethrows — falling back after the site exists would strand
394
+ * an empty site and deploy a duplicate.
409
395
  */
410
396
  async function attemptAuthenticatedStaticDeploy(
411
397
  client: DropClient,
@@ -447,11 +433,10 @@ export function useDropDeploy({
447
433
  }
448
434
 
449
435
  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`.
436
+ // The redirect below can cut a fire-and-forget analytics request short;
437
+ // harden delivery with sendBeacon or by deferring the redirect.
438
+ // `auth_fallback` is only sent when an authenticated attempt bounced
439
+ // (stale auth hint) — omitted otherwise, like `file_types`.
455
440
  emit(DROPZONE_EVENTS.DEPLOY_SUCCEEDED, {
456
441
  method: source,
457
442
  site_name: result.subdomain,
@@ -471,7 +456,6 @@ export function useDropDeploy({
471
456
  redirectAfterStaticDeploy(authenticated, result.subdomain, token);
472
457
  }
473
458
 
474
- /** Hand the visitor to their new site: its dashboard page, or the claim page. */
475
459
  function redirectAfterStaticDeploy(authenticated: boolean, siteName: string, token: string) {
476
460
  if (authenticated) {
477
461
  // The site already lives in the visitor's account — send them straight
@@ -486,13 +470,8 @@ export function useDropDeploy({
486
470
  }
487
471
 
488
472
  /**
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.
473
+ * Deploy the stashed drop. Consumed only once provably authenticated (a
474
+ * bounced attempt keeps it), then consumed win or lose — no retry loops.
496
475
  */
497
476
  async function resumeStashedBuild(client: BuildClient) {
498
477
  const stash = await loadDropStash();
@@ -517,7 +496,7 @@ export function useDropDeploy({
517
496
 
518
497
  if (!built) {
519
498
  // Still no session — the visitor came back logged out. Keep the stash for
520
- // their next authenticated visit and return to idle without fuss.
499
+ // their next authenticated visit.
521
500
  setStatus({ kind: 'idle' });
522
501
  return;
523
502
  }
@@ -1,20 +1,300 @@
1
1
  # drop-core
2
2
 
3
- The framework-free engine behind the Drop Zone: reading a dropped folder/zip
4
- into deployable files, deciding whether it needs a build, and deploying it —
5
- anonymously (claim flow) or into a logged-in visitor's account — plus the
6
- stash that carries a build drop across the signup round-trip.
3
+ The engine behind Netlify Drop, without any UI attached.
7
4
 
8
- Consumed three ways: by the Preact `DropZone` in this repo, by anything
9
- importing `@netlify/spark-ui/drop-core`, and eventually by other Netlify
10
- surfaces implementing their own Drop UI over the same core.
5
+ Give it the files a visitor dropped on your page and it will read them, work out
6
+ whether the project needs a build, and deploy it — either anonymously, so the
7
+ visitor claims the site afterwards, or straight into their Netlify account if
8
+ they're signed in.
11
9
 
12
- ## Conventions
10
+ There's no Preact, React, or DOM-framework dependency anywhere in here, so it
11
+ can sit under whatever interface you're building. If you want the drop zone
12
+ _and_ the UI, use the [`DropZone` component](../components/preact/DropZone)
13
+ instead — it's a thin view over this package.
13
14
 
14
- These follow the practices shared by the libraries doing our two jobs —
15
- react-dropzone/Uppy/FilePond on the component side, netlify-cli's deploy
16
- engine, @vercel/client, and tus-js-client on the pipeline side. Where we
17
- deliberately differ, that's recorded too, so it isn't re-litigated by accident.
15
+ ## Install
16
+
17
+ drop-core ships inside `@netlify/spark-ui`:
18
+
19
+ ```sh
20
+ npm install @netlify/spark-ui
21
+ ```
22
+
23
+ ```ts
24
+ import { deployFiles, AnonymousDropClient } from '@netlify/spark-ui/drop-core';
25
+ ```
26
+
27
+ ## Quick start
28
+
29
+ The shortest useful thing: accept a dropped folder and deploy it anonymously.
30
+
31
+ ```ts
32
+ import { AnonymousDropClient, deployFiles, readDataTransfer } from '@netlify/spark-ui/drop-core';
33
+
34
+ zone.addEventListener('drop', async event => {
35
+ event.preventDefault();
36
+ if (!event.dataTransfer) return;
37
+
38
+ // Read the DataTransfer first. The browser detaches it once the event turn
39
+ // ends, so anything that touches it has to run before you await elsewhere.
40
+ const files = await readDataTransfer(event.dataTransfer);
41
+
42
+ const { deploy, token, url } = await deployFiles(new AnonymousDropClient(), files, {
43
+ onProgress: e => console.log(e.phase, e.uploaded, '/', e.total),
44
+ });
45
+
46
+ console.log('live at', url);
47
+
48
+ // Anonymous sites are unclaimed. Send the visitor to the claim page with
49
+ // the token in the fragment — localStorage doesn't cross origins.
50
+ location.assign(`https://app.netlify.com/drop/${deploy.subdomain}#drop_token=${token}`);
51
+ });
52
+ ```
53
+
54
+ `readDataTransfer` handles a dropped folder, a zip, or a multi-file selection,
55
+ strips junk like `__MACOSX` and dotfiles, flattens a single wrapping folder, and
56
+ sha1-digests each file. A lone `.html` file is renamed to `index.html` so it
57
+ serves at the site root.
58
+
59
+ For a file picker instead of a drop, use `readFileList(input.files)`.
60
+
61
+ ## Deploying into a signed-in account
62
+
63
+ Swap the client. `AuthenticatedDropClient` creates the site in the visitor's own
64
+ account, so there's no claim step — you can send them straight to their new
65
+ project.
66
+
67
+ JSON calls ride a same-origin `/access-control` proxy on their session cookie;
68
+ file uploads go direct to `api.netlify.com` on a bearer token you mint:
69
+
70
+ ```ts
71
+ import {
72
+ AuthenticatedDropClient,
73
+ deployFiles,
74
+ isNotAuthenticatedError,
75
+ } from '@netlify/spark-ui/drop-core';
76
+
77
+ const client = new AuthenticatedDropClient({
78
+ getUploadToken: async () => {
79
+ const res = await fetch('/access-control/generate-access-control-token', {
80
+ credentials: 'include',
81
+ });
82
+ if (!res.ok) return null; // `null` means "no session"
83
+ return (await res.json()).accessControlToken ?? null;
84
+ },
85
+ });
86
+
87
+ try {
88
+ const { deploy } = await deployFiles(client, files);
89
+ location.assign(`https://app.netlify.com/projects/${deploy.subdomain}`);
90
+ } catch (err) {
91
+ if (isNotAuthenticatedError(err)) {
92
+ // No session after all. Nothing was created, so falling back to an
93
+ // anonymous deploy here is safe.
94
+ return deployAnonymously(files);
95
+ }
96
+ throw err;
97
+ }
98
+ ```
99
+
100
+ Pass `accountSlug` if you want the site created in a specific team rather than
101
+ the visitor's default one.
102
+
103
+ ## Projects that need a build
104
+
105
+ A dropped Astro or Next project can't be served as static files. `detectBuild`
106
+ tells you, cheaply, by looking at `netlify.toml`, `package.json`, and the
107
+ zero-config functions directories:
108
+
109
+ ```ts
110
+ import { detectBuild, deployBuild, AuthenticatedBuildClient } from '@netlify/spark-ui/drop-core';
111
+
112
+ const detection = detectBuild(files);
113
+
114
+ if (!detection.buildRequired) {
115
+ await deployFiles(client, files);
116
+ } else {
117
+ const build = await deployBuild(new AuthenticatedBuildClient({ getUploadToken }), files, {
118
+ onProgress: e => setPhase(e.phase),
119
+ });
120
+ location.assign(`https://app.netlify.com/projects/${build.site.name}`);
121
+ }
122
+ ```
123
+
124
+ `detection.reason` tells you which signal fired (`netlify-toml-build`,
125
+ `package-json-build-script`, `framework-dependency`, `functions-directory`) —
126
+ useful for analytics or for explaining the decision to the visitor.
127
+
128
+ `deployBuild` zips the project, creates the site, and hands the archive to
129
+ Netlify's build system. It returns as soon as the build is enqueued; pass
130
+ `waitForBuild: true` if you'd rather wait for it to finish, which is also the
131
+ only way a failing build's own error message reaches you.
132
+
133
+ If the project has no `[build]` table of its own, drop-core infers one from the
134
+ detected framework and writes it into the archive — but only when it's confident
135
+ about both the command and the publish directory, since guessing the publish
136
+ directory wrong ships your source instead of your site.
137
+
138
+ Building requires a signed-in visitor: there's no anonymous build API.
139
+
140
+ ## Carrying a drop across signup
141
+
142
+ If a visitor drops a project that needs a build and _isn't_ signed in, you don't
143
+ have to throw their work away. Stash the archive, send them to signup, and
144
+ deploy it when they come back:
145
+
146
+ ```ts
147
+ import {
148
+ clearDropStash,
149
+ deployZippedBuild,
150
+ hasDropStashMarker,
151
+ loadDropStash,
152
+ saveDropStash,
153
+ zipFiles,
154
+ } from '@netlify/spark-ui/drop-core';
155
+
156
+ // On the way out:
157
+ const archive = await zipFiles(files);
158
+ await saveDropStash(archive.file, archive.buildSettings);
159
+ location.assign('https://app.netlify.com/signup?next=/drop');
160
+
161
+ // On any later page load, once they're back and signed in:
162
+ if (hasDropStashMarker()) {
163
+ const stash = await loadDropStash();
164
+ if (stash) {
165
+ const built = await deployZippedBuild(buildClient, {
166
+ file: stash.zipFile,
167
+ buildSettings: stash.buildSettings,
168
+ });
169
+ await clearDropStash();
170
+ location.assign(`https://app.netlify.com/projects/${built.site.name}`);
171
+ }
172
+ }
173
+ ```
174
+
175
+ The archive lives in IndexedDB for 24 hours, with a small localStorage marker so
176
+ an ordinary page load can ask "is there anything waiting?" without opening a
177
+ database. Storage is per-origin, so whatever you hand off to must bring the
178
+ visitor back to the same origin.
179
+
180
+ Only consume the stash once the visitor is genuinely authenticated — if they
181
+ come back still signed out, leave it alone for next time.
182
+
183
+ ## Reporting progress
184
+
185
+ Every deploy function takes an `onProgress` callback and reports a phase:
186
+
187
+ | Phase | Meaning |
188
+ | ------------ | ------------------------------------------ |
189
+ | `zipping` | Packing the project (build path only) |
190
+ | `creating` | Creating the site and deploy |
191
+ | `uploading` | Uploading files, with `uploaded` / `total` |
192
+ | `processing` | Uploaded; Netlify is making the site live |
193
+ | `building` | Build running (build path only) |
194
+ | `ready` | Done |
195
+
196
+ Static deploys run `creating → uploading → processing → ready`; build deploys
197
+ run `zipping → creating → uploading → building → ready`. Only `uploading` on the
198
+ static path carries file counts — a build zip is a single request, and the
199
+ browser can't report progress within it.
200
+
201
+ ## Handling failures
202
+
203
+ Each failure mode has its own error class carrying a stable `code`:
204
+ `UnsupportedDropError`, `NotAuthenticatedError`, `SiteCreateError`,
205
+ `BuildCreateError`, `BuildFailedError`, `BuildTimeoutError`,
206
+ `DeployFailedError`, `DeployTimeoutError`.
207
+
208
+ For anything you're showing a person, `humanizeDropError` turns any of them —
209
+ plus network failures and raw HTTP statuses — into a sentence you can display:
210
+
211
+ ```ts
212
+ import { humanizeDropError } from '@netlify/spark-ui/drop-core';
213
+
214
+ catch (err) {
215
+ showError(humanizeDropError(err));
216
+ track('deploy_failed', { reason: String(err) }); // keep the raw one for logs
217
+ }
218
+ ```
219
+
220
+ To branch on control flow rather than display, use the `isNotAuthenticatedError`
221
+ predicate rather than `instanceof`. This package ships as both raw TypeScript
222
+ and compiled output, so two copies of the same class can exist in one app and
223
+ `instanceof` will quietly miss.
224
+
225
+ ## Analytics
226
+
227
+ `DROPZONE_EVENTS` holds the canonical event names, and
228
+ `DropZoneEventProperties` maps each one to its payload shape, so you can wire
229
+ your own provider without hand-writing strings:
230
+
231
+ ```ts
232
+ import { DROPZONE_EVENTS } from '@netlify/spark-ui/drop-core';
233
+
234
+ track(DROPZONE_EVENTS.DEPLOY_SUCCEEDED, { method: 'drag', file_count: 12 });
235
+ ```
236
+
237
+ ## Good to know
238
+
239
+ - **Uploads can't go through the `/access-control` proxy.** `PUT` isn't
240
+ allowlisted and bodies cap around 6 MB, so file uploads always go direct to
241
+ `api.netlify.com` on a bearer token.
242
+ - **Upload tokens are short-lived off the app origin** — 30 seconds, against the
243
+ app's 300. Static deploys re-mint as they go, but a build zip is one request,
244
+ so an upload slower than that will fail. Raising the limit is a platform-side
245
+ change.
246
+ - **`NotAuthenticatedError` only ever means "nothing was created yet."** It's
247
+ your signal that an anonymous retry is safe. Nothing throws it once a site
248
+ exists, because falling back then would leave an empty project behind and
249
+ deploy a duplicate.
250
+ - **`deploy_id` is not `deploy.id`.** File uploads and readiness polling key off
251
+ `deploy_id` (BSON); `deploy.id` is the site's UUID. Mixing them up produces a
252
+ misleading 401.
253
+ - **`detectBuild` is deliberately lightweight** — two config files and the
254
+ functions directories, no framework-detection dependency. If you already have
255
+ something better, inject your own detection and use `deployBuild` directly.
256
+
257
+ ## Reference
258
+
259
+ **Reading files**
260
+
261
+ | Export | Purpose |
262
+ | ---------------------- | ------------------------------------------------------- |
263
+ | `readDataTransfer(dt)` | A drop: folder, zip, or loose files → `ProcessedFile[]` |
264
+ | `readFileList(files)` | The same, from an `<input type="file">` |
265
+ | `getFileTypes(files)` | Distinct MIME types, for analytics |
266
+
267
+ **Detecting and packing**
268
+
269
+ | Export | Purpose |
270
+ | --------------------------- | ------------------------------------------------- |
271
+ | `detectBuild(files)` | `{ buildRequired, reason }` |
272
+ | `inferBuildSettings(files)` | Build command + publish dir, or `null` if unsure |
273
+ | `zipFiles(files)` | The source archive, plus any synthesised settings |
274
+
275
+ **Deploying**
276
+
277
+ | Export | Purpose |
278
+ | -------------------------------------- | ----------------------------------------- |
279
+ | `deployFiles(client, files, opts)` | Static deploy |
280
+ | `deployBuild(client, files, opts)` | Zip, create site, enqueue build |
281
+ | `deployZippedBuild(client, zip, opts)` | Same, from an archive you already have |
282
+ | `AnonymousDropClient` | Claim-flow deploys, no session needed |
283
+ | `AuthenticatedDropClient` | Static deploys into the visitor's account |
284
+ | `AuthenticatedBuildClient` | Builds in the visitor's account |
285
+
286
+ **Stash**
287
+
288
+ | Export | Purpose |
289
+ | ----------------------------------- | ------------------------------------------- |
290
+ | `saveDropStash(zip, buildSettings)` | Store an archive for after signup |
291
+ | `hasDropStashMarker()` | Cheap synchronous "is anything waiting?" |
292
+ | `loadDropStash()` | The archive, or `null` if expired or absent |
293
+ | `clearDropStash()` | Remove it; idempotent, never throws |
294
+
295
+ Anything not exported from `index.ts` is internal and may change.
296
+
297
+ ## Contributing
18
298
 
19
299
  ### Boundaries
20
300
 
@@ -33,14 +313,14 @@ deliberately differ, that's recorded too, so it isn't re-litigated by accident.
33
313
 
34
314
  ### Naming
35
315
 
36
- | Kind | Convention | Examples here |
37
- | ------------------ | ---------------------------------- | ------------------------------------------------------------- |
38
- | Outcome callbacks | `onVerb` / past participle | `onDeploy`, `onBuildRequired`, `onError` |
39
- | Boolean state | `isX` | `busy` (exception, pre-dates rule), `isNotAuthenticatedError` |
40
- | Strategy injection | bare noun | `client`, `buildClient`, `validator`-style |
41
- | Analytics events | `dropzone_snake_case` — **frozen** | `DROPZONE_EVENTS` (test-pinned wire names) |
42
- | Error predicates | `isXError(err)` | `isNotAuthenticatedError` |
43
- | Files | filename = main export | `detectBuild.ts`, `buildStash.ts` |
316
+ | Kind | Convention | Examples here |
317
+ | ------------------ | --------------------------------------- | ------------------------------------------------------------- |
318
+ | Outcome callbacks | `onVerb` / past participle | `onDeploy`, `onBuildRequired`, `onError` |
319
+ | Boolean state | `isX` | `busy` (exception, pre-dates rule), `isNotAuthenticatedError` |
320
+ | Strategy injection | bare noun | `client`, `buildClient`, `validator`-style |
321
+ | Analytics events | `dropzone_snake_case` — **append-only** | `DROPZONE_EVENTS` (renames fail `analytics.test.ts`) |
322
+ | Error predicates | `isXError(err)` | `isNotAuthenticatedError` |
323
+ | Files | filename = main export | `detectBuild.ts`, `buildStash.ts` |
44
324
 
45
325
  ### Comments
46
326
 
@@ -55,9 +335,9 @@ deliberately differ, that's recorded too, so it isn't re-litigated by accident.
55
335
  appears in the client _and_ the orchestrator on purpose — a note that only
56
336
  exists where nobody is reading protects nothing.
57
337
  - **"Don't simplify" notes name what breaks** — the invariant, and ideally the
58
- test that pins it (e.g. the `NotAuthenticatedError` boundary below).
338
+ test that pins it.
59
339
  - **No `@param`/`@returns`.** Types carry the shape; JSDoc carries rationale
60
- and edge cases. (react-dropzone: zero `@param` across its entire source.)
340
+ and edge cases.
61
341
  - File-header purpose blocks only where the filename undersells the contents.
62
342
 
63
343
  ### Errors
@@ -71,21 +351,13 @@ deliberately differ, that's recorded too, so it isn't re-litigated by accident.
71
351
  before anything is created (token gate, site-create 401) — it is the signal
72
352
  that an anonymous retry is safe. Throwing it after a site exists would strand
73
353
  an empty project and deploy a duplicate. Pinned by tests.
74
- - **Humanized copy lives in `humanizeDropError`, in this package.** A
75
- deliberate divergence from @vercel/client (which leaves rendering to the
76
- CLI): our consumers render our copy directly, and the docs promise it.
354
+ - **Humanized copy lives in `humanizeDropError`, in this package.** Consumers
355
+ render our copy directly, and the docs promise it.
77
356
  - Transient upload failures retry on `UPLOAD_RETRY_DELAYS_MS` (backoff as
78
- data, the tus idiom); **400/422 never retry** — the request itself is wrong.
79
-
80
- ### Considered and rejected
81
-
82
- - **Async-generator lifecycles** (@vercel/client): our phase-callback design is
83
- equivalent and churn-free; `DeployPhase` is a closed union, which netlify-cli's
84
- own author wished theirs was.
85
- - **Kebab-case event renames**: `dropzone_*` wire names are pinned analytics
86
- history; `analytics.test.ts` fails on any rename. Append-only.
87
- - **`getRootProps`/`getInputProps` attribute getters** (react-dropzone, Uppy):
88
- the right shape for a future framework-free UI tier shared with the app —
89
- not something to bolt onto the shipped wrapper component.
90
- - **A `defaultOptions` object** (tus, Uppy): our destructured constructor
91
- defaults are react-dropzone's pattern; either is fine, we have this one.
357
+ data, so the curve is overridable); **400/422 never retry** — the request
358
+ itself is wrong.
359
+
360
+ ### Tests
361
+
362
+ Colocated as `*.test.ts` — the Vite build glob ignores `**/*.test.*`, and
363
+ anything else under `packages/` gets published. `npm test` runs the suite.
@@ -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;