@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
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* deployed as a one-asset site (matches react-ui).
|
|
7
7
|
*/
|
|
8
8
|
export class UnsupportedDropError extends Error {
|
|
9
|
+
readonly code = 'unsupported-drop';
|
|
10
|
+
|
|
9
11
|
constructor() {
|
|
10
12
|
super('unsupported single file');
|
|
11
13
|
this.name = 'UnsupportedDropError';
|
|
@@ -21,14 +23,30 @@ export class UnsupportedDropError extends Error {
|
|
|
21
23
|
* through login/signup and have them drop again.
|
|
22
24
|
*/
|
|
23
25
|
export class NotAuthenticatedError extends Error {
|
|
26
|
+
readonly code = 'not-authenticated';
|
|
27
|
+
|
|
24
28
|
constructor(message = 'not authenticated') {
|
|
25
29
|
super(message);
|
|
26
30
|
this.name = 'NotAuthenticatedError';
|
|
27
31
|
}
|
|
28
32
|
}
|
|
29
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Whether an error is the pre-mutation "no session" signal, however it
|
|
36
|
+
* traveled. This package ships as both raw TypeScript (`./components`) and
|
|
37
|
+
* compiled dist (`./preact`, `./drop-core`), so two copies of the class can
|
|
38
|
+
* coexist in one app and defeat `instanceof` — the `code` brand is the
|
|
39
|
+
* copy-proof fallback. Control flow that decides whether an anonymous retry
|
|
40
|
+
* is safe must use this, never a bare `instanceof`.
|
|
41
|
+
*/
|
|
42
|
+
export function isNotAuthenticatedError(err: unknown): err is NotAuthenticatedError {
|
|
43
|
+
if (err instanceof NotAuthenticatedError) return true;
|
|
44
|
+
return err instanceof Error && (err as { code?: string }).code === 'not-authenticated';
|
|
45
|
+
}
|
|
46
|
+
|
|
30
47
|
/** `POST …/sites` failed, so there is nothing to build into. Carries the HTTP status. */
|
|
31
48
|
export class SiteCreateError extends Error {
|
|
49
|
+
readonly code = 'site-create-failed';
|
|
32
50
|
status?: number;
|
|
33
51
|
|
|
34
52
|
constructor(status?: number) {
|
|
@@ -43,6 +61,7 @@ export class SiteCreateError extends Error {
|
|
|
43
61
|
* started. Distinct from `BuildFailedError`, which is a build that ran and broke.
|
|
44
62
|
*/
|
|
45
63
|
export class BuildCreateError extends Error {
|
|
64
|
+
readonly code = 'build-create-failed';
|
|
46
65
|
status?: number;
|
|
47
66
|
|
|
48
67
|
constructor(status?: number) {
|
|
@@ -59,6 +78,8 @@ export class BuildCreateError extends Error {
|
|
|
59
78
|
* it is surfaced to the user rather than swallowed.
|
|
60
79
|
*/
|
|
61
80
|
export class BuildFailedError extends Error {
|
|
81
|
+
readonly code = 'build-failed';
|
|
82
|
+
|
|
62
83
|
constructor(public reason?: string) {
|
|
63
84
|
super(reason ? `build failed: ${reason}` : 'build failed');
|
|
64
85
|
this.name = 'BuildFailedError';
|
|
@@ -67,12 +88,37 @@ export class BuildFailedError extends Error {
|
|
|
67
88
|
|
|
68
89
|
/** The build hadn't settled before the poller gave up. The build itself may still finish. */
|
|
69
90
|
export class BuildTimeoutError extends Error {
|
|
91
|
+
readonly code = 'build-timeout';
|
|
92
|
+
|
|
70
93
|
constructor() {
|
|
71
94
|
super('Build did not finish in time');
|
|
72
95
|
this.name = 'BuildTimeoutError';
|
|
73
96
|
}
|
|
74
97
|
}
|
|
75
98
|
|
|
99
|
+
/**
|
|
100
|
+
* A static deploy ran and ended in `error`/`rejected`. Like a build failure,
|
|
101
|
+
* the API's `error_message` is the actionable part, so it travels on the error.
|
|
102
|
+
*/
|
|
103
|
+
export class DeployFailedError extends Error {
|
|
104
|
+
readonly code = 'deploy-failed';
|
|
105
|
+
|
|
106
|
+
constructor(public reason?: string) {
|
|
107
|
+
super(reason ? `deploy failed: ${reason}` : 'deploy failed');
|
|
108
|
+
this.name = 'DeployFailedError';
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The deploy hadn't gone ready before the poller gave up. It may still finish. */
|
|
113
|
+
export class DeployTimeoutError extends Error {
|
|
114
|
+
readonly code = 'deploy-timeout';
|
|
115
|
+
|
|
116
|
+
constructor() {
|
|
117
|
+
super('Deploy did not become ready in time');
|
|
118
|
+
this.name = 'DeployTimeoutError';
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
76
122
|
/**
|
|
77
123
|
* Turn a raw deploy failure into copy a non-technical user can act on.
|
|
78
124
|
* The original message is still tracked via analytics — this is only for display.
|
|
@@ -80,31 +126,42 @@ export class BuildTimeoutError extends Error {
|
|
|
80
126
|
export function humanizeDropError(err: unknown): string {
|
|
81
127
|
if (!(err instanceof Error)) return 'Something went wrong. Please try again.';
|
|
82
128
|
|
|
83
|
-
|
|
129
|
+
// Match on the `code` brand rather than instanceof: raw-TS and dist copies
|
|
130
|
+
// of these classes can coexist (see isNotAuthenticatedError).
|
|
131
|
+
const code = (err as { code?: string }).code;
|
|
132
|
+
|
|
133
|
+
if (code === 'unsupported-drop') {
|
|
84
134
|
return 'To deploy, drop a folder or a .zip file — or a single HTML page.';
|
|
85
135
|
}
|
|
86
136
|
|
|
87
|
-
if (
|
|
88
|
-
return 'Please log in to Netlify to deploy
|
|
137
|
+
if (code === 'not-authenticated') {
|
|
138
|
+
return 'Please log in to Netlify to deploy this project, then try again.';
|
|
89
139
|
}
|
|
90
140
|
|
|
91
|
-
if (
|
|
141
|
+
if (code === 'site-create-failed') {
|
|
92
142
|
return "We couldn't create a project for this build. Please try again.";
|
|
93
143
|
}
|
|
94
144
|
|
|
95
|
-
// The
|
|
96
|
-
//
|
|
97
|
-
if (
|
|
98
|
-
|
|
99
|
-
|
|
145
|
+
// The failure's own error message is the only actionable part, so pass it
|
|
146
|
+
// straight through instead of a generic apology.
|
|
147
|
+
if (code === 'build-failed') {
|
|
148
|
+
const reason = (err as BuildFailedError).reason;
|
|
149
|
+
return reason
|
|
150
|
+
? `Your build failed: ${reason}`
|
|
100
151
|
: 'Your build failed. Check your build settings and try again.';
|
|
101
152
|
}
|
|
153
|
+
if (code === 'deploy-failed') {
|
|
154
|
+
const reason = (err as DeployFailedError).reason;
|
|
155
|
+
return reason
|
|
156
|
+
? `Your deploy failed: ${reason}`
|
|
157
|
+
: 'Your deploy failed. Please try again.';
|
|
158
|
+
}
|
|
102
159
|
|
|
103
|
-
if (
|
|
160
|
+
if (code === 'build-timeout') {
|
|
104
161
|
return 'Your build is taking longer than expected. It may still finish — check your Netlify dashboard.';
|
|
105
162
|
}
|
|
106
163
|
|
|
107
|
-
if (
|
|
164
|
+
if (code === 'deploy-timeout') {
|
|
108
165
|
return 'Your site is taking longer than expected to go live. Please try again in a moment.';
|
|
109
166
|
}
|
|
110
167
|
// fetch() rejects with a TypeError when the network is unreachable (offline, DNS, CORS, etc).
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { getFileTypes } from './fileTypes';
|
|
3
|
+
|
|
4
|
+
/** Minimal FileList stand-in — getFileTypes only touches length and iteration. */
|
|
5
|
+
const fileList = (...types: string[]): FileList => {
|
|
6
|
+
const files = types.map((type, i) => new File(['x'], `f${i}`, { type }));
|
|
7
|
+
return Object.assign(files, { item: (i: number) => files[i] }) as unknown as FileList;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
describe('getFileTypes', () => {
|
|
11
|
+
it('returns [] when the browser exposes no files', () => {
|
|
12
|
+
expect(getFileTypes(null)).toEqual([]);
|
|
13
|
+
expect(getFileTypes(undefined)).toEqual([]);
|
|
14
|
+
expect(getFileTypes(fileList())).toEqual([]);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('dedupes and sorts so the same selection always yields the same array', () => {
|
|
18
|
+
expect(getFileTypes(fileList('text/html', 'image/png', 'text/html', 'text/css'))).toEqual([
|
|
19
|
+
'image/png',
|
|
20
|
+
'text/css',
|
|
21
|
+
'text/html',
|
|
22
|
+
]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('reports application/octet-stream for files the browser could not type', () => {
|
|
26
|
+
// A dragged folder or an .exe arrives with an empty File.type.
|
|
27
|
+
expect(getFileTypes(fileList(''))).toEqual(['application/octet-stream']);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('caps the list at 20 distinct types', () => {
|
|
31
|
+
const many = Array.from(
|
|
32
|
+
{ length: 30 },
|
|
33
|
+
(_, i) => `application/x-${String(i).padStart(2, '0')}`
|
|
34
|
+
);
|
|
35
|
+
expect(getFileTypes(fileList(...many))).toHaveLength(20);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The public surface of drop-core — nothing is public unless named here.
|
|
3
|
+
* `export *` is reserved for the three modules that are contracts in their
|
|
4
|
+
* entirety: types, errors, and the analytics event map.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export * from './types';
|
|
8
|
+
export * from './errors';
|
|
9
|
+
export * from './analytics';
|
|
10
|
+
|
|
11
|
+
// Reading a drop into deployable files
|
|
12
|
+
export {
|
|
13
|
+
getSingleNonIndexHtmlFileName,
|
|
14
|
+
hasMultipleHtmlFilesWithoutIndex,
|
|
15
|
+
readDataTransfer,
|
|
16
|
+
readFileList,
|
|
17
|
+
renameSingleNonIndexHtmlToIndex,
|
|
18
|
+
} from './readFiles';
|
|
19
|
+
export { getFileTypes } from './fileTypes';
|
|
20
|
+
|
|
21
|
+
// Build detection and archive synthesis
|
|
22
|
+
export { detectBuild, inferBuildSettings } from './detectBuild';
|
|
23
|
+
export type { BuildDetection, BuildReason, InferredBuildSettings } from './detectBuild';
|
|
24
|
+
export { zipFiles } from './zip';
|
|
25
|
+
export type { ZipOptions, ZipResult } from './zip';
|
|
26
|
+
|
|
27
|
+
// Deploy clients — anonymous (claim flow) and authenticated (into the account)
|
|
28
|
+
export { AnonymousDropClient } from './client';
|
|
29
|
+
export type { DropClient } from './client';
|
|
30
|
+
export { AuthenticatedDropClient } from './authedDropClient';
|
|
31
|
+
export type { AuthenticatedDropClientConfig } from './authedDropClient';
|
|
32
|
+
export { AuthenticatedBuildClient } from './buildClient';
|
|
33
|
+
export type { AuthenticatedBuildClientConfig, BuildClient, BuildResponse } from './buildClient';
|
|
34
|
+
|
|
35
|
+
// Deploy orchestration
|
|
36
|
+
export { deployBuild, deployFiles, deployZippedBuild } from './deploy';
|
|
37
|
+
export type { BuildDeployOptions, BuildDeployResult, DeployOptions } from './deploy';
|
|
38
|
+
|
|
39
|
+
// Carrying a build drop across the signup round-trip
|
|
40
|
+
export { clearDropStash, hasDropStashMarker, loadDropStash, saveDropStash } from './buildStash';
|
|
41
|
+
export type { DropBuildStash } from './buildStash';
|
|
42
|
+
|
|
43
|
+
// Documented defaults consumers may want to reference
|
|
44
|
+
export { DEFAULT_API_BASE, DEFAULT_PROXY_BASE, UPLOAD_RETRY_DELAYS_MS } from './constants';
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { zip } from 'fflate';
|
|
3
|
+
import {
|
|
4
|
+
getSingleNonIndexHtmlFileName,
|
|
5
|
+
hasMultipleHtmlFilesWithoutIndex,
|
|
6
|
+
readFileList,
|
|
7
|
+
renameSingleNonIndexHtmlToIndex,
|
|
8
|
+
} from './readFiles';
|
|
9
|
+
import { UnsupportedDropError } from './errors';
|
|
10
|
+
import type { ProcessedFile } from './types';
|
|
11
|
+
|
|
12
|
+
// `readDataTransfer` is deliberately untested: it rides the webkit FileSystem
|
|
13
|
+
// entries API (webkitGetAsEntry / createReader), which no test DOM implements.
|
|
14
|
+
// Its FileList fallback and zip branch share the code paths covered below.
|
|
15
|
+
|
|
16
|
+
const encoder = new TextEncoder();
|
|
17
|
+
|
|
18
|
+
const processed = (path: string, name = path.split('/').pop()!): ProcessedFile => ({
|
|
19
|
+
path,
|
|
20
|
+
name,
|
|
21
|
+
size: 1,
|
|
22
|
+
sha: 'test-sha',
|
|
23
|
+
content: encoder.encode('x').buffer as ArrayBuffer,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/** A File carrying the relative path a directory-picker <input> would report. */
|
|
27
|
+
const pickedFile = (relativePath: string, contents = 'x'): File => {
|
|
28
|
+
const f = new File([contents], relativePath.split('/').pop()!);
|
|
29
|
+
Object.defineProperty(f, 'webkitRelativePath', { value: relativePath });
|
|
30
|
+
return f;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const asFileList = (files: File[]): FileList =>
|
|
34
|
+
Object.assign(files, { item: (i: number) => files[i] }) as unknown as FileList;
|
|
35
|
+
|
|
36
|
+
const zipBytes = (entries: Record<string, string>): Promise<Uint8Array> =>
|
|
37
|
+
new Promise((resolve, reject) => {
|
|
38
|
+
const encoded: Record<string, Uint8Array> = {};
|
|
39
|
+
for (const [path, contents] of Object.entries(entries))
|
|
40
|
+
encoded[path] = encoder.encode(contents);
|
|
41
|
+
zip(encoded, (err, out) => (err ? reject(err) : resolve(out)));
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('readFileList', () => {
|
|
45
|
+
it('reads a folder selection into root-relative ProcessedFiles', async () => {
|
|
46
|
+
const files = await readFileList(
|
|
47
|
+
asFileList([pickedFile('site/index.html', '<h1>hi</h1>'), pickedFile('site/css/site.css')])
|
|
48
|
+
);
|
|
49
|
+
// The common root folder is stripped so config sits at the deploy root.
|
|
50
|
+
expect(files.map(f => f.path).sort()).toEqual(['/css/site.css', '/index.html']);
|
|
51
|
+
expect(files[0].sha).toMatch(/^[0-9a-f]{40}$/);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('does not strip a shared folder when a file sits beside it at the root', async () => {
|
|
55
|
+
const files = await readFileList(
|
|
56
|
+
asFileList([pickedFile('index.html'), pickedFile('assets/app.js')])
|
|
57
|
+
);
|
|
58
|
+
expect(files.map(f => f.path).sort()).toEqual(['/assets/app.js', '/index.html']);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('rejects a single loose file that is neither a zip nor an HTML page', async () => {
|
|
62
|
+
await expect(readFileList(asFileList([pickedFile('photo.png')]))).rejects.toBeInstanceOf(
|
|
63
|
+
UnsupportedDropError
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('allows a lone HTML page to stand alone', async () => {
|
|
68
|
+
const files = await readFileList(asFileList([pickedFile('page.html', '<p>solo</p>')]));
|
|
69
|
+
expect(files).toHaveLength(1);
|
|
70
|
+
expect(files[0].path).toBe('/page.html');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('expands a single dropped zip, skipping directories and junk entries', async () => {
|
|
74
|
+
const bytes = await zipBytes({
|
|
75
|
+
'my-site/index.html': '<h1>zipped</h1>',
|
|
76
|
+
'my-site/__MACOSX/._index.html': 'resource fork',
|
|
77
|
+
'my-site/.DS_Store': 'junk',
|
|
78
|
+
'my-site/node_modules/pkg/index.js': 'dep',
|
|
79
|
+
'my-site/css/site.css': 'body{}',
|
|
80
|
+
});
|
|
81
|
+
const files = await readFileList(
|
|
82
|
+
asFileList([new File([bytes.buffer.slice(0) as ArrayBuffer], 'my-site.zip')])
|
|
83
|
+
);
|
|
84
|
+
expect(files.map(f => f.path).sort()).toEqual(['/css/site.css', '/index.html']);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('index.html helpers', () => {
|
|
89
|
+
it('names the lone non-index HTML file, and only that case', () => {
|
|
90
|
+
expect(getSingleNonIndexHtmlFileName([processed('/about.html'), processed('/a.css')])).toBe(
|
|
91
|
+
'about.html'
|
|
92
|
+
);
|
|
93
|
+
expect(
|
|
94
|
+
getSingleNonIndexHtmlFileName([processed('/index.html'), processed('/about.html')])
|
|
95
|
+
).toBeNull();
|
|
96
|
+
expect(getSingleNonIndexHtmlFileName([processed('/a.html'), processed('/b.html')])).toBeNull();
|
|
97
|
+
expect(getSingleNonIndexHtmlFileName([processed('/a.css')])).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('flags multiple HTML files with no index', () => {
|
|
101
|
+
expect(hasMultipleHtmlFilesWithoutIndex([processed('/a.html'), processed('/b.html')])).toBe(
|
|
102
|
+
true
|
|
103
|
+
);
|
|
104
|
+
expect(hasMultipleHtmlFilesWithoutIndex([processed('/index.html'), processed('/b.html')])).toBe(
|
|
105
|
+
false
|
|
106
|
+
);
|
|
107
|
+
expect(hasMultipleHtmlFilesWithoutIndex([processed('/a.html')])).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('renames the lone HTML page to index.html in place, keeping its directory', () => {
|
|
111
|
+
const input = [processed('/docs/about.html'), processed('/docs/site.css')];
|
|
112
|
+
const renamed = renameSingleNonIndexHtmlToIndex(input);
|
|
113
|
+
const page = renamed.find(f => f.name === 'index.html')!;
|
|
114
|
+
expect(page.path).toBe('/docs/index.html');
|
|
115
|
+
// Content and sha untouched — only the served path changes.
|
|
116
|
+
expect(page.sha).toBe(input[0].sha);
|
|
117
|
+
expect(page.content).toBe(input[0].content);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('leaves sets with an index, several pages, or no HTML unchanged', () => {
|
|
121
|
+
const withIndex = [processed('/index.html'), processed('/about.html')];
|
|
122
|
+
expect(renameSingleNonIndexHtmlToIndex(withIndex)).toBe(withIndex);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -40,15 +40,21 @@ export interface DeployResult {
|
|
|
40
40
|
* zipping → creating → uploading → building → ready.
|
|
41
41
|
*/
|
|
42
42
|
export type DeployPhase =
|
|
43
|
-
| '
|
|
44
|
-
| 'creating'
|
|
45
|
-
| 'uploading'
|
|
46
|
-
| 'processing'
|
|
47
|
-
| 'building'
|
|
48
|
-
| 'ready';
|
|
43
|
+
'zipping' | 'creating' | 'uploading' | 'processing' | 'building' | 'ready';
|
|
49
44
|
|
|
50
45
|
export interface DeployProgress {
|
|
51
46
|
phase: DeployPhase;
|
|
52
47
|
uploaded?: number;
|
|
53
48
|
total?: number;
|
|
54
49
|
}
|
|
50
|
+
|
|
51
|
+
/** The subset of a Netlify Site the drop flow needs to redirect and report. */
|
|
52
|
+
export interface BuildSite {
|
|
53
|
+
/** site id (UUID) */
|
|
54
|
+
id: string;
|
|
55
|
+
/** the *.netlify.app subdomain, also the app's URL segment */
|
|
56
|
+
name: string;
|
|
57
|
+
/** https://<name>.netlify.app */
|
|
58
|
+
ssl_url?: string;
|
|
59
|
+
url?: string;
|
|
60
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { unzip } from 'fflate';
|
|
3
|
+
import { generateNetlifyToml, zipFiles } from './zip';
|
|
4
|
+
import type { ProcessedFile } from './types';
|
|
5
|
+
|
|
6
|
+
const encoder = new TextEncoder();
|
|
7
|
+
const decoder = new TextDecoder();
|
|
8
|
+
|
|
9
|
+
const file = (path: string, contents = 'x'): ProcessedFile => {
|
|
10
|
+
const content = encoder.encode(contents).buffer as ArrayBuffer;
|
|
11
|
+
return { path, content, size: content.byteLength, sha: 'test-sha', name: path.split('/').pop()! };
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const buildableProject = () => [
|
|
15
|
+
file('/index.html', '<h1>hi</h1>'),
|
|
16
|
+
file(
|
|
17
|
+
'/package.json',
|
|
18
|
+
JSON.stringify({ scripts: { build: 'astro build' }, dependencies: { astro: '^4' } })
|
|
19
|
+
),
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
const unzipArchive = async (archive: File): Promise<Record<string, string>> => {
|
|
23
|
+
const bytes = new Uint8Array(await archive.arrayBuffer());
|
|
24
|
+
const entries = await new Promise<Record<string, Uint8Array>>((resolve, reject) =>
|
|
25
|
+
unzip(bytes, (err, data) => (err ? reject(err) : resolve(data)))
|
|
26
|
+
);
|
|
27
|
+
return Object.fromEntries(Object.entries(entries).map(([k, v]) => [k, decoder.decode(v)]));
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
describe('generateNetlifyToml', () => {
|
|
31
|
+
it('renders only command and publish — everything else stays zero-config', () => {
|
|
32
|
+
expect(
|
|
33
|
+
generateNetlifyToml({ command: 'npm run build', publish: 'dist', framework: 'astro' })
|
|
34
|
+
).toBe('[build]\n command = "npm run build"\n publish = "dist"\n');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('escapes TOML basic-string metacharacters', () => {
|
|
38
|
+
const toml = generateNetlifyToml({ command: 'echo "a\\b"', publish: 'dist', framework: 'x' });
|
|
39
|
+
expect(toml).toContain('command = "echo \\"a\\\\b\\""');
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('zipFiles', () => {
|
|
44
|
+
it('throws on an empty file set', async () => {
|
|
45
|
+
await expect(zipFiles([])).rejects.toThrow('No files to deploy');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('zips files with archive-root-relative paths (no leading slash)', async () => {
|
|
49
|
+
const { file: archive, buildSettings } = await zipFiles([file('/index.html', '<h1>hi</h1>')]);
|
|
50
|
+
expect(archive.name).toBe('source.zip');
|
|
51
|
+
expect(archive.type).toBe('application/zip');
|
|
52
|
+
expect(buildSettings).toBeNull(); // nothing inferable for a static site
|
|
53
|
+
expect(await unzipArchive(archive)).toEqual({ 'index.html': '<h1>hi</h1>' });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('synthesises a netlify.toml when settings are inferable', async () => {
|
|
57
|
+
const { file: archive, buildSettings } = await zipFiles(buildableProject());
|
|
58
|
+
expect(buildSettings).toEqual({
|
|
59
|
+
command: 'npm run build',
|
|
60
|
+
publish: 'dist',
|
|
61
|
+
framework: 'astro',
|
|
62
|
+
});
|
|
63
|
+
const entries = await unzipArchive(archive);
|
|
64
|
+
expect(entries['netlify.toml']).toContain('[build]');
|
|
65
|
+
expect(entries['netlify.toml']).toContain('publish = "dist"');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('appends to an existing netlify.toml so redirects and headers survive', async () => {
|
|
69
|
+
const existing = '[[redirects]]\n from = "/old"\n to = "/new"\n';
|
|
70
|
+
const { file: archive } = await zipFiles([
|
|
71
|
+
...buildableProject(),
|
|
72
|
+
file('/netlify.toml', existing),
|
|
73
|
+
]);
|
|
74
|
+
const toml = (await unzipArchive(archive))['netlify.toml'];
|
|
75
|
+
expect(toml).toContain('[[redirects]]');
|
|
76
|
+
expect(toml).toContain('[build]');
|
|
77
|
+
expect(toml.indexOf('[[redirects]]')).toBeLessThan(toml.indexOf('[build]'));
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('uploads the drop untouched when synthesizeNetlifyToml is off', async () => {
|
|
81
|
+
const { file: archive, buildSettings } = await zipFiles(buildableProject(), {
|
|
82
|
+
synthesizeNetlifyToml: false,
|
|
83
|
+
});
|
|
84
|
+
expect(buildSettings).toBeNull();
|
|
85
|
+
expect((await unzipArchive(archive))['netlify.toml']).toBeUndefined();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('honors a custom archive name', async () => {
|
|
89
|
+
const { file: archive } = await zipFiles([file('/index.html')], { name: 'drop.zip' });
|
|
90
|
+
expect(archive.name).toBe('drop.zip');
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -1,123 +0,0 @@
|
|
|
1
|
-
var h = Object.defineProperty;
|
|
2
|
-
var p = (r, e, o) => e in r ? h(r, e, { enumerable: !0, configurable: !0, writable: !0, value: o }) : r[e] = o;
|
|
3
|
-
var i = (r, e, o) => p(r, typeof e != "symbol" ? e + "" : e, o);
|
|
4
|
-
import { apiError as d } from "./client.js";
|
|
5
|
-
import { NotAuthenticatedError as l, SiteCreateError as u } from "./errors.js";
|
|
6
|
-
const y = "/access-control/bb-api/api/v1", w = "https://api.netlify.com/api/v1", T = 1e3, f = 10 * 60 * 1e3, k = 20 * 1e3, m = /* @__PURE__ */ new Set(["ready", "error", "rejected"]);
|
|
7
|
-
class g {
|
|
8
|
-
constructor({
|
|
9
|
-
proxyBase: e = y,
|
|
10
|
-
apiBase: o = w,
|
|
11
|
-
getUploadToken: n,
|
|
12
|
-
accountSlug: s,
|
|
13
|
-
pollIntervalMs: a = T,
|
|
14
|
-
timeoutMs: t = f
|
|
15
|
-
}) {
|
|
16
|
-
i(this, "proxyBase");
|
|
17
|
-
i(this, "apiBase");
|
|
18
|
-
i(this, "getUploadToken");
|
|
19
|
-
i(this, "accountSlug");
|
|
20
|
-
i(this, "pollIntervalMs");
|
|
21
|
-
i(this, "timeoutMs");
|
|
22
|
-
i(this, "cachedToken");
|
|
23
|
-
this.proxyBase = e, this.apiBase = o, this.getUploadToken = n, this.accountSlug = s, this.pollIntervalMs = a, this.timeoutMs = t;
|
|
24
|
-
}
|
|
25
|
-
/**
|
|
26
|
-
* The auth gate. Minting here — before anything is created — is what lets a
|
|
27
|
-
* logged-out visitor fail cleanly and fall back to the anonymous flow. The
|
|
28
|
-
* returned token is threaded through `deployFiles`, but only the uploads
|
|
29
|
-
* actually need one, and they re-mint rather than trusting this to still be
|
|
30
|
-
* valid by the time they run.
|
|
31
|
-
*/
|
|
32
|
-
async getToken() {
|
|
33
|
-
const e = await this.getUploadToken();
|
|
34
|
-
if (!e) throw new l();
|
|
35
|
-
return this.cachedToken = { value: e, mintedAt: Date.now() }, e;
|
|
36
|
-
}
|
|
37
|
-
/**
|
|
38
|
-
* A token young enough to authorize an upload, minting a new one once the
|
|
39
|
-
* cached one nears its 30s expiry.
|
|
40
|
-
*
|
|
41
|
-
* Deliberately NOT `NotAuthenticatedError` when minting fails: by the time
|
|
42
|
-
* uploads run the site already exists, and that error would send the drop
|
|
43
|
-
* back through the anonymous path — leaving an empty project behind and
|
|
44
|
-
* deploying a duplicate. See the class doc.
|
|
45
|
-
*/
|
|
46
|
-
async freshToken() {
|
|
47
|
-
const e = Date.now();
|
|
48
|
-
if (this.cachedToken && e - this.cachedToken.mintedAt < k)
|
|
49
|
-
return this.cachedToken.value;
|
|
50
|
-
const o = await this.getUploadToken();
|
|
51
|
-
if (!o) throw new Error("could not renew the upload token");
|
|
52
|
-
return this.cachedToken = { value: o, mintedAt: e }, o;
|
|
53
|
-
}
|
|
54
|
-
/**
|
|
55
|
-
* Two proxied calls: create the site in the visitor's account, then the
|
|
56
|
-
* deploy on it. Mapped into the anonymous `DropResponse` shape so
|
|
57
|
-
* `deployFiles` runs unchanged over either client.
|
|
58
|
-
*/
|
|
59
|
-
async createDeploy(e, o) {
|
|
60
|
-
const n = this.accountSlug ? `/${this.accountSlug}/sites` : "/sites", s = await fetch(`${this.proxyBase}${n}`, {
|
|
61
|
-
method: "POST",
|
|
62
|
-
credentials: "include",
|
|
63
|
-
headers: { "Content-Type": "application/json" },
|
|
64
|
-
body: JSON.stringify({ created_via: "drop" })
|
|
65
|
-
});
|
|
66
|
-
if (s.status === 401 || s.status === 403) throw new l();
|
|
67
|
-
if (!s.ok) throw new u(s.status);
|
|
68
|
-
const a = await s.json(), t = await fetch(`${this.proxyBase}/sites/${a.id}/deploys`, {
|
|
69
|
-
method: "POST",
|
|
70
|
-
credentials: "include",
|
|
71
|
-
headers: { "Content-Type": "application/json" },
|
|
72
|
-
body: JSON.stringify({ files: e, deploy_source: "drop" })
|
|
73
|
-
});
|
|
74
|
-
if (!t.ok) throw d("deploy create", t.status);
|
|
75
|
-
const c = await t.json();
|
|
76
|
-
return {
|
|
77
|
-
id: a.id,
|
|
78
|
-
deploy_id: c.id,
|
|
79
|
-
subdomain: a.name,
|
|
80
|
-
required: c.required ?? []
|
|
81
|
-
};
|
|
82
|
-
}
|
|
83
|
-
/**
|
|
84
|
-
* Direct to `api.netlify.com` on a freshly-aged bearer — the threaded `token`
|
|
85
|
-
* is ignored because an upload queue easily outlives the 30 seconds it was
|
|
86
|
-
* minted with. PUT (not POST) so paths with "&" etc. work.
|
|
87
|
-
*/
|
|
88
|
-
async uploadFile(e, o, n, s) {
|
|
89
|
-
const a = await this.freshToken(), t = await fetch(`${this.apiBase}/deploys/${e}/files${o}`, {
|
|
90
|
-
method: "PUT",
|
|
91
|
-
headers: { "Content-Type": "application/octet-stream", Authorization: `Bearer ${a}` },
|
|
92
|
-
body: n
|
|
93
|
-
});
|
|
94
|
-
if (!t.ok) throw d(`upload ${o}`, t.status);
|
|
95
|
-
}
|
|
96
|
-
/**
|
|
97
|
-
* Poll the deploy through the proxy until it settles: resolve on `ready`,
|
|
98
|
-
* throw on `error`/`rejected`, throw on timeout. On the session cookie rather
|
|
99
|
-
* than a bearer, so a deploy taking minutes can't outlive its own credentials.
|
|
100
|
-
*/
|
|
101
|
-
async waitUntilReady(e, o) {
|
|
102
|
-
const n = this.timeoutMs / this.pollIntervalMs;
|
|
103
|
-
for (let s = 0; s < n; s++) {
|
|
104
|
-
const a = await fetch(`${this.proxyBase}/deploys/${e.deploy_id}`, {
|
|
105
|
-
credentials: "include"
|
|
106
|
-
});
|
|
107
|
-
if (a.ok) {
|
|
108
|
-
const t = await a.json();
|
|
109
|
-
if (t.state && m.has(t.state)) {
|
|
110
|
-
if (t.state === "ready") return;
|
|
111
|
-
throw new Error(
|
|
112
|
-
t.error_message ? `deploy failed: ${t.error_message}` : "deploy failed"
|
|
113
|
-
);
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
await new Promise((t) => setTimeout(t, this.pollIntervalMs));
|
|
117
|
-
}
|
|
118
|
-
throw new Error("Deploy did not become ready in time");
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
export {
|
|
122
|
-
g as AuthenticatedDropClient
|
|
123
|
-
};
|