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

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 (100) hide show
  1. package/dist/components/index.d.ts +2 -2
  2. package/dist/components/preact/DropZone/DropZone.d.ts +27 -9
  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 +29 -27
  7. package/dist/components/preact/DropZone/useDropDeploy.d.ts +9 -6
  8. package/dist/components/preact/DropZone/useDropDeploy.js +182 -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 +24 -18
  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 +45 -0
  16. package/dist/drop-core/authedApi.js +30 -0
  17. package/dist/drop-core/authedApi.test.d.ts +1 -0
  18. package/dist/{components/preact/DropZone/drop-core → drop-core}/authedDropClient.d.ts +4 -11
  19. package/dist/drop-core/authedDropClient.js +100 -0
  20. package/dist/drop-core/authedDropClient.test.d.ts +1 -0
  21. package/dist/{components/preact/DropZone/drop-core → drop-core}/buildClient.d.ts +5 -13
  22. package/dist/drop-core/buildClient.js +85 -0
  23. package/dist/drop-core/buildClient.test.d.ts +1 -0
  24. package/dist/drop-core/buildStash.d.ts +37 -0
  25. package/dist/drop-core/buildStash.js +84 -0
  26. package/dist/drop-core/buildStash.test.d.ts +0 -0
  27. package/dist/{components/preact/DropZone/drop-core → drop-core}/client.d.ts +3 -1
  28. package/dist/drop-core/client.js +47 -0
  29. package/dist/drop-core/constants.d.ts +29 -0
  30. package/dist/drop-core/constants.js +10 -0
  31. package/dist/{components/preact/DropZone/drop-core → drop-core}/deploy.d.ts +9 -0
  32. package/dist/drop-core/deploy.js +66 -0
  33. package/dist/drop-core/deploy.test.d.ts +1 -0
  34. package/dist/drop-core/detectBuild.test.d.ts +1 -0
  35. package/dist/drop-core/digest.test.d.ts +1 -0
  36. package/dist/{components/preact/DropZone/drop-core → drop-core}/errors.d.ts +29 -0
  37. package/dist/drop-core/errors.js +119 -0
  38. package/dist/drop-core/errors.test.d.ts +1 -0
  39. package/dist/drop-core/fileTypes.test.d.ts +1 -0
  40. package/dist/drop-core/index.d.ts +25 -0
  41. package/dist/drop-core/index.js +47 -0
  42. package/dist/{components/preact/DropZone/drop-core → drop-core}/readFiles.js +2 -2
  43. package/dist/drop-core/readFiles.test.d.ts +1 -0
  44. package/dist/{components/preact/DropZone/drop-core → drop-core}/types.d.ts +10 -0
  45. package/dist/{components/preact/DropZone/drop-core → drop-core}/zip.js +1 -1
  46. package/dist/drop-core/zip.test.d.ts +1 -0
  47. package/package.json +4 -1
  48. package/packages/components/index.ts +19 -0
  49. package/packages/components/preact/DropZone/DropZone.test.ts +71 -0
  50. package/packages/components/preact/DropZone/DropZone.tsx +29 -9
  51. package/packages/components/preact/DropZone/index.tsx +14 -3
  52. package/packages/components/preact/DropZone/useDropDeploy.test.ts +376 -0
  53. package/packages/components/preact/DropZone/useDropDeploy.ts +301 -145
  54. package/packages/components/preact/index.ts +19 -2
  55. package/packages/drop-core/README.md +91 -0
  56. package/packages/drop-core/analytics.test.ts +17 -0
  57. package/packages/drop-core/analytics.ts +122 -0
  58. package/packages/drop-core/authedApi.test.ts +100 -0
  59. package/packages/drop-core/authedApi.ts +81 -0
  60. package/packages/drop-core/authedDropClient.test.ts +196 -0
  61. package/packages/{components/preact/DropZone/drop-core → drop-core}/authedDropClient.ts +30 -72
  62. package/packages/drop-core/buildClient.test.ts +112 -0
  63. package/packages/{components/preact/DropZone/drop-core → drop-core}/buildClient.ts +27 -57
  64. package/packages/drop-core/buildStash.test.ts +85 -0
  65. package/packages/drop-core/buildStash.ts +185 -0
  66. package/packages/{components/preact/DropZone/drop-core → drop-core}/client.ts +18 -6
  67. package/packages/drop-core/constants.ts +39 -0
  68. package/packages/drop-core/deploy.test.ts +177 -0
  69. package/packages/{components/preact/DropZone/drop-core → drop-core}/deploy.ts +59 -9
  70. package/packages/drop-core/detectBuild.test.ts +139 -0
  71. package/packages/drop-core/digest.test.ts +24 -0
  72. package/packages/drop-core/errors.test.ts +98 -0
  73. package/packages/{components/preact/DropZone/drop-core → drop-core}/errors.ts +68 -11
  74. package/packages/drop-core/fileTypes.test.ts +37 -0
  75. package/packages/drop-core/index.ts +44 -0
  76. package/packages/drop-core/readFiles.test.ts +124 -0
  77. package/packages/{components/preact/DropZone/drop-core → drop-core}/types.ts +12 -6
  78. package/packages/drop-core/zip.test.ts +92 -0
  79. package/dist/components/preact/DropZone/drop-core/authedDropClient.js +0 -123
  80. package/dist/components/preact/DropZone/drop-core/buildClient.js +0 -99
  81. package/dist/components/preact/DropZone/drop-core/client.js +0 -45
  82. package/dist/components/preact/DropZone/drop-core/deploy.js +0 -48
  83. package/dist/components/preact/DropZone/drop-core/errors.js +0 -82
  84. package/dist/components/preact/DropZone/drop-core/index.d.ts +0 -11
  85. package/dist/components/preact/DropZone/drop-core/index.js +0 -36
  86. package/packages/components/preact/DropZone/drop-core/index.ts +0 -11
  87. /package/dist/{components/preact/DropZone/drop-core → drop-core}/detectBuild.d.ts +0 -0
  88. /package/dist/{components/preact/DropZone/drop-core → drop-core}/detectBuild.js +0 -0
  89. /package/dist/{components/preact/DropZone/drop-core → drop-core}/digest.d.ts +0 -0
  90. /package/dist/{components/preact/DropZone/drop-core → drop-core}/digest.js +0 -0
  91. /package/dist/{components/preact/DropZone/drop-core → drop-core}/fileTypes.d.ts +0 -0
  92. /package/dist/{components/preact/DropZone/drop-core → drop-core}/fileTypes.js +0 -0
  93. /package/dist/{components/preact/DropZone/drop-core → drop-core}/readFiles.d.ts +0 -0
  94. /package/dist/{components/preact/DropZone/drop-core → drop-core}/types.js +0 -0
  95. /package/dist/{components/preact/DropZone/drop-core → drop-core}/zip.d.ts +0 -0
  96. /package/packages/{components/preact/DropZone/drop-core → drop-core}/detectBuild.ts +0 -0
  97. /package/packages/{components/preact/DropZone/drop-core → drop-core}/digest.ts +0 -0
  98. /package/packages/{components/preact/DropZone/drop-core → drop-core}/fileTypes.ts +0 -0
  99. /package/packages/{components/preact/DropZone/drop-core → drop-core}/readFiles.ts +0 -0
  100. /package/packages/{components/preact/DropZone/drop-core → drop-core}/zip.ts +0 -0
@@ -1,7 +1,14 @@
1
+ import { createSiteInAccount, pollDeployUntilSettled } from './authedApi';
2
+ import {
3
+ DEFAULT_API_BASE,
4
+ DEFAULT_POLL_INTERVAL_MS,
5
+ DEFAULT_PROXY_BASE,
6
+ DEFAULT_TIMEOUT_MS,
7
+ TOKEN_REUSE_MS,
8
+ } from './constants';
1
9
  import { apiError, type DropClient } from './client';
2
- import type { BuildSite } from './buildClient';
3
- import type { Digest, DropResponse } from './types';
4
- import { NotAuthenticatedError, SiteCreateError } from './errors';
10
+ import { DeployFailedError, DeployTimeoutError, NotAuthenticatedError } from './errors';
11
+ import type { BuildSite, Digest, DropResponse } from './types';
5
12
 
6
13
  export interface AuthenticatedDropClientConfig {
7
14
  /**
@@ -35,30 +42,6 @@ export interface AuthenticatedDropClientConfig {
35
42
  timeoutMs?: number;
36
43
  }
37
44
 
38
- // Same defaults as AuthenticatedBuildClient — the ~15 lines shared with it
39
- // (constants + site create) are deliberately duplicated rather than extracted,
40
- // to avoid churn on the in-review build-client branch. Worth unifying once both
41
- // have landed.
42
- const PROXY_BASE = '/access-control/bb-api/api/v1';
43
- const API_BASE = 'https://api.netlify.com/api/v1';
44
- const POLL_INTERVAL_MS = 1000;
45
- const TIMEOUT_MS = 10 * 60 * 1000;
46
-
47
- /**
48
- * How long a minted upload token is reused before minting another. The server
49
- * issues non-app origins a 30s token, so this leaves a margin for the request
50
- * it authorizes to finish.
51
- */
52
- const TOKEN_REUSE_MS = 20 * 1000;
53
-
54
- /** Deploy states that end the wait — `ready` succeeds, the rest are failures. */
55
- const TERMINAL_STATES = new Set(['ready', 'error', 'rejected']);
56
-
57
- interface DeployState {
58
- state?: string;
59
- error_message?: string;
60
- }
61
-
62
45
  /** Response body of `POST /sites/{id}/deploys` — the slice the drop flow needs. */
63
46
  interface SiteDeployResponse {
64
47
  /** deploy id (BSON) */
@@ -98,12 +81,12 @@ export class AuthenticatedDropClient implements DropClient {
98
81
  private cachedToken?: { value: string; mintedAt: number };
99
82
 
100
83
  constructor({
101
- proxyBase = PROXY_BASE,
102
- apiBase = API_BASE,
84
+ proxyBase = DEFAULT_PROXY_BASE,
85
+ apiBase = DEFAULT_API_BASE,
103
86
  getUploadToken,
104
87
  accountSlug,
105
- pollIntervalMs = POLL_INTERVAL_MS,
106
- timeoutMs = TIMEOUT_MS,
88
+ pollIntervalMs = DEFAULT_POLL_INTERVAL_MS,
89
+ timeoutMs = DEFAULT_TIMEOUT_MS,
107
90
  }: AuthenticatedDropClientConfig) {
108
91
  this.proxyBase = proxyBase;
109
92
  this.apiBase = apiBase;
@@ -114,11 +97,8 @@ export class AuthenticatedDropClient implements DropClient {
114
97
  }
115
98
 
116
99
  /**
117
- * The auth gate. Minting here — before anything is created — is what lets a
118
- * logged-out visitor fail cleanly and fall back to the anonymous flow. The
119
- * returned token is threaded through `deployFiles`, but only the uploads
120
- * actually need one, and they re-mint rather than trusting this to still be
121
- * valid by the time they run.
100
+ * The auth gate: minting here, before anything is created, is what lets a
101
+ * logged-out visitor fail cleanly and fall back to the anonymous flow.
122
102
  */
123
103
  async getToken(): Promise<string> {
124
104
  const token = await this.getUploadToken();
@@ -129,12 +109,8 @@ export class AuthenticatedDropClient implements DropClient {
129
109
 
130
110
  /**
131
111
  * A token young enough to authorize an upload, minting a new one once the
132
- * cached one nears its 30s expiry.
133
- *
134
- * Deliberately NOT `NotAuthenticatedError` when minting fails: by the time
135
- * uploads run the site already exists, and that error would send the drop
136
- * back through the anonymous path — leaving an empty project behind and
137
- * deploying a duplicate. See the class doc.
112
+ * cached one nears its 30s expiry. A mint failure here is a plain `Error`,
113
+ * never `NotAuthenticatedError` — the site exists by now (class doc).
138
114
  */
139
115
  private async freshToken(): Promise<string> {
140
116
  const now = Date.now();
@@ -153,18 +129,7 @@ export class AuthenticatedDropClient implements DropClient {
153
129
  * `deployFiles` runs unchanged over either client.
154
130
  */
155
131
  async createDeploy(files: Digest, _token: string): Promise<DropResponse> {
156
- const path = this.accountSlug ? `/${this.accountSlug}/sites` : '/sites';
157
- const siteRes = await fetch(`${this.proxyBase}${path}`, {
158
- method: 'POST',
159
- credentials: 'include',
160
- headers: { 'Content-Type': 'application/json' },
161
- body: JSON.stringify({ created_via: 'drop' }),
162
- });
163
- // A logged-out visitor 401s here, before anything is created — the one
164
- // point an anonymous fallback is still safe (see the class doc).
165
- if (siteRes.status === 401 || siteRes.status === 403) throw new NotAuthenticatedError();
166
- if (!siteRes.ok) throw new SiteCreateError(siteRes.status);
167
- const site: BuildSite = await siteRes.json();
132
+ const site: BuildSite = await createSiteInAccount(this.proxyBase, this.accountSlug);
168
133
 
169
134
  const deployRes = await fetch(`${this.proxyBase}/sites/${site.id}/deploys`, {
170
135
  method: 'POST',
@@ -209,23 +174,16 @@ export class AuthenticatedDropClient implements DropClient {
209
174
  * than a bearer, so a deploy taking minutes can't outlive its own credentials.
210
175
  */
211
176
  async waitUntilReady(deploy: DropResponse, _token: string): Promise<void> {
212
- const deadline = this.timeoutMs / this.pollIntervalMs;
213
- for (let i = 0; i < deadline; i++) {
214
- const res = await fetch(`${this.proxyBase}/deploys/${deploy.deploy_id}`, {
215
- credentials: 'include',
216
- });
217
- if (res.ok) {
218
- const state: DeployState = await res.json();
219
- if (state.state && TERMINAL_STATES.has(state.state)) {
220
- if (state.state === 'ready') return;
221
- throw new Error(
222
- state.error_message ? `deploy failed: ${state.error_message}` : 'deploy failed'
223
- );
224
- }
225
- }
226
- await new Promise(r => setTimeout(r, this.pollIntervalMs));
227
- }
228
- // Same message the anonymous client uses, so humanizeDropError maps it.
229
- throw new Error('Deploy did not become ready in time');
177
+ const settled = await pollDeployUntilSettled({
178
+ proxyBase: this.proxyBase,
179
+ deployId: deploy.deploy_id,
180
+ pollIntervalMs: this.pollIntervalMs,
181
+ timeoutMs: this.timeoutMs,
182
+ });
183
+ if (settled.outcome === 'ready') return;
184
+ // Neither class is NotAuthenticatedError-shaped on purpose: once the site
185
+ // exists, nothing here may trigger the anonymous fallback (class doc).
186
+ if (settled.outcome === 'failed') throw new DeployFailedError(settled.errorMessage);
187
+ throw new DeployTimeoutError();
230
188
  }
231
189
  }
@@ -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,19 @@
1
+ import { createSiteInAccount, pollDeployUntilSettled } from './authedApi';
2
+ import {
3
+ DEFAULT_API_BASE,
4
+ DEFAULT_POLL_INTERVAL_MS,
5
+ DEFAULT_PROXY_BASE,
6
+ DEFAULT_TIMEOUT_MS,
7
+ } from './constants';
1
8
  import {
2
9
  BuildCreateError,
3
10
  BuildFailedError,
4
11
  BuildTimeoutError,
5
12
  NotAuthenticatedError,
6
- SiteCreateError,
7
13
  } from './errors';
14
+ import type { BuildSite } from './types';
8
15
 
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
- }
16
+ export type { BuildSite } from './types';
19
17
 
20
18
  /** The subset of a Netlify Build returned by `POST /sites/{id}/builds`. */
21
19
  export interface BuildResponse {
@@ -66,19 +64,6 @@ export interface AuthenticatedBuildClientConfig {
66
64
  timeoutMs?: number;
67
65
  }
68
66
 
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
67
  /**
83
68
  * Authenticated drop client for projects that need a build. Unlike
84
69
  * `AnonymousDropClient` — which creates an account-less static site and can only
@@ -101,13 +86,13 @@ export class AuthenticatedBuildClient implements BuildClient {
101
86
  private timeoutMs: number;
102
87
 
103
88
  constructor({
104
- proxyBase = PROXY_BASE,
105
- apiBase = API_BASE,
89
+ proxyBase = DEFAULT_PROXY_BASE,
90
+ apiBase = DEFAULT_API_BASE,
106
91
  getUploadToken,
107
92
  accountSlug,
108
93
  title = 'Build from drop deployment',
109
- pollIntervalMs = POLL_INTERVAL_MS,
110
- timeoutMs = TIMEOUT_MS,
94
+ pollIntervalMs = DEFAULT_POLL_INTERVAL_MS,
95
+ timeoutMs = DEFAULT_TIMEOUT_MS,
111
96
  }: AuthenticatedBuildClientConfig) {
112
97
  this.proxyBase = proxyBase;
113
98
  this.apiBase = apiBase;
@@ -119,23 +104,12 @@ export class AuthenticatedBuildClient implements BuildClient {
119
104
  }
120
105
 
121
106
  /**
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.
107
+ * Create the site the build deploys into. A logged-out visitor fails here,
108
+ * before anything is created — see `createSiteInAccount` for the error
109
+ * mapping that makes the signup fallback safe.
125
110
  */
126
111
  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();
112
+ return createSiteInAccount(this.proxyBase, this.accountSlug);
139
113
  }
140
114
 
141
115
  /**
@@ -182,20 +156,16 @@ export class AuthenticatedBuildClient implements BuildClient {
182
156
  async waitUntilBuildReady(build: BuildResponse): Promise<void> {
183
157
  if (!build.deploy_id) return;
184
158
 
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
- }
159
+ const settled = await pollDeployUntilSettled({
160
+ proxyBase: this.proxyBase,
161
+ deployId: build.deploy_id,
162
+ pollIntervalMs: this.pollIntervalMs,
163
+ timeoutMs: this.timeoutMs,
164
+ });
165
+ if (settled.outcome === 'ready') return;
166
+ // The build's own error message is the only actionable part of a failure,
167
+ // so it travels on the typed error rather than being flattened away.
168
+ if (settled.outcome === 'failed') throw new BuildFailedError(settled.errorMessage);
199
169
  throw new BuildTimeoutError();
200
170
  }
201
171
  }
@@ -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
+ }