@netlify/spark-ui 1.30.0 → 1.31.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -3
- package/dist/chunks/browser.Ca2rTNTu.js +769 -0
- package/dist/components/articleCopyActions.d.ts +9 -0
- package/dist/components/articleCopyActions.test.d.ts +1 -0
- package/dist/components/index.d.ts +2 -2
- package/dist/components/preact/DropZone/DropZone.d.ts +76 -14
- package/dist/components/preact/DropZone/DropZone.js +111 -84
- package/dist/components/preact/DropZone/drop-core/authedDropClient.d.ts +80 -0
- package/dist/components/preact/DropZone/drop-core/authedDropClient.js +99 -0
- package/dist/components/preact/DropZone/drop-core/buildClient.d.ts +98 -0
- package/dist/components/preact/DropZone/drop-core/buildClient.js +92 -0
- package/dist/components/preact/DropZone/drop-core/client.d.ts +4 -0
- package/dist/components/preact/DropZone/drop-core/client.js +2 -1
- package/dist/components/preact/DropZone/drop-core/deploy.d.ts +31 -0
- package/dist/components/preact/DropZone/drop-core/deploy.js +36 -18
- package/dist/components/preact/DropZone/drop-core/detectBuild.d.ts +26 -0
- package/dist/components/preact/DropZone/drop-core/detectBuild.js +80 -53
- package/dist/components/preact/DropZone/drop-core/errors.d.ts +38 -0
- package/dist/components/preact/DropZone/drop-core/errors.js +50 -5
- package/dist/components/preact/DropZone/drop-core/index.d.ts +3 -0
- package/dist/components/preact/DropZone/drop-core/index.js +33 -18
- package/dist/components/preact/DropZone/drop-core/readFiles.js +1 -1
- package/dist/components/preact/DropZone/drop-core/types.d.ts +6 -1
- package/dist/components/preact/DropZone/drop-core/zip.d.ts +46 -0
- package/dist/components/preact/DropZone/drop-core/zip.js +43 -0
- package/dist/components/preact/DropZone/index.d.ts +3 -2
- package/dist/components/preact/DropZone/index.js +27 -11
- package/dist/components/preact/DropZone/useDropDeploy.d.ts +35 -4
- package/dist/components/preact/DropZone/useDropDeploy.js +139 -70
- package/dist/components/preact/index.d.ts +2 -1
- package/dist/components/preact/index.js +17 -12
- package/package.json +5 -2
- package/packages/components/ArticleCopyActions.astro +122 -0
- package/packages/components/articleCopyActions.test.ts +143 -0
- package/packages/components/articleCopyActions.ts +41 -0
- package/packages/components/index.ts +15 -0
- package/packages/components/preact/DropZone/DropZone.tsx +123 -20
- package/packages/components/preact/DropZone/drop-core/authedDropClient.ts +194 -0
- package/packages/components/preact/DropZone/drop-core/buildClient.ts +190 -0
- package/packages/components/preact/DropZone/drop-core/client.ts +1 -1
- package/packages/components/preact/DropZone/drop-core/deploy.ts +65 -0
- package/packages/components/preact/DropZone/drop-core/detectBuild.ts +125 -41
- package/packages/components/preact/DropZone/drop-core/errors.ts +84 -1
- package/packages/components/preact/DropZone/drop-core/index.ts +3 -0
- package/packages/components/preact/DropZone/drop-core/readFiles.ts +1 -1
- package/packages/components/preact/DropZone/drop-core/types.ts +12 -1
- package/packages/components/preact/DropZone/drop-core/zip.ts +118 -0
- package/packages/components/preact/DropZone/index.tsx +23 -0
- package/packages/components/preact/DropZone/useDropDeploy.ts +195 -30
- package/packages/components/preact/index.ts +2 -1
- package/dist/chunks/browser.D58rdxmc.js +0 -444
|
@@ -12,6 +12,67 @@ export class UnsupportedDropError extends Error {
|
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Thrown when an authenticated path (static or build) turns out to have no
|
|
17
|
+
* usable session — the short-lived upload token came back empty, or the
|
|
18
|
+
* session-cookie site create was refused, so nothing was created. For static
|
|
19
|
+
* drops the flow falls back to the anonymous drop + claim; builds have no
|
|
20
|
+
* anonymous equivalent, so the consumer's only recourse is to send the visitor
|
|
21
|
+
* through login/signup and have them drop again.
|
|
22
|
+
*/
|
|
23
|
+
export class NotAuthenticatedError extends Error {
|
|
24
|
+
constructor(message = 'not authenticated') {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = 'NotAuthenticatedError';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** `POST …/sites` failed, so there is nothing to build into. Carries the HTTP status. */
|
|
31
|
+
export class SiteCreateError extends Error {
|
|
32
|
+
status?: number;
|
|
33
|
+
|
|
34
|
+
constructor(status?: number) {
|
|
35
|
+
super(`site create failed${status ? `: ${status}` : ''}`);
|
|
36
|
+
this.name = 'SiteCreateError';
|
|
37
|
+
this.status = status;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The multipart `POST /sites/{id}/builds` upload itself failed — the build never
|
|
43
|
+
* started. Distinct from `BuildFailedError`, which is a build that ran and broke.
|
|
44
|
+
*/
|
|
45
|
+
export class BuildCreateError extends Error {
|
|
46
|
+
status?: number;
|
|
47
|
+
|
|
48
|
+
constructor(status?: number) {
|
|
49
|
+
super(`build create failed${status ? `: ${status}` : ''}`);
|
|
50
|
+
this.name = 'BuildCreateError';
|
|
51
|
+
this.status = status;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The build ran and ended in `error`/`rejected`. `reason` is the API's
|
|
57
|
+
* `error_message` when there is one — it's the only part of a build failure that
|
|
58
|
+
* says anything actionable (a failing build command, a missing dependency), so
|
|
59
|
+
* it is surfaced to the user rather than swallowed.
|
|
60
|
+
*/
|
|
61
|
+
export class BuildFailedError extends Error {
|
|
62
|
+
constructor(public reason?: string) {
|
|
63
|
+
super(reason ? `build failed: ${reason}` : 'build failed');
|
|
64
|
+
this.name = 'BuildFailedError';
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The build hadn't settled before the poller gave up. The build itself may still finish. */
|
|
69
|
+
export class BuildTimeoutError extends Error {
|
|
70
|
+
constructor() {
|
|
71
|
+
super('Build did not finish in time');
|
|
72
|
+
this.name = 'BuildTimeoutError';
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
15
76
|
/**
|
|
16
77
|
* Turn a raw deploy failure into copy a non-technical user can act on.
|
|
17
78
|
* The original message is still tracked via analytics — this is only for display.
|
|
@@ -23,6 +84,26 @@ export function humanizeDropError(err: unknown): string {
|
|
|
23
84
|
return 'To deploy, drop a folder or a .zip file — or a single HTML page.';
|
|
24
85
|
}
|
|
25
86
|
|
|
87
|
+
if (err instanceof NotAuthenticatedError) {
|
|
88
|
+
return 'Please log in to Netlify to deploy a project that needs a build, then try again.';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (err instanceof SiteCreateError) {
|
|
92
|
+
return "We couldn't create a project for this build. Please try again.";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// The build's own error message is the only actionable part of a build
|
|
96
|
+
// failure, so pass it straight through instead of a generic apology.
|
|
97
|
+
if (err instanceof BuildFailedError) {
|
|
98
|
+
return err.reason
|
|
99
|
+
? `Your build failed: ${err.reason}`
|
|
100
|
+
: 'Your build failed. Check your build settings and try again.';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (err instanceof BuildTimeoutError) {
|
|
104
|
+
return 'Your build is taking longer than expected. It may still finish — check your Netlify dashboard.';
|
|
105
|
+
}
|
|
106
|
+
|
|
26
107
|
if (err.message === 'Deploy did not become ready in time') {
|
|
27
108
|
return 'Your site is taking longer than expected to go live. Please try again in a moment.';
|
|
28
109
|
}
|
|
@@ -31,7 +112,9 @@ export function humanizeDropError(err: unknown): string {
|
|
|
31
112
|
return "Couldn't connect to Netlify. Check your internet connection and try again.";
|
|
32
113
|
}
|
|
33
114
|
|
|
34
|
-
// client.ts attaches `.status` to API failures
|
|
115
|
+
// client.ts attaches `.status` to API failures (see apiError()), as do the
|
|
116
|
+
// typed build errors above — so `BuildCreateError(413)` lands on the
|
|
117
|
+
// upload-too-large copy rather than a generic message.
|
|
35
118
|
const status = 'status' in err && typeof err.status === 'number' ? err.status : undefined;
|
|
36
119
|
|
|
37
120
|
switch (status) {
|
|
@@ -4,5 +4,8 @@ export * from './readFiles';
|
|
|
4
4
|
export * from './fileTypes';
|
|
5
5
|
export * from './detectBuild';
|
|
6
6
|
export * from './client';
|
|
7
|
+
export * from './authedDropClient';
|
|
8
|
+
export * from './buildClient';
|
|
9
|
+
export * from './zip';
|
|
7
10
|
export * from './deploy';
|
|
8
11
|
export * from './errors';
|
|
@@ -123,7 +123,7 @@ async function readZip(zip: File): Promise<ProcessedFile[]> {
|
|
|
123
123
|
// ArrayBuffer, so narrow it for toProcessedFile's ArrayBuffer parameter.
|
|
124
124
|
const content = bytes.buffer.slice(
|
|
125
125
|
bytes.byteOffset,
|
|
126
|
-
bytes.byteOffset + bytes.byteLength
|
|
126
|
+
bytes.byteOffset + bytes.byteLength
|
|
127
127
|
) as ArrayBuffer;
|
|
128
128
|
out.push(toProcessedFile(path, content, path.split('/').pop()!));
|
|
129
129
|
}
|
|
@@ -34,7 +34,18 @@ export interface DeployResult {
|
|
|
34
34
|
url: string; // https://<subdomain>.netlify.app
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
/**
|
|
38
|
+
* Phases shared by both deploy paths. A static drop runs
|
|
39
|
+
* creating → uploading → processing → ready; a build drop runs
|
|
40
|
+
* zipping → creating → uploading → building → ready.
|
|
41
|
+
*/
|
|
42
|
+
export type DeployPhase =
|
|
43
|
+
| 'zipping'
|
|
44
|
+
| 'creating'
|
|
45
|
+
| 'uploading'
|
|
46
|
+
| 'processing'
|
|
47
|
+
| 'building'
|
|
48
|
+
| 'ready';
|
|
38
49
|
|
|
39
50
|
export interface DeployProgress {
|
|
40
51
|
phase: DeployPhase;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { inferBuildSettings, type InferredBuildSettings } from './detectBuild';
|
|
2
|
+
import type { ProcessedFile } from './types';
|
|
3
|
+
|
|
4
|
+
const NETLIFY_TOML_PATH = '/netlify.toml';
|
|
5
|
+
|
|
6
|
+
/** TOML basic-string escaping — enough for the two values we ever write. */
|
|
7
|
+
function escapeTomlString(value: string): string {
|
|
8
|
+
return value
|
|
9
|
+
.replace(/\\/g, '\\\\')
|
|
10
|
+
.replace(/"/g, '\\"')
|
|
11
|
+
.replace(/\n/g, '\\n')
|
|
12
|
+
.replace(/\r/g, '\\r')
|
|
13
|
+
.replace(/\t/g, '\\t');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Render inferred settings as a `[build]` table. Only `command` and `publish`
|
|
18
|
+
* are written: everything else (functions directory, plugins, node version)
|
|
19
|
+
* is zero-config on Netlify's side and guessing at it would override detection
|
|
20
|
+
* that is better informed than we are.
|
|
21
|
+
*/
|
|
22
|
+
export function generateNetlifyToml({ command, publish }: InferredBuildSettings): string {
|
|
23
|
+
return [
|
|
24
|
+
'[build]',
|
|
25
|
+
` command = "${escapeTomlString(command)}"`,
|
|
26
|
+
` publish = "${escapeTomlString(publish)}"`,
|
|
27
|
+
'',
|
|
28
|
+
].join('\n');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ZipOptions {
|
|
32
|
+
/** Filename given to the produced `File`. Shows up as the build's source archive. */
|
|
33
|
+
name?: string;
|
|
34
|
+
/**
|
|
35
|
+
* Synthesise a `netlify.toml` `[build]` table from `inferBuildSettings` when the
|
|
36
|
+
* drop doesn't declare one (default `true`). Set `false` to upload the files
|
|
37
|
+
* exactly as dropped and let buildbot resolve every setting itself.
|
|
38
|
+
*/
|
|
39
|
+
synthesizeNetlifyToml?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ZipResult {
|
|
43
|
+
/** The archive, ready to hand to `createBuildFromZip`. */
|
|
44
|
+
file: File;
|
|
45
|
+
/**
|
|
46
|
+
* The settings written into `netlify.toml`, or `null` when the archive is a
|
|
47
|
+
* faithful copy of the drop — either because it already declared a `[build]`
|
|
48
|
+
* table, or because nothing could be inferred with confidence.
|
|
49
|
+
*/
|
|
50
|
+
buildSettings: InferredBuildSettings | null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const encoder = new TextEncoder();
|
|
54
|
+
const decoder = new TextDecoder();
|
|
55
|
+
|
|
56
|
+
function toBytes(content: ArrayBuffer): Uint8Array {
|
|
57
|
+
return new Uint8Array(content);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Zip an already-read file set into the source archive that
|
|
62
|
+
* `POST /sites/{id}/builds` expects.
|
|
63
|
+
*
|
|
64
|
+
* The input is the same root-normalized `ProcessedFile[]` the static deploy path
|
|
65
|
+
* uses (see `readFiles`), so junk — dotfiles, `node_modules`, macOS metadata — is
|
|
66
|
+
* already gone and paths sit at the deploy root. Output directories (`dist/`,
|
|
67
|
+
* `build/`) are deliberately *not* stripped: buildbot regenerates them when
|
|
68
|
+
* there's a build command, and a drop whose `netlify.toml` publishes a committed
|
|
69
|
+
* directory without a command would break if we removed it.
|
|
70
|
+
*
|
|
71
|
+
* When the drop needs a build but declares no `[build]` table, an inferred one is
|
|
72
|
+
* written in (appended to an existing `netlify.toml`, or created if there is
|
|
73
|
+
* none) so buildbot doesn't publish the project's source. See
|
|
74
|
+
* `inferBuildSettings` for how conservative that inference is.
|
|
75
|
+
*/
|
|
76
|
+
export async function zipFiles(
|
|
77
|
+
files: ProcessedFile[],
|
|
78
|
+
{ name = 'source.zip', synthesizeNetlifyToml = true }: ZipOptions = {}
|
|
79
|
+
): Promise<ZipResult> {
|
|
80
|
+
if (!files.length) throw new Error('No files to deploy');
|
|
81
|
+
|
|
82
|
+
const buildSettings = synthesizeNetlifyToml ? inferBuildSettings(files) : null;
|
|
83
|
+
|
|
84
|
+
const entries: Record<string, Uint8Array> = {};
|
|
85
|
+
for (const file of files) {
|
|
86
|
+
// fflate keys are paths relative to the archive root, so drop the leading
|
|
87
|
+
// slash `ProcessedFile.path` carries.
|
|
88
|
+
const path = file.path.replace(/^\//, '');
|
|
89
|
+
if (path) entries[path] = toBytes(file.content);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (buildSettings) {
|
|
93
|
+
const existing = files.find(f => f.path === NETLIFY_TOML_PATH);
|
|
94
|
+
const table = generateNetlifyToml(buildSettings);
|
|
95
|
+
// Append rather than replace so the drop's own redirects, headers and
|
|
96
|
+
// environment survive alongside the synthesised build settings.
|
|
97
|
+
const contents = existing
|
|
98
|
+
? `${decoder.decode(existing.content).replace(/\n*$/, '\n')}\n${table}`
|
|
99
|
+
: table;
|
|
100
|
+
entries['netlify.toml'] = encoder.encode(contents);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// fflate is loaded on demand — same as the unzip path in readFiles — so a
|
|
104
|
+
// consumer that never drops a build project never pays for it.
|
|
105
|
+
const { zip } = await import('fflate');
|
|
106
|
+
const bytes = await new Promise<Uint8Array>((resolve, reject) =>
|
|
107
|
+
zip(entries, { level: 6 }, (err, out) => (err ? reject(err) : resolve(out)))
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
// `bytes.buffer` is ArrayBufferLike; narrow it to the plain ArrayBuffer the
|
|
111
|
+
// File constructor takes, the same way readFiles does on the unzip side.
|
|
112
|
+
const archive = bytes.buffer.slice(
|
|
113
|
+
bytes.byteOffset,
|
|
114
|
+
bytes.byteOffset + bytes.byteLength
|
|
115
|
+
) as ArrayBuffer;
|
|
116
|
+
const file = new File([archive], name, { type: 'application/zip' });
|
|
117
|
+
return { file, buildSettings };
|
|
118
|
+
}
|
|
@@ -1,20 +1,43 @@
|
|
|
1
1
|
export { default as DropZone } from './DropZone';
|
|
2
2
|
export {
|
|
3
3
|
AnonymousDropClient,
|
|
4
|
+
AuthenticatedBuildClient,
|
|
5
|
+
AuthenticatedDropClient,
|
|
6
|
+
BuildCreateError,
|
|
7
|
+
BuildFailedError,
|
|
8
|
+
BuildTimeoutError,
|
|
9
|
+
deployBuild,
|
|
4
10
|
deployFiles,
|
|
5
11
|
detectBuild,
|
|
6
12
|
getFileTypes,
|
|
13
|
+
humanizeDropError,
|
|
14
|
+
inferBuildSettings,
|
|
15
|
+
NotAuthenticatedError,
|
|
7
16
|
readDataTransfer,
|
|
8
17
|
readFileList,
|
|
18
|
+
SiteCreateError,
|
|
19
|
+
UnsupportedDropError,
|
|
20
|
+
zipFiles,
|
|
9
21
|
} from './drop-core';
|
|
10
22
|
export type {
|
|
23
|
+
AuthenticatedBuildClientConfig,
|
|
24
|
+
AuthenticatedDropClientConfig,
|
|
25
|
+
BuildClient,
|
|
26
|
+
BuildDeployOptions,
|
|
27
|
+
BuildDeployResult,
|
|
11
28
|
BuildDetection,
|
|
12
29
|
BuildReason,
|
|
30
|
+
BuildResponse,
|
|
31
|
+
BuildSite,
|
|
13
32
|
DropClient,
|
|
14
33
|
DropResponse,
|
|
15
34
|
Digest,
|
|
16
35
|
DeployResult,
|
|
17
36
|
DeployProgress,
|
|
18
37
|
DeployPhase,
|
|
38
|
+
InferredBuildSettings,
|
|
19
39
|
ProcessedFile,
|
|
40
|
+
ZipOptions,
|
|
41
|
+
ZipResult,
|
|
20
42
|
} from './drop-core';
|
|
43
|
+
export type { DeployedBuild, DeployedSite, DeployError, DeployStatus } from './useDropDeploy';
|
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
import { useRef, useState } from 'preact/hooks';
|
|
2
2
|
import {
|
|
3
3
|
AnonymousDropClient,
|
|
4
|
+
AuthenticatedBuildClient,
|
|
5
|
+
AuthenticatedDropClient,
|
|
6
|
+
deployBuild,
|
|
4
7
|
deployFiles,
|
|
5
8
|
detectBuild,
|
|
6
9
|
getSingleNonIndexHtmlFileName,
|
|
7
10
|
hasMultipleHtmlFilesWithoutIndex,
|
|
8
11
|
humanizeDropError,
|
|
12
|
+
NotAuthenticatedError,
|
|
9
13
|
renameSingleNonIndexHtmlToIndex,
|
|
14
|
+
type BuildClient,
|
|
10
15
|
type BuildDetection,
|
|
11
16
|
type DeployProgress,
|
|
17
|
+
type DeployResult,
|
|
12
18
|
type DropClient,
|
|
19
|
+
type InferredBuildSettings,
|
|
13
20
|
type ProcessedFile,
|
|
14
21
|
} from './drop-core';
|
|
15
22
|
|
|
@@ -19,9 +26,15 @@ export type DeploySource = 'drag' | 'browse';
|
|
|
19
26
|
/** The live site handed back once a deploy succeeds. */
|
|
20
27
|
export interface DeployedSite {
|
|
21
28
|
url: string;
|
|
22
|
-
|
|
29
|
+
/**
|
|
30
|
+
* Drop claim token — only present for anonymous deploys. An authenticated
|
|
31
|
+
* deploy lands directly in the visitor's account, so there is nothing to claim.
|
|
32
|
+
*/
|
|
33
|
+
token?: string;
|
|
23
34
|
siteName: string;
|
|
24
35
|
siteId: string;
|
|
36
|
+
/** True when the deploy landed directly in the visitor's account. */
|
|
37
|
+
authenticated: boolean;
|
|
25
38
|
}
|
|
26
39
|
|
|
27
40
|
/** A deploy failure, handed to `onError` so consumers can render their own error UI. */
|
|
@@ -32,16 +45,36 @@ export interface DeployError {
|
|
|
32
45
|
reason: string;
|
|
33
46
|
}
|
|
34
47
|
|
|
48
|
+
/** The build handed back once a build-required drop is enqueued. */
|
|
49
|
+
export interface DeployedBuild {
|
|
50
|
+
/** The site created for the build — live once the build finishes. */
|
|
51
|
+
url: string;
|
|
52
|
+
siteName: string;
|
|
53
|
+
siteId: string;
|
|
54
|
+
buildId: string;
|
|
55
|
+
/** The deploy the build produces, if the API has assigned one yet. */
|
|
56
|
+
deployId?: string;
|
|
57
|
+
/** Build settings synthesised into the uploaded `netlify.toml`, if any. */
|
|
58
|
+
buildSettings: InferredBuildSettings | null;
|
|
59
|
+
}
|
|
60
|
+
|
|
35
61
|
/** Finite states of a single deploy attempt. */
|
|
36
62
|
export type DeployStatus =
|
|
37
63
|
| { kind: 'idle' }
|
|
38
64
|
| { kind: 'reading' }
|
|
65
|
+
// Build path only: packing the drop into the source archive buildbot builds.
|
|
66
|
+
| { kind: 'zipping' }
|
|
39
67
|
| { kind: 'creating' }
|
|
40
68
|
| { kind: 'uploading'; uploaded: number; total: number }
|
|
41
69
|
| { kind: 'processing' }
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
70
|
+
// Build path only: the archive is uploaded and buildbot is building it.
|
|
71
|
+
| { kind: 'building' }
|
|
72
|
+
// `claim` follows an anonymous static deploy (hand off to the claim page);
|
|
73
|
+
// `dashboard` follows an authenticated static deploy (hand off to the new
|
|
74
|
+
// site's project page); `build` follows an authenticated build (hand off to
|
|
75
|
+
// the app's project page while the build runs); `signup` follows a
|
|
76
|
+
// build-required drop with no session to build it with.
|
|
77
|
+
| { kind: 'redirecting'; target: 'claim' | 'dashboard' | 'signup' | 'build' }
|
|
45
78
|
| { kind: 'error'; message: string };
|
|
46
79
|
|
|
47
80
|
/** Provider-agnostic analytics sink. */
|
|
@@ -51,9 +84,18 @@ interface UseDropDeployOptions {
|
|
|
51
84
|
apiBase?: string;
|
|
52
85
|
client?: DropClient;
|
|
53
86
|
claimUrl: (siteName: string) => string;
|
|
87
|
+
siteDashboardUrl: (siteName: string) => string;
|
|
54
88
|
buildSignupUrl: string;
|
|
89
|
+
buildClient?: BuildClient;
|
|
90
|
+
authenticatedDropClient?: DropClient;
|
|
91
|
+
getUploadToken?: () => Promise<string | null>;
|
|
92
|
+
proxyBase?: string;
|
|
93
|
+
accountSlug?: string;
|
|
94
|
+
buildDeployUrl: (build: DeployedBuild) => string;
|
|
95
|
+
waitForBuild?: boolean;
|
|
55
96
|
onBuildRequired?: (info: BuildDetection) => void;
|
|
56
97
|
onDeploy?: (site: DeployedSite) => void;
|
|
98
|
+
onBuildDeploy?: (build: DeployedBuild) => void;
|
|
57
99
|
onError?: (error: DeployError) => void;
|
|
58
100
|
track: TrackFn;
|
|
59
101
|
}
|
|
@@ -61,10 +103,14 @@ interface UseDropDeployOptions {
|
|
|
61
103
|
/** Map a low-level deploy progress event onto our UI status. */
|
|
62
104
|
function progressToStatus(e: DeployProgress): DeployStatus {
|
|
63
105
|
switch (e.phase) {
|
|
106
|
+
case 'zipping':
|
|
107
|
+
return { kind: 'zipping' };
|
|
64
108
|
case 'creating':
|
|
65
109
|
return { kind: 'creating' };
|
|
66
110
|
case 'uploading':
|
|
67
111
|
return { kind: 'uploading', uploaded: e.uploaded ?? 0, total: e.total ?? 0 };
|
|
112
|
+
case 'building':
|
|
113
|
+
return { kind: 'building' };
|
|
68
114
|
case 'processing':
|
|
69
115
|
case 'ready':
|
|
70
116
|
return { kind: 'processing' };
|
|
@@ -91,9 +137,18 @@ export function useDropDeploy({
|
|
|
91
137
|
apiBase,
|
|
92
138
|
client,
|
|
93
139
|
claimUrl,
|
|
140
|
+
siteDashboardUrl,
|
|
94
141
|
buildSignupUrl,
|
|
142
|
+
buildClient,
|
|
143
|
+
authenticatedDropClient,
|
|
144
|
+
getUploadToken,
|
|
145
|
+
proxyBase,
|
|
146
|
+
accountSlug,
|
|
147
|
+
buildDeployUrl,
|
|
148
|
+
waitForBuild,
|
|
95
149
|
onBuildRequired,
|
|
96
150
|
onDeploy,
|
|
151
|
+
onBuildDeploy,
|
|
97
152
|
onError,
|
|
98
153
|
track,
|
|
99
154
|
}: UseDropDeployOptions) {
|
|
@@ -101,8 +156,58 @@ export function useDropDeploy({
|
|
|
101
156
|
const [warning, setWarning] = useState<string | null>(null);
|
|
102
157
|
const dropClient = useRef(client ?? new AnonymousDropClient(apiBase)).current;
|
|
103
158
|
|
|
159
|
+
// Building is opt-in: without an injected client or a way to mint an upload
|
|
160
|
+
// token there is no authenticated path, and build-required drops fall back to
|
|
161
|
+
// the signup hand-off. Built once and held so a re-render doesn't hand a
|
|
162
|
+
// fresh client (and a fresh token cache) to an in-flight deploy.
|
|
163
|
+
const authedBuildClient = useRef(
|
|
164
|
+
buildClient ??
|
|
165
|
+
(getUploadToken
|
|
166
|
+
? new AuthenticatedBuildClient({ proxyBase, getUploadToken, accountSlug })
|
|
167
|
+
: null)
|
|
168
|
+
).current;
|
|
169
|
+
|
|
170
|
+
// Same opt-in for static drops: with authenticated wiring present, deploy
|
|
171
|
+
// straight into the visitor's account and skip the claim step; without it,
|
|
172
|
+
// every static drop stays anonymous.
|
|
173
|
+
const authedDropClient = useRef(
|
|
174
|
+
authenticatedDropClient ??
|
|
175
|
+
(getUploadToken
|
|
176
|
+
? new AuthenticatedDropClient({ proxyBase, getUploadToken, accountSlug })
|
|
177
|
+
: null)
|
|
178
|
+
).current;
|
|
179
|
+
|
|
104
180
|
const busy = status.kind !== 'idle' && status.kind !== 'error';
|
|
105
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Zip the drop, create a site and enqueue a build. Returns `null` when the
|
|
184
|
+
* visitor turns out not to be logged in — nothing is created in that case, so
|
|
185
|
+
* the caller can still fall back to the signup hand-off. Every other failure
|
|
186
|
+
* throws and is reported by the caller's `catch`.
|
|
187
|
+
*/
|
|
188
|
+
async function buildProject(
|
|
189
|
+
client: BuildClient,
|
|
190
|
+
files: ProcessedFile[]
|
|
191
|
+
): Promise<DeployedBuild | null> {
|
|
192
|
+
try {
|
|
193
|
+
const { site, build, buildSettings, url } = await deployBuild(client, files, {
|
|
194
|
+
onProgress: e => setStatus(progressToStatus(e)),
|
|
195
|
+
waitForBuild,
|
|
196
|
+
});
|
|
197
|
+
return {
|
|
198
|
+
url,
|
|
199
|
+
siteName: site.name,
|
|
200
|
+
siteId: site.id,
|
|
201
|
+
buildId: build.id,
|
|
202
|
+
deployId: build.deploy_id,
|
|
203
|
+
buildSettings,
|
|
204
|
+
};
|
|
205
|
+
} catch (err) {
|
|
206
|
+
if (err instanceof NotAuthenticatedError) return null;
|
|
207
|
+
throw err;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
106
211
|
/**
|
|
107
212
|
* `fileTypes` is the MIME types of the selection, read at the drop boundary
|
|
108
213
|
* (see `getFileTypes`) and passed in rather than derived here — it has to be
|
|
@@ -164,9 +269,10 @@ export function useDropDeploy({
|
|
|
164
269
|
|
|
165
270
|
// Build-required projects can't deploy through the anonymous drop API —
|
|
166
271
|
// it only performs static deploys, so building one this way yields a
|
|
167
|
-
// broken site. Detect them up front and
|
|
168
|
-
//
|
|
169
|
-
//
|
|
272
|
+
// broken site. Detect them up front and route them to the build path
|
|
273
|
+
// (which needs a logged-in user) or, failing that, hand off to the
|
|
274
|
+
// authenticated Drop page via signup. Consumers can override the hand-off
|
|
275
|
+
// entirely with `onBuildRequired`.
|
|
170
276
|
const build = detectBuild(files);
|
|
171
277
|
if (build.buildRequired) {
|
|
172
278
|
track('dropzone_build_required', {
|
|
@@ -182,6 +288,26 @@ export function useDropDeploy({
|
|
|
182
288
|
onBuildRequired(build);
|
|
183
289
|
return;
|
|
184
290
|
}
|
|
291
|
+
if (authedBuildClient) {
|
|
292
|
+
const built = await buildProject(authedBuildClient, files);
|
|
293
|
+
// No session after all (the auth hint cookie can outlive the session).
|
|
294
|
+
// Nothing was created, so fall through to the signup hand-off rather
|
|
295
|
+
// than dead-ending on an error the visitor can't act on from here.
|
|
296
|
+
if (built) {
|
|
297
|
+
setStatus({ kind: 'redirecting', target: 'build' });
|
|
298
|
+
track('dropzone_build_deploy_succeeded', {
|
|
299
|
+
method: source,
|
|
300
|
+
reason: build.reason,
|
|
301
|
+
site_name: built.siteName,
|
|
302
|
+
file_count: files.length,
|
|
303
|
+
...(built.buildSettings ? { framework: built.buildSettings.framework } : {}),
|
|
304
|
+
...typeProps,
|
|
305
|
+
});
|
|
306
|
+
onBuildDeploy?.(built);
|
|
307
|
+
window.location.assign(buildDeployUrl(built));
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
185
311
|
setStatus({ kind: 'redirecting', target: 'signup' });
|
|
186
312
|
window.location.assign(buildSignupUrl);
|
|
187
313
|
return;
|
|
@@ -189,41 +315,80 @@ export function useDropDeploy({
|
|
|
189
315
|
|
|
190
316
|
setWarning(indexHtmlWarning(files));
|
|
191
317
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
318
|
+
// Deploy into the visitor's account when we can. NotAuthenticatedError is
|
|
319
|
+
// the one failure that falls back to the anonymous flow — it's thrown
|
|
320
|
+
// before anything is created (the auth hint cookie can outlive the
|
|
321
|
+
// session), so retrying anonymously can't strand a half-made site. Any
|
|
322
|
+
// later failure is real and surfaces as an error instead: falling back
|
|
323
|
+
// then would leave an empty site in the account and deploy a duplicate.
|
|
324
|
+
let deployed: DeployResult | null = null;
|
|
325
|
+
let authenticated = false;
|
|
326
|
+
let authFallback = false;
|
|
327
|
+
if (authedDropClient) {
|
|
328
|
+
try {
|
|
329
|
+
deployed = await deployFiles(authedDropClient, files, {
|
|
330
|
+
onProgress: e => setStatus(progressToStatus(e)),
|
|
331
|
+
});
|
|
332
|
+
authenticated = true;
|
|
333
|
+
} catch (err) {
|
|
334
|
+
if (!(err instanceof NotAuthenticatedError)) throw err;
|
|
335
|
+
authFallback = true;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
if (!deployed) {
|
|
339
|
+
deployed = await deployFiles(dropClient, files, {
|
|
340
|
+
onProgress: e => setStatus(progressToStatus(e)),
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
const { url, token, deploy: result } = deployed;
|
|
199
344
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
345
|
+
if (!authenticated) {
|
|
346
|
+
// Best-effort local persistence. Note this does NOT reach the dashboard:
|
|
347
|
+
// localStorage is per-origin, so a token written here (e.g. www.netlify.com)
|
|
348
|
+
// is invisible to app.netlify.com. The cross-origin handoff is the URL
|
|
349
|
+
// fragment below; this only helps if the drop and claim share an origin.
|
|
350
|
+
// Authenticated deploys skip this entirely — there is no claim token,
|
|
351
|
+
// and the upload bearer must never be persisted.
|
|
352
|
+
try {
|
|
353
|
+
localStorage.setItem('drop-token', token);
|
|
354
|
+
localStorage.setItem('dropSiteName', result.subdomain);
|
|
355
|
+
} catch {
|
|
356
|
+
/* private mode / storage disabled — non-fatal */
|
|
357
|
+
}
|
|
209
358
|
}
|
|
210
359
|
|
|
211
|
-
setStatus({ kind: 'redirecting', target: 'claim' });
|
|
360
|
+
setStatus({ kind: 'redirecting', target: authenticated ? 'dashboard' : 'claim' });
|
|
212
361
|
// Fired before the redirect below. Note: the navigation can cut a
|
|
213
362
|
// fire-and-forget analytics request short — see the docs for delivery
|
|
214
|
-
// hardening (sendBeacon / deferring the redirect).
|
|
363
|
+
// hardening (sendBeacon / deferring the redirect). `auth_fallback` is only
|
|
364
|
+
// sent when an authenticated attempt bounced (stale auth hint) — omitted
|
|
365
|
+
// otherwise, like `file_types`.
|
|
215
366
|
track('dropzone_deploy_succeeded', {
|
|
216
367
|
method: source,
|
|
217
368
|
site_name: result.subdomain,
|
|
218
369
|
file_count: files.length,
|
|
370
|
+
authenticated,
|
|
371
|
+
...(authFallback ? { auth_fallback: true } : {}),
|
|
219
372
|
...typeProps,
|
|
220
373
|
});
|
|
221
|
-
onDeploy?.({
|
|
374
|
+
onDeploy?.({
|
|
375
|
+
url,
|
|
376
|
+
...(authenticated ? {} : { token }),
|
|
377
|
+
siteName: result.subdomain,
|
|
378
|
+
siteId: result.id,
|
|
379
|
+
authenticated,
|
|
380
|
+
});
|
|
222
381
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
382
|
+
if (authenticated) {
|
|
383
|
+
// The site already lives in the visitor's account — send them straight
|
|
384
|
+
// to its dashboard page. No claim step, no token in the URL.
|
|
385
|
+
window.location.assign(siteDashboardUrl(result.subdomain));
|
|
386
|
+
} else {
|
|
387
|
+
// Hand the drop token to the dashboard via the URL fragment — localStorage
|
|
388
|
+
// does not cross origins. The dashboard's /drop/:name page reads
|
|
389
|
+
// `#drop_token=` and persists it for the claim + post-signup steps.
|
|
390
|
+
window.location.assign(`${claimUrl(result.subdomain)}#drop_token=${token}`);
|
|
391
|
+
}
|
|
227
392
|
} catch (err) {
|
|
228
393
|
failFromError(err);
|
|
229
394
|
}
|
|
@@ -6,6 +6,7 @@ export { default as SearchHeading } from './SearchModal/components/SearchHeading
|
|
|
6
6
|
export { default as SearchToggle } from './SearchModal/components/SearchToggle/SearchToggle';
|
|
7
7
|
export { default as AskNetlify } from './AskNetlify/AskNetlify';
|
|
8
8
|
export { SearchModal } from './SearchModal';
|
|
9
|
-
export { DropZone } from './DropZone';
|
|
9
|
+
export { DropZone, AuthenticatedBuildClient, AuthenticatedDropClient } from './DropZone';
|
|
10
|
+
export type { BuildClient, DeployedBuild, DeployedSite, DropClient } from './DropZone';
|
|
10
11
|
|
|
11
12
|
export { type IconName } from './Icon';
|