@netlify/spark-ui 1.31.0-alpha.2 → 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 +28 -2
- package/dist/components/preact/DropZone/DropZone.js +58 -51
- 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 +11 -6
- package/dist/components/preact/DropZone/useDropDeploy.js +183 -129
- 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.d.ts +100 -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 +32 -26
- 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 +32 -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 +305 -134
- 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/drop-core/authedDropClient.ts +210 -0
- package/packages/drop-core/buildClient.test.ts +112 -0
- package/packages/{components/preact/DropZone/drop-core → drop-core}/buildClient.ts +55 -70
- 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.d.ts +0 -77
- package/dist/components/preact/DropZone/drop-core/authedDropClient.js +0 -97
- package/dist/components/preact/DropZone/drop-core/buildClient.js +0 -92
- package/dist/components/preact/DropZone/drop-core/deploy.js +0 -48
- package/dist/components/preact/DropZone/drop-core/index.js +0 -36
- package/packages/components/preact/DropZone/drop-core/authedDropClient.ts +0 -185
- /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,210 @@
|
|
|
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';
|
|
9
|
+
import { apiError, type DropClient } from './client';
|
|
10
|
+
import { NotAuthenticatedError } from './errors';
|
|
11
|
+
import type { BuildSite, Digest, DropResponse } from './types';
|
|
12
|
+
|
|
13
|
+
export interface AuthenticatedDropClientConfig {
|
|
14
|
+
/**
|
|
15
|
+
* Base for JSON API calls, routed through the consumer's access-control
|
|
16
|
+
* rewrite so the session cookie authenticates them. On netlify.com that
|
|
17
|
+
* rewrite is `/access-control/*` → the app's access-control function, which
|
|
18
|
+
* makes `/access-control/bb-api/api/v1` the API root (the default here).
|
|
19
|
+
*/
|
|
20
|
+
proxyBase?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Direct API base for the file uploads. The proxy above runs in a Lambda with
|
|
23
|
+
* a ~6MB request body limit, so file PUTs go straight to `api.netlify.com`
|
|
24
|
+
* (whose `/api/*` CORS policy allows any origin).
|
|
25
|
+
*/
|
|
26
|
+
apiBase?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Mint a bearer token for those direct uploads. On netlify.com this is
|
|
29
|
+
* `GET /access-control/generate-access-control-token`, whose
|
|
30
|
+
* `accessControlToken` the API decrypts back into the user's access token.
|
|
31
|
+
* Resolving `null` means "no session" and surfaces as `NotAuthenticatedError`.
|
|
32
|
+
*
|
|
33
|
+
* Called repeatedly, not once: the token issued to a non-app origin lives
|
|
34
|
+
* only 30 seconds, which a multi-file upload routinely outlasts.
|
|
35
|
+
*/
|
|
36
|
+
getUploadToken: () => Promise<string | null>;
|
|
37
|
+
/** Create the site in this account. Omit to use the user's default account. */
|
|
38
|
+
accountSlug?: string;
|
|
39
|
+
/** How often to re-read the deploy while waiting. Default 1s. */
|
|
40
|
+
pollIntervalMs?: number;
|
|
41
|
+
/** How long to wait before giving up on the deploy. Default 10 minutes. */
|
|
42
|
+
timeoutMs?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* How long a minted upload token is reused before minting another. The server
|
|
47
|
+
* issues non-app origins a 30s token, so this leaves a margin for the request
|
|
48
|
+
* it authorizes to finish.
|
|
49
|
+
*/
|
|
50
|
+
const TOKEN_REUSE_MS = 20 * 1000;
|
|
51
|
+
|
|
52
|
+
/** Response body of `POST /sites/{id}/deploys` — the slice the drop flow needs. */
|
|
53
|
+
interface SiteDeployResponse {
|
|
54
|
+
/** deploy id (BSON) */
|
|
55
|
+
id: string;
|
|
56
|
+
/** SHAs the backend does NOT yet have — only these get uploaded */
|
|
57
|
+
required?: string[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Authenticated drop client for logged-in visitors. Unlike `AnonymousDropClient`
|
|
62
|
+
* — which creates an account-less site the visitor must later claim — this
|
|
63
|
+
* deploys straight into the visitor's account via the sites/deploys API,
|
|
64
|
+
* mirroring what app.netlify.com does for a logged-in drag-and-drop deploy.
|
|
65
|
+
* There is no claim step, so the caller should hand off to the site's dashboard
|
|
66
|
+
* page rather than the claim page.
|
|
67
|
+
*
|
|
68
|
+
* Requests split across two hosts by necessity. JSON calls (create the site,
|
|
69
|
+
* create the deploy, poll it) go through the consumer's access-control proxy on
|
|
70
|
+
* the session cookie — no token involved, and nothing that can expire mid-deploy.
|
|
71
|
+
* File uploads can't: the proxy caps bodies at ~6MB, so they go direct to
|
|
72
|
+
* `api.netlify.com` with a bearer, re-minted as it ages (see `TOKEN_REUSE_MS`).
|
|
73
|
+
*
|
|
74
|
+
* Error-mapping invariant: only pre-mutation failures — a null upload token at
|
|
75
|
+
* the gate, or a 401/403 creating the site — throw `NotAuthenticatedError`,
|
|
76
|
+
* which is the signal the drop flow may safely retry anonymously. Once the site
|
|
77
|
+
* exists, any auth failure throws a plain error instead: falling back at that
|
|
78
|
+
* point would strand an empty site in the account *and* deploy a duplicate
|
|
79
|
+
* anonymously.
|
|
80
|
+
*/
|
|
81
|
+
export class AuthenticatedDropClient implements DropClient {
|
|
82
|
+
private proxyBase: string;
|
|
83
|
+
private apiBase: string;
|
|
84
|
+
private getUploadToken: () => Promise<string | null>;
|
|
85
|
+
private accountSlug?: string;
|
|
86
|
+
private pollIntervalMs: number;
|
|
87
|
+
private timeoutMs: number;
|
|
88
|
+
private cachedToken?: { value: string; mintedAt: number };
|
|
89
|
+
|
|
90
|
+
constructor({
|
|
91
|
+
proxyBase = DEFAULT_PROXY_BASE,
|
|
92
|
+
apiBase = DEFAULT_API_BASE,
|
|
93
|
+
getUploadToken,
|
|
94
|
+
accountSlug,
|
|
95
|
+
pollIntervalMs = DEFAULT_POLL_INTERVAL_MS,
|
|
96
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
97
|
+
}: AuthenticatedDropClientConfig) {
|
|
98
|
+
this.proxyBase = proxyBase;
|
|
99
|
+
this.apiBase = apiBase;
|
|
100
|
+
this.getUploadToken = getUploadToken;
|
|
101
|
+
this.accountSlug = accountSlug;
|
|
102
|
+
this.pollIntervalMs = pollIntervalMs;
|
|
103
|
+
this.timeoutMs = timeoutMs;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The auth gate. Minting here — before anything is created — is what lets a
|
|
108
|
+
* logged-out visitor fail cleanly and fall back to the anonymous flow. The
|
|
109
|
+
* returned token is threaded through `deployFiles`, but only the uploads
|
|
110
|
+
* actually need one, and they re-mint rather than trusting this to still be
|
|
111
|
+
* valid by the time they run.
|
|
112
|
+
*/
|
|
113
|
+
async getToken(): Promise<string> {
|
|
114
|
+
const token = await this.getUploadToken();
|
|
115
|
+
if (!token) throw new NotAuthenticatedError();
|
|
116
|
+
this.cachedToken = { value: token, mintedAt: Date.now() };
|
|
117
|
+
return token;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* A token young enough to authorize an upload, minting a new one once the
|
|
122
|
+
* cached one nears its 30s expiry.
|
|
123
|
+
*
|
|
124
|
+
* Deliberately NOT `NotAuthenticatedError` when minting fails: by the time
|
|
125
|
+
* uploads run the site already exists, and that error would send the drop
|
|
126
|
+
* back through the anonymous path — leaving an empty project behind and
|
|
127
|
+
* deploying a duplicate. See the class doc.
|
|
128
|
+
*/
|
|
129
|
+
private async freshToken(): Promise<string> {
|
|
130
|
+
const now = Date.now();
|
|
131
|
+
if (this.cachedToken && now - this.cachedToken.mintedAt < TOKEN_REUSE_MS) {
|
|
132
|
+
return this.cachedToken.value;
|
|
133
|
+
}
|
|
134
|
+
const value = await this.getUploadToken();
|
|
135
|
+
if (!value) throw new Error('could not renew the upload token');
|
|
136
|
+
this.cachedToken = { value, mintedAt: now };
|
|
137
|
+
return value;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Two proxied calls: create the site in the visitor's account, then the
|
|
142
|
+
* deploy on it. Mapped into the anonymous `DropResponse` shape so
|
|
143
|
+
* `deployFiles` runs unchanged over either client.
|
|
144
|
+
*/
|
|
145
|
+
async createDeploy(files: Digest, _token: string): Promise<DropResponse> {
|
|
146
|
+
// A logged-out visitor fails inside createSiteInAccount, before anything is
|
|
147
|
+
// created — the one point an anonymous fallback is still safe (class doc).
|
|
148
|
+
const site: BuildSite = await createSiteInAccount(this.proxyBase, this.accountSlug);
|
|
149
|
+
|
|
150
|
+
const deployRes = await fetch(`${this.proxyBase}/sites/${site.id}/deploys`, {
|
|
151
|
+
method: 'POST',
|
|
152
|
+
credentials: 'include',
|
|
153
|
+
headers: { 'Content-Type': 'application/json' },
|
|
154
|
+
body: JSON.stringify({ files, deploy_source: 'drop' }),
|
|
155
|
+
});
|
|
156
|
+
if (!deployRes.ok) throw apiError('deploy create', deployRes.status);
|
|
157
|
+
const deploy: SiteDeployResponse = await deployRes.json();
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
id: site.id,
|
|
161
|
+
deploy_id: deploy.id,
|
|
162
|
+
subdomain: site.name,
|
|
163
|
+
required: deploy.required ?? [],
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Direct to `api.netlify.com` on a freshly-aged bearer — the threaded `token`
|
|
169
|
+
* is ignored because an upload queue easily outlives the 30 seconds it was
|
|
170
|
+
* minted with. PUT (not POST) so paths with "&" etc. work.
|
|
171
|
+
*/
|
|
172
|
+
async uploadFile(
|
|
173
|
+
deployId: string,
|
|
174
|
+
path: string,
|
|
175
|
+
content: ArrayBuffer,
|
|
176
|
+
_token: string
|
|
177
|
+
): Promise<void> {
|
|
178
|
+
const token = await this.freshToken();
|
|
179
|
+
const res = await fetch(`${this.apiBase}/deploys/${deployId}/files${path}`, {
|
|
180
|
+
method: 'PUT',
|
|
181
|
+
headers: { 'Content-Type': 'application/octet-stream', Authorization: `Bearer ${token}` },
|
|
182
|
+
body: content,
|
|
183
|
+
});
|
|
184
|
+
if (!res.ok) throw apiError(`upload ${path}`, res.status);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Poll the deploy through the proxy until it settles: resolve on `ready`,
|
|
189
|
+
* throw on `error`/`rejected`, throw on timeout. On the session cookie rather
|
|
190
|
+
* than a bearer, so a deploy taking minutes can't outlive its own credentials.
|
|
191
|
+
*/
|
|
192
|
+
async waitUntilReady(deploy: DropResponse, _token: string): Promise<void> {
|
|
193
|
+
const settled = await pollDeployUntilSettled({
|
|
194
|
+
proxyBase: this.proxyBase,
|
|
195
|
+
deployId: deploy.deploy_id,
|
|
196
|
+
pollIntervalMs: this.pollIntervalMs,
|
|
197
|
+
timeoutMs: this.timeoutMs,
|
|
198
|
+
});
|
|
199
|
+
if (settled.outcome === 'ready') return;
|
|
200
|
+
// Plain errors on purpose: humanizeDropError maps them, and nothing here
|
|
201
|
+
// may look like NotAuthenticatedError once the site exists (class doc).
|
|
202
|
+
if (settled.outcome === 'failed') {
|
|
203
|
+
throw new Error(
|
|
204
|
+
settled.errorMessage ? `deploy failed: ${settled.errorMessage}` : 'deploy failed'
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
// Same message the anonymous client uses, so humanizeDropError maps it.
|
|
208
|
+
throw new Error('Deploy did not become ready in time');
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -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 {
|
|
@@ -33,20 +32,27 @@ export interface BuildClient {
|
|
|
33
32
|
|
|
34
33
|
export interface AuthenticatedBuildClientConfig {
|
|
35
34
|
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* access-control
|
|
39
|
-
*
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
35
|
+
* Base for JSON API calls, routed through the consumer's access-control
|
|
36
|
+
* rewrite so the session cookie authenticates them. On netlify.com that
|
|
37
|
+
* rewrite is `/access-control/*` → the app's access-control function, which
|
|
38
|
+
* makes `/access-control/bb-api/api/v1` the API root (the default here).
|
|
39
|
+
*/
|
|
40
|
+
proxyBase?: string;
|
|
41
|
+
/**
|
|
42
|
+
* Direct API base for the zip upload. The proxy above runs in a Lambda with a
|
|
43
|
+
* ~6MB request body limit, so the multipart POST has to go straight to
|
|
44
|
+
* `api.netlify.com` (whose `/api/*` CORS policy allows any origin).
|
|
43
45
|
*/
|
|
44
46
|
apiBase?: string;
|
|
45
47
|
/**
|
|
46
|
-
* Mint a
|
|
47
|
-
*
|
|
48
|
+
* Mint a bearer token for that direct upload. On netlify.com this is
|
|
49
|
+
* `GET /access-control/generate-access-control-token`, whose
|
|
48
50
|
* `accessControlToken` the API decrypts back into the user's access token.
|
|
49
51
|
* Resolving `null` means "no session" and surfaces as `NotAuthenticatedError`.
|
|
52
|
+
*
|
|
53
|
+
* Note the token issued to a non-app origin lives only 30 seconds. It is
|
|
54
|
+
* minted immediately before the upload, but a zip that takes longer than that
|
|
55
|
+
* to transfer will still be rejected mid-flight — see `createBuildFromZip`.
|
|
50
56
|
*/
|
|
51
57
|
getUploadToken: () => Promise<string | null>;
|
|
52
58
|
/** Create the site in this account. Omit to use the user's default account. */
|
|
@@ -59,18 +65,6 @@ export interface AuthenticatedBuildClientConfig {
|
|
|
59
65
|
timeoutMs?: number;
|
|
60
66
|
}
|
|
61
67
|
|
|
62
|
-
const API_BASE = 'https://api.netlify.com/api/v1';
|
|
63
|
-
const POLL_INTERVAL_MS = 1000;
|
|
64
|
-
const TIMEOUT_MS = 10 * 60 * 1000;
|
|
65
|
-
|
|
66
|
-
/** Deploy states that end the wait — `ready` succeeds, the rest are failures. */
|
|
67
|
-
const TERMINAL_STATES = new Set(['ready', 'error', 'rejected']);
|
|
68
|
-
|
|
69
|
-
interface DeployState {
|
|
70
|
-
state?: string;
|
|
71
|
-
error_message?: string;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
68
|
/**
|
|
75
69
|
* Authenticated drop client for projects that need a build. Unlike
|
|
76
70
|
* `AnonymousDropClient` — which creates an account-less static site and can only
|
|
@@ -84,6 +78,7 @@ interface DeployState {
|
|
|
84
78
|
* short-lived bearer token because the proxy can't carry a body that large.
|
|
85
79
|
*/
|
|
86
80
|
export class AuthenticatedBuildClient implements BuildClient {
|
|
81
|
+
private proxyBase: string;
|
|
87
82
|
private apiBase: string;
|
|
88
83
|
private getUploadToken: () => Promise<string | null>;
|
|
89
84
|
private accountSlug?: string;
|
|
@@ -92,13 +87,15 @@ export class AuthenticatedBuildClient implements BuildClient {
|
|
|
92
87
|
private timeoutMs: number;
|
|
93
88
|
|
|
94
89
|
constructor({
|
|
95
|
-
|
|
90
|
+
proxyBase = DEFAULT_PROXY_BASE,
|
|
91
|
+
apiBase = DEFAULT_API_BASE,
|
|
96
92
|
getUploadToken,
|
|
97
93
|
accountSlug,
|
|
98
94
|
title = 'Build from drop deployment',
|
|
99
|
-
pollIntervalMs =
|
|
100
|
-
timeoutMs =
|
|
95
|
+
pollIntervalMs = DEFAULT_POLL_INTERVAL_MS,
|
|
96
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
101
97
|
}: AuthenticatedBuildClientConfig) {
|
|
98
|
+
this.proxyBase = proxyBase;
|
|
102
99
|
this.apiBase = apiBase;
|
|
103
100
|
this.getUploadToken = getUploadToken;
|
|
104
101
|
this.accountSlug = accountSlug;
|
|
@@ -108,24 +105,12 @@ export class AuthenticatedBuildClient implements BuildClient {
|
|
|
108
105
|
}
|
|
109
106
|
|
|
110
107
|
/**
|
|
111
|
-
* Create the site the build deploys into.
|
|
112
|
-
*
|
|
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.
|
|
113
111
|
*/
|
|
114
112
|
async createSite(): Promise<BuildSite> {
|
|
115
|
-
|
|
116
|
-
if (!token) throw new NotAuthenticatedError();
|
|
117
|
-
|
|
118
|
-
const path = this.accountSlug ? `/${this.accountSlug}/sites` : '/sites';
|
|
119
|
-
const res = await fetch(`${this.apiBase}${path}`, {
|
|
120
|
-
method: 'POST',
|
|
121
|
-
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
122
|
-
body: JSON.stringify({ created_via: 'drop' }),
|
|
123
|
-
});
|
|
124
|
-
// A logged-out visitor gets a 401 here rather than at the token step, since
|
|
125
|
-
// this is the first call that needs the session.
|
|
126
|
-
if (res.status === 401 || res.status === 403) throw new NotAuthenticatedError();
|
|
127
|
-
if (!res.ok) throw new SiteCreateError(res.status);
|
|
128
|
-
return res.json();
|
|
113
|
+
return createSiteInAccount(this.proxyBase, this.accountSlug);
|
|
129
114
|
}
|
|
130
115
|
|
|
131
116
|
/**
|
|
@@ -133,6 +118,11 @@ export class AuthenticatedBuildClient implements BuildClient {
|
|
|
133
118
|
* (see the note on `apiBase`), authorized by a freshly minted bearer token.
|
|
134
119
|
* `Content-Type` is left unset on purpose so the browser adds the multipart
|
|
135
120
|
* boundary itself.
|
|
121
|
+
*
|
|
122
|
+
* Known ceiling: this is one request on a 30s token, and re-minting can't
|
|
123
|
+
* help once it is in flight. A zip that takes longer than that to upload will
|
|
124
|
+
* fail — the app origin gets 300s for exactly this reason, and lifting the
|
|
125
|
+
* limit for other origins is a platform-side change.
|
|
136
126
|
*/
|
|
137
127
|
async createBuildFromZip(siteId: string, zip: File): Promise<BuildResponse> {
|
|
138
128
|
const token = await this.getUploadToken();
|
|
@@ -159,29 +149,24 @@ export class AuthenticatedBuildClient implements BuildClient {
|
|
|
159
149
|
* deploy id yet has nothing pollable, so it resolves immediately and the
|
|
160
150
|
* caller falls back to the site page.
|
|
161
151
|
*
|
|
162
|
-
* Optional in the drop flow, and off by default: a build takes minutes,
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
* a
|
|
152
|
+
* Optional in the drop flow, and off by default: a build takes minutes, so
|
|
153
|
+
* the useful hand-off is usually the project page rather than a spinner.
|
|
154
|
+
* Polling rides the session cookie, so it cannot outlive its credentials the
|
|
155
|
+
* way a 30s bearer would.
|
|
166
156
|
*/
|
|
167
157
|
async waitUntilBuildReady(build: BuildResponse): Promise<void> {
|
|
168
158
|
if (!build.deploy_id) return;
|
|
169
159
|
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
throw new BuildFailedError(deploy.error_message);
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
await new Promise(r => setTimeout(r, this.pollIntervalMs));
|
|
184
|
-
}
|
|
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);
|
|
185
170
|
throw new BuildTimeoutError();
|
|
186
171
|
}
|
|
187
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
|
+
});
|