@netlify/spark-ui 1.31.0-alpha.3 → 1.31.0-alpha.4
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 +21 -2
- 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 +25 -23
- package/dist/components/preact/DropZone/useDropDeploy.d.ts +10 -6
- package/dist/components/preact/DropZone/useDropDeploy.js +184 -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 +25 -19
- 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 +55 -0
- package/dist/drop-core/authedApi.js +35 -0
- package/dist/drop-core/authedApi.test.d.ts +1 -0
- package/dist/drop-core/authedDropClient.js +109 -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 +84 -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}/deploy.d.ts +7 -0
- package/dist/drop-core/deploy.js +53 -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.js +1 -1
- package/dist/drop-core/errors.test.d.ts +1 -0
- package/dist/drop-core/fileTypes.test.d.ts +1 -0
- package/dist/{components/preact/DropZone/drop-core → drop-core}/index.d.ts +3 -0
- package/dist/drop-core/index.js +51 -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 +23 -2
- 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 +297 -132
- package/packages/components/preact/index.ts +19 -2
- 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 +94 -0
- package/packages/drop-core/authedDropClient.test.ts +196 -0
- package/packages/{components/preact/DropZone/drop-core → drop-core}/authedDropClient.ts +30 -51
- package/packages/drop-core/buildClient.test.ts +112 -0
- package/packages/{components/preact/DropZone/drop-core → drop-core}/buildClient.ts +28 -57
- package/packages/drop-core/buildStash.test.ts +85 -0
- package/packages/drop-core/buildStash.ts +185 -0
- package/packages/drop-core/deploy.test.ts +135 -0
- package/packages/{components/preact/DropZone/drop-core → drop-core}/deploy.ts +18 -4
- 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 +69 -0
- package/packages/{components/preact/DropZone/drop-core → drop-core}/errors.ts +1 -1
- package/packages/drop-core/fileTypes.test.ts +37 -0
- package/packages/{components/preact/DropZone/drop-core → drop-core}/index.ts +3 -0
- package/packages/drop-core/readFiles.test.ts +125 -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/deploy.js +0 -48
- package/dist/components/preact/DropZone/drop-core/index.js +0 -36
- /package/dist/{components/preact/DropZone/drop-core → drop-core}/authedDropClient.d.ts +0 -0
- /package/dist/{components/preact/DropZone/drop-core → drop-core}/client.d.ts +0 -0
- /package/dist/{components/preact/DropZone/drop-core → drop-core}/client.js +0 -0
- /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}/errors.d.ts +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}/client.ts +0 -0
- /package/packages/{components/preact/DropZone/drop-core → drop-core}/detectBuild.ts +0 -0
- /package/packages/{components/preact/DropZone/drop-core → drop-core}/digest.ts +0 -0
- /package/packages/{components/preact/DropZone/drop-core → drop-core}/fileTypes.ts +0 -0
- /package/packages/{components/preact/DropZone/drop-core → drop-core}/readFiles.ts +0 -0
- /package/packages/{components/preact/DropZone/drop-core → drop-core}/zip.ts +0 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { detectBuild, inferBuildSettings } from './detectBuild';
|
|
3
|
+
import type { ProcessedFile } from './types';
|
|
4
|
+
|
|
5
|
+
const encoder = new TextEncoder();
|
|
6
|
+
|
|
7
|
+
const file = (path: string, contents = ''): ProcessedFile => {
|
|
8
|
+
const content = encoder.encode(contents).buffer as ArrayBuffer;
|
|
9
|
+
return { path, content, size: content.byteLength, sha: 'test-sha', name: path.split('/').pop()! };
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const packageJson = (json: object) => file('/package.json', JSON.stringify(json));
|
|
13
|
+
const staticSite = () => [file('/index.html', '<h1>hi</h1>'), file('/style.css', 'body{}')];
|
|
14
|
+
|
|
15
|
+
describe('detectBuild', () => {
|
|
16
|
+
it('treats a plain static site as deployable without a build', () => {
|
|
17
|
+
expect(detectBuild(staticSite())).toEqual({ buildRequired: false, reason: null });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('requires a build for a netlify.toml [build] table', () => {
|
|
21
|
+
const files = [
|
|
22
|
+
...staticSite(),
|
|
23
|
+
file('/netlify.toml', '[build]\n command = "npm run build"\n'),
|
|
24
|
+
];
|
|
25
|
+
expect(detectBuild(files)).toEqual({ buildRequired: true, reason: 'netlify-toml-build' });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('counts [build] sub-tables like [build.environment]', () => {
|
|
29
|
+
const files = [...staticSite(), file('/netlify.toml', '[build.environment]\n FOO = "1"\n')];
|
|
30
|
+
expect(detectBuild(files)).toEqual({ buildRequired: true, reason: 'netlify-toml-build' });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('ignores a [build] mention inside a comment', () => {
|
|
34
|
+
const files = [
|
|
35
|
+
...staticSite(),
|
|
36
|
+
file('/netlify.toml', '# [build] used to live here\n[[redirects]]\n'),
|
|
37
|
+
];
|
|
38
|
+
expect(detectBuild(files)).toEqual({ buildRequired: false, reason: null });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('requires a build for a package.json build script', () => {
|
|
42
|
+
const files = [...staticSite(), packageJson({ scripts: { build: 'vite build' } })];
|
|
43
|
+
expect(detectBuild(files)).toEqual({
|
|
44
|
+
buildRequired: true,
|
|
45
|
+
reason: 'package-json-build-script',
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('does not count an empty build script', () => {
|
|
50
|
+
const files = [...staticSite(), packageJson({ scripts: { build: ' ' } })];
|
|
51
|
+
expect(detectBuild(files)).toEqual({ buildRequired: false, reason: null });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('requires a build for a known framework dependency, prod or dev', () => {
|
|
55
|
+
expect(detectBuild([packageJson({ dependencies: { astro: '^4.0.0' } })])).toEqual({
|
|
56
|
+
buildRequired: true,
|
|
57
|
+
reason: 'framework-dependency',
|
|
58
|
+
});
|
|
59
|
+
expect(detectBuild([packageJson({ devDependencies: { vite: '^6.0.0' } })])).toEqual({
|
|
60
|
+
buildRequired: true,
|
|
61
|
+
reason: 'framework-dependency',
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('requires a build for the zero-config functions directories', () => {
|
|
66
|
+
expect(
|
|
67
|
+
detectBuild([...staticSite(), file('/netlify/functions/hello.ts', 'export {}')])
|
|
68
|
+
).toEqual({
|
|
69
|
+
buildRequired: true,
|
|
70
|
+
reason: 'functions-directory',
|
|
71
|
+
});
|
|
72
|
+
expect(
|
|
73
|
+
detectBuild([...staticSite(), file('/netlify/edge-functions/hello.ts', 'export {}')])
|
|
74
|
+
).toEqual({ buildRequired: true, reason: 'functions-directory' });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('falls through on a malformed package.json rather than guessing', () => {
|
|
78
|
+
expect(detectBuild([...staticSite(), file('/package.json', '{ not json')])).toEqual({
|
|
79
|
+
buildRequired: false,
|
|
80
|
+
reason: null,
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('reports the first matching signal when several apply', () => {
|
|
85
|
+
const files = [
|
|
86
|
+
file('/netlify.toml', '[build]\n'),
|
|
87
|
+
packageJson({ scripts: { build: 'x' }, dependencies: { astro: '1' } }),
|
|
88
|
+
];
|
|
89
|
+
expect(detectBuild(files).reason).toBe('netlify-toml-build');
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('inferBuildSettings', () => {
|
|
94
|
+
const buildable = (extra: object = {}) =>
|
|
95
|
+
packageJson({ scripts: { build: 'astro build' }, dependencies: { astro: '^4' }, ...extra });
|
|
96
|
+
|
|
97
|
+
it('returns command + publish + framework when both halves are known', () => {
|
|
98
|
+
expect(inferBuildSettings([buildable()])).toEqual({
|
|
99
|
+
command: 'npm run build',
|
|
100
|
+
publish: 'dist',
|
|
101
|
+
framework: 'astro',
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('names the package manager from the lockfile', () => {
|
|
106
|
+
expect(inferBuildSettings([buildable(), file('/pnpm-lock.yaml')])?.command).toBe(
|
|
107
|
+
'pnpm run build'
|
|
108
|
+
);
|
|
109
|
+
expect(inferBuildSettings([buildable(), file('/yarn.lock')])?.command).toBe('yarn run build');
|
|
110
|
+
expect(inferBuildSettings([buildable(), file('/bun.lock')])?.command).toBe('bun run build');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('defers to an existing [build] table — the author config wins', () => {
|
|
114
|
+
expect(
|
|
115
|
+
inferBuildSettings([buildable(), file('/netlify.toml', '[build]\n publish = "out"\n')])
|
|
116
|
+
).toBeNull();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('still synthesises when the toml has only sub-tables (no [build] itself)', () => {
|
|
120
|
+
expect(
|
|
121
|
+
inferBuildSettings([buildable(), file('/netlify.toml', '[build.environment]\n A = "1"\n')])
|
|
122
|
+
).not.toBeNull();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('returns null without a build script — no command to state', () => {
|
|
126
|
+
expect(inferBuildSettings([packageJson({ dependencies: { astro: '^4' } })])).toBeNull();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('returns null for frameworks whose publish directory is not a fixed default', () => {
|
|
130
|
+
// Guessing publish wrong ships source instead of output — worse than not guessing.
|
|
131
|
+
expect(
|
|
132
|
+
inferBuildSettings([packageJson({ scripts: { build: 'x' }, dependencies: { nuxt: '^3' } })])
|
|
133
|
+
).toBeNull();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('returns null when no known framework is present', () => {
|
|
137
|
+
expect(inferBuildSettings([packageJson({ scripts: { build: 'make site' } })])).toBeNull();
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { getDigest } from './digest';
|
|
3
|
+
import type { ProcessedFile } from './types';
|
|
4
|
+
|
|
5
|
+
const file = (path: string, sha: string): ProcessedFile => ({
|
|
6
|
+
path,
|
|
7
|
+
sha,
|
|
8
|
+
size: 0,
|
|
9
|
+
content: new ArrayBuffer(0),
|
|
10
|
+
name: path.split('/').pop()!,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe('getDigest', () => {
|
|
14
|
+
it('maps each path to its sha', () => {
|
|
15
|
+
expect(getDigest([file('/index.html', 'aaa'), file('/css/site.css', 'bbb')])).toEqual({
|
|
16
|
+
'/index.html': 'aaa',
|
|
17
|
+
'/css/site.css': 'bbb',
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('returns an empty digest for an empty file set', () => {
|
|
22
|
+
expect(getDigest([])).toEqual({});
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { apiError } from './client';
|
|
3
|
+
import {
|
|
4
|
+
BuildCreateError,
|
|
5
|
+
BuildFailedError,
|
|
6
|
+
BuildTimeoutError,
|
|
7
|
+
humanizeDropError,
|
|
8
|
+
NotAuthenticatedError,
|
|
9
|
+
SiteCreateError,
|
|
10
|
+
UnsupportedDropError,
|
|
11
|
+
} from './errors';
|
|
12
|
+
|
|
13
|
+
describe('humanizeDropError', () => {
|
|
14
|
+
it('maps each typed error to its user-facing copy', () => {
|
|
15
|
+
expect(humanizeDropError(new UnsupportedDropError())).toBe(
|
|
16
|
+
'To deploy, drop a folder or a .zip file — or a single HTML page.'
|
|
17
|
+
);
|
|
18
|
+
expect(humanizeDropError(new SiteCreateError(500))).toBe(
|
|
19
|
+
"We couldn't create a project for this build. Please try again."
|
|
20
|
+
);
|
|
21
|
+
expect(humanizeDropError(new BuildTimeoutError())).toBe(
|
|
22
|
+
'Your build is taking longer than expected. It may still finish — check your Netlify dashboard.'
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("passes a failed build's own message through — the only actionable part", () => {
|
|
27
|
+
expect(humanizeDropError(new BuildFailedError('Command failed: npm run build'))).toBe(
|
|
28
|
+
'Your build failed: Command failed: npm run build'
|
|
29
|
+
);
|
|
30
|
+
expect(humanizeDropError(new BuildFailedError())).toBe(
|
|
31
|
+
'Your build failed. Check your build settings and try again.'
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('maps API statuses attached by apiError()', () => {
|
|
36
|
+
expect(humanizeDropError(apiError('drop', 429))).toContain('Too many attempts');
|
|
37
|
+
expect(humanizeDropError(apiError('upload /x', 413))).toContain('too large');
|
|
38
|
+
expect(humanizeDropError(apiError('poll', 404))).toContain("couldn't find that deploy");
|
|
39
|
+
expect(humanizeDropError(apiError('drop', 401))).toContain("wasn't allowed");
|
|
40
|
+
expect(humanizeDropError(apiError('drop', 503))).toContain('servers are having trouble');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('routes typed errors carrying a status through the same mapping', () => {
|
|
44
|
+
expect(humanizeDropError(new BuildCreateError(413))).toContain('too large');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('maps the shared deploy-timeout message', () => {
|
|
48
|
+
expect(humanizeDropError(new Error('Deploy did not become ready in time'))).toContain(
|
|
49
|
+
'taking longer than expected'
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('reads a fetch TypeError as a connectivity problem', () => {
|
|
54
|
+
expect(humanizeDropError(new TypeError('Failed to fetch'))).toContain(
|
|
55
|
+
'Check your internet connection'
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('falls back to generic copy for anything else', () => {
|
|
60
|
+
expect(humanizeDropError('not even an error')).toBe('Something went wrong. Please try again.');
|
|
61
|
+
expect(humanizeDropError(new Error('mystery'))).toBe('Something went wrong. Please try again.');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('keeps the NotAuthenticatedError copy path-agnostic — it gates static deploys and builds alike', () => {
|
|
65
|
+
expect(humanizeDropError(new NotAuthenticatedError())).toBe(
|
|
66
|
+
'Please log in to Netlify to deploy this project, then try again.'
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -85,7 +85,7 @@ export function humanizeDropError(err: unknown): string {
|
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
if (err instanceof NotAuthenticatedError) {
|
|
88
|
-
return 'Please log in to Netlify to deploy
|
|
88
|
+
return 'Please log in to Netlify to deploy this project, then try again.';
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
if (err instanceof SiteCreateError) {
|
|
@@ -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
|
+
});
|
|
@@ -4,8 +4,11 @@ export * from './readFiles';
|
|
|
4
4
|
export * from './fileTypes';
|
|
5
5
|
export * from './detectBuild';
|
|
6
6
|
export * from './client';
|
|
7
|
+
export * from './authedApi';
|
|
7
8
|
export * from './authedDropClient';
|
|
8
9
|
export * from './buildClient';
|
|
9
10
|
export * from './zip';
|
|
11
|
+
export * from './buildStash';
|
|
10
12
|
export * from './deploy';
|
|
11
13
|
export * from './errors';
|
|
14
|
+
export * from './analytics';
|
|
@@ -0,0 +1,125 @@
|
|
|
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
|
+
// Junk gone, common "my-site/" root stripped.
|
|
85
|
+
expect(files.map(f => f.path).sort()).toEqual(['/css/site.css', '/index.html']);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('index.html helpers', () => {
|
|
90
|
+
it('names the lone non-index HTML file, and only that case', () => {
|
|
91
|
+
expect(getSingleNonIndexHtmlFileName([processed('/about.html'), processed('/a.css')])).toBe(
|
|
92
|
+
'about.html'
|
|
93
|
+
);
|
|
94
|
+
expect(
|
|
95
|
+
getSingleNonIndexHtmlFileName([processed('/index.html'), processed('/about.html')])
|
|
96
|
+
).toBeNull();
|
|
97
|
+
expect(getSingleNonIndexHtmlFileName([processed('/a.html'), processed('/b.html')])).toBeNull();
|
|
98
|
+
expect(getSingleNonIndexHtmlFileName([processed('/a.css')])).toBeNull();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('flags multiple HTML files with no index', () => {
|
|
102
|
+
expect(hasMultipleHtmlFilesWithoutIndex([processed('/a.html'), processed('/b.html')])).toBe(
|
|
103
|
+
true
|
|
104
|
+
);
|
|
105
|
+
expect(hasMultipleHtmlFilesWithoutIndex([processed('/index.html'), processed('/b.html')])).toBe(
|
|
106
|
+
false
|
|
107
|
+
);
|
|
108
|
+
expect(hasMultipleHtmlFilesWithoutIndex([processed('/a.html')])).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('renames the lone HTML page to index.html in place, keeping its directory', () => {
|
|
112
|
+
const input = [processed('/docs/about.html'), processed('/docs/site.css')];
|
|
113
|
+
const renamed = renameSingleNonIndexHtmlToIndex(input);
|
|
114
|
+
const page = renamed.find(f => f.name === 'index.html')!;
|
|
115
|
+
expect(page.path).toBe('/docs/index.html');
|
|
116
|
+
// Content and sha untouched — only the served path changes.
|
|
117
|
+
expect(page.sha).toBe(input[0].sha);
|
|
118
|
+
expect(page.content).toBe(input[0].content);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('leaves sets with an index, several pages, or no HTML unchanged', () => {
|
|
122
|
+
const withIndex = [processed('/index.html'), processed('/about.html')];
|
|
123
|
+
expect(renameSingleNonIndexHtmlToIndex(withIndex)).toBe(withIndex);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -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
|
-
};
|