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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +3 -4
  2. package/dist/components/preact/DropZone/DropZone.d.ts +23 -98
  3. package/dist/components/preact/DropZone/DropZone.js +5 -5
  4. package/dist/components/preact/DropZone/index.js +29 -29
  5. package/dist/components/preact/DropZone/useDropDeploy.d.ts +0 -1
  6. package/dist/components/preact/DropZone/useDropDeploy.js +25 -27
  7. package/dist/components/preact/index.js +10 -10
  8. package/dist/drop-core/analytics.d.ts +4 -15
  9. package/dist/drop-core/authedApi.d.ts +9 -32
  10. package/dist/drop-core/authedApi.js +15 -20
  11. package/dist/drop-core/authedDropClient.d.ts +15 -64
  12. package/dist/drop-core/authedDropClient.js +27 -49
  13. package/dist/drop-core/buildClient.d.ts +19 -53
  14. package/dist/drop-core/buildClient.js +32 -44
  15. package/dist/drop-core/buildStash.d.ts +6 -20
  16. package/dist/drop-core/client.d.ts +4 -2
  17. package/dist/drop-core/client.js +19 -17
  18. package/dist/drop-core/constants.d.ts +9 -0
  19. package/dist/drop-core/constants.js +10 -0
  20. package/dist/drop-core/deploy.d.ts +7 -17
  21. package/dist/drop-core/deploy.js +56 -43
  22. package/dist/drop-core/detectBuild.d.ts +8 -26
  23. package/dist/drop-core/errors.d.ts +27 -17
  24. package/dist/drop-core/errors.js +70 -33
  25. package/dist/drop-core/fileTypes.d.ts +4 -14
  26. package/dist/drop-core/index.d.ts +22 -11
  27. package/dist/drop-core/index.js +45 -49
  28. package/dist/drop-core/readFiles.d.ts +5 -11
  29. package/dist/drop-core/types.d.ts +2 -4
  30. package/dist/drop-core/zip.d.ts +7 -30
  31. package/package.json +1 -1
  32. package/packages/components/preact/DropZone/DropZone.tsx +27 -122
  33. package/packages/components/preact/DropZone/useDropDeploy.ts +31 -73
  34. package/packages/drop-core/README.md +91 -0
  35. package/packages/drop-core/analytics.ts +4 -15
  36. package/packages/drop-core/authedApi.ts +9 -35
  37. package/packages/drop-core/authedDropClient.ts +23 -86
  38. package/packages/drop-core/buildClient.test.ts +19 -4
  39. package/packages/drop-core/buildClient.ts +23 -66
  40. package/packages/drop-core/buildStash.ts +9 -25
  41. package/packages/drop-core/client.ts +19 -11
  42. package/packages/drop-core/constants.ts +25 -0
  43. package/packages/drop-core/deploy.test.ts +42 -0
  44. package/packages/drop-core/deploy.ts +42 -22
  45. package/packages/drop-core/detectBuild.ts +15 -50
  46. package/packages/drop-core/errors.test.ts +34 -3
  47. package/packages/drop-core/errors.ts +63 -27
  48. package/packages/drop-core/fileTypes.ts +5 -19
  49. package/packages/drop-core/index.ts +41 -11
  50. package/packages/drop-core/readFiles.test.ts +0 -1
  51. package/packages/drop-core/readFiles.ts +5 -12
  52. package/packages/drop-core/types.ts +2 -4
  53. package/packages/drop-core/zip.ts +7 -30
@@ -1,46 +1,23 @@
1
1
  import { InferredBuildSettings } from './detectBuild';
2
2
  import { ProcessedFile } from './types';
3
- /**
4
- * Render inferred settings as a `[build]` table. Only `command` and `publish`
5
- * are written: everything else (functions directory, plugins, node version)
6
- * is zero-config on Netlify's side and guessing at it would override detection
7
- * that is better informed than we are.
8
- */
3
+ /** Renders a [build] table — command and publish only; everything else is zero-config server-side. */
9
4
  export declare function generateNetlifyToml({ command, publish }: InferredBuildSettings): string;
10
5
  export interface ZipOptions {
11
6
  /** Filename given to the produced `File`. Shows up as the build's source archive. */
12
7
  name?: string;
13
- /**
14
- * Synthesise a `netlify.toml` `[build]` table from `inferBuildSettings` when the
15
- * drop doesn't declare one (default `true`). Set `false` to upload the files
16
- * exactly as dropped and let buildbot resolve every setting itself.
17
- */
8
+ /** Synthesise a [build] table when the drop lacks one (default true); false uploads as-dropped. */
18
9
  synthesizeNetlifyToml?: boolean;
19
10
  }
20
11
  export interface ZipResult {
21
12
  /** The archive, ready to hand to `createBuildFromZip`. */
22
13
  file: File;
23
- /**
24
- * The settings written into `netlify.toml`, or `null` when the archive is a
25
- * faithful copy of the drop — either because it already declared a `[build]`
26
- * table, or because nothing could be inferred with confidence.
27
- */
14
+ /** Settings written into netlify.toml, or null when the archive is a faithful copy. */
28
15
  buildSettings: InferredBuildSettings | null;
29
16
  }
30
17
  /**
31
- * Zip an already-read file set into the source archive that
32
- * `POST /sites/{id}/builds` expects.
33
- *
34
- * The input is the same root-normalized `ProcessedFile[]` the static deploy path
35
- * uses (see `readFiles`), so junk — dotfiles, `node_modules`, macOS metadata — is
36
- * already gone and paths sit at the deploy root. Output directories (`dist/`,
37
- * `build/`) are deliberately *not* stripped: buildbot regenerates them when
38
- * there's a build command, and a drop whose `netlify.toml` publishes a committed
39
- * directory without a command would break if we removed it.
40
- *
41
- * When the drop needs a build but declares no `[build]` table, an inferred one is
42
- * written in (appended to an existing `netlify.toml`, or created if there is
43
- * none) so buildbot doesn't publish the project's source. See
44
- * `inferBuildSettings` for how conservative that inference is.
18
+ * Zip a read file set into the archive `POST /sites/{id}/builds` expects.
19
+ * Junk is already gone (readFiles); dist/build are deliberately kept — a drop
20
+ * publishing a committed directory without a command would break without them.
21
+ * A [build] table is synthesised when missing (appended, so redirects survive).
45
22
  */
46
23
  export declare function zipFiles(files: ProcessedFile[], { name, synthesizeNetlifyToml }?: ZipOptions): Promise<ZipResult>;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@netlify/spark-ui",
3
3
  "description": "Assets, design tokens, components, and utilities",
4
- "version": "1.31.0-alpha.4",
4
+ "version": "1.31.0-alpha.6",
5
5
  "type": "module",
6
6
  "main": "dist/components/preact/index.js",
7
7
  "types": "dist/components/preact/index.d.ts",
@@ -25,26 +25,13 @@ const DRAG_INACTIVE_MS = 120;
25
25
 
26
26
  const defaultClaimUrl = (siteName: string) => `https://app.netlify.com/drop/${siteName}`;
27
27
 
28
- /**
29
- * Where an authenticated static deploy lands: the new site's project overview,
30
- * the same place the app's own logged-in drop sends you.
31
- */
28
+ /** The project overview — where the app's own logged-in drop lands too. */
32
29
  const defaultSiteDashboardUrl = (siteName: string) =>
33
30
  `https://app.netlify.com/projects/${siteName}`;
34
31
 
35
- /**
36
- * Where a build-required drop is sent by default. Mirrors the app's own Drop
37
- * page: signup with `next=/drop`, so after signup + email verification the user
38
- * lands back on the authenticated Drop page and can build there.
39
- */
32
+ // Mirrors the app's own Drop page hand-off.
40
33
  const defaultBuildSignupUrl = 'https://app.netlify.com/signup?next=/drop&utm_campaign=drop';
41
34
 
42
- /**
43
- * Where an authenticated build drop lands: the new site's project overview —
44
- * the same destination as a static deploy, and where the app's own Drop page
45
- * sends build drops. The overview surfaces the in-progress deploy; consumers
46
- * who want the deploy-log page instead can override with `build.deployId`.
47
- */
48
35
  const defaultBuildDeployUrl = ({ siteName }: DeployedBuild) =>
49
36
  `https://app.netlify.com/projects/${siteName}`;
50
37
 
@@ -57,30 +44,17 @@ interface Props {
57
44
  client?: DropClient;
58
45
  /** Build the "claim this site" URL from the assigned site name (subdomain). */
59
46
  claimUrl?: (siteName: string) => string;
60
- /**
61
- * Where an authenticated static deploy sends the visitor — the site already
62
- * lives in their account, so there is no claim step. Defaults to the new
63
- * site's project overview on app.netlify.com.
64
- */
47
+ /** Destination after an authenticated static deploy (no claim step). Default: the project overview. */
65
48
  siteDashboardUrl?: (siteName: string) => string;
66
49
  /**
67
- * Where to send a build-required drop (framework source, build command,
68
- * functions) that can't be built here — no `getUploadToken`/`buildClient`, or a
69
- * visitor who isn't logged in. Such projects can't deploy through the anonymous
70
- * static drop API, so by default the user is redirected here — the app signup
71
- * page with `next=/drop` — to build on the authenticated Drop page. Override to
72
- * point at your own destination.
50
+ * Signup hand-off for a build drop with no session. The drop is stashed on
51
+ * this origin first — return the visitor here and it deploys automatically.
73
52
  */
74
53
  buildSignupUrl?: string;
75
54
  /**
76
- * Mint a short-lived bearer token for direct api.netlify.com uploads — pass it
77
- * to switch on the whole authenticated path for logged-in visitors: static
78
- * drops deploy straight into their account (then `siteDashboardUrl`), and
79
- * build-required drops build in place instead of being handed off to the app.
80
- * On netlify.com this reads `GET /access-control/generate-access-control-token`.
81
- * Resolving `null` means "no session": static drops fall back to the anonymous
82
- * claim flow, build drops to `buildSignupUrl`.
83
- * See [Logged-in visitors](#logged-in-visitors).
55
+ * Mint the upload bearer — passing this switches on the whole authenticated
56
+ * path. `null` means "no session": static drops fall back to the anonymous
57
+ * claim flow; build drops stash and hand off. See the docs' Logged-in visitors.
84
58
  */
85
59
  getUploadToken?: () => Promise<string | null>;
86
60
  /**
@@ -88,108 +62,46 @@ interface Props {
88
62
  * Takes precedence over `getUploadToken` for the build path.
89
63
  */
90
64
  buildClient?: BuildClient;
91
- /**
92
- * Inject a custom authenticated `DropClient` for static drops (tests, staging,
93
- * a different account flow). Takes precedence over `getUploadToken` for the
94
- * static path. Distinct from `client`, which is the anonymous fallback.
95
- */
65
+ /** Custom authenticated static-drop client; wins over `getUploadToken`. (`client` is the anonymous fallback.) */
96
66
  authenticatedDropClient?: DropClient;
97
- /**
98
- * API root for the authenticated paths' JSON calls, which go through your
99
- * access-control rewrite so the session cookie authorizes them. Defaults to
100
- * `/access-control/bb-api/api/v1`. (Uploads always go direct to
101
- * `api.netlify.com` — the proxy can't carry bodies that large.)
102
- */
67
+ /** Root for the session-cookie JSON calls, via your access-control rewrite. Uploads always go direct. */
103
68
  proxyBase?: string;
104
69
  /** Create the visitor's new project in this account. Omit for their default account. */
105
70
  accountSlug?: string;
106
- /**
107
- * Where an enqueued build sends the visitor. Defaults to the new site's
108
- * project overview, same as a static deploy; `build.deployId` is available for
109
- * consumers who want the deploy-log page instead.
110
- */
71
+ /** Destination after a build is enqueued. Default: the project overview (`build.deployId` for the logs page). */
111
72
  buildDeployUrl?: (build: DeployedBuild) => string;
112
73
  /**
113
- * Poll until the build finishes before handing off, instead of redirecting as
114
- * soon as it's enqueued. The trade-off: waiting means a failing build surfaces
115
- * its own error message here (via `onError` and the overlay) rather than the
116
- * visitor discovering it on the deploy page — at the cost of holding them on a
117
- * spinner for the length of the build, which is minutes. Off by default.
74
+ * Hold the visitor until the build finishes (off by default — builds take
75
+ * minutes). On is the only way a failing build's own message reaches `onError`.
118
76
  */
119
77
  waitForBuild?: boolean;
120
- /**
121
- * Called when a build-required drop is detected, instead of building it or
122
- * redirecting. Use it to own the hand-off yourself (custom routing, persisting
123
- * the drop, gating on auth). When set, the component neither builds nor
124
- * redirects. The static drop path is unaffected either way.
125
- */
78
+ /** Take over build-required drops entirely — when set, the component neither builds nor redirects. */
126
79
  onBuildRequired?: (info: BuildDetection) => void;
127
80
  /**
128
- * Called instead of the `buildSignupUrl` redirect when a build-required drop
129
- * can't be built here (no session). By this point the drop has been stashed
130
- * on this origin (`stashed` says whether that succeeded) — own the hand-off
131
- * however fits your auth flow, e.g. open a signup popup that keeps the
132
- * visitor on the page. Once they're back authenticated, a remount of the
133
- * zone deploys the stash automatically.
81
+ * Own the signup hand-off instead of the `buildSignupUrl` redirect (e.g. an
82
+ * auth popup). The drop is already stashed; a remount while authenticated
83
+ * deploys it.
134
84
  */
135
85
  onBuildSignupHandoff?: (info: { stashed: boolean; detection: BuildDetection }) => void;
136
- /**
137
- * Deploy a previously stashed build drop automatically when the zone mounts
138
- * with an authenticated visitor (default `true`). The stash is written when a
139
- * logged-out build drop is handed to signup; see
140
- * [Signup hand-off and auto-resume](#signup-hand-off-and-auto-resume).
141
- */
86
+ /** Auto-deploy a stashed build drop on mount when authenticated (default true). */
142
87
  resumeStashedBuilds?: boolean;
143
88
  /** Called once the deploy is live. */
144
89
  onDeploy?: (result: DeployedSite) => void;
145
- /**
146
- * Called once a build-required drop has been zipped, uploaded and enqueued —
147
- * before the redirect to `buildDeployUrl`. The site exists at this point but
148
- * won't serve content until the build finishes.
149
- */
90
+ /** Called once a build is enqueued, before the `buildDeployUrl` redirect. */
150
91
  onBuildDeploy?: (build: DeployedBuild) => void;
151
- /**
152
- * Called when a deploy fails, with a humanized `message` (ready to show a
153
- * user — the same copy the built-in overlay uses) and the raw `reason`. Pair
154
- * this with `showStatus={false}` to render your own error UI.
155
- */
92
+ /** Deploy failures: humanized `message` (the overlay's copy) + raw `reason`. Pair with showStatus={false}. */
156
93
  onError?: (error: DeployError) => void;
157
94
  /**
158
- * Render the built-in status overlay (drag tint, spinner, progress bar, error).
159
- * Set to `false` to suppress it entirely and keep your children visible, then
160
- * drive your own feedback from the `data-state` attribute on the root element.
161
- * Note: in this mode you're responsible for surfacing errors yourself — use
162
- * `onError` to get the message.
95
+ * Render the built-in overlay. `false` keeps children visible — drive your
96
+ * own feedback from `data-state`, and surface errors yourself via `onError`.
163
97
  */
164
98
  showStatus?: boolean;
165
99
  /**
166
- * Analytics hook. Called at each tracked moment with an event name and
167
- * properties. Stays provider-agnostic — wire it to Segment/Amplitude/GA in the
168
- * consuming app, e.g. `onTrack={(name, props) => new Analytics().track(name, props)}`.
169
- * Emitted events (all include `dropzone_id` when `analyticsId` is set):
170
- * - `dropzone_files_dropped` — files dropped via drag ({ method: 'drag', file_count, file_types })
171
- * - `dropzone_browse_opened` — file picker opened ({ method: 'keyboard' | 'click' })
172
- * - `dropzone_build_required` — drop needs a build (built here, or handed off) ({ method, reason, file_count, file_types })
173
- * - `dropzone_deploy_succeeded` — static deploy went live ({ method, site_name, file_count, authenticated, auth_fallback?, file_types })
174
- * - `dropzone_build_deploy_succeeded` — build enqueued for a logged-in visitor ({ method, reason, site_name, file_count, framework?, file_types })
175
- * - `dropzone_deploy_failed` — deploy or build errored out ({ method, reason, file_count?, file_types })
176
- *
177
- * `authenticated` says whether the deploy landed in the visitor's account;
178
- * `auth_fallback: true` is sent only when an authenticated attempt bounced
179
- * (stale auth hint) and the drop completed anonymously — omitted otherwise.
180
- * `file_types` is the sorted, de-duplicated list of MIME types in the
181
- * selection, with `application/octet-stream` standing in wherever the browser
182
- * reports none (a dragged folder, an `.exe`). It is omitted when the browser
183
- * exposes no files at all. Note `dropzone_files_dropped` only fires for drag —
184
- * a file-picker selection reports its types on the outcome events, as
185
- * `dropzone_browse_opened` fires before any file exists.
100
+ * Provider-agnostic analytics hook. Event names and payload shapes live in
101
+ * DROPZONE_EVENTS / DropZoneEventProperties (drop-core/analytics.ts).
186
102
  */
187
103
  onTrack?: TrackFn;
188
- /**
189
- * Stable identifier for this instance, attached to every tracked event as
190
- * `dropzone_id`. Set it when a page renders more than one Drop Zone so the
191
- * events can be told apart in Amplitude.
192
- */
104
+ /** Stamped on every event as `dropzone_id` — set when a page has more than one zone. */
193
105
  analyticsId?: string;
194
106
  className?: string;
195
107
  }
@@ -219,8 +131,6 @@ export default function DropZone({
219
131
  analyticsId,
220
132
  className,
221
133
  }: Props) {
222
- // Tag every event with this instance's id so multiple zones on a page can be
223
- // told apart, then hand it off to the consumer's analytics.
224
134
  const track: TrackFn = (event, properties = {}) =>
225
135
  onTrack?.(event, analyticsId ? { dropzone_id: analyticsId, ...properties } : properties);
226
136
 
@@ -254,8 +164,6 @@ export default function DropZone({
254
164
  inputRef.current?.click();
255
165
  };
256
166
 
257
- // The overlay only paints when there's something to show — dragging, deploying,
258
- // or an error. With `showStatus` off, the consumer drives their own feedback.
259
167
  const showOverlay = showStatus && (active || busy || status.kind === 'error');
260
168
  const rootClass = [
261
169
  'n-dropzone',
@@ -344,11 +252,8 @@ export default function DropZone({
344
252
  }
345
253
 
346
254
  /**
347
- * Track whether a drag is currently over the zone. A drag has no reliable
348
- * "cancelled" event — pressing Escape or dropping elsewhere just stops the
349
- * stream of `dragover` events — so we stay active only while they keep arriving
350
- * and clear shortly after they stop. This also handles drags over children,
351
- * where `dragleave` targets a child rather than the zone.
255
+ * Drag-over tracking by dragover decay: drags have no reliable cancel event,
256
+ * and dragleave targets children — so stay active while events keep arriving.
352
257
  */
353
258
  function useDragActive() {
354
259
  const [active, setActive] = useState(false);
@@ -15,7 +15,7 @@ import {
15
15
  getSingleNonIndexHtmlFileName,
16
16
  hasMultipleHtmlFilesWithoutIndex,
17
17
  humanizeDropError,
18
- NotAuthenticatedError,
18
+ isNotAuthenticatedError,
19
19
  renameSingleNonIndexHtmlToIndex,
20
20
  type BuildClient,
21
21
  type BuildDetection,
@@ -88,7 +88,6 @@ export type DeployStatus =
88
88
  | { kind: 'redirecting'; target: 'claim' | 'dashboard' | 'signup' | 'build' }
89
89
  | { kind: 'error'; message: string };
90
90
 
91
- /** Re-exported from the drop-core analytics contract, where the shape now lives. */
92
91
  export type { TrackFn };
93
92
 
94
93
  interface UseDropDeployOptions {
@@ -113,7 +112,6 @@ interface UseDropDeployOptions {
113
112
  track: TrackFn;
114
113
  }
115
114
 
116
- /** Map a low-level deploy progress event onto our UI status. */
117
115
  function progressToStatus(e: DeployProgress): DeployStatus {
118
116
  switch (e.phase) {
119
117
  case 'zipping':
@@ -130,7 +128,7 @@ function progressToStatus(e: DeployProgress): DeployStatus {
130
128
  }
131
129
  }
132
130
 
133
- /** 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. */
134
132
  function indexHtmlWarning(files: ProcessedFile[]): string | null {
135
133
  const singleNonIndex = getSingleNonIndexHtmlFileName(files);
136
134
  if (singleNonIndex) {
@@ -143,12 +141,9 @@ function indexHtmlWarning(files: ProcessedFile[]): string | null {
143
141
  }
144
142
 
145
143
  /**
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.
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.
152
147
  */
153
148
  function persistAnonymousClaimLocally(token: string, siteName: string): void {
154
149
  try {
@@ -184,16 +179,9 @@ export function useDropDeploy({
184
179
  onError,
185
180
  track,
186
181
  }: 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
- */
182
+ /** Typed veneer over `track` — a renamed property fails to compile instead of forking analytics history. */
193
183
  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.
184
+ // The event interfaces carry no index signature, hence the cast to TrackFn's record.
197
185
  track(event, props as Record<string, unknown>);
198
186
 
199
187
  const [status, setStatus] = useState<DeployStatus>({ kind: 'idle' });
@@ -224,10 +212,8 @@ export function useDropDeploy({
224
212
  const busy = status.kind !== 'idle' && status.kind !== 'error';
225
213
 
226
214
  /**
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`.
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.
231
217
  */
232
218
  async function enqueueZippedBuild(
233
219
  client: BuildClient,
@@ -247,7 +233,7 @@ export function useDropDeploy({
247
233
  buildSettings,
248
234
  };
249
235
  } catch (err) {
250
- if (err instanceof NotAuthenticatedError) return null;
236
+ if (isNotAuthenticatedError(err)) return null;
251
237
  throw err;
252
238
  }
253
239
  }
@@ -261,10 +247,8 @@ export function useDropDeploy({
261
247
  }
262
248
 
263
249
  /**
264
- * `fileTypes` is the MIME types of the selection, read at the drop boundary
265
- * (see `getFileTypes`) and passed in rather than derived here — it has to be
266
- * captured before `read()` runs so it survives a read that throws. Omitted
267
- * 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 `[]`.
268
252
  */
269
253
  async function deploy(
270
254
  read: () => Promise<ProcessedFile[]>,
@@ -276,17 +260,8 @@ export function useDropDeploy({
276
260
 
277
261
  const typeProps = fileTypes.length ? { file_types: fileTypes } : {};
278
262
 
279
- // Surface a failure: move to the error state and emit the matching
280
- // analytics event. Centralised so every failure path (empty selection, a
281
- // read/deploy throw) reports uniformly — the mirror of the
282
- // `dropzone_deploy_succeeded` event on the happy path. `file_count` is only
283
- // included where the file set is known (it isn't if `read()` itself threw);
284
- // `file_types` rides along on every failure precisely because it *is* known
285
- // in that case — a rejected single file is the main thing we want to see.
286
- // `reason` goes to analytics as-is (raw, e.g. "drop failed: 429"); `message`
287
- // is what the user sees, which for a caught error is humanized separately.
288
- // `onError` gets both so a consumer (e.g. with `showStatus={false}`) can show
289
- // the humanized message while keeping the raw reason for their own logging.
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.
290
265
  const fail = (message: string, reason: string, extra?: Record<string, unknown>) => {
291
266
  setStatus({ kind: 'error', message });
292
267
  emit(DROPZONE_EVENTS.DEPLOY_FAILED, { method: source, reason, ...typeProps, ...extra });
@@ -294,10 +269,9 @@ export function useDropDeploy({
294
269
  };
295
270
 
296
271
  try {
297
- // Auto-rename a lone non-index HTML file to index.html so it is served at
298
- // the site root. Detection (getSingleNonIndexHtmlFileName) sits on the
299
- // read side; recompute the warning on the renamed set so the fixable
300
- // 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.
301
275
  const files = renameSingleNonIndexHtmlToIndex(await read());
302
276
  if (!files.length) {
303
277
  fail('No files found to deploy.', 'No files found to deploy.', { file_count: 0 });
@@ -328,12 +302,8 @@ export function useDropDeploy({
328
302
  }
329
303
 
330
304
  /**
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`.
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.
337
307
  */
338
308
  async function handleBuildRequiredDrop(
339
309
  files: ProcessedFile[],
@@ -381,8 +351,7 @@ export function useDropDeploy({
381
351
 
382
352
  // No session after all (the auth hint cookie can outlive the session).
383
353
  // 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.
354
+ // signup round-trip.
386
355
  stashed = await saveDropStash(archive.file, archive.buildSettings);
387
356
  if (stashed) {
388
357
  emit(DROPZONE_EVENTS.PROJECT_STASHED, {
@@ -408,13 +377,9 @@ export function useDropDeploy({
408
377
  }
409
378
 
410
379
  /**
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.
380
+ * `null` = no session (pre-mutation, so the anonymous fallback is safe).
381
+ * Anything else rethrows — falling back after the site exists would strand
382
+ * an empty site and deploy a duplicate.
418
383
  */
419
384
  async function attemptAuthenticatedStaticDeploy(
420
385
  client: DropClient,
@@ -425,7 +390,7 @@ export function useDropDeploy({
425
390
  onProgress: e => setStatus(progressToStatus(e)),
426
391
  });
427
392
  } catch (err) {
428
- if (err instanceof NotAuthenticatedError) return null;
393
+ if (isNotAuthenticatedError(err)) return null;
429
394
  throw err;
430
395
  }
431
396
  }
@@ -456,11 +421,10 @@ export function useDropDeploy({
456
421
  }
457
422
 
458
423
  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`.
424
+ // The redirect below can cut a fire-and-forget analytics request short;
425
+ // harden delivery with sendBeacon or by deferring the redirect.
426
+ // `auth_fallback` is only sent when an authenticated attempt bounced
427
+ // (stale auth hint) — omitted otherwise, like `file_types`.
464
428
  emit(DROPZONE_EVENTS.DEPLOY_SUCCEEDED, {
465
429
  method: source,
466
430
  site_name: result.subdomain,
@@ -480,7 +444,6 @@ export function useDropDeploy({
480
444
  redirectAfterStaticDeploy(authenticated, result.subdomain, token);
481
445
  }
482
446
 
483
- /** Hand the visitor to their new site: its dashboard page, or the claim page. */
484
447
  function redirectAfterStaticDeploy(authenticated: boolean, siteName: string, token: string) {
485
448
  if (authenticated) {
486
449
  // The site already lives in the visitor's account — send them straight
@@ -495,13 +458,8 @@ export function useDropDeploy({
495
458
  }
496
459
 
497
460
  /**
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.
461
+ * Deploy the stashed drop. Consumed only once provably authenticated (a
462
+ * bounced attempt keeps it), then consumed win or lose — no retry loops.
505
463
  */
506
464
  async function resumeStashedBuild(client: BuildClient) {
507
465
  const stash = await loadDropStash();
@@ -526,7 +484,7 @@ export function useDropDeploy({
526
484
 
527
485
  if (!built) {
528
486
  // Still no session — the visitor came back logged out. Keep the stash for
529
- // their next authenticated visit and return to idle without fuss.
487
+ // their next authenticated visit.
530
488
  setStatus({ kind: 'idle' });
531
489
  return;
532
490
  }
@@ -0,0 +1,91 @@
1
+ # drop-core
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.
7
+
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.
11
+
12
+ ## Conventions
13
+
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.
18
+
19
+ ### Boundaries
20
+
21
+ - **Nothing in `drop-core` may import from `preact`** (or any UI framework).
22
+ Anything that imports preact may not contain deploy/read/detect logic — it
23
+ adapts this package to a view, nothing more.
24
+ - **The barrel is the public API.** Nothing is public unless `index.ts` names
25
+ it; `export *` is reserved for modules that are contracts in their entirety
26
+ (`types`, `errors`, `analytics`). New helpers default to private.
27
+ - **Granular pure modules, one cohesive orchestrator.** Pure helpers get their
28
+ own small file named after what they export; the deploy orchestration stays
29
+ together in `deploy.ts` rather than fragmenting a state machine.
30
+ - **Shared tunables live in `constants.ts`** with a unit-bearing comment each
31
+ (`// 10 minutes`, not `// the timeout`). Module-specific constants stay with
32
+ their module. No magic numbers inline.
33
+
34
+ ### Naming
35
+
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` |
44
+
45
+ ### Comments
46
+
47
+ - **WHY over WHAT.** A comment earns its place by stating something the code
48
+ can't: an invariant, an observed API behavior, a browser quirk, a
49
+ don't-simplify warning. Never narrate the next line.
50
+ - **Record observed API behavior, with receipts.** The model comment in this
51
+ package: readiness polling keys off `deploy_id` (BSON), not `deploy.id` (site
52
+ UUID), "or the request 401s misleadingly" — naming the server-side file. Say
53
+ what the server _actually does_, and link the server source when you can.
54
+ - **Repeat load-bearing quirk notes at every call site.** The `deploy_id` note
55
+ appears in the client _and_ the orchestrator on purpose — a note that only
56
+ exists where nobody is reading protects nothing.
57
+ - **"Don't simplify" notes name what breaks** — the invariant, and ideally the
58
+ test that pins it (e.g. the `NotAuthenticatedError` boundary below).
59
+ - **No `@param`/`@returns`.** Types carry the shape; JSDoc carries rationale
60
+ and edge cases. (react-dropzone: zero `@param` across its entire source.)
61
+ - File-header purpose blocks only where the filename undersells the contents.
62
+
63
+ ### Errors
64
+
65
+ - **Class per failure, each with a readonly kebab-case `code` brand.** This
66
+ package ships as raw TS (`./components`) _and_ compiled dist (`./preact`,
67
+ `./drop-core`) — two copies of a class can coexist in one app and defeat
68
+ `instanceof`. Detection that steers control flow must go through a
69
+ code-checking predicate (`isNotAuthenticatedError`), never bare `instanceof`.
70
+ - **`NotAuthenticatedError` is pre-mutation only.** It may be thrown solely
71
+ before anything is created (token gate, site-create 401) — it is the signal
72
+ that an anonymous retry is safe. Throwing it after a site exists would strand
73
+ 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.
77
+ - 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.