@netlify/spark-ui 1.31.0-alpha.5 → 1.31.0-alpha.6
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 +3 -4
- package/dist/components/preact/DropZone/DropZone.d.ts +23 -97
- package/dist/drop-core/analytics.d.ts +4 -15
- package/dist/drop-core/authedApi.d.ts +9 -22
- package/dist/drop-core/authedDropClient.d.ts +13 -55
- package/dist/drop-core/authedDropClient.js +5 -18
- package/dist/drop-core/buildClient.d.ts +19 -53
- package/dist/drop-core/buildClient.js +27 -40
- package/dist/drop-core/buildStash.d.ts +6 -20
- package/dist/drop-core/client.d.ts +1 -1
- package/dist/drop-core/constants.d.ts +1 -21
- package/dist/drop-core/deploy.d.ts +5 -17
- package/dist/drop-core/detectBuild.d.ts +8 -26
- package/dist/drop-core/errors.d.ts +8 -27
- package/dist/drop-core/fileTypes.d.ts +4 -14
- package/dist/drop-core/readFiles.d.ts +5 -11
- package/dist/drop-core/types.d.ts +2 -4
- package/dist/drop-core/zip.d.ts +7 -30
- package/package.json +1 -1
- package/packages/components/preact/DropZone/DropZone.tsx +27 -121
- package/packages/components/preact/DropZone/useDropDeploy.ts +27 -60
- package/packages/drop-core/analytics.ts +4 -15
- package/packages/drop-core/authedApi.ts +9 -22
- package/packages/drop-core/authedDropClient.ts +13 -55
- package/packages/drop-core/buildClient.test.ts +19 -4
- package/packages/drop-core/buildClient.ts +21 -63
- package/packages/drop-core/buildStash.ts +9 -25
- package/packages/drop-core/client.ts +6 -10
- package/packages/drop-core/constants.ts +7 -21
- package/packages/drop-core/deploy.ts +6 -22
- package/packages/drop-core/detectBuild.ts +15 -50
- package/packages/drop-core/errors.test.ts +3 -1
- package/packages/drop-core/errors.ts +9 -30
- package/packages/drop-core/fileTypes.ts +5 -19
- package/packages/drop-core/readFiles.ts +5 -12
- package/packages/drop-core/types.ts +2 -4
- package/packages/drop-core/zip.ts +7 -30
|
@@ -25,26 +25,13 @@ const DRAG_INACTIVE_MS = 120;
|
|
|
25
25
|
|
|
26
26
|
const defaultClaimUrl = (siteName: string) => `https://app.netlify.com/drop/${siteName}`;
|
|
27
27
|
|
|
28
|
-
/**
|
|
29
|
-
* Where an authenticated static deploy lands: the new site's project overview,
|
|
30
|
-
* the same place the app's own logged-in drop sends you.
|
|
31
|
-
*/
|
|
28
|
+
/** The project overview — where the app's own logged-in drop lands too. */
|
|
32
29
|
const defaultSiteDashboardUrl = (siteName: string) =>
|
|
33
30
|
`https://app.netlify.com/projects/${siteName}`;
|
|
34
31
|
|
|
35
|
-
|
|
36
|
-
* Where a build-required drop is sent by default. Mirrors the app's own Drop
|
|
37
|
-
* page: signup with `next=/drop`, so after signup + email verification the user
|
|
38
|
-
* lands back on the authenticated Drop page and can build there.
|
|
39
|
-
*/
|
|
32
|
+
// Mirrors the app's own Drop page hand-off.
|
|
40
33
|
const defaultBuildSignupUrl = 'https://app.netlify.com/signup?next=/drop&utm_campaign=drop';
|
|
41
34
|
|
|
42
|
-
/**
|
|
43
|
-
* Where an authenticated build drop lands: the new site's project overview —
|
|
44
|
-
* the same destination as a static deploy, and where the app's own Drop page
|
|
45
|
-
* sends build drops. The overview surfaces the in-progress deploy; consumers
|
|
46
|
-
* who want the deploy-log page instead can override with `build.deployId`.
|
|
47
|
-
*/
|
|
48
35
|
const defaultBuildDeployUrl = ({ siteName }: DeployedBuild) =>
|
|
49
36
|
`https://app.netlify.com/projects/${siteName}`;
|
|
50
37
|
|
|
@@ -57,29 +44,17 @@ interface Props {
|
|
|
57
44
|
client?: DropClient;
|
|
58
45
|
/** Build the "claim this site" URL from the assigned site name (subdomain). */
|
|
59
46
|
claimUrl?: (siteName: string) => string;
|
|
60
|
-
/**
|
|
61
|
-
* Where an authenticated static deploy sends the visitor — the site already
|
|
62
|
-
* lives in their account, so there is no claim step. Defaults to the new
|
|
63
|
-
* site's project overview on app.netlify.com.
|
|
64
|
-
*/
|
|
47
|
+
/** Destination after an authenticated static deploy (no claim step). Default: the project overview. */
|
|
65
48
|
siteDashboardUrl?: (siteName: string) => string;
|
|
66
49
|
/**
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
* a flow that returns the visitor to this page deploys it automatically —
|
|
70
|
-
* see `resumeStashedBuilds`. Defaults to the app signup page with
|
|
71
|
-
* `next=/drop`.
|
|
50
|
+
* Signup hand-off for a build drop with no session. The drop is stashed on
|
|
51
|
+
* this origin first — return the visitor here and it deploys automatically.
|
|
72
52
|
*/
|
|
73
53
|
buildSignupUrl?: string;
|
|
74
54
|
/**
|
|
75
|
-
* Mint
|
|
76
|
-
*
|
|
77
|
-
* drops
|
|
78
|
-
* build-required drops build in place instead of being handed off to the app.
|
|
79
|
-
* On netlify.com this reads `GET /access-control/generate-access-control-token`.
|
|
80
|
-
* Resolving `null` means "no session": static drops fall back to the anonymous
|
|
81
|
-
* claim flow; build drops are stashed and handed to `buildSignupUrl`.
|
|
82
|
-
* See [Logged-in visitors](#logged-in-visitors).
|
|
55
|
+
* Mint the upload bearer — passing this switches on the whole authenticated
|
|
56
|
+
* path. `null` means "no session": static drops fall back to the anonymous
|
|
57
|
+
* claim flow; build drops stash and hand off. See the docs' Logged-in visitors.
|
|
83
58
|
*/
|
|
84
59
|
getUploadToken?: () => Promise<string | null>;
|
|
85
60
|
/**
|
|
@@ -87,108 +62,46 @@ interface Props {
|
|
|
87
62
|
* Takes precedence over `getUploadToken` for the build path.
|
|
88
63
|
*/
|
|
89
64
|
buildClient?: BuildClient;
|
|
90
|
-
/**
|
|
91
|
-
* Inject a custom authenticated `DropClient` for static drops (tests, staging,
|
|
92
|
-
* a different account flow). Takes precedence over `getUploadToken` for the
|
|
93
|
-
* static path. Distinct from `client`, which is the anonymous fallback.
|
|
94
|
-
*/
|
|
65
|
+
/** Custom authenticated static-drop client; wins over `getUploadToken`. (`client` is the anonymous fallback.) */
|
|
95
66
|
authenticatedDropClient?: DropClient;
|
|
96
|
-
/**
|
|
97
|
-
* API root for the authenticated paths' JSON calls, which go through your
|
|
98
|
-
* access-control rewrite so the session cookie authorizes them. Defaults to
|
|
99
|
-
* `/access-control/bb-api/api/v1`. (Uploads always go direct to
|
|
100
|
-
* `api.netlify.com` — the proxy can't carry bodies that large.)
|
|
101
|
-
*/
|
|
67
|
+
/** Root for the session-cookie JSON calls, via your access-control rewrite. Uploads always go direct. */
|
|
102
68
|
proxyBase?: string;
|
|
103
69
|
/** Create the visitor's new project in this account. Omit for their default account. */
|
|
104
70
|
accountSlug?: string;
|
|
105
|
-
/**
|
|
106
|
-
* Where an enqueued build sends the visitor. Defaults to the new site's
|
|
107
|
-
* project overview, same as a static deploy; `build.deployId` is available for
|
|
108
|
-
* consumers who want the deploy-log page instead.
|
|
109
|
-
*/
|
|
71
|
+
/** Destination after a build is enqueued. Default: the project overview (`build.deployId` for the logs page). */
|
|
110
72
|
buildDeployUrl?: (build: DeployedBuild) => string;
|
|
111
73
|
/**
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
* its own error message here (via `onError` and the overlay) rather than the
|
|
115
|
-
* visitor discovering it on the deploy page — at the cost of holding them on a
|
|
116
|
-
* spinner for the length of the build, which is minutes. Off by default.
|
|
74
|
+
* Hold the visitor until the build finishes (off by default — builds take
|
|
75
|
+
* minutes). On is the only way a failing build's own message reaches `onError`.
|
|
117
76
|
*/
|
|
118
77
|
waitForBuild?: boolean;
|
|
119
|
-
/**
|
|
120
|
-
* Called when a build-required drop is detected, instead of building it or
|
|
121
|
-
* redirecting. Use it to own the hand-off yourself (custom routing, persisting
|
|
122
|
-
* the drop, gating on auth). When set, the component neither builds nor
|
|
123
|
-
* redirects. The static drop path is unaffected either way.
|
|
124
|
-
*/
|
|
78
|
+
/** Take over build-required drops entirely — when set, the component neither builds nor redirects. */
|
|
125
79
|
onBuildRequired?: (info: BuildDetection) => void;
|
|
126
80
|
/**
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
* however fits your auth flow, e.g. open a signup popup that keeps the
|
|
131
|
-
* visitor on the page. Once they're back authenticated, a remount of the
|
|
132
|
-
* zone deploys the stash automatically.
|
|
81
|
+
* Own the signup hand-off instead of the `buildSignupUrl` redirect (e.g. an
|
|
82
|
+
* auth popup). The drop is already stashed; a remount while authenticated
|
|
83
|
+
* deploys it.
|
|
133
84
|
*/
|
|
134
85
|
onBuildSignupHandoff?: (info: { stashed: boolean; detection: BuildDetection }) => void;
|
|
135
|
-
/**
|
|
136
|
-
* Deploy a previously stashed build drop automatically when the zone mounts
|
|
137
|
-
* with an authenticated visitor (default `true`). The stash is written when a
|
|
138
|
-
* logged-out build drop is handed to signup; see
|
|
139
|
-
* [Signup hand-off and auto-resume](#signup-hand-off-and-auto-resume).
|
|
140
|
-
*/
|
|
86
|
+
/** Auto-deploy a stashed build drop on mount when authenticated (default true). */
|
|
141
87
|
resumeStashedBuilds?: boolean;
|
|
142
88
|
/** Called once the deploy is live. */
|
|
143
89
|
onDeploy?: (result: DeployedSite) => void;
|
|
144
|
-
/**
|
|
145
|
-
* Called once a build-required drop has been zipped, uploaded and enqueued —
|
|
146
|
-
* before the redirect to `buildDeployUrl`. The site exists at this point but
|
|
147
|
-
* won't serve content until the build finishes.
|
|
148
|
-
*/
|
|
90
|
+
/** Called once a build is enqueued, before the `buildDeployUrl` redirect. */
|
|
149
91
|
onBuildDeploy?: (build: DeployedBuild) => void;
|
|
150
|
-
/**
|
|
151
|
-
* Called when a deploy fails, with a humanized `message` (ready to show a
|
|
152
|
-
* user — the same copy the built-in overlay uses) and the raw `reason`. Pair
|
|
153
|
-
* this with `showStatus={false}` to render your own error UI.
|
|
154
|
-
*/
|
|
92
|
+
/** Deploy failures: humanized `message` (the overlay's copy) + raw `reason`. Pair with showStatus={false}. */
|
|
155
93
|
onError?: (error: DeployError) => void;
|
|
156
94
|
/**
|
|
157
|
-
* Render the built-in
|
|
158
|
-
*
|
|
159
|
-
* drive your own feedback from the `data-state` attribute on the root element.
|
|
160
|
-
* Note: in this mode you're responsible for surfacing errors yourself — use
|
|
161
|
-
* `onError` to get the message.
|
|
95
|
+
* Render the built-in overlay. `false` keeps children visible — drive your
|
|
96
|
+
* own feedback from `data-state`, and surface errors yourself via `onError`.
|
|
162
97
|
*/
|
|
163
98
|
showStatus?: boolean;
|
|
164
99
|
/**
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
* consuming app, e.g. `onTrack={(name, props) => new Analytics().track(name, props)}`.
|
|
168
|
-
* Emitted events (all include `dropzone_id` when `analyticsId` is set):
|
|
169
|
-
* - `dropzone_files_dropped` — files dropped via drag ({ method: 'drag', file_count, file_types })
|
|
170
|
-
* - `dropzone_browse_opened` — file picker opened ({ method: 'keyboard' | 'click' })
|
|
171
|
-
* - `dropzone_build_required` — drop needs a build (built here, or handed off) ({ method, reason, file_count, file_types })
|
|
172
|
-
* - `dropzone_deploy_succeeded` — static deploy went live ({ method, site_name, file_count, authenticated, auth_fallback?, file_types })
|
|
173
|
-
* - `dropzone_build_deploy_succeeded` — build enqueued for a logged-in visitor ({ method, reason, site_name, file_count, framework?, file_types })
|
|
174
|
-
* - `dropzone_deploy_failed` — deploy or build errored out ({ method, reason, file_count?, file_types })
|
|
175
|
-
*
|
|
176
|
-
* `authenticated` says whether the deploy landed in the visitor's account;
|
|
177
|
-
* `auth_fallback: true` is sent only when an authenticated attempt bounced
|
|
178
|
-
* (stale auth hint) and the drop completed anonymously — omitted otherwise.
|
|
179
|
-
* `file_types` is the sorted, de-duplicated list of MIME types in the
|
|
180
|
-
* selection, with `application/octet-stream` standing in wherever the browser
|
|
181
|
-
* reports none (a dragged folder, an `.exe`). It is omitted when the browser
|
|
182
|
-
* exposes no files at all. Note `dropzone_files_dropped` only fires for drag —
|
|
183
|
-
* a file-picker selection reports its types on the outcome events, as
|
|
184
|
-
* `dropzone_browse_opened` fires before any file exists.
|
|
100
|
+
* Provider-agnostic analytics hook. Event names and payload shapes live in
|
|
101
|
+
* DROPZONE_EVENTS / DropZoneEventProperties (drop-core/analytics.ts).
|
|
185
102
|
*/
|
|
186
103
|
onTrack?: TrackFn;
|
|
187
|
-
/**
|
|
188
|
-
* Stable identifier for this instance, attached to every tracked event as
|
|
189
|
-
* `dropzone_id`. Set it when a page renders more than one Drop Zone so the
|
|
190
|
-
* events can be told apart in Amplitude.
|
|
191
|
-
*/
|
|
104
|
+
/** Stamped on every event as `dropzone_id` — set when a page has more than one zone. */
|
|
192
105
|
analyticsId?: string;
|
|
193
106
|
className?: string;
|
|
194
107
|
}
|
|
@@ -218,8 +131,6 @@ export default function DropZone({
|
|
|
218
131
|
analyticsId,
|
|
219
132
|
className,
|
|
220
133
|
}: Props) {
|
|
221
|
-
// Tag every event with this instance's id so multiple zones on a page can be
|
|
222
|
-
// told apart, then hand it off to the consumer's analytics.
|
|
223
134
|
const track: TrackFn = (event, properties = {}) =>
|
|
224
135
|
onTrack?.(event, analyticsId ? { dropzone_id: analyticsId, ...properties } : properties);
|
|
225
136
|
|
|
@@ -253,8 +164,6 @@ export default function DropZone({
|
|
|
253
164
|
inputRef.current?.click();
|
|
254
165
|
};
|
|
255
166
|
|
|
256
|
-
// The overlay only paints when there's something to show — dragging, deploying,
|
|
257
|
-
// or an error. With `showStatus` off, the consumer drives their own feedback.
|
|
258
167
|
const showOverlay = showStatus && (active || busy || status.kind === 'error');
|
|
259
168
|
const rootClass = [
|
|
260
169
|
'n-dropzone',
|
|
@@ -343,11 +252,8 @@ export default function DropZone({
|
|
|
343
252
|
}
|
|
344
253
|
|
|
345
254
|
/**
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
* stream of `dragover` events — so we stay active only while they keep arriving
|
|
349
|
-
* and clear shortly after they stop. This also handles drags over children,
|
|
350
|
-
* where `dragleave` targets a child rather than the zone.
|
|
255
|
+
* Drag-over tracking by dragover decay: drags have no reliable cancel event,
|
|
256
|
+
* and dragleave targets children — so stay active while events keep arriving.
|
|
351
257
|
*/
|
|
352
258
|
function useDragActive() {
|
|
353
259
|
const [active, setActive] = useState(false);
|
|
@@ -112,7 +112,6 @@ interface UseDropDeployOptions {
|
|
|
112
112
|
track: TrackFn;
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
/** Map a low-level deploy progress event onto our UI status. */
|
|
116
115
|
function progressToStatus(e: DeployProgress): DeployStatus {
|
|
117
116
|
switch (e.phase) {
|
|
118
117
|
case 'zipping':
|
|
@@ -129,7 +128,7 @@ function progressToStatus(e: DeployProgress): DeployStatus {
|
|
|
129
128
|
}
|
|
130
129
|
}
|
|
131
130
|
|
|
132
|
-
/**
|
|
131
|
+
/** Advisory only — a missing index.html never fails the deploy. */
|
|
133
132
|
function indexHtmlWarning(files: ProcessedFile[]): string | null {
|
|
134
133
|
const singleNonIndex = getSingleNonIndexHtmlFileName(files);
|
|
135
134
|
if (singleNonIndex) {
|
|
@@ -142,12 +141,9 @@ function indexHtmlWarning(files: ProcessedFile[]): string | null {
|
|
|
142
141
|
}
|
|
143
142
|
|
|
144
143
|
/**
|
|
145
|
-
* Best-effort
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
* handoff is the URL fragment on the claim redirect; this only helps if the
|
|
149
|
-
* drop and claim share an origin. Authenticated deploys never call this —
|
|
150
|
-
* there is no claim token, and the upload bearer must never be persisted.
|
|
144
|
+
* Best-effort claim persistence. localStorage is per-origin, so this never
|
|
145
|
+
* reaches app.netlify.com — the URL fragment is the real cross-origin handoff.
|
|
146
|
+
* Authenticated deploys skip it: no claim token, and bearers are never persisted.
|
|
151
147
|
*/
|
|
152
148
|
function persistAnonymousClaimLocally(token: string, siteName: string): void {
|
|
153
149
|
try {
|
|
@@ -183,11 +179,7 @@ export function useDropDeploy({
|
|
|
183
179
|
onError,
|
|
184
180
|
track,
|
|
185
181
|
}: UseDropDeployOptions) {
|
|
186
|
-
/**
|
|
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.
|
|
190
|
-
*/
|
|
182
|
+
/** Typed veneer over `track` — a renamed property fails to compile instead of forking analytics history. */
|
|
191
183
|
const emit = <E extends DropZoneEventName>(event: E, props: DropZoneEventProperties[E]) =>
|
|
192
184
|
// The event interfaces carry no index signature, hence the cast to TrackFn's record.
|
|
193
185
|
track(event, props as Record<string, unknown>);
|
|
@@ -220,10 +212,8 @@ export function useDropDeploy({
|
|
|
220
212
|
const busy = status.kind !== 'idle' && status.kind !== 'error';
|
|
221
213
|
|
|
222
214
|
/**
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
* in that case, so the caller can still stash the archive and hand off to
|
|
226
|
-
* signup. Every other failure throws and is reported by the caller's `catch`.
|
|
215
|
+
* Site + build for a zipped drop. `null` = not logged in (nothing created,
|
|
216
|
+
* so the caller can still stash and hand off); other failures throw.
|
|
227
217
|
*/
|
|
228
218
|
async function enqueueZippedBuild(
|
|
229
219
|
client: BuildClient,
|
|
@@ -257,10 +247,8 @@ export function useDropDeploy({
|
|
|
257
247
|
}
|
|
258
248
|
|
|
259
249
|
/**
|
|
260
|
-
* `fileTypes` is
|
|
261
|
-
*
|
|
262
|
-
* captured before `read()` runs so it survives a read that throws. Omitted
|
|
263
|
-
* from events when empty rather than sent as `[]`.
|
|
250
|
+
* `fileTypes` is captured at the drop boundary, before `read()` runs, so it
|
|
251
|
+
* survives a read that throws. Omitted from events when empty, never `[]`.
|
|
264
252
|
*/
|
|
265
253
|
async function deploy(
|
|
266
254
|
read: () => Promise<ProcessedFile[]>,
|
|
@@ -272,12 +260,8 @@ export function useDropDeploy({
|
|
|
272
260
|
|
|
273
261
|
const typeProps = fileTypes.length ? { file_types: fileTypes } : {};
|
|
274
262
|
|
|
275
|
-
//
|
|
276
|
-
//
|
|
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.
|
|
263
|
+
// Single failure funnel. `file_count` only when the file set is known;
|
|
264
|
+
// `reason` is raw for analytics, `message` is the humanized copy; `onError` gets both.
|
|
281
265
|
const fail = (message: string, reason: string, extra?: Record<string, unknown>) => {
|
|
282
266
|
setStatus({ kind: 'error', message });
|
|
283
267
|
emit(DROPZONE_EVENTS.DEPLOY_FAILED, { method: source, reason, ...typeProps, ...extra });
|
|
@@ -285,10 +269,9 @@ export function useDropDeploy({
|
|
|
285
269
|
};
|
|
286
270
|
|
|
287
271
|
try {
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
// single-file case no longer warns and only the multi-HTML case does.
|
|
272
|
+
// Rename a lone non-index HTML file so it is served at the site root.
|
|
273
|
+
// Warnings are computed on the renamed set, so the fixable single-file
|
|
274
|
+
// case no longer warns and only the multi-HTML case does.
|
|
292
275
|
const files = renameSingleNonIndexHtmlToIndex(await read());
|
|
293
276
|
if (!files.length) {
|
|
294
277
|
fail('No files found to deploy.', 'No files found to deploy.', { file_count: 0 });
|
|
@@ -319,12 +302,8 @@ export function useDropDeploy({
|
|
|
319
302
|
}
|
|
320
303
|
|
|
321
304
|
/**
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
* way yields a broken site. They're routed to the authenticated build path
|
|
325
|
-
* instead, or stashed and handed off to signup when there is no session.
|
|
326
|
-
* Consumers can take over entirely with `onBuildRequired`, or own just the
|
|
327
|
-
* signup hand-off with `onBuildSignupHandoff`.
|
|
305
|
+
* Build-required drops can't use the anonymous API (static-only): build in
|
|
306
|
+
* the account, or stash + hand off to signup when there's no session.
|
|
328
307
|
*/
|
|
329
308
|
async function handleBuildRequiredDrop(
|
|
330
309
|
files: ProcessedFile[],
|
|
@@ -372,8 +351,7 @@ export function useDropDeploy({
|
|
|
372
351
|
|
|
373
352
|
// No session after all (the auth hint cookie can outlive the session).
|
|
374
353
|
// Nothing was created — stash the archive so the drop survives the
|
|
375
|
-
// signup round-trip.
|
|
376
|
-
// authenticated, the mount-time resume deploys it for them.
|
|
354
|
+
// signup round-trip.
|
|
377
355
|
stashed = await saveDropStash(archive.file, archive.buildSettings);
|
|
378
356
|
if (stashed) {
|
|
379
357
|
emit(DROPZONE_EVENTS.PROJECT_STASHED, {
|
|
@@ -399,13 +377,9 @@ export function useDropDeploy({
|
|
|
399
377
|
}
|
|
400
378
|
|
|
401
379
|
/**
|
|
402
|
-
*
|
|
403
|
-
*
|
|
404
|
-
*
|
|
405
|
-
* anything is created (the auth hint cookie can outlive the session), so
|
|
406
|
-
* retrying anonymously can't strand a half-made site. Any later failure is
|
|
407
|
-
* real and rethrows: falling back then would leave an empty site in the
|
|
408
|
-
* account and deploy a duplicate.
|
|
380
|
+
* `null` = no session (pre-mutation, so the anonymous fallback is safe).
|
|
381
|
+
* Anything else rethrows — falling back after the site exists would strand
|
|
382
|
+
* an empty site and deploy a duplicate.
|
|
409
383
|
*/
|
|
410
384
|
async function attemptAuthenticatedStaticDeploy(
|
|
411
385
|
client: DropClient,
|
|
@@ -447,11 +421,10 @@ export function useDropDeploy({
|
|
|
447
421
|
}
|
|
448
422
|
|
|
449
423
|
setStatus({ kind: 'redirecting', target: authenticated ? 'dashboard' : 'claim' });
|
|
450
|
-
//
|
|
451
|
-
//
|
|
452
|
-
//
|
|
453
|
-
//
|
|
454
|
-
// otherwise, like `file_types`.
|
|
424
|
+
// The redirect below can cut a fire-and-forget analytics request short;
|
|
425
|
+
// harden delivery with sendBeacon or by deferring the redirect.
|
|
426
|
+
// `auth_fallback` is only sent when an authenticated attempt bounced
|
|
427
|
+
// (stale auth hint) — omitted otherwise, like `file_types`.
|
|
455
428
|
emit(DROPZONE_EVENTS.DEPLOY_SUCCEEDED, {
|
|
456
429
|
method: source,
|
|
457
430
|
site_name: result.subdomain,
|
|
@@ -471,7 +444,6 @@ export function useDropDeploy({
|
|
|
471
444
|
redirectAfterStaticDeploy(authenticated, result.subdomain, token);
|
|
472
445
|
}
|
|
473
446
|
|
|
474
|
-
/** Hand the visitor to their new site: its dashboard page, or the claim page. */
|
|
475
447
|
function redirectAfterStaticDeploy(authenticated: boolean, siteName: string, token: string) {
|
|
476
448
|
if (authenticated) {
|
|
477
449
|
// The site already lives in the visitor's account — send them straight
|
|
@@ -486,13 +458,8 @@ export function useDropDeploy({
|
|
|
486
458
|
}
|
|
487
459
|
|
|
488
460
|
/**
|
|
489
|
-
* Deploy
|
|
490
|
-
*
|
|
491
|
-
* The stash is consumed only once the visitor is provably authenticated —
|
|
492
|
-
* the marker (or the auth hint behind `getUploadToken`) can outlive the
|
|
493
|
-
* session, and consuming on a bounced attempt would quietly lose the drop.
|
|
494
|
-
* Once past that gate it is consumed whether the deploy succeeds or fails,
|
|
495
|
-
* so a broken project can never retry-loop.
|
|
461
|
+
* Deploy the stashed drop. Consumed only once provably authenticated (a
|
|
462
|
+
* bounced attempt keeps it), then consumed win or lose — no retry loops.
|
|
496
463
|
*/
|
|
497
464
|
async function resumeStashedBuild(client: BuildClient) {
|
|
498
465
|
const stash = await loadDropStash();
|
|
@@ -517,7 +484,7 @@ export function useDropDeploy({
|
|
|
517
484
|
|
|
518
485
|
if (!built) {
|
|
519
486
|
// Still no session — the visitor came back logged out. Keep the stash for
|
|
520
|
-
// their next authenticated visit
|
|
487
|
+
// their next authenticated visit.
|
|
521
488
|
setStatus({ kind: 'idle' });
|
|
522
489
|
return;
|
|
523
490
|
}
|
|
@@ -1,20 +1,13 @@
|
|
|
1
1
|
import type { BuildReason } from './detectBuild';
|
|
2
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
|
-
*/
|
|
3
|
+
/** The analytics contract: canonical event names + property shapes, so no consumer hand-writes them. */
|
|
9
4
|
|
|
10
5
|
/** How the files reached the zone — carried on every drop-initiated event as `method`. */
|
|
11
6
|
export type DropMethod = 'drag' | 'browse';
|
|
12
7
|
|
|
13
8
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* renaming a value silently forks every funnel built on it — treat them as
|
|
17
|
-
* append-only.
|
|
9
|
+
* The wire names. Amplitude/Segment history keys off these exact strings —
|
|
10
|
+
* append-only, never rename.
|
|
18
11
|
*/
|
|
19
12
|
export const DROPZONE_EVENTS = {
|
|
20
13
|
/** Files arrived via drag — the picker path reports its files on outcome events instead. */
|
|
@@ -114,9 +107,5 @@ export interface DropZoneEventProperties {
|
|
|
114
107
|
dropzone_resumed: ResumedProps;
|
|
115
108
|
}
|
|
116
109
|
|
|
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
|
-
*/
|
|
110
|
+
/** Provider-agnostic sink — loose on purpose, since hosts pipe their own events through it too. */
|
|
122
111
|
export type TrackFn = (event: string, properties?: Record<string, unknown>) => void;
|
|
@@ -2,22 +2,15 @@ import type { BuildSite } from './types';
|
|
|
2
2
|
import { NotAuthenticatedError, SiteCreateError } from './errors';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* (Uploads are the exception — the proxy's Lambda caps request bodies at
|
|
9
|
-
* ~6MB — and each client handles its own, bearer-authorized.)
|
|
5
|
+
* Transport shared by both authenticated clients: JSON rides the consumer's
|
|
6
|
+
* access-control rewrite on the session cookie. Uploads are each client's own
|
|
7
|
+
* (bearer-direct — the proxy can't carry them).
|
|
10
8
|
*/
|
|
11
9
|
|
|
12
10
|
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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.
|
|
11
|
+
* `POST {proxyBase}/sites`, attributed `created_via: 'drop'`. A logged-out
|
|
12
|
+
* visitor 401s here, before anything is created — the one point an anonymous
|
|
13
|
+
* fallback is still safe, hence NotAuthenticatedError.
|
|
21
14
|
*/
|
|
22
15
|
export async function createSiteInAccount(
|
|
23
16
|
proxyBase: string,
|
|
@@ -48,15 +41,9 @@ interface DeployState {
|
|
|
48
41
|
}
|
|
49
42
|
|
|
50
43
|
/**
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
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.
|
|
44
|
+
* Polls the deploy on the session cookie and reports the terminal outcome —
|
|
45
|
+
* callers map it onto their own error vocabulary. Non-ok reads are skipped,
|
|
46
|
+
* not fatal: a proxy hiccup shouldn't kill a progressing deploy.
|
|
60
47
|
*/
|
|
61
48
|
export async function pollDeployUntilSettled(options: {
|
|
62
49
|
proxyBase: string;
|
|
@@ -11,27 +11,13 @@ import { DeployFailedError, DeployTimeoutError, NotAuthenticatedError } from './
|
|
|
11
11
|
import type { BuildSite, Digest, DropResponse } from './types';
|
|
12
12
|
|
|
13
13
|
export interface AuthenticatedDropClientConfig {
|
|
14
|
-
/**
|
|
15
|
-
* Base for JSON API calls, routed through the consumer's access-control
|
|
16
|
-
* rewrite so the session cookie authenticates them. On netlify.com that
|
|
17
|
-
* rewrite is `/access-control/*` → the app's access-control function, which
|
|
18
|
-
* makes `/access-control/bb-api/api/v1` the API root (the default here).
|
|
19
|
-
*/
|
|
14
|
+
/** Root for session-cookie JSON calls, via the consumer's access-control rewrite. */
|
|
20
15
|
proxyBase?: string;
|
|
21
|
-
/**
|
|
22
|
-
* Direct API base for the file uploads. The proxy above runs in a Lambda with
|
|
23
|
-
* a ~6MB request body limit, so file PUTs go straight to `api.netlify.com`
|
|
24
|
-
* (whose `/api/*` CORS policy allows any origin).
|
|
25
|
-
*/
|
|
16
|
+
/** Direct base for file PUTs — the proxy's ~6MB Lambda body limit can't carry uploads. */
|
|
26
17
|
apiBase?: string;
|
|
27
18
|
/**
|
|
28
|
-
* Mint
|
|
29
|
-
*
|
|
30
|
-
* `accessControlToken` the API decrypts back into the user's access token.
|
|
31
|
-
* Resolving `null` means "no session" and surfaces as `NotAuthenticatedError`.
|
|
32
|
-
*
|
|
33
|
-
* Called repeatedly, not once: the token issued to a non-app origin lives
|
|
34
|
-
* only 30 seconds, which a multi-file upload routinely outlasts.
|
|
19
|
+
* Mint the upload bearer; `null` means "no session". Called repeatedly —
|
|
20
|
+
* non-app origins get 30-second tokens, which an upload queue outlasts.
|
|
35
21
|
*/
|
|
36
22
|
getUploadToken: () => Promise<string | null>;
|
|
37
23
|
/** Create the site in this account. Omit to use the user's default account. */
|
|
@@ -51,25 +37,10 @@ interface SiteDeployResponse {
|
|
|
51
37
|
}
|
|
52
38
|
|
|
53
39
|
/**
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
* There is no claim step, so the caller should hand off to the site's dashboard
|
|
59
|
-
* page rather than the claim page.
|
|
60
|
-
*
|
|
61
|
-
* Requests split across two hosts by necessity. JSON calls (create the site,
|
|
62
|
-
* create the deploy, poll it) go through the consumer's access-control proxy on
|
|
63
|
-
* the session cookie — no token involved, and nothing that can expire mid-deploy.
|
|
64
|
-
* File uploads can't: the proxy caps bodies at ~6MB, so they go direct to
|
|
65
|
-
* `api.netlify.com` with a bearer, re-minted as it ages (see `TOKEN_REUSE_MS`).
|
|
66
|
-
*
|
|
67
|
-
* Error-mapping invariant: only pre-mutation failures — a null upload token at
|
|
68
|
-
* the gate, or a 401/403 creating the site — throw `NotAuthenticatedError`,
|
|
69
|
-
* which is the signal the drop flow may safely retry anonymously. Once the site
|
|
70
|
-
* exists, any auth failure throws a plain error instead: falling back at that
|
|
71
|
-
* point would strand an empty site in the account *and* deploy a duplicate
|
|
72
|
-
* anonymously.
|
|
40
|
+
* Deploys a static drop straight into a logged-in visitor's account — no claim
|
|
41
|
+
* step. JSON rides the cookie proxy; file PUTs go direct on a re-minted bearer.
|
|
42
|
+
* Invariant: NotAuthenticatedError only pre-mutation — after the site exists, a
|
|
43
|
+
* fallback would strand an empty site and deploy a duplicate.
|
|
73
44
|
*/
|
|
74
45
|
export class AuthenticatedDropClient implements DropClient {
|
|
75
46
|
private proxyBase: string;
|
|
@@ -108,9 +79,8 @@ export class AuthenticatedDropClient implements DropClient {
|
|
|
108
79
|
}
|
|
109
80
|
|
|
110
81
|
/**
|
|
111
|
-
* A token
|
|
112
|
-
*
|
|
113
|
-
* never `NotAuthenticatedError` — the site exists by now (class doc).
|
|
82
|
+
* A token younger than its 30s expiry. Mint failures here are plain Errors,
|
|
83
|
+
* never NotAuthenticatedError — the site exists by now (class doc).
|
|
114
84
|
*/
|
|
115
85
|
private async freshToken(): Promise<string> {
|
|
116
86
|
const now = Date.now();
|
|
@@ -123,11 +93,7 @@ export class AuthenticatedDropClient implements DropClient {
|
|
|
123
93
|
return value;
|
|
124
94
|
}
|
|
125
95
|
|
|
126
|
-
/**
|
|
127
|
-
* Two proxied calls: create the site in the visitor's account, then the
|
|
128
|
-
* deploy on it. Mapped into the anonymous `DropResponse` shape so
|
|
129
|
-
* `deployFiles` runs unchanged over either client.
|
|
130
|
-
*/
|
|
96
|
+
/** Site + deploy via the proxy, mapped into `DropResponse` so `deployFiles` runs unchanged. */
|
|
131
97
|
async createDeploy(files: Digest, _token: string): Promise<DropResponse> {
|
|
132
98
|
const site: BuildSite = await createSiteInAccount(this.proxyBase, this.accountSlug);
|
|
133
99
|
|
|
@@ -148,11 +114,7 @@ export class AuthenticatedDropClient implements DropClient {
|
|
|
148
114
|
};
|
|
149
115
|
}
|
|
150
116
|
|
|
151
|
-
/**
|
|
152
|
-
* Direct to `api.netlify.com` on a freshly-aged bearer — the threaded `token`
|
|
153
|
-
* is ignored because an upload queue easily outlives the 30 seconds it was
|
|
154
|
-
* minted with. PUT (not POST) so paths with "&" etc. work.
|
|
155
|
-
*/
|
|
117
|
+
/** Direct on a fresh bearer (the threaded one has expired). PUT so paths with "&" work. */
|
|
156
118
|
async uploadFile(
|
|
157
119
|
deployId: string,
|
|
158
120
|
path: string,
|
|
@@ -168,11 +130,7 @@ export class AuthenticatedDropClient implements DropClient {
|
|
|
168
130
|
if (!res.ok) throw apiError(`upload ${path}`, res.status);
|
|
169
131
|
}
|
|
170
132
|
|
|
171
|
-
/**
|
|
172
|
-
* Poll the deploy through the proxy until it settles: resolve on `ready`,
|
|
173
|
-
* throw on `error`/`rejected`, throw on timeout. On the session cookie rather
|
|
174
|
-
* than a bearer, so a deploy taking minutes can't outlive its own credentials.
|
|
175
|
-
*/
|
|
133
|
+
/** Polls on the session cookie, so a slow deploy can't outlive its own credentials. */
|
|
176
134
|
async waitUntilReady(deploy: DropResponse, _token: string): Promise<void> {
|
|
177
135
|
const settled = await pollDeployUntilSettled({
|
|
178
136
|
proxyBase: this.proxyBase,
|
|
@@ -56,10 +56,25 @@ describe('AuthenticatedBuildClient.createBuildFromZip', () => {
|
|
|
56
56
|
expect(body.get('title')).toBe('Build from drop deployment');
|
|
57
57
|
});
|
|
58
58
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
59
|
+
// The site exists by the time this method runs, so a NotAuthenticatedError
|
|
60
|
+
// here would read as "nothing was created" and send the drop back to the
|
|
61
|
+
// stash — leaving an empty project behind, and another on the next resume.
|
|
62
|
+
it('reports a missing upload token as BuildCreateError, never NotAuthenticatedError', async () => {
|
|
63
|
+
const failure = await client(async () => null)
|
|
64
|
+
.createBuildFromZip('s', zip)
|
|
65
|
+
.catch(e => e);
|
|
66
|
+
expect(failure).toBeInstanceOf(BuildCreateError);
|
|
67
|
+
expect(failure).not.toBeInstanceOf(NotAuthenticatedError);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('reports a 401 from an expired upload token as BuildCreateError', async () => {
|
|
71
|
+
vi.stubGlobal('fetch', async () => ({ ok: false, status: 401, json: async () => ({}) }));
|
|
72
|
+
const failure = await client()
|
|
73
|
+
.createBuildFromZip('s', zip)
|
|
74
|
+
.catch(e => e);
|
|
75
|
+
expect(failure).toBeInstanceOf(BuildCreateError);
|
|
76
|
+
expect(failure).not.toBeInstanceOf(NotAuthenticatedError);
|
|
77
|
+
expect(failure.status).toBe(401);
|
|
63
78
|
});
|
|
64
79
|
|
|
65
80
|
it('wraps other upload failures in BuildCreateError with the status', async () => {
|