@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.
- package/dist/components/preact/DropZone/useDropDeploy.js +110 -105
- package/dist/drop-core/buildStash.js +36 -24
- package/package.json +1 -1
- package/packages/components/preact/DropZone/useDropDeploy.test.ts +12 -0
- package/packages/components/preact/DropZone/useDropDeploy.ts +12 -0
- package/packages/drop-core/README.md +311 -39
- package/packages/drop-core/buildStash.test.ts +19 -0
- package/packages/drop-core/buildStash.ts +29 -4
- package/packages/drop-core/constants.ts +1 -1
|
@@ -1,22 +1,22 @@
|
|
|
1
|
-
import { d as
|
|
2
|
-
import { humanizeDropError as rt, isNotAuthenticatedError as
|
|
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
|
|
7
|
-
import { AnonymousDropClient as
|
|
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
|
|
12
|
-
function I(
|
|
13
|
-
switch (
|
|
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:
|
|
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(
|
|
28
|
-
const m = ot(
|
|
29
|
-
return m ? `No index.html found — "${m}" won’t be served at the site root.` : st(
|
|
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(
|
|
31
|
+
function St(u, m) {
|
|
32
32
|
try {
|
|
33
|
-
localStorage.setItem("drop-token",
|
|
33
|
+
localStorage.setItem("drop-token", u), localStorage.setItem("dropSiteName", m);
|
|
34
34
|
} catch {
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
|
-
function
|
|
38
|
-
apiBase:
|
|
37
|
+
function Ft({
|
|
38
|
+
apiBase: u,
|
|
39
39
|
client: m,
|
|
40
40
|
claimUrl: z,
|
|
41
|
-
siteDashboardUrl:
|
|
42
|
-
buildSignupUrl:
|
|
43
|
-
buildClient:
|
|
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:
|
|
48
|
+
buildDeployUrl: L,
|
|
49
49
|
waitForBuild: Z,
|
|
50
|
-
onBuildRequired:
|
|
51
|
-
onBuildSignupHandoff:
|
|
50
|
+
onBuildRequired: C,
|
|
51
|
+
onBuildSignupHandoff: F,
|
|
52
52
|
resumeStashedBuilds: $ = !0,
|
|
53
|
-
onDeploy:
|
|
53
|
+
onDeploy: _,
|
|
54
54
|
onBuildDeploy: f,
|
|
55
55
|
onError: g,
|
|
56
56
|
track: q
|
|
57
57
|
}) {
|
|
58
|
-
const
|
|
58
|
+
const l = (n, i) => (
|
|
59
59
|
// The event interfaces carry no index signature, hence the cast to TrackFn's record.
|
|
60
|
-
q(n,
|
|
61
|
-
), [
|
|
62
|
-
|
|
63
|
-
).current,
|
|
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 =
|
|
66
|
-
async function
|
|
65
|
+
).current, J = k.kind !== "idle" && k.kind !== "error";
|
|
66
|
+
async function P(n, i) {
|
|
67
67
|
try {
|
|
68
|
-
const { site:
|
|
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:
|
|
74
|
-
siteName:
|
|
75
|
-
siteId:
|
|
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 (
|
|
81
|
-
if (
|
|
82
|
-
throw
|
|
80
|
+
} catch (e) {
|
|
81
|
+
if (R(e)) return null;
|
|
82
|
+
throw e;
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
|
-
async function Q(n,
|
|
86
|
-
|
|
87
|
-
const r =
|
|
88
|
-
a({ kind: "error", message:
|
|
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
|
|
92
|
-
if (!
|
|
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
|
-
|
|
97
|
-
method:
|
|
98
|
-
file_count:
|
|
96
|
+
i === "drag" && l(c.FILES_DROPPED, {
|
|
97
|
+
method: i,
|
|
98
|
+
file_count: t.length,
|
|
99
99
|
...r
|
|
100
100
|
});
|
|
101
|
-
const s = { source:
|
|
102
|
-
d.buildRequired ? await T(
|
|
103
|
-
} catch (
|
|
104
|
-
const s =
|
|
105
|
-
o(rt(
|
|
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,
|
|
109
|
-
if (
|
|
110
|
-
method:
|
|
111
|
-
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
|
-
}),
|
|
115
|
-
a({ kind: "idle" }),
|
|
114
|
+
}), C) {
|
|
115
|
+
a({ kind: "idle" }), C(i);
|
|
116
116
|
return;
|
|
117
117
|
}
|
|
118
|
-
let
|
|
118
|
+
let t = !1;
|
|
119
119
|
if (w) {
|
|
120
120
|
a({ kind: "zipping" });
|
|
121
|
-
const s = await
|
|
121
|
+
const s = await ut(n), d = await P(w, s);
|
|
122
122
|
if (d) {
|
|
123
|
-
a({ kind: "redirecting", target: "build" }),
|
|
124
|
-
method:
|
|
125
|
-
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(
|
|
130
|
+
}), f == null || f(d), window.location.assign(L(d));
|
|
131
131
|
return;
|
|
132
132
|
}
|
|
133
|
-
|
|
134
|
-
method:
|
|
135
|
-
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 (
|
|
141
|
-
|
|
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(
|
|
149
|
+
a({ kind: "redirecting", target: "signup" }), window.location.assign(H);
|
|
145
150
|
}
|
|
146
|
-
async function V(n,
|
|
151
|
+
async function V(n, i) {
|
|
147
152
|
try {
|
|
148
|
-
return await v(n,
|
|
149
|
-
onProgress: (
|
|
153
|
+
return await v(n, i, {
|
|
154
|
+
onProgress: (e) => a(I(e))
|
|
150
155
|
});
|
|
151
|
-
} catch (
|
|
152
|
-
if (
|
|
153
|
-
throw
|
|
156
|
+
} catch (e) {
|
|
157
|
+
if (R(e)) return null;
|
|
158
|
+
throw e;
|
|
154
159
|
}
|
|
155
160
|
}
|
|
156
|
-
async function G(n, { source:
|
|
157
|
-
|
|
158
|
-
const o =
|
|
159
|
-
onProgress: (
|
|
160
|
-
}), { url:
|
|
161
|
-
|
|
162
|
-
method:
|
|
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:
|
|
165
|
-
authenticated:
|
|
169
|
+
file_count: e,
|
|
170
|
+
authenticated: t,
|
|
166
171
|
...s ? { auth_fallback: !0 } : {},
|
|
167
172
|
...r
|
|
168
|
-
}),
|
|
169
|
-
url:
|
|
170
|
-
...
|
|
173
|
+
}), _ == null || _({
|
|
174
|
+
url: it,
|
|
175
|
+
...t ? {} : { token: E },
|
|
171
176
|
siteName: h.subdomain,
|
|
172
177
|
siteId: h.id,
|
|
173
|
-
authenticated:
|
|
174
|
-
}), K(
|
|
178
|
+
authenticated: t
|
|
179
|
+
}), K(t, h.subdomain, E);
|
|
175
180
|
}
|
|
176
|
-
function K(n,
|
|
177
|
-
n ? window.location.assign(
|
|
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
|
|
181
|
-
if (!
|
|
182
|
-
await
|
|
185
|
+
const i = await ht();
|
|
186
|
+
if (!i) {
|
|
187
|
+
await y();
|
|
183
188
|
return;
|
|
184
189
|
}
|
|
185
|
-
|
|
186
|
-
const
|
|
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
|
|
194
|
+
r = await P(n, e);
|
|
190
195
|
} catch (o) {
|
|
191
|
-
await
|
|
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
|
|
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
|
|
202
|
-
a({ kind: "error", message:
|
|
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
|
|
209
|
+
const x = S(!1);
|
|
205
210
|
nt(() => {
|
|
206
|
-
|
|
211
|
+
x.current || !$ || !w || !gt() || (x.current = !0, X(w));
|
|
207
212
|
}, []);
|
|
208
213
|
function tt() {
|
|
209
|
-
|
|
214
|
+
b(null), a({ kind: "idle" });
|
|
210
215
|
}
|
|
211
|
-
return { status:
|
|
216
|
+
return { status: k, warning: U, busy: J, deploy: Q, reset: tt };
|
|
212
217
|
}
|
|
213
218
|
export {
|
|
214
|
-
|
|
219
|
+
Ft as useDropDeploy
|
|
215
220
|
};
|
|
@@ -1,17 +1,20 @@
|
|
|
1
|
-
const
|
|
1
|
+
const l = "netlify-drop";
|
|
2
2
|
const s = "build-stash", c = "latest", i = "dropBuildStash";
|
|
3
|
-
const
|
|
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
|
-
]),
|
|
9
|
-
const t = indexedDB.
|
|
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) =>
|
|
14
|
-
|
|
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
|
-
),
|
|
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(
|
|
29
|
-
|
|
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
|
|
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
|
|
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 = {
|
|
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
|
|
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
|
|
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
|
|
70
|
+
async function D() {
|
|
59
71
|
try {
|
|
60
|
-
|
|
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
|
|
81
|
+
function E() {
|
|
70
82
|
try {
|
|
71
83
|
const e = localStorage.getItem(i);
|
|
72
84
|
if (!e) return !1;
|
|
73
|
-
const {
|
|
74
|
-
return typeof
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
37
|
-
| ------------------ |
|
|
38
|
-
| Outcome callbacks | `onVerb` / past participle
|
|
39
|
-
| Boolean state | `isX`
|
|
40
|
-
| Strategy injection | bare noun
|
|
41
|
-
| Analytics events | `dropzone_snake_case` — **
|
|
42
|
-
| Error predicates | `isXError(err)`
|
|
43
|
-
| Files | filename = main export
|
|
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
|
|
338
|
+
test that pins it.
|
|
59
339
|
- **No `@param`/`@returns`.** Types carry the shape; JSDoc carries rationale
|
|
60
|
-
and edge cases.
|
|
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.**
|
|
75
|
-
|
|
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
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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(
|
|
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 = {
|
|
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 {
|
|
161
|
-
if (typeof
|
|
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
|
|
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
|