@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.
- package/dist/components/index.d.ts +2 -2
- package/dist/components/preact/DropZone/DropZone.d.ts +21 -2
- 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 +25 -23
- package/dist/components/preact/DropZone/useDropDeploy.d.ts +10 -6
- package/dist/components/preact/DropZone/useDropDeploy.js +184 -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 +25 -19
- 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 +55 -0
- package/dist/drop-core/authedApi.js +35 -0
- package/dist/drop-core/authedApi.test.d.ts +1 -0
- package/dist/drop-core/authedDropClient.js +109 -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 +84 -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}/deploy.d.ts +7 -0
- package/dist/drop-core/deploy.js +53 -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.js +1 -1
- package/dist/drop-core/errors.test.d.ts +1 -0
- package/dist/drop-core/fileTypes.test.d.ts +1 -0
- package/dist/{components/preact/DropZone/drop-core → drop-core}/index.d.ts +3 -0
- package/dist/drop-core/index.js +51 -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 +23 -2
- 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 +297 -132
- package/packages/components/preact/index.ts +19 -2
- 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 +94 -0
- package/packages/drop-core/authedDropClient.test.ts +196 -0
- package/packages/{components/preact/DropZone/drop-core → drop-core}/authedDropClient.ts +30 -51
- package/packages/drop-core/buildClient.test.ts +112 -0
- package/packages/{components/preact/DropZone/drop-core → drop-core}/buildClient.ts +28 -57
- package/packages/drop-core/buildStash.test.ts +85 -0
- package/packages/drop-core/buildStash.ts +185 -0
- package/packages/drop-core/deploy.test.ts +135 -0
- package/packages/{components/preact/DropZone/drop-core → drop-core}/deploy.ts +18 -4
- 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 +69 -0
- package/packages/{components/preact/DropZone/drop-core → drop-core}/errors.ts +1 -1
- package/packages/drop-core/fileTypes.test.ts +37 -0
- package/packages/{components/preact/DropZone/drop-core → drop-core}/index.ts +3 -0
- package/packages/drop-core/readFiles.test.ts +125 -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/deploy.js +0 -48
- package/dist/components/preact/DropZone/drop-core/index.js +0 -36
- /package/dist/{components/preact/DropZone/drop-core → drop-core}/authedDropClient.d.ts +0 -0
- /package/dist/{components/preact/DropZone/drop-core → drop-core}/client.d.ts +0 -0
- /package/dist/{components/preact/DropZone/drop-core → drop-core}/client.js +0 -0
- /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}/errors.d.ts +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}/client.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
|
@@ -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
|
-
|
|
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 =
|
|
105
|
-
apiBase =
|
|
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 =
|
|
110
|
-
timeoutMs =
|
|
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.
|
|
123
|
-
*
|
|
124
|
-
*
|
|
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
|
-
|
|
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
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
|
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();
|