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

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.
@@ -0,0 +1,29 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { AnonymousDropClient } from './client';
3
+ import { DeployFailedError } from './errors';
4
+ import type { DropResponse } from './types';
5
+
6
+ const deploy: DropResponse = { id: 'site', deploy_id: 'd', subdomain: 'x', required: [] };
7
+ const client = () => new AnonymousDropClient('https://api', 1, 50);
8
+
9
+ afterEach(() => vi.unstubAllGlobals());
10
+
11
+ describe('AnonymousDropClient.waitUntilReady', () => {
12
+ it('resolves once the deploy is ready', async () => {
13
+ vi.stubGlobal('fetch', async () => ({ ok: true, json: async () => ({ state: 'ready' }) }));
14
+ await expect(client().waitUntilReady(deploy, 'tok')).resolves.toBeUndefined();
15
+ });
16
+
17
+ // Regression: a failed anonymous deploy used to poll to the full timeout.
18
+ it('fails fast on a failed deploy instead of timing out', async () => {
19
+ vi.stubGlobal('fetch', async () => ({
20
+ ok: true,
21
+ json: async () => ({ state: 'error', error_message: 'nope' }),
22
+ }));
23
+ const err = await client()
24
+ .waitUntilReady(deploy, 'tok')
25
+ .catch(e => e);
26
+ expect(err).toBeInstanceOf(DeployFailedError);
27
+ expect(err.reason).toBe('nope');
28
+ });
29
+ });
@@ -3,7 +3,8 @@
3
3
  * interface, so `deployFiles` runs the same pipeline anonymous or authenticated.
4
4
  */
5
5
  import { DEFAULT_API_BASE, DEFAULT_POLL_INTERVAL_MS, DEFAULT_TIMEOUT_MS } from './constants';
6
- import { DeployTimeoutError } from './errors';
6
+ import { DeployFailedError, DeployTimeoutError } from './errors';
7
+ import { pollUntilSettled } from './pollDeploy';
7
8
  import type { DropResponse, Digest } from './types';
8
9
 
9
10
  /** A normal Error with the response's HTTP status attached, so callers can branch on it directly. */
@@ -61,17 +62,24 @@ export class AnonymousDropClient implements DropClient {
61
62
  }
62
63
 
63
64
  async waitUntilReady(deploy: DropResponse, token: string): Promise<void> {
64
- // `/deploys/{id}` expects the deploy_id (BSON), not the site id (UUID) that
65
- // lives at `deploy.id`. See drop_controller.rb; Deploy.find(uuid) is nil and
66
- // the request 401s misleadingly via require_current_actor!.
67
- const deadline = this.timeoutMs / this.pollIntervalMs;
68
- for (let i = 0; i < deadline; i++) {
69
- const res = await fetch(`${this.apiBase}/deploys/${deploy.deploy_id}`, {
70
- headers: { Authorization: `Bearer ${token}` },
71
- });
72
- if (res.ok && (await res.json()).state === 'ready') return;
73
- await new Promise(r => setTimeout(r, this.pollIntervalMs));
74
- }
65
+ const settled = await pollUntilSettled({
66
+ deployId: deploy.deploy_id,
67
+ pollIntervalMs: this.pollIntervalMs,
68
+ timeoutMs: this.timeoutMs,
69
+ // `/deploys/{id}` expects the deploy_id (BSON), not the site id (UUID) that
70
+ // lives at `deploy.id`. See drop_controller.rb; Deploy.find(uuid) is nil and
71
+ // the request 401s misleadingly via require_current_actor!.
72
+ readState: async id => {
73
+ const res = await fetch(`${this.apiBase}/deploys/${id}`, {
74
+ headers: { Authorization: `Bearer ${token}` },
75
+ });
76
+ return res.ok ? res.json() : null;
77
+ },
78
+ });
79
+ if (settled.outcome === 'ready') return;
80
+ // A failed deploy now surfaces immediately instead of waiting out the full
81
+ // timeout, matching the authenticated path.
82
+ if (settled.outcome === 'failed') throw new DeployFailedError(settled.errorMessage);
75
83
  throw new DeployTimeoutError();
76
84
  }
77
85
  }
@@ -13,7 +13,7 @@ export const DEFAULT_POLL_INTERVAL_MS = 1000;
13
13
  // How long to keep polling before giving up: 10 minutes
14
14
  export const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
15
15
 
16
- // Concurrent file uploads (matches netlify-cli's deploy engine)
16
+ // Concurrent file uploads
17
17
  export const MAX_CONCURRENT_UPLOADS = 5;
18
18
 
19
19
  // Reuse window for a minted upload token: 20s (server TTL is 30s; the gap is
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { pollUntilSettled } from './pollDeploy';
3
+
4
+ const base = { deployId: 'd', pollIntervalMs: 1, timeoutMs: 50 };
5
+
6
+ describe('pollUntilSettled', () => {
7
+ it('resolves ready when the deploy reaches ready', async () => {
8
+ const settled = await pollUntilSettled({ ...base, readState: async () => ({ state: 'ready' }) });
9
+ expect(settled).toEqual({ outcome: 'ready' });
10
+ });
11
+
12
+ it('reports failure with the API message on error/rejected', async () => {
13
+ for (const state of ['error', 'rejected']) {
14
+ const settled = await pollUntilSettled({
15
+ ...base,
16
+ readState: async () => ({ state, error_message: 'boom' }),
17
+ });
18
+ expect(settled).toEqual({ outcome: 'failed', errorMessage: 'boom' });
19
+ }
20
+ });
21
+
22
+ it('times out when the deploy never settles (does not hang on failure states either)', async () => {
23
+ const settled = await pollUntilSettled({
24
+ ...base,
25
+ timeoutMs: 3,
26
+ readState: async () => ({ state: 'building' }),
27
+ });
28
+ expect(settled).toEqual({ outcome: 'timed-out' });
29
+ });
30
+
31
+ it('skips a null read without settling, then resolves', async () => {
32
+ let calls = 0;
33
+ const settled = await pollUntilSettled({
34
+ ...base,
35
+ readState: async () => (++calls < 3 ? null : { state: 'ready' }),
36
+ });
37
+ expect(settled).toEqual({ outcome: 'ready' });
38
+ expect(calls).toBeGreaterThanOrEqual(3);
39
+ });
40
+
41
+ it('waits through non-terminal states then settles on the terminal one', async () => {
42
+ const states = ['uploading', 'processing', 'ready'];
43
+ let i = 0;
44
+ const settled = await pollUntilSettled({
45
+ ...base,
46
+ readState: async () => ({ state: states[Math.min(i++, states.length - 1)] }),
47
+ });
48
+ expect(settled).toEqual({ outcome: 'ready' });
49
+ });
50
+ });
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The single deploy poller both drop clients share. The loop and its
3
+ * terminal-state policy live here once; transport (cookie proxy vs bearer) is
4
+ * injected via `readState`, so the anonymous and authenticated paths can't
5
+ * drift on what "done" means.
6
+ */
7
+
8
+ /** How a watched deploy ended. `errorMessage` is the API's own explanation, when it gave one. */
9
+ export type SettledDeploy =
10
+ | { outcome: 'ready' }
11
+ | { outcome: 'failed'; errorMessage?: string }
12
+ | { outcome: 'timed-out' };
13
+
14
+ /** The slice of a deploy record the poller reads; `null` = a read that failed and should be skipped. */
15
+ export interface DeployState {
16
+ state?: string;
17
+ error_message?: string;
18
+ }
19
+
20
+ /** `ready` succeeds; `error`/`rejected` are failures; anything else keeps waiting. */
21
+ const TERMINAL_STATES = new Set(['ready', 'error', 'rejected']);
22
+
23
+ /**
24
+ * Poll until the deploy reaches a terminal state or the timeout elapses.
25
+ * A `null` read is skipped, not fatal — a transient hiccup shouldn't kill a
26
+ * progressing deploy. Callers map the outcome onto their own error vocabulary.
27
+ */
28
+ export async function pollUntilSettled(options: {
29
+ deployId: string;
30
+ pollIntervalMs: number;
31
+ timeoutMs: number;
32
+ readState: (deployId: string) => Promise<DeployState | null>;
33
+ }): Promise<SettledDeploy> {
34
+ const { deployId, pollIntervalMs, timeoutMs, readState } = options;
35
+ const deadline = timeoutMs / pollIntervalMs;
36
+ for (let i = 0; i < deadline; i++) {
37
+ const deploy = await readState(deployId);
38
+ if (deploy?.state && TERMINAL_STATES.has(deploy.state)) {
39
+ return deploy.state === 'ready'
40
+ ? { outcome: 'ready' }
41
+ : { outcome: 'failed', errorMessage: deploy.error_message };
42
+ }
43
+ await new Promise(r => setTimeout(r, pollIntervalMs));
44
+ }
45
+ return { outcome: 'timed-out' };
46
+ }