@netlify/spark-ui 1.31.0-alpha.6 → 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.
@@ -1,22 +1,22 @@
1
- import { d as R, A as S, y as nt } from "../../../chunks/hooks.module.DRcxknIz.js";
2
- import { humanizeDropError as rt, isNotAuthenticatedError as O } from "../../../drop-core/errors.js";
1
+ import { d as O, A as S, y as nt } from "../../../chunks/hooks.module.DRcxknIz.js";
2
+ import { humanizeDropError as rt, isNotAuthenticatedError as R } from "../../../drop-core/errors.js";
3
3
  import { DROPZONE_EVENTS as c } from "../../../drop-core/analytics.js";
4
4
  import { renameSingleNonIndexHtmlToIndex as at, getSingleNonIndexHtmlFileName as ot, hasMultipleHtmlFilesWithoutIndex as st } from "../../../drop-core/readFiles.js";
5
5
  import { detectBuild as dt } from "../../../drop-core/detectBuild.js";
6
- import { zipFiles as lt } from "../../../drop-core/zip.js";
7
- import { AnonymousDropClient as ut } from "../../../drop-core/client.js";
6
+ import { zipFiles as ut } from "../../../drop-core/zip.js";
7
+ import { AnonymousDropClient as lt } from "../../../drop-core/client.js";
8
8
  import { AuthenticatedDropClient as ct } from "../../../drop-core/authedDropClient.js";
9
9
  import { AuthenticatedBuildClient as mt } from "../../../drop-core/buildClient.js";
10
10
  import { deployZippedBuild as ft, deployFiles as v } from "../../../drop-core/deploy.js";
11
- import { hasDropStashMarker as gt, loadDropStash as ht, clearDropStash as E, saveDropStash as pt } from "../../../drop-core/buildStash.js";
12
- function I(l) {
13
- switch (l.phase) {
11
+ import { hasDropStashMarker as gt, loadDropStash as ht, clearDropStash as y, saveDropStash as pt } from "../../../drop-core/buildStash.js";
12
+ function I(u) {
13
+ switch (u.phase) {
14
14
  case "zipping":
15
15
  return { kind: "zipping" };
16
16
  case "creating":
17
17
  return { kind: "creating" };
18
18
  case "uploading":
19
- return { kind: "uploading", uploaded: l.uploaded ?? 0, total: l.total ?? 0 };
19
+ return { kind: "uploading", uploaded: u.uploaded ?? 0, total: u.total ?? 0 };
20
20
  case "building":
21
21
  return { kind: "building" };
22
22
  case "processing":
@@ -24,192 +24,197 @@ function I(l) {
24
24
  return { kind: "processing" };
25
25
  }
26
26
  }
27
- function wt(l) {
28
- const m = ot(l);
29
- return m ? `No index.html found — "${m}" won’t be served at the site root.` : st(l) ? "No index.html found — your site may not have a landing page." : null;
27
+ function wt(u) {
28
+ const m = ot(u);
29
+ return m ? `No index.html found — "${m}" won’t be served at the site root.` : st(u) ? "No index.html found — your site may not have a landing page." : null;
30
30
  }
31
- function St(l, m) {
31
+ function St(u, m) {
32
32
  try {
33
- localStorage.setItem("drop-token", l), localStorage.setItem("dropSiteName", m);
33
+ localStorage.setItem("drop-token", u), localStorage.setItem("dropSiteName", m);
34
34
  } catch {
35
35
  }
36
36
  }
37
- function Pt({
38
- apiBase: l,
37
+ function Ft({
38
+ apiBase: u,
39
39
  client: m,
40
40
  claimUrl: z,
41
- siteDashboardUrl: H,
42
- buildSignupUrl: W,
43
- buildClient: Y,
41
+ siteDashboardUrl: Y,
42
+ buildSignupUrl: H,
43
+ buildClient: W,
44
44
  authenticatedDropClient: M,
45
45
  getUploadToken: p,
46
46
  proxyBase: N,
47
47
  accountSlug: A,
48
- buildDeployUrl: C,
48
+ buildDeployUrl: L,
49
49
  waitForBuild: Z,
50
- onBuildRequired: L,
51
- onBuildSignupHandoff: P,
50
+ onBuildRequired: C,
51
+ onBuildSignupHandoff: F,
52
52
  resumeStashedBuilds: $ = !0,
53
- onDeploy: k,
53
+ onDeploy: _,
54
54
  onBuildDeploy: f,
55
55
  onError: g,
56
56
  track: q
57
57
  }) {
58
- const u = (n, t) => (
58
+ const l = (n, i) => (
59
59
  // The event interfaces carry no index signature, hence the cast to TrackFn's record.
60
- q(n, t)
61
- ), [b, a] = R({ kind: "idle" }), [U, _] = R(null), j = S(m ?? new ut(l)).current, w = S(
62
- Y ?? (p ? new mt({ proxyBase: N, getUploadToken: p, accountSlug: A }) : null)
63
- ).current, y = S(
60
+ q(n, i)
61
+ ), [k, a] = O({ kind: "idle" }), [U, b] = O(null), j = S(m ?? new lt(u)).current, w = S(
62
+ W ?? (p ? new mt({ proxyBase: N, getUploadToken: p, accountSlug: A }) : null)
63
+ ).current, D = S(
64
64
  M ?? (p ? new ct({ proxyBase: N, getUploadToken: p, accountSlug: A }) : null)
65
- ).current, J = b.kind !== "idle" && b.kind !== "error";
66
- async function x(n, t) {
65
+ ).current, J = k.kind !== "idle" && k.kind !== "error";
66
+ async function P(n, i) {
67
67
  try {
68
- const { site: i, build: r, buildSettings: o, url: e } = await ft(n, t, {
68
+ const { site: e, build: r, buildSettings: o, url: t } = await ft(n, i, {
69
69
  onProgress: (s) => a(I(s)),
70
70
  waitForBuild: Z
71
71
  });
72
72
  return {
73
- url: e,
74
- siteName: i.name,
75
- siteId: i.id,
73
+ url: t,
74
+ siteName: e.name,
75
+ siteId: e.id,
76
76
  buildId: r.id,
77
77
  deployId: r.deploy_id,
78
78
  buildSettings: o
79
79
  };
80
- } catch (i) {
81
- if (O(i)) return null;
82
- throw i;
80
+ } catch (e) {
81
+ if (R(e)) return null;
82
+ throw e;
83
83
  }
84
84
  }
85
- async function Q(n, t, i) {
86
- _(null), a({ kind: "reading" });
87
- const r = i.length ? { file_types: i } : {}, o = (e, s, d) => {
88
- a({ kind: "error", message: e }), u(c.DEPLOY_FAILED, { method: t, reason: s, ...r, ...d }), g == null || g({ message: e, reason: s });
85
+ async function Q(n, i, e) {
86
+ b(null), a({ kind: "reading" });
87
+ const r = e.length ? { file_types: e } : {}, o = (t, s, d) => {
88
+ a({ kind: "error", message: t }), l(c.DEPLOY_FAILED, { method: i, reason: s, ...r, ...d }), g == null || g({ message: t, reason: s });
89
89
  };
90
90
  try {
91
- const e = at(await n());
92
- if (!e.length) {
91
+ const t = at(await n());
92
+ if (!t.length) {
93
93
  o("No files found to deploy.", "No files found to deploy.", { file_count: 0 });
94
94
  return;
95
95
  }
96
- t === "drag" && u(c.FILES_DROPPED, {
97
- method: t,
98
- file_count: e.length,
96
+ i === "drag" && l(c.FILES_DROPPED, {
97
+ method: i,
98
+ file_count: t.length,
99
99
  ...r
100
100
  });
101
- const s = { source: t, fileCount: e.length, typeProps: r }, d = dt(e);
102
- d.buildRequired ? await T(e, d, s) : await G(e, s);
103
- } catch (e) {
104
- const s = e instanceof Error ? e.message : String(e);
105
- o(rt(e), s);
101
+ const s = { source: i, fileCount: t.length, typeProps: r }, d = dt(t);
102
+ d.buildRequired ? await T(t, d, s) : await G(t, s);
103
+ } catch (t) {
104
+ const s = t instanceof Error ? t.message : String(t);
105
+ o(rt(t), s);
106
106
  }
107
107
  }
108
- async function T(n, t, { source: i, fileCount: r, typeProps: o }) {
109
- if (u(c.BUILD_REQUIRED, {
110
- method: i,
111
- reason: t.reason,
108
+ async function T(n, i, { source: e, fileCount: r, typeProps: o }) {
109
+ if (l(c.BUILD_REQUIRED, {
110
+ method: e,
111
+ reason: i.reason,
112
112
  file_count: r,
113
113
  ...o
114
- }), L) {
115
- a({ kind: "idle" }), L(t);
114
+ }), C) {
115
+ a({ kind: "idle" }), C(i);
116
116
  return;
117
117
  }
118
- let e = !1;
118
+ let t = !1;
119
119
  if (w) {
120
120
  a({ kind: "zipping" });
121
- const s = await lt(n), d = await x(w, s);
121
+ const s = await ut(n), d = await P(w, s);
122
122
  if (d) {
123
- a({ kind: "redirecting", target: "build" }), u(c.BUILD_DEPLOY_SUCCEEDED, {
124
- method: i,
125
- reason: t.reason,
123
+ a({ kind: "redirecting", target: "build" }), l(c.BUILD_DEPLOY_SUCCEEDED, {
124
+ method: e,
125
+ reason: i.reason,
126
126
  site_name: d.siteName,
127
127
  file_count: r,
128
128
  ...d.buildSettings ? { framework: d.buildSettings.framework } : {},
129
129
  ...o
130
- }), f == null || f(d), window.location.assign(C(d));
130
+ }), f == null || f(d), window.location.assign(L(d));
131
131
  return;
132
132
  }
133
- e = await pt(s.file, s.buildSettings), e && u(c.PROJECT_STASHED, {
134
- method: i,
135
- reason: t.reason,
133
+ t = await pt(s.file, s.buildSettings), t && l(c.PROJECT_STASHED, {
134
+ method: e,
135
+ reason: i.reason,
136
136
  file_count: r,
137
137
  ...o
138
138
  });
139
139
  }
140
- if (P) {
141
- a({ kind: "idle" }), P({ stashed: e, detection: t });
140
+ if (t || l(c.DEPLOY_FAILED, {
141
+ method: e,
142
+ reason: "anonymous_build_not_supported",
143
+ file_count: r,
144
+ ...o
145
+ }), F) {
146
+ a({ kind: "idle" }), F({ stashed: t, detection: i });
142
147
  return;
143
148
  }
144
- a({ kind: "redirecting", target: "signup" }), window.location.assign(W);
149
+ a({ kind: "redirecting", target: "signup" }), window.location.assign(H);
145
150
  }
146
- async function V(n, t) {
151
+ async function V(n, i) {
147
152
  try {
148
- return await v(n, t, {
149
- onProgress: (i) => a(I(i))
153
+ return await v(n, i, {
154
+ onProgress: (e) => a(I(e))
150
155
  });
151
- } catch (i) {
152
- if (O(i)) return null;
153
- throw i;
156
+ } catch (e) {
157
+ if (R(e)) return null;
158
+ throw e;
154
159
  }
155
160
  }
156
- async function G(n, { source: t, fileCount: i, typeProps: r }) {
157
- _(wt(n));
158
- const o = y ? await V(y, n) : null, e = o !== null, s = y !== null && !e, d = o ?? await v(j, n, {
159
- onProgress: (it) => a(I(it))
160
- }), { url: et, token: D, deploy: h } = d;
161
- e || St(D, h.subdomain), a({ kind: "redirecting", target: e ? "dashboard" : "claim" }), u(c.DEPLOY_SUCCEEDED, {
162
- method: t,
161
+ async function G(n, { source: i, fileCount: e, typeProps: r }) {
162
+ b(wt(n));
163
+ const o = D ? await V(D, n) : null, t = o !== null, s = D !== null && !t, d = o ?? await v(j, n, {
164
+ onProgress: (et) => a(I(et))
165
+ }), { url: it, token: E, deploy: h } = d;
166
+ t || St(E, h.subdomain), a({ kind: "redirecting", target: t ? "dashboard" : "claim" }), l(c.DEPLOY_SUCCEEDED, {
167
+ method: i,
163
168
  site_name: h.subdomain,
164
- file_count: i,
165
- authenticated: e,
169
+ file_count: e,
170
+ authenticated: t,
166
171
  ...s ? { auth_fallback: !0 } : {},
167
172
  ...r
168
- }), k == null || k({
169
- url: et,
170
- ...e ? {} : { token: D },
173
+ }), _ == null || _({
174
+ url: it,
175
+ ...t ? {} : { token: E },
171
176
  siteName: h.subdomain,
172
177
  siteId: h.id,
173
- authenticated: e
174
- }), K(e, h.subdomain, D);
178
+ authenticated: t
179
+ }), K(t, h.subdomain, E);
175
180
  }
176
- function K(n, t, i) {
177
- n ? window.location.assign(H(t)) : window.location.assign(`${z(t)}#drop_token=${i}`);
181
+ function K(n, i, e) {
182
+ n ? window.location.assign(Y(i)) : window.location.assign(`${z(i)}#drop_token=${e}`);
178
183
  }
179
184
  async function X(n) {
180
- const t = await ht();
181
- if (!t) {
182
- await E();
185
+ const i = await ht();
186
+ if (!i) {
187
+ await y();
183
188
  return;
184
189
  }
185
- u(c.RESUMED, {}), a({ kind: "creating" });
186
- const i = { file: t.zipFile, buildSettings: t.buildSettings };
190
+ l(c.RESUMED, {}), a({ kind: "creating" });
191
+ const e = { file: i.zipFile, buildSettings: i.buildSettings };
187
192
  let r;
188
193
  try {
189
- r = await x(n, i);
194
+ r = await P(n, e);
190
195
  } catch (o) {
191
- await E(), B(o instanceof Error ? o.message : String(o));
196
+ await y(), B(o instanceof Error ? o.message : String(o));
192
197
  return;
193
198
  }
194
199
  if (!r) {
195
200
  a({ kind: "idle" });
196
201
  return;
197
202
  }
198
- await E(), a({ kind: "redirecting", target: "build" }), f == null || f(r), window.location.assign(C(r));
203
+ await y(), a({ kind: "redirecting", target: "build" }), f == null || f(r), window.location.assign(L(r));
199
204
  }
200
205
  function B(n) {
201
- const t = "We couldn’t automatically deploy your project. Drop it again to publish it.";
202
- a({ kind: "error", message: t }), u(c.DEPLOY_FAILED, { reason: "resume_failed" }), g == null || g({ message: t, reason: n });
206
+ const i = "We couldn’t automatically deploy your project. Drop it again to publish it.";
207
+ a({ kind: "error", message: i }), l(c.DEPLOY_FAILED, { reason: "resume_failed" }), g == null || g({ message: i, reason: n });
203
208
  }
204
- const F = S(!1);
209
+ const x = S(!1);
205
210
  nt(() => {
206
- F.current || !$ || !w || !gt() || (F.current = !0, X(w));
211
+ x.current || !$ || !w || !gt() || (x.current = !0, X(w));
207
212
  }, []);
208
213
  function tt() {
209
- _(null), a({ kind: "idle" });
214
+ b(null), a({ kind: "idle" });
210
215
  }
211
- return { status: b, warning: U, busy: J, deploy: Q, reset: tt };
216
+ return { status: k, warning: U, busy: J, deploy: Q, reset: tt };
212
217
  }
213
218
  export {
214
- Pt as useDropDeploy
219
+ Ft as useDropDeploy
215
220
  };
@@ -1,17 +1,20 @@
1
- const f = "netlify-drop";
1
+ const l = "netlify-drop";
2
2
  const s = "build-stash", c = "latest", i = "dropBuildStash";
3
- const S = (e) => Promise.race([
3
+ const f = (e) => Promise.race([
4
4
  e,
5
5
  new Promise((r, t) => {
6
6
  setTimeout(() => t(new Error("IndexedDB operation timed out")), 3e3);
7
7
  })
8
- ]), d = () => new Promise((e, r) => {
9
- const t = indexedDB.open(f, 1);
8
+ ]), S = () => new Promise((e, r) => {
9
+ const t = indexedDB.deleteDatabase(l);
10
+ t.onsuccess = () => e(), t.onblocked = () => e(), t.onerror = () => r(t.error);
11
+ }), p = () => new Promise((e, r) => {
12
+ const t = indexedDB.open(l, 1);
10
13
  t.onupgradeneeded = () => {
11
14
  t.result.createObjectStore(s);
12
15
  }, t.onsuccess = () => e(t.result), t.onerror = () => r(t.error);
13
- }), u = (e, r) => S(
14
- d().then(
16
+ }), u = (e, r) => f(
17
+ p().then(
15
18
  (t) => new Promise((o, a) => {
16
19
  const n = r(t.transaction(s, e).objectStore(s));
17
20
  n.onsuccess = () => {
@@ -21,33 +24,42 @@ const S = (e) => Promise.race([
21
24
  };
22
25
  })
23
26
  )
24
- ), p = async (e) => {
27
+ ), y = async (e) => {
25
28
  const { quota: r, usage: t } = await navigator.storage.estimate();
26
29
  return r !== void 0 && t !== void 0 && r - t > e * 2;
27
30
  }, T = () => {
28
- localStorage.setItem(i, JSON.stringify({ expiresAt: Date.now() + 864e5 }));
29
- }, l = () => {
31
+ localStorage.setItem(
32
+ i,
33
+ JSON.stringify({ value: "true", expiry: Date.now() + 864e5 })
34
+ );
35
+ }, d = () => {
30
36
  localStorage.removeItem(i);
31
37
  };
32
- async function A(e, r = null) {
38
+ async function O(e, r = null) {
33
39
  var t;
34
40
  try {
35
- if (typeof indexedDB > "u" || !((t = navigator.storage) != null && t.estimate) || e.size > 209715200 || !await p(e.size))
41
+ if (typeof indexedDB > "u" || !((t = navigator.storage) != null && t.estimate) || e.size > 209715200 || !await y(e.size))
36
42
  return !1;
37
- const o = { zipFile: e, buildSettings: r, createdAt: Date.now() };
43
+ const o = {
44
+ // Round-trip through JSON so an injected detector's richer object can't
45
+ // fail the structured clone the put() below performs.
46
+ buildSettings: r && JSON.parse(JSON.stringify(r)),
47
+ zipFile: e,
48
+ createdAt: Date.now()
49
+ };
38
50
  return await u("readwrite", (a) => a.put(o, c)), T(), !0;
39
51
  } catch {
40
52
  return !1;
41
53
  }
42
54
  }
43
- async function D() {
55
+ async function w() {
44
56
  try {
45
57
  if (typeof indexedDB > "u") return null;
46
58
  const e = await u(
47
59
  "readonly",
48
60
  (t) => t.get(c)
49
61
  ), r = e && _(e.zipFile);
50
- return !e || !r ? null : Date.now() - e.createdAt > 864e5 ? (await y(), null) : { ...e, zipFile: r };
62
+ return !e || !r ? null : Date.now() - e.createdAt > 864e5 ? (await D(), null) : { ...e, zipFile: r };
51
63
  } catch {
52
64
  return null;
53
65
  }
@@ -55,30 +67,30 @@ async function D() {
55
67
  function _(e) {
56
68
  return e instanceof File && e.name ? e : e instanceof Blob || e && typeof e.arrayBuffer == "function" ? new File([e], "source.zip", { type: "application/zip" }) : null;
57
69
  }
58
- async function y() {
70
+ async function D() {
59
71
  try {
60
- l();
72
+ d();
61
73
  } catch {
62
74
  }
63
75
  try {
64
76
  if (typeof indexedDB > "u") return;
65
- await u("readwrite", (e) => e.delete(c));
77
+ await u("readwrite", (e) => e.delete(c)), await f(S());
66
78
  } catch {
67
79
  }
68
80
  }
69
- function O() {
81
+ function E() {
70
82
  try {
71
83
  const e = localStorage.getItem(i);
72
84
  if (!e) return !1;
73
- const { expiresAt: r } = JSON.parse(e);
74
- return typeof r != "number" || Date.now() > r ? (l(), !1) : !0;
85
+ const { value: r, expiry: t } = JSON.parse(e);
86
+ return r !== "true" || typeof t != "number" || Date.now() > t ? (d(), !1) : !0;
75
87
  } catch {
76
88
  return !1;
77
89
  }
78
90
  }
79
91
  export {
80
- y as clearDropStash,
81
- O as hasDropStashMarker,
82
- D as loadDropStash,
83
- A as saveDropStash
92
+ D as clearDropStash,
93
+ E as hasDropStashMarker,
94
+ w as loadDropStash,
95
+ O as saveDropStash
84
96
  };
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.6",
4
+ "version": "1.31.0-alpha.7",
5
5
  "type": "module",
6
6
  "main": "dist/components/preact/index.js",
7
7
  "types": "dist/components/preact/index.d.ts",
@@ -287,6 +287,18 @@ describe('build-required drops', () => {
287
287
 
288
288
  expect(saveDropStash).not.toHaveBeenCalled();
289
289
  expect(harness.assigned).toEqual(['https://app.netlify.com/signup?next=/drop']);
290
+ // Nothing was stashed, so the drop is lost rather than deferred — reported
291
+ // with the same reason react-ui uses for its own build bounce.
292
+ const failed = harness.events.find(e => e.event === DROPZONE_EVENTS.DEPLOY_FAILED);
293
+ expect(failed?.props).toMatchObject({ reason: 'anonymous_build_not_supported' });
294
+ });
295
+
296
+ it('does not report a loss when the archive was stashed', async () => {
297
+ const harness = mountHook({ buildClient: makeBuildClient('no-auth'), getUploadToken: async () => null });
298
+ await harness.api.deploy(async () => buildFiles(), 'drag', []);
299
+ await settle();
300
+
301
+ expect(harness.events.map(e => e.event)).not.toContain(DROPZONE_EVENTS.DEPLOY_FAILED);
290
302
  });
291
303
 
292
304
  it('cedes everything to onBuildRequired when set', async () => {
@@ -365,6 +365,18 @@ export function useDropDeploy({
365
365
  // Without build wiring nothing here could deploy a stash later, so nothing
366
366
  // is stashed — the hand-off is all that's left.
367
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
+
368
380
  if (onBuildSignupHandoff) {
369
381
  // The consumer owns the hand-off (e.g. an auth popup that keeps the
370
382
  // visitor on this page); back to idle so the zone stays usable.
@@ -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.
@@ -47,6 +47,25 @@ describe('buildStash', () => {
47
47
  expect(await stash?.zipFile.text()).toBe('zip-bytes');
48
48
  });
49
49
 
50
+ // Same key AND same value shape as react-ui's setWithExpiry, so the app can
51
+ // adopt this module without stranding a marker its own code already wrote.
52
+ it('writes the marker in react-ui’s { value, expiry } shape', async () => {
53
+ await saveDropStash(zip());
54
+ const marker = JSON.parse(localStorage.getItem('dropBuildStash') as string);
55
+ expect(marker.value).toBe('true');
56
+ expect(marker.expiry).toBeGreaterThan(Date.now());
57
+ expect(marker.expiry).toBeLessThanOrEqual(Date.now() + 24 * 60 * 60 * 1000);
58
+ });
59
+
60
+ // The row delete is what makes the stash unusable; dropping the database is
61
+ // cleanup, so a 200MB archive doesn't linger in the visitor's profile.
62
+ it('drops the database, not just the row, once the stash is consumed', async () => {
63
+ const deleteDatabase = vi.spyOn(indexedDB, 'deleteDatabase');
64
+ await saveDropStash(zip());
65
+ await clearDropStash();
66
+ expect(deleteDatabase).toHaveBeenCalledWith('netlify-drop');
67
+ });
68
+
50
69
  it('clears the stash and marker idempotently', async () => {
51
70
  await saveDropStash(zip());
52
71
  await clearDropStash();
@@ -35,6 +35,16 @@ const withTimeout = <T>(promise: Promise<T>): Promise<T> =>
35
35
  }),
36
36
  ]);
37
37
 
38
+ // Resolves on `blocked` too: another tab holding the database open is not worth
39
+ // stalling the caller for, and the row delete already made the stash unusable.
40
+ const deleteDatabase = (): Promise<void> =>
41
+ new Promise((resolve, reject) => {
42
+ const request = indexedDB.deleteDatabase(DB_NAME);
43
+ request.onsuccess = () => resolve();
44
+ request.onblocked = () => resolve();
45
+ request.onerror = () => reject(request.error);
46
+ });
47
+
38
48
  const openDatabase = (): Promise<IDBDatabase> =>
39
49
  new Promise((resolve, reject) => {
40
50
  const request = indexedDB.open(DB_NAME, DB_VERSION);
@@ -74,8 +84,13 @@ const hasEnoughQuota = async (bytes: number): Promise<boolean> => {
74
84
 
75
85
  // The marker is a plain localStorage flag with its own expiry, so page loads can
76
86
  // ask "is there anything to resume?" synchronously without opening IndexedDB.
87
+ // `{ value, expiry }` is react-ui's setWithExpiry shape — same key, same shape,
88
+ // so the app can adopt this module without stranding a marker it already wrote.
77
89
  const setMarker = () => {
78
- localStorage.setItem(MARKER_KEY, JSON.stringify({ expiresAt: Date.now() + STASH_TTL_MS }));
90
+ localStorage.setItem(
91
+ MARKER_KEY,
92
+ JSON.stringify({ value: 'true', expiry: Date.now() + STASH_TTL_MS })
93
+ );
79
94
  };
80
95
 
81
96
  const clearMarker = () => {
@@ -96,7 +111,13 @@ export async function saveDropStash(
96
111
  ) {
97
112
  return false;
98
113
  }
99
- const record: DropBuildStash = { zipFile, buildSettings, createdAt: Date.now() };
114
+ const record: DropBuildStash = {
115
+ // Round-trip through JSON so an injected detector's richer object can't
116
+ // fail the structured clone the put() below performs.
117
+ buildSettings: buildSettings && JSON.parse(JSON.stringify(buildSettings)),
118
+ zipFile,
119
+ createdAt: Date.now(),
120
+ };
100
121
  await runStoreOperation('readwrite', store => store.put(record, STASH_KEY));
101
122
  setMarker();
102
123
  return true;
@@ -146,7 +167,11 @@ export async function clearDropStash(): Promise<void> {
146
167
  }
147
168
  try {
148
169
  if (typeof indexedDB === 'undefined') return;
170
+ // The row delete is what guarantees the stash can't come back; dropping the
171
+ // database afterwards is cleanup, so the visitor's project source doesn't
172
+ // sit in their profile once we're done with it. It reappears on the next drop.
149
173
  await runStoreOperation('readwrite', store => store.delete(STASH_KEY));
174
+ await withTimeout(deleteDatabase());
150
175
  } catch {
151
176
  // best effort — an orphaned record expires via its TTL
152
177
  }
@@ -157,8 +182,8 @@ export function hasDropStashMarker(): boolean {
157
182
  try {
158
183
  const marker = localStorage.getItem(MARKER_KEY);
159
184
  if (!marker) return false;
160
- const { expiresAt } = JSON.parse(marker) as { expiresAt?: number };
161
- if (typeof expiresAt !== 'number' || Date.now() > expiresAt) {
185
+ const { value, expiry } = JSON.parse(marker) as { value?: string; expiry?: number };
186
+ if (value !== 'true' || typeof expiry !== 'number' || Date.now() > expiry) {
162
187
  clearMarker();
163
188
  return false;
164
189
  }
@@ -13,7 +13,7 @@ export const DEFAULT_POLL_INTERVAL_MS = 1000;
13
13
  // How long to keep polling before giving up: 10 minutes
14
14
  export const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
15
15
 
16
- // Concurrent file uploads (matches netlify-cli's deploy engine)
16
+ // Concurrent file uploads
17
17
  export const MAX_CONCURRENT_UPLOADS = 5;
18
18
 
19
19
  // Reuse window for a minted upload token: 20s (server TTL is 30s; the gap is