@netlify/spark-ui 1.31.0-alpha.5 → 1.31.0-alpha.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -4
- package/dist/components/preact/DropZone/DropZone.d.ts +23 -97
- package/dist/components/preact/DropZone/useDropDeploy.js +110 -105
- package/dist/drop-core/analytics.d.ts +4 -15
- package/dist/drop-core/authedApi.d.ts +9 -22
- package/dist/drop-core/authedDropClient.d.ts +13 -55
- package/dist/drop-core/authedDropClient.js +5 -18
- package/dist/drop-core/buildClient.d.ts +19 -53
- package/dist/drop-core/buildClient.js +27 -40
- package/dist/drop-core/buildStash.d.ts +6 -20
- package/dist/drop-core/buildStash.js +36 -24
- package/dist/drop-core/client.d.ts +1 -1
- package/dist/drop-core/constants.d.ts +1 -21
- package/dist/drop-core/deploy.d.ts +5 -17
- package/dist/drop-core/detectBuild.d.ts +8 -26
- package/dist/drop-core/errors.d.ts +8 -27
- package/dist/drop-core/fileTypes.d.ts +4 -14
- package/dist/drop-core/readFiles.d.ts +5 -11
- package/dist/drop-core/types.d.ts +2 -4
- package/dist/drop-core/zip.d.ts +7 -30
- package/package.json +1 -1
- package/packages/components/preact/DropZone/DropZone.tsx +27 -121
- package/packages/components/preact/DropZone/useDropDeploy.test.ts +12 -0
- package/packages/components/preact/DropZone/useDropDeploy.ts +39 -60
- package/packages/drop-core/README.md +311 -39
- package/packages/drop-core/analytics.ts +4 -15
- package/packages/drop-core/authedApi.ts +9 -22
- package/packages/drop-core/authedDropClient.ts +13 -55
- package/packages/drop-core/buildClient.test.ts +19 -4
- package/packages/drop-core/buildClient.ts +21 -63
- package/packages/drop-core/buildStash.test.ts +19 -0
- package/packages/drop-core/buildStash.ts +38 -29
- package/packages/drop-core/client.ts +6 -10
- package/packages/drop-core/constants.ts +8 -22
- package/packages/drop-core/deploy.ts +6 -22
- package/packages/drop-core/detectBuild.ts +15 -50
- package/packages/drop-core/errors.test.ts +3 -1
- package/packages/drop-core/errors.ts +9 -30
- package/packages/drop-core/fileTypes.ts +5 -19
- package/packages/drop-core/readFiles.ts +5 -12
- package/packages/drop-core/types.ts +2 -4
- package/packages/drop-core/zip.ts +7 -30
|
@@ -112,7 +112,6 @@ interface UseDropDeployOptions {
|
|
|
112
112
|
track: TrackFn;
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
/** Map a low-level deploy progress event onto our UI status. */
|
|
116
115
|
function progressToStatus(e: DeployProgress): DeployStatus {
|
|
117
116
|
switch (e.phase) {
|
|
118
117
|
case 'zipping':
|
|
@@ -129,7 +128,7 @@ function progressToStatus(e: DeployProgress): DeployStatus {
|
|
|
129
128
|
}
|
|
130
129
|
}
|
|
131
130
|
|
|
132
|
-
/**
|
|
131
|
+
/** Advisory only — a missing index.html never fails the deploy. */
|
|
133
132
|
function indexHtmlWarning(files: ProcessedFile[]): string | null {
|
|
134
133
|
const singleNonIndex = getSingleNonIndexHtmlFileName(files);
|
|
135
134
|
if (singleNonIndex) {
|
|
@@ -142,12 +141,9 @@ function indexHtmlWarning(files: ProcessedFile[]): string | null {
|
|
|
142
141
|
}
|
|
143
142
|
|
|
144
143
|
/**
|
|
145
|
-
* Best-effort
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
* handoff is the URL fragment on the claim redirect; this only helps if the
|
|
149
|
-
* drop and claim share an origin. Authenticated deploys never call this —
|
|
150
|
-
* there is no claim token, and the upload bearer must never be persisted.
|
|
144
|
+
* Best-effort claim persistence. localStorage is per-origin, so this never
|
|
145
|
+
* reaches app.netlify.com — the URL fragment is the real cross-origin handoff.
|
|
146
|
+
* Authenticated deploys skip it: no claim token, and bearers are never persisted.
|
|
151
147
|
*/
|
|
152
148
|
function persistAnonymousClaimLocally(token: string, siteName: string): void {
|
|
153
149
|
try {
|
|
@@ -183,11 +179,7 @@ export function useDropDeploy({
|
|
|
183
179
|
onError,
|
|
184
180
|
track,
|
|
185
181
|
}: UseDropDeployOptions) {
|
|
186
|
-
/**
|
|
187
|
-
* Typed veneer over the consumer's `track`: each payload is checked against
|
|
188
|
-
* the DROPZONE_EVENTS contract, so a renamed property fails to compile
|
|
189
|
-
* instead of silently forking the analytics history.
|
|
190
|
-
*/
|
|
182
|
+
/** Typed veneer over `track` — a renamed property fails to compile instead of forking analytics history. */
|
|
191
183
|
const emit = <E extends DropZoneEventName>(event: E, props: DropZoneEventProperties[E]) =>
|
|
192
184
|
// The event interfaces carry no index signature, hence the cast to TrackFn's record.
|
|
193
185
|
track(event, props as Record<string, unknown>);
|
|
@@ -220,10 +212,8 @@ export function useDropDeploy({
|
|
|
220
212
|
const busy = status.kind !== 'idle' && status.kind !== 'error';
|
|
221
213
|
|
|
222
214
|
/**
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
* in that case, so the caller can still stash the archive and hand off to
|
|
226
|
-
* signup. Every other failure throws and is reported by the caller's `catch`.
|
|
215
|
+
* Site + build for a zipped drop. `null` = not logged in (nothing created,
|
|
216
|
+
* so the caller can still stash and hand off); other failures throw.
|
|
227
217
|
*/
|
|
228
218
|
async function enqueueZippedBuild(
|
|
229
219
|
client: BuildClient,
|
|
@@ -257,10 +247,8 @@ export function useDropDeploy({
|
|
|
257
247
|
}
|
|
258
248
|
|
|
259
249
|
/**
|
|
260
|
-
* `fileTypes` is
|
|
261
|
-
*
|
|
262
|
-
* captured before `read()` runs so it survives a read that throws. Omitted
|
|
263
|
-
* from events when empty rather than sent as `[]`.
|
|
250
|
+
* `fileTypes` is captured at the drop boundary, before `read()` runs, so it
|
|
251
|
+
* survives a read that throws. Omitted from events when empty, never `[]`.
|
|
264
252
|
*/
|
|
265
253
|
async function deploy(
|
|
266
254
|
read: () => Promise<ProcessedFile[]>,
|
|
@@ -272,12 +260,8 @@ export function useDropDeploy({
|
|
|
272
260
|
|
|
273
261
|
const typeProps = fileTypes.length ? { file_types: fileTypes } : {};
|
|
274
262
|
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
// isn't if `read()` threw); `file_types` always rides along because it *is*
|
|
278
|
-
// known then — a rejected single file is the main thing worth seeing.
|
|
279
|
-
// `reason` is raw (e.g. "drop failed: 429") for analytics; `message` is the
|
|
280
|
-
// humanized copy the user sees; `onError` gets both.
|
|
263
|
+
// Single failure funnel. `file_count` only when the file set is known;
|
|
264
|
+
// `reason` is raw for analytics, `message` is the humanized copy; `onError` gets both.
|
|
281
265
|
const fail = (message: string, reason: string, extra?: Record<string, unknown>) => {
|
|
282
266
|
setStatus({ kind: 'error', message });
|
|
283
267
|
emit(DROPZONE_EVENTS.DEPLOY_FAILED, { method: source, reason, ...typeProps, ...extra });
|
|
@@ -285,10 +269,9 @@ export function useDropDeploy({
|
|
|
285
269
|
};
|
|
286
270
|
|
|
287
271
|
try {
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
// single-file case no longer warns and only the multi-HTML case does.
|
|
272
|
+
// Rename a lone non-index HTML file so it is served at the site root.
|
|
273
|
+
// Warnings are computed on the renamed set, so the fixable single-file
|
|
274
|
+
// case no longer warns and only the multi-HTML case does.
|
|
292
275
|
const files = renameSingleNonIndexHtmlToIndex(await read());
|
|
293
276
|
if (!files.length) {
|
|
294
277
|
fail('No files found to deploy.', 'No files found to deploy.', { file_count: 0 });
|
|
@@ -319,12 +302,8 @@ export function useDropDeploy({
|
|
|
319
302
|
}
|
|
320
303
|
|
|
321
304
|
/**
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
* way yields a broken site. They're routed to the authenticated build path
|
|
325
|
-
* instead, or stashed and handed off to signup when there is no session.
|
|
326
|
-
* Consumers can take over entirely with `onBuildRequired`, or own just the
|
|
327
|
-
* signup hand-off with `onBuildSignupHandoff`.
|
|
305
|
+
* Build-required drops can't use the anonymous API (static-only): build in
|
|
306
|
+
* the account, or stash + hand off to signup when there's no session.
|
|
328
307
|
*/
|
|
329
308
|
async function handleBuildRequiredDrop(
|
|
330
309
|
files: ProcessedFile[],
|
|
@@ -372,8 +351,7 @@ export function useDropDeploy({
|
|
|
372
351
|
|
|
373
352
|
// No session after all (the auth hint cookie can outlive the session).
|
|
374
353
|
// Nothing was created — stash the archive so the drop survives the
|
|
375
|
-
// signup round-trip.
|
|
376
|
-
// authenticated, the mount-time resume deploys it for them.
|
|
354
|
+
// signup round-trip.
|
|
377
355
|
stashed = await saveDropStash(archive.file, archive.buildSettings);
|
|
378
356
|
if (stashed) {
|
|
379
357
|
emit(DROPZONE_EVENTS.PROJECT_STASHED, {
|
|
@@ -387,6 +365,18 @@ export function useDropDeploy({
|
|
|
387
365
|
// Without build wiring nothing here could deploy a stash later, so nothing
|
|
388
366
|
// is stashed — the hand-off is all that's left.
|
|
389
367
|
|
|
368
|
+
if (!stashed) {
|
|
369
|
+
// Nothing survived the hand-off, so this drop is genuinely lost rather
|
|
370
|
+
// than deferred. Reported with the app's own bounce reason so both
|
|
371
|
+
// surfaces count it the same way.
|
|
372
|
+
emit(DROPZONE_EVENTS.DEPLOY_FAILED, {
|
|
373
|
+
method: source,
|
|
374
|
+
reason: 'anonymous_build_not_supported',
|
|
375
|
+
file_count: fileCount,
|
|
376
|
+
...typeProps,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
390
380
|
if (onBuildSignupHandoff) {
|
|
391
381
|
// The consumer owns the hand-off (e.g. an auth popup that keeps the
|
|
392
382
|
// visitor on this page); back to idle so the zone stays usable.
|
|
@@ -399,13 +389,9 @@ export function useDropDeploy({
|
|
|
399
389
|
}
|
|
400
390
|
|
|
401
391
|
/**
|
|
402
|
-
*
|
|
403
|
-
*
|
|
404
|
-
*
|
|
405
|
-
* anything is created (the auth hint cookie can outlive the session), so
|
|
406
|
-
* retrying anonymously can't strand a half-made site. Any later failure is
|
|
407
|
-
* real and rethrows: falling back then would leave an empty site in the
|
|
408
|
-
* account and deploy a duplicate.
|
|
392
|
+
* `null` = no session (pre-mutation, so the anonymous fallback is safe).
|
|
393
|
+
* Anything else rethrows — falling back after the site exists would strand
|
|
394
|
+
* an empty site and deploy a duplicate.
|
|
409
395
|
*/
|
|
410
396
|
async function attemptAuthenticatedStaticDeploy(
|
|
411
397
|
client: DropClient,
|
|
@@ -447,11 +433,10 @@ export function useDropDeploy({
|
|
|
447
433
|
}
|
|
448
434
|
|
|
449
435
|
setStatus({ kind: 'redirecting', target: authenticated ? 'dashboard' : 'claim' });
|
|
450
|
-
//
|
|
451
|
-
//
|
|
452
|
-
//
|
|
453
|
-
//
|
|
454
|
-
// otherwise, like `file_types`.
|
|
436
|
+
// The redirect below can cut a fire-and-forget analytics request short;
|
|
437
|
+
// harden delivery with sendBeacon or by deferring the redirect.
|
|
438
|
+
// `auth_fallback` is only sent when an authenticated attempt bounced
|
|
439
|
+
// (stale auth hint) — omitted otherwise, like `file_types`.
|
|
455
440
|
emit(DROPZONE_EVENTS.DEPLOY_SUCCEEDED, {
|
|
456
441
|
method: source,
|
|
457
442
|
site_name: result.subdomain,
|
|
@@ -471,7 +456,6 @@ export function useDropDeploy({
|
|
|
471
456
|
redirectAfterStaticDeploy(authenticated, result.subdomain, token);
|
|
472
457
|
}
|
|
473
458
|
|
|
474
|
-
/** Hand the visitor to their new site: its dashboard page, or the claim page. */
|
|
475
459
|
function redirectAfterStaticDeploy(authenticated: boolean, siteName: string, token: string) {
|
|
476
460
|
if (authenticated) {
|
|
477
461
|
// The site already lives in the visitor's account — send them straight
|
|
@@ -486,13 +470,8 @@ export function useDropDeploy({
|
|
|
486
470
|
}
|
|
487
471
|
|
|
488
472
|
/**
|
|
489
|
-
* Deploy
|
|
490
|
-
*
|
|
491
|
-
* The stash is consumed only once the visitor is provably authenticated —
|
|
492
|
-
* the marker (or the auth hint behind `getUploadToken`) can outlive the
|
|
493
|
-
* session, and consuming on a bounced attempt would quietly lose the drop.
|
|
494
|
-
* Once past that gate it is consumed whether the deploy succeeds or fails,
|
|
495
|
-
* so a broken project can never retry-loop.
|
|
473
|
+
* Deploy the stashed drop. Consumed only once provably authenticated (a
|
|
474
|
+
* bounced attempt keeps it), then consumed win or lose — no retry loops.
|
|
496
475
|
*/
|
|
497
476
|
async function resumeStashedBuild(client: BuildClient) {
|
|
498
477
|
const stash = await loadDropStash();
|
|
@@ -517,7 +496,7 @@ export function useDropDeploy({
|
|
|
517
496
|
|
|
518
497
|
if (!built) {
|
|
519
498
|
// Still no session — the visitor came back logged out. Keep the stash for
|
|
520
|
-
// their next authenticated visit
|
|
499
|
+
// their next authenticated visit.
|
|
521
500
|
setStatus({ kind: 'idle' });
|
|
522
501
|
return;
|
|
523
502
|
}
|
|
@@ -1,20 +1,300 @@
|
|
|
1
1
|
# drop-core
|
|
2
2
|
|
|
3
|
-
The
|
|
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.
|
|
@@ -1,20 +1,13 @@
|
|
|
1
1
|
import type { BuildReason } from './detectBuild';
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
* The Drop Zone's analytics contract — the canonical event names and property
|
|
5
|
-
* shapes, exported so no consumer (this package's own component, a host site's
|
|
6
|
-
* GTM wiring, or another Netlify surface implementing its own Drop UI over
|
|
7
|
-
* drop-core) ever hand-writes the strings.
|
|
8
|
-
*/
|
|
3
|
+
/** The analytics contract: canonical event names + property shapes, so no consumer hand-writes them. */
|
|
9
4
|
|
|
10
5
|
/** How the files reached the zone — carried on every drop-initiated event as `method`. */
|
|
11
6
|
export type DropMethod = 'drag' | 'browse';
|
|
12
7
|
|
|
13
8
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* renaming a value silently forks every funnel built on it — treat them as
|
|
17
|
-
* append-only.
|
|
9
|
+
* The wire names. Amplitude/Segment history keys off these exact strings —
|
|
10
|
+
* append-only, never rename.
|
|
18
11
|
*/
|
|
19
12
|
export const DROPZONE_EVENTS = {
|
|
20
13
|
/** Files arrived via drag — the picker path reports its files on outcome events instead. */
|
|
@@ -114,9 +107,5 @@ export interface DropZoneEventProperties {
|
|
|
114
107
|
dropzone_resumed: ResumedProps;
|
|
115
108
|
}
|
|
116
109
|
|
|
117
|
-
/**
|
|
118
|
-
* Provider-agnostic analytics sink. Deliberately loose (string, not the event
|
|
119
|
-
* union): hosts forward these to Segment/GA/GTM and often pipe their own
|
|
120
|
-
* events through the same function.
|
|
121
|
-
*/
|
|
110
|
+
/** Provider-agnostic sink — loose on purpose, since hosts pipe their own events through it too. */
|
|
122
111
|
export type TrackFn = (event: string, properties?: Record<string, unknown>) => void;
|