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