@netlify/spark-ui 1.31.0-alpha.5 → 1.31.0-alpha.7
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/README.md +3 -4
- package/dist/components/preact/DropZone/DropZone.d.ts +23 -97
- package/dist/components/preact/DropZone/useDropDeploy.js +110 -105
- package/dist/drop-core/analytics.d.ts +4 -15
- package/dist/drop-core/authedApi.d.ts +9 -22
- package/dist/drop-core/authedDropClient.d.ts +13 -55
- package/dist/drop-core/authedDropClient.js +5 -18
- package/dist/drop-core/buildClient.d.ts +19 -53
- package/dist/drop-core/buildClient.js +27 -40
- package/dist/drop-core/buildStash.d.ts +6 -20
- package/dist/drop-core/buildStash.js +36 -24
- package/dist/drop-core/client.d.ts +1 -1
- package/dist/drop-core/constants.d.ts +1 -21
- package/dist/drop-core/deploy.d.ts +5 -17
- package/dist/drop-core/detectBuild.d.ts +8 -26
- package/dist/drop-core/errors.d.ts +8 -27
- package/dist/drop-core/fileTypes.d.ts +4 -14
- package/dist/drop-core/readFiles.d.ts +5 -11
- package/dist/drop-core/types.d.ts +2 -4
- package/dist/drop-core/zip.d.ts +7 -30
- package/package.json +1 -1
- package/packages/components/preact/DropZone/DropZone.tsx +27 -121
- package/packages/components/preact/DropZone/useDropDeploy.test.ts +12 -0
- package/packages/components/preact/DropZone/useDropDeploy.ts +39 -60
- package/packages/drop-core/README.md +311 -39
- package/packages/drop-core/analytics.ts +4 -15
- package/packages/drop-core/authedApi.ts +9 -22
- package/packages/drop-core/authedDropClient.ts +13 -55
- package/packages/drop-core/buildClient.test.ts +19 -4
- package/packages/drop-core/buildClient.ts +21 -63
- package/packages/drop-core/buildStash.test.ts +19 -0
- package/packages/drop-core/buildStash.ts +38 -29
- package/packages/drop-core/client.ts +6 -10
- package/packages/drop-core/constants.ts +8 -22
- package/packages/drop-core/deploy.ts +6 -22
- package/packages/drop-core/detectBuild.ts +15 -50
- package/packages/drop-core/errors.test.ts +3 -1
- package/packages/drop-core/errors.ts +9 -30
- package/packages/drop-core/fileTypes.ts +5 -19
- package/packages/drop-core/readFiles.ts +5 -12
- package/packages/drop-core/types.ts +2 -4
- package/packages/drop-core/zip.ts +7 -30
|
@@ -2,22 +2,15 @@ import type { BuildSite } from './types';
|
|
|
2
2
|
import { NotAuthenticatedError, SiteCreateError } from './errors';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* (Uploads are the exception — the proxy's Lambda caps request bodies at
|
|
9
|
-
* ~6MB — and each client handles its own, bearer-authorized.)
|
|
5
|
+
* Transport shared by both authenticated clients: JSON rides the consumer's
|
|
6
|
+
* access-control rewrite on the session cookie. Uploads are each client's own
|
|
7
|
+
* (bearer-direct — the proxy can't carry them).
|
|
10
8
|
*/
|
|
11
9
|
|
|
12
10
|
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* A logged-out visitor 401s here, before anything is created — the one
|
|
18
|
-
* pre-mutation point where callers may still safely fall back to an anonymous
|
|
19
|
-
* flow, which is why it maps to `NotAuthenticatedError`. Any other failure is
|
|
20
|
-
* a `SiteCreateError` carrying the status.
|
|
11
|
+
* `POST {proxyBase}/sites`, attributed `created_via: 'drop'`. A logged-out
|
|
12
|
+
* visitor 401s here, before anything is created — the one point an anonymous
|
|
13
|
+
* fallback is still safe, hence NotAuthenticatedError.
|
|
21
14
|
*/
|
|
22
15
|
export async function createSiteInAccount(
|
|
23
16
|
proxyBase: string,
|
|
@@ -48,15 +41,9 @@ interface DeployState {
|
|
|
48
41
|
}
|
|
49
42
|
|
|
50
43
|
/**
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
* errors `humanizeDropError` understands; the build client throws
|
|
55
|
-
* `BuildFailedError`/`BuildTimeoutError`), so that knowledge stays next to the
|
|
56
|
-
* class it belongs to.
|
|
57
|
-
*
|
|
58
|
-
* A non-ok poll response is skipped, not fatal — a transient proxy hiccup
|
|
59
|
-
* shouldn't kill a deploy that is still progressing.
|
|
44
|
+
* Polls the deploy on the session cookie and reports the terminal outcome —
|
|
45
|
+
* callers map it onto their own error vocabulary. Non-ok reads are skipped,
|
|
46
|
+
* not fatal: a proxy hiccup shouldn't kill a progressing deploy.
|
|
60
47
|
*/
|
|
61
48
|
export async function pollDeployUntilSettled(options: {
|
|
62
49
|
proxyBase: string;
|
|
@@ -11,27 +11,13 @@ import { DeployFailedError, DeployTimeoutError, NotAuthenticatedError } from './
|
|
|
11
11
|
import type { BuildSite, Digest, DropResponse } from './types';
|
|
12
12
|
|
|
13
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
|
-
*/
|
|
14
|
+
/** Root for session-cookie JSON calls, via the consumer's access-control rewrite. */
|
|
20
15
|
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
|
-
*/
|
|
16
|
+
/** Direct base for file PUTs — the proxy's ~6MB Lambda body limit can't carry uploads. */
|
|
26
17
|
apiBase?: string;
|
|
27
18
|
/**
|
|
28
|
-
* Mint
|
|
29
|
-
*
|
|
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.
|
|
19
|
+
* Mint the upload bearer; `null` means "no session". Called repeatedly —
|
|
20
|
+
* non-app origins get 30-second tokens, which an upload queue outlasts.
|
|
35
21
|
*/
|
|
36
22
|
getUploadToken: () => Promise<string | null>;
|
|
37
23
|
/** Create the site in this account. Omit to use the user's default account. */
|
|
@@ -51,25 +37,10 @@ interface SiteDeployResponse {
|
|
|
51
37
|
}
|
|
52
38
|
|
|
53
39
|
/**
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
* There is no claim step, so the caller should hand off to the site's dashboard
|
|
59
|
-
* page rather than the claim page.
|
|
60
|
-
*
|
|
61
|
-
* Requests split across two hosts by necessity. JSON calls (create the site,
|
|
62
|
-
* create the deploy, poll it) go through the consumer's access-control proxy on
|
|
63
|
-
* the session cookie — no token involved, and nothing that can expire mid-deploy.
|
|
64
|
-
* File uploads can't: the proxy caps bodies at ~6MB, so they go direct to
|
|
65
|
-
* `api.netlify.com` with a bearer, re-minted as it ages (see `TOKEN_REUSE_MS`).
|
|
66
|
-
*
|
|
67
|
-
* Error-mapping invariant: only pre-mutation failures — a null upload token at
|
|
68
|
-
* the gate, or a 401/403 creating the site — throw `NotAuthenticatedError`,
|
|
69
|
-
* which is the signal the drop flow may safely retry anonymously. Once the site
|
|
70
|
-
* exists, any auth failure throws a plain error instead: falling back at that
|
|
71
|
-
* point would strand an empty site in the account *and* deploy a duplicate
|
|
72
|
-
* anonymously.
|
|
40
|
+
* Deploys a static drop straight into a logged-in visitor's account — no claim
|
|
41
|
+
* step. JSON rides the cookie proxy; file PUTs go direct on a re-minted bearer.
|
|
42
|
+
* Invariant: NotAuthenticatedError only pre-mutation — after the site exists, a
|
|
43
|
+
* fallback would strand an empty site and deploy a duplicate.
|
|
73
44
|
*/
|
|
74
45
|
export class AuthenticatedDropClient implements DropClient {
|
|
75
46
|
private proxyBase: string;
|
|
@@ -108,9 +79,8 @@ export class AuthenticatedDropClient implements DropClient {
|
|
|
108
79
|
}
|
|
109
80
|
|
|
110
81
|
/**
|
|
111
|
-
* A token
|
|
112
|
-
*
|
|
113
|
-
* never `NotAuthenticatedError` — the site exists by now (class doc).
|
|
82
|
+
* A token younger than its 30s expiry. Mint failures here are plain Errors,
|
|
83
|
+
* never NotAuthenticatedError — the site exists by now (class doc).
|
|
114
84
|
*/
|
|
115
85
|
private async freshToken(): Promise<string> {
|
|
116
86
|
const now = Date.now();
|
|
@@ -123,11 +93,7 @@ export class AuthenticatedDropClient implements DropClient {
|
|
|
123
93
|
return value;
|
|
124
94
|
}
|
|
125
95
|
|
|
126
|
-
/**
|
|
127
|
-
* Two proxied calls: create the site in the visitor's account, then the
|
|
128
|
-
* deploy on it. Mapped into the anonymous `DropResponse` shape so
|
|
129
|
-
* `deployFiles` runs unchanged over either client.
|
|
130
|
-
*/
|
|
96
|
+
/** Site + deploy via the proxy, mapped into `DropResponse` so `deployFiles` runs unchanged. */
|
|
131
97
|
async createDeploy(files: Digest, _token: string): Promise<DropResponse> {
|
|
132
98
|
const site: BuildSite = await createSiteInAccount(this.proxyBase, this.accountSlug);
|
|
133
99
|
|
|
@@ -148,11 +114,7 @@ export class AuthenticatedDropClient implements DropClient {
|
|
|
148
114
|
};
|
|
149
115
|
}
|
|
150
116
|
|
|
151
|
-
/**
|
|
152
|
-
* Direct to `api.netlify.com` on a freshly-aged bearer — the threaded `token`
|
|
153
|
-
* is ignored because an upload queue easily outlives the 30 seconds it was
|
|
154
|
-
* minted with. PUT (not POST) so paths with "&" etc. work.
|
|
155
|
-
*/
|
|
117
|
+
/** Direct on a fresh bearer (the threaded one has expired). PUT so paths with "&" work. */
|
|
156
118
|
async uploadFile(
|
|
157
119
|
deployId: string,
|
|
158
120
|
path: string,
|
|
@@ -168,11 +130,7 @@ export class AuthenticatedDropClient implements DropClient {
|
|
|
168
130
|
if (!res.ok) throw apiError(`upload ${path}`, res.status);
|
|
169
131
|
}
|
|
170
132
|
|
|
171
|
-
/**
|
|
172
|
-
* Poll the deploy through the proxy until it settles: resolve on `ready`,
|
|
173
|
-
* throw on `error`/`rejected`, throw on timeout. On the session cookie rather
|
|
174
|
-
* than a bearer, so a deploy taking minutes can't outlive its own credentials.
|
|
175
|
-
*/
|
|
133
|
+
/** Polls on the session cookie, so a slow deploy can't outlive its own credentials. */
|
|
176
134
|
async waitUntilReady(deploy: DropResponse, _token: string): Promise<void> {
|
|
177
135
|
const settled = await pollDeployUntilSettled({
|
|
178
136
|
proxyBase: this.proxyBase,
|
|
@@ -56,10 +56,25 @@ describe('AuthenticatedBuildClient.createBuildFromZip', () => {
|
|
|
56
56
|
expect(body.get('title')).toBe('Build from drop deployment');
|
|
57
57
|
});
|
|
58
58
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
59
|
+
// The site exists by the time this method runs, so a NotAuthenticatedError
|
|
60
|
+
// here would read as "nothing was created" and send the drop back to the
|
|
61
|
+
// stash — leaving an empty project behind, and another on the next resume.
|
|
62
|
+
it('reports a missing upload token as BuildCreateError, never NotAuthenticatedError', async () => {
|
|
63
|
+
const failure = await client(async () => null)
|
|
64
|
+
.createBuildFromZip('s', zip)
|
|
65
|
+
.catch(e => e);
|
|
66
|
+
expect(failure).toBeInstanceOf(BuildCreateError);
|
|
67
|
+
expect(failure).not.toBeInstanceOf(NotAuthenticatedError);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('reports a 401 from an expired upload token as BuildCreateError', async () => {
|
|
71
|
+
vi.stubGlobal('fetch', async () => ({ ok: false, status: 401, json: async () => ({}) }));
|
|
72
|
+
const failure = await client()
|
|
73
|
+
.createBuildFromZip('s', zip)
|
|
74
|
+
.catch(e => e);
|
|
75
|
+
expect(failure).toBeInstanceOf(BuildCreateError);
|
|
76
|
+
expect(failure).not.toBeInstanceOf(NotAuthenticatedError);
|
|
77
|
+
expect(failure.status).toBe(401);
|
|
63
78
|
});
|
|
64
79
|
|
|
65
80
|
it('wraps other upload failures in BuildCreateError with the status', async () => {
|
|
@@ -5,19 +5,13 @@ import {
|
|
|
5
5
|
DEFAULT_PROXY_BASE,
|
|
6
6
|
DEFAULT_TIMEOUT_MS,
|
|
7
7
|
} from './constants';
|
|
8
|
-
import {
|
|
9
|
-
BuildCreateError,
|
|
10
|
-
BuildFailedError,
|
|
11
|
-
BuildTimeoutError,
|
|
12
|
-
NotAuthenticatedError,
|
|
13
|
-
} from './errors';
|
|
8
|
+
import { BuildCreateError, BuildFailedError, BuildTimeoutError } from './errors';
|
|
14
9
|
import type { BuildSite } from './types';
|
|
15
10
|
|
|
16
11
|
export type { BuildSite } from './types';
|
|
17
12
|
|
|
18
13
|
/** The subset of a Netlify Build returned by `POST /sites/{id}/builds`. */
|
|
19
14
|
export interface BuildResponse {
|
|
20
|
-
/** build id */
|
|
21
15
|
id: string;
|
|
22
16
|
/** the deploy the build produces — present as soon as the build is enqueued */
|
|
23
17
|
deploy_id?: string;
|
|
@@ -30,28 +24,14 @@ export interface BuildClient {
|
|
|
30
24
|
}
|
|
31
25
|
|
|
32
26
|
export interface AuthenticatedBuildClientConfig {
|
|
33
|
-
/**
|
|
34
|
-
* Base for JSON API calls, routed through the consumer's access-control
|
|
35
|
-
* rewrite so the session cookie authenticates them. On netlify.com that
|
|
36
|
-
* rewrite is `/access-control/*` → the app's access-control function, which
|
|
37
|
-
* makes `/access-control/bb-api/api/v1` the API root (the default here).
|
|
38
|
-
*/
|
|
27
|
+
/** Root for session-cookie JSON calls, via the consumer's access-control rewrite. */
|
|
39
28
|
proxyBase?: string;
|
|
40
|
-
/**
|
|
41
|
-
* Direct API base for the zip upload. The proxy above runs in a Lambda with a
|
|
42
|
-
* ~6MB request body limit, so the multipart POST has to go straight to
|
|
43
|
-
* `api.netlify.com` (whose `/api/*` CORS policy allows any origin).
|
|
44
|
-
*/
|
|
29
|
+
/** Direct base for the zip upload — the proxy's ~6MB Lambda body limit can't carry it. */
|
|
45
30
|
apiBase?: string;
|
|
46
31
|
/**
|
|
47
|
-
* Mint
|
|
48
|
-
*
|
|
49
|
-
* `
|
|
50
|
-
* Resolving `null` means "no session" and surfaces as `NotAuthenticatedError`.
|
|
51
|
-
*
|
|
52
|
-
* Note the token issued to a non-app origin lives only 30 seconds. It is
|
|
53
|
-
* minted immediately before the upload, but a zip that takes longer than that
|
|
54
|
-
* to transfer will still be rejected mid-flight — see `createBuildFromZip`.
|
|
32
|
+
* Mint the upload bearer; `null` means "no session". Non-app origins get
|
|
33
|
+
* 30-second tokens — a zip slower than that fails mid-flight (see
|
|
34
|
+
* `createBuildFromZip`).
|
|
55
35
|
*/
|
|
56
36
|
getUploadToken: () => Promise<string | null>;
|
|
57
37
|
/** Create the site in this account. Omit to use the user's default account. */
|
|
@@ -65,16 +45,9 @@ export interface AuthenticatedBuildClientConfig {
|
|
|
65
45
|
}
|
|
66
46
|
|
|
67
47
|
/**
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
* hands buildbot a source archive to build, mirroring what app.netlify.com does
|
|
72
|
-
* for a drag-and-drop build.
|
|
73
|
-
*
|
|
74
|
-
* Requests split across two hosts by necessity: JSON calls go through the
|
|
75
|
-
* consumer's access-control proxy (cookie session, no token handling in the
|
|
76
|
-
* browser), while the zip upload goes direct to `api.netlify.com` with a
|
|
77
|
-
* short-lived bearer token because the proxy can't carry a body that large.
|
|
48
|
+
* Builds a drop in a logged-in visitor's account: create the site, hand
|
|
49
|
+
* buildbot the source zip. JSON rides the cookie proxy; the zip goes direct
|
|
50
|
+
* with a bearer (the proxy can't carry it).
|
|
78
51
|
*/
|
|
79
52
|
export class AuthenticatedBuildClient implements BuildClient {
|
|
80
53
|
private proxyBase: string;
|
|
@@ -103,29 +76,24 @@ export class AuthenticatedBuildClient implements BuildClient {
|
|
|
103
76
|
this.timeoutMs = timeoutMs;
|
|
104
77
|
}
|
|
105
78
|
|
|
106
|
-
/**
|
|
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.
|
|
110
|
-
*/
|
|
79
|
+
/** A logged-out visitor fails here, before anything is created (see `createSiteInAccount`). */
|
|
111
80
|
async createSite(): Promise<BuildSite> {
|
|
112
81
|
return createSiteInAccount(this.proxyBase, this.accountSlug);
|
|
113
82
|
}
|
|
114
83
|
|
|
115
84
|
/**
|
|
116
|
-
*
|
|
117
|
-
* (
|
|
118
|
-
*
|
|
119
|
-
* boundary itself.
|
|
85
|
+
* One multipart request on a fresh 30s bearer — a slower zip fails mid-flight
|
|
86
|
+
* (the app origin gets 300s; raising ours is a platform-side change).
|
|
87
|
+
* Content-Type stays unset so the browser adds the multipart boundary.
|
|
120
88
|
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
89
|
+
* Never NotAuthenticatedError, including for a 401: the site exists by the
|
|
90
|
+
* time this runs, so signalling "no session" would strand it and re-stash
|
|
91
|
+
* the drop for the next visit to strand another. `createSite` is the auth
|
|
92
|
+
* gate for this path.
|
|
125
93
|
*/
|
|
126
94
|
async createBuildFromZip(siteId: string, zip: File): Promise<BuildResponse> {
|
|
127
95
|
const token = await this.getUploadToken();
|
|
128
|
-
if (!token) throw new
|
|
96
|
+
if (!token) throw new BuildCreateError();
|
|
129
97
|
|
|
130
98
|
const body = new FormData();
|
|
131
99
|
body.append('zip', zip);
|
|
@@ -136,22 +104,14 @@ export class AuthenticatedBuildClient implements BuildClient {
|
|
|
136
104
|
headers: { Authorization: `Bearer ${token}` },
|
|
137
105
|
body,
|
|
138
106
|
});
|
|
139
|
-
if (res.status === 401 || res.status === 403) throw new NotAuthenticatedError();
|
|
140
107
|
if (!res.ok) throw new BuildCreateError(res.status);
|
|
141
108
|
return res.json();
|
|
142
109
|
}
|
|
143
110
|
|
|
144
111
|
/**
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
* deploy id yet has nothing pollable, so it resolves immediately and the
|
|
149
|
-
* caller falls back to the site page.
|
|
150
|
-
*
|
|
151
|
-
* Optional in the drop flow, and off by default: a build takes minutes, so
|
|
152
|
-
* the useful hand-off is usually the project page rather than a spinner.
|
|
153
|
-
* Polling rides the session cookie, so it cannot outlive its credentials the
|
|
154
|
-
* way a 30s bearer would.
|
|
112
|
+
* Optional (a build takes minutes; the useful hand-off is the project page).
|
|
113
|
+
* Polls on the cookie, so it can't outlive its credentials; no deploy id yet
|
|
114
|
+
* means nothing pollable, so it resolves immediately.
|
|
155
115
|
*/
|
|
156
116
|
async waitUntilBuildReady(build: BuildResponse): Promise<void> {
|
|
157
117
|
if (!build.deploy_id) return;
|
|
@@ -163,8 +123,6 @@ export class AuthenticatedBuildClient implements BuildClient {
|
|
|
163
123
|
timeoutMs: this.timeoutMs,
|
|
164
124
|
});
|
|
165
125
|
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
126
|
if (settled.outcome === 'failed') throw new BuildFailedError(settled.errorMessage);
|
|
169
127
|
throw new BuildTimeoutError();
|
|
170
128
|
}
|
|
@@ -47,6 +47,25 @@ describe('buildStash', () => {
|
|
|
47
47
|
expect(await stash?.zipFile.text()).toBe('zip-bytes');
|
|
48
48
|
});
|
|
49
49
|
|
|
50
|
+
// Same key AND same value shape as react-ui's setWithExpiry, so the app can
|
|
51
|
+
// adopt this module without stranding a marker its own code already wrote.
|
|
52
|
+
it('writes the marker in react-ui’s { value, expiry } shape', async () => {
|
|
53
|
+
await saveDropStash(zip());
|
|
54
|
+
const marker = JSON.parse(localStorage.getItem('dropBuildStash') as string);
|
|
55
|
+
expect(marker.value).toBe('true');
|
|
56
|
+
expect(marker.expiry).toBeGreaterThan(Date.now());
|
|
57
|
+
expect(marker.expiry).toBeLessThanOrEqual(Date.now() + 24 * 60 * 60 * 1000);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// The row delete is what makes the stash unusable; dropping the database is
|
|
61
|
+
// cleanup, so a 200MB archive doesn't linger in the visitor's profile.
|
|
62
|
+
it('drops the database, not just the row, once the stash is consumed', async () => {
|
|
63
|
+
const deleteDatabase = vi.spyOn(indexedDB, 'deleteDatabase');
|
|
64
|
+
await saveDropStash(zip());
|
|
65
|
+
await clearDropStash();
|
|
66
|
+
expect(deleteDatabase).toHaveBeenCalledWith('netlify-drop');
|
|
67
|
+
});
|
|
68
|
+
|
|
50
69
|
it('clears the stash and marker idempotently', async () => {
|
|
51
70
|
await saveDropStash(zip());
|
|
52
71
|
await clearDropStash();
|
|
@@ -1,21 +1,11 @@
|
|
|
1
1
|
import type { InferredBuildSettings } from './detectBuild';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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.
|
|
4
|
+
* Carries a build drop across the signup round-trip: stash the zip in
|
|
5
|
+
* IndexedDB before the hand-off, deploy it when the visitor returns
|
|
6
|
+
* authenticated. Storage is per-origin by design — the hand-off must bring
|
|
7
|
+
* them back here. Every operation swallows failures: no storage means the
|
|
8
|
+
* plain signup redirect the zone always supported.
|
|
19
9
|
*/
|
|
20
10
|
|
|
21
11
|
export interface DropBuildStash {
|
|
@@ -45,6 +35,16 @@ const withTimeout = <T>(promise: Promise<T>): Promise<T> =>
|
|
|
45
35
|
}),
|
|
46
36
|
]);
|
|
47
37
|
|
|
38
|
+
// Resolves on `blocked` too: another tab holding the database open is not worth
|
|
39
|
+
// stalling the caller for, and the row delete already made the stash unusable.
|
|
40
|
+
const deleteDatabase = (): Promise<void> =>
|
|
41
|
+
new Promise((resolve, reject) => {
|
|
42
|
+
const request = indexedDB.deleteDatabase(DB_NAME);
|
|
43
|
+
request.onsuccess = () => resolve();
|
|
44
|
+
request.onblocked = () => resolve();
|
|
45
|
+
request.onerror = () => reject(request.error);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
48
|
const openDatabase = (): Promise<IDBDatabase> =>
|
|
49
49
|
new Promise((resolve, reject) => {
|
|
50
50
|
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
|
@@ -84,19 +84,20 @@ const hasEnoughQuota = async (bytes: number): Promise<boolean> => {
|
|
|
84
84
|
|
|
85
85
|
// The marker is a plain localStorage flag with its own expiry, so page loads can
|
|
86
86
|
// ask "is there anything to resume?" synchronously without opening IndexedDB.
|
|
87
|
+
// `{ value, expiry }` is react-ui's setWithExpiry shape — same key, same shape,
|
|
88
|
+
// so the app can adopt this module without stranding a marker it already wrote.
|
|
87
89
|
const setMarker = () => {
|
|
88
|
-
localStorage.setItem(
|
|
90
|
+
localStorage.setItem(
|
|
91
|
+
MARKER_KEY,
|
|
92
|
+
JSON.stringify({ value: 'true', expiry: Date.now() + STASH_TTL_MS })
|
|
93
|
+
);
|
|
89
94
|
};
|
|
90
95
|
|
|
91
96
|
const clearMarker = () => {
|
|
92
97
|
localStorage.removeItem(MARKER_KEY);
|
|
93
98
|
};
|
|
94
99
|
|
|
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
|
+
/** Stash the archive for auto-deploy after signup. The result phrases the hand-off, never blocks it. */
|
|
100
101
|
export async function saveDropStash(
|
|
101
102
|
zipFile: File,
|
|
102
103
|
buildSettings: InferredBuildSettings | null = null
|
|
@@ -110,7 +111,13 @@ export async function saveDropStash(
|
|
|
110
111
|
) {
|
|
111
112
|
return false;
|
|
112
113
|
}
|
|
113
|
-
const record: DropBuildStash = {
|
|
114
|
+
const record: DropBuildStash = {
|
|
115
|
+
// Round-trip through JSON so an injected detector's richer object can't
|
|
116
|
+
// fail the structured clone the put() below performs.
|
|
117
|
+
buildSettings: buildSettings && JSON.parse(JSON.stringify(buildSettings)),
|
|
118
|
+
zipFile,
|
|
119
|
+
createdAt: Date.now(),
|
|
120
|
+
};
|
|
114
121
|
await runStoreOperation('readwrite', store => store.put(record, STASH_KEY));
|
|
115
122
|
setMarker();
|
|
116
123
|
return true;
|
|
@@ -139,11 +146,9 @@ export async function loadDropStash(): Promise<DropBuildStash | null> {
|
|
|
139
146
|
}
|
|
140
147
|
|
|
141
148
|
/**
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
* named by the File). A Blob-shaped survivor is rewrapped; anything else is
|
|
146
|
-
* treated as a corrupt record.
|
|
149
|
+
* Rewraps a Blob-shaped survivor into a named File — structured-clone
|
|
150
|
+
* implementations vary in preserving File identity, and the multipart upload
|
|
151
|
+
* needs the name. Anything less Blob-shaped is a corrupt record.
|
|
147
152
|
*/
|
|
148
153
|
function rehydrateZipFile(stored: unknown): File | null {
|
|
149
154
|
if (stored instanceof File && stored.name) return stored;
|
|
@@ -162,7 +167,11 @@ export async function clearDropStash(): Promise<void> {
|
|
|
162
167
|
}
|
|
163
168
|
try {
|
|
164
169
|
if (typeof indexedDB === 'undefined') return;
|
|
170
|
+
// The row delete is what guarantees the stash can't come back; dropping the
|
|
171
|
+
// database afterwards is cleanup, so the visitor's project source doesn't
|
|
172
|
+
// sit in their profile once we're done with it. It reappears on the next drop.
|
|
165
173
|
await runStoreOperation('readwrite', store => store.delete(STASH_KEY));
|
|
174
|
+
await withTimeout(deleteDatabase());
|
|
166
175
|
} catch {
|
|
167
176
|
// best effort — an orphaned record expires via its TTL
|
|
168
177
|
}
|
|
@@ -173,8 +182,8 @@ export function hasDropStashMarker(): boolean {
|
|
|
173
182
|
try {
|
|
174
183
|
const marker = localStorage.getItem(MARKER_KEY);
|
|
175
184
|
if (!marker) return false;
|
|
176
|
-
const {
|
|
177
|
-
if (typeof
|
|
185
|
+
const { value, expiry } = JSON.parse(marker) as { value?: string; expiry?: number };
|
|
186
|
+
if (value !== 'true' || typeof expiry !== 'number' || Date.now() > expiry) {
|
|
178
187
|
clearMarker();
|
|
179
188
|
return false;
|
|
180
189
|
}
|
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
3
|
-
*
|
|
4
|
-
* so `deployFiles` runs the same pipeline whether the deploy is anonymous
|
|
5
|
-
* (this file's `AnonymousDropClient`, claimed later by the visitor) or lands
|
|
6
|
-
* directly in an account (`authedDropClient.ts`).
|
|
2
|
+
* The DropClient seam: the four calls a static deploy needs, behind one
|
|
3
|
+
* interface, so `deployFiles` runs the same pipeline anonymous or authenticated.
|
|
7
4
|
*/
|
|
8
5
|
import { DEFAULT_API_BASE, DEFAULT_POLL_INTERVAL_MS, DEFAULT_TIMEOUT_MS } from './constants';
|
|
9
6
|
import { DeployTimeoutError } from './errors';
|
|
@@ -23,7 +20,7 @@ export interface DropClient {
|
|
|
23
20
|
|
|
24
21
|
/**
|
|
25
22
|
* Unauthenticated drop client. Authorized server-side by the Referer header,
|
|
26
|
-
* so it only works from an allowlisted origin
|
|
23
|
+
* so it only works from an allowlisted origin.
|
|
27
24
|
*/
|
|
28
25
|
export class AnonymousDropClient implements DropClient {
|
|
29
26
|
constructor(
|
|
@@ -64,10 +61,9 @@ export class AnonymousDropClient implements DropClient {
|
|
|
64
61
|
}
|
|
65
62
|
|
|
66
63
|
async waitUntilReady(deploy: DropResponse, token: string): Promise<void> {
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
// and the request 401s misleadingly via require_current_actor!.
|
|
64
|
+
// `/deploys/{id}` expects the deploy_id (BSON), not the site id (UUID) that
|
|
65
|
+
// lives at `deploy.id`. See drop_controller.rb; Deploy.find(uuid) is nil and
|
|
66
|
+
// the request 401s misleadingly via require_current_actor!.
|
|
71
67
|
const deadline = this.timeoutMs / this.pollIntervalMs;
|
|
72
68
|
for (let i = 0; i < deadline; i++) {
|
|
73
69
|
const res = await fetch(`${this.apiBase}/deploys/${deploy.deploy_id}`, {
|
|
@@ -1,15 +1,7 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The tunables shared across drop-core, one place and one comment each.
|
|
3
|
-
* Module-specific constants (e.g. buildStash's storage caps) stay with their
|
|
4
|
-
* module; anything two modules read belongs here.
|
|
5
|
-
*/
|
|
1
|
+
/** Tunables shared across drop-core. Module-specific constants stay with their module. */
|
|
6
2
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
* rewrite. On netlify.com, `/access-control/*` targets the app's
|
|
10
|
-
* access-control function; the paths an origin may call are allowlisted
|
|
11
|
-
* server-side.
|
|
12
|
-
*/
|
|
3
|
+
// Root for session-cookie JSON calls via the consumer's access-control rewrite
|
|
4
|
+
// (paths per origin are allowlisted server-side)
|
|
13
5
|
export const DEFAULT_PROXY_BASE = '/access-control/bb-api/api/v1';
|
|
14
6
|
|
|
15
7
|
/** Direct api.netlify.com root for the uploads the proxy's ~6MB Lambda body limit can't carry. */
|
|
@@ -21,19 +13,13 @@ export const DEFAULT_POLL_INTERVAL_MS = 1000;
|
|
|
21
13
|
// How long to keep polling before giving up: 10 minutes
|
|
22
14
|
export const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
|
|
23
15
|
|
|
24
|
-
// Concurrent file uploads
|
|
16
|
+
// Concurrent file uploads
|
|
25
17
|
export const MAX_CONCURRENT_UPLOADS = 5;
|
|
26
18
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
* The server issues non-app origins a 30s token; the gap leaves margin for the
|
|
30
|
-
* request the token authorizes to finish.
|
|
31
|
-
*/
|
|
19
|
+
// Reuse window for a minted upload token: 20s (server TTL is 30s; the gap is
|
|
20
|
+
// margin for the request it authorizes to finish)
|
|
32
21
|
export const TOKEN_REUSE_MS = 20 * 1000;
|
|
33
22
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
* can be overridden (the tus-js-client `retryDelays` idiom). Three retries,
|
|
37
|
-
* linear backoff; 400/422 responses never retry — see `uploadWithRetry`.
|
|
38
|
-
*/
|
|
23
|
+
// Waits between upload retries, as data so the curve is overridable.
|
|
24
|
+
// 400/422 never retry — see uploadWithRetry.
|
|
39
25
|
export const UPLOAD_RETRY_DELAYS_MS: readonly number[] = [1_000, 2_000, 3_000];
|
|
@@ -9,11 +9,7 @@ import { zipFiles, type ZipResult } from './zip';
|
|
|
9
9
|
/** Responses that mean the request itself is wrong — retrying can only repeat the answer. */
|
|
10
10
|
const NON_RETRIABLE_STATUSES = new Set([400, 422]);
|
|
11
11
|
|
|
12
|
-
/**
|
|
13
|
-
* Upload one file, retrying transient failures (5xx, 429, network errors) on
|
|
14
|
-
* the given delay curve. A single flaky PUT among hundreds shouldn't kill the
|
|
15
|
-
* whole deploy — every deploy client we know of retries here.
|
|
16
|
-
*/
|
|
12
|
+
/** Retries transient failures (5xx/429/network) — one flaky PUT shouldn't kill the deploy. */
|
|
17
13
|
async function uploadWithRetry(
|
|
18
14
|
client: DropClient,
|
|
19
15
|
deployId: string,
|
|
@@ -96,7 +92,6 @@ export async function deployFiles(
|
|
|
96
92
|
}
|
|
97
93
|
|
|
98
94
|
export interface BuildDeployResult {
|
|
99
|
-
/** The site created for this build. */
|
|
100
95
|
site: BuildSite;
|
|
101
96
|
/** The enqueued build; `deploy_id` is what the app's deploy page keys off. */
|
|
102
97
|
build: BuildResponse;
|
|
@@ -108,22 +103,15 @@ export interface BuildDeployResult {
|
|
|
108
103
|
|
|
109
104
|
export interface BuildDeployOptions extends DeployOptions {
|
|
110
105
|
/**
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
* app's deploy page with its live logs, not a spinner. Turn it on when the
|
|
114
|
-
* caller needs to know the outcome — only then can a `BuildFailedError` reach
|
|
115
|
-
* it with the build's own error message.
|
|
106
|
+
* Wait for the build instead of returning at enqueue. Off by default (builds
|
|
107
|
+
* take minutes); on is the only way a BuildFailedError reaches the caller.
|
|
116
108
|
*/
|
|
117
109
|
waitForBuild?: boolean;
|
|
118
110
|
}
|
|
119
111
|
|
|
120
112
|
/**
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
* client — builds have no anonymous equivalent.
|
|
124
|
-
*
|
|
125
|
-
* Site creation deliberately happens *after* zipping, so a zip that fails
|
|
126
|
-
* doesn't leave an empty site behind in the user's account.
|
|
113
|
+
* Zip the drop, create a site, hand buildbot the archive. Zipping comes first
|
|
114
|
+
* on purpose: a failed zip must not leave an empty site behind.
|
|
127
115
|
*/
|
|
128
116
|
export async function deployBuild(
|
|
129
117
|
client: BuildClient,
|
|
@@ -138,11 +126,7 @@ export async function deployBuild(
|
|
|
138
126
|
return deployZippedBuild(client, archive, opts);
|
|
139
127
|
}
|
|
140
128
|
|
|
141
|
-
/**
|
|
142
|
-
* Deploy an archive that was already zipped — the resume path for a drop
|
|
143
|
-
* stashed across the signup round-trip (see `buildStash`), where `source.zip`
|
|
144
|
-
* and its synthesised settings were produced before the visitor left.
|
|
145
|
-
*/
|
|
129
|
+
/** Deploy an already-zipped archive — the resume path for a stashed drop (see `buildStash`). */
|
|
146
130
|
export async function deployZippedBuild(
|
|
147
131
|
client: BuildClient,
|
|
148
132
|
{ file: zip, buildSettings }: ZipResult,
|