@netlify/spark-ui 1.31.0-alpha.4 → 1.31.0-alpha.6

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 (53) hide show
  1. package/README.md +3 -4
  2. package/dist/components/preact/DropZone/DropZone.d.ts +23 -98
  3. package/dist/components/preact/DropZone/DropZone.js +5 -5
  4. package/dist/components/preact/DropZone/index.js +29 -29
  5. package/dist/components/preact/DropZone/useDropDeploy.d.ts +0 -1
  6. package/dist/components/preact/DropZone/useDropDeploy.js +25 -27
  7. package/dist/components/preact/index.js +10 -10
  8. package/dist/drop-core/analytics.d.ts +4 -15
  9. package/dist/drop-core/authedApi.d.ts +9 -32
  10. package/dist/drop-core/authedApi.js +15 -20
  11. package/dist/drop-core/authedDropClient.d.ts +15 -64
  12. package/dist/drop-core/authedDropClient.js +27 -49
  13. package/dist/drop-core/buildClient.d.ts +19 -53
  14. package/dist/drop-core/buildClient.js +32 -44
  15. package/dist/drop-core/buildStash.d.ts +6 -20
  16. package/dist/drop-core/client.d.ts +4 -2
  17. package/dist/drop-core/client.js +19 -17
  18. package/dist/drop-core/constants.d.ts +9 -0
  19. package/dist/drop-core/constants.js +10 -0
  20. package/dist/drop-core/deploy.d.ts +7 -17
  21. package/dist/drop-core/deploy.js +56 -43
  22. package/dist/drop-core/detectBuild.d.ts +8 -26
  23. package/dist/drop-core/errors.d.ts +27 -17
  24. package/dist/drop-core/errors.js +70 -33
  25. package/dist/drop-core/fileTypes.d.ts +4 -14
  26. package/dist/drop-core/index.d.ts +22 -11
  27. package/dist/drop-core/index.js +45 -49
  28. package/dist/drop-core/readFiles.d.ts +5 -11
  29. package/dist/drop-core/types.d.ts +2 -4
  30. package/dist/drop-core/zip.d.ts +7 -30
  31. package/package.json +1 -1
  32. package/packages/components/preact/DropZone/DropZone.tsx +27 -122
  33. package/packages/components/preact/DropZone/useDropDeploy.ts +31 -73
  34. package/packages/drop-core/README.md +91 -0
  35. package/packages/drop-core/analytics.ts +4 -15
  36. package/packages/drop-core/authedApi.ts +9 -35
  37. package/packages/drop-core/authedDropClient.ts +23 -86
  38. package/packages/drop-core/buildClient.test.ts +19 -4
  39. package/packages/drop-core/buildClient.ts +23 -66
  40. package/packages/drop-core/buildStash.ts +9 -25
  41. package/packages/drop-core/client.ts +19 -11
  42. package/packages/drop-core/constants.ts +25 -0
  43. package/packages/drop-core/deploy.test.ts +42 -0
  44. package/packages/drop-core/deploy.ts +42 -22
  45. package/packages/drop-core/detectBuild.ts +15 -50
  46. package/packages/drop-core/errors.test.ts +34 -3
  47. package/packages/drop-core/errors.ts +63 -27
  48. package/packages/drop-core/fileTypes.ts +5 -19
  49. package/packages/drop-core/index.ts +41 -11
  50. package/packages/drop-core/readFiles.test.ts +0 -1
  51. package/packages/drop-core/readFiles.ts +5 -12
  52. package/packages/drop-core/types.ts +2 -4
  53. package/packages/drop-core/zip.ts +7 -30
@@ -71,6 +71,48 @@ describe('deployFiles', () => {
71
71
  expect(phases[1]).toEqual({ phase: 'uploading', uploaded: 0, total: 1 });
72
72
  });
73
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
+
74
116
  it('throws on an empty file set before any network call', async () => {
75
117
  const { client } = stubDropClient([]);
76
118
  await expect(deployFiles(client, [])).rejects.toThrow('No files to deploy');
@@ -1,3 +1,4 @@
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';
@@ -5,7 +6,29 @@ import type { InferredBuildSettings } from './detectBuild';
5
6
  import type { DeployProgress, DeployResult, ProcessedFile } from './types';
6
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
+ /** Retries transient failures (5xx/429/network) — one flaky PUT shouldn't kill the deploy. */
13
+ async function uploadWithRetry(
14
+ client: DropClient,
15
+ deployId: string,
16
+ file: ProcessedFile,
17
+ token: string,
18
+ retryDelays: readonly number[]
19
+ ): Promise<void> {
20
+ for (let attempt = 0; ; attempt++) {
21
+ try {
22
+ await client.uploadFile(deployId, file.path, file.content, token);
23
+ return;
24
+ } catch (err) {
25
+ const status = (err as { status?: number }).status;
26
+ if (status !== undefined && NON_RETRIABLE_STATUSES.has(status)) throw err;
27
+ if (attempt >= retryDelays.length) throw err;
28
+ await new Promise(r => setTimeout(r, retryDelays[attempt]));
29
+ }
30
+ }
31
+ }
9
32
 
10
33
  async function uploadRequired(
11
34
  client: DropClient,
@@ -13,6 +36,7 @@ async function uploadRequired(
13
36
  files: ProcessedFile[],
14
37
  required: Set<string>,
15
38
  token: string,
39
+ retryDelays: readonly number[],
16
40
  onUploaded?: (uploaded: number) => void
17
41
  ) {
18
42
  const queue = files.filter(f => required.has(f.sha));
@@ -21,7 +45,7 @@ async function uploadRequired(
21
45
  const worker = async () => {
22
46
  while (cursor < queue.length) {
23
47
  const f = queue[cursor++];
24
- await client.uploadFile(deployId, f.path, f.content, token);
48
+ await uploadWithRetry(client, deployId, f, token, retryDelays);
25
49
  onUploaded?.(++done);
26
50
  }
27
51
  };
@@ -30,6 +54,8 @@ async function uploadRequired(
30
54
 
31
55
  export interface DeployOptions {
32
56
  onProgress?: (e: DeployProgress) => void;
57
+ /** Waits between retries of a failed file upload. Override for tests or a different curve. */
58
+ retryDelays?: readonly number[];
33
59
  }
34
60
 
35
61
  export async function deployFiles(
@@ -38,7 +64,7 @@ export async function deployFiles(
38
64
  opts: DeployOptions = {}
39
65
  ): Promise<DeployResult> {
40
66
  if (!files.length) throw new Error('No files to deploy');
41
- const { onProgress } = opts;
67
+ const { onProgress, retryDelays = UPLOAD_RETRY_DELAYS_MS } = opts;
42
68
 
43
69
  onProgress?.({ phase: 'creating' });
44
70
  const token = await client.getToken();
@@ -48,8 +74,14 @@ export async function deployFiles(
48
74
  onProgress?.({ phase: 'uploading', uploaded: 0, total });
49
75
  // File PUTs and readiness polling both key off `deploy_id` (BSON), NOT
50
76
  // `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 })
77
+ await uploadRequired(
78
+ client,
79
+ deploy.deploy_id,
80
+ files,
81
+ new Set(deploy.required),
82
+ token,
83
+ retryDelays,
84
+ uploaded => onProgress?.({ phase: 'uploading', uploaded, total })
53
85
  );
54
86
 
55
87
  onProgress?.({ phase: 'processing' });
@@ -60,7 +92,6 @@ export async function deployFiles(
60
92
  }
61
93
 
62
94
  export interface BuildDeployResult {
63
- /** The site created for this build. */
64
95
  site: BuildSite;
65
96
  /** The enqueued build; `deploy_id` is what the app's deploy page keys off. */
66
97
  build: BuildResponse;
@@ -72,22 +103,15 @@ export interface BuildDeployResult {
72
103
 
73
104
  export interface BuildDeployOptions extends DeployOptions {
74
105
  /**
75
- * Poll until the build finishes instead of returning as soon as it's enqueued.
76
- * Off by default: a build takes minutes, so the useful hand-off is usually the
77
- * app's deploy page with its live logs, not a spinner. Turn it on when the
78
- * caller needs to know the outcome — only then can a `BuildFailedError` reach
79
- * it with the build's own error message.
106
+ * Wait for the build instead of returning at enqueue. Off by default (builds
107
+ * take minutes); on is the only way a BuildFailedError reaches the caller.
80
108
  */
81
109
  waitForBuild?: boolean;
82
110
  }
83
111
 
84
112
  /**
85
- * The build counterpart to `deployFiles`: zip the drop, create a site in the
86
- * user's account, and hand the archive to buildbot. Requires an authenticated
87
- * client — builds have no anonymous equivalent.
88
- *
89
- * Site creation deliberately happens *after* zipping, so a zip that fails
90
- * doesn't leave an empty site behind in the user's account.
113
+ * Zip the drop, create a site, hand buildbot the archive. Zipping comes first
114
+ * on purpose: a failed zip must not leave an empty site behind.
91
115
  */
92
116
  export async function deployBuild(
93
117
  client: BuildClient,
@@ -102,11 +126,7 @@ export async function deployBuild(
102
126
  return deployZippedBuild(client, archive, opts);
103
127
  }
104
128
 
105
- /**
106
- * Deploy an archive that was already zipped — the resume path for a drop
107
- * stashed across the signup round-trip (see `buildStash`), where `source.zip`
108
- * and its synthesised settings were produced before the visitor left.
109
- */
129
+ /** Deploy an already-zipped archive — the resume path for a stashed drop (see `buildStash`). */
110
130
  export async function deployZippedBuild(
111
131
  client: BuildClient,
112
132
  { file: zip, buildSettings }: ZipResult,
@@ -8,23 +8,16 @@ export type BuildReason =
8
8
  | 'functions-directory';
9
9
 
10
10
  export interface BuildDetection {
11
- /** True when the dropped project needs a build step and can't be served as static files. */
11
+ /** The drop can't be served as static files. */
12
12
  buildRequired: boolean;
13
13
  /** The signal that decided it (first match wins), or null for a plain static drop. */
14
14
  reason: BuildReason | null;
15
15
  }
16
16
 
17
17
  /**
18
- * Dependencies whose presence implies a build even without an explicit `build`
19
- * script (e.g. a framework wired through its own CLI). Not exhaustive — the
20
- * `build` script check below catches the common case; this is the backstop.
21
- *
22
- * The value is the framework's default publish directory, or `null` where that
23
- * directory isn't a fixed string we can state with confidence — Nuxt, SvelteKit,
24
- * Remix and Angular all resolve it from an adapter or project name. `null` means
25
- * "a build is required, but don't guess a publish directory": see
26
- * `inferBuildSettings`, which only synthesises a `netlify.toml` when it knows
27
- * both halves of the answer.
18
+ * Frameworks that imply a build even without a `build` script, mapped to their
19
+ * default publish dir — `null` where it isn't a fixed string (Nuxt, SvelteKit,
20
+ * Remix, Angular resolve theirs), meaning "build required, don't guess".
28
21
  */
29
22
  const FRAMEWORK_DEPENDENCIES = new Map<string, string | null>([
30
23
  ['astro', 'dist'],
@@ -49,11 +42,7 @@ const FRAMEWORK_DEPENDENCIES = new Map<string, string | null>([
49
42
  ['@solidjs/start', null],
50
43
  ]);
51
44
 
52
- /**
53
- * A `netlify.toml` build config: a bare `[build]` table or any of its
54
- * sub-tables (`[build.environment]`, `[build.processing]`, …). Anchored to the
55
- * line start so a `[build]` mention inside a string/comment doesn't count.
56
- */
45
+ // A [build] table or sub-table, line-anchored so a mention in a comment doesn't count.
57
46
  const NETLIFY_TOML_BUILD_SECTION = /^\s*\[build(\.[\w-]+)?\]/m;
58
47
 
59
48
  /** Zero-config functions directories — their presence alone requires bundling. */
@@ -61,7 +50,7 @@ const FUNCTIONS_DIR_PREFIXES = ['/netlify/functions/', '/netlify/edge-functions/
61
50
 
62
51
  const decoder = new TextDecoder();
63
52
 
64
- /** Find a file at the deploy root by its normalized path (leading slash). */
53
+ /** Root paths are normalized with a leading slash. */
65
54
  function rootFile(files: ProcessedFile[], path: string): ProcessedFile | undefined {
66
55
  return files.find(f => f.path === path);
67
56
  }
@@ -90,23 +79,14 @@ function findFrameworkDependency(pkg: Record<string, unknown>): string | null {
90
79
  return null;
91
80
  }
92
81
 
93
- /** True if any dependency (prod or dev) is a known build-requiring framework. */
94
82
  function hasFrameworkDependency(pkg: Record<string, unknown>): boolean {
95
83
  return findFrameworkDependency(pkg) !== null;
96
84
  }
97
85
 
98
86
  /**
99
- * Decide whether a dropped project needs a build. Runs on the already-read,
100
- * root-normalized `ProcessedFile[]` (a dropped `.zip` is expanded and the common
101
- * root folder stripped upstream in `readFiles`), so build config sits at the
102
- * deploy root, e.g. `/netlify.toml`, `/package.json`.
103
- *
104
- * The anonymous drop API only performs static deploys, so a build-required
105
- * project can't go through it — the consumer redirects such drops to the
106
- * authenticated Drop page instead. This heuristic is deliberately lightweight
107
- * (no framework-detection dependency): it reads the two config files and looks
108
- * for the zero-config functions directories. A plain static site (HTML/CSS/JS,
109
- * no build config) returns `{ buildRequired: false }`.
87
+ * Does this drop need a build? Deliberately lightweight (two config files +
88
+ * the functions dirs; no framework-detection dependency). Input is
89
+ * root-normalized by `readFiles`, so config sits at `/netlify.toml` etc.
110
90
  */
111
91
  export function detectBuild(files: ProcessedFile[]): BuildDetection {
112
92
  const toml = rootFile(files, '/netlify.toml');
@@ -165,13 +145,8 @@ const LOCKFILE_PACKAGE_MANAGERS: ReadonlyArray<readonly [string, string]> = [
165
145
  ['/package-lock.json', 'npm'],
166
146
  ];
167
147
 
168
- /**
169
- * A bare `[build]` table header at the start of a line. Stricter than
170
- * `NETLIFY_TOML_BUILD_SECTION` on purpose: a config with only
171
- * `[build.environment]` carries no command or publish directory, so it still
172
- * needs a `[build]` table synthesised. (TOML permits defining a super-table
173
- * after its sub-tables, so appending one is valid.)
174
- */
148
+ // Bare [build] only — stricter than the detection regex, since a config with
149
+ // just [build.environment] still needs a [build] table synthesised.
175
150
  const NETLIFY_TOML_BUILD_TABLE = /^\s*\[build\]/m;
176
151
 
177
152
  function detectPackageManager(files: ProcessedFile[]): string {
@@ -182,20 +157,10 @@ function detectPackageManager(files: ProcessedFile[]): string {
182
157
  }
183
158
 
184
159
  /**
185
- * Infer the build command and publish directory for a build-required drop, so
186
- * `zipFiles` can write them into a `netlify.toml` that buildbot reads.
187
- *
188
- * Deliberately conservative: it returns settings **only** when both halves are
189
- * known — a `build` script (which gives the command) and a recognised framework
190
- * with a fixed default publish directory (which gives the output). Anything else
191
- * returns `null`, leaving the zip without a synthesised `[build]` table so
192
- * buildbot's own framework detection resolves the settings instead. That matters
193
- * because a `[build]` table with a command but no `publish` makes buildbot
194
- * publish the deploy root — shipping the project's *source* rather than its
195
- * built output, which is worse than not guessing at all.
196
- *
197
- * Returns `null` too when the drop already declares a `[build]` table: the
198
- * author's own config always wins.
160
+ * Build settings for the synthesised netlify.toml — only when BOTH halves are
161
+ * known (build script → command, known framework → publish). Guessing publish
162
+ * wrong ships source instead of output, so `null` beats a bad guess. An
163
+ * author-declared [build] table always wins (also `null`).
199
164
  */
200
165
  export function inferBuildSettings(files: ProcessedFile[]): InferredBuildSettings | null {
201
166
  const toml = rootFile(files, '/netlify.toml');
@@ -4,12 +4,39 @@ import {
4
4
  BuildCreateError,
5
5
  BuildFailedError,
6
6
  BuildTimeoutError,
7
+ DeployFailedError,
8
+ DeployTimeoutError,
7
9
  humanizeDropError,
10
+ isNotAuthenticatedError,
8
11
  NotAuthenticatedError,
9
12
  SiteCreateError,
10
13
  UnsupportedDropError,
11
14
  } from './errors';
12
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
+
13
40
  describe('humanizeDropError', () => {
14
41
  it('maps each typed error to its user-facing copy', () => {
15
42
  expect(humanizeDropError(new UnsupportedDropError())).toBe(
@@ -44,9 +71,13 @@ describe('humanizeDropError', () => {
44
71
  expect(humanizeDropError(new BuildCreateError(413))).toContain('too large');
45
72
  });
46
73
 
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'
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(
80
+ 'Your deploy failed. Please try again.'
50
81
  );
51
82
  });
52
83
 
@@ -1,11 +1,10 @@
1
1
  /**
2
- * Thrown when the user drops or picks a single loose file that Drop can't turn
3
- * into a site on its own — anything that isn't a `.zip` (a site archive) or an
4
- * HTML page (served as index.html). A folder, multiple files, a zip, or a lone
5
- * HTML file are all fine; any other single file is rejected rather than
6
- * deployed as a one-asset site (matches react-ui).
2
+ * A single loose file that can't stand up a site — only a zip, folder,
3
+ * multiple files, or a lone HTML page may deploy (matches react-ui).
7
4
  */
8
5
  export class UnsupportedDropError extends Error {
6
+ readonly code = 'unsupported-drop';
7
+
9
8
  constructor() {
10
9
  super('unsupported single file');
11
10
  this.name = 'UnsupportedDropError';
@@ -13,22 +12,30 @@ export class UnsupportedDropError extends Error {
13
12
  }
14
13
 
15
14
  /**
16
- * Thrown when an authenticated path (static or build) turns out to have no
17
- * usable session — the short-lived upload token came back empty, or the
18
- * session-cookie site create was refused, so nothing was created. For static
19
- * drops the flow falls back to the anonymous drop + claim; builds have no
20
- * anonymous equivalent, so the consumer's only recourse is to send the visitor
21
- * through login/signup and have them drop again.
15
+ * The pre-mutation "no session" signal: nothing was created yet, so static
16
+ * drops may fall back to the anonymous flow and build drops stash + hand off.
22
17
  */
23
18
  export class NotAuthenticatedError extends Error {
19
+ readonly code = 'not-authenticated';
20
+
24
21
  constructor(message = 'not authenticated') {
25
22
  super(message);
26
23
  this.name = 'NotAuthenticatedError';
27
24
  }
28
25
  }
29
26
 
27
+ /**
28
+ * Copy-proof check for the "no session" error — `instanceof` misses class
29
+ * copies from the package's other entrypoint. Fallback gates must use this.
30
+ */
31
+ export function isNotAuthenticatedError(err: unknown): err is NotAuthenticatedError {
32
+ if (err instanceof NotAuthenticatedError) return true;
33
+ return err instanceof Error && (err as { code?: string }).code === 'not-authenticated';
34
+ }
35
+
30
36
  /** `POST …/sites` failed, so there is nothing to build into. Carries the HTTP status. */
31
37
  export class SiteCreateError extends Error {
38
+ readonly code = 'site-create-failed';
32
39
  status?: number;
33
40
 
34
41
  constructor(status?: number) {
@@ -43,6 +50,7 @@ export class SiteCreateError extends Error {
43
50
  * started. Distinct from `BuildFailedError`, which is a build that ran and broke.
44
51
  */
45
52
  export class BuildCreateError extends Error {
53
+ readonly code = 'build-create-failed';
46
54
  status?: number;
47
55
 
48
56
  constructor(status?: number) {
@@ -52,13 +60,10 @@ export class BuildCreateError extends Error {
52
60
  }
53
61
  }
54
62
 
55
- /**
56
- * The build ran and ended in `error`/`rejected`. `reason` is the API's
57
- * `error_message` when there is one — it's the only part of a build failure that
58
- * says anything actionable (a failing build command, a missing dependency), so
59
- * it is surfaced to the user rather than swallowed.
60
- */
63
+ /** The build ran and broke. `reason` carries the API's `error_message` — the only actionable part. */
61
64
  export class BuildFailedError extends Error {
65
+ readonly code = 'build-failed';
66
+
62
67
  constructor(public reason?: string) {
63
68
  super(reason ? `build failed: ${reason}` : 'build failed');
64
69
  this.name = 'BuildFailedError';
@@ -67,12 +72,34 @@ export class BuildFailedError extends Error {
67
72
 
68
73
  /** The build hadn't settled before the poller gave up. The build itself may still finish. */
69
74
  export class BuildTimeoutError extends Error {
75
+ readonly code = 'build-timeout';
76
+
70
77
  constructor() {
71
78
  super('Build did not finish in time');
72
79
  this.name = 'BuildTimeoutError';
73
80
  }
74
81
  }
75
82
 
83
+ /** A static deploy ran and ended in `error`/`rejected`; `reason` carries the API's `error_message`. */
84
+ export class DeployFailedError extends Error {
85
+ readonly code = 'deploy-failed';
86
+
87
+ constructor(public reason?: string) {
88
+ super(reason ? `deploy failed: ${reason}` : 'deploy failed');
89
+ this.name = 'DeployFailedError';
90
+ }
91
+ }
92
+
93
+ /** The deploy hadn't gone ready before the poller gave up. It may still finish. */
94
+ export class DeployTimeoutError extends Error {
95
+ readonly code = 'deploy-timeout';
96
+
97
+ constructor() {
98
+ super('Deploy did not become ready in time');
99
+ this.name = 'DeployTimeoutError';
100
+ }
101
+ }
102
+
76
103
  /**
77
104
  * Turn a raw deploy failure into copy a non-technical user can act on.
78
105
  * The original message is still tracked via analytics — this is only for display.
@@ -80,31 +107,40 @@ export class BuildTimeoutError extends Error {
80
107
  export function humanizeDropError(err: unknown): string {
81
108
  if (!(err instanceof Error)) return 'Something went wrong. Please try again.';
82
109
 
83
- if (err instanceof UnsupportedDropError) {
110
+ // Match on the `code` brand rather than instanceof: raw-TS and dist copies
111
+ // of these classes can coexist (see isNotAuthenticatedError).
112
+ const code = (err as { code?: string }).code;
113
+
114
+ if (code === 'unsupported-drop') {
84
115
  return 'To deploy, drop a folder or a .zip file — or a single HTML page.';
85
116
  }
86
117
 
87
- if (err instanceof NotAuthenticatedError) {
118
+ if (code === 'not-authenticated') {
88
119
  return 'Please log in to Netlify to deploy this project, then try again.';
89
120
  }
90
121
 
91
- if (err instanceof SiteCreateError) {
122
+ if (code === 'site-create-failed') {
92
123
  return "We couldn't create a project for this build. Please try again.";
93
124
  }
94
125
 
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}`
126
+ // The failure's own error message is the only actionable part, so pass it
127
+ // straight through instead of a generic apology.
128
+ if (code === 'build-failed') {
129
+ const reason = (err as BuildFailedError).reason;
130
+ return reason
131
+ ? `Your build failed: ${reason}`
100
132
  : 'Your build failed. Check your build settings and try again.';
101
133
  }
134
+ if (code === 'deploy-failed') {
135
+ const reason = (err as DeployFailedError).reason;
136
+ return reason ? `Your deploy failed: ${reason}` : 'Your deploy failed. Please try again.';
137
+ }
102
138
 
103
- if (err instanceof BuildTimeoutError) {
139
+ if (code === 'build-timeout') {
104
140
  return 'Your build is taking longer than expected. It may still finish — check your Netlify dashboard.';
105
141
  }
106
142
 
107
- if (err.message === 'Deploy did not become ready in time') {
143
+ if (code === 'deploy-timeout') {
108
144
  return 'Your site is taking longer than expected to go live. Please try again in a moment.';
109
145
  }
110
146
  // fetch() rejects with a TypeError when the network is unreachable (offline, DNS, CORS, etc).
@@ -1,28 +1,14 @@
1
1
  /** What we report when the browser gives us no MIME type at all. */
2
2
  const UNKNOWN_TYPE = 'application/octet-stream';
3
3
 
4
- /**
5
- * Distinct types kept per event. Deduplication alone keeps this small for a real
6
- * site (a handful of types across hundreds of files); the cap is a backstop so an
7
- * unusual folder can't push an oversized property into the analytics payload.
8
- */
4
+ // Cap on distinct types per event — a backstop against oversized analytics payloads.
9
5
  const MAX_TYPES = 20;
10
6
 
11
7
  /**
12
- * Distinct MIME types across a drop or file-picker selection, sorted so the same
13
- * selection always yields the same array.
14
- *
15
- * Read from the raw `File.type` at the drop boundary rather than from
16
- * `ProcessedFile`s: the MIME type only exists on the original `File`, and reading
17
- * can throw before any `ProcessedFile` does (an unsupported single file) — the
18
- * very case the file type is most worth knowing about.
19
- *
20
- * Browsers leave `File.type` empty for anything they don't recognise — a dragged
21
- * folder, an `.exe`, an extensionless file — so those collapse to
22
- * `application/octet-stream` and the property never carries an empty string.
23
- *
24
- * Call this synchronously inside the drop handler: a `DataTransfer` detaches once
25
- * the event turn ends.
8
+ * Distinct, sorted MIME types of a selection. Read at the drop boundary — the
9
+ * type only exists on the original File, and reading can throw before any
10
+ * ProcessedFile exists. Call synchronously in the drop handler: a DataTransfer
11
+ * detaches after the event turn.
26
12
  */
27
13
  export function getFileTypes(files: FileList | null | undefined): string[] {
28
14
  if (!files?.length) return [];
@@ -1,14 +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
+
1
7
  export * from './types';
2
- export * from './digest';
3
- export * from './readFiles';
4
- export * from './fileTypes';
5
- export * from './detectBuild';
6
- export * from './client';
7
- export * from './authedApi';
8
- export * from './authedDropClient';
9
- export * from './buildClient';
10
- export * from './zip';
11
- export * from './buildStash';
12
- export * from './deploy';
13
8
  export * from './errors';
14
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';
@@ -81,7 +81,6 @@ describe('readFileList', () => {
81
81
  const files = await readFileList(
82
82
  asFileList([new File([bytes.buffer.slice(0) as ArrayBuffer], 'my-site.zip')])
83
83
  );
84
- // Junk gone, common "my-site/" root stripped.
85
84
  expect(files.map(f => f.path).sort()).toEqual(['/css/site.css', '/index.html']);
86
85
  });
87
86
  });
@@ -53,15 +53,11 @@ async function readDirEntry(entry: any): Promise<ProcessedFile[]> {
53
53
  }
54
54
 
55
55
  /**
56
- * Read a DataTransfer from a drop event into ProcessedFiles.
57
- *
58
- * NOTE: `webkitGetAsEntry()` must be called synchronously inside the drop
59
- * handler — the DataTransfer is detached once the event turn ends. The
60
- * component captures entries first (see `readDroppedEntries`) then calls the
61
- * async readers, so this function is safe to `await`.
56
+ * Read a drop's DataTransfer into ProcessedFiles. `webkitGetAsEntry()` runs
57
+ * synchronously before the first await — the DataTransfer detaches once the
58
+ * event turn ends.
62
59
  */
63
60
  export async function readDataTransfer(dt: DataTransfer): Promise<ProcessedFile[]> {
64
- // single zip?
65
61
  const first = dt.files?.[0];
66
62
  if (dt.files.length === 1 && first && isZipName(first.name)) {
67
63
  return readZip(first);
@@ -164,11 +160,8 @@ export function hasMultipleHtmlFilesWithoutIndex(files: ProcessedFile[]): boolea
164
160
  }
165
161
 
166
162
  /**
167
- * When the set has no index.html but exactly one HTML file (a lone page
168
- * alongside its assets, or a single dropped file), rename that file to
169
- * index.html — keeping its directory — so it is served at the site root
170
- * instead of 404ing. Content and sha are untouched. Any other set (already has
171
- * an index, multiple HTML files, or no HTML) is returned unchanged.
163
+ * A lone non-index HTML page is renamed to index.html (same directory,
164
+ * content/sha untouched) so it serves instead of 404ing. Other sets unchanged.
172
165
  */
173
166
  export function renameSingleNonIndexHtmlToIndex(files: ProcessedFile[]): ProcessedFile[] {
174
167
  const name = getSingleNonIndexHtmlFileName(files);