@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.
- package/dist/components/index.d.ts +2 -2
- package/dist/components/preact/DropZone/DropZone.d.ts +27 -9
- package/dist/components/preact/DropZone/DropZone.js +59 -54
- package/dist/components/preact/DropZone/DropZone.test.d.ts +1 -0
- package/dist/components/preact/DropZone/index.d.ts +3 -3
- package/dist/components/preact/DropZone/index.js +29 -27
- package/dist/components/preact/DropZone/useDropDeploy.d.ts +9 -6
- package/dist/components/preact/DropZone/useDropDeploy.js +182 -131
- package/dist/components/preact/DropZone/useDropDeploy.test.d.ts +1 -0
- package/dist/components/preact/index.d.ts +2 -2
- package/dist/components/preact/index.js +24 -18
- package/dist/drop-core/analytics.d.ts +107 -0
- package/dist/drop-core/analytics.js +21 -0
- package/dist/drop-core/analytics.test.d.ts +1 -0
- package/dist/drop-core/authedApi.d.ts +45 -0
- package/dist/drop-core/authedApi.js +30 -0
- package/dist/drop-core/authedApi.test.d.ts +1 -0
- package/dist/{components/preact/DropZone/drop-core → drop-core}/authedDropClient.d.ts +4 -11
- package/dist/drop-core/authedDropClient.js +100 -0
- package/dist/drop-core/authedDropClient.test.d.ts +1 -0
- package/dist/{components/preact/DropZone/drop-core → drop-core}/buildClient.d.ts +5 -13
- package/dist/drop-core/buildClient.js +85 -0
- package/dist/drop-core/buildClient.test.d.ts +1 -0
- package/dist/drop-core/buildStash.d.ts +37 -0
- package/dist/drop-core/buildStash.js +84 -0
- package/dist/drop-core/buildStash.test.d.ts +0 -0
- package/dist/{components/preact/DropZone/drop-core → drop-core}/client.d.ts +3 -1
- package/dist/drop-core/client.js +47 -0
- package/dist/drop-core/constants.d.ts +29 -0
- package/dist/drop-core/constants.js +10 -0
- package/dist/{components/preact/DropZone/drop-core → drop-core}/deploy.d.ts +9 -0
- package/dist/drop-core/deploy.js +66 -0
- package/dist/drop-core/deploy.test.d.ts +1 -0
- package/dist/drop-core/detectBuild.test.d.ts +1 -0
- package/dist/drop-core/digest.test.d.ts +1 -0
- package/dist/{components/preact/DropZone/drop-core → drop-core}/errors.d.ts +29 -0
- package/dist/drop-core/errors.js +119 -0
- package/dist/drop-core/errors.test.d.ts +1 -0
- package/dist/drop-core/fileTypes.test.d.ts +1 -0
- package/dist/drop-core/index.d.ts +25 -0
- package/dist/drop-core/index.js +47 -0
- package/dist/{components/preact/DropZone/drop-core → drop-core}/readFiles.js +2 -2
- package/dist/drop-core/readFiles.test.d.ts +1 -0
- package/dist/{components/preact/DropZone/drop-core → drop-core}/types.d.ts +10 -0
- package/dist/{components/preact/DropZone/drop-core → drop-core}/zip.js +1 -1
- package/dist/drop-core/zip.test.d.ts +1 -0
- package/package.json +4 -1
- package/packages/components/index.ts +19 -0
- package/packages/components/preact/DropZone/DropZone.test.ts +71 -0
- package/packages/components/preact/DropZone/DropZone.tsx +29 -9
- package/packages/components/preact/DropZone/index.tsx +14 -3
- package/packages/components/preact/DropZone/useDropDeploy.test.ts +376 -0
- package/packages/components/preact/DropZone/useDropDeploy.ts +301 -145
- package/packages/components/preact/index.ts +19 -2
- package/packages/drop-core/README.md +91 -0
- package/packages/drop-core/analytics.test.ts +17 -0
- package/packages/drop-core/analytics.ts +122 -0
- package/packages/drop-core/authedApi.test.ts +100 -0
- package/packages/drop-core/authedApi.ts +81 -0
- package/packages/drop-core/authedDropClient.test.ts +196 -0
- package/packages/{components/preact/DropZone/drop-core → drop-core}/authedDropClient.ts +30 -72
- package/packages/drop-core/buildClient.test.ts +112 -0
- package/packages/{components/preact/DropZone/drop-core → drop-core}/buildClient.ts +27 -57
- package/packages/drop-core/buildStash.test.ts +85 -0
- package/packages/drop-core/buildStash.ts +185 -0
- package/packages/{components/preact/DropZone/drop-core → drop-core}/client.ts +18 -6
- package/packages/drop-core/constants.ts +39 -0
- package/packages/drop-core/deploy.test.ts +177 -0
- package/packages/{components/preact/DropZone/drop-core → drop-core}/deploy.ts +59 -9
- package/packages/drop-core/detectBuild.test.ts +139 -0
- package/packages/drop-core/digest.test.ts +24 -0
- package/packages/drop-core/errors.test.ts +98 -0
- package/packages/{components/preact/DropZone/drop-core → drop-core}/errors.ts +68 -11
- package/packages/drop-core/fileTypes.test.ts +37 -0
- package/packages/drop-core/index.ts +44 -0
- package/packages/drop-core/readFiles.test.ts +124 -0
- package/packages/{components/preact/DropZone/drop-core → drop-core}/types.ts +12 -6
- package/packages/drop-core/zip.test.ts +92 -0
- package/dist/components/preact/DropZone/drop-core/authedDropClient.js +0 -123
- package/dist/components/preact/DropZone/drop-core/buildClient.js +0 -99
- package/dist/components/preact/DropZone/drop-core/client.js +0 -45
- package/dist/components/preact/DropZone/drop-core/deploy.js +0 -48
- package/dist/components/preact/DropZone/drop-core/errors.js +0 -82
- package/dist/components/preact/DropZone/drop-core/index.d.ts +0 -11
- package/dist/components/preact/DropZone/drop-core/index.js +0 -36
- package/packages/components/preact/DropZone/drop-core/index.ts +0 -11
- /package/dist/{components/preact/DropZone/drop-core → drop-core}/detectBuild.d.ts +0 -0
- /package/dist/{components/preact/DropZone/drop-core → drop-core}/detectBuild.js +0 -0
- /package/dist/{components/preact/DropZone/drop-core → drop-core}/digest.d.ts +0 -0
- /package/dist/{components/preact/DropZone/drop-core → drop-core}/digest.js +0 -0
- /package/dist/{components/preact/DropZone/drop-core → drop-core}/fileTypes.d.ts +0 -0
- /package/dist/{components/preact/DropZone/drop-core → drop-core}/fileTypes.js +0 -0
- /package/dist/{components/preact/DropZone/drop-core → drop-core}/readFiles.d.ts +0 -0
- /package/dist/{components/preact/DropZone/drop-core → drop-core}/types.js +0 -0
- /package/dist/{components/preact/DropZone/drop-core → drop-core}/zip.d.ts +0 -0
- /package/packages/{components/preact/DropZone/drop-core → drop-core}/detectBuild.ts +0 -0
- /package/packages/{components/preact/DropZone/drop-core → drop-core}/digest.ts +0 -0
- /package/packages/{components/preact/DropZone/drop-core → drop-core}/fileTypes.ts +0 -0
- /package/packages/{components/preact/DropZone/drop-core → drop-core}/readFiles.ts +0 -0
- /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
|
|
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 =
|
|
102
|
-
apiBase =
|
|
84
|
+
proxyBase = DEFAULT_PROXY_BASE,
|
|
85
|
+
apiBase = DEFAULT_API_BASE,
|
|
103
86
|
getUploadToken,
|
|
104
87
|
accountSlug,
|
|
105
|
-
pollIntervalMs =
|
|
106
|
-
timeoutMs =
|
|
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
|
|
118
|
-
* logged-out visitor fail cleanly and fall back to the anonymous flow.
|
|
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
|
|
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
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
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 =
|
|
105
|
-
apiBase =
|
|
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 =
|
|
110
|
-
timeoutMs =
|
|
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.
|
|
123
|
-
*
|
|
124
|
-
*
|
|
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
|
-
|
|
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
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
+
}
|