@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.
Files changed (100) hide show
  1. package/dist/components/index.d.ts +2 -2
  2. package/dist/components/preact/DropZone/DropZone.d.ts +27 -9
  3. package/dist/components/preact/DropZone/DropZone.js +59 -54
  4. package/dist/components/preact/DropZone/DropZone.test.d.ts +1 -0
  5. package/dist/components/preact/DropZone/index.d.ts +3 -3
  6. package/dist/components/preact/DropZone/index.js +29 -27
  7. package/dist/components/preact/DropZone/useDropDeploy.d.ts +9 -6
  8. package/dist/components/preact/DropZone/useDropDeploy.js +182 -131
  9. package/dist/components/preact/DropZone/useDropDeploy.test.d.ts +1 -0
  10. package/dist/components/preact/index.d.ts +2 -2
  11. package/dist/components/preact/index.js +24 -18
  12. package/dist/drop-core/analytics.d.ts +107 -0
  13. package/dist/drop-core/analytics.js +21 -0
  14. package/dist/drop-core/analytics.test.d.ts +1 -0
  15. package/dist/drop-core/authedApi.d.ts +45 -0
  16. package/dist/drop-core/authedApi.js +30 -0
  17. package/dist/drop-core/authedApi.test.d.ts +1 -0
  18. package/dist/{components/preact/DropZone/drop-core → drop-core}/authedDropClient.d.ts +4 -11
  19. package/dist/drop-core/authedDropClient.js +100 -0
  20. package/dist/drop-core/authedDropClient.test.d.ts +1 -0
  21. package/dist/{components/preact/DropZone/drop-core → drop-core}/buildClient.d.ts +5 -13
  22. package/dist/drop-core/buildClient.js +85 -0
  23. package/dist/drop-core/buildClient.test.d.ts +1 -0
  24. package/dist/drop-core/buildStash.d.ts +37 -0
  25. package/dist/drop-core/buildStash.js +84 -0
  26. package/dist/drop-core/buildStash.test.d.ts +0 -0
  27. package/dist/{components/preact/DropZone/drop-core → drop-core}/client.d.ts +3 -1
  28. package/dist/drop-core/client.js +47 -0
  29. package/dist/drop-core/constants.d.ts +29 -0
  30. package/dist/drop-core/constants.js +10 -0
  31. package/dist/{components/preact/DropZone/drop-core → drop-core}/deploy.d.ts +9 -0
  32. package/dist/drop-core/deploy.js +66 -0
  33. package/dist/drop-core/deploy.test.d.ts +1 -0
  34. package/dist/drop-core/detectBuild.test.d.ts +1 -0
  35. package/dist/drop-core/digest.test.d.ts +1 -0
  36. package/dist/{components/preact/DropZone/drop-core → drop-core}/errors.d.ts +29 -0
  37. package/dist/drop-core/errors.js +119 -0
  38. package/dist/drop-core/errors.test.d.ts +1 -0
  39. package/dist/drop-core/fileTypes.test.d.ts +1 -0
  40. package/dist/drop-core/index.d.ts +25 -0
  41. package/dist/drop-core/index.js +47 -0
  42. package/dist/{components/preact/DropZone/drop-core → drop-core}/readFiles.js +2 -2
  43. package/dist/drop-core/readFiles.test.d.ts +1 -0
  44. package/dist/{components/preact/DropZone/drop-core → drop-core}/types.d.ts +10 -0
  45. package/dist/{components/preact/DropZone/drop-core → drop-core}/zip.js +1 -1
  46. package/dist/drop-core/zip.test.d.ts +1 -0
  47. package/package.json +4 -1
  48. package/packages/components/index.ts +19 -0
  49. package/packages/components/preact/DropZone/DropZone.test.ts +71 -0
  50. package/packages/components/preact/DropZone/DropZone.tsx +29 -9
  51. package/packages/components/preact/DropZone/index.tsx +14 -3
  52. package/packages/components/preact/DropZone/useDropDeploy.test.ts +376 -0
  53. package/packages/components/preact/DropZone/useDropDeploy.ts +301 -145
  54. package/packages/components/preact/index.ts +19 -2
  55. package/packages/drop-core/README.md +91 -0
  56. package/packages/drop-core/analytics.test.ts +17 -0
  57. package/packages/drop-core/analytics.ts +122 -0
  58. package/packages/drop-core/authedApi.test.ts +100 -0
  59. package/packages/drop-core/authedApi.ts +81 -0
  60. package/packages/drop-core/authedDropClient.test.ts +196 -0
  61. package/packages/{components/preact/DropZone/drop-core → drop-core}/authedDropClient.ts +30 -72
  62. package/packages/drop-core/buildClient.test.ts +112 -0
  63. package/packages/{components/preact/DropZone/drop-core → drop-core}/buildClient.ts +27 -57
  64. package/packages/drop-core/buildStash.test.ts +85 -0
  65. package/packages/drop-core/buildStash.ts +185 -0
  66. package/packages/{components/preact/DropZone/drop-core → drop-core}/client.ts +18 -6
  67. package/packages/drop-core/constants.ts +39 -0
  68. package/packages/drop-core/deploy.test.ts +177 -0
  69. package/packages/{components/preact/DropZone/drop-core → drop-core}/deploy.ts +59 -9
  70. package/packages/drop-core/detectBuild.test.ts +139 -0
  71. package/packages/drop-core/digest.test.ts +24 -0
  72. package/packages/drop-core/errors.test.ts +98 -0
  73. package/packages/{components/preact/DropZone/drop-core → drop-core}/errors.ts +68 -11
  74. package/packages/drop-core/fileTypes.test.ts +37 -0
  75. package/packages/drop-core/index.ts +44 -0
  76. package/packages/drop-core/readFiles.test.ts +124 -0
  77. package/packages/{components/preact/DropZone/drop-core → drop-core}/types.ts +12 -6
  78. package/packages/drop-core/zip.test.ts +92 -0
  79. package/dist/components/preact/DropZone/drop-core/authedDropClient.js +0 -123
  80. package/dist/components/preact/DropZone/drop-core/buildClient.js +0 -99
  81. package/dist/components/preact/DropZone/drop-core/client.js +0 -45
  82. package/dist/components/preact/DropZone/drop-core/deploy.js +0 -48
  83. package/dist/components/preact/DropZone/drop-core/errors.js +0 -82
  84. package/dist/components/preact/DropZone/drop-core/index.d.ts +0 -11
  85. package/dist/components/preact/DropZone/drop-core/index.js +0 -36
  86. package/packages/components/preact/DropZone/drop-core/index.ts +0 -11
  87. /package/dist/{components/preact/DropZone/drop-core → drop-core}/detectBuild.d.ts +0 -0
  88. /package/dist/{components/preact/DropZone/drop-core → drop-core}/detectBuild.js +0 -0
  89. /package/dist/{components/preact/DropZone/drop-core → drop-core}/digest.d.ts +0 -0
  90. /package/dist/{components/preact/DropZone/drop-core → drop-core}/digest.js +0 -0
  91. /package/dist/{components/preact/DropZone/drop-core → drop-core}/fileTypes.d.ts +0 -0
  92. /package/dist/{components/preact/DropZone/drop-core → drop-core}/fileTypes.js +0 -0
  93. /package/dist/{components/preact/DropZone/drop-core → drop-core}/readFiles.d.ts +0 -0
  94. /package/dist/{components/preact/DropZone/drop-core → drop-core}/types.js +0 -0
  95. /package/dist/{components/preact/DropZone/drop-core → drop-core}/zip.d.ts +0 -0
  96. /package/packages/{components/preact/DropZone/drop-core → drop-core}/detectBuild.ts +0 -0
  97. /package/packages/{components/preact/DropZone/drop-core → drop-core}/digest.ts +0 -0
  98. /package/packages/{components/preact/DropZone/drop-core → drop-core}/fileTypes.ts +0 -0
  99. /package/packages/{components/preact/DropZone/drop-core → drop-core}/readFiles.ts +0 -0
  100. /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
- if (err instanceof UnsupportedDropError) {
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 (err instanceof NotAuthenticatedError) {
88
- return 'Please log in to Netlify to deploy a project that needs a build, then try again.';
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 (err instanceof SiteCreateError) {
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 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}`
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 (err instanceof BuildTimeoutError) {
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 (err.message === 'Deploy did not become ready in time') {
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
- | 'zipping'
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
- };