@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
@@ -0,0 +1,91 @@
1
+ # drop-core
2
+
3
+ The framework-free engine behind the Drop Zone: reading a dropped folder/zip
4
+ into deployable files, deciding whether it needs a build, and deploying it —
5
+ anonymously (claim flow) or into a logged-in visitor's account — plus the
6
+ stash that carries a build drop across the signup round-trip.
7
+
8
+ Consumed three ways: by the Preact `DropZone` in this repo, by anything
9
+ importing `@netlify/spark-ui/drop-core`, and eventually by other Netlify
10
+ surfaces implementing their own Drop UI over the same core.
11
+
12
+ ## Conventions
13
+
14
+ These follow the practices shared by the libraries doing our two jobs —
15
+ react-dropzone/Uppy/FilePond on the component side, netlify-cli's deploy
16
+ engine, @vercel/client, and tus-js-client on the pipeline side. Where we
17
+ deliberately differ, that's recorded too, so it isn't re-litigated by accident.
18
+
19
+ ### Boundaries
20
+
21
+ - **Nothing in `drop-core` may import from `preact`** (or any UI framework).
22
+ Anything that imports preact may not contain deploy/read/detect logic — it
23
+ adapts this package to a view, nothing more.
24
+ - **The barrel is the public API.** Nothing is public unless `index.ts` names
25
+ it; `export *` is reserved for modules that are contracts in their entirety
26
+ (`types`, `errors`, `analytics`). New helpers default to private.
27
+ - **Granular pure modules, one cohesive orchestrator.** Pure helpers get their
28
+ own small file named after what they export; the deploy orchestration stays
29
+ together in `deploy.ts` rather than fragmenting a state machine.
30
+ - **Shared tunables live in `constants.ts`** with a unit-bearing comment each
31
+ (`// 10 minutes`, not `// the timeout`). Module-specific constants stay with
32
+ their module. No magic numbers inline.
33
+
34
+ ### Naming
35
+
36
+ | Kind | Convention | Examples here |
37
+ | ------------------ | ---------------------------------- | ------------------------------------------------------------- |
38
+ | Outcome callbacks | `onVerb` / past participle | `onDeploy`, `onBuildRequired`, `onError` |
39
+ | Boolean state | `isX` | `busy` (exception, pre-dates rule), `isNotAuthenticatedError` |
40
+ | Strategy injection | bare noun | `client`, `buildClient`, `validator`-style |
41
+ | Analytics events | `dropzone_snake_case` — **frozen** | `DROPZONE_EVENTS` (test-pinned wire names) |
42
+ | Error predicates | `isXError(err)` | `isNotAuthenticatedError` |
43
+ | Files | filename = main export | `detectBuild.ts`, `buildStash.ts` |
44
+
45
+ ### Comments
46
+
47
+ - **WHY over WHAT.** A comment earns its place by stating something the code
48
+ can't: an invariant, an observed API behavior, a browser quirk, a
49
+ don't-simplify warning. Never narrate the next line.
50
+ - **Record observed API behavior, with receipts.** The model comment in this
51
+ package: readiness polling keys off `deploy_id` (BSON), not `deploy.id` (site
52
+ UUID), "or the request 401s misleadingly" — naming the server-side file. Say
53
+ what the server _actually does_, and link the server source when you can.
54
+ - **Repeat load-bearing quirk notes at every call site.** The `deploy_id` note
55
+ appears in the client _and_ the orchestrator on purpose — a note that only
56
+ exists where nobody is reading protects nothing.
57
+ - **"Don't simplify" notes name what breaks** — the invariant, and ideally the
58
+ test that pins it (e.g. the `NotAuthenticatedError` boundary below).
59
+ - **No `@param`/`@returns`.** Types carry the shape; JSDoc carries rationale
60
+ and edge cases. (react-dropzone: zero `@param` across its entire source.)
61
+ - File-header purpose blocks only where the filename undersells the contents.
62
+
63
+ ### Errors
64
+
65
+ - **Class per failure, each with a readonly kebab-case `code` brand.** This
66
+ package ships as raw TS (`./components`) _and_ compiled dist (`./preact`,
67
+ `./drop-core`) — two copies of a class can coexist in one app and defeat
68
+ `instanceof`. Detection that steers control flow must go through a
69
+ code-checking predicate (`isNotAuthenticatedError`), never bare `instanceof`.
70
+ - **`NotAuthenticatedError` is pre-mutation only.** It may be thrown solely
71
+ before anything is created (token gate, site-create 401) — it is the signal
72
+ that an anonymous retry is safe. Throwing it after a site exists would strand
73
+ an empty project and deploy a duplicate. Pinned by tests.
74
+ - **Humanized copy lives in `humanizeDropError`, in this package.** A
75
+ deliberate divergence from @vercel/client (which leaves rendering to the
76
+ CLI): our consumers render our copy directly, and the docs promise it.
77
+ - Transient upload failures retry on `UPLOAD_RETRY_DELAYS_MS` (backoff as
78
+ data, the tus idiom); **400/422 never retry** — the request itself is wrong.
79
+
80
+ ### Considered and rejected
81
+
82
+ - **Async-generator lifecycles** (@vercel/client): our phase-callback design is
83
+ equivalent and churn-free; `DeployPhase` is a closed union, which netlify-cli's
84
+ own author wished theirs was.
85
+ - **Kebab-case event renames**: `dropzone_*` wire names are pinned analytics
86
+ history; `analytics.test.ts` fails on any rename. Append-only.
87
+ - **`getRootProps`/`getInputProps` attribute getters** (react-dropzone, Uppy):
88
+ the right shape for a future framework-free UI tier shared with the app —
89
+ not something to bolt onto the shipped wrapper component.
90
+ - **A `defaultOptions` object** (tus, Uppy): our destructured constructor
91
+ defaults are react-dropzone's pattern; either is fine, we have this one.
@@ -0,0 +1,17 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { DROPZONE_EVENTS } from './analytics';
3
+
4
+ describe('DROPZONE_EVENTS', () => {
5
+ it('pins the wire names — renaming one silently forks Amplitude/Segment history', () => {
6
+ expect(DROPZONE_EVENTS).toEqual({
7
+ FILES_DROPPED: 'dropzone_files_dropped',
8
+ BROWSE_OPENED: 'dropzone_browse_opened',
9
+ BUILD_REQUIRED: 'dropzone_build_required',
10
+ DEPLOY_SUCCEEDED: 'dropzone_deploy_succeeded',
11
+ BUILD_DEPLOY_SUCCEEDED: 'dropzone_build_deploy_succeeded',
12
+ DEPLOY_FAILED: 'dropzone_deploy_failed',
13
+ PROJECT_STASHED: 'dropzone_project_stashed',
14
+ RESUMED: 'dropzone_resumed',
15
+ });
16
+ });
17
+ });
@@ -0,0 +1,122 @@
1
+ import type { BuildReason } from './detectBuild';
2
+
3
+ /**
4
+ * The Drop Zone's analytics contract — the canonical event names and property
5
+ * shapes, exported so no consumer (this package's own component, a host site's
6
+ * GTM wiring, or another Netlify surface implementing its own Drop UI over
7
+ * drop-core) ever hand-writes the strings.
8
+ */
9
+
10
+ /** How the files reached the zone — carried on every drop-initiated event as `method`. */
11
+ export type DropMethod = 'drag' | 'browse';
12
+
13
+ /**
14
+ * Canonical Drop Zone lifecycle events. The values are the wire names:
15
+ * analytics history in Amplitude/Segment keys off these exact strings, so
16
+ * renaming a value silently forks every funnel built on it — treat them as
17
+ * append-only.
18
+ */
19
+ export const DROPZONE_EVENTS = {
20
+ /** Files arrived via drag — the picker path reports its files on outcome events instead. */
21
+ FILES_DROPPED: 'dropzone_files_dropped',
22
+ /** The native file picker was opened (keyboard or click). */
23
+ BROWSE_OPENED: 'dropzone_browse_opened',
24
+ /** The drop needs a build — it will be built in place, stashed, or handed off. */
25
+ BUILD_REQUIRED: 'dropzone_build_required',
26
+ /** A static deploy went live (anonymous or into the visitor's account). */
27
+ DEPLOY_SUCCEEDED: 'dropzone_deploy_succeeded',
28
+ /** A build was enqueued in the visitor's account. */
29
+ BUILD_DEPLOY_SUCCEEDED: 'dropzone_build_deploy_succeeded',
30
+ /** Any attempt ended in an error (including a failed resume). */
31
+ DEPLOY_FAILED: 'dropzone_deploy_failed',
32
+ /** A logged-out build drop was stashed locally before the signup hand-off. */
33
+ PROJECT_STASHED: 'dropzone_project_stashed',
34
+ /** A stashed build drop was picked up for automatic deployment. */
35
+ RESUMED: 'dropzone_resumed',
36
+ } as const;
37
+
38
+ export type DropZoneEventName = (typeof DROPZONE_EVENTS)[keyof typeof DROPZONE_EVENTS];
39
+
40
+ /** Properties the component adds to every event. */
41
+ export interface DropZoneBaseEventProps {
42
+ /** Present when the consumer sets `analyticsId` — tells multiple zones apart. */
43
+ dropzone_id?: string;
44
+ }
45
+
46
+ export interface FilesDroppedProps extends DropZoneBaseEventProps {
47
+ method: 'drag';
48
+ file_count: number;
49
+ /** Omitted — never `[]` — when the browser exposes no files. */
50
+ file_types?: string[];
51
+ }
52
+
53
+ export interface BrowseOpenedProps extends DropZoneBaseEventProps {
54
+ method: 'keyboard' | 'click';
55
+ }
56
+
57
+ export interface BuildRequiredProps extends DropZoneBaseEventProps {
58
+ method: DropMethod;
59
+ reason: BuildReason | null;
60
+ file_count: number;
61
+ file_types?: string[];
62
+ }
63
+
64
+ export interface DeploySucceededProps extends DropZoneBaseEventProps {
65
+ method: DropMethod;
66
+ site_name: string;
67
+ file_count: number;
68
+ /** Whether the deploy landed directly in the visitor's account. */
69
+ authenticated: boolean;
70
+ /** Present only when an authenticated attempt bounced (stale login hint) and the drop completed anonymously. */
71
+ auth_fallback?: true;
72
+ file_types?: string[];
73
+ }
74
+
75
+ export interface BuildDeploySucceededProps extends DropZoneBaseEventProps {
76
+ method: DropMethod;
77
+ reason: BuildReason | null;
78
+ site_name: string;
79
+ file_count: number;
80
+ /** Present when build settings were synthesised into the archive. */
81
+ framework?: string;
82
+ file_types?: string[];
83
+ }
84
+
85
+ export interface DeployFailedProps extends DropZoneBaseEventProps {
86
+ /** Absent when the failure comes from a stash resume rather than a live drop gesture. */
87
+ method?: DropMethod;
88
+ /** Raw failure reason (e.g. "drop failed: 429", "resume_failed") — the humanized copy goes to the UI, not here. */
89
+ reason: string;
90
+ /** Present only when the file set is known (absent when reading itself threw). */
91
+ file_count?: number;
92
+ file_types?: string[];
93
+ }
94
+
95
+ export interface ProjectStashedProps extends DropZoneBaseEventProps {
96
+ method: DropMethod;
97
+ reason: BuildReason | null;
98
+ file_count: number;
99
+ file_types?: string[];
100
+ }
101
+
102
+ /** A resume starts from storage, not a fresh gesture — no method or file set to report. */
103
+ export type ResumedProps = DropZoneBaseEventProps;
104
+
105
+ /** Event name → its property shape, for typed emitters and sinks on either side of the seam. */
106
+ export interface DropZoneEventProperties {
107
+ dropzone_files_dropped: FilesDroppedProps;
108
+ dropzone_browse_opened: BrowseOpenedProps;
109
+ dropzone_build_required: BuildRequiredProps;
110
+ dropzone_deploy_succeeded: DeploySucceededProps;
111
+ dropzone_build_deploy_succeeded: BuildDeploySucceededProps;
112
+ dropzone_deploy_failed: DeployFailedProps;
113
+ dropzone_project_stashed: ProjectStashedProps;
114
+ dropzone_resumed: ResumedProps;
115
+ }
116
+
117
+ /**
118
+ * Provider-agnostic analytics sink. Deliberately loose (string, not the event
119
+ * union): hosts forward these to Segment/GA/GTM and often pipe their own
120
+ * events through the same function.
121
+ */
122
+ export type TrackFn = (event: string, properties?: Record<string, unknown>) => void;
@@ -0,0 +1,100 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { createSiteInAccount, pollDeployUntilSettled } from './authedApi';
3
+ import { NotAuthenticatedError, SiteCreateError } from './errors';
4
+
5
+ const PROXY = '/access-control/bb-api/api/v1';
6
+
7
+ afterEach(() => vi.unstubAllGlobals());
8
+
9
+ describe('createSiteInAccount', () => {
10
+ const record = () => {
11
+ const calls: Array<{ url: string; init: RequestInit }> = [];
12
+ vi.stubGlobal('fetch', async (url: unknown, init: RequestInit = {}) => {
13
+ calls.push({ url: String(url), init });
14
+ return { ok: true, status: 200, json: async () => ({ id: 's', name: 'n' }) } as Response;
15
+ });
16
+ return calls;
17
+ };
18
+
19
+ it('POSTs through the proxy on the session cookie, attributed to drop', async () => {
20
+ const calls = record();
21
+ const site = await createSiteInAccount(PROXY);
22
+ expect(site).toEqual({ id: 's', name: 'n' });
23
+ expect(calls[0].url).toBe(`${PROXY}/sites`);
24
+ expect(calls[0].init.credentials).toBe('include');
25
+ expect(JSON.parse(String(calls[0].init.body))).toEqual({ created_via: 'drop' });
26
+ });
27
+
28
+ it('scopes to the account slug when given', async () => {
29
+ const calls = record();
30
+ await createSiteInAccount(PROXY, 'my-team');
31
+ expect(calls[0].url).toBe(`${PROXY}/my-team/sites`);
32
+ });
33
+
34
+ it.each([401, 403])(
35
+ 'maps a %i — pre-mutation — to NotAuthenticatedError',
36
+ async (status: number) => {
37
+ vi.stubGlobal('fetch', async () => ({ ok: false, status, json: async () => ({}) }));
38
+ await expect(createSiteInAccount(PROXY)).rejects.toBeInstanceOf(NotAuthenticatedError);
39
+ }
40
+ );
41
+
42
+ it('wraps any other failure in SiteCreateError with the status', async () => {
43
+ vi.stubGlobal('fetch', async () => ({ ok: false, status: 500, json: async () => ({}) }));
44
+ const failure = await createSiteInAccount(PROXY).catch(e => e);
45
+ expect(failure).toBeInstanceOf(SiteCreateError);
46
+ expect(failure.status).toBe(500);
47
+ });
48
+ });
49
+
50
+ describe('pollDeployUntilSettled', () => {
51
+ const poll = (states: Array<{ ok: boolean; state?: string; error_message?: string }>) => {
52
+ let call = 0;
53
+ const urls: string[] = [];
54
+ vi.stubGlobal('fetch', async (url: unknown, init: RequestInit = {}) => {
55
+ urls.push(String(url) + `|${init.credentials}`);
56
+ const next = states[Math.min(call++, states.length - 1)];
57
+ return { ok: next.ok, status: 200, json: async () => next } as Response;
58
+ });
59
+ return {
60
+ urls,
61
+ run: () =>
62
+ pollDeployUntilSettled({
63
+ proxyBase: PROXY,
64
+ deployId: 'd',
65
+ pollIntervalMs: 1,
66
+ timeoutMs: 5,
67
+ }),
68
+ };
69
+ };
70
+
71
+ it('reads the deploy through the proxy on the cookie until ready', async () => {
72
+ const { run, urls } = poll([
73
+ { ok: true, state: 'processing' },
74
+ { ok: true, state: 'ready' },
75
+ ]);
76
+ expect(await run()).toEqual({ outcome: 'ready' });
77
+ expect(urls[0]).toBe(`${PROXY}/deploys/d|include`);
78
+ });
79
+
80
+ it('reports terminal failures with the API error message', async () => {
81
+ const { run } = poll([{ ok: true, state: 'error', error_message: 'boom' }]);
82
+ expect(await run()).toEqual({ outcome: 'failed', errorMessage: 'boom' });
83
+ });
84
+
85
+ it('treats rejected as a failure too', async () => {
86
+ const { run } = poll([{ ok: true, state: 'rejected' }]);
87
+ expect(await run()).toEqual({ outcome: 'failed', errorMessage: undefined });
88
+ });
89
+
90
+ it('skips transient non-ok reads rather than failing the deploy', async () => {
91
+ const { run } = poll([{ ok: false }, { ok: true, state: 'ready' }]);
92
+ expect(await run()).toEqual({ outcome: 'ready' });
93
+ });
94
+
95
+ it('times out after timeoutMs / pollIntervalMs polls', async () => {
96
+ const { run, urls } = poll([{ ok: true, state: 'building' }]);
97
+ expect(await run()).toEqual({ outcome: 'timed-out' });
98
+ expect(urls).toHaveLength(5);
99
+ });
100
+ });
@@ -0,0 +1,81 @@
1
+ import type { BuildSite } from './types';
2
+ import { NotAuthenticatedError, SiteCreateError } from './errors';
3
+
4
+ /**
5
+ * The transport both authenticated clients share: JSON calls ride the
6
+ * consumer's access-control rewrite on the session cookie, so nothing
7
+ * credential-shaped lives in the browser and nothing can expire mid-deploy.
8
+ * (Uploads are the exception — the proxy's Lambda caps request bodies at
9
+ * ~6MB — and each client handles its own, bearer-authorized.)
10
+ */
11
+
12
+ /**
13
+ * Create a site in the visitor's account via `POST {proxyBase}/sites` (scoped
14
+ * to `accountSlug` when given, else the user's default account), attributed
15
+ * `created_via: 'drop'`.
16
+ *
17
+ * A logged-out visitor 401s here, before anything is created — the one
18
+ * pre-mutation point where callers may still safely fall back to an anonymous
19
+ * flow, which is why it maps to `NotAuthenticatedError`. Any other failure is
20
+ * a `SiteCreateError` carrying the status.
21
+ */
22
+ export async function createSiteInAccount(
23
+ proxyBase: string,
24
+ accountSlug?: string
25
+ ): Promise<BuildSite> {
26
+ const path = accountSlug ? `/${accountSlug}/sites` : '/sites';
27
+ const res = await fetch(`${proxyBase}${path}`, {
28
+ method: 'POST',
29
+ credentials: 'include',
30
+ headers: { 'Content-Type': 'application/json' },
31
+ body: JSON.stringify({ created_via: 'drop' }),
32
+ });
33
+ if (res.status === 401 || res.status === 403) throw new NotAuthenticatedError();
34
+ if (!res.ok) throw new SiteCreateError(res.status);
35
+ return res.json();
36
+ }
37
+
38
+ /** How a watched deploy ended. `errorMessage` is the API's own explanation, when it gave one. */
39
+ export type SettledDeploy =
40
+ { outcome: 'ready' } | { outcome: 'failed'; errorMessage?: string } | { outcome: 'timed-out' };
41
+
42
+ /** Deploy states that end the wait — `ready` succeeds, the rest are failures. */
43
+ const TERMINAL_STATES = new Set(['ready', 'error', 'rejected']);
44
+
45
+ interface DeployState {
46
+ state?: string;
47
+ error_message?: string;
48
+ }
49
+
50
+ /**
51
+ * Poll `GET {proxyBase}/deploys/{deployId}` on the session cookie until the
52
+ * deploy settles, and report how. Purely mechanical on purpose: each caller
53
+ * maps the outcome onto its own error vocabulary (the drop client throws plain
54
+ * errors `humanizeDropError` understands; the build client throws
55
+ * `BuildFailedError`/`BuildTimeoutError`), so that knowledge stays next to the
56
+ * class it belongs to.
57
+ *
58
+ * A non-ok poll response is skipped, not fatal — a transient proxy hiccup
59
+ * shouldn't kill a deploy that is still progressing.
60
+ */
61
+ export async function pollDeployUntilSettled(options: {
62
+ proxyBase: string;
63
+ deployId: string;
64
+ pollIntervalMs: number;
65
+ timeoutMs: number;
66
+ }): Promise<SettledDeploy> {
67
+ const { proxyBase, deployId, pollIntervalMs, timeoutMs } = options;
68
+ const deadline = timeoutMs / pollIntervalMs;
69
+ for (let i = 0; i < deadline; i++) {
70
+ const res = await fetch(`${proxyBase}/deploys/${deployId}`, { credentials: 'include' });
71
+ if (res.ok) {
72
+ const deploy: DeployState = await res.json();
73
+ if (deploy.state && TERMINAL_STATES.has(deploy.state)) {
74
+ if (deploy.state === 'ready') return { outcome: 'ready' };
75
+ return { outcome: 'failed', errorMessage: deploy.error_message };
76
+ }
77
+ }
78
+ await new Promise(r => setTimeout(r, pollIntervalMs));
79
+ }
80
+ return { outcome: 'timed-out' };
81
+ }
@@ -0,0 +1,196 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { AuthenticatedDropClient } from './authedDropClient';
3
+ import { NotAuthenticatedError } from './errors';
4
+
5
+ const PROXY = '/access-control/bb-api/api/v1';
6
+ const API = 'https://api.netlify.com/api/v1';
7
+
8
+ interface RecordedCall {
9
+ url: string;
10
+ init: RequestInit;
11
+ }
12
+
13
+ /** Stub fetch that answers like the happy-path API and records every request. */
14
+ function stubApi() {
15
+ const calls: RecordedCall[] = [];
16
+ vi.stubGlobal('fetch', async (url: unknown, init: RequestInit = {}) => {
17
+ calls.push({ url: String(url), init });
18
+ const u = String(url);
19
+ const body = u.endsWith('/sites')
20
+ ? { id: 'site-uuid', name: 'my-account-site' }
21
+ : u.endsWith('/deploys')
22
+ ? { id: 'deploy-bson', required: ['aaa'] }
23
+ : { state: 'ready' };
24
+ return { ok: true, status: 200, json: async () => body } as Response;
25
+ });
26
+ return calls;
27
+ }
28
+
29
+ const client = (getUploadToken = async (): Promise<string | null> => 'bearer-abc') =>
30
+ new AuthenticatedDropClient({ getUploadToken, pollIntervalMs: 1, timeoutMs: 5 });
31
+
32
+ const content = new TextEncoder().encode('x').buffer as ArrayBuffer;
33
+
34
+ afterEach(() => {
35
+ vi.unstubAllGlobals();
36
+ vi.restoreAllMocks();
37
+ });
38
+
39
+ describe('AuthenticatedDropClient transport', () => {
40
+ it('splits requests: JSON rides the cookie proxy, uploads go direct with the bearer', async () => {
41
+ const calls = stubApi();
42
+ const c = client();
43
+ const token = await c.getToken();
44
+ const deploy = await c.createDeploy({ '/index.html': 'aaa' }, token);
45
+ await c.uploadFile(deploy.deploy_id, '/index.html', content, token);
46
+ await c.waitUntilReady(deploy, token);
47
+
48
+ const [site, deployCreate, upload, poll] = calls;
49
+ expect(site.url).toBe(`${PROXY}/sites`);
50
+ expect(deployCreate.url).toBe(`${PROXY}/sites/site-uuid/deploys`);
51
+ // Readiness and file PUTs key off deploy_id (BSON), never the site UUID.
52
+ expect(upload.url).toBe(`${API}/deploys/deploy-bson/files/index.html`);
53
+ expect(poll.url).toBe(`${PROXY}/deploys/deploy-bson`);
54
+
55
+ for (const jsonCall of [site, deployCreate, poll]) {
56
+ expect(jsonCall.init.credentials).toBe('include');
57
+ expect((jsonCall.init.headers as Record<string, string>)?.Authorization).toBeUndefined();
58
+ }
59
+ expect((upload.init.headers as Record<string, string>).Authorization).toBe('Bearer bearer-abc');
60
+ expect(upload.init.credentials).toBeUndefined();
61
+ });
62
+
63
+ it('creates the site with created_via: drop and the deploy with the digest', async () => {
64
+ const calls = stubApi();
65
+ const c = client();
66
+ await c.createDeploy({ '/index.html': 'aaa' }, await c.getToken());
67
+ expect(JSON.parse(String(calls[0].init.body))).toEqual({ created_via: 'drop' });
68
+ expect(JSON.parse(String(calls[1].init.body))).toEqual({
69
+ files: { '/index.html': 'aaa' },
70
+ deploy_source: 'drop',
71
+ });
72
+ });
73
+
74
+ it('scopes site creation to the account slug when given', async () => {
75
+ const calls = stubApi();
76
+ const c = new AuthenticatedDropClient({
77
+ getUploadToken: async () => 'tok',
78
+ accountSlug: 'my-team',
79
+ });
80
+ await c.createDeploy({}, 'tok');
81
+ expect(calls[0].url).toBe(`${PROXY}/my-team/sites`);
82
+ });
83
+
84
+ it('maps the site + deploy responses into the anonymous DropResponse shape', async () => {
85
+ stubApi();
86
+ const c = client();
87
+ const deploy = await c.createDeploy({ '/index.html': 'aaa' }, 'tok');
88
+ expect(deploy).toEqual({
89
+ id: 'site-uuid',
90
+ deploy_id: 'deploy-bson',
91
+ subdomain: 'my-account-site',
92
+ required: ['aaa'],
93
+ });
94
+ });
95
+ });
96
+
97
+ describe('AuthenticatedDropClient upload-token lifecycle', () => {
98
+ it('reuses the gate-minted token inside the reuse window', async () => {
99
+ stubApi();
100
+ let minted = 0;
101
+ const c = client(async () => `tok-${++minted}`);
102
+ const token = await c.getToken();
103
+ await c.uploadFile('d', '/a', content, token);
104
+ await c.uploadFile('d', '/b', content, token);
105
+ expect(minted).toBe(1);
106
+ });
107
+
108
+ it('re-mints once the cached token nears the server-side 30s expiry', async () => {
109
+ stubApi();
110
+ let minted = 0;
111
+ let now = 1_000_000;
112
+ vi.spyOn(Date, 'now').mockImplementation(() => now);
113
+
114
+ const c = client(async () => `tok-${++minted}`);
115
+ const token = await c.getToken();
116
+ await c.uploadFile('d', '/a', content, token);
117
+ expect(minted).toBe(1);
118
+
119
+ now += 21_000; // past TOKEN_REUSE_MS (20s)
120
+ await c.uploadFile('d', '/b', content, token);
121
+ expect(minted).toBe(2);
122
+
123
+ await c.uploadFile('d', '/c', content, token);
124
+ expect(minted).toBe(2); // the renewed token is reused in turn
125
+ });
126
+ });
127
+
128
+ describe('AuthenticatedDropClient auth boundaries', () => {
129
+ it('throws NotAuthenticatedError at the gate when no token can be minted', async () => {
130
+ await expect(client(async () => null).getToken()).rejects.toBeInstanceOf(NotAuthenticatedError);
131
+ });
132
+
133
+ it('throws NotAuthenticatedError when site creation is refused — pre-mutation, fallback-safe', async () => {
134
+ vi.stubGlobal('fetch', async () => ({ ok: false, status: 401, json: async () => ({}) }));
135
+ await expect(client().createDeploy({}, 'tok')).rejects.toBeInstanceOf(NotAuthenticatedError);
136
+ });
137
+
138
+ it('throws a plain Error — never NotAuthenticatedError — when minting fails mid-upload', async () => {
139
+ // The anti-duplicate invariant: by upload time the site exists, and a
140
+ // NotAuthenticatedError would send the drop back through the anonymous
141
+ // path, stranding an empty project and deploying twice.
142
+ stubApi();
143
+ let minted = 0;
144
+ let now = 1_000_000;
145
+ vi.spyOn(Date, 'now').mockImplementation(() => now);
146
+ const c = client(async () => (++minted === 1 ? 'tok' : null));
147
+ const token = await c.getToken();
148
+ now += 60_000;
149
+ const failure = await c.uploadFile('d', '/a', content, token).catch(e => e);
150
+ expect(failure).toBeInstanceOf(Error);
151
+ expect(failure).not.toBeInstanceOf(NotAuthenticatedError);
152
+ });
153
+
154
+ it('surfaces upload HTTP failures with the status attached', async () => {
155
+ vi.stubGlobal('fetch', async () => ({ ok: false, status: 413, json: async () => ({}) }));
156
+ const c = client();
157
+ const failure = await c.uploadFile('d', '/big', content, 'tok').catch(e => e);
158
+ expect(failure.status).toBe(413);
159
+ });
160
+ });
161
+
162
+ describe('AuthenticatedDropClient polling', () => {
163
+ const pollClient = (states: Array<{ ok: boolean; state?: string; error_message?: string }>) => {
164
+ let call = 0;
165
+ vi.stubGlobal('fetch', async () => {
166
+ const next = states[Math.min(call++, states.length - 1)];
167
+ return { ok: next.ok, status: next.ok ? 200 : 500, json: async () => next } as Response;
168
+ });
169
+ return client();
170
+ };
171
+
172
+ it('resolves when the deploy goes ready, skipping transient bad reads', async () => {
173
+ const c = pollClient([
174
+ { ok: false },
175
+ { ok: true, state: 'processing' },
176
+ { ok: true, state: 'ready' },
177
+ ]);
178
+ await expect(
179
+ c.waitUntilReady({ id: 's', deploy_id: 'd', subdomain: 'x', required: [] }, 'tok')
180
+ ).resolves.toBeUndefined();
181
+ });
182
+
183
+ it('fails with the deploy error message on a terminal error state', async () => {
184
+ const c = pollClient([{ ok: true, state: 'error', error_message: 'no index.html' }]);
185
+ await expect(
186
+ c.waitUntilReady({ id: 's', deploy_id: 'd', subdomain: 'x', required: [] }, 'tok')
187
+ ).rejects.toThrow('deploy failed: no index.html');
188
+ });
189
+
190
+ it('times out with the shared humanizable message', async () => {
191
+ const c = pollClient([{ ok: true, state: 'uploading' }]);
192
+ await expect(
193
+ c.waitUntilReady({ id: 's', deploy_id: 'd', subdomain: 'x', required: [] }, 'tok')
194
+ ).rejects.toThrow('Deploy did not become ready in time');
195
+ });
196
+ });