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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/dist/components/index.d.ts +2 -2
  2. package/dist/components/preact/DropZone/DropZone.d.ts +21 -2
  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 +25 -23
  7. package/dist/components/preact/DropZone/useDropDeploy.d.ts +10 -6
  8. package/dist/components/preact/DropZone/useDropDeploy.js +184 -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 +25 -19
  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 +55 -0
  16. package/dist/drop-core/authedApi.js +35 -0
  17. package/dist/drop-core/authedApi.test.d.ts +1 -0
  18. package/dist/drop-core/authedDropClient.js +109 -0
  19. package/dist/drop-core/authedDropClient.test.d.ts +1 -0
  20. package/dist/{components/preact/DropZone/drop-core → drop-core}/buildClient.d.ts +5 -13
  21. package/dist/drop-core/buildClient.js +84 -0
  22. package/dist/drop-core/buildClient.test.d.ts +1 -0
  23. package/dist/drop-core/buildStash.d.ts +37 -0
  24. package/dist/drop-core/buildStash.js +84 -0
  25. package/dist/drop-core/buildStash.test.d.ts +0 -0
  26. package/dist/{components/preact/DropZone/drop-core → drop-core}/deploy.d.ts +7 -0
  27. package/dist/drop-core/deploy.js +53 -0
  28. package/dist/drop-core/deploy.test.d.ts +1 -0
  29. package/dist/drop-core/detectBuild.test.d.ts +1 -0
  30. package/dist/drop-core/digest.test.d.ts +1 -0
  31. package/dist/{components/preact/DropZone/drop-core → drop-core}/errors.js +1 -1
  32. package/dist/drop-core/errors.test.d.ts +1 -0
  33. package/dist/drop-core/fileTypes.test.d.ts +1 -0
  34. package/dist/{components/preact/DropZone/drop-core → drop-core}/index.d.ts +3 -0
  35. package/dist/drop-core/index.js +51 -0
  36. package/dist/{components/preact/DropZone/drop-core → drop-core}/readFiles.js +2 -2
  37. package/dist/drop-core/readFiles.test.d.ts +1 -0
  38. package/dist/{components/preact/DropZone/drop-core → drop-core}/types.d.ts +10 -0
  39. package/dist/{components/preact/DropZone/drop-core → drop-core}/zip.js +1 -1
  40. package/dist/drop-core/zip.test.d.ts +1 -0
  41. package/package.json +4 -1
  42. package/packages/components/index.ts +19 -0
  43. package/packages/components/preact/DropZone/DropZone.test.ts +71 -0
  44. package/packages/components/preact/DropZone/DropZone.tsx +23 -2
  45. package/packages/components/preact/DropZone/index.tsx +14 -3
  46. package/packages/components/preact/DropZone/useDropDeploy.test.ts +376 -0
  47. package/packages/components/preact/DropZone/useDropDeploy.ts +297 -132
  48. package/packages/components/preact/index.ts +19 -2
  49. package/packages/drop-core/analytics.test.ts +17 -0
  50. package/packages/drop-core/analytics.ts +122 -0
  51. package/packages/drop-core/authedApi.test.ts +100 -0
  52. package/packages/drop-core/authedApi.ts +94 -0
  53. package/packages/drop-core/authedDropClient.test.ts +196 -0
  54. package/packages/{components/preact/DropZone/drop-core → drop-core}/authedDropClient.ts +30 -51
  55. package/packages/drop-core/buildClient.test.ts +112 -0
  56. package/packages/{components/preact/DropZone/drop-core → drop-core}/buildClient.ts +28 -57
  57. package/packages/drop-core/buildStash.test.ts +85 -0
  58. package/packages/drop-core/buildStash.ts +185 -0
  59. package/packages/drop-core/deploy.test.ts +135 -0
  60. package/packages/{components/preact/DropZone/drop-core → drop-core}/deploy.ts +18 -4
  61. package/packages/drop-core/detectBuild.test.ts +139 -0
  62. package/packages/drop-core/digest.test.ts +24 -0
  63. package/packages/drop-core/errors.test.ts +69 -0
  64. package/packages/{components/preact/DropZone/drop-core → drop-core}/errors.ts +1 -1
  65. package/packages/drop-core/fileTypes.test.ts +37 -0
  66. package/packages/{components/preact/DropZone/drop-core → drop-core}/index.ts +3 -0
  67. package/packages/drop-core/readFiles.test.ts +125 -0
  68. package/packages/{components/preact/DropZone/drop-core → drop-core}/types.ts +12 -6
  69. package/packages/drop-core/zip.test.ts +92 -0
  70. package/dist/components/preact/DropZone/drop-core/authedDropClient.js +0 -123
  71. package/dist/components/preact/DropZone/drop-core/buildClient.js +0 -99
  72. package/dist/components/preact/DropZone/drop-core/deploy.js +0 -48
  73. package/dist/components/preact/DropZone/drop-core/index.js +0 -36
  74. /package/dist/{components/preact/DropZone/drop-core → drop-core}/authedDropClient.d.ts +0 -0
  75. /package/dist/{components/preact/DropZone/drop-core → drop-core}/client.d.ts +0 -0
  76. /package/dist/{components/preact/DropZone/drop-core → drop-core}/client.js +0 -0
  77. /package/dist/{components/preact/DropZone/drop-core → drop-core}/detectBuild.d.ts +0 -0
  78. /package/dist/{components/preact/DropZone/drop-core → drop-core}/detectBuild.js +0 -0
  79. /package/dist/{components/preact/DropZone/drop-core → drop-core}/digest.d.ts +0 -0
  80. /package/dist/{components/preact/DropZone/drop-core → drop-core}/digest.js +0 -0
  81. /package/dist/{components/preact/DropZone/drop-core → drop-core}/errors.d.ts +0 -0
  82. /package/dist/{components/preact/DropZone/drop-core → drop-core}/fileTypes.d.ts +0 -0
  83. /package/dist/{components/preact/DropZone/drop-core → drop-core}/fileTypes.js +0 -0
  84. /package/dist/{components/preact/DropZone/drop-core → drop-core}/readFiles.d.ts +0 -0
  85. /package/dist/{components/preact/DropZone/drop-core → drop-core}/types.js +0 -0
  86. /package/dist/{components/preact/DropZone/drop-core → drop-core}/zip.d.ts +0 -0
  87. /package/packages/{components/preact/DropZone/drop-core → drop-core}/client.ts +0 -0
  88. /package/packages/{components/preact/DropZone/drop-core → drop-core}/detectBuild.ts +0 -0
  89. /package/packages/{components/preact/DropZone/drop-core → drop-core}/digest.ts +0 -0
  90. /package/packages/{components/preact/DropZone/drop-core → drop-core}/fileTypes.ts +0 -0
  91. /package/packages/{components/preact/DropZone/drop-core → drop-core}/readFiles.ts +0 -0
  92. /package/packages/{components/preact/DropZone/drop-core → drop-core}/zip.ts +0 -0
@@ -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,94 @@
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
+ * Default root for proxied JSON calls. On netlify.com the `/access-control/*`
14
+ * rewrite targets the app's access-control function, making this path the API
15
+ * root. The paths a given origin may call are allowlisted server-side.
16
+ */
17
+ export const DEFAULT_PROXY_BASE = '/access-control/bb-api/api/v1';
18
+
19
+ /** Direct api.netlify.com root for the uploads the proxy can't carry. */
20
+ export const DEFAULT_API_BASE = 'https://api.netlify.com/api/v1';
21
+
22
+ export const DEFAULT_POLL_INTERVAL_MS = 1000;
23
+ export const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
24
+
25
+ /**
26
+ * Create a site in the visitor's account via `POST {proxyBase}/sites` (scoped
27
+ * to `accountSlug` when given, else the user's default account), attributed
28
+ * `created_via: 'drop'`.
29
+ *
30
+ * A logged-out visitor 401s here, before anything is created — the one
31
+ * pre-mutation point where callers may still safely fall back to an anonymous
32
+ * flow, which is why it maps to `NotAuthenticatedError`. Any other failure is
33
+ * a `SiteCreateError` carrying the status.
34
+ */
35
+ export async function createSiteInAccount(
36
+ proxyBase: string,
37
+ accountSlug?: string
38
+ ): Promise<BuildSite> {
39
+ const path = accountSlug ? `/${accountSlug}/sites` : '/sites';
40
+ const res = await fetch(`${proxyBase}${path}`, {
41
+ method: 'POST',
42
+ credentials: 'include',
43
+ headers: { 'Content-Type': 'application/json' },
44
+ body: JSON.stringify({ created_via: 'drop' }),
45
+ });
46
+ if (res.status === 401 || res.status === 403) throw new NotAuthenticatedError();
47
+ if (!res.ok) throw new SiteCreateError(res.status);
48
+ return res.json();
49
+ }
50
+
51
+ /** How a watched deploy ended. `errorMessage` is the API's own explanation, when it gave one. */
52
+ export type SettledDeploy =
53
+ { outcome: 'ready' } | { outcome: 'failed'; errorMessage?: string } | { outcome: 'timed-out' };
54
+
55
+ /** Deploy states that end the wait — `ready` succeeds, the rest are failures. */
56
+ const TERMINAL_STATES = new Set(['ready', 'error', 'rejected']);
57
+
58
+ interface DeployState {
59
+ state?: string;
60
+ error_message?: string;
61
+ }
62
+
63
+ /**
64
+ * Poll `GET {proxyBase}/deploys/{deployId}` on the session cookie until the
65
+ * deploy settles, and report how. Purely mechanical on purpose: each caller
66
+ * maps the outcome onto its own error vocabulary (the drop client throws plain
67
+ * errors `humanizeDropError` understands; the build client throws
68
+ * `BuildFailedError`/`BuildTimeoutError`), so that knowledge stays next to the
69
+ * class it belongs to.
70
+ *
71
+ * A non-ok poll response is skipped, not fatal — a transient proxy hiccup
72
+ * shouldn't kill a deploy that is still progressing.
73
+ */
74
+ export async function pollDeployUntilSettled(options: {
75
+ proxyBase: string;
76
+ deployId: string;
77
+ pollIntervalMs: number;
78
+ timeoutMs: number;
79
+ }): Promise<SettledDeploy> {
80
+ const { proxyBase, deployId, pollIntervalMs, timeoutMs } = options;
81
+ const deadline = timeoutMs / pollIntervalMs;
82
+ for (let i = 0; i < deadline; i++) {
83
+ const res = await fetch(`${proxyBase}/deploys/${deployId}`, { credentials: 'include' });
84
+ if (res.ok) {
85
+ const deploy: DeployState = await res.json();
86
+ if (deploy.state && TERMINAL_STATES.has(deploy.state)) {
87
+ if (deploy.state === 'ready') return { outcome: 'ready' };
88
+ return { outcome: 'failed', errorMessage: deploy.error_message };
89
+ }
90
+ }
91
+ await new Promise(r => setTimeout(r, pollIntervalMs));
92
+ }
93
+ return { outcome: 'timed-out' };
94
+ }
@@ -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
+ });
@@ -1,7 +1,14 @@
1
+ import {
2
+ createSiteInAccount,
3
+ DEFAULT_API_BASE,
4
+ DEFAULT_POLL_INTERVAL_MS,
5
+ DEFAULT_PROXY_BASE,
6
+ DEFAULT_TIMEOUT_MS,
7
+ pollDeployUntilSettled,
8
+ } from './authedApi';
1
9
  import { apiError, type DropClient } from './client';
2
- import type { BuildSite } from './buildClient';
3
- import type { Digest, DropResponse } from './types';
4
- import { NotAuthenticatedError, SiteCreateError } from './errors';
10
+ import { NotAuthenticatedError } from './errors';
11
+ import type { BuildSite, Digest, DropResponse } from './types';
5
12
 
6
13
  export interface AuthenticatedDropClientConfig {
7
14
  /**
@@ -35,15 +42,6 @@ export interface AuthenticatedDropClientConfig {
35
42
  timeoutMs?: number;
36
43
  }
37
44
 
38
- // Same defaults as AuthenticatedBuildClient — the ~15 lines shared with it
39
- // (constants + site create) are deliberately duplicated rather than extracted,
40
- // to avoid churn on the in-review build-client branch. Worth unifying once both
41
- // have landed.
42
- const PROXY_BASE = '/access-control/bb-api/api/v1';
43
- const API_BASE = 'https://api.netlify.com/api/v1';
44
- const POLL_INTERVAL_MS = 1000;
45
- const TIMEOUT_MS = 10 * 60 * 1000;
46
-
47
45
  /**
48
46
  * How long a minted upload token is reused before minting another. The server
49
47
  * issues non-app origins a 30s token, so this leaves a margin for the request
@@ -51,14 +49,6 @@ const TIMEOUT_MS = 10 * 60 * 1000;
51
49
  */
52
50
  const TOKEN_REUSE_MS = 20 * 1000;
53
51
 
54
- /** Deploy states that end the wait — `ready` succeeds, the rest are failures. */
55
- const TERMINAL_STATES = new Set(['ready', 'error', 'rejected']);
56
-
57
- interface DeployState {
58
- state?: string;
59
- error_message?: string;
60
- }
61
-
62
52
  /** Response body of `POST /sites/{id}/deploys` — the slice the drop flow needs. */
63
53
  interface SiteDeployResponse {
64
54
  /** deploy id (BSON) */
@@ -98,12 +88,12 @@ export class AuthenticatedDropClient implements DropClient {
98
88
  private cachedToken?: { value: string; mintedAt: number };
99
89
 
100
90
  constructor({
101
- proxyBase = PROXY_BASE,
102
- apiBase = API_BASE,
91
+ proxyBase = DEFAULT_PROXY_BASE,
92
+ apiBase = DEFAULT_API_BASE,
103
93
  getUploadToken,
104
94
  accountSlug,
105
- pollIntervalMs = POLL_INTERVAL_MS,
106
- timeoutMs = TIMEOUT_MS,
95
+ pollIntervalMs = DEFAULT_POLL_INTERVAL_MS,
96
+ timeoutMs = DEFAULT_TIMEOUT_MS,
107
97
  }: AuthenticatedDropClientConfig) {
108
98
  this.proxyBase = proxyBase;
109
99
  this.apiBase = apiBase;
@@ -153,18 +143,9 @@ export class AuthenticatedDropClient implements DropClient {
153
143
  * `deployFiles` runs unchanged over either client.
154
144
  */
155
145
  async createDeploy(files: Digest, _token: string): Promise<DropResponse> {
156
- const path = this.accountSlug ? `/${this.accountSlug}/sites` : '/sites';
157
- const siteRes = await fetch(`${this.proxyBase}${path}`, {
158
- method: 'POST',
159
- credentials: 'include',
160
- headers: { 'Content-Type': 'application/json' },
161
- body: JSON.stringify({ created_via: 'drop' }),
162
- });
163
- // A logged-out visitor 401s here, before anything is created — the one
164
- // point an anonymous fallback is still safe (see the class doc).
165
- if (siteRes.status === 401 || siteRes.status === 403) throw new NotAuthenticatedError();
166
- if (!siteRes.ok) throw new SiteCreateError(siteRes.status);
167
- const site: BuildSite = await siteRes.json();
146
+ // A logged-out visitor fails inside createSiteInAccount, before anything is
147
+ // created — the one point an anonymous fallback is still safe (class doc).
148
+ const site: BuildSite = await createSiteInAccount(this.proxyBase, this.accountSlug);
168
149
 
169
150
  const deployRes = await fetch(`${this.proxyBase}/sites/${site.id}/deploys`, {
170
151
  method: 'POST',
@@ -209,21 +190,19 @@ export class AuthenticatedDropClient implements DropClient {
209
190
  * than a bearer, so a deploy taking minutes can't outlive its own credentials.
210
191
  */
211
192
  async waitUntilReady(deploy: DropResponse, _token: string): Promise<void> {
212
- const deadline = this.timeoutMs / this.pollIntervalMs;
213
- for (let i = 0; i < deadline; i++) {
214
- const res = await fetch(`${this.proxyBase}/deploys/${deploy.deploy_id}`, {
215
- credentials: 'include',
216
- });
217
- if (res.ok) {
218
- const state: DeployState = await res.json();
219
- if (state.state && TERMINAL_STATES.has(state.state)) {
220
- if (state.state === 'ready') return;
221
- throw new Error(
222
- state.error_message ? `deploy failed: ${state.error_message}` : 'deploy failed'
223
- );
224
- }
225
- }
226
- await new Promise(r => setTimeout(r, this.pollIntervalMs));
193
+ const settled = await pollDeployUntilSettled({
194
+ proxyBase: this.proxyBase,
195
+ deployId: deploy.deploy_id,
196
+ pollIntervalMs: this.pollIntervalMs,
197
+ timeoutMs: this.timeoutMs,
198
+ });
199
+ if (settled.outcome === 'ready') return;
200
+ // Plain errors on purpose: humanizeDropError maps them, and nothing here
201
+ // may look like NotAuthenticatedError once the site exists (class doc).
202
+ if (settled.outcome === 'failed') {
203
+ throw new Error(
204
+ settled.errorMessage ? `deploy failed: ${settled.errorMessage}` : 'deploy failed'
205
+ );
227
206
  }
228
207
  // Same message the anonymous client uses, so humanizeDropError maps it.
229
208
  throw new Error('Deploy did not become ready in time');