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

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