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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/dist/components/index.d.ts +2 -2
  2. package/dist/components/preact/DropZone/DropZone.d.ts +21 -2
  3. package/dist/components/preact/DropZone/DropZone.js +59 -54
  4. package/dist/components/preact/DropZone/DropZone.test.d.ts +1 -0
  5. package/dist/components/preact/DropZone/index.d.ts +3 -3
  6. package/dist/components/preact/DropZone/index.js +25 -23
  7. package/dist/components/preact/DropZone/useDropDeploy.d.ts +10 -6
  8. package/dist/components/preact/DropZone/useDropDeploy.js +184 -131
  9. package/dist/components/preact/DropZone/useDropDeploy.test.d.ts +1 -0
  10. package/dist/components/preact/index.d.ts +2 -2
  11. package/dist/components/preact/index.js +25 -19
  12. package/dist/drop-core/analytics.d.ts +107 -0
  13. package/dist/drop-core/analytics.js +21 -0
  14. package/dist/drop-core/analytics.test.d.ts +1 -0
  15. package/dist/drop-core/authedApi.d.ts +55 -0
  16. package/dist/drop-core/authedApi.js +35 -0
  17. package/dist/drop-core/authedApi.test.d.ts +1 -0
  18. package/dist/drop-core/authedDropClient.js +109 -0
  19. package/dist/drop-core/authedDropClient.test.d.ts +1 -0
  20. package/dist/{components/preact/DropZone/drop-core → drop-core}/buildClient.d.ts +5 -13
  21. package/dist/drop-core/buildClient.js +84 -0
  22. package/dist/drop-core/buildClient.test.d.ts +1 -0
  23. package/dist/drop-core/buildStash.d.ts +37 -0
  24. package/dist/drop-core/buildStash.js +84 -0
  25. package/dist/drop-core/buildStash.test.d.ts +0 -0
  26. package/dist/{components/preact/DropZone/drop-core → drop-core}/deploy.d.ts +7 -0
  27. package/dist/drop-core/deploy.js +53 -0
  28. package/dist/drop-core/deploy.test.d.ts +1 -0
  29. package/dist/drop-core/detectBuild.test.d.ts +1 -0
  30. package/dist/drop-core/digest.test.d.ts +1 -0
  31. package/dist/{components/preact/DropZone/drop-core → drop-core}/errors.js +1 -1
  32. package/dist/drop-core/errors.test.d.ts +1 -0
  33. package/dist/drop-core/fileTypes.test.d.ts +1 -0
  34. package/dist/{components/preact/DropZone/drop-core → drop-core}/index.d.ts +3 -0
  35. package/dist/drop-core/index.js +51 -0
  36. package/dist/{components/preact/DropZone/drop-core → drop-core}/readFiles.js +2 -2
  37. package/dist/drop-core/readFiles.test.d.ts +1 -0
  38. package/dist/{components/preact/DropZone/drop-core → drop-core}/types.d.ts +10 -0
  39. package/dist/{components/preact/DropZone/drop-core → drop-core}/zip.js +1 -1
  40. package/dist/drop-core/zip.test.d.ts +1 -0
  41. package/package.json +4 -1
  42. package/packages/components/index.ts +19 -0
  43. package/packages/components/preact/DropZone/DropZone.test.ts +71 -0
  44. package/packages/components/preact/DropZone/DropZone.tsx +23 -2
  45. package/packages/components/preact/DropZone/index.tsx +14 -3
  46. package/packages/components/preact/DropZone/useDropDeploy.test.ts +376 -0
  47. package/packages/components/preact/DropZone/useDropDeploy.ts +297 -132
  48. package/packages/components/preact/index.ts +19 -2
  49. package/packages/drop-core/analytics.test.ts +17 -0
  50. package/packages/drop-core/analytics.ts +122 -0
  51. package/packages/drop-core/authedApi.test.ts +100 -0
  52. package/packages/drop-core/authedApi.ts +94 -0
  53. package/packages/drop-core/authedDropClient.test.ts +196 -0
  54. package/packages/{components/preact/DropZone/drop-core → drop-core}/authedDropClient.ts +30 -51
  55. package/packages/drop-core/buildClient.test.ts +112 -0
  56. package/packages/{components/preact/DropZone/drop-core → drop-core}/buildClient.ts +28 -57
  57. package/packages/drop-core/buildStash.test.ts +85 -0
  58. package/packages/drop-core/buildStash.ts +185 -0
  59. package/packages/drop-core/deploy.test.ts +135 -0
  60. package/packages/{components/preact/DropZone/drop-core → drop-core}/deploy.ts +18 -4
  61. package/packages/drop-core/detectBuild.test.ts +139 -0
  62. package/packages/drop-core/digest.test.ts +24 -0
  63. package/packages/drop-core/errors.test.ts +69 -0
  64. package/packages/{components/preact/DropZone/drop-core → drop-core}/errors.ts +1 -1
  65. package/packages/drop-core/fileTypes.test.ts +37 -0
  66. package/packages/{components/preact/DropZone/drop-core → drop-core}/index.ts +3 -0
  67. package/packages/drop-core/readFiles.test.ts +125 -0
  68. package/packages/{components/preact/DropZone/drop-core → drop-core}/types.ts +12 -6
  69. package/packages/drop-core/zip.test.ts +92 -0
  70. package/dist/components/preact/DropZone/drop-core/authedDropClient.js +0 -123
  71. package/dist/components/preact/DropZone/drop-core/buildClient.js +0 -99
  72. package/dist/components/preact/DropZone/drop-core/deploy.js +0 -48
  73. package/dist/components/preact/DropZone/drop-core/index.js +0 -36
  74. /package/dist/{components/preact/DropZone/drop-core → drop-core}/authedDropClient.d.ts +0 -0
  75. /package/dist/{components/preact/DropZone/drop-core → drop-core}/client.d.ts +0 -0
  76. /package/dist/{components/preact/DropZone/drop-core → drop-core}/client.js +0 -0
  77. /package/dist/{components/preact/DropZone/drop-core → drop-core}/detectBuild.d.ts +0 -0
  78. /package/dist/{components/preact/DropZone/drop-core → drop-core}/detectBuild.js +0 -0
  79. /package/dist/{components/preact/DropZone/drop-core → drop-core}/digest.d.ts +0 -0
  80. /package/dist/{components/preact/DropZone/drop-core → drop-core}/digest.js +0 -0
  81. /package/dist/{components/preact/DropZone/drop-core → drop-core}/errors.d.ts +0 -0
  82. /package/dist/{components/preact/DropZone/drop-core → drop-core}/fileTypes.d.ts +0 -0
  83. /package/dist/{components/preact/DropZone/drop-core → drop-core}/fileTypes.js +0 -0
  84. /package/dist/{components/preact/DropZone/drop-core → drop-core}/readFiles.d.ts +0 -0
  85. /package/dist/{components/preact/DropZone/drop-core → drop-core}/types.js +0 -0
  86. /package/dist/{components/preact/DropZone/drop-core → drop-core}/zip.d.ts +0 -0
  87. /package/packages/{components/preact/DropZone/drop-core → drop-core}/client.ts +0 -0
  88. /package/packages/{components/preact/DropZone/drop-core → drop-core}/detectBuild.ts +0 -0
  89. /package/packages/{components/preact/DropZone/drop-core → drop-core}/digest.ts +0 -0
  90. /package/packages/{components/preact/DropZone/drop-core → drop-core}/fileTypes.ts +0 -0
  91. /package/packages/{components/preact/DropZone/drop-core → drop-core}/readFiles.ts +0 -0
  92. /package/packages/{components/preact/DropZone/drop-core → drop-core}/zip.ts +0 -0
@@ -0,0 +1,112 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { AuthenticatedBuildClient } from './buildClient';
3
+ import {
4
+ BuildCreateError,
5
+ BuildFailedError,
6
+ BuildTimeoutError,
7
+ NotAuthenticatedError,
8
+ } from './errors';
9
+
10
+ const PROXY = '/access-control/bb-api/api/v1';
11
+ const API = 'https://api.netlify.com/api/v1';
12
+
13
+ const zip = new File(['zip-bytes'], 'source.zip', { type: 'application/zip' });
14
+
15
+ const client = (getUploadToken = async (): Promise<string | null> => 'bearer-abc') =>
16
+ new AuthenticatedBuildClient({ getUploadToken, pollIntervalMs: 1, timeoutMs: 5 });
17
+
18
+ afterEach(() => vi.unstubAllGlobals());
19
+
20
+ describe('AuthenticatedBuildClient.createSite', () => {
21
+ it('creates the site through the cookie proxy with created_via: drop', async () => {
22
+ const calls: Array<{ url: string; init: RequestInit }> = [];
23
+ vi.stubGlobal('fetch', async (url: unknown, init: RequestInit = {}) => {
24
+ calls.push({ url: String(url), init });
25
+ return { ok: true, status: 200, json: async () => ({ id: 's', name: 'n' }) } as Response;
26
+ });
27
+ await client().createSite();
28
+ expect(calls[0].url).toBe(`${PROXY}/sites`);
29
+ expect(calls[0].init.credentials).toBe('include');
30
+ expect((calls[0].init.headers as Record<string, string>).Authorization).toBeUndefined();
31
+ expect(JSON.parse(String(calls[0].init.body))).toEqual({ created_via: 'drop' });
32
+ });
33
+
34
+ it('throws NotAuthenticatedError on 401 — before anything is created', async () => {
35
+ vi.stubGlobal('fetch', async () => ({ ok: false, status: 401, json: async () => ({}) }));
36
+ await expect(client().createSite()).rejects.toBeInstanceOf(NotAuthenticatedError);
37
+ });
38
+ });
39
+
40
+ describe('AuthenticatedBuildClient.createBuildFromZip', () => {
41
+ it('uploads the archive direct to api.netlify.com as multipart with a fresh bearer', async () => {
42
+ const calls: Array<{ url: string; init: RequestInit }> = [];
43
+ vi.stubGlobal('fetch', async (url: unknown, init: RequestInit = {}) => {
44
+ calls.push({ url: String(url), init });
45
+ return { ok: true, status: 200, json: async () => ({ id: 'b', deploy_id: 'd' }) } as Response;
46
+ });
47
+ const build = await client().createBuildFromZip('site-id', zip);
48
+ expect(build).toEqual({ id: 'b', deploy_id: 'd' });
49
+ const [call] = calls;
50
+ expect(call.url).toBe(`${API}/sites/site-id/builds`);
51
+ expect((call.init.headers as Record<string, string>).Authorization).toBe('Bearer bearer-abc');
52
+ // No explicit Content-Type: the browser must add the multipart boundary itself.
53
+ expect((call.init.headers as Record<string, string>)['Content-Type']).toBeUndefined();
54
+ const body = call.init.body as FormData;
55
+ expect(body.get('zip')).toBeInstanceOf(File);
56
+ expect(body.get('title')).toBe('Build from drop deployment');
57
+ });
58
+
59
+ it('throws NotAuthenticatedError when no upload token can be minted', async () => {
60
+ await expect(client(async () => null).createBuildFromZip('s', zip)).rejects.toBeInstanceOf(
61
+ NotAuthenticatedError
62
+ );
63
+ });
64
+
65
+ it('wraps other upload failures in BuildCreateError with the status', async () => {
66
+ vi.stubGlobal('fetch', async () => ({ ok: false, status: 413, json: async () => ({}) }));
67
+ const failure = await client()
68
+ .createBuildFromZip('s', zip)
69
+ .catch(e => e);
70
+ expect(failure).toBeInstanceOf(BuildCreateError);
71
+ expect(failure.status).toBe(413);
72
+ });
73
+ });
74
+
75
+ describe('AuthenticatedBuildClient.waitUntilBuildReady', () => {
76
+ const pollClient = (states: Array<{ ok: boolean; state?: string; error_message?: string }>) => {
77
+ let call = 0;
78
+ vi.stubGlobal('fetch', async () => {
79
+ const next = states[Math.min(call++, states.length - 1)];
80
+ return { ok: next.ok, status: 200, json: async () => next } as Response;
81
+ });
82
+ return client();
83
+ };
84
+
85
+ it('has nothing to poll before the API assigns a deploy id', async () => {
86
+ vi.stubGlobal('fetch', vi.fn());
87
+ await client().waitUntilBuildReady({ id: 'b' });
88
+ expect(fetch).not.toHaveBeenCalled();
89
+ });
90
+
91
+ it('polls the deploy through the proxy until ready', async () => {
92
+ const c = pollClient([
93
+ { ok: true, state: 'building' },
94
+ { ok: true, state: 'ready' },
95
+ ]);
96
+ await expect(c.waitUntilBuildReady({ id: 'b', deploy_id: 'd' })).resolves.toBeUndefined();
97
+ });
98
+
99
+ it('surfaces the build error message via BuildFailedError', async () => {
100
+ const c = pollClient([{ ok: true, state: 'error', error_message: 'Command failed' }]);
101
+ const failure = await c.waitUntilBuildReady({ id: 'b', deploy_id: 'd' }).catch(e => e);
102
+ expect(failure).toBeInstanceOf(BuildFailedError);
103
+ expect(failure.reason).toBe('Command failed');
104
+ });
105
+
106
+ it('gives up with BuildTimeoutError when the build never settles', async () => {
107
+ const c = pollClient([{ ok: true, state: 'building' }]);
108
+ await expect(c.waitUntilBuildReady({ id: 'b', deploy_id: 'd' })).rejects.toBeInstanceOf(
109
+ BuildTimeoutError
110
+ );
111
+ });
112
+ });
@@ -1,21 +1,20 @@
1
+ import {
2
+ createSiteInAccount,
3
+ DEFAULT_API_BASE,
4
+ DEFAULT_POLL_INTERVAL_MS,
5
+ DEFAULT_PROXY_BASE,
6
+ DEFAULT_TIMEOUT_MS,
7
+ pollDeployUntilSettled,
8
+ } from './authedApi';
1
9
  import {
2
10
  BuildCreateError,
3
11
  BuildFailedError,
4
12
  BuildTimeoutError,
5
13
  NotAuthenticatedError,
6
- SiteCreateError,
7
14
  } from './errors';
15
+ import type { BuildSite } from './types';
8
16
 
9
- /** The subset of a Netlify Site the drop flow needs to redirect and report. */
10
- export interface BuildSite {
11
- /** site id (UUID) */
12
- id: string;
13
- /** the *.netlify.app subdomain, also the app's URL segment */
14
- name: string;
15
- /** https://<name>.netlify.app */
16
- ssl_url?: string;
17
- url?: string;
18
- }
17
+ export type { BuildSite } from './types';
19
18
 
20
19
  /** The subset of a Netlify Build returned by `POST /sites/{id}/builds`. */
21
20
  export interface BuildResponse {
@@ -66,19 +65,6 @@ export interface AuthenticatedBuildClientConfig {
66
65
  timeoutMs?: number;
67
66
  }
68
67
 
69
- const PROXY_BASE = '/access-control/bb-api/api/v1';
70
- const API_BASE = 'https://api.netlify.com/api/v1';
71
- const POLL_INTERVAL_MS = 1000;
72
- const TIMEOUT_MS = 10 * 60 * 1000;
73
-
74
- /** Deploy states that end the wait — `ready` succeeds, the rest are failures. */
75
- const TERMINAL_STATES = new Set(['ready', 'error', 'rejected']);
76
-
77
- interface DeployState {
78
- state?: string;
79
- error_message?: string;
80
- }
81
-
82
68
  /**
83
69
  * Authenticated drop client for projects that need a build. Unlike
84
70
  * `AnonymousDropClient` — which creates an account-less static site and can only
@@ -101,13 +87,13 @@ export class AuthenticatedBuildClient implements BuildClient {
101
87
  private timeoutMs: number;
102
88
 
103
89
  constructor({
104
- proxyBase = PROXY_BASE,
105
- apiBase = API_BASE,
90
+ proxyBase = DEFAULT_PROXY_BASE,
91
+ apiBase = DEFAULT_API_BASE,
106
92
  getUploadToken,
107
93
  accountSlug,
108
94
  title = 'Build from drop deployment',
109
- pollIntervalMs = POLL_INTERVAL_MS,
110
- timeoutMs = TIMEOUT_MS,
95
+ pollIntervalMs = DEFAULT_POLL_INTERVAL_MS,
96
+ timeoutMs = DEFAULT_TIMEOUT_MS,
111
97
  }: AuthenticatedBuildClientConfig) {
112
98
  this.proxyBase = proxyBase;
113
99
  this.apiBase = apiBase;
@@ -119,23 +105,12 @@ export class AuthenticatedBuildClient implements BuildClient {
119
105
  }
120
106
 
121
107
  /**
122
- * Create the site the build deploys into. Goes through the proxy with
123
- * `credentials: 'include'` so the app session authorizes it; without an
124
- * account slug the API picks the user's default account.
108
+ * Create the site the build deploys into. A logged-out visitor fails here,
109
+ * before anything is created — see `createSiteInAccount` for the error
110
+ * mapping that makes the signup fallback safe.
125
111
  */
126
112
  async createSite(): Promise<BuildSite> {
127
- const path = this.accountSlug ? `/${this.accountSlug}/sites` : '/sites';
128
- const res = await fetch(`${this.proxyBase}${path}`, {
129
- method: 'POST',
130
- credentials: 'include',
131
- headers: { 'Content-Type': 'application/json' },
132
- body: JSON.stringify({ created_via: 'drop' }),
133
- });
134
- // A logged-out visitor gets a 401 here rather than at the token step, since
135
- // this is the first call that needs the session.
136
- if (res.status === 401 || res.status === 403) throw new NotAuthenticatedError();
137
- if (!res.ok) throw new SiteCreateError(res.status);
138
- return res.json();
113
+ return createSiteInAccount(this.proxyBase, this.accountSlug);
139
114
  }
140
115
 
141
116
  /**
@@ -182,20 +157,16 @@ export class AuthenticatedBuildClient implements BuildClient {
182
157
  async waitUntilBuildReady(build: BuildResponse): Promise<void> {
183
158
  if (!build.deploy_id) return;
184
159
 
185
- const deadline = this.timeoutMs / this.pollIntervalMs;
186
- for (let i = 0; i < deadline; i++) {
187
- const res = await fetch(`${this.proxyBase}/deploys/${build.deploy_id}`, {
188
- credentials: 'include',
189
- });
190
- if (res.ok) {
191
- const deploy: DeployState = await res.json();
192
- if (deploy.state && TERMINAL_STATES.has(deploy.state)) {
193
- if (deploy.state === 'ready') return;
194
- throw new BuildFailedError(deploy.error_message);
195
- }
196
- }
197
- await new Promise(r => setTimeout(r, this.pollIntervalMs));
198
- }
160
+ const settled = await pollDeployUntilSettled({
161
+ proxyBase: this.proxyBase,
162
+ deployId: build.deploy_id,
163
+ pollIntervalMs: this.pollIntervalMs,
164
+ timeoutMs: this.timeoutMs,
165
+ });
166
+ if (settled.outcome === 'ready') return;
167
+ // The build's own error message is the only actionable part of a failure,
168
+ // so it travels on the typed error rather than being flattened away.
169
+ if (settled.outcome === 'failed') throw new BuildFailedError(settled.errorMessage);
199
170
  throw new BuildTimeoutError();
200
171
  }
201
172
  }
@@ -0,0 +1,85 @@
1
+ import 'fake-indexeddb/auto';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { clearDropStash, hasDropStashMarker, loadDropStash, saveDropStash } from './buildStash';
4
+
5
+ // Node environment on purpose: fake-indexeddb structured-clones Node's own
6
+ // File/Blob faithfully, where a DOM shim's File comes back as a husk. The two
7
+ // browser globals the module needs are stubbed below.
8
+ const localStorageStub = () => {
9
+ const store = new Map<string, string>();
10
+ return {
11
+ getItem: (k: string) => store.get(k) ?? null,
12
+ setItem: (k: string, v: string) => void store.set(k, v),
13
+ removeItem: (k: string) => void store.delete(k),
14
+ };
15
+ };
16
+
17
+ const stubQuota = (free: number) =>
18
+ vi.stubGlobal('navigator', { storage: { estimate: async () => ({ quota: free, usage: 0 }) } });
19
+
20
+ const zip = () => new File(['zip-bytes'], 'source.zip', { type: 'application/zip' });
21
+
22
+ beforeEach(async () => {
23
+ vi.stubGlobal('localStorage', localStorageStub());
24
+ stubQuota(10_000_000);
25
+ await clearDropStash();
26
+ });
27
+
28
+ afterEach(() => {
29
+ vi.unstubAllGlobals();
30
+ vi.restoreAllMocks();
31
+ });
32
+
33
+ describe('buildStash', () => {
34
+ it('round-trips a drop: archive, settings, and the cheap marker', async () => {
35
+ const settings = { command: 'npm run build', publish: 'dist', framework: 'astro' };
36
+ expect(hasDropStashMarker()).toBe(false);
37
+
38
+ expect(await saveDropStash(zip(), settings)).toBe(true);
39
+ expect(hasDropStashMarker()).toBe(true);
40
+
41
+ const stash = await loadDropStash();
42
+ expect(stash?.buildSettings).toEqual(settings);
43
+ // A real, named File comes back out — rehydrated when the structured-clone
44
+ // implementation drops File identity.
45
+ expect(stash?.zipFile).toBeInstanceOf(File);
46
+ expect(stash?.zipFile.name).toBe('source.zip');
47
+ expect(await stash?.zipFile.text()).toBe('zip-bytes');
48
+ });
49
+
50
+ it('clears the stash and marker idempotently', async () => {
51
+ await saveDropStash(zip());
52
+ await clearDropStash();
53
+ expect(hasDropStashMarker()).toBe(false);
54
+ expect(await loadDropStash()).toBeNull();
55
+ await expect(clearDropStash()).resolves.toBeUndefined(); // second clear is a no-op
56
+ });
57
+
58
+ it('expires the stash after its TTL and cleans up on read', async () => {
59
+ await saveDropStash(zip());
60
+ const realNow = Date.now;
61
+ vi.spyOn(Date, 'now').mockImplementation(() => realNow() + 25 * 60 * 60 * 1000);
62
+ expect(await loadDropStash()).toBeNull();
63
+ expect(hasDropStashMarker()).toBe(false); // the marker carries its own expiry
64
+ });
65
+
66
+ it('declines to stash without comfortable quota headroom', async () => {
67
+ stubQuota(10); // nowhere near 2x the zip
68
+ expect(await saveDropStash(zip())).toBe(false);
69
+ expect(hasDropStashMarker()).toBe(false);
70
+ });
71
+
72
+ it('declines oversized archives outright', async () => {
73
+ const huge = { size: 201 * 1024 * 1024 } as File;
74
+ expect(await saveDropStash(huge)).toBe(false);
75
+ });
76
+
77
+ it('degrades to false/null when IndexedDB is missing, never throwing', async () => {
78
+ const original = globalThis.indexedDB;
79
+ vi.stubGlobal('indexedDB', undefined);
80
+ expect(await saveDropStash(zip())).toBe(false);
81
+ expect(await loadDropStash()).toBeNull();
82
+ await expect(clearDropStash()).resolves.toBeUndefined();
83
+ vi.stubGlobal('indexedDB', original);
84
+ });
85
+ });
@@ -0,0 +1,185 @@
1
+ import type { InferredBuildSettings } from './detectBuild';
2
+
3
+ /**
4
+ * Carry a build-required drop across the signup round-trip.
5
+ *
6
+ * A logged-out visitor's drop can't be built (builds have no anonymous
7
+ * equivalent), so before handing them to signup the zone stashes the source
8
+ * archive here — IndexedDB, because the zip is far beyond localStorage's
9
+ * budget. When they return to this origin authenticated, the zone deploys the
10
+ * stash automatically instead of asking them to drop again.
11
+ *
12
+ * Storage is per-origin: a stash written on www.netlify.com can only ever be
13
+ * resumed on www.netlify.com. That is the design, not a limitation — it is
14
+ * exactly why the hand-off page must bring the visitor back here.
15
+ *
16
+ * Every operation is failure-swallowing: the stash is an enhancement, and a
17
+ * browser without IndexedDB (or with a full quota) degrades to the plain
18
+ * signup redirect the zone always supported.
19
+ */
20
+
21
+ export interface DropBuildStash {
22
+ /** The `source.zip` exactly as it would have been uploaded. */
23
+ zipFile: File;
24
+ /** Settings synthesised into the archive's netlify.toml, if any. */
25
+ buildSettings: InferredBuildSettings | null;
26
+ createdAt: number;
27
+ }
28
+
29
+ const DB_NAME = 'netlify-drop';
30
+ const DB_VERSION = 1;
31
+ const STORE_NAME = 'build-stash';
32
+ const STASH_KEY = 'latest';
33
+ const MARKER_KEY = 'dropBuildStash';
34
+ const STASH_TTL_MS = 24 * 60 * 60 * 1000;
35
+ const OPERATION_TIMEOUT_MS = 3000;
36
+ const MAX_ZIP_BYTES = 200 * 1024 * 1024;
37
+ /** Only stash when the quota comfortably fits the zip — never wedge the origin's storage. */
38
+ const QUOTA_HEADROOM = 2;
39
+
40
+ const withTimeout = <T>(promise: Promise<T>): Promise<T> =>
41
+ Promise.race([
42
+ promise,
43
+ new Promise<never>((_resolve, reject) => {
44
+ setTimeout(() => reject(new Error('IndexedDB operation timed out')), OPERATION_TIMEOUT_MS);
45
+ }),
46
+ ]);
47
+
48
+ const openDatabase = (): Promise<IDBDatabase> =>
49
+ new Promise((resolve, reject) => {
50
+ const request = indexedDB.open(DB_NAME, DB_VERSION);
51
+ request.onupgradeneeded = () => {
52
+ request.result.createObjectStore(STORE_NAME);
53
+ };
54
+ request.onsuccess = () => resolve(request.result);
55
+ request.onerror = () => reject(request.error);
56
+ });
57
+
58
+ // One request per transaction; awaiting mid-transaction would auto-commit it.
59
+ const runStoreOperation = <T>(
60
+ mode: IDBTransactionMode,
61
+ makeRequest: (store: IDBObjectStore) => IDBRequest<T>
62
+ ): Promise<T> =>
63
+ withTimeout(
64
+ openDatabase().then(
65
+ db =>
66
+ new Promise<T>((resolve, reject) => {
67
+ const request = makeRequest(db.transaction(STORE_NAME, mode).objectStore(STORE_NAME));
68
+ request.onsuccess = () => {
69
+ db.close();
70
+ resolve(request.result);
71
+ };
72
+ request.onerror = () => {
73
+ db.close();
74
+ reject(request.error);
75
+ };
76
+ })
77
+ )
78
+ );
79
+
80
+ const hasEnoughQuota = async (bytes: number): Promise<boolean> => {
81
+ const { quota, usage } = await navigator.storage.estimate();
82
+ return quota !== undefined && usage !== undefined && quota - usage > bytes * QUOTA_HEADROOM;
83
+ };
84
+
85
+ // The marker is a plain localStorage flag with its own expiry, so page loads can
86
+ // ask "is there anything to resume?" synchronously without opening IndexedDB.
87
+ const setMarker = () => {
88
+ localStorage.setItem(MARKER_KEY, JSON.stringify({ expiresAt: Date.now() + STASH_TTL_MS }));
89
+ };
90
+
91
+ const clearMarker = () => {
92
+ localStorage.removeItem(MARKER_KEY);
93
+ };
94
+
95
+ /**
96
+ * Stash a drop's source archive for an automatic deploy after signup. Returns
97
+ * whether it was actually stored — callers use that only to phrase the
98
+ * hand-off, never to block it.
99
+ */
100
+ export async function saveDropStash(
101
+ zipFile: File,
102
+ buildSettings: InferredBuildSettings | null = null
103
+ ): Promise<boolean> {
104
+ try {
105
+ if (
106
+ typeof indexedDB === 'undefined' ||
107
+ !navigator.storage?.estimate ||
108
+ zipFile.size > MAX_ZIP_BYTES ||
109
+ !(await hasEnoughQuota(zipFile.size))
110
+ ) {
111
+ return false;
112
+ }
113
+ const record: DropBuildStash = { zipFile, buildSettings, createdAt: Date.now() };
114
+ await runStoreOperation('readwrite', store => store.put(record, STASH_KEY));
115
+ setMarker();
116
+ return true;
117
+ } catch {
118
+ return false;
119
+ }
120
+ }
121
+
122
+ /** The stashed drop, or null when there is none, it expired, or storage is unavailable. */
123
+ export async function loadDropStash(): Promise<DropBuildStash | null> {
124
+ try {
125
+ if (typeof indexedDB === 'undefined') return null;
126
+ const record = await runStoreOperation<DropBuildStash | undefined>('readonly', store =>
127
+ store.get(STASH_KEY)
128
+ );
129
+ const zipFile = record && rehydrateZipFile(record.zipFile);
130
+ if (!record || !zipFile) return null;
131
+ if (Date.now() - record.createdAt > STASH_TTL_MS) {
132
+ await clearDropStash();
133
+ return null;
134
+ }
135
+ return { ...record, zipFile };
136
+ } catch {
137
+ return null;
138
+ }
139
+ }
140
+
141
+ /**
142
+ * The archive back out of storage as a real `File`. Structured clone should
143
+ * preserve File identity, but implementations vary in whether the name and
144
+ * prototype survive — and the build upload needs both (the multipart field is
145
+ * named by the File). A Blob-shaped survivor is rewrapped; anything else is
146
+ * treated as a corrupt record.
147
+ */
148
+ function rehydrateZipFile(stored: unknown): File | null {
149
+ if (stored instanceof File && stored.name) return stored;
150
+ if (stored instanceof Blob || (stored && typeof (stored as Blob).arrayBuffer === 'function')) {
151
+ return new File([stored as Blob], 'source.zip', { type: 'application/zip' });
152
+ }
153
+ return null;
154
+ }
155
+
156
+ /** Remove the stash and its marker. Idempotent; never throws. */
157
+ export async function clearDropStash(): Promise<void> {
158
+ try {
159
+ clearMarker();
160
+ } catch {
161
+ // storage disabled — nothing to clear
162
+ }
163
+ try {
164
+ if (typeof indexedDB === 'undefined') return;
165
+ await runStoreOperation('readwrite', store => store.delete(STASH_KEY));
166
+ } catch {
167
+ // best effort — an orphaned record expires via its TTL
168
+ }
169
+ }
170
+
171
+ /** Cheap synchronous check so page loads don't open IndexedDB just to look for a stash. */
172
+ export function hasDropStashMarker(): boolean {
173
+ try {
174
+ const marker = localStorage.getItem(MARKER_KEY);
175
+ if (!marker) return false;
176
+ const { expiresAt } = JSON.parse(marker) as { expiresAt?: number };
177
+ if (typeof expiresAt !== 'number' || Date.now() > expiresAt) {
178
+ clearMarker();
179
+ return false;
180
+ }
181
+ return true;
182
+ } catch {
183
+ return false;
184
+ }
185
+ }
@@ -0,0 +1,135 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { deployBuild, deployFiles } from './deploy';
3
+ import type { BuildClient } from './buildClient';
4
+ import type { DropClient } from './client';
5
+ import type { DeployProgress, ProcessedFile } from './types';
6
+
7
+ const encoder = new TextEncoder();
8
+
9
+ const file = (path: string, sha: string, contents = 'x'): ProcessedFile => {
10
+ const content = encoder.encode(contents).buffer as ArrayBuffer;
11
+ return { path, sha, content, size: content.byteLength, name: path.split('/').pop()! };
12
+ };
13
+
14
+ const files = [file('/index.html', 'aaa'), file('/site.css', 'bbb'), file('/app.js', 'ccc')];
15
+
16
+ function stubDropClient(required: string[]) {
17
+ const uploaded: string[] = [];
18
+ let inFlight = 0;
19
+ let maxInFlight = 0;
20
+ const client: DropClient = {
21
+ getToken: vi.fn(async () => 'tok'),
22
+ createDeploy: vi.fn(async () => ({
23
+ id: 'site',
24
+ deploy_id: 'deploy',
25
+ subdomain: 'sub',
26
+ required,
27
+ })),
28
+ uploadFile: vi.fn(async (deployId: string, path: string) => {
29
+ inFlight++;
30
+ maxInFlight = Math.max(maxInFlight, inFlight);
31
+ await new Promise(r => setTimeout(r, 2));
32
+ uploaded.push(`${deployId}:${path}`);
33
+ inFlight--;
34
+ }),
35
+ waitUntilReady: vi.fn(async () => {}),
36
+ };
37
+ return { client, uploaded, maxConcurrent: () => maxInFlight };
38
+ }
39
+
40
+ describe('deployFiles', () => {
41
+ it('uploads only the SHAs the backend reported missing, keyed by deploy_id', async () => {
42
+ const { client, uploaded } = stubDropClient(['aaa', 'ccc']);
43
+ const result = await deployFiles(client, files);
44
+ expect(uploaded.sort()).toEqual(['deploy:/app.js', 'deploy:/index.html']);
45
+ expect(result.url).toBe('https://sub.netlify.app');
46
+ expect(result.token).toBe('tok');
47
+ expect(client.waitUntilReady).toHaveBeenCalledWith(
48
+ expect.objectContaining({ deploy_id: 'deploy' }),
49
+ 'tok'
50
+ );
51
+ });
52
+
53
+ it('never runs more than five uploads at once', async () => {
54
+ const many = Array.from({ length: 20 }, (_, i) => file(`/f${i}.txt`, `sha${i}`));
55
+ const { client, maxConcurrent } = stubDropClient(many.map(f => f.sha));
56
+ await deployFiles(client, many);
57
+ expect(maxConcurrent()).toBeLessThanOrEqual(5);
58
+ });
59
+
60
+ it('reports progress through the full phase sequence', async () => {
61
+ const { client } = stubDropClient(['aaa']);
62
+ const phases: DeployProgress[] = [];
63
+ await deployFiles(client, files, { onProgress: e => phases.push(e) });
64
+ expect(phases.map(p => p.phase)).toEqual([
65
+ 'creating',
66
+ 'uploading',
67
+ 'uploading',
68
+ 'processing',
69
+ 'ready',
70
+ ]);
71
+ expect(phases[1]).toEqual({ phase: 'uploading', uploaded: 0, total: 1 });
72
+ });
73
+
74
+ it('throws on an empty file set before any network call', async () => {
75
+ const { client } = stubDropClient([]);
76
+ await expect(deployFiles(client, [])).rejects.toThrow('No files to deploy');
77
+ expect(client.getToken).not.toHaveBeenCalled();
78
+ });
79
+ });
80
+
81
+ describe('deployBuild', () => {
82
+ const stubBuildClient = (): BuildClient & { calls: string[] } => {
83
+ const calls: string[] = [];
84
+ return {
85
+ calls,
86
+ createSite: vi.fn(async () => (calls.push('createSite'), { id: 'site', name: 'sub' })),
87
+ createBuildFromZip: vi.fn(
88
+ async () => (calls.push('createBuildFromZip'), { id: 'b', deploy_id: 'd' })
89
+ ),
90
+ waitUntilBuildReady: vi.fn(async () => {
91
+ calls.push('waitUntilBuildReady');
92
+ }),
93
+ };
94
+ };
95
+
96
+ it('zips, creates the site, uploads, and reports the site URL', async () => {
97
+ const client = stubBuildClient();
98
+ const result = await deployBuild(client, files);
99
+ expect(result.site.name).toBe('sub');
100
+ expect(result.build.deploy_id).toBe('d');
101
+ expect(result.url).toBe('https://sub.netlify.app');
102
+ // Off by default: the useful hand-off is the project page, not a spinner.
103
+ expect(client.waitUntilBuildReady).not.toHaveBeenCalled();
104
+ });
105
+
106
+ it('zips BEFORE creating the site, so a failed zip leaves no empty project behind', async () => {
107
+ const client = stubBuildClient();
108
+ // An empty file set makes zipFiles throw before any client call.
109
+ await expect(deployBuild(client, [])).rejects.toThrow('No files to deploy');
110
+ expect(client.createSite).not.toHaveBeenCalled();
111
+ });
112
+
113
+ it('waits for the build only when asked', async () => {
114
+ const client = stubBuildClient();
115
+ await deployBuild(client, files, { waitForBuild: true });
116
+ expect(client.calls).toEqual(['createSite', 'createBuildFromZip', 'waitUntilBuildReady']);
117
+ });
118
+
119
+ it('reports progress through the build phase sequence', async () => {
120
+ const client = stubBuildClient();
121
+ const phases: string[] = [];
122
+ await deployBuild(client, files, { onProgress: e => phases.push(e.phase) });
123
+ expect(phases).toEqual(['zipping', 'creating', 'uploading', 'building', 'ready']);
124
+ });
125
+
126
+ it('falls back through ssl_url → url → derived netlify.app domain', async () => {
127
+ const client = stubBuildClient();
128
+ (client.createSite as ReturnType<typeof vi.fn>).mockResolvedValue({
129
+ id: 's',
130
+ name: 'sub',
131
+ url: 'http://custom.example',
132
+ });
133
+ expect((await deployBuild(client, files)).url).toBe('http://custom.example');
134
+ });
135
+ });
@@ -3,7 +3,7 @@ import type { BuildClient, BuildResponse, BuildSite } from './buildClient';
3
3
  import type { DropClient } from './client';
4
4
  import type { InferredBuildSettings } from './detectBuild';
5
5
  import type { DeployProgress, DeployResult, ProcessedFile } from './types';
6
- import { zipFiles } from './zip';
6
+ import { zipFiles, type ZipResult } from './zip';
7
7
 
8
8
  const MAX_CONCURRENT_UPLOADS = 5;
9
9
 
@@ -95,10 +95,24 @@ export async function deployBuild(
95
95
  opts: BuildDeployOptions = {}
96
96
  ): Promise<BuildDeployResult> {
97
97
  if (!files.length) throw new Error('No files to deploy');
98
- const { onProgress, waitForBuild = false } = opts;
99
98
 
100
- onProgress?.({ phase: 'zipping' });
101
- const { file: zip, buildSettings } = await zipFiles(files);
99
+ opts.onProgress?.({ phase: 'zipping' });
100
+ const archive = await zipFiles(files);
101
+
102
+ return deployZippedBuild(client, archive, opts);
103
+ }
104
+
105
+ /**
106
+ * Deploy an archive that was already zipped — the resume path for a drop
107
+ * stashed across the signup round-trip (see `buildStash`), where `source.zip`
108
+ * and its synthesised settings were produced before the visitor left.
109
+ */
110
+ export async function deployZippedBuild(
111
+ client: BuildClient,
112
+ { file: zip, buildSettings }: ZipResult,
113
+ opts: BuildDeployOptions = {}
114
+ ): Promise<BuildDeployResult> {
115
+ const { onProgress, waitForBuild = false } = opts;
102
116
 
103
117
  onProgress?.({ phase: 'creating' });
104
118
  const site = await client.createSite();