@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
@@ -1,3 +1,12 @@
1
+ /**
2
+ * The `DropClient` seam: the four calls a static deploy needs — mint a token,
3
+ * create the deploy, upload a file, wait until ready — behind one interface,
4
+ * so `deployFiles` runs the same pipeline whether the deploy is anonymous
5
+ * (this file's `AnonymousDropClient`, claimed later by the visitor) or lands
6
+ * directly in an account (`authedDropClient.ts`).
7
+ */
8
+ import { DEFAULT_API_BASE, DEFAULT_POLL_INTERVAL_MS, DEFAULT_TIMEOUT_MS } from './constants';
9
+ import { DeployTimeoutError } from './errors';
1
10
  import type { DropResponse, Digest } from './types';
2
11
 
3
12
  /** A normal Error with the response's HTTP status attached, so callers can branch on it directly. */
@@ -12,14 +21,16 @@ export interface DropClient {
12
21
  waitUntilReady(deploy: DropResponse, token: string): Promise<void>;
13
22
  }
14
23
 
15
- const API = 'https://api.netlify.com/api/v1';
16
-
17
24
  /**
18
25
  * Unauthenticated drop client. Authorized server-side by the Referer header,
19
26
  * so it only works from an allowlisted origin (see backend checklist).
20
27
  */
21
28
  export class AnonymousDropClient implements DropClient {
22
- constructor(private apiBase = API) {}
29
+ constructor(
30
+ private apiBase = DEFAULT_API_BASE,
31
+ private pollIntervalMs = DEFAULT_POLL_INTERVAL_MS,
32
+ private timeoutMs = DEFAULT_TIMEOUT_MS
33
+ ) {}
23
34
 
24
35
  async getToken(): Promise<string> {
25
36
  const res = await fetch(`${this.apiBase}/drop/token`, { method: 'POST' });
@@ -57,13 +68,14 @@ export class AnonymousDropClient implements DropClient {
57
68
  // Note: `/deploys/{id}` expects the deploy_id (BSON), not the site id (UUID)
58
69
  // that lives at `deploy.id`. See drop_controller.rb; Deploy.find(uuid) is nil
59
70
  // and the request 401s misleadingly via require_current_actor!.
60
- for (let i = 0; i < 600; i++) {
71
+ const deadline = this.timeoutMs / this.pollIntervalMs;
72
+ for (let i = 0; i < deadline; i++) {
61
73
  const res = await fetch(`${this.apiBase}/deploys/${deploy.deploy_id}`, {
62
74
  headers: { Authorization: `Bearer ${token}` },
63
75
  });
64
76
  if (res.ok && (await res.json()).state === 'ready') return;
65
- await new Promise(r => setTimeout(r, 1000));
77
+ await new Promise(r => setTimeout(r, this.pollIntervalMs));
66
78
  }
67
- throw new Error('Deploy did not become ready in time');
79
+ throw new DeployTimeoutError();
68
80
  }
69
81
  }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The tunables shared across drop-core, one place and one comment each.
3
+ * Module-specific constants (e.g. buildStash's storage caps) stay with their
4
+ * module; anything two modules read belongs here.
5
+ */
6
+
7
+ /**
8
+ * Root for session-cookie JSON calls, via the consumer's access-control
9
+ * rewrite. On netlify.com, `/access-control/*` targets the app's
10
+ * access-control function; the paths an origin may call are allowlisted
11
+ * server-side.
12
+ */
13
+ export const DEFAULT_PROXY_BASE = '/access-control/bb-api/api/v1';
14
+
15
+ /** Direct api.netlify.com root for the uploads the proxy's ~6MB Lambda body limit can't carry. */
16
+ export const DEFAULT_API_BASE = 'https://api.netlify.com/api/v1';
17
+
18
+ // How often a waiting deploy/build is re-read: 1 second
19
+ export const DEFAULT_POLL_INTERVAL_MS = 1000;
20
+
21
+ // How long to keep polling before giving up: 10 minutes
22
+ export const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
23
+
24
+ // Concurrent file uploads (matches netlify-cli's deploy engine)
25
+ export const MAX_CONCURRENT_UPLOADS = 5;
26
+
27
+ /**
28
+ * How long a minted upload token is reused before minting another: 20 seconds.
29
+ * The server issues non-app origins a 30s token; the gap leaves margin for the
30
+ * request the token authorizes to finish.
31
+ */
32
+ export const TOKEN_REUSE_MS = 20 * 1000;
33
+
34
+ /**
35
+ * Waits between attempts of a failed file upload, as data so the whole curve
36
+ * can be overridden (the tus-js-client `retryDelays` idiom). Three retries,
37
+ * linear backoff; 400/422 responses never retry — see `uploadWithRetry`.
38
+ */
39
+ export const UPLOAD_RETRY_DELAYS_MS: readonly number[] = [1_000, 2_000, 3_000];
@@ -0,0 +1,177 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { deployBuild, deployFiles } from './deploy';
3
+ import type { BuildClient } from './buildClient';
4
+ import type { DropClient } from './client';
5
+ import type { DeployProgress, ProcessedFile } from './types';
6
+
7
+ const encoder = new TextEncoder();
8
+
9
+ const file = (path: string, sha: string, contents = 'x'): ProcessedFile => {
10
+ const content = encoder.encode(contents).buffer as ArrayBuffer;
11
+ return { path, sha, content, size: content.byteLength, name: path.split('/').pop()! };
12
+ };
13
+
14
+ const files = [file('/index.html', 'aaa'), file('/site.css', 'bbb'), file('/app.js', 'ccc')];
15
+
16
+ function stubDropClient(required: string[]) {
17
+ const uploaded: string[] = [];
18
+ let inFlight = 0;
19
+ let maxInFlight = 0;
20
+ const client: DropClient = {
21
+ getToken: vi.fn(async () => 'tok'),
22
+ createDeploy: vi.fn(async () => ({
23
+ id: 'site',
24
+ deploy_id: 'deploy',
25
+ subdomain: 'sub',
26
+ required,
27
+ })),
28
+ uploadFile: vi.fn(async (deployId: string, path: string) => {
29
+ inFlight++;
30
+ maxInFlight = Math.max(maxInFlight, inFlight);
31
+ await new Promise(r => setTimeout(r, 2));
32
+ uploaded.push(`${deployId}:${path}`);
33
+ inFlight--;
34
+ }),
35
+ waitUntilReady: vi.fn(async () => {}),
36
+ };
37
+ return { client, uploaded, maxConcurrent: () => maxInFlight };
38
+ }
39
+
40
+ describe('deployFiles', () => {
41
+ it('uploads only the SHAs the backend reported missing, keyed by deploy_id', async () => {
42
+ const { client, uploaded } = stubDropClient(['aaa', 'ccc']);
43
+ const result = await deployFiles(client, files);
44
+ expect(uploaded.sort()).toEqual(['deploy:/app.js', 'deploy:/index.html']);
45
+ expect(result.url).toBe('https://sub.netlify.app');
46
+ expect(result.token).toBe('tok');
47
+ expect(client.waitUntilReady).toHaveBeenCalledWith(
48
+ expect.objectContaining({ deploy_id: 'deploy' }),
49
+ 'tok'
50
+ );
51
+ });
52
+
53
+ it('never runs more than five uploads at once', async () => {
54
+ const many = Array.from({ length: 20 }, (_, i) => file(`/f${i}.txt`, `sha${i}`));
55
+ const { client, maxConcurrent } = stubDropClient(many.map(f => f.sha));
56
+ await deployFiles(client, many);
57
+ expect(maxConcurrent()).toBeLessThanOrEqual(5);
58
+ });
59
+
60
+ it('reports progress through the full phase sequence', async () => {
61
+ const { client } = stubDropClient(['aaa']);
62
+ const phases: DeployProgress[] = [];
63
+ await deployFiles(client, files, { onProgress: e => phases.push(e) });
64
+ expect(phases.map(p => p.phase)).toEqual([
65
+ 'creating',
66
+ 'uploading',
67
+ 'uploading',
68
+ 'processing',
69
+ 'ready',
70
+ ]);
71
+ expect(phases[1]).toEqual({ phase: 'uploading', uploaded: 0, total: 1 });
72
+ });
73
+
74
+ it('retries a transient upload failure and succeeds', async () => {
75
+ const { client } = stubDropClient(['aaa']);
76
+ let attempts = 0;
77
+ (client.uploadFile as ReturnType<typeof vi.fn>).mockImplementation(async () => {
78
+ if (++attempts === 1) throw Object.assign(new Error('upload failed: 502'), { status: 502 });
79
+ });
80
+ await deployFiles(client, files, { retryDelays: [0, 0] });
81
+ expect(attempts).toBe(2);
82
+ });
83
+
84
+ it('gives up once the retry curve is exhausted', async () => {
85
+ const { client } = stubDropClient(['aaa']);
86
+ let attempts = 0;
87
+ (client.uploadFile as ReturnType<typeof vi.fn>).mockImplementation(async () => {
88
+ attempts++;
89
+ throw Object.assign(new Error('upload failed: 503'), { status: 503 });
90
+ });
91
+ await expect(deployFiles(client, files, { retryDelays: [0, 0] })).rejects.toThrow('503');
92
+ expect(attempts).toBe(3); // initial try + one per delay
93
+ });
94
+
95
+ it('never retries a 400/422 — the request itself is wrong', async () => {
96
+ const { client } = stubDropClient(['aaa']);
97
+ let attempts = 0;
98
+ (client.uploadFile as ReturnType<typeof vi.fn>).mockImplementation(async () => {
99
+ attempts++;
100
+ throw Object.assign(new Error('upload failed: 422'), { status: 422 });
101
+ });
102
+ await expect(deployFiles(client, files, { retryDelays: [0, 0] })).rejects.toThrow('422');
103
+ expect(attempts).toBe(1);
104
+ });
105
+
106
+ it('retries statusless network failures too', async () => {
107
+ const { client } = stubDropClient(['aaa']);
108
+ let attempts = 0;
109
+ (client.uploadFile as ReturnType<typeof vi.fn>).mockImplementation(async () => {
110
+ if (++attempts === 1) throw new TypeError('fetch failed');
111
+ });
112
+ await deployFiles(client, files, { retryDelays: [0] });
113
+ expect(attempts).toBe(2);
114
+ });
115
+
116
+ it('throws on an empty file set before any network call', async () => {
117
+ const { client } = stubDropClient([]);
118
+ await expect(deployFiles(client, [])).rejects.toThrow('No files to deploy');
119
+ expect(client.getToken).not.toHaveBeenCalled();
120
+ });
121
+ });
122
+
123
+ describe('deployBuild', () => {
124
+ const stubBuildClient = (): BuildClient & { calls: string[] } => {
125
+ const calls: string[] = [];
126
+ return {
127
+ calls,
128
+ createSite: vi.fn(async () => (calls.push('createSite'), { id: 'site', name: 'sub' })),
129
+ createBuildFromZip: vi.fn(
130
+ async () => (calls.push('createBuildFromZip'), { id: 'b', deploy_id: 'd' })
131
+ ),
132
+ waitUntilBuildReady: vi.fn(async () => {
133
+ calls.push('waitUntilBuildReady');
134
+ }),
135
+ };
136
+ };
137
+
138
+ it('zips, creates the site, uploads, and reports the site URL', async () => {
139
+ const client = stubBuildClient();
140
+ const result = await deployBuild(client, files);
141
+ expect(result.site.name).toBe('sub');
142
+ expect(result.build.deploy_id).toBe('d');
143
+ expect(result.url).toBe('https://sub.netlify.app');
144
+ // Off by default: the useful hand-off is the project page, not a spinner.
145
+ expect(client.waitUntilBuildReady).not.toHaveBeenCalled();
146
+ });
147
+
148
+ it('zips BEFORE creating the site, so a failed zip leaves no empty project behind', async () => {
149
+ const client = stubBuildClient();
150
+ // An empty file set makes zipFiles throw before any client call.
151
+ await expect(deployBuild(client, [])).rejects.toThrow('No files to deploy');
152
+ expect(client.createSite).not.toHaveBeenCalled();
153
+ });
154
+
155
+ it('waits for the build only when asked', async () => {
156
+ const client = stubBuildClient();
157
+ await deployBuild(client, files, { waitForBuild: true });
158
+ expect(client.calls).toEqual(['createSite', 'createBuildFromZip', 'waitUntilBuildReady']);
159
+ });
160
+
161
+ it('reports progress through the build phase sequence', async () => {
162
+ const client = stubBuildClient();
163
+ const phases: string[] = [];
164
+ await deployBuild(client, files, { onProgress: e => phases.push(e.phase) });
165
+ expect(phases).toEqual(['zipping', 'creating', 'uploading', 'building', 'ready']);
166
+ });
167
+
168
+ it('falls back through ssl_url → url → derived netlify.app domain', async () => {
169
+ const client = stubBuildClient();
170
+ (client.createSite as ReturnType<typeof vi.fn>).mockResolvedValue({
171
+ id: 's',
172
+ name: 'sub',
173
+ url: 'http://custom.example',
174
+ });
175
+ expect((await deployBuild(client, files)).url).toBe('http://custom.example');
176
+ });
177
+ });
@@ -1,11 +1,38 @@
1
+ import { MAX_CONCURRENT_UPLOADS, UPLOAD_RETRY_DELAYS_MS } from './constants';
1
2
  import { getDigest } from './digest';
2
3
  import type { BuildClient, BuildResponse, BuildSite } from './buildClient';
3
4
  import type { DropClient } from './client';
4
5
  import type { InferredBuildSettings } from './detectBuild';
5
6
  import type { DeployProgress, DeployResult, ProcessedFile } from './types';
6
- import { zipFiles } from './zip';
7
+ import { zipFiles, type ZipResult } from './zip';
7
8
 
8
- const MAX_CONCURRENT_UPLOADS = 5;
9
+ /** Responses that mean the request itself is wrong — retrying can only repeat the answer. */
10
+ const NON_RETRIABLE_STATUSES = new Set([400, 422]);
11
+
12
+ /**
13
+ * Upload one file, retrying transient failures (5xx, 429, network errors) on
14
+ * the given delay curve. A single flaky PUT among hundreds shouldn't kill the
15
+ * whole deploy — every deploy client we know of retries here.
16
+ */
17
+ async function uploadWithRetry(
18
+ client: DropClient,
19
+ deployId: string,
20
+ file: ProcessedFile,
21
+ token: string,
22
+ retryDelays: readonly number[]
23
+ ): Promise<void> {
24
+ for (let attempt = 0; ; attempt++) {
25
+ try {
26
+ await client.uploadFile(deployId, file.path, file.content, token);
27
+ return;
28
+ } catch (err) {
29
+ const status = (err as { status?: number }).status;
30
+ if (status !== undefined && NON_RETRIABLE_STATUSES.has(status)) throw err;
31
+ if (attempt >= retryDelays.length) throw err;
32
+ await new Promise(r => setTimeout(r, retryDelays[attempt]));
33
+ }
34
+ }
35
+ }
9
36
 
10
37
  async function uploadRequired(
11
38
  client: DropClient,
@@ -13,6 +40,7 @@ async function uploadRequired(
13
40
  files: ProcessedFile[],
14
41
  required: Set<string>,
15
42
  token: string,
43
+ retryDelays: readonly number[],
16
44
  onUploaded?: (uploaded: number) => void
17
45
  ) {
18
46
  const queue = files.filter(f => required.has(f.sha));
@@ -21,7 +49,7 @@ async function uploadRequired(
21
49
  const worker = async () => {
22
50
  while (cursor < queue.length) {
23
51
  const f = queue[cursor++];
24
- await client.uploadFile(deployId, f.path, f.content, token);
52
+ await uploadWithRetry(client, deployId, f, token, retryDelays);
25
53
  onUploaded?.(++done);
26
54
  }
27
55
  };
@@ -30,6 +58,8 @@ async function uploadRequired(
30
58
 
31
59
  export interface DeployOptions {
32
60
  onProgress?: (e: DeployProgress) => void;
61
+ /** Waits between retries of a failed file upload. Override for tests or a different curve. */
62
+ retryDelays?: readonly number[];
33
63
  }
34
64
 
35
65
  export async function deployFiles(
@@ -38,7 +68,7 @@ export async function deployFiles(
38
68
  opts: DeployOptions = {}
39
69
  ): Promise<DeployResult> {
40
70
  if (!files.length) throw new Error('No files to deploy');
41
- const { onProgress } = opts;
71
+ const { onProgress, retryDelays = UPLOAD_RETRY_DELAYS_MS } = opts;
42
72
 
43
73
  onProgress?.({ phase: 'creating' });
44
74
  const token = await client.getToken();
@@ -48,8 +78,14 @@ export async function deployFiles(
48
78
  onProgress?.({ phase: 'uploading', uploaded: 0, total });
49
79
  // File PUTs and readiness polling both key off `deploy_id` (BSON), NOT
50
80
  // `deploy.id` (the site UUID) — see the note in client.waitUntilReady.
51
- await uploadRequired(client, deploy.deploy_id, files, new Set(deploy.required), token, uploaded =>
52
- onProgress?.({ phase: 'uploading', uploaded, total })
81
+ await uploadRequired(
82
+ client,
83
+ deploy.deploy_id,
84
+ files,
85
+ new Set(deploy.required),
86
+ token,
87
+ retryDelays,
88
+ uploaded => onProgress?.({ phase: 'uploading', uploaded, total })
53
89
  );
54
90
 
55
91
  onProgress?.({ phase: 'processing' });
@@ -95,10 +131,24 @@ export async function deployBuild(
95
131
  opts: BuildDeployOptions = {}
96
132
  ): Promise<BuildDeployResult> {
97
133
  if (!files.length) throw new Error('No files to deploy');
98
- const { onProgress, waitForBuild = false } = opts;
99
134
 
100
- onProgress?.({ phase: 'zipping' });
101
- const { file: zip, buildSettings } = await zipFiles(files);
135
+ opts.onProgress?.({ phase: 'zipping' });
136
+ const archive = await zipFiles(files);
137
+
138
+ return deployZippedBuild(client, archive, opts);
139
+ }
140
+
141
+ /**
142
+ * Deploy an archive that was already zipped — the resume path for a drop
143
+ * stashed across the signup round-trip (see `buildStash`), where `source.zip`
144
+ * and its synthesised settings were produced before the visitor left.
145
+ */
146
+ export async function deployZippedBuild(
147
+ client: BuildClient,
148
+ { file: zip, buildSettings }: ZipResult,
149
+ opts: BuildDeployOptions = {}
150
+ ): Promise<BuildDeployResult> {
151
+ const { onProgress, waitForBuild = false } = opts;
102
152
 
103
153
  onProgress?.({ phase: 'creating' });
104
154
  const site = await client.createSite();
@@ -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,98 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { apiError } from './client';
3
+ import {
4
+ BuildCreateError,
5
+ BuildFailedError,
6
+ BuildTimeoutError,
7
+ DeployFailedError,
8
+ DeployTimeoutError,
9
+ humanizeDropError,
10
+ isNotAuthenticatedError,
11
+ NotAuthenticatedError,
12
+ SiteCreateError,
13
+ UnsupportedDropError,
14
+ } from './errors';
15
+
16
+ describe('error codes', () => {
17
+ it('pins each class to its copy-proof code brand', () => {
18
+ expect(new UnsupportedDropError().code).toBe('unsupported-drop');
19
+ expect(new NotAuthenticatedError().code).toBe('not-authenticated');
20
+ expect(new SiteCreateError().code).toBe('site-create-failed');
21
+ expect(new BuildCreateError().code).toBe('build-create-failed');
22
+ expect(new BuildFailedError().code).toBe('build-failed');
23
+ expect(new BuildTimeoutError().code).toBe('build-timeout');
24
+ expect(new DeployFailedError().code).toBe('deploy-failed');
25
+ expect(new DeployTimeoutError().code).toBe('deploy-timeout');
26
+ });
27
+
28
+ it('recognizes a foreign-copy NotAuthenticatedError by its code', () => {
29
+ // Raw-TS and dist entrypoints can each load their own class copy;
30
+ // instanceof fails across copies, the code brand does not.
31
+ const foreignCopy = Object.assign(new Error('not authenticated'), {
32
+ code: 'not-authenticated',
33
+ });
34
+ expect(isNotAuthenticatedError(foreignCopy)).toBe(true);
35
+ expect(isNotAuthenticatedError(new Error('nope'))).toBe(false);
36
+ expect(isNotAuthenticatedError('not-authenticated')).toBe(false);
37
+ });
38
+ });
39
+
40
+ describe('humanizeDropError', () => {
41
+ it('maps each typed error to its user-facing copy', () => {
42
+ expect(humanizeDropError(new UnsupportedDropError())).toBe(
43
+ 'To deploy, drop a folder or a .zip file — or a single HTML page.'
44
+ );
45
+ expect(humanizeDropError(new SiteCreateError(500))).toBe(
46
+ "We couldn't create a project for this build. Please try again."
47
+ );
48
+ expect(humanizeDropError(new BuildTimeoutError())).toBe(
49
+ 'Your build is taking longer than expected. It may still finish — check your Netlify dashboard.'
50
+ );
51
+ });
52
+
53
+ it("passes a failed build's own message through — the only actionable part", () => {
54
+ expect(humanizeDropError(new BuildFailedError('Command failed: npm run build'))).toBe(
55
+ 'Your build failed: Command failed: npm run build'
56
+ );
57
+ expect(humanizeDropError(new BuildFailedError())).toBe(
58
+ 'Your build failed. Check your build settings and try again.'
59
+ );
60
+ });
61
+
62
+ it('maps API statuses attached by apiError()', () => {
63
+ expect(humanizeDropError(apiError('drop', 429))).toContain('Too many attempts');
64
+ expect(humanizeDropError(apiError('upload /x', 413))).toContain('too large');
65
+ expect(humanizeDropError(apiError('poll', 404))).toContain("couldn't find that deploy");
66
+ expect(humanizeDropError(apiError('drop', 401))).toContain("wasn't allowed");
67
+ expect(humanizeDropError(apiError('drop', 503))).toContain('servers are having trouble');
68
+ });
69
+
70
+ it('routes typed errors carrying a status through the same mapping', () => {
71
+ expect(humanizeDropError(new BuildCreateError(413))).toContain('too large');
72
+ });
73
+
74
+ it('maps the deploy timeout and failure classes', () => {
75
+ expect(humanizeDropError(new DeployTimeoutError())).toContain('taking longer than expected');
76
+ expect(humanizeDropError(new DeployFailedError('no index.html'))).toBe(
77
+ 'Your deploy failed: no index.html'
78
+ );
79
+ expect(humanizeDropError(new DeployFailedError())).toBe('Your deploy failed. Please try again.');
80
+ });
81
+
82
+ it('reads a fetch TypeError as a connectivity problem', () => {
83
+ expect(humanizeDropError(new TypeError('Failed to fetch'))).toContain(
84
+ 'Check your internet connection'
85
+ );
86
+ });
87
+
88
+ it('falls back to generic copy for anything else', () => {
89
+ expect(humanizeDropError('not even an error')).toBe('Something went wrong. Please try again.');
90
+ expect(humanizeDropError(new Error('mystery'))).toBe('Something went wrong. Please try again.');
91
+ });
92
+
93
+ it('keeps the NotAuthenticatedError copy path-agnostic — it gates static deploys and builds alike', () => {
94
+ expect(humanizeDropError(new NotAuthenticatedError())).toBe(
95
+ 'Please log in to Netlify to deploy this project, then try again.'
96
+ );
97
+ });
98
+ });