@netlify/spark-ui 1.31.0-alpha.4 → 1.31.0-alpha.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/preact/DropZone/DropZone.d.ts +6 -7
- package/dist/components/preact/DropZone/DropZone.js +5 -5
- package/dist/components/preact/DropZone/index.js +29 -29
- package/dist/components/preact/DropZone/useDropDeploy.d.ts +0 -1
- package/dist/components/preact/DropZone/useDropDeploy.js +25 -27
- package/dist/components/preact/index.js +10 -10
- package/dist/drop-core/authedApi.d.ts +0 -10
- package/dist/drop-core/authedApi.js +15 -20
- package/dist/drop-core/authedDropClient.d.ts +4 -11
- package/dist/drop-core/authedDropClient.js +24 -33
- package/dist/drop-core/buildClient.js +17 -16
- package/dist/drop-core/client.d.ts +3 -1
- package/dist/drop-core/client.js +19 -17
- package/dist/drop-core/constants.d.ts +29 -0
- package/dist/drop-core/constants.js +10 -0
- package/dist/drop-core/deploy.d.ts +2 -0
- package/dist/drop-core/deploy.js +56 -43
- package/dist/drop-core/errors.d.ts +29 -0
- package/dist/drop-core/errors.js +70 -33
- package/dist/drop-core/index.d.ts +22 -11
- package/dist/drop-core/index.js +45 -49
- package/package.json +1 -1
- package/packages/components/preact/DropZone/DropZone.tsx +6 -7
- package/packages/components/preact/DropZone/useDropDeploy.ts +13 -22
- package/packages/drop-core/README.md +91 -0
- package/packages/drop-core/authedApi.ts +0 -13
- package/packages/drop-core/authedDropClient.ts +12 -33
- package/packages/drop-core/buildClient.ts +2 -3
- package/packages/drop-core/client.ts +18 -6
- package/packages/drop-core/constants.ts +39 -0
- package/packages/drop-core/deploy.test.ts +42 -0
- package/packages/drop-core/deploy.ts +41 -5
- package/packages/drop-core/errors.test.ts +32 -3
- package/packages/drop-core/errors.ts +67 -10
- package/packages/drop-core/index.ts +41 -11
- package/packages/drop-core/readFiles.test.ts +0 -1
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
getSingleNonIndexHtmlFileName,
|
|
16
16
|
hasMultipleHtmlFilesWithoutIndex,
|
|
17
17
|
humanizeDropError,
|
|
18
|
-
|
|
18
|
+
isNotAuthenticatedError,
|
|
19
19
|
renameSingleNonIndexHtmlToIndex,
|
|
20
20
|
type BuildClient,
|
|
21
21
|
type BuildDetection,
|
|
@@ -88,7 +88,6 @@ export type DeployStatus =
|
|
|
88
88
|
| { kind: 'redirecting'; target: 'claim' | 'dashboard' | 'signup' | 'build' }
|
|
89
89
|
| { kind: 'error'; message: string };
|
|
90
90
|
|
|
91
|
-
/** Re-exported from the drop-core analytics contract, where the shape now lives. */
|
|
92
91
|
export type { TrackFn };
|
|
93
92
|
|
|
94
93
|
interface UseDropDeployOptions {
|
|
@@ -185,15 +184,12 @@ export function useDropDeploy({
|
|
|
185
184
|
track,
|
|
186
185
|
}: UseDropDeployOptions) {
|
|
187
186
|
/**
|
|
188
|
-
* Typed veneer over the consumer's `track`:
|
|
189
|
-
* DROPZONE_EVENTS contract
|
|
190
|
-
*
|
|
191
|
-
* analytics history.
|
|
187
|
+
* Typed veneer over the consumer's `track`: each payload is checked against
|
|
188
|
+
* the DROPZONE_EVENTS contract, so a renamed property fails to compile
|
|
189
|
+
* instead of silently forking the analytics history.
|
|
192
190
|
*/
|
|
193
191
|
const emit = <E extends DropZoneEventName>(event: E, props: DropZoneEventProperties[E]) =>
|
|
194
|
-
// The
|
|
195
|
-
// signature (so payloads stay closed for consumers), and the checking that
|
|
196
|
-
// matters happened against DropZoneEventProperties[E] above.
|
|
192
|
+
// The event interfaces carry no index signature, hence the cast to TrackFn's record.
|
|
197
193
|
track(event, props as Record<string, unknown>);
|
|
198
194
|
|
|
199
195
|
const [status, setStatus] = useState<DeployStatus>({ kind: 'idle' });
|
|
@@ -247,7 +243,7 @@ export function useDropDeploy({
|
|
|
247
243
|
buildSettings,
|
|
248
244
|
};
|
|
249
245
|
} catch (err) {
|
|
250
|
-
if (err
|
|
246
|
+
if (isNotAuthenticatedError(err)) return null;
|
|
251
247
|
throw err;
|
|
252
248
|
}
|
|
253
249
|
}
|
|
@@ -276,17 +272,12 @@ export function useDropDeploy({
|
|
|
276
272
|
|
|
277
273
|
const typeProps = fileTypes.length ? { file_types: fileTypes } : {};
|
|
278
274
|
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
// read
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
// in that case — a rejected single file is the main thing we want to see.
|
|
286
|
-
// `reason` goes to analytics as-is (raw, e.g. "drop failed: 429"); `message`
|
|
287
|
-
// is what the user sees, which for a caught error is humanized separately.
|
|
288
|
-
// `onError` gets both so a consumer (e.g. with `showStatus={false}`) can show
|
|
289
|
-
// the humanized message while keeping the raw reason for their own logging.
|
|
275
|
+
// Every failure path reports through here so the failure event stays
|
|
276
|
+
// uniform. `file_count` is only included where the file set is known (it
|
|
277
|
+
// isn't if `read()` threw); `file_types` always rides along because it *is*
|
|
278
|
+
// known then — a rejected single file is the main thing worth seeing.
|
|
279
|
+
// `reason` is raw (e.g. "drop failed: 429") for analytics; `message` is the
|
|
280
|
+
// humanized copy the user sees; `onError` gets both.
|
|
290
281
|
const fail = (message: string, reason: string, extra?: Record<string, unknown>) => {
|
|
291
282
|
setStatus({ kind: 'error', message });
|
|
292
283
|
emit(DROPZONE_EVENTS.DEPLOY_FAILED, { method: source, reason, ...typeProps, ...extra });
|
|
@@ -425,7 +416,7 @@ export function useDropDeploy({
|
|
|
425
416
|
onProgress: e => setStatus(progressToStatus(e)),
|
|
426
417
|
});
|
|
427
418
|
} catch (err) {
|
|
428
|
-
if (err
|
|
419
|
+
if (isNotAuthenticatedError(err)) return null;
|
|
429
420
|
throw err;
|
|
430
421
|
}
|
|
431
422
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# drop-core
|
|
2
|
+
|
|
3
|
+
The framework-free engine behind the Drop Zone: reading a dropped folder/zip
|
|
4
|
+
into deployable files, deciding whether it needs a build, and deploying it —
|
|
5
|
+
anonymously (claim flow) or into a logged-in visitor's account — plus the
|
|
6
|
+
stash that carries a build drop across the signup round-trip.
|
|
7
|
+
|
|
8
|
+
Consumed three ways: by the Preact `DropZone` in this repo, by anything
|
|
9
|
+
importing `@netlify/spark-ui/drop-core`, and eventually by other Netlify
|
|
10
|
+
surfaces implementing their own Drop UI over the same core.
|
|
11
|
+
|
|
12
|
+
## Conventions
|
|
13
|
+
|
|
14
|
+
These follow the practices shared by the libraries doing our two jobs —
|
|
15
|
+
react-dropzone/Uppy/FilePond on the component side, netlify-cli's deploy
|
|
16
|
+
engine, @vercel/client, and tus-js-client on the pipeline side. Where we
|
|
17
|
+
deliberately differ, that's recorded too, so it isn't re-litigated by accident.
|
|
18
|
+
|
|
19
|
+
### Boundaries
|
|
20
|
+
|
|
21
|
+
- **Nothing in `drop-core` may import from `preact`** (or any UI framework).
|
|
22
|
+
Anything that imports preact may not contain deploy/read/detect logic — it
|
|
23
|
+
adapts this package to a view, nothing more.
|
|
24
|
+
- **The barrel is the public API.** Nothing is public unless `index.ts` names
|
|
25
|
+
it; `export *` is reserved for modules that are contracts in their entirety
|
|
26
|
+
(`types`, `errors`, `analytics`). New helpers default to private.
|
|
27
|
+
- **Granular pure modules, one cohesive orchestrator.** Pure helpers get their
|
|
28
|
+
own small file named after what they export; the deploy orchestration stays
|
|
29
|
+
together in `deploy.ts` rather than fragmenting a state machine.
|
|
30
|
+
- **Shared tunables live in `constants.ts`** with a unit-bearing comment each
|
|
31
|
+
(`// 10 minutes`, not `// the timeout`). Module-specific constants stay with
|
|
32
|
+
their module. No magic numbers inline.
|
|
33
|
+
|
|
34
|
+
### Naming
|
|
35
|
+
|
|
36
|
+
| Kind | Convention | Examples here |
|
|
37
|
+
| ------------------ | ---------------------------------- | ------------------------------------------------------------- |
|
|
38
|
+
| Outcome callbacks | `onVerb` / past participle | `onDeploy`, `onBuildRequired`, `onError` |
|
|
39
|
+
| Boolean state | `isX` | `busy` (exception, pre-dates rule), `isNotAuthenticatedError` |
|
|
40
|
+
| Strategy injection | bare noun | `client`, `buildClient`, `validator`-style |
|
|
41
|
+
| Analytics events | `dropzone_snake_case` — **frozen** | `DROPZONE_EVENTS` (test-pinned wire names) |
|
|
42
|
+
| Error predicates | `isXError(err)` | `isNotAuthenticatedError` |
|
|
43
|
+
| Files | filename = main export | `detectBuild.ts`, `buildStash.ts` |
|
|
44
|
+
|
|
45
|
+
### Comments
|
|
46
|
+
|
|
47
|
+
- **WHY over WHAT.** A comment earns its place by stating something the code
|
|
48
|
+
can't: an invariant, an observed API behavior, a browser quirk, a
|
|
49
|
+
don't-simplify warning. Never narrate the next line.
|
|
50
|
+
- **Record observed API behavior, with receipts.** The model comment in this
|
|
51
|
+
package: readiness polling keys off `deploy_id` (BSON), not `deploy.id` (site
|
|
52
|
+
UUID), "or the request 401s misleadingly" — naming the server-side file. Say
|
|
53
|
+
what the server _actually does_, and link the server source when you can.
|
|
54
|
+
- **Repeat load-bearing quirk notes at every call site.** The `deploy_id` note
|
|
55
|
+
appears in the client _and_ the orchestrator on purpose — a note that only
|
|
56
|
+
exists where nobody is reading protects nothing.
|
|
57
|
+
- **"Don't simplify" notes name what breaks** — the invariant, and ideally the
|
|
58
|
+
test that pins it (e.g. the `NotAuthenticatedError` boundary below).
|
|
59
|
+
- **No `@param`/`@returns`.** Types carry the shape; JSDoc carries rationale
|
|
60
|
+
and edge cases. (react-dropzone: zero `@param` across its entire source.)
|
|
61
|
+
- File-header purpose blocks only where the filename undersells the contents.
|
|
62
|
+
|
|
63
|
+
### Errors
|
|
64
|
+
|
|
65
|
+
- **Class per failure, each with a readonly kebab-case `code` brand.** This
|
|
66
|
+
package ships as raw TS (`./components`) _and_ compiled dist (`./preact`,
|
|
67
|
+
`./drop-core`) — two copies of a class can coexist in one app and defeat
|
|
68
|
+
`instanceof`. Detection that steers control flow must go through a
|
|
69
|
+
code-checking predicate (`isNotAuthenticatedError`), never bare `instanceof`.
|
|
70
|
+
- **`NotAuthenticatedError` is pre-mutation only.** It may be thrown solely
|
|
71
|
+
before anything is created (token gate, site-create 401) — it is the signal
|
|
72
|
+
that an anonymous retry is safe. Throwing it after a site exists would strand
|
|
73
|
+
an empty project and deploy a duplicate. Pinned by tests.
|
|
74
|
+
- **Humanized copy lives in `humanizeDropError`, in this package.** A
|
|
75
|
+
deliberate divergence from @vercel/client (which leaves rendering to the
|
|
76
|
+
CLI): our consumers render our copy directly, and the docs promise it.
|
|
77
|
+
- Transient upload failures retry on `UPLOAD_RETRY_DELAYS_MS` (backoff as
|
|
78
|
+
data, the tus idiom); **400/422 never retry** — the request itself is wrong.
|
|
79
|
+
|
|
80
|
+
### Considered and rejected
|
|
81
|
+
|
|
82
|
+
- **Async-generator lifecycles** (@vercel/client): our phase-callback design is
|
|
83
|
+
equivalent and churn-free; `DeployPhase` is a closed union, which netlify-cli's
|
|
84
|
+
own author wished theirs was.
|
|
85
|
+
- **Kebab-case event renames**: `dropzone_*` wire names are pinned analytics
|
|
86
|
+
history; `analytics.test.ts` fails on any rename. Append-only.
|
|
87
|
+
- **`getRootProps`/`getInputProps` attribute getters** (react-dropzone, Uppy):
|
|
88
|
+
the right shape for a future framework-free UI tier shared with the app —
|
|
89
|
+
not something to bolt onto the shipped wrapper component.
|
|
90
|
+
- **A `defaultOptions` object** (tus, Uppy): our destructured constructor
|
|
91
|
+
defaults are react-dropzone's pattern; either is fine, we have this one.
|
|
@@ -9,19 +9,6 @@ import { NotAuthenticatedError, SiteCreateError } from './errors';
|
|
|
9
9
|
* ~6MB — and each client handles its own, bearer-authorized.)
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
/**
|
|
13
|
-
* Default root for proxied JSON calls. On netlify.com the `/access-control/*`
|
|
14
|
-
* rewrite targets the app's access-control function, making this path the API
|
|
15
|
-
* root. The paths a given origin may call are allowlisted server-side.
|
|
16
|
-
*/
|
|
17
|
-
export const DEFAULT_PROXY_BASE = '/access-control/bb-api/api/v1';
|
|
18
|
-
|
|
19
|
-
/** Direct api.netlify.com root for the uploads the proxy can't carry. */
|
|
20
|
-
export const DEFAULT_API_BASE = 'https://api.netlify.com/api/v1';
|
|
21
|
-
|
|
22
|
-
export const DEFAULT_POLL_INTERVAL_MS = 1000;
|
|
23
|
-
export const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
|
|
24
|
-
|
|
25
12
|
/**
|
|
26
13
|
* Create a site in the visitor's account via `POST {proxyBase}/sites` (scoped
|
|
27
14
|
* to `accountSlug` when given, else the user's default account), attributed
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
+
import { createSiteInAccount, pollDeployUntilSettled } from './authedApi';
|
|
1
2
|
import {
|
|
2
|
-
createSiteInAccount,
|
|
3
3
|
DEFAULT_API_BASE,
|
|
4
4
|
DEFAULT_POLL_INTERVAL_MS,
|
|
5
5
|
DEFAULT_PROXY_BASE,
|
|
6
6
|
DEFAULT_TIMEOUT_MS,
|
|
7
|
-
|
|
8
|
-
} from './
|
|
7
|
+
TOKEN_REUSE_MS,
|
|
8
|
+
} from './constants';
|
|
9
9
|
import { apiError, type DropClient } from './client';
|
|
10
|
-
import { NotAuthenticatedError } from './errors';
|
|
10
|
+
import { DeployFailedError, DeployTimeoutError, NotAuthenticatedError } from './errors';
|
|
11
11
|
import type { BuildSite, Digest, DropResponse } from './types';
|
|
12
12
|
|
|
13
13
|
export interface AuthenticatedDropClientConfig {
|
|
@@ -42,13 +42,6 @@ export interface AuthenticatedDropClientConfig {
|
|
|
42
42
|
timeoutMs?: number;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
/**
|
|
46
|
-
* How long a minted upload token is reused before minting another. The server
|
|
47
|
-
* issues non-app origins a 30s token, so this leaves a margin for the request
|
|
48
|
-
* it authorizes to finish.
|
|
49
|
-
*/
|
|
50
|
-
const TOKEN_REUSE_MS = 20 * 1000;
|
|
51
|
-
|
|
52
45
|
/** Response body of `POST /sites/{id}/deploys` — the slice the drop flow needs. */
|
|
53
46
|
interface SiteDeployResponse {
|
|
54
47
|
/** deploy id (BSON) */
|
|
@@ -104,11 +97,8 @@ export class AuthenticatedDropClient implements DropClient {
|
|
|
104
97
|
}
|
|
105
98
|
|
|
106
99
|
/**
|
|
107
|
-
* The auth gate
|
|
108
|
-
* logged-out visitor fail cleanly and fall back to the anonymous flow.
|
|
109
|
-
* returned token is threaded through `deployFiles`, but only the uploads
|
|
110
|
-
* actually need one, and they re-mint rather than trusting this to still be
|
|
111
|
-
* valid by the time they run.
|
|
100
|
+
* The auth gate: minting here, before anything is created, is what lets a
|
|
101
|
+
* logged-out visitor fail cleanly and fall back to the anonymous flow.
|
|
112
102
|
*/
|
|
113
103
|
async getToken(): Promise<string> {
|
|
114
104
|
const token = await this.getUploadToken();
|
|
@@ -119,12 +109,8 @@ export class AuthenticatedDropClient implements DropClient {
|
|
|
119
109
|
|
|
120
110
|
/**
|
|
121
111
|
* A token young enough to authorize an upload, minting a new one once the
|
|
122
|
-
* cached one nears its 30s expiry.
|
|
123
|
-
*
|
|
124
|
-
* Deliberately NOT `NotAuthenticatedError` when minting fails: by the time
|
|
125
|
-
* uploads run the site already exists, and that error would send the drop
|
|
126
|
-
* back through the anonymous path — leaving an empty project behind and
|
|
127
|
-
* deploying a duplicate. See the class doc.
|
|
112
|
+
* cached one nears its 30s expiry. A mint failure here is a plain `Error`,
|
|
113
|
+
* never `NotAuthenticatedError` — the site exists by now (class doc).
|
|
128
114
|
*/
|
|
129
115
|
private async freshToken(): Promise<string> {
|
|
130
116
|
const now = Date.now();
|
|
@@ -143,8 +129,6 @@ export class AuthenticatedDropClient implements DropClient {
|
|
|
143
129
|
* `deployFiles` runs unchanged over either client.
|
|
144
130
|
*/
|
|
145
131
|
async createDeploy(files: Digest, _token: string): Promise<DropResponse> {
|
|
146
|
-
// A logged-out visitor fails inside createSiteInAccount, before anything is
|
|
147
|
-
// created — the one point an anonymous fallback is still safe (class doc).
|
|
148
132
|
const site: BuildSite = await createSiteInAccount(this.proxyBase, this.accountSlug);
|
|
149
133
|
|
|
150
134
|
const deployRes = await fetch(`${this.proxyBase}/sites/${site.id}/deploys`, {
|
|
@@ -197,14 +181,9 @@ export class AuthenticatedDropClient implements DropClient {
|
|
|
197
181
|
timeoutMs: this.timeoutMs,
|
|
198
182
|
});
|
|
199
183
|
if (settled.outcome === 'ready') return;
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
if (settled.outcome === 'failed')
|
|
203
|
-
|
|
204
|
-
settled.errorMessage ? `deploy failed: ${settled.errorMessage}` : 'deploy failed'
|
|
205
|
-
);
|
|
206
|
-
}
|
|
207
|
-
// Same message the anonymous client uses, so humanizeDropError maps it.
|
|
208
|
-
throw new Error('Deploy did not become ready in time');
|
|
184
|
+
// Neither class is NotAuthenticatedError-shaped on purpose: once the site
|
|
185
|
+
// exists, nothing here may trigger the anonymous fallback (class doc).
|
|
186
|
+
if (settled.outcome === 'failed') throw new DeployFailedError(settled.errorMessage);
|
|
187
|
+
throw new DeployTimeoutError();
|
|
209
188
|
}
|
|
210
189
|
}
|
|
@@ -1,11 +1,10 @@
|
|
|
1
|
+
import { createSiteInAccount, pollDeployUntilSettled } from './authedApi';
|
|
1
2
|
import {
|
|
2
|
-
createSiteInAccount,
|
|
3
3
|
DEFAULT_API_BASE,
|
|
4
4
|
DEFAULT_POLL_INTERVAL_MS,
|
|
5
5
|
DEFAULT_PROXY_BASE,
|
|
6
6
|
DEFAULT_TIMEOUT_MS,
|
|
7
|
-
|
|
8
|
-
} from './authedApi';
|
|
7
|
+
} from './constants';
|
|
9
8
|
import {
|
|
10
9
|
BuildCreateError,
|
|
11
10
|
BuildFailedError,
|
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `DropClient` seam: the four calls a static deploy needs — mint a token,
|
|
3
|
+
* create the deploy, upload a file, wait until ready — behind one interface,
|
|
4
|
+
* so `deployFiles` runs the same pipeline whether the deploy is anonymous
|
|
5
|
+
* (this file's `AnonymousDropClient`, claimed later by the visitor) or lands
|
|
6
|
+
* directly in an account (`authedDropClient.ts`).
|
|
7
|
+
*/
|
|
8
|
+
import { DEFAULT_API_BASE, DEFAULT_POLL_INTERVAL_MS, DEFAULT_TIMEOUT_MS } from './constants';
|
|
9
|
+
import { DeployTimeoutError } from './errors';
|
|
1
10
|
import type { DropResponse, Digest } from './types';
|
|
2
11
|
|
|
3
12
|
/** A normal Error with the response's HTTP status attached, so callers can branch on it directly. */
|
|
@@ -12,14 +21,16 @@ export interface DropClient {
|
|
|
12
21
|
waitUntilReady(deploy: DropResponse, token: string): Promise<void>;
|
|
13
22
|
}
|
|
14
23
|
|
|
15
|
-
const API = 'https://api.netlify.com/api/v1';
|
|
16
|
-
|
|
17
24
|
/**
|
|
18
25
|
* Unauthenticated drop client. Authorized server-side by the Referer header,
|
|
19
26
|
* so it only works from an allowlisted origin (see backend checklist).
|
|
20
27
|
*/
|
|
21
28
|
export class AnonymousDropClient implements DropClient {
|
|
22
|
-
constructor(
|
|
29
|
+
constructor(
|
|
30
|
+
private apiBase = DEFAULT_API_BASE,
|
|
31
|
+
private pollIntervalMs = DEFAULT_POLL_INTERVAL_MS,
|
|
32
|
+
private timeoutMs = DEFAULT_TIMEOUT_MS
|
|
33
|
+
) {}
|
|
23
34
|
|
|
24
35
|
async getToken(): Promise<string> {
|
|
25
36
|
const res = await fetch(`${this.apiBase}/drop/token`, { method: 'POST' });
|
|
@@ -57,13 +68,14 @@ export class AnonymousDropClient implements DropClient {
|
|
|
57
68
|
// Note: `/deploys/{id}` expects the deploy_id (BSON), not the site id (UUID)
|
|
58
69
|
// that lives at `deploy.id`. See drop_controller.rb; Deploy.find(uuid) is nil
|
|
59
70
|
// and the request 401s misleadingly via require_current_actor!.
|
|
60
|
-
|
|
71
|
+
const deadline = this.timeoutMs / this.pollIntervalMs;
|
|
72
|
+
for (let i = 0; i < deadline; i++) {
|
|
61
73
|
const res = await fetch(`${this.apiBase}/deploys/${deploy.deploy_id}`, {
|
|
62
74
|
headers: { Authorization: `Bearer ${token}` },
|
|
63
75
|
});
|
|
64
76
|
if (res.ok && (await res.json()).state === 'ready') return;
|
|
65
|
-
await new Promise(r => setTimeout(r,
|
|
77
|
+
await new Promise(r => setTimeout(r, this.pollIntervalMs));
|
|
66
78
|
}
|
|
67
|
-
throw new
|
|
79
|
+
throw new DeployTimeoutError();
|
|
68
80
|
}
|
|
69
81
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tunables shared across drop-core, one place and one comment each.
|
|
3
|
+
* Module-specific constants (e.g. buildStash's storage caps) stay with their
|
|
4
|
+
* module; anything two modules read belongs here.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Root for session-cookie JSON calls, via the consumer's access-control
|
|
9
|
+
* rewrite. On netlify.com, `/access-control/*` targets the app's
|
|
10
|
+
* access-control function; the paths an origin may call are allowlisted
|
|
11
|
+
* server-side.
|
|
12
|
+
*/
|
|
13
|
+
export const DEFAULT_PROXY_BASE = '/access-control/bb-api/api/v1';
|
|
14
|
+
|
|
15
|
+
/** Direct api.netlify.com root for the uploads the proxy's ~6MB Lambda body limit can't carry. */
|
|
16
|
+
export const DEFAULT_API_BASE = 'https://api.netlify.com/api/v1';
|
|
17
|
+
|
|
18
|
+
// How often a waiting deploy/build is re-read: 1 second
|
|
19
|
+
export const DEFAULT_POLL_INTERVAL_MS = 1000;
|
|
20
|
+
|
|
21
|
+
// How long to keep polling before giving up: 10 minutes
|
|
22
|
+
export const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
|
|
23
|
+
|
|
24
|
+
// Concurrent file uploads (matches netlify-cli's deploy engine)
|
|
25
|
+
export const MAX_CONCURRENT_UPLOADS = 5;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* How long a minted upload token is reused before minting another: 20 seconds.
|
|
29
|
+
* The server issues non-app origins a 30s token; the gap leaves margin for the
|
|
30
|
+
* request the token authorizes to finish.
|
|
31
|
+
*/
|
|
32
|
+
export const TOKEN_REUSE_MS = 20 * 1000;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Waits between attempts of a failed file upload, as data so the whole curve
|
|
36
|
+
* can be overridden (the tus-js-client `retryDelays` idiom). Three retries,
|
|
37
|
+
* linear backoff; 400/422 responses never retry — see `uploadWithRetry`.
|
|
38
|
+
*/
|
|
39
|
+
export const UPLOAD_RETRY_DELAYS_MS: readonly number[] = [1_000, 2_000, 3_000];
|
|
@@ -71,6 +71,48 @@ describe('deployFiles', () => {
|
|
|
71
71
|
expect(phases[1]).toEqual({ phase: 'uploading', uploaded: 0, total: 1 });
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
+
it('retries a transient upload failure and succeeds', async () => {
|
|
75
|
+
const { client } = stubDropClient(['aaa']);
|
|
76
|
+
let attempts = 0;
|
|
77
|
+
(client.uploadFile as ReturnType<typeof vi.fn>).mockImplementation(async () => {
|
|
78
|
+
if (++attempts === 1) throw Object.assign(new Error('upload failed: 502'), { status: 502 });
|
|
79
|
+
});
|
|
80
|
+
await deployFiles(client, files, { retryDelays: [0, 0] });
|
|
81
|
+
expect(attempts).toBe(2);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('gives up once the retry curve is exhausted', async () => {
|
|
85
|
+
const { client } = stubDropClient(['aaa']);
|
|
86
|
+
let attempts = 0;
|
|
87
|
+
(client.uploadFile as ReturnType<typeof vi.fn>).mockImplementation(async () => {
|
|
88
|
+
attempts++;
|
|
89
|
+
throw Object.assign(new Error('upload failed: 503'), { status: 503 });
|
|
90
|
+
});
|
|
91
|
+
await expect(deployFiles(client, files, { retryDelays: [0, 0] })).rejects.toThrow('503');
|
|
92
|
+
expect(attempts).toBe(3); // initial try + one per delay
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('never retries a 400/422 — the request itself is wrong', async () => {
|
|
96
|
+
const { client } = stubDropClient(['aaa']);
|
|
97
|
+
let attempts = 0;
|
|
98
|
+
(client.uploadFile as ReturnType<typeof vi.fn>).mockImplementation(async () => {
|
|
99
|
+
attempts++;
|
|
100
|
+
throw Object.assign(new Error('upload failed: 422'), { status: 422 });
|
|
101
|
+
});
|
|
102
|
+
await expect(deployFiles(client, files, { retryDelays: [0, 0] })).rejects.toThrow('422');
|
|
103
|
+
expect(attempts).toBe(1);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('retries statusless network failures too', async () => {
|
|
107
|
+
const { client } = stubDropClient(['aaa']);
|
|
108
|
+
let attempts = 0;
|
|
109
|
+
(client.uploadFile as ReturnType<typeof vi.fn>).mockImplementation(async () => {
|
|
110
|
+
if (++attempts === 1) throw new TypeError('fetch failed');
|
|
111
|
+
});
|
|
112
|
+
await deployFiles(client, files, { retryDelays: [0] });
|
|
113
|
+
expect(attempts).toBe(2);
|
|
114
|
+
});
|
|
115
|
+
|
|
74
116
|
it('throws on an empty file set before any network call', async () => {
|
|
75
117
|
const { client } = stubDropClient([]);
|
|
76
118
|
await expect(deployFiles(client, [])).rejects.toThrow('No files to deploy');
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { MAX_CONCURRENT_UPLOADS, UPLOAD_RETRY_DELAYS_MS } from './constants';
|
|
1
2
|
import { getDigest } from './digest';
|
|
2
3
|
import type { BuildClient, BuildResponse, BuildSite } from './buildClient';
|
|
3
4
|
import type { DropClient } from './client';
|
|
@@ -5,7 +6,33 @@ import type { InferredBuildSettings } from './detectBuild';
|
|
|
5
6
|
import type { DeployProgress, DeployResult, ProcessedFile } from './types';
|
|
6
7
|
import { zipFiles, type ZipResult } from './zip';
|
|
7
8
|
|
|
8
|
-
|
|
9
|
+
/** Responses that mean the request itself is wrong — retrying can only repeat the answer. */
|
|
10
|
+
const NON_RETRIABLE_STATUSES = new Set([400, 422]);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Upload one file, retrying transient failures (5xx, 429, network errors) on
|
|
14
|
+
* the given delay curve. A single flaky PUT among hundreds shouldn't kill the
|
|
15
|
+
* whole deploy — every deploy client we know of retries here.
|
|
16
|
+
*/
|
|
17
|
+
async function uploadWithRetry(
|
|
18
|
+
client: DropClient,
|
|
19
|
+
deployId: string,
|
|
20
|
+
file: ProcessedFile,
|
|
21
|
+
token: string,
|
|
22
|
+
retryDelays: readonly number[]
|
|
23
|
+
): Promise<void> {
|
|
24
|
+
for (let attempt = 0; ; attempt++) {
|
|
25
|
+
try {
|
|
26
|
+
await client.uploadFile(deployId, file.path, file.content, token);
|
|
27
|
+
return;
|
|
28
|
+
} catch (err) {
|
|
29
|
+
const status = (err as { status?: number }).status;
|
|
30
|
+
if (status !== undefined && NON_RETRIABLE_STATUSES.has(status)) throw err;
|
|
31
|
+
if (attempt >= retryDelays.length) throw err;
|
|
32
|
+
await new Promise(r => setTimeout(r, retryDelays[attempt]));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
9
36
|
|
|
10
37
|
async function uploadRequired(
|
|
11
38
|
client: DropClient,
|
|
@@ -13,6 +40,7 @@ async function uploadRequired(
|
|
|
13
40
|
files: ProcessedFile[],
|
|
14
41
|
required: Set<string>,
|
|
15
42
|
token: string,
|
|
43
|
+
retryDelays: readonly number[],
|
|
16
44
|
onUploaded?: (uploaded: number) => void
|
|
17
45
|
) {
|
|
18
46
|
const queue = files.filter(f => required.has(f.sha));
|
|
@@ -21,7 +49,7 @@ async function uploadRequired(
|
|
|
21
49
|
const worker = async () => {
|
|
22
50
|
while (cursor < queue.length) {
|
|
23
51
|
const f = queue[cursor++];
|
|
24
|
-
await client
|
|
52
|
+
await uploadWithRetry(client, deployId, f, token, retryDelays);
|
|
25
53
|
onUploaded?.(++done);
|
|
26
54
|
}
|
|
27
55
|
};
|
|
@@ -30,6 +58,8 @@ async function uploadRequired(
|
|
|
30
58
|
|
|
31
59
|
export interface DeployOptions {
|
|
32
60
|
onProgress?: (e: DeployProgress) => void;
|
|
61
|
+
/** Waits between retries of a failed file upload. Override for tests or a different curve. */
|
|
62
|
+
retryDelays?: readonly number[];
|
|
33
63
|
}
|
|
34
64
|
|
|
35
65
|
export async function deployFiles(
|
|
@@ -38,7 +68,7 @@ export async function deployFiles(
|
|
|
38
68
|
opts: DeployOptions = {}
|
|
39
69
|
): Promise<DeployResult> {
|
|
40
70
|
if (!files.length) throw new Error('No files to deploy');
|
|
41
|
-
const { onProgress } = opts;
|
|
71
|
+
const { onProgress, retryDelays = UPLOAD_RETRY_DELAYS_MS } = opts;
|
|
42
72
|
|
|
43
73
|
onProgress?.({ phase: 'creating' });
|
|
44
74
|
const token = await client.getToken();
|
|
@@ -48,8 +78,14 @@ export async function deployFiles(
|
|
|
48
78
|
onProgress?.({ phase: 'uploading', uploaded: 0, total });
|
|
49
79
|
// File PUTs and readiness polling both key off `deploy_id` (BSON), NOT
|
|
50
80
|
// `deploy.id` (the site UUID) — see the note in client.waitUntilReady.
|
|
51
|
-
await uploadRequired(
|
|
52
|
-
|
|
81
|
+
await uploadRequired(
|
|
82
|
+
client,
|
|
83
|
+
deploy.deploy_id,
|
|
84
|
+
files,
|
|
85
|
+
new Set(deploy.required),
|
|
86
|
+
token,
|
|
87
|
+
retryDelays,
|
|
88
|
+
uploaded => onProgress?.({ phase: 'uploading', uploaded, total })
|
|
53
89
|
);
|
|
54
90
|
|
|
55
91
|
onProgress?.({ phase: 'processing' });
|
|
@@ -4,12 +4,39 @@ import {
|
|
|
4
4
|
BuildCreateError,
|
|
5
5
|
BuildFailedError,
|
|
6
6
|
BuildTimeoutError,
|
|
7
|
+
DeployFailedError,
|
|
8
|
+
DeployTimeoutError,
|
|
7
9
|
humanizeDropError,
|
|
10
|
+
isNotAuthenticatedError,
|
|
8
11
|
NotAuthenticatedError,
|
|
9
12
|
SiteCreateError,
|
|
10
13
|
UnsupportedDropError,
|
|
11
14
|
} from './errors';
|
|
12
15
|
|
|
16
|
+
describe('error codes', () => {
|
|
17
|
+
it('pins each class to its copy-proof code brand', () => {
|
|
18
|
+
expect(new UnsupportedDropError().code).toBe('unsupported-drop');
|
|
19
|
+
expect(new NotAuthenticatedError().code).toBe('not-authenticated');
|
|
20
|
+
expect(new SiteCreateError().code).toBe('site-create-failed');
|
|
21
|
+
expect(new BuildCreateError().code).toBe('build-create-failed');
|
|
22
|
+
expect(new BuildFailedError().code).toBe('build-failed');
|
|
23
|
+
expect(new BuildTimeoutError().code).toBe('build-timeout');
|
|
24
|
+
expect(new DeployFailedError().code).toBe('deploy-failed');
|
|
25
|
+
expect(new DeployTimeoutError().code).toBe('deploy-timeout');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('recognizes a foreign-copy NotAuthenticatedError by its code', () => {
|
|
29
|
+
// Raw-TS and dist entrypoints can each load their own class copy;
|
|
30
|
+
// instanceof fails across copies, the code brand does not.
|
|
31
|
+
const foreignCopy = Object.assign(new Error('not authenticated'), {
|
|
32
|
+
code: 'not-authenticated',
|
|
33
|
+
});
|
|
34
|
+
expect(isNotAuthenticatedError(foreignCopy)).toBe(true);
|
|
35
|
+
expect(isNotAuthenticatedError(new Error('nope'))).toBe(false);
|
|
36
|
+
expect(isNotAuthenticatedError('not-authenticated')).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
13
40
|
describe('humanizeDropError', () => {
|
|
14
41
|
it('maps each typed error to its user-facing copy', () => {
|
|
15
42
|
expect(humanizeDropError(new UnsupportedDropError())).toBe(
|
|
@@ -44,10 +71,12 @@ describe('humanizeDropError', () => {
|
|
|
44
71
|
expect(humanizeDropError(new BuildCreateError(413))).toContain('too large');
|
|
45
72
|
});
|
|
46
73
|
|
|
47
|
-
it('maps the
|
|
48
|
-
expect(humanizeDropError(new
|
|
49
|
-
|
|
74
|
+
it('maps the deploy timeout and failure classes', () => {
|
|
75
|
+
expect(humanizeDropError(new DeployTimeoutError())).toContain('taking longer than expected');
|
|
76
|
+
expect(humanizeDropError(new DeployFailedError('no index.html'))).toBe(
|
|
77
|
+
'Your deploy failed: no index.html'
|
|
50
78
|
);
|
|
79
|
+
expect(humanizeDropError(new DeployFailedError())).toBe('Your deploy failed. Please try again.');
|
|
51
80
|
});
|
|
52
81
|
|
|
53
82
|
it('reads a fetch TypeError as a connectivity problem', () => {
|