@netlify/spark-ui 1.31.0-alpha.3 → 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/index.d.ts +2 -2
- package/dist/components/preact/DropZone/DropZone.d.ts +27 -9
- package/dist/components/preact/DropZone/DropZone.js +59 -54
- package/dist/components/preact/DropZone/DropZone.test.d.ts +1 -0
- package/dist/components/preact/DropZone/index.d.ts +3 -3
- package/dist/components/preact/DropZone/index.js +29 -27
- package/dist/components/preact/DropZone/useDropDeploy.d.ts +9 -6
- package/dist/components/preact/DropZone/useDropDeploy.js +182 -131
- package/dist/components/preact/DropZone/useDropDeploy.test.d.ts +1 -0
- package/dist/components/preact/index.d.ts +2 -2
- package/dist/components/preact/index.js +24 -18
- package/dist/drop-core/analytics.d.ts +107 -0
- package/dist/drop-core/analytics.js +21 -0
- package/dist/drop-core/analytics.test.d.ts +1 -0
- package/dist/drop-core/authedApi.d.ts +45 -0
- package/dist/drop-core/authedApi.js +30 -0
- package/dist/drop-core/authedApi.test.d.ts +1 -0
- package/dist/{components/preact/DropZone/drop-core → drop-core}/authedDropClient.d.ts +4 -11
- package/dist/drop-core/authedDropClient.js +100 -0
- package/dist/drop-core/authedDropClient.test.d.ts +1 -0
- package/dist/{components/preact/DropZone/drop-core → drop-core}/buildClient.d.ts +5 -13
- package/dist/drop-core/buildClient.js +85 -0
- package/dist/drop-core/buildClient.test.d.ts +1 -0
- package/dist/drop-core/buildStash.d.ts +37 -0
- package/dist/drop-core/buildStash.js +84 -0
- package/dist/drop-core/buildStash.test.d.ts +0 -0
- package/dist/{components/preact/DropZone/drop-core → drop-core}/client.d.ts +3 -1
- package/dist/drop-core/client.js +47 -0
- package/dist/drop-core/constants.d.ts +29 -0
- package/dist/drop-core/constants.js +10 -0
- package/dist/{components/preact/DropZone/drop-core → drop-core}/deploy.d.ts +9 -0
- package/dist/drop-core/deploy.js +66 -0
- package/dist/drop-core/deploy.test.d.ts +1 -0
- package/dist/drop-core/detectBuild.test.d.ts +1 -0
- package/dist/drop-core/digest.test.d.ts +1 -0
- package/dist/{components/preact/DropZone/drop-core → drop-core}/errors.d.ts +29 -0
- package/dist/drop-core/errors.js +119 -0
- package/dist/drop-core/errors.test.d.ts +1 -0
- package/dist/drop-core/fileTypes.test.d.ts +1 -0
- package/dist/drop-core/index.d.ts +25 -0
- package/dist/drop-core/index.js +47 -0
- package/dist/{components/preact/DropZone/drop-core → drop-core}/readFiles.js +2 -2
- package/dist/drop-core/readFiles.test.d.ts +1 -0
- package/dist/{components/preact/DropZone/drop-core → drop-core}/types.d.ts +10 -0
- package/dist/{components/preact/DropZone/drop-core → drop-core}/zip.js +1 -1
- package/dist/drop-core/zip.test.d.ts +1 -0
- package/package.json +4 -1
- package/packages/components/index.ts +19 -0
- package/packages/components/preact/DropZone/DropZone.test.ts +71 -0
- package/packages/components/preact/DropZone/DropZone.tsx +29 -9
- package/packages/components/preact/DropZone/index.tsx +14 -3
- package/packages/components/preact/DropZone/useDropDeploy.test.ts +376 -0
- package/packages/components/preact/DropZone/useDropDeploy.ts +301 -145
- package/packages/components/preact/index.ts +19 -2
- package/packages/drop-core/README.md +91 -0
- package/packages/drop-core/analytics.test.ts +17 -0
- package/packages/drop-core/analytics.ts +122 -0
- package/packages/drop-core/authedApi.test.ts +100 -0
- package/packages/drop-core/authedApi.ts +81 -0
- package/packages/drop-core/authedDropClient.test.ts +196 -0
- package/packages/{components/preact/DropZone/drop-core → drop-core}/authedDropClient.ts +30 -72
- package/packages/drop-core/buildClient.test.ts +112 -0
- package/packages/{components/preact/DropZone/drop-core → drop-core}/buildClient.ts +27 -57
- package/packages/drop-core/buildStash.test.ts +85 -0
- package/packages/drop-core/buildStash.ts +185 -0
- package/packages/{components/preact/DropZone/drop-core → drop-core}/client.ts +18 -6
- package/packages/drop-core/constants.ts +39 -0
- package/packages/drop-core/deploy.test.ts +177 -0
- package/packages/{components/preact/DropZone/drop-core → drop-core}/deploy.ts +59 -9
- package/packages/drop-core/detectBuild.test.ts +139 -0
- package/packages/drop-core/digest.test.ts +24 -0
- package/packages/drop-core/errors.test.ts +98 -0
- package/packages/{components/preact/DropZone/drop-core → drop-core}/errors.ts +68 -11
- package/packages/drop-core/fileTypes.test.ts +37 -0
- package/packages/drop-core/index.ts +44 -0
- package/packages/drop-core/readFiles.test.ts +124 -0
- package/packages/{components/preact/DropZone/drop-core → drop-core}/types.ts +12 -6
- package/packages/drop-core/zip.test.ts +92 -0
- package/dist/components/preact/DropZone/drop-core/authedDropClient.js +0 -123
- package/dist/components/preact/DropZone/drop-core/buildClient.js +0 -99
- package/dist/components/preact/DropZone/drop-core/client.js +0 -45
- package/dist/components/preact/DropZone/drop-core/deploy.js +0 -48
- package/dist/components/preact/DropZone/drop-core/errors.js +0 -82
- package/dist/components/preact/DropZone/drop-core/index.d.ts +0 -11
- package/dist/components/preact/DropZone/drop-core/index.js +0 -36
- package/packages/components/preact/DropZone/drop-core/index.ts +0 -11
- /package/dist/{components/preact/DropZone/drop-core → drop-core}/detectBuild.d.ts +0 -0
- /package/dist/{components/preact/DropZone/drop-core → drop-core}/detectBuild.js +0 -0
- /package/dist/{components/preact/DropZone/drop-core → drop-core}/digest.d.ts +0 -0
- /package/dist/{components/preact/DropZone/drop-core → drop-core}/digest.js +0 -0
- /package/dist/{components/preact/DropZone/drop-core → drop-core}/fileTypes.d.ts +0 -0
- /package/dist/{components/preact/DropZone/drop-core → drop-core}/fileTypes.js +0 -0
- /package/dist/{components/preact/DropZone/drop-core → drop-core}/readFiles.d.ts +0 -0
- /package/dist/{components/preact/DropZone/drop-core → drop-core}/types.js +0 -0
- /package/dist/{components/preact/DropZone/drop-core → drop-core}/zip.d.ts +0 -0
- /package/packages/{components/preact/DropZone/drop-core → drop-core}/detectBuild.ts +0 -0
- /package/packages/{components/preact/DropZone/drop-core → drop-core}/digest.ts +0 -0
- /package/packages/{components/preact/DropZone/drop-core → drop-core}/fileTypes.ts +0 -0
- /package/packages/{components/preact/DropZone/drop-core → drop-core}/readFiles.ts +0 -0
- /package/packages/{components/preact/DropZone/drop-core → drop-core}/zip.ts +0 -0
|
@@ -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.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { DROPZONE_EVENTS } from './analytics';
|
|
3
|
+
|
|
4
|
+
describe('DROPZONE_EVENTS', () => {
|
|
5
|
+
it('pins the wire names — renaming one silently forks Amplitude/Segment history', () => {
|
|
6
|
+
expect(DROPZONE_EVENTS).toEqual({
|
|
7
|
+
FILES_DROPPED: 'dropzone_files_dropped',
|
|
8
|
+
BROWSE_OPENED: 'dropzone_browse_opened',
|
|
9
|
+
BUILD_REQUIRED: 'dropzone_build_required',
|
|
10
|
+
DEPLOY_SUCCEEDED: 'dropzone_deploy_succeeded',
|
|
11
|
+
BUILD_DEPLOY_SUCCEEDED: 'dropzone_build_deploy_succeeded',
|
|
12
|
+
DEPLOY_FAILED: 'dropzone_deploy_failed',
|
|
13
|
+
PROJECT_STASHED: 'dropzone_project_stashed',
|
|
14
|
+
RESUMED: 'dropzone_resumed',
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { BuildReason } from './detectBuild';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The Drop Zone's analytics contract — the canonical event names and property
|
|
5
|
+
* shapes, exported so no consumer (this package's own component, a host site's
|
|
6
|
+
* GTM wiring, or another Netlify surface implementing its own Drop UI over
|
|
7
|
+
* drop-core) ever hand-writes the strings.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** How the files reached the zone — carried on every drop-initiated event as `method`. */
|
|
11
|
+
export type DropMethod = 'drag' | 'browse';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Canonical Drop Zone lifecycle events. The values are the wire names:
|
|
15
|
+
* analytics history in Amplitude/Segment keys off these exact strings, so
|
|
16
|
+
* renaming a value silently forks every funnel built on it — treat them as
|
|
17
|
+
* append-only.
|
|
18
|
+
*/
|
|
19
|
+
export const DROPZONE_EVENTS = {
|
|
20
|
+
/** Files arrived via drag — the picker path reports its files on outcome events instead. */
|
|
21
|
+
FILES_DROPPED: 'dropzone_files_dropped',
|
|
22
|
+
/** The native file picker was opened (keyboard or click). */
|
|
23
|
+
BROWSE_OPENED: 'dropzone_browse_opened',
|
|
24
|
+
/** The drop needs a build — it will be built in place, stashed, or handed off. */
|
|
25
|
+
BUILD_REQUIRED: 'dropzone_build_required',
|
|
26
|
+
/** A static deploy went live (anonymous or into the visitor's account). */
|
|
27
|
+
DEPLOY_SUCCEEDED: 'dropzone_deploy_succeeded',
|
|
28
|
+
/** A build was enqueued in the visitor's account. */
|
|
29
|
+
BUILD_DEPLOY_SUCCEEDED: 'dropzone_build_deploy_succeeded',
|
|
30
|
+
/** Any attempt ended in an error (including a failed resume). */
|
|
31
|
+
DEPLOY_FAILED: 'dropzone_deploy_failed',
|
|
32
|
+
/** A logged-out build drop was stashed locally before the signup hand-off. */
|
|
33
|
+
PROJECT_STASHED: 'dropzone_project_stashed',
|
|
34
|
+
/** A stashed build drop was picked up for automatic deployment. */
|
|
35
|
+
RESUMED: 'dropzone_resumed',
|
|
36
|
+
} as const;
|
|
37
|
+
|
|
38
|
+
export type DropZoneEventName = (typeof DROPZONE_EVENTS)[keyof typeof DROPZONE_EVENTS];
|
|
39
|
+
|
|
40
|
+
/** Properties the component adds to every event. */
|
|
41
|
+
export interface DropZoneBaseEventProps {
|
|
42
|
+
/** Present when the consumer sets `analyticsId` — tells multiple zones apart. */
|
|
43
|
+
dropzone_id?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface FilesDroppedProps extends DropZoneBaseEventProps {
|
|
47
|
+
method: 'drag';
|
|
48
|
+
file_count: number;
|
|
49
|
+
/** Omitted — never `[]` — when the browser exposes no files. */
|
|
50
|
+
file_types?: string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface BrowseOpenedProps extends DropZoneBaseEventProps {
|
|
54
|
+
method: 'keyboard' | 'click';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface BuildRequiredProps extends DropZoneBaseEventProps {
|
|
58
|
+
method: DropMethod;
|
|
59
|
+
reason: BuildReason | null;
|
|
60
|
+
file_count: number;
|
|
61
|
+
file_types?: string[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface DeploySucceededProps extends DropZoneBaseEventProps {
|
|
65
|
+
method: DropMethod;
|
|
66
|
+
site_name: string;
|
|
67
|
+
file_count: number;
|
|
68
|
+
/** Whether the deploy landed directly in the visitor's account. */
|
|
69
|
+
authenticated: boolean;
|
|
70
|
+
/** Present only when an authenticated attempt bounced (stale login hint) and the drop completed anonymously. */
|
|
71
|
+
auth_fallback?: true;
|
|
72
|
+
file_types?: string[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface BuildDeploySucceededProps extends DropZoneBaseEventProps {
|
|
76
|
+
method: DropMethod;
|
|
77
|
+
reason: BuildReason | null;
|
|
78
|
+
site_name: string;
|
|
79
|
+
file_count: number;
|
|
80
|
+
/** Present when build settings were synthesised into the archive. */
|
|
81
|
+
framework?: string;
|
|
82
|
+
file_types?: string[];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface DeployFailedProps extends DropZoneBaseEventProps {
|
|
86
|
+
/** Absent when the failure comes from a stash resume rather than a live drop gesture. */
|
|
87
|
+
method?: DropMethod;
|
|
88
|
+
/** Raw failure reason (e.g. "drop failed: 429", "resume_failed") — the humanized copy goes to the UI, not here. */
|
|
89
|
+
reason: string;
|
|
90
|
+
/** Present only when the file set is known (absent when reading itself threw). */
|
|
91
|
+
file_count?: number;
|
|
92
|
+
file_types?: string[];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface ProjectStashedProps extends DropZoneBaseEventProps {
|
|
96
|
+
method: DropMethod;
|
|
97
|
+
reason: BuildReason | null;
|
|
98
|
+
file_count: number;
|
|
99
|
+
file_types?: string[];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A resume starts from storage, not a fresh gesture — no method or file set to report. */
|
|
103
|
+
export type ResumedProps = DropZoneBaseEventProps;
|
|
104
|
+
|
|
105
|
+
/** Event name → its property shape, for typed emitters and sinks on either side of the seam. */
|
|
106
|
+
export interface DropZoneEventProperties {
|
|
107
|
+
dropzone_files_dropped: FilesDroppedProps;
|
|
108
|
+
dropzone_browse_opened: BrowseOpenedProps;
|
|
109
|
+
dropzone_build_required: BuildRequiredProps;
|
|
110
|
+
dropzone_deploy_succeeded: DeploySucceededProps;
|
|
111
|
+
dropzone_build_deploy_succeeded: BuildDeploySucceededProps;
|
|
112
|
+
dropzone_deploy_failed: DeployFailedProps;
|
|
113
|
+
dropzone_project_stashed: ProjectStashedProps;
|
|
114
|
+
dropzone_resumed: ResumedProps;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Provider-agnostic analytics sink. Deliberately loose (string, not the event
|
|
119
|
+
* union): hosts forward these to Segment/GA/GTM and often pipe their own
|
|
120
|
+
* events through the same function.
|
|
121
|
+
*/
|
|
122
|
+
export type TrackFn = (event: string, properties?: Record<string, unknown>) => void;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { createSiteInAccount, pollDeployUntilSettled } from './authedApi';
|
|
3
|
+
import { NotAuthenticatedError, SiteCreateError } from './errors';
|
|
4
|
+
|
|
5
|
+
const PROXY = '/access-control/bb-api/api/v1';
|
|
6
|
+
|
|
7
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
8
|
+
|
|
9
|
+
describe('createSiteInAccount', () => {
|
|
10
|
+
const record = () => {
|
|
11
|
+
const calls: Array<{ url: string; init: RequestInit }> = [];
|
|
12
|
+
vi.stubGlobal('fetch', async (url: unknown, init: RequestInit = {}) => {
|
|
13
|
+
calls.push({ url: String(url), init });
|
|
14
|
+
return { ok: true, status: 200, json: async () => ({ id: 's', name: 'n' }) } as Response;
|
|
15
|
+
});
|
|
16
|
+
return calls;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
it('POSTs through the proxy on the session cookie, attributed to drop', async () => {
|
|
20
|
+
const calls = record();
|
|
21
|
+
const site = await createSiteInAccount(PROXY);
|
|
22
|
+
expect(site).toEqual({ id: 's', name: 'n' });
|
|
23
|
+
expect(calls[0].url).toBe(`${PROXY}/sites`);
|
|
24
|
+
expect(calls[0].init.credentials).toBe('include');
|
|
25
|
+
expect(JSON.parse(String(calls[0].init.body))).toEqual({ created_via: 'drop' });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('scopes to the account slug when given', async () => {
|
|
29
|
+
const calls = record();
|
|
30
|
+
await createSiteInAccount(PROXY, 'my-team');
|
|
31
|
+
expect(calls[0].url).toBe(`${PROXY}/my-team/sites`);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it.each([401, 403])(
|
|
35
|
+
'maps a %i — pre-mutation — to NotAuthenticatedError',
|
|
36
|
+
async (status: number) => {
|
|
37
|
+
vi.stubGlobal('fetch', async () => ({ ok: false, status, json: async () => ({}) }));
|
|
38
|
+
await expect(createSiteInAccount(PROXY)).rejects.toBeInstanceOf(NotAuthenticatedError);
|
|
39
|
+
}
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
it('wraps any other failure in SiteCreateError with the status', async () => {
|
|
43
|
+
vi.stubGlobal('fetch', async () => ({ ok: false, status: 500, json: async () => ({}) }));
|
|
44
|
+
const failure = await createSiteInAccount(PROXY).catch(e => e);
|
|
45
|
+
expect(failure).toBeInstanceOf(SiteCreateError);
|
|
46
|
+
expect(failure.status).toBe(500);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('pollDeployUntilSettled', () => {
|
|
51
|
+
const poll = (states: Array<{ ok: boolean; state?: string; error_message?: string }>) => {
|
|
52
|
+
let call = 0;
|
|
53
|
+
const urls: string[] = [];
|
|
54
|
+
vi.stubGlobal('fetch', async (url: unknown, init: RequestInit = {}) => {
|
|
55
|
+
urls.push(String(url) + `|${init.credentials}`);
|
|
56
|
+
const next = states[Math.min(call++, states.length - 1)];
|
|
57
|
+
return { ok: next.ok, status: 200, json: async () => next } as Response;
|
|
58
|
+
});
|
|
59
|
+
return {
|
|
60
|
+
urls,
|
|
61
|
+
run: () =>
|
|
62
|
+
pollDeployUntilSettled({
|
|
63
|
+
proxyBase: PROXY,
|
|
64
|
+
deployId: 'd',
|
|
65
|
+
pollIntervalMs: 1,
|
|
66
|
+
timeoutMs: 5,
|
|
67
|
+
}),
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
it('reads the deploy through the proxy on the cookie until ready', async () => {
|
|
72
|
+
const { run, urls } = poll([
|
|
73
|
+
{ ok: true, state: 'processing' },
|
|
74
|
+
{ ok: true, state: 'ready' },
|
|
75
|
+
]);
|
|
76
|
+
expect(await run()).toEqual({ outcome: 'ready' });
|
|
77
|
+
expect(urls[0]).toBe(`${PROXY}/deploys/d|include`);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('reports terminal failures with the API error message', async () => {
|
|
81
|
+
const { run } = poll([{ ok: true, state: 'error', error_message: 'boom' }]);
|
|
82
|
+
expect(await run()).toEqual({ outcome: 'failed', errorMessage: 'boom' });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('treats rejected as a failure too', async () => {
|
|
86
|
+
const { run } = poll([{ ok: true, state: 'rejected' }]);
|
|
87
|
+
expect(await run()).toEqual({ outcome: 'failed', errorMessage: undefined });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('skips transient non-ok reads rather than failing the deploy', async () => {
|
|
91
|
+
const { run } = poll([{ ok: false }, { ok: true, state: 'ready' }]);
|
|
92
|
+
expect(await run()).toEqual({ outcome: 'ready' });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('times out after timeoutMs / pollIntervalMs polls', async () => {
|
|
96
|
+
const { run, urls } = poll([{ ok: true, state: 'building' }]);
|
|
97
|
+
expect(await run()).toEqual({ outcome: 'timed-out' });
|
|
98
|
+
expect(urls).toHaveLength(5);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { BuildSite } from './types';
|
|
2
|
+
import { NotAuthenticatedError, SiteCreateError } from './errors';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The transport both authenticated clients share: JSON calls ride the
|
|
6
|
+
* consumer's access-control rewrite on the session cookie, so nothing
|
|
7
|
+
* credential-shaped lives in the browser and nothing can expire mid-deploy.
|
|
8
|
+
* (Uploads are the exception — the proxy's Lambda caps request bodies at
|
|
9
|
+
* ~6MB — and each client handles its own, bearer-authorized.)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Create a site in the visitor's account via `POST {proxyBase}/sites` (scoped
|
|
14
|
+
* to `accountSlug` when given, else the user's default account), attributed
|
|
15
|
+
* `created_via: 'drop'`.
|
|
16
|
+
*
|
|
17
|
+
* A logged-out visitor 401s here, before anything is created — the one
|
|
18
|
+
* pre-mutation point where callers may still safely fall back to an anonymous
|
|
19
|
+
* flow, which is why it maps to `NotAuthenticatedError`. Any other failure is
|
|
20
|
+
* a `SiteCreateError` carrying the status.
|
|
21
|
+
*/
|
|
22
|
+
export async function createSiteInAccount(
|
|
23
|
+
proxyBase: string,
|
|
24
|
+
accountSlug?: string
|
|
25
|
+
): Promise<BuildSite> {
|
|
26
|
+
const path = accountSlug ? `/${accountSlug}/sites` : '/sites';
|
|
27
|
+
const res = await fetch(`${proxyBase}${path}`, {
|
|
28
|
+
method: 'POST',
|
|
29
|
+
credentials: 'include',
|
|
30
|
+
headers: { 'Content-Type': 'application/json' },
|
|
31
|
+
body: JSON.stringify({ created_via: 'drop' }),
|
|
32
|
+
});
|
|
33
|
+
if (res.status === 401 || res.status === 403) throw new NotAuthenticatedError();
|
|
34
|
+
if (!res.ok) throw new SiteCreateError(res.status);
|
|
35
|
+
return res.json();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** How a watched deploy ended. `errorMessage` is the API's own explanation, when it gave one. */
|
|
39
|
+
export type SettledDeploy =
|
|
40
|
+
{ outcome: 'ready' } | { outcome: 'failed'; errorMessage?: string } | { outcome: 'timed-out' };
|
|
41
|
+
|
|
42
|
+
/** Deploy states that end the wait — `ready` succeeds, the rest are failures. */
|
|
43
|
+
const TERMINAL_STATES = new Set(['ready', 'error', 'rejected']);
|
|
44
|
+
|
|
45
|
+
interface DeployState {
|
|
46
|
+
state?: string;
|
|
47
|
+
error_message?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Poll `GET {proxyBase}/deploys/{deployId}` on the session cookie until the
|
|
52
|
+
* deploy settles, and report how. Purely mechanical on purpose: each caller
|
|
53
|
+
* maps the outcome onto its own error vocabulary (the drop client throws plain
|
|
54
|
+
* errors `humanizeDropError` understands; the build client throws
|
|
55
|
+
* `BuildFailedError`/`BuildTimeoutError`), so that knowledge stays next to the
|
|
56
|
+
* class it belongs to.
|
|
57
|
+
*
|
|
58
|
+
* A non-ok poll response is skipped, not fatal — a transient proxy hiccup
|
|
59
|
+
* shouldn't kill a deploy that is still progressing.
|
|
60
|
+
*/
|
|
61
|
+
export async function pollDeployUntilSettled(options: {
|
|
62
|
+
proxyBase: string;
|
|
63
|
+
deployId: string;
|
|
64
|
+
pollIntervalMs: number;
|
|
65
|
+
timeoutMs: number;
|
|
66
|
+
}): Promise<SettledDeploy> {
|
|
67
|
+
const { proxyBase, deployId, pollIntervalMs, timeoutMs } = options;
|
|
68
|
+
const deadline = timeoutMs / pollIntervalMs;
|
|
69
|
+
for (let i = 0; i < deadline; i++) {
|
|
70
|
+
const res = await fetch(`${proxyBase}/deploys/${deployId}`, { credentials: 'include' });
|
|
71
|
+
if (res.ok) {
|
|
72
|
+
const deploy: DeployState = await res.json();
|
|
73
|
+
if (deploy.state && TERMINAL_STATES.has(deploy.state)) {
|
|
74
|
+
if (deploy.state === 'ready') return { outcome: 'ready' };
|
|
75
|
+
return { outcome: 'failed', errorMessage: deploy.error_message };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
await new Promise(r => setTimeout(r, pollIntervalMs));
|
|
79
|
+
}
|
|
80
|
+
return { outcome: 'timed-out' };
|
|
81
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { AuthenticatedDropClient } from './authedDropClient';
|
|
3
|
+
import { NotAuthenticatedError } from './errors';
|
|
4
|
+
|
|
5
|
+
const PROXY = '/access-control/bb-api/api/v1';
|
|
6
|
+
const API = 'https://api.netlify.com/api/v1';
|
|
7
|
+
|
|
8
|
+
interface RecordedCall {
|
|
9
|
+
url: string;
|
|
10
|
+
init: RequestInit;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Stub fetch that answers like the happy-path API and records every request. */
|
|
14
|
+
function stubApi() {
|
|
15
|
+
const calls: RecordedCall[] = [];
|
|
16
|
+
vi.stubGlobal('fetch', async (url: unknown, init: RequestInit = {}) => {
|
|
17
|
+
calls.push({ url: String(url), init });
|
|
18
|
+
const u = String(url);
|
|
19
|
+
const body = u.endsWith('/sites')
|
|
20
|
+
? { id: 'site-uuid', name: 'my-account-site' }
|
|
21
|
+
: u.endsWith('/deploys')
|
|
22
|
+
? { id: 'deploy-bson', required: ['aaa'] }
|
|
23
|
+
: { state: 'ready' };
|
|
24
|
+
return { ok: true, status: 200, json: async () => body } as Response;
|
|
25
|
+
});
|
|
26
|
+
return calls;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const client = (getUploadToken = async (): Promise<string | null> => 'bearer-abc') =>
|
|
30
|
+
new AuthenticatedDropClient({ getUploadToken, pollIntervalMs: 1, timeoutMs: 5 });
|
|
31
|
+
|
|
32
|
+
const content = new TextEncoder().encode('x').buffer as ArrayBuffer;
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
vi.unstubAllGlobals();
|
|
36
|
+
vi.restoreAllMocks();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('AuthenticatedDropClient transport', () => {
|
|
40
|
+
it('splits requests: JSON rides the cookie proxy, uploads go direct with the bearer', async () => {
|
|
41
|
+
const calls = stubApi();
|
|
42
|
+
const c = client();
|
|
43
|
+
const token = await c.getToken();
|
|
44
|
+
const deploy = await c.createDeploy({ '/index.html': 'aaa' }, token);
|
|
45
|
+
await c.uploadFile(deploy.deploy_id, '/index.html', content, token);
|
|
46
|
+
await c.waitUntilReady(deploy, token);
|
|
47
|
+
|
|
48
|
+
const [site, deployCreate, upload, poll] = calls;
|
|
49
|
+
expect(site.url).toBe(`${PROXY}/sites`);
|
|
50
|
+
expect(deployCreate.url).toBe(`${PROXY}/sites/site-uuid/deploys`);
|
|
51
|
+
// Readiness and file PUTs key off deploy_id (BSON), never the site UUID.
|
|
52
|
+
expect(upload.url).toBe(`${API}/deploys/deploy-bson/files/index.html`);
|
|
53
|
+
expect(poll.url).toBe(`${PROXY}/deploys/deploy-bson`);
|
|
54
|
+
|
|
55
|
+
for (const jsonCall of [site, deployCreate, poll]) {
|
|
56
|
+
expect(jsonCall.init.credentials).toBe('include');
|
|
57
|
+
expect((jsonCall.init.headers as Record<string, string>)?.Authorization).toBeUndefined();
|
|
58
|
+
}
|
|
59
|
+
expect((upload.init.headers as Record<string, string>).Authorization).toBe('Bearer bearer-abc');
|
|
60
|
+
expect(upload.init.credentials).toBeUndefined();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('creates the site with created_via: drop and the deploy with the digest', async () => {
|
|
64
|
+
const calls = stubApi();
|
|
65
|
+
const c = client();
|
|
66
|
+
await c.createDeploy({ '/index.html': 'aaa' }, await c.getToken());
|
|
67
|
+
expect(JSON.parse(String(calls[0].init.body))).toEqual({ created_via: 'drop' });
|
|
68
|
+
expect(JSON.parse(String(calls[1].init.body))).toEqual({
|
|
69
|
+
files: { '/index.html': 'aaa' },
|
|
70
|
+
deploy_source: 'drop',
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('scopes site creation to the account slug when given', async () => {
|
|
75
|
+
const calls = stubApi();
|
|
76
|
+
const c = new AuthenticatedDropClient({
|
|
77
|
+
getUploadToken: async () => 'tok',
|
|
78
|
+
accountSlug: 'my-team',
|
|
79
|
+
});
|
|
80
|
+
await c.createDeploy({}, 'tok');
|
|
81
|
+
expect(calls[0].url).toBe(`${PROXY}/my-team/sites`);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('maps the site + deploy responses into the anonymous DropResponse shape', async () => {
|
|
85
|
+
stubApi();
|
|
86
|
+
const c = client();
|
|
87
|
+
const deploy = await c.createDeploy({ '/index.html': 'aaa' }, 'tok');
|
|
88
|
+
expect(deploy).toEqual({
|
|
89
|
+
id: 'site-uuid',
|
|
90
|
+
deploy_id: 'deploy-bson',
|
|
91
|
+
subdomain: 'my-account-site',
|
|
92
|
+
required: ['aaa'],
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('AuthenticatedDropClient upload-token lifecycle', () => {
|
|
98
|
+
it('reuses the gate-minted token inside the reuse window', async () => {
|
|
99
|
+
stubApi();
|
|
100
|
+
let minted = 0;
|
|
101
|
+
const c = client(async () => `tok-${++minted}`);
|
|
102
|
+
const token = await c.getToken();
|
|
103
|
+
await c.uploadFile('d', '/a', content, token);
|
|
104
|
+
await c.uploadFile('d', '/b', content, token);
|
|
105
|
+
expect(minted).toBe(1);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('re-mints once the cached token nears the server-side 30s expiry', async () => {
|
|
109
|
+
stubApi();
|
|
110
|
+
let minted = 0;
|
|
111
|
+
let now = 1_000_000;
|
|
112
|
+
vi.spyOn(Date, 'now').mockImplementation(() => now);
|
|
113
|
+
|
|
114
|
+
const c = client(async () => `tok-${++minted}`);
|
|
115
|
+
const token = await c.getToken();
|
|
116
|
+
await c.uploadFile('d', '/a', content, token);
|
|
117
|
+
expect(minted).toBe(1);
|
|
118
|
+
|
|
119
|
+
now += 21_000; // past TOKEN_REUSE_MS (20s)
|
|
120
|
+
await c.uploadFile('d', '/b', content, token);
|
|
121
|
+
expect(minted).toBe(2);
|
|
122
|
+
|
|
123
|
+
await c.uploadFile('d', '/c', content, token);
|
|
124
|
+
expect(minted).toBe(2); // the renewed token is reused in turn
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('AuthenticatedDropClient auth boundaries', () => {
|
|
129
|
+
it('throws NotAuthenticatedError at the gate when no token can be minted', async () => {
|
|
130
|
+
await expect(client(async () => null).getToken()).rejects.toBeInstanceOf(NotAuthenticatedError);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('throws NotAuthenticatedError when site creation is refused — pre-mutation, fallback-safe', async () => {
|
|
134
|
+
vi.stubGlobal('fetch', async () => ({ ok: false, status: 401, json: async () => ({}) }));
|
|
135
|
+
await expect(client().createDeploy({}, 'tok')).rejects.toBeInstanceOf(NotAuthenticatedError);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('throws a plain Error — never NotAuthenticatedError — when minting fails mid-upload', async () => {
|
|
139
|
+
// The anti-duplicate invariant: by upload time the site exists, and a
|
|
140
|
+
// NotAuthenticatedError would send the drop back through the anonymous
|
|
141
|
+
// path, stranding an empty project and deploying twice.
|
|
142
|
+
stubApi();
|
|
143
|
+
let minted = 0;
|
|
144
|
+
let now = 1_000_000;
|
|
145
|
+
vi.spyOn(Date, 'now').mockImplementation(() => now);
|
|
146
|
+
const c = client(async () => (++minted === 1 ? 'tok' : null));
|
|
147
|
+
const token = await c.getToken();
|
|
148
|
+
now += 60_000;
|
|
149
|
+
const failure = await c.uploadFile('d', '/a', content, token).catch(e => e);
|
|
150
|
+
expect(failure).toBeInstanceOf(Error);
|
|
151
|
+
expect(failure).not.toBeInstanceOf(NotAuthenticatedError);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('surfaces upload HTTP failures with the status attached', async () => {
|
|
155
|
+
vi.stubGlobal('fetch', async () => ({ ok: false, status: 413, json: async () => ({}) }));
|
|
156
|
+
const c = client();
|
|
157
|
+
const failure = await c.uploadFile('d', '/big', content, 'tok').catch(e => e);
|
|
158
|
+
expect(failure.status).toBe(413);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe('AuthenticatedDropClient polling', () => {
|
|
163
|
+
const pollClient = (states: Array<{ ok: boolean; state?: string; error_message?: string }>) => {
|
|
164
|
+
let call = 0;
|
|
165
|
+
vi.stubGlobal('fetch', async () => {
|
|
166
|
+
const next = states[Math.min(call++, states.length - 1)];
|
|
167
|
+
return { ok: next.ok, status: next.ok ? 200 : 500, json: async () => next } as Response;
|
|
168
|
+
});
|
|
169
|
+
return client();
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
it('resolves when the deploy goes ready, skipping transient bad reads', async () => {
|
|
173
|
+
const c = pollClient([
|
|
174
|
+
{ ok: false },
|
|
175
|
+
{ ok: true, state: 'processing' },
|
|
176
|
+
{ ok: true, state: 'ready' },
|
|
177
|
+
]);
|
|
178
|
+
await expect(
|
|
179
|
+
c.waitUntilReady({ id: 's', deploy_id: 'd', subdomain: 'x', required: [] }, 'tok')
|
|
180
|
+
).resolves.toBeUndefined();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('fails with the deploy error message on a terminal error state', async () => {
|
|
184
|
+
const c = pollClient([{ ok: true, state: 'error', error_message: 'no index.html' }]);
|
|
185
|
+
await expect(
|
|
186
|
+
c.waitUntilReady({ id: 's', deploy_id: 'd', subdomain: 'x', required: [] }, 'tok')
|
|
187
|
+
).rejects.toThrow('deploy failed: no index.html');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('times out with the shared humanizable message', async () => {
|
|
191
|
+
const c = pollClient([{ ok: true, state: 'uploading' }]);
|
|
192
|
+
await expect(
|
|
193
|
+
c.waitUntilReady({ id: 's', deploy_id: 'd', subdomain: 'x', required: [] }, 'tok')
|
|
194
|
+
).rejects.toThrow('Deploy did not become ready in time');
|
|
195
|
+
});
|
|
196
|
+
});
|