@netlify/spark-ui 1.31.0-alpha.2 → 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.
@@ -1,39 +1,41 @@
1
- var h = Object.defineProperty;
2
- var d = (n, t, o) => t in n ? h(n, t, { enumerable: !0, configurable: !0, writable: !0, value: o }) : n[t] = o;
3
- var r = (n, t, o) => d(n, typeof t != "symbol" ? t + "" : t, o);
4
- import { NotAuthenticatedError as l, SiteCreateError as c, BuildCreateError as p, BuildFailedError as u, BuildTimeoutError as w } from "./errors.js";
5
- const f = "https://api.netlify.com/api/v1", m = 1e3, y = 10 * 60 * 1e3, B = /* @__PURE__ */ new Set(["ready", "error", "rejected"]);
6
- class g {
1
+ var d = Object.defineProperty;
2
+ var p = (r, t, e) => t in r ? d(r, t, { enumerable: !0, configurable: !0, writable: !0, value: e }) : r[t] = e;
3
+ var i = (r, t, e) => p(r, typeof t != "symbol" ? t + "" : t, e);
4
+ import { NotAuthenticatedError as n, SiteCreateError as h, BuildCreateError as u, BuildFailedError as w, BuildTimeoutError as y } from "./errors.js";
5
+ const f = "/access-control/bb-api/api/v1", B = "https://api.netlify.com/api/v1", m = 1e3, S = 10 * 60 * 1e3, T = /* @__PURE__ */ new Set(["ready", "error", "rejected"]);
6
+ class _ {
7
7
  constructor({
8
- apiBase: t = f,
9
- getUploadToken: o,
10
- accountSlug: e,
8
+ proxyBase: t = f,
9
+ apiBase: e = B,
10
+ getUploadToken: a,
11
+ accountSlug: o,
11
12
  title: s = "Build from drop deployment",
12
- pollIntervalMs: i = m,
13
- timeoutMs: a = y
13
+ pollIntervalMs: l = m,
14
+ timeoutMs: c = S
14
15
  }) {
15
- r(this, "apiBase");
16
- r(this, "getUploadToken");
17
- r(this, "accountSlug");
18
- r(this, "title");
19
- r(this, "pollIntervalMs");
20
- r(this, "timeoutMs");
21
- this.apiBase = t, this.getUploadToken = o, this.accountSlug = e, this.title = s, this.pollIntervalMs = i, this.timeoutMs = a;
16
+ i(this, "proxyBase");
17
+ i(this, "apiBase");
18
+ i(this, "getUploadToken");
19
+ i(this, "accountSlug");
20
+ i(this, "title");
21
+ i(this, "pollIntervalMs");
22
+ i(this, "timeoutMs");
23
+ this.proxyBase = t, this.apiBase = e, this.getUploadToken = a, this.accountSlug = o, this.title = s, this.pollIntervalMs = l, this.timeoutMs = c;
22
24
  }
23
25
  /**
24
- * Create the site the build deploys into. Authorized by the bearer token;
25
- * without an account slug the API picks the user's default account.
26
+ * Create the site the build deploys into. Goes through the proxy with
27
+ * `credentials: 'include'` so the app session authorizes it; without an
28
+ * account slug the API picks the user's default account.
26
29
  */
27
30
  async createSite() {
28
- const t = await this.getUploadToken();
29
- if (!t) throw new l();
30
- const o = this.accountSlug ? `/${this.accountSlug}/sites` : "/sites", e = await fetch(`${this.apiBase}${o}`, {
31
+ const t = this.accountSlug ? `/${this.accountSlug}/sites` : "/sites", e = await fetch(`${this.proxyBase}${t}`, {
31
32
  method: "POST",
32
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` },
33
+ credentials: "include",
34
+ headers: { "Content-Type": "application/json" },
33
35
  body: JSON.stringify({ created_via: "drop" })
34
36
  });
35
- if (e.status === 401 || e.status === 403) throw new l();
36
- if (!e.ok) throw new c(e.status);
37
+ if (e.status === 401 || e.status === 403) throw new n();
38
+ if (!e.ok) throw new h(e.status);
37
39
  return e.json();
38
40
  }
39
41
  /**
@@ -41,20 +43,25 @@ class g {
41
43
  * (see the note on `apiBase`), authorized by a freshly minted bearer token.
42
44
  * `Content-Type` is left unset on purpose so the browser adds the multipart
43
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.
44
51
  */
45
- async createBuildFromZip(t, o) {
46
- const e = await this.getUploadToken();
47
- if (!e) throw new l();
48
- const s = new FormData();
49
- s.append("zip", o), s.append("title", this.title);
50
- const i = await fetch(`${this.apiBase}/sites/${t}/builds`, {
52
+ async createBuildFromZip(t, e) {
53
+ const a = await this.getUploadToken();
54
+ if (!a) throw new n();
55
+ const o = new FormData();
56
+ o.append("zip", e), o.append("title", this.title);
57
+ const s = await fetch(`${this.apiBase}/sites/${t}/builds`, {
51
58
  method: "POST",
52
- headers: { Authorization: `Bearer ${e}` },
53
- body: s
59
+ headers: { Authorization: `Bearer ${a}` },
60
+ body: o
54
61
  });
55
- if (i.status === 401 || i.status === 403) throw new l();
56
- if (!i.ok) throw new p(i.status);
57
- return i.json();
62
+ if (s.status === 401 || s.status === 403) throw new n();
63
+ if (!s.ok) throw new u(s.status);
64
+ return s.json();
58
65
  }
59
66
  /**
60
67
  * Poll the build's deploy until it settles, mirroring
@@ -63,30 +70,30 @@ class g {
63
70
  * deploy id yet has nothing pollable, so it resolves immediately and the
64
71
  * caller falls back to the site page.
65
72
  *
66
- * Optional in the drop flow, and off by default: a build takes minutes, which
67
- * can outlive the bearer token minted for it. Each poll re-mints so a long
68
- * build keeps reading; a token that can't be minted any more ends the wait as
69
- * a timeout rather than a false failure.
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;
73
- const o = this.timeoutMs / this.pollIntervalMs;
74
- for (let e = 0; e < o; e++) {
75
- const s = await this.getUploadToken(), i = await fetch(`${this.apiBase}/deploys/${t.deploy_id}`, {
76
- headers: s ? { Authorization: `Bearer ${s}` } : {}
80
+ const e = this.timeoutMs / this.pollIntervalMs;
81
+ for (let a = 0; a < e; a++) {
82
+ const o = await fetch(`${this.proxyBase}/deploys/${t.deploy_id}`, {
83
+ credentials: "include"
77
84
  });
78
- if (i.ok) {
79
- const a = await i.json();
80
- if (a.state && B.has(a.state)) {
81
- if (a.state === "ready") return;
82
- throw new u(a.error_message);
85
+ if (o.ok) {
86
+ const s = await o.json();
87
+ if (s.state && T.has(s.state)) {
88
+ if (s.state === "ready") return;
89
+ throw new w(s.error_message);
83
90
  }
84
91
  }
85
- await new Promise((a) => setTimeout(a, this.pollIntervalMs));
92
+ await new Promise((s) => setTimeout(s, this.pollIntervalMs));
86
93
  }
87
- throw new w();
94
+ throw new y();
88
95
  }
89
96
  }
90
97
  export {
91
- g as AuthenticatedBuildClient
98
+ _ as AuthenticatedBuildClient
92
99
  };
@@ -68,6 +68,7 @@ interface UseDropDeployOptions {
68
68
  buildClient?: BuildClient;
69
69
  authenticatedDropClient?: DropClient;
70
70
  getUploadToken?: () => Promise<string | null>;
71
+ proxyBase?: string;
71
72
  accountSlug?: string;
72
73
  buildDeployUrl: (build: DeployedBuild) => string;
73
74
  waitForBuild?: boolean;
@@ -81,7 +82,7 @@ interface UseDropDeployOptions {
81
82
  * Owns the deploy state machine and its side effects (upload, local persistence,
82
83
  * analytics, redirect), keeping DropZone a thin view over the returned `status`.
83
84
  */
84
- export declare function useDropDeploy({ apiBase, client, claimUrl, siteDashboardUrl, buildSignupUrl, buildClient, authenticatedDropClient, getUploadToken, accountSlug, buildDeployUrl, waitForBuild, onBuildRequired, onDeploy, onBuildDeploy, onError, track, }: UseDropDeployOptions): {
85
+ export declare function useDropDeploy({ apiBase, client, claimUrl, siteDashboardUrl, buildSignupUrl, buildClient, authenticatedDropClient, getUploadToken, proxyBase, accountSlug, buildDeployUrl, waitForBuild, onBuildRequired, onDeploy, onBuildDeploy, onError, track, }: UseDropDeployOptions): {
85
86
  status: DeployStatus;
86
87
  warning: string | null;
87
88
  busy: boolean;
@@ -1,12 +1,12 @@
1
- import { d as P, A as I } from "../../../chunks/hooks.module.DRcxknIz.js";
2
- import { renameSingleNonIndexHtmlToIndex as X, getSingleNonIndexHtmlFileName as Y, hasMultipleHtmlFilesWithoutIndex as Z } from "./drop-core/readFiles.js";
3
- import { detectBuild as B } from "./drop-core/detectBuild.js";
4
- import { AnonymousDropClient as D } from "./drop-core/client.js";
5
- import { AuthenticatedDropClient as T } from "./drop-core/authedDropClient.js";
6
- import { AuthenticatedBuildClient as U } from "./drop-core/buildClient.js";
1
+ import { d as H, A as I } from "../../../chunks/hooks.module.DRcxknIz.js";
2
+ import { renameSingleNonIndexHtmlToIndex as Y, getSingleNonIndexHtmlFileName as Z, hasMultipleHtmlFilesWithoutIndex as B } from "./drop-core/readFiles.js";
3
+ import { detectBuild as D } from "./drop-core/detectBuild.js";
4
+ import { AnonymousDropClient as T } from "./drop-core/client.js";
5
+ import { AuthenticatedDropClient as U } from "./drop-core/authedDropClient.js";
6
+ import { AuthenticatedBuildClient as tt } from "./drop-core/buildClient.js";
7
7
  import "./drop-core/zip.js";
8
- import { deployFiles as H, deployBuild as tt } from "./drop-core/deploy.js";
9
- import { NotAuthenticatedError as W, humanizeDropError as et } from "./drop-core/errors.js";
8
+ import { deployFiles as W, deployBuild as et } from "./drop-core/deploy.js";
9
+ import { NotAuthenticatedError as $, humanizeDropError as nt } from "./drop-core/errors.js";
10
10
  function S(o) {
11
11
  switch (o.phase) {
12
12
  case "zipping":
@@ -22,38 +22,39 @@ function S(o) {
22
22
  return { kind: "processing" };
23
23
  }
24
24
  }
25
- function nt(o) {
26
- const f = Y(o);
27
- return f ? `No index.html found — "${f}" won’t be served at the site root.` : Z(o) ? "No index.html found — your site may not have a landing page." : null;
25
+ function it(o) {
26
+ const f = Z(o);
27
+ return f ? `No index.html found — "${f}" won’t be served at the site root.` : B(o) ? "No index.html found — your site may not have a landing page." : null;
28
28
  }
29
- function ft({
29
+ function mt({
30
30
  apiBase: o,
31
31
  client: f,
32
- claimUrl: $,
33
- siteDashboardUrl: v,
34
- buildSignupUrl: j,
35
- buildClient: q,
36
- authenticatedDropClient: M,
32
+ claimUrl: v,
33
+ siteDashboardUrl: j,
34
+ buildSignupUrl: q,
35
+ buildClient: M,
36
+ authenticatedDropClient: E,
37
37
  getUploadToken: m,
38
- accountSlug: x,
39
- buildDeployUrl: E,
40
- waitForBuild: G,
41
- onBuildRequired: z,
42
- onDeploy: g,
38
+ proxyBase: x,
39
+ accountSlug: z,
40
+ buildDeployUrl: G,
41
+ waitForBuild: J,
42
+ onBuildRequired: C,
43
+ onDeploy: p,
43
44
  onBuildDeploy: h,
44
45
  onError: b,
45
46
  track: c
46
47
  }) {
47
- const [w, n] = P({ kind: "idle" }), [J, _] = P(null), K = I(f ?? new D(o)).current, C = I(
48
- q ?? (m ? new U({ getUploadToken: m, accountSlug: x }) : null)
49
- ).current, A = I(
50
- M ?? (m ? new T({ getUploadToken: m, accountSlug: x }) : null)
51
- ).current, L = w.kind !== "idle" && w.kind !== "error";
52
- async function O(y, r) {
48
+ const [w, n] = H({ kind: "idle" }), [K, _] = H(null), L = I(f ?? new T(o)).current, A = I(
49
+ M ?? (m ? new tt({ proxyBase: x, getUploadToken: m, accountSlug: z }) : null)
50
+ ).current, F = I(
51
+ E ?? (m ? new U({ proxyBase: x, getUploadToken: m, accountSlug: z }) : null)
52
+ ).current, O = w.kind !== "idle" && w.kind !== "error";
53
+ async function Q(y, r) {
53
54
  try {
54
- const { site: d, build: l, buildSettings: p, url: k } = await tt(y, r, {
55
+ const { site: d, build: l, buildSettings: g, url: k } = await et(y, r, {
55
56
  onProgress: (t) => n(S(t)),
56
- waitForBuild: G
57
+ waitForBuild: J
57
58
  });
58
59
  return {
59
60
  url: k,
@@ -61,25 +62,25 @@ function ft({
61
62
  siteId: d.id,
62
63
  buildId: l.id,
63
64
  deployId: l.deploy_id,
64
- buildSettings: p
65
+ buildSettings: g
65
66
  };
66
67
  } catch (d) {
67
- if (d instanceof W) return null;
68
+ if (d instanceof $) return null;
68
69
  throw d;
69
70
  }
70
71
  }
71
- async function Q(y, r, d) {
72
+ async function R(y, r, d) {
72
73
  _(null), n({ kind: "reading" });
73
- const l = d.length ? { file_types: d } : {}, p = (t, i, a) => {
74
+ const l = d.length ? { file_types: d } : {}, g = (t, i, a) => {
74
75
  n({ kind: "error", message: t }), c("dropzone_deploy_failed", { method: r, reason: i, ...l, ...a }), b == null || b({ message: t, reason: i });
75
76
  }, k = (t, i) => {
76
77
  const a = t instanceof Error ? t.message : String(t);
77
- p(et(t), a, i);
78
+ g(nt(t), a, i);
78
79
  };
79
80
  try {
80
- const t = X(await y());
81
+ const t = Y(await y());
81
82
  if (!t.length) {
82
- p("No files found to deploy.", "No files found to deploy.", { file_count: 0 });
83
+ g("No files found to deploy.", "No files found to deploy.", { file_count: 0 });
83
84
  return;
84
85
  }
85
86
  r === "drag" && c("dropzone_files_dropped", {
@@ -87,19 +88,19 @@ function ft({
87
88
  file_count: t.length,
88
89
  ...l
89
90
  });
90
- const i = B(t);
91
+ const i = D(t);
91
92
  if (i.buildRequired) {
92
93
  if (c("dropzone_build_required", {
93
94
  method: r,
94
95
  reason: i.reason,
95
96
  file_count: t.length,
96
97
  ...l
97
- }), z) {
98
- n({ kind: "idle" }), z(i);
98
+ }), C) {
99
+ n({ kind: "idle" }), C(i);
99
100
  return;
100
101
  }
101
- if (C) {
102
- const e = await O(C, t);
102
+ if (A) {
103
+ const e = await Q(A, t);
103
104
  if (e) {
104
105
  n({ kind: "redirecting", target: "build" }), c("dropzone_build_deploy_succeeded", {
105
106
  method: r,
@@ -108,28 +109,28 @@ function ft({
108
109
  file_count: t.length,
109
110
  ...e.buildSettings ? { framework: e.buildSettings.framework } : {},
110
111
  ...l
111
- }), h == null || h(e), window.location.assign(E(e));
112
+ }), h == null || h(e), window.location.assign(G(e));
112
113
  return;
113
114
  }
114
115
  }
115
- n({ kind: "redirecting", target: "signup" }), window.location.assign(j);
116
+ n({ kind: "redirecting", target: "signup" }), window.location.assign(q);
116
117
  return;
117
118
  }
118
- _(nt(t));
119
- let a = null, s = !1, F = !1;
120
- if (A)
119
+ _(it(t));
120
+ let a = null, s = !1, P = !1;
121
+ if (F)
121
122
  try {
122
- a = await H(A, t, {
123
+ a = await W(F, t, {
123
124
  onProgress: (e) => n(S(e))
124
125
  }), s = !0;
125
126
  } catch (e) {
126
- if (!(e instanceof W)) throw e;
127
- F = !0;
127
+ if (!(e instanceof $)) throw e;
128
+ P = !0;
128
129
  }
129
- a || (a = await H(K, t, {
130
+ a || (a = await W(L, t, {
130
131
  onProgress: (e) => n(S(e))
131
132
  }));
132
- const { url: V, token: N, deploy: u } = a;
133
+ const { url: X, token: N, deploy: u } = a;
133
134
  if (!s)
134
135
  try {
135
136
  localStorage.setItem("drop-token", N), localStorage.setItem("dropSiteName", u.subdomain);
@@ -140,24 +141,24 @@ function ft({
140
141
  site_name: u.subdomain,
141
142
  file_count: t.length,
142
143
  authenticated: s,
143
- ...F ? { auth_fallback: !0 } : {},
144
+ ...P ? { auth_fallback: !0 } : {},
144
145
  ...l
145
- }), g == null || g({
146
- url: V,
146
+ }), p == null || p({
147
+ url: X,
147
148
  ...s ? {} : { token: N },
148
149
  siteName: u.subdomain,
149
150
  siteId: u.id,
150
151
  authenticated: s
151
- }), s ? window.location.assign(v(u.subdomain)) : window.location.assign(`${$(u.subdomain)}#drop_token=${N}`);
152
+ }), s ? window.location.assign(j(u.subdomain)) : window.location.assign(`${v(u.subdomain)}#drop_token=${N}`);
152
153
  } catch (t) {
153
154
  k(t);
154
155
  }
155
156
  }
156
- function R() {
157
+ function V() {
157
158
  _(null), n({ kind: "idle" });
158
159
  }
159
- return { status: w, warning: J, busy: L, deploy: Q, reset: R };
160
+ return { status: w, warning: K, busy: O, deploy: R, reset: V };
160
161
  }
161
162
  export {
162
- ft as useDropDeploy
163
+ mt as useDropDeploy
163
164
  };
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.2",
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",
@@ -93,6 +93,13 @@ interface Props {
93
93
  * static path. Distinct from `client`, which is the anonymous fallback.
94
94
  */
95
95
  authenticatedDropClient?: DropClient;
96
+ /**
97
+ * API root for the authenticated paths' JSON calls, which go through your
98
+ * access-control rewrite so the session cookie authorizes them. Defaults to
99
+ * `/access-control/bb-api/api/v1`. (Uploads always go direct to
100
+ * `api.netlify.com` — the proxy can't carry bodies that large.)
101
+ */
102
+ proxyBase?: string;
96
103
  /** Create the visitor's new project in this account. Omit for their default account. */
97
104
  accountSlug?: string;
98
105
  /**
@@ -180,6 +187,7 @@ export default function DropZone({
180
187
  getUploadToken,
181
188
  buildClient,
182
189
  authenticatedDropClient,
190
+ proxyBase,
183
191
  accountSlug,
184
192
  buildDeployUrl = defaultBuildDeployUrl,
185
193
  waitForBuild,
@@ -206,6 +214,7 @@ export default function DropZone({
206
214
  getUploadToken,
207
215
  buildClient,
208
216
  authenticatedDropClient,
217
+ proxyBase,
209
218
  accountSlug,
210
219
  buildDeployUrl,
211
220
  waitForBuild,
@@ -5,18 +5,26 @@ import { NotAuthenticatedError, SiteCreateError } from './errors';
5
5
 
6
6
  export interface AuthenticatedDropClientConfig {
7
7
  /**
8
- * API base for every call. Requests go straight to `api.netlify.com`, whose
9
- * `/api/*` CORS policy allows any origin, rather than through a consumer's
10
- * access-control rewrite — that proxy allowlists paths per origin, and site
11
- * and deploy creation are not on the list for netlify.com (they 400 with
12
- * `x-debug: request origin not allowed to call endpoint`).
8
+ * Base for JSON API calls, routed through the consumer's access-control
9
+ * rewrite so the session cookie authenticates them. On netlify.com that
10
+ * rewrite is `/access-control/*` → the app's access-control function, which
11
+ * makes `/access-control/bb-api/api/v1` the API root (the default here).
12
+ */
13
+ proxyBase?: string;
14
+ /**
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).
13
18
  */
14
19
  apiBase?: string;
15
20
  /**
16
- * Mint a short-lived bearer token authorizing the calls. On netlify.com this
17
- * 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
18
23
  * `accessControlToken` the API decrypts back into the user's access token.
19
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.
20
28
  */
21
29
  getUploadToken: () => Promise<string | null>;
22
30
  /** Create the site in this account. Omit to use the user's default account. */
@@ -31,10 +39,18 @@ export interface AuthenticatedDropClientConfig {
31
39
  // (constants + site create) are deliberately duplicated rather than extracted,
32
40
  // to avoid churn on the in-review build-client branch. Worth unifying once both
33
41
  // have landed.
42
+ const PROXY_BASE = '/access-control/bb-api/api/v1';
34
43
  const API_BASE = 'https://api.netlify.com/api/v1';
35
44
  const POLL_INTERVAL_MS = 1000;
36
45
  const TIMEOUT_MS = 10 * 60 * 1000;
37
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
+
38
54
  /** Deploy states that end the wait — `ready` succeeds, the rest are failures. */
39
55
  const TERMINAL_STATES = new Set(['ready', 'error', 'rejected']);
40
56
 
@@ -59,34 +75,37 @@ interface SiteDeployResponse {
59
75
  * There is no claim step, so the caller should hand off to the site's dashboard
60
76
  * page rather than the claim page.
61
77
  *
62
- * Every request goes direct to `api.netlify.com` with the short-lived bearer
63
- * from `getUploadToken()`. Routing the JSON calls through a consumer's
64
- * access-control rewrite instead (cookie session, no token in the browser)
65
- * would be the tighter design, but that proxy allowlists paths per origin and
66
- * site/deploy creation isn't allowed from netlify.com — it 400s before
67
- * reaching the API. Revisit if that allowlist gains these paths.
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`).
68
83
  *
69
- * Error-mapping invariant: only pre-mutation failures — a null upload token, or
70
- * a 401/403 creating the site — throw `NotAuthenticatedError`, which is the
71
- * signal the drop flow may safely retry anonymously. Once the site exists, an
72
- * auth failure throws a plain status-carrying error instead: falling back at
73
- * 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
74
89
  * anonymously.
75
90
  */
76
91
  export class AuthenticatedDropClient implements DropClient {
92
+ private proxyBase: string;
77
93
  private apiBase: string;
78
94
  private getUploadToken: () => Promise<string | null>;
79
95
  private accountSlug?: string;
80
96
  private pollIntervalMs: number;
81
97
  private timeoutMs: number;
98
+ private cachedToken?: { value: string; mintedAt: number };
82
99
 
83
100
  constructor({
101
+ proxyBase = PROXY_BASE,
84
102
  apiBase = API_BASE,
85
103
  getUploadToken,
86
104
  accountSlug,
87
105
  pollIntervalMs = POLL_INTERVAL_MS,
88
106
  timeoutMs = TIMEOUT_MS,
89
107
  }: AuthenticatedDropClientConfig) {
108
+ this.proxyBase = proxyBase;
90
109
  this.apiBase = apiBase;
91
110
  this.getUploadToken = getUploadToken;
92
111
  this.accountSlug = accountSlug;
@@ -95,26 +114,50 @@ export class AuthenticatedDropClient implements DropClient {
95
114
  }
96
115
 
97
116
  /**
98
- * The token here is the upload bearer, not a drop token — it authorizes every
99
- * call this client makes. Minted once per deploy, before anything is created,
100
- * so a 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.
101
122
  */
102
123
  async getToken(): Promise<string> {
103
124
  const token = await this.getUploadToken();
104
125
  if (!token) throw new NotAuthenticatedError();
126
+ this.cachedToken = { value: token, mintedAt: Date.now() };
105
127
  return token;
106
128
  }
107
129
 
108
130
  /**
109
- * Two calls: create the site in the visitor's account, then the deploy on it.
110
- * Mapped into the anonymous `DropResponse` shape so `deployFiles` runs
111
- * unchanged over either client.
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
+
150
+ /**
151
+ * Two proxied calls: create the site in the visitor's account, then the
152
+ * deploy on it. Mapped into the anonymous `DropResponse` shape so
153
+ * `deployFiles` runs unchanged over either client.
112
154
  */
113
- async createDeploy(files: Digest, token: string): Promise<DropResponse> {
155
+ async createDeploy(files: Digest, _token: string): Promise<DropResponse> {
114
156
  const path = this.accountSlug ? `/${this.accountSlug}/sites` : '/sites';
115
- const siteRes = await fetch(`${this.apiBase}${path}`, {
157
+ const siteRes = await fetch(`${this.proxyBase}${path}`, {
116
158
  method: 'POST',
117
- headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
159
+ credentials: 'include',
160
+ headers: { 'Content-Type': 'application/json' },
118
161
  body: JSON.stringify({ created_via: 'drop' }),
119
162
  });
120
163
  // A logged-out visitor 401s here, before anything is created — the one
@@ -123,9 +166,10 @@ export class AuthenticatedDropClient implements DropClient {
123
166
  if (!siteRes.ok) throw new SiteCreateError(siteRes.status);
124
167
  const site: BuildSite = await siteRes.json();
125
168
 
126
- const deployRes = await fetch(`${this.apiBase}/sites/${site.id}/deploys`, {
169
+ const deployRes = await fetch(`${this.proxyBase}/sites/${site.id}/deploys`, {
127
170
  method: 'POST',
128
- headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
171
+ credentials: 'include',
172
+ headers: { 'Content-Type': 'application/json' },
129
173
  body: JSON.stringify({ files, deploy_source: 'drop' }),
130
174
  });
131
175
  if (!deployRes.ok) throw apiError('deploy create', deployRes.status);
@@ -139,13 +183,18 @@ export class AuthenticatedDropClient implements DropClient {
139
183
  };
140
184
  }
141
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
+ */
142
191
  async uploadFile(
143
192
  deployId: string,
144
193
  path: string,
145
194
  content: ArrayBuffer,
146
- token: string
195
+ _token: string
147
196
  ): Promise<void> {
148
- // PUT (not POST) so paths with "&" etc. work.
197
+ const token = await this.freshToken();
149
198
  const res = await fetch(`${this.apiBase}/deploys/${deployId}/files${path}`, {
150
199
  method: 'PUT',
151
200
  headers: { 'Content-Type': 'application/octet-stream', Authorization: `Bearer ${token}` },
@@ -155,18 +204,15 @@ export class AuthenticatedDropClient implements DropClient {
155
204
  }
156
205
 
157
206
  /**
158
- * Poll until the deploy settles: resolve on `ready`, throw on
159
- * `error`/`rejected`, throw on timeout. Reuses the bearer minted at the start
160
- * of the deploy, so a deploy that outlives the token's TTL will start seeing
161
- * 401s here — those are ignored like any other non-ok read and the loop runs
162
- * to its timeout rather than failing fast. Acceptable while static deploys
163
- * finish in seconds; re-mint per poll if that stops being true.
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.
164
210
  */
165
- async waitUntilReady(deploy: DropResponse, token: string): Promise<void> {
211
+ async waitUntilReady(deploy: DropResponse, _token: string): Promise<void> {
166
212
  const deadline = this.timeoutMs / this.pollIntervalMs;
167
213
  for (let i = 0; i < deadline; i++) {
168
- const res = await fetch(`${this.apiBase}/deploys/${deploy.deploy_id}`, {
169
- headers: { Authorization: `Bearer ${token}` },
214
+ const res = await fetch(`${this.proxyBase}/deploys/${deploy.deploy_id}`, {
215
+ credentials: 'include',
170
216
  });
171
217
  if (res.ok) {
172
218
  const state: DeployState = await res.json();