@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.
@@ -9,16 +9,19 @@ export interface AuthenticatedDropClientConfig {
9
9
  */
10
10
  proxyBase?: string;
11
11
  /**
12
- * Direct API base for the per-file uploads. The proxy above runs in a Lambda
13
- * with a ~6MB request body limit, so file PUTs go straight to
14
- * `api.netlify.com` (whose `/api/*` CORS policy allows any origin).
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 short-lived bearer token for those direct uploads. On netlify.com
19
- * this is `GET /access-control/generate-access-control-token`, whose
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: JSON calls go through the
40
- * consumer's access-control proxy (cookie session, no token handling in the
41
- * browser), while the per-file PUTs go direct to `api.netlify.com` with the
42
- * short-lived bearer from `getUploadToken()` because the proxy can't carry
43
- * large bodies.
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, or
46
- * a 401/403 creating the site — throw `NotAuthenticatedError`, which is the
47
- * signal the drop flow may safely retry anonymously. Once the site exists, an
48
- * auth failure throws a plain status-carrying error instead: falling back at
49
- * that point would strand an empty site in the account *and* deploy a duplicate
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 token here is the upload bearer, not a drop token — it authorizes the
62
- * direct-API file PUTs, while the proxied JSON calls ignore it and ride the
63
- * session cookie. Minted once per deploy, before anything is created, so a
64
- * missing session fails the whole attempt cleanly.
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
- * Poll the deploy through the proxy until it settles — the session cookie
76
- * outlives the short-lived upload bearer, so a slow deploy can't expire its
77
- * own poll. Resolve on `ready`, throw on `error`/`rejected`, throw on timeout.
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 p = Object.defineProperty;
2
- var h = (n, t, s) => t in n ? p(n, t, { enumerable: !0, configurable: !0, writable: !0, value: s }) : n[t] = s;
3
- var r = (n, t, s) => h(n, typeof t != "symbol" ? t + "" : t, s);
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 y } from "./errors.js";
6
- const u = "/access-control/bb-api/api/v1", w = "https://api.netlify.com/api/v1", f = 1e3, m = 10 * 60 * 1e3, T = /* @__PURE__ */ new Set(["ready", "error", "rejected"]);
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: t = u,
10
- apiBase: s = w,
11
- getUploadToken: i,
12
- accountSlug: o,
13
- pollIntervalMs: a = f,
14
- timeoutMs: e = m
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
- r(this, "proxyBase");
17
- r(this, "apiBase");
18
- r(this, "getUploadToken");
19
- r(this, "accountSlug");
20
- r(this, "pollIntervalMs");
21
- r(this, "timeoutMs");
22
- this.proxyBase = t, this.apiBase = s, this.getUploadToken = i, this.accountSlug = o, this.pollIntervalMs = a, this.timeoutMs = e;
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 token here is the upload bearer, not a drop token — it authorizes the
26
- * direct-API file PUTs, while the proxied JSON calls ignore it and ride the
27
- * session cookie. Minted once per deploy, before anything is created, so a
28
- * missing session fails the whole attempt cleanly.
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 t = await this.getUploadToken();
32
- if (!t) throw new l();
33
- return t;
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(t, s) {
41
- const i = this.accountSlug ? `/${this.accountSlug}/sites` : "/sites", o = await fetch(`${this.proxyBase}${i}`, {
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 (o.status === 401 || o.status === 403) throw new l();
48
- if (!o.ok) throw new y(o.status);
49
- const a = await o.json(), e = await fetch(`${this.proxyBase}/sites/${a.id}/deploys`, {
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: t, deploy_source: "drop" })
72
+ body: JSON.stringify({ files: e, deploy_source: "drop" })
54
73
  });
55
- if (!e.ok) throw d("deploy create", e.status);
56
- const c = await e.json();
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
- async uploadFile(t, s, i, o) {
65
- const a = await fetch(`${this.apiBase}/deploys/${t}/files${s}`, {
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 ${o}` },
68
- body: i
91
+ headers: { "Content-Type": "application/octet-stream", Authorization: `Bearer ${a}` },
92
+ body: n
69
93
  });
70
- if (!a.ok) throw d(`upload ${s}`, a.status);
94
+ if (!t.ok) throw d(`upload ${o}`, t.status);
71
95
  }
72
96
  /**
73
- * Poll the deploy through the proxy until it settles — the session cookie
74
- * outlives the short-lived upload bearer, so a slow deploy can't expire its
75
- * own poll. Resolve on `ready`, throw on `error`/`rejected`, throw on timeout.
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(t, s) {
78
- const i = this.timeoutMs / this.pollIntervalMs;
79
- for (let o = 0; o < i; o++) {
80
- const a = await fetch(`${this.proxyBase}/deploys/${t.deploy_id}`, {
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 e = await a.json();
85
- if (e.state && T.has(e.state)) {
86
- if (e.state === "ready") return;
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
- e.error_message ? `deploy failed: ${e.error_message}` : "deploy failed"
112
+ t.error_message ? `deploy failed: ${t.error_message}` : "deploy failed"
89
113
  );
90
114
  }
91
115
  }
92
- await new Promise((e) => setTimeout(e, this.pollIntervalMs));
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 short-lived bearer token for that direct upload. On netlify.com this
39
- * is `GET /access-control/generate-access-control-token`, whose
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 through the proxy until it settles, mirroring
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 the default hand-off
95
- * redirects to the app's deploy page (live logs) instead of waiting here.
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 through the proxy until it settles, mirroring
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 the default hand-off
69
- * redirects to the app's deploy page (live logs) instead of waiting here.
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.1",
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 per-file uploads. The proxy above runs in a Lambda
16
- * with a ~6MB request body limit, so file PUTs go straight to
17
- * `api.netlify.com` (whose `/api/*` CORS policy allows any origin).
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 short-lived bearer token for those direct uploads. On netlify.com
22
- * this is `GET /access-control/generate-access-control-token`, whose
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: JSON calls go through the
69
- * consumer's access-control proxy (cookie session, no token handling in the
70
- * browser), while the per-file PUTs go direct to `api.netlify.com` with the
71
- * short-lived bearer from `getUploadToken()` because the proxy can't carry
72
- * large bodies.
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, or
75
- * a 401/403 creating the site — throw `NotAuthenticatedError`, which is the
76
- * signal the drop flow may safely retry anonymously. Once the site exists, an
77
- * auth failure throws a plain status-carrying error instead: falling back at
78
- * that point would strand an empty site in the account *and* deploy a duplicate
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 token here is the upload bearer, not a drop token — it authorizes the
107
- * direct-API file PUTs, while the proxied JSON calls ignore it and ride the
108
- * session cookie. Minted once per deploy, before anything is created, so a
109
- * missing session fails the whole attempt cleanly.
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
- token: string
195
+ _token: string
158
196
  ): Promise<void> {
159
- // Direct to api.netlify.com with the upload bearer — see the class doc.
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 — the session cookie
171
- * outlives the short-lived upload bearer, so a slow deploy can't expire its
172
- * own poll. Resolve on `ready`, throw on `error`/`rejected`, throw on timeout.
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 short-lived bearer token for that direct upload. On netlify.com this
50
- * is `GET /access-control/generate-access-control-token`, whose
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 through the proxy until it settles, mirroring
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 the default hand-off
169
- * redirects to the app's deploy page (live logs) instead of waiting here.
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;