@netlify/spark-ui 1.30.0 → 1.31.0-alpha.2
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 +4 -3
- package/dist/chunks/browser.Ca2rTNTu.js +769 -0
- package/dist/components/articleCopyActions.d.ts +9 -0
- package/dist/components/articleCopyActions.test.d.ts +1 -0
- package/dist/components/index.d.ts +2 -2
- package/dist/components/preact/DropZone/DropZone.d.ts +69 -14
- package/dist/components/preact/DropZone/DropZone.js +109 -84
- package/dist/components/preact/DropZone/drop-core/authedDropClient.d.ts +77 -0
- package/dist/components/preact/DropZone/drop-core/authedDropClient.js +97 -0
- package/dist/components/preact/DropZone/drop-core/buildClient.d.ts +95 -0
- package/dist/components/preact/DropZone/drop-core/buildClient.js +92 -0
- package/dist/components/preact/DropZone/drop-core/client.d.ts +4 -0
- package/dist/components/preact/DropZone/drop-core/client.js +2 -1
- package/dist/components/preact/DropZone/drop-core/deploy.d.ts +31 -0
- package/dist/components/preact/DropZone/drop-core/deploy.js +36 -18
- package/dist/components/preact/DropZone/drop-core/detectBuild.d.ts +26 -0
- package/dist/components/preact/DropZone/drop-core/detectBuild.js +80 -53
- package/dist/components/preact/DropZone/drop-core/errors.d.ts +38 -0
- package/dist/components/preact/DropZone/drop-core/errors.js +50 -5
- package/dist/components/preact/DropZone/drop-core/index.d.ts +3 -0
- package/dist/components/preact/DropZone/drop-core/index.js +33 -18
- package/dist/components/preact/DropZone/drop-core/readFiles.js +1 -1
- package/dist/components/preact/DropZone/drop-core/types.d.ts +6 -1
- package/dist/components/preact/DropZone/drop-core/zip.d.ts +46 -0
- package/dist/components/preact/DropZone/drop-core/zip.js +43 -0
- package/dist/components/preact/DropZone/index.d.ts +3 -2
- package/dist/components/preact/DropZone/index.js +27 -11
- package/dist/components/preact/DropZone/useDropDeploy.d.ts +34 -4
- package/dist/components/preact/DropZone/useDropDeploy.js +138 -70
- package/dist/components/preact/index.d.ts +2 -1
- package/dist/components/preact/index.js +17 -12
- package/package.json +5 -2
- package/packages/components/ArticleCopyActions.astro +122 -0
- package/packages/components/articleCopyActions.test.ts +143 -0
- package/packages/components/articleCopyActions.ts +41 -0
- package/packages/components/index.ts +15 -0
- package/packages/components/preact/DropZone/DropZone.tsx +114 -20
- package/packages/components/preact/DropZone/drop-core/authedDropClient.ts +185 -0
- package/packages/components/preact/DropZone/drop-core/buildClient.ts +187 -0
- package/packages/components/preact/DropZone/drop-core/client.ts +1 -1
- package/packages/components/preact/DropZone/drop-core/deploy.ts +65 -0
- package/packages/components/preact/DropZone/drop-core/detectBuild.ts +125 -41
- package/packages/components/preact/DropZone/drop-core/errors.ts +84 -1
- package/packages/components/preact/DropZone/drop-core/index.ts +3 -0
- package/packages/components/preact/DropZone/drop-core/readFiles.ts +1 -1
- package/packages/components/preact/DropZone/drop-core/types.ts +12 -1
- package/packages/components/preact/DropZone/drop-core/zip.ts +118 -0
- package/packages/components/preact/DropZone/index.tsx +23 -0
- package/packages/components/preact/DropZone/useDropDeploy.ts +189 -30
- package/packages/components/preact/index.ts +2 -1
- package/dist/chunks/browser.D58rdxmc.js +0 -444
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { apiError, type DropClient } from './client';
|
|
2
|
+
import type { BuildSite } from './buildClient';
|
|
3
|
+
import type { Digest, DropResponse } from './types';
|
|
4
|
+
import { NotAuthenticatedError, SiteCreateError } from './errors';
|
|
5
|
+
|
|
6
|
+
export interface AuthenticatedDropClientConfig {
|
|
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`).
|
|
13
|
+
*/
|
|
14
|
+
apiBase?: string;
|
|
15
|
+
/**
|
|
16
|
+
* Mint a short-lived bearer token authorizing the calls. On netlify.com this
|
|
17
|
+
* is `GET /access-control/generate-access-control-token`, whose
|
|
18
|
+
* `accessControlToken` the API decrypts back into the user's access token.
|
|
19
|
+
* Resolving `null` means "no session" and surfaces as `NotAuthenticatedError`.
|
|
20
|
+
*/
|
|
21
|
+
getUploadToken: () => Promise<string | null>;
|
|
22
|
+
/** Create the site in this account. Omit to use the user's default account. */
|
|
23
|
+
accountSlug?: string;
|
|
24
|
+
/** How often to re-read the deploy while waiting. Default 1s. */
|
|
25
|
+
pollIntervalMs?: number;
|
|
26
|
+
/** How long to wait before giving up on the deploy. Default 10 minutes. */
|
|
27
|
+
timeoutMs?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Same defaults as AuthenticatedBuildClient — the ~15 lines shared with it
|
|
31
|
+
// (constants + site create) are deliberately duplicated rather than extracted,
|
|
32
|
+
// to avoid churn on the in-review build-client branch. Worth unifying once both
|
|
33
|
+
// have landed.
|
|
34
|
+
const API_BASE = 'https://api.netlify.com/api/v1';
|
|
35
|
+
const POLL_INTERVAL_MS = 1000;
|
|
36
|
+
const TIMEOUT_MS = 10 * 60 * 1000;
|
|
37
|
+
|
|
38
|
+
/** Deploy states that end the wait — `ready` succeeds, the rest are failures. */
|
|
39
|
+
const TERMINAL_STATES = new Set(['ready', 'error', 'rejected']);
|
|
40
|
+
|
|
41
|
+
interface DeployState {
|
|
42
|
+
state?: string;
|
|
43
|
+
error_message?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Response body of `POST /sites/{id}/deploys` — the slice the drop flow needs. */
|
|
47
|
+
interface SiteDeployResponse {
|
|
48
|
+
/** deploy id (BSON) */
|
|
49
|
+
id: string;
|
|
50
|
+
/** SHAs the backend does NOT yet have — only these get uploaded */
|
|
51
|
+
required?: string[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Authenticated drop client for logged-in visitors. Unlike `AnonymousDropClient`
|
|
56
|
+
* — which creates an account-less site the visitor must later claim — this
|
|
57
|
+
* deploys straight into the visitor's account via the sites/deploys API,
|
|
58
|
+
* mirroring what app.netlify.com does for a logged-in drag-and-drop deploy.
|
|
59
|
+
* There is no claim step, so the caller should hand off to the site's dashboard
|
|
60
|
+
* page rather than the claim page.
|
|
61
|
+
*
|
|
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.
|
|
68
|
+
*
|
|
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
|
|
74
|
+
* anonymously.
|
|
75
|
+
*/
|
|
76
|
+
export class AuthenticatedDropClient implements DropClient {
|
|
77
|
+
private apiBase: string;
|
|
78
|
+
private getUploadToken: () => Promise<string | null>;
|
|
79
|
+
private accountSlug?: string;
|
|
80
|
+
private pollIntervalMs: number;
|
|
81
|
+
private timeoutMs: number;
|
|
82
|
+
|
|
83
|
+
constructor({
|
|
84
|
+
apiBase = API_BASE,
|
|
85
|
+
getUploadToken,
|
|
86
|
+
accountSlug,
|
|
87
|
+
pollIntervalMs = POLL_INTERVAL_MS,
|
|
88
|
+
timeoutMs = TIMEOUT_MS,
|
|
89
|
+
}: AuthenticatedDropClientConfig) {
|
|
90
|
+
this.apiBase = apiBase;
|
|
91
|
+
this.getUploadToken = getUploadToken;
|
|
92
|
+
this.accountSlug = accountSlug;
|
|
93
|
+
this.pollIntervalMs = pollIntervalMs;
|
|
94
|
+
this.timeoutMs = timeoutMs;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
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.
|
|
101
|
+
*/
|
|
102
|
+
async getToken(): Promise<string> {
|
|
103
|
+
const token = await this.getUploadToken();
|
|
104
|
+
if (!token) throw new NotAuthenticatedError();
|
|
105
|
+
return token;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
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.
|
|
112
|
+
*/
|
|
113
|
+
async createDeploy(files: Digest, token: string): Promise<DropResponse> {
|
|
114
|
+
const path = this.accountSlug ? `/${this.accountSlug}/sites` : '/sites';
|
|
115
|
+
const siteRes = await fetch(`${this.apiBase}${path}`, {
|
|
116
|
+
method: 'POST',
|
|
117
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
118
|
+
body: JSON.stringify({ created_via: 'drop' }),
|
|
119
|
+
});
|
|
120
|
+
// A logged-out visitor 401s here, before anything is created — the one
|
|
121
|
+
// point an anonymous fallback is still safe (see the class doc).
|
|
122
|
+
if (siteRes.status === 401 || siteRes.status === 403) throw new NotAuthenticatedError();
|
|
123
|
+
if (!siteRes.ok) throw new SiteCreateError(siteRes.status);
|
|
124
|
+
const site: BuildSite = await siteRes.json();
|
|
125
|
+
|
|
126
|
+
const deployRes = await fetch(`${this.apiBase}/sites/${site.id}/deploys`, {
|
|
127
|
+
method: 'POST',
|
|
128
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
129
|
+
body: JSON.stringify({ files, deploy_source: 'drop' }),
|
|
130
|
+
});
|
|
131
|
+
if (!deployRes.ok) throw apiError('deploy create', deployRes.status);
|
|
132
|
+
const deploy: SiteDeployResponse = await deployRes.json();
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
id: site.id,
|
|
136
|
+
deploy_id: deploy.id,
|
|
137
|
+
subdomain: site.name,
|
|
138
|
+
required: deploy.required ?? [],
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async uploadFile(
|
|
143
|
+
deployId: string,
|
|
144
|
+
path: string,
|
|
145
|
+
content: ArrayBuffer,
|
|
146
|
+
token: string
|
|
147
|
+
): Promise<void> {
|
|
148
|
+
// PUT (not POST) so paths with "&" etc. work.
|
|
149
|
+
const res = await fetch(`${this.apiBase}/deploys/${deployId}/files${path}`, {
|
|
150
|
+
method: 'PUT',
|
|
151
|
+
headers: { 'Content-Type': 'application/octet-stream', Authorization: `Bearer ${token}` },
|
|
152
|
+
body: content,
|
|
153
|
+
});
|
|
154
|
+
if (!res.ok) throw apiError(`upload ${path}`, res.status);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
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.
|
|
164
|
+
*/
|
|
165
|
+
async waitUntilReady(deploy: DropResponse, token: string): Promise<void> {
|
|
166
|
+
const deadline = this.timeoutMs / this.pollIntervalMs;
|
|
167
|
+
for (let i = 0; i < deadline; i++) {
|
|
168
|
+
const res = await fetch(`${this.apiBase}/deploys/${deploy.deploy_id}`, {
|
|
169
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
170
|
+
});
|
|
171
|
+
if (res.ok) {
|
|
172
|
+
const state: DeployState = await res.json();
|
|
173
|
+
if (state.state && TERMINAL_STATES.has(state.state)) {
|
|
174
|
+
if (state.state === 'ready') return;
|
|
175
|
+
throw new Error(
|
|
176
|
+
state.error_message ? `deploy failed: ${state.error_message}` : 'deploy failed'
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
await new Promise(r => setTimeout(r, this.pollIntervalMs));
|
|
181
|
+
}
|
|
182
|
+
// Same message the anonymous client uses, so humanizeDropError maps it.
|
|
183
|
+
throw new Error('Deploy did not become ready in time');
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BuildCreateError,
|
|
3
|
+
BuildFailedError,
|
|
4
|
+
BuildTimeoutError,
|
|
5
|
+
NotAuthenticatedError,
|
|
6
|
+
SiteCreateError,
|
|
7
|
+
} from './errors';
|
|
8
|
+
|
|
9
|
+
/** The subset of a Netlify Site the drop flow needs to redirect and report. */
|
|
10
|
+
export interface BuildSite {
|
|
11
|
+
/** site id (UUID) */
|
|
12
|
+
id: string;
|
|
13
|
+
/** the *.netlify.app subdomain, also the app's URL segment */
|
|
14
|
+
name: string;
|
|
15
|
+
/** https://<name>.netlify.app */
|
|
16
|
+
ssl_url?: string;
|
|
17
|
+
url?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The subset of a Netlify Build returned by `POST /sites/{id}/builds`. */
|
|
21
|
+
export interface BuildResponse {
|
|
22
|
+
/** build id */
|
|
23
|
+
id: string;
|
|
24
|
+
/** the deploy the build produces — present as soon as the build is enqueued */
|
|
25
|
+
deploy_id?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface BuildClient {
|
|
29
|
+
createSite(): Promise<BuildSite>;
|
|
30
|
+
createBuildFromZip(siteId: string, zip: File): Promise<BuildResponse>;
|
|
31
|
+
waitUntilBuildReady(build: BuildResponse): Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface AuthenticatedBuildClientConfig {
|
|
35
|
+
/**
|
|
36
|
+
* API base for every call. Requests go straight to `api.netlify.com`, whose
|
|
37
|
+
* `/api/*` CORS policy allows any origin, rather than through a consumer's
|
|
38
|
+
* access-control rewrite — that proxy allowlists paths per origin, and site
|
|
39
|
+
* creation and deploy reads are not on the list for netlify.com (they 400
|
|
40
|
+
* with `x-debug: request origin not allowed to call endpoint`). The zip
|
|
41
|
+
* upload could not use that proxy regardless: it runs in a Lambda with a
|
|
42
|
+
* ~6MB request body limit.
|
|
43
|
+
*/
|
|
44
|
+
apiBase?: string;
|
|
45
|
+
/**
|
|
46
|
+
* Mint a short-lived bearer token authorizing the calls. On netlify.com this
|
|
47
|
+
* is `GET /access-control/generate-access-control-token`, whose
|
|
48
|
+
* `accessControlToken` the API decrypts back into the user's access token.
|
|
49
|
+
* Resolving `null` means "no session" and surfaces as `NotAuthenticatedError`.
|
|
50
|
+
*/
|
|
51
|
+
getUploadToken: () => Promise<string | null>;
|
|
52
|
+
/** Create the site in this account. Omit to use the user's default account. */
|
|
53
|
+
accountSlug?: string;
|
|
54
|
+
/** Title recorded against the build. */
|
|
55
|
+
title?: string;
|
|
56
|
+
/** How often to re-read the deploy while waiting. Default 1s. */
|
|
57
|
+
pollIntervalMs?: number;
|
|
58
|
+
/** How long to wait before giving up on the build. Default 10 minutes. */
|
|
59
|
+
timeoutMs?: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const API_BASE = 'https://api.netlify.com/api/v1';
|
|
63
|
+
const POLL_INTERVAL_MS = 1000;
|
|
64
|
+
const TIMEOUT_MS = 10 * 60 * 1000;
|
|
65
|
+
|
|
66
|
+
/** Deploy states that end the wait — `ready` succeeds, the rest are failures. */
|
|
67
|
+
const TERMINAL_STATES = new Set(['ready', 'error', 'rejected']);
|
|
68
|
+
|
|
69
|
+
interface DeployState {
|
|
70
|
+
state?: string;
|
|
71
|
+
error_message?: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Authenticated drop client for projects that need a build. Unlike
|
|
76
|
+
* `AnonymousDropClient` — which creates an account-less static site and can only
|
|
77
|
+
* upload pre-built files — this creates a real site in the user's account and
|
|
78
|
+
* hands buildbot a source archive to build, mirroring what app.netlify.com does
|
|
79
|
+
* for a drag-and-drop build.
|
|
80
|
+
*
|
|
81
|
+
* Requests split across two hosts by necessity: JSON calls go through the
|
|
82
|
+
* consumer's access-control proxy (cookie session, no token handling in the
|
|
83
|
+
* browser), while the zip upload goes direct to `api.netlify.com` with a
|
|
84
|
+
* short-lived bearer token because the proxy can't carry a body that large.
|
|
85
|
+
*/
|
|
86
|
+
export class AuthenticatedBuildClient implements BuildClient {
|
|
87
|
+
private apiBase: string;
|
|
88
|
+
private getUploadToken: () => Promise<string | null>;
|
|
89
|
+
private accountSlug?: string;
|
|
90
|
+
private title: string;
|
|
91
|
+
private pollIntervalMs: number;
|
|
92
|
+
private timeoutMs: number;
|
|
93
|
+
|
|
94
|
+
constructor({
|
|
95
|
+
apiBase = API_BASE,
|
|
96
|
+
getUploadToken,
|
|
97
|
+
accountSlug,
|
|
98
|
+
title = 'Build from drop deployment',
|
|
99
|
+
pollIntervalMs = POLL_INTERVAL_MS,
|
|
100
|
+
timeoutMs = TIMEOUT_MS,
|
|
101
|
+
}: AuthenticatedBuildClientConfig) {
|
|
102
|
+
this.apiBase = apiBase;
|
|
103
|
+
this.getUploadToken = getUploadToken;
|
|
104
|
+
this.accountSlug = accountSlug;
|
|
105
|
+
this.title = title;
|
|
106
|
+
this.pollIntervalMs = pollIntervalMs;
|
|
107
|
+
this.timeoutMs = timeoutMs;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Create the site the build deploys into. Authorized by the bearer token;
|
|
112
|
+
* without an account slug the API picks the user's default account.
|
|
113
|
+
*/
|
|
114
|
+
async createSite(): Promise<BuildSite> {
|
|
115
|
+
const token = await this.getUploadToken();
|
|
116
|
+
if (!token) throw new NotAuthenticatedError();
|
|
117
|
+
|
|
118
|
+
const path = this.accountSlug ? `/${this.accountSlug}/sites` : '/sites';
|
|
119
|
+
const res = await fetch(`${this.apiBase}${path}`, {
|
|
120
|
+
method: 'POST',
|
|
121
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
122
|
+
body: JSON.stringify({ created_via: 'drop' }),
|
|
123
|
+
});
|
|
124
|
+
// A logged-out visitor gets a 401 here rather than at the token step, since
|
|
125
|
+
// this is the first call that needs the session.
|
|
126
|
+
if (res.status === 401 || res.status === 403) throw new NotAuthenticatedError();
|
|
127
|
+
if (!res.ok) throw new SiteCreateError(res.status);
|
|
128
|
+
return res.json();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Upload the source archive and enqueue a build. Direct to `api.netlify.com`
|
|
133
|
+
* (see the note on `apiBase`), authorized by a freshly minted bearer token.
|
|
134
|
+
* `Content-Type` is left unset on purpose so the browser adds the multipart
|
|
135
|
+
* boundary itself.
|
|
136
|
+
*/
|
|
137
|
+
async createBuildFromZip(siteId: string, zip: File): Promise<BuildResponse> {
|
|
138
|
+
const token = await this.getUploadToken();
|
|
139
|
+
if (!token) throw new NotAuthenticatedError();
|
|
140
|
+
|
|
141
|
+
const body = new FormData();
|
|
142
|
+
body.append('zip', zip);
|
|
143
|
+
body.append('title', this.title);
|
|
144
|
+
|
|
145
|
+
const res = await fetch(`${this.apiBase}/sites/${siteId}/builds`, {
|
|
146
|
+
method: 'POST',
|
|
147
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
148
|
+
body,
|
|
149
|
+
});
|
|
150
|
+
if (res.status === 401 || res.status === 403) throw new NotAuthenticatedError();
|
|
151
|
+
if (!res.ok) throw new BuildCreateError(res.status);
|
|
152
|
+
return res.json();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Poll the build's deploy until it settles, mirroring
|
|
157
|
+
* `AnonymousDropClient.waitUntilReady` — resolve on `ready`, throw on
|
|
158
|
+
* `error`/`rejected`, throw on timeout. A build that hasn't been given a
|
|
159
|
+
* deploy id yet has nothing pollable, so it resolves immediately and the
|
|
160
|
+
* caller falls back to the site page.
|
|
161
|
+
*
|
|
162
|
+
* Optional in the drop flow, and off by default: a build takes minutes, which
|
|
163
|
+
* can outlive the bearer token minted for it. Each poll re-mints so a long
|
|
164
|
+
* build keeps reading; a token that can't be minted any more ends the wait as
|
|
165
|
+
* a timeout rather than a false failure.
|
|
166
|
+
*/
|
|
167
|
+
async waitUntilBuildReady(build: BuildResponse): Promise<void> {
|
|
168
|
+
if (!build.deploy_id) return;
|
|
169
|
+
|
|
170
|
+
const deadline = this.timeoutMs / this.pollIntervalMs;
|
|
171
|
+
for (let i = 0; i < deadline; i++) {
|
|
172
|
+
const token = await this.getUploadToken();
|
|
173
|
+
const res = await fetch(`${this.apiBase}/deploys/${build.deploy_id}`, {
|
|
174
|
+
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
|
175
|
+
});
|
|
176
|
+
if (res.ok) {
|
|
177
|
+
const deploy: DeployState = await res.json();
|
|
178
|
+
if (deploy.state && TERMINAL_STATES.has(deploy.state)) {
|
|
179
|
+
if (deploy.state === 'ready') return;
|
|
180
|
+
throw new BuildFailedError(deploy.error_message);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
await new Promise(r => setTimeout(r, this.pollIntervalMs));
|
|
184
|
+
}
|
|
185
|
+
throw new BuildTimeoutError();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { DropResponse, Digest } from './types';
|
|
2
2
|
|
|
3
3
|
/** A normal Error with the response's HTTP status attached, so callers can branch on it directly. */
|
|
4
|
-
function apiError(action: string, status: number): Error & { status: number } {
|
|
4
|
+
export function apiError(action: string, status: number): Error & { status: number } {
|
|
5
5
|
return Object.assign(new Error(`${action} failed: ${status}`), { status });
|
|
6
6
|
}
|
|
7
7
|
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { getDigest } from './digest';
|
|
2
|
+
import type { BuildClient, BuildResponse, BuildSite } from './buildClient';
|
|
2
3
|
import type { DropClient } from './client';
|
|
4
|
+
import type { InferredBuildSettings } from './detectBuild';
|
|
3
5
|
import type { DeployProgress, DeployResult, ProcessedFile } from './types';
|
|
6
|
+
import { zipFiles } from './zip';
|
|
4
7
|
|
|
5
8
|
const MAX_CONCURRENT_UPLOADS = 5;
|
|
6
9
|
|
|
@@ -55,3 +58,65 @@ export async function deployFiles(
|
|
|
55
58
|
onProgress?.({ phase: 'ready' });
|
|
56
59
|
return { deploy, token, url: `https://${deploy.subdomain}.netlify.app` };
|
|
57
60
|
}
|
|
61
|
+
|
|
62
|
+
export interface BuildDeployResult {
|
|
63
|
+
/** The site created for this build. */
|
|
64
|
+
site: BuildSite;
|
|
65
|
+
/** The enqueued build; `deploy_id` is what the app's deploy page keys off. */
|
|
66
|
+
build: BuildResponse;
|
|
67
|
+
/** Settings synthesised into the uploaded `netlify.toml`, or null if none were. */
|
|
68
|
+
buildSettings: InferredBuildSettings | null;
|
|
69
|
+
/** The site's live URL — only serves content once the build finishes. */
|
|
70
|
+
url: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface BuildDeployOptions extends DeployOptions {
|
|
74
|
+
/**
|
|
75
|
+
* Poll until the build finishes instead of returning as soon as it's enqueued.
|
|
76
|
+
* Off by default: a build takes minutes, so the useful hand-off is usually the
|
|
77
|
+
* app's deploy page with its live logs, not a spinner. Turn it on when the
|
|
78
|
+
* caller needs to know the outcome — only then can a `BuildFailedError` reach
|
|
79
|
+
* it with the build's own error message.
|
|
80
|
+
*/
|
|
81
|
+
waitForBuild?: boolean;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The build counterpart to `deployFiles`: zip the drop, create a site in the
|
|
86
|
+
* user's account, and hand the archive to buildbot. Requires an authenticated
|
|
87
|
+
* client — builds have no anonymous equivalent.
|
|
88
|
+
*
|
|
89
|
+
* Site creation deliberately happens *after* zipping, so a zip that fails
|
|
90
|
+
* doesn't leave an empty site behind in the user's account.
|
|
91
|
+
*/
|
|
92
|
+
export async function deployBuild(
|
|
93
|
+
client: BuildClient,
|
|
94
|
+
files: ProcessedFile[],
|
|
95
|
+
opts: BuildDeployOptions = {}
|
|
96
|
+
): Promise<BuildDeployResult> {
|
|
97
|
+
if (!files.length) throw new Error('No files to deploy');
|
|
98
|
+
const { onProgress, waitForBuild = false } = opts;
|
|
99
|
+
|
|
100
|
+
onProgress?.({ phase: 'zipping' });
|
|
101
|
+
const { file: zip, buildSettings } = await zipFiles(files);
|
|
102
|
+
|
|
103
|
+
onProgress?.({ phase: 'creating' });
|
|
104
|
+
const site = await client.createSite();
|
|
105
|
+
|
|
106
|
+
// No byte-level progress to report: this is one multipart request, and
|
|
107
|
+
// `fetch` exposes no upload progress. The phase still flips so the UI can say
|
|
108
|
+
// "uploading" rather than sitting on "creating" for the length of the upload.
|
|
109
|
+
onProgress?.({ phase: 'uploading' });
|
|
110
|
+
const build = await client.createBuildFromZip(site.id, zip);
|
|
111
|
+
|
|
112
|
+
onProgress?.({ phase: 'building' });
|
|
113
|
+
if (waitForBuild) await client.waitUntilBuildReady(build);
|
|
114
|
+
|
|
115
|
+
onProgress?.({ phase: 'ready' });
|
|
116
|
+
return {
|
|
117
|
+
site,
|
|
118
|
+
build,
|
|
119
|
+
buildSettings,
|
|
120
|
+
url: site.ssl_url || site.url || `https://${site.name}.netlify.app`,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -18,28 +18,35 @@ export interface BuildDetection {
|
|
|
18
18
|
* Dependencies whose presence implies a build even without an explicit `build`
|
|
19
19
|
* script (e.g. a framework wired through its own CLI). Not exhaustive — the
|
|
20
20
|
* `build` script check below catches the common case; this is the backstop.
|
|
21
|
+
*
|
|
22
|
+
* The value is the framework's default publish directory, or `null` where that
|
|
23
|
+
* directory isn't a fixed string we can state with confidence — Nuxt, SvelteKit,
|
|
24
|
+
* Remix and Angular all resolve it from an adapter or project name. `null` means
|
|
25
|
+
* "a build is required, but don't guess a publish directory": see
|
|
26
|
+
* `inferBuildSettings`, which only synthesises a `netlify.toml` when it knows
|
|
27
|
+
* both halves of the answer.
|
|
21
28
|
*/
|
|
22
|
-
const FRAMEWORK_DEPENDENCIES = new
|
|
23
|
-
'astro',
|
|
24
|
-
'next',
|
|
25
|
-
'nuxt',
|
|
26
|
-
'gatsby',
|
|
27
|
-
'vite',
|
|
28
|
-
'react-scripts',
|
|
29
|
-
'parcel',
|
|
30
|
-
'expo',
|
|
31
|
-
'gridsome',
|
|
32
|
-
'vuepress',
|
|
33
|
-
'hexo',
|
|
34
|
-
'@remix-run/dev',
|
|
35
|
-
'@sveltejs/kit',
|
|
36
|
-
'@angular/cli',
|
|
37
|
-
'@vue/cli-service',
|
|
38
|
-
'@11ty/eleventy',
|
|
39
|
-
'@docusaurus/core',
|
|
40
|
-
'@builder.io/qwik',
|
|
41
|
-
'@analogjs/platform',
|
|
42
|
-
'@solidjs/start',
|
|
29
|
+
const FRAMEWORK_DEPENDENCIES = new Map<string, string | null>([
|
|
30
|
+
['astro', 'dist'],
|
|
31
|
+
['next', '.next'],
|
|
32
|
+
['nuxt', null],
|
|
33
|
+
['gatsby', 'public'],
|
|
34
|
+
['vite', 'dist'],
|
|
35
|
+
['react-scripts', 'build'],
|
|
36
|
+
['parcel', 'dist'],
|
|
37
|
+
['expo', 'dist'],
|
|
38
|
+
['gridsome', 'dist'],
|
|
39
|
+
['vuepress', null],
|
|
40
|
+
['hexo', 'public'],
|
|
41
|
+
['@remix-run/dev', null],
|
|
42
|
+
['@sveltejs/kit', null],
|
|
43
|
+
['@angular/cli', null],
|
|
44
|
+
['@vue/cli-service', 'dist'],
|
|
45
|
+
['@11ty/eleventy', '_site'],
|
|
46
|
+
['@docusaurus/core', 'build'],
|
|
47
|
+
['@builder.io/qwik', 'dist'],
|
|
48
|
+
['@analogjs/platform', null],
|
|
49
|
+
['@solidjs/start', null],
|
|
43
50
|
]);
|
|
44
51
|
|
|
45
52
|
/**
|
|
@@ -71,16 +78,21 @@ function hasBuildScript(pkg: Record<string, unknown>): boolean {
|
|
|
71
78
|
return typeof build === 'string' && build.trim().length > 0;
|
|
72
79
|
}
|
|
73
80
|
|
|
74
|
-
/**
|
|
75
|
-
function
|
|
81
|
+
/** The first known build-requiring framework among the deps (prod or dev), if any. */
|
|
82
|
+
function findFrameworkDependency(pkg: Record<string, unknown>): string | null {
|
|
76
83
|
for (const key of ['dependencies', 'devDependencies'] as const) {
|
|
77
84
|
const deps = pkg[key];
|
|
78
85
|
if (!deps || typeof deps !== 'object') continue;
|
|
79
86
|
for (const name of Object.keys(deps)) {
|
|
80
|
-
if (FRAMEWORK_DEPENDENCIES.has(name)) return
|
|
87
|
+
if (FRAMEWORK_DEPENDENCIES.has(name)) return name;
|
|
81
88
|
}
|
|
82
89
|
}
|
|
83
|
-
return
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** True if any dependency (prod or dev) is a known build-requiring framework. */
|
|
94
|
+
function hasFrameworkDependency(pkg: Record<string, unknown>): boolean {
|
|
95
|
+
return findFrameworkDependency(pkg) !== null;
|
|
84
96
|
}
|
|
85
97
|
|
|
86
98
|
/**
|
|
@@ -102,23 +114,13 @@ export function detectBuild(files: ProcessedFile[]): BuildDetection {
|
|
|
102
114
|
return { buildRequired: true, reason: 'netlify-toml-build' };
|
|
103
115
|
}
|
|
104
116
|
|
|
105
|
-
const
|
|
106
|
-
if (
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
parsed = JSON.parse(text(pkg)) as Record<string, unknown>;
|
|
110
|
-
} catch {
|
|
111
|
-
// A malformed package.json isn't a reliable build signal on its own —
|
|
112
|
-
// fall through and let the other heuristics decide.
|
|
113
|
-
parsed = null;
|
|
117
|
+
const parsed = readPackageJson(files);
|
|
118
|
+
if (parsed) {
|
|
119
|
+
if (hasBuildScript(parsed)) {
|
|
120
|
+
return { buildRequired: true, reason: 'package-json-build-script' };
|
|
114
121
|
}
|
|
115
|
-
if (parsed) {
|
|
116
|
-
|
|
117
|
-
return { buildRequired: true, reason: 'package-json-build-script' };
|
|
118
|
-
}
|
|
119
|
-
if (hasFrameworkDependency(parsed)) {
|
|
120
|
-
return { buildRequired: true, reason: 'framework-dependency' };
|
|
121
|
-
}
|
|
122
|
+
if (hasFrameworkDependency(parsed)) {
|
|
123
|
+
return { buildRequired: true, reason: 'framework-dependency' };
|
|
122
124
|
}
|
|
123
125
|
}
|
|
124
126
|
|
|
@@ -128,3 +130,85 @@ export function detectBuild(files: ProcessedFile[]): BuildDetection {
|
|
|
128
130
|
|
|
129
131
|
return { buildRequired: false, reason: null };
|
|
130
132
|
}
|
|
133
|
+
|
|
134
|
+
/** Parse the root `package.json`, or null when it's absent or malformed. */
|
|
135
|
+
function readPackageJson(files: ProcessedFile[]): Record<string, unknown> | null {
|
|
136
|
+
const pkg = rootFile(files, '/package.json');
|
|
137
|
+
if (!pkg) return null;
|
|
138
|
+
try {
|
|
139
|
+
return JSON.parse(text(pkg)) as Record<string, unknown>;
|
|
140
|
+
} catch {
|
|
141
|
+
// A malformed package.json isn't a reliable build signal on its own —
|
|
142
|
+
// callers fall through and let the other heuristics decide.
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// --- build settings for the synthesised netlify.toml ------------------------
|
|
148
|
+
|
|
149
|
+
/** Build settings confident enough to write into a `netlify.toml` for buildbot. */
|
|
150
|
+
export interface InferredBuildSettings {
|
|
151
|
+
/** Build command, e.g. `"npm run build"`. */
|
|
152
|
+
command: string;
|
|
153
|
+
/** Publish directory relative to the deploy root, no leading slash, e.g. `"dist"`. */
|
|
154
|
+
publish: string;
|
|
155
|
+
/** The dependency the settings were inferred from, e.g. `"astro"`. */
|
|
156
|
+
framework: string;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Lockfile at the deploy root → the package manager that wrote it. */
|
|
160
|
+
const LOCKFILE_PACKAGE_MANAGERS: ReadonlyArray<readonly [string, string]> = [
|
|
161
|
+
['/pnpm-lock.yaml', 'pnpm'],
|
|
162
|
+
['/yarn.lock', 'yarn'],
|
|
163
|
+
['/bun.lockb', 'bun'],
|
|
164
|
+
['/bun.lock', 'bun'],
|
|
165
|
+
['/package-lock.json', 'npm'],
|
|
166
|
+
];
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* A bare `[build]` table header at the start of a line. Stricter than
|
|
170
|
+
* `NETLIFY_TOML_BUILD_SECTION` on purpose: a config with only
|
|
171
|
+
* `[build.environment]` carries no command or publish directory, so it still
|
|
172
|
+
* needs a `[build]` table synthesised. (TOML permits defining a super-table
|
|
173
|
+
* after its sub-tables, so appending one is valid.)
|
|
174
|
+
*/
|
|
175
|
+
const NETLIFY_TOML_BUILD_TABLE = /^\s*\[build\]/m;
|
|
176
|
+
|
|
177
|
+
function detectPackageManager(files: ProcessedFile[]): string {
|
|
178
|
+
for (const [path, manager] of LOCKFILE_PACKAGE_MANAGERS) {
|
|
179
|
+
if (rootFile(files, path)) return manager;
|
|
180
|
+
}
|
|
181
|
+
return 'npm';
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Infer the build command and publish directory for a build-required drop, so
|
|
186
|
+
* `zipFiles` can write them into a `netlify.toml` that buildbot reads.
|
|
187
|
+
*
|
|
188
|
+
* Deliberately conservative: it returns settings **only** when both halves are
|
|
189
|
+
* known — a `build` script (which gives the command) and a recognised framework
|
|
190
|
+
* with a fixed default publish directory (which gives the output). Anything else
|
|
191
|
+
* returns `null`, leaving the zip without a synthesised `[build]` table so
|
|
192
|
+
* buildbot's own framework detection resolves the settings instead. That matters
|
|
193
|
+
* because a `[build]` table with a command but no `publish` makes buildbot
|
|
194
|
+
* publish the deploy root — shipping the project's *source* rather than its
|
|
195
|
+
* built output, which is worse than not guessing at all.
|
|
196
|
+
*
|
|
197
|
+
* Returns `null` too when the drop already declares a `[build]` table: the
|
|
198
|
+
* author's own config always wins.
|
|
199
|
+
*/
|
|
200
|
+
export function inferBuildSettings(files: ProcessedFile[]): InferredBuildSettings | null {
|
|
201
|
+
const toml = rootFile(files, '/netlify.toml');
|
|
202
|
+
if (toml && NETLIFY_TOML_BUILD_TABLE.test(text(toml))) return null;
|
|
203
|
+
|
|
204
|
+
const pkg = readPackageJson(files);
|
|
205
|
+
if (!pkg || !hasBuildScript(pkg)) return null;
|
|
206
|
+
|
|
207
|
+
const framework = findFrameworkDependency(pkg);
|
|
208
|
+
if (!framework) return null;
|
|
209
|
+
|
|
210
|
+
const publish = FRAMEWORK_DEPENDENCIES.get(framework);
|
|
211
|
+
if (!publish) return null;
|
|
212
|
+
|
|
213
|
+
return { command: `${detectPackageManager(files)} run build`, publish, framework };
|
|
214
|
+
}
|