diff --git a/packages/cli/src/commands/deploy.ts b/packages/cli/src/commands/deploy.ts index 61f4f0c27..53277c613 100644 --- a/packages/cli/src/commands/deploy.ts +++ b/packages/cli/src/commands/deploy.ts @@ -15,7 +15,8 @@ import chalk from 'chalk' import { splitConfigFilePath, getGitInformation } from '../services/util.js' import commonMessages from '../messages/common-messages.js' import { forceFlag } from '../helpers/flags.js' -import { ProjectDeployResponse } from '../rest/projects.js' +import { ProjectDeployResponse, ProjectDeployCancelledError } from '../rest/projects.js' +import { ConflictError } from '../rest/errors.js' import { uploadSnapshots } from '../services/snapshot-service.js' import { BrowserCheckBundle } from '../constructs/browser-check-bundle.js' import { Runtime } from '../runtimes/index.js' @@ -56,6 +57,10 @@ export default class Deploy extends AuthCommand { allowNo: true, }), 'force': forceFlag(), + 'cancel-in-progress-deployment': Flags.boolean({ + description: 'If a deployment for this project is already in progress, cancel it instead of waiting for it to finish.', + default: false, + }), 'config': Flags.string({ char: 'c', description: commonMessages.configFile, @@ -83,6 +88,7 @@ export default class Deploy extends AuthCommand { const { force, preview, + 'cancel-in-progress-deployment': cancelInProgress, 'schedule-on-deploy': scheduleOnDeploy, output: outputFlag, verbose, @@ -239,7 +245,22 @@ export default class Deploy extends AuthCommand { } try { - const { data } = await api.projects.deploy({ ...projectPayload, repoInfo }, { dryRun: preview, scheduleOnDeploy }) + if (!preview) { + this.style.actionStart('Deploying project') + } + const { data } = await api.projects.deploy( + { ...projectPayload, repoInfo }, + { + dryRun: preview, + scheduleOnDeploy, + cancelInProgress, + onProgress: preview ? undefined : progress => this.style.actionStatus(`${progress}% complete`), + onStatus: preview ? undefined : message => this.style.actionStatus(message), + }, + ) + if (!preview) { + this.style.actionSuccess() + } if (preview || output) { this.log(this.formatPreview(data, project, verbose)) } @@ -258,7 +279,22 @@ export default class Deploy extends AuthCommand { }) } } catch (err: any) { - this.style.longError(`Your project could not be deployed.`, err) + if (!preview) { + this.style.actionFailure() + } + if (err instanceof ProjectDeployCancelledError) { + this.style.longError('Your deployment was cancelled.', err.message) + } else if (err instanceof ConflictError) { + // deploy() waits-and-retries behind an in-progress deployment, so a 409 + // only reaches here once that wait exceeded its deadline. + this.style.longError( + 'A deployment for this project is still in progress.', + 'Try again later, or re-run with `--cancel-in-progress-deployment` to ' + + 'cancel the running deployment and deploy now.', + ) + } else { + this.style.longError(`Your project could not be deployed.`, err) + } this.exit(1) } } diff --git a/packages/cli/src/rest/__tests__/projects.spec.ts b/packages/cli/src/rest/__tests__/projects.spec.ts new file mode 100644 index 000000000..6297240e5 --- /dev/null +++ b/packages/cli/src/rest/__tests__/projects.spec.ts @@ -0,0 +1,343 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { Readable } from 'node:stream' +import type { AxiosInstance } from 'axios' +import Projects, { ProjectDeployCancelledError, ProjectDeployFailedError, type ProjectSync } from '../projects.js' +import { ConflictError, NotFoundError, RequestTimeoutError } from '../errors.js' + +function makeAxiosMock (): AxiosInstance { + return { + get: vi.fn(), + post: vi.fn(), + } as unknown as AxiosInstance +} + +const sync: ProjectSync = { + project: { name: 'My Project', logicalId: 'my-project' }, + resources: [], + repoInfo: null, +} + +// Build an SSE frame and a readable stream that emits the given frames then ends. +const sse = (event: string, data: unknown) => `event: ${event}\ndata: ${JSON.stringify(data)}\n\n` +const sseStream = (...frames: string[]) => ({ data: Readable.from(frames) }) +// A stream that drops with a socket error before any terminal frame. +const erroringStream = () => { + const stream = new Readable({ read () {} }) + setImmediate(() => stream.destroy(new Error('ECONNRESET'))) + return { data: stream } +} + +const applied = { project: sync.project, diff: [{ logicalId: 'check-1', type: 'check', action: 'CREATE' }] } + +describe('Projects.deploy', () => { + let api: AxiosInstance + let projects: Projects + + beforeEach(() => { + api = makeAxiosMock() + projects = new Projects(api) + }) + + it('returns the preview diff synchronously for a dry run (no stream)', async () => { + const preview = { project: sync.project, diff: [{ logicalId: 'c1', type: 'check', action: 'CREATE' }] } + vi.mocked(api.post).mockResolvedValue({ data: preview }) + + const { data } = await projects.deploy(sync, { dryRun: true }) + + expect(api.post).toHaveBeenCalledWith( + '/v1/projects/deploy?dryRun=true&scheduleOnDeploy=true', + sync, + expect.objectContaining({ transformRequest: expect.any(Function) }), + ) + expect(api.get).not.toHaveBeenCalled() + expect(data).toEqual(preview) + }) + + it('submits async, follows the SSE stream, reports progress, and returns the applied diff', async () => { + vi.mocked(api.post).mockResolvedValue({ data: { id: 'dep-1', status: 'PENDING' } }) + vi.mocked(api.get).mockResolvedValue( + sseStream( + sse('progress', { status: 'RUNNING', progress: 40 }), + sse('complete', { id: 'dep-1', status: 'SUCCEEDED', progress: 100, result: applied, error: null }), + ), + ) + + const onProgress = vi.fn() + const { data } = await projects.deploy(sync, { dryRun: false, onProgress }) + + expect(api.get).toHaveBeenCalledWith( + '/v1/projects/my-project/deployments/dep-1/events', + expect.objectContaining({ responseType: 'stream', headers: { Accept: 'text/event-stream' } }), + ) + expect(onProgress).toHaveBeenCalledWith(40) + expect(data).toEqual(applied) + }) + + it('throws ProjectDeployFailedError when the terminal event is not SUCCEEDED', async () => { + vi.mocked(api.post).mockResolvedValue({ data: { id: 'dep-1', status: 'PENDING' } }) + // A fresh stream per call (the assertions below deploy twice). + vi.mocked(api.get).mockImplementation(() => + Promise.resolve( + sseStream( + sse('complete', { + id: 'dep-1', + status: 'FAILED', + result: null, + error: { code: 'PLAN_LIMITS_EXCEEDED', message: 'Too many checks.' }, + }), + ), + ), + ) + + await expect(projects.deploy(sync, { dryRun: false })).rejects.toThrow(ProjectDeployFailedError) + await expect(projects.deploy(sync, { dryRun: false })).rejects.toThrow('Too many checks.') + }) + + it('throws ProjectDeployCancelledError when the deployment is cancelled', async () => { + vi.mocked(api.post).mockResolvedValue({ data: { id: 'dep-1', status: 'PENDING' } }) + vi.mocked(api.get).mockResolvedValue( + sseStream(sse('complete', { id: 'dep-1', status: 'CANCELLED', result: null, error: null })), + ) + + await expect(projects.deploy(sync, { dryRun: false })).rejects.toThrow(ProjectDeployCancelledError) + }) + + it('throws when the stream emits an error event', async () => { + vi.mocked(api.post).mockResolvedValue({ data: { id: 'dep-1', status: 'PENDING' } }) + vi.mocked(api.get).mockResolvedValue(sseStream(sse('error', { message: 'stream blew up' }))) + + await expect(projects.deploy(sync, { dryRun: false })).rejects.toThrow('stream blew up') + }) + + it('reconnects when the stream ends before a terminal event', async () => { + vi.mocked(api.post).mockResolvedValue({ data: { id: 'dep-1', status: 'PENDING' } }) + vi.mocked(api.get) + .mockResolvedValueOnce(sseStream(sse('progress', { progress: 10 }))) // ends, no terminal + .mockResolvedValueOnce(sseStream(sse('complete', { status: 'SUCCEEDED', result: applied }))) + + const { data } = await projects.deploy(sync, { dryRun: false }) + + expect(api.get).toHaveBeenCalledTimes(2) + expect(data).toEqual(applied) + }) + + it('reconnects after a socket error before a terminal event', async () => { + vi.mocked(api.post).mockResolvedValue({ data: { id: 'dep-1', status: 'PENDING' } }) + vi.mocked(api.get) + .mockImplementationOnce(() => Promise.resolve(erroringStream()) as never) + .mockImplementationOnce(() => Promise.resolve(sseStream(sse('complete', { status: 'SUCCEEDED', result: applied }))) as never) + + const { data } = await projects.deploy(sync, { dryRun: false }) + + expect(api.get).toHaveBeenCalledTimes(2) + expect(data).toEqual(applied) + }) + + it('propagates a typed connect error without reconnecting', async () => { + vi.mocked(api.post).mockResolvedValue({ data: { id: 'dep-1', status: 'PENDING' } }) + vi.mocked(api.get).mockRejectedValue( + new NotFoundError({ statusCode: 404, error: 'Not Found', message: 'No such project deployment.' }), + ) + + await expect(projects.deploy(sync, { dryRun: false })).rejects.toThrow(NotFoundError) + expect(api.get).toHaveBeenCalledTimes(1) + }) + + it('gives up after exhausting reconnects', async () => { + // Fresh stream per (re)connect; never emits a terminal event. + vi.mocked(api.get).mockImplementation(() => Promise.resolve(sseStream(sse('progress', { progress: 10 })))) + + await expect(projects.streamDeploymentEvents('my-project', 'dep-1', { maxReconnects: 2 })).rejects.toThrow() + expect(api.get).toHaveBeenCalledTimes(3) // initial + 2 reconnects + }) + + it('propagates a non-stream error from the initial connect', async () => { + vi.mocked(api.post).mockResolvedValue({ data: { id: 'dep-1', status: 'PENDING' } }) + vi.mocked(api.get).mockRejectedValue(new Error('network down')) + + await expect(projects.deploy(sync, { dryRun: false })).rejects.toThrow('network down') + }) +}) + +const conflict = (deploymentId: string) => + new ConflictError({ + statusCode: 409, + error: 'Conflict', + message: 'A deployment for this project is already in progress.', + deploymentId, + }) + +describe('Projects.deploy cancel-in-progress', () => { + let api: AxiosInstance + let projects: Projects + + beforeEach(() => { + api = makeAxiosMock() + projects = new Projects(api) + }) + + it('waits for the in-progress deployment then re-POSTs to success (no flag, no cancel)', async () => { + let deployPosts = 0 + vi.mocked(api.post).mockImplementation((url: string) => { + // Without the flag we must never cancel the predecessor. + expect(url).not.toContain('/cancel') + deployPosts += 1 + return (deployPosts === 1 + ? Promise.reject(conflict('old-dep')) + : Promise.resolve({ data: { id: 'new-dep', status: 'PENDING' } })) as never + }) + vi.mocked(api.get).mockImplementation((url: string) => + (url.includes('/completion') + ? Promise.resolve({ data: { id: 'old-dep', status: 'SUCCEEDED' } }) + : Promise.resolve(sseStream(sse('complete', { id: 'new-dep', status: 'SUCCEEDED', result: applied })))) as never, + ) + + const onStatus = vi.fn() + const { data } = await projects.deploy(sync, { onStatus }) + + // Waited on the predecessor's completion, then re-POSTed. + expect(api.get).toHaveBeenCalledWith( + '/v1/projects/my-project/deployments/old-dep/completion', + expect.objectContaining({ params: { maxWaitSeconds: 30 } }), + ) + const cancelCalls = vi.mocked(api.post).mock.calls.filter(([url]) => String(url).includes('/cancel')) + expect(cancelCalls).toHaveLength(0) + expect(deployPosts).toBe(2) + expect(data).toEqual(applied) + expect(onStatus).toHaveBeenCalled() + }) + + it('does not re-POST while the predecessor is still running; re-POSTs once it is final', async () => { + let deployPosts = 0 + let completionPolls = 0 + vi.mocked(api.post).mockImplementation(() => { + deployPosts += 1 + return (deployPosts === 1 + ? Promise.reject(conflict('old-dep')) + : Promise.resolve({ data: { id: 'new-dep', status: 'PENDING' } })) as never + }) + vi.mocked(api.get).mockImplementation((url: string) => { + if (url.includes('/completion')) { + completionPolls += 1 + // Still running for the first two long-poll windows, then final. + return (completionPolls < 3 + ? Promise.reject( + new RequestTimeoutError({ statusCode: 408, error: 'Request Timeout', message: 'still running' }), + ) + : Promise.resolve({ data: { id: 'old-dep', status: 'SUCCEEDED' } })) as never + } + return Promise.resolve(sseStream(sse('complete', { id: 'new-dep', status: 'SUCCEEDED', result: applied }))) as never + }) + + const { data } = await projects.deploy(sync) + + // Polled three times (two 408s + final) but the payload was POSTed only twice: + // the initial collision and a single re-POST after the predecessor was final. + expect(completionPolls).toBe(3) + expect(deployPosts).toBe(2) + expect(data).toEqual(applied) + }) + + it('cancels the in-flight deployment, waits, and retries when cancelInProgress is set', async () => { + let deployPosts = 0 + vi.mocked(api.post).mockImplementation((url: string) => { + if (url.includes('/cancel')) { + return Promise.resolve({ data: { id: 'old-dep', status: 'RUNNING' } }) as never + } + deployPosts += 1 + // First deploy collides with an in-flight deployment; the retry succeeds. + return (deployPosts === 1 + ? Promise.reject(conflict('old-dep')) + : Promise.resolve({ data: { id: 'new-dep', status: 'PENDING' } })) as never + }) + vi.mocked(api.get).mockImplementation((url: string) => + (url.includes('/completion') + ? Promise.resolve({ data: { id: 'old-dep', status: 'CANCELLED' } }) + : Promise.resolve(sseStream(sse('complete', { id: 'new-dep', status: 'SUCCEEDED', result: applied })))) as never, + ) + + const onStatus = vi.fn() + const { data } = await projects.deploy(sync, { cancelInProgress: true, onStatus }) + + expect(api.post).toHaveBeenCalledWith('/v1/projects/my-project/deployments/old-dep/cancel') + expect(api.get).toHaveBeenCalledWith( + '/v1/projects/my-project/deployments/old-dep/completion', + expect.objectContaining({ params: { maxWaitSeconds: 30 } }), + ) + expect(onStatus).toHaveBeenCalled() + expect(deployPosts).toBe(2) + expect(data).toEqual(applied) + }) + + it('proceeds with the retry when the in-flight deployment is already gone (404 on cancel)', async () => { + let deployPosts = 0 + vi.mocked(api.post).mockImplementation((url: string) => { + if (url.includes('/cancel')) { + return Promise.reject( + new NotFoundError({ statusCode: 404, error: 'Not Found', message: 'No such project deployment.' }), + ) as never + } + deployPosts += 1 + return (deployPosts === 1 + ? Promise.reject(conflict('old-dep')) + : Promise.resolve({ data: { id: 'new-dep', status: 'PENDING' } })) as never + }) + vi.mocked(api.get).mockResolvedValue( + sseStream(sse('complete', { id: 'new-dep', status: 'SUCCEEDED', result: applied })) as never, + ) + + const { data } = await projects.deploy(sync, { cancelInProgress: true }) + + expect(deployPosts).toBe(2) + expect(data).toEqual(applied) + }) + + it('awaitDeploymentCompletion does a single long-poll and returns the final deployment', async () => { + vi.mocked(api.get).mockResolvedValue({ data: { id: 'dep-1', status: 'CANCELLED' } } as never) + + const result = await projects.awaitDeploymentCompletion('my-project', 'dep-1') + + expect(result.status).toBe('CANCELLED') + expect(api.get).toHaveBeenCalledTimes(1) + expect(api.get).toHaveBeenCalledWith( + '/v1/projects/my-project/deployments/dep-1/completion', + expect.objectContaining({ params: { maxWaitSeconds: 30 } }), + ) + }) + + it('gives up and surfaces the conflict once the overall wait deadline is exceeded', async () => { + // Every POST conflicts and the predecessor never finishes (completion 408s). + let deployPosts = 0 + vi.mocked(api.post).mockImplementation((url: string) => { + if (url.includes('/cancel')) { + return Promise.resolve({ data: { id: 'x', status: 'RUNNING' } }) as never + } + deployPosts += 1 + return Promise.reject(conflict('x')) as never + }) + vi.mocked(api.get).mockRejectedValue( + new RequestTimeoutError({ statusCode: 408, error: 'Request Timeout', message: 'still running' }) as never, + ) + + // Advance the (mocked) clock 20 min on every Date.now() call, so the wait + // deadline (30 min) is crossed after a couple of checks regardless of the + // exact call count — robust to incidental Date.now() usage. + const base = Date.now() + let clock = base + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => { + const value = clock + clock += 20 * 60_000 + return value + }) + try { + await expect(projects.deploy(sync, { cancelInProgress: true })).rejects.toThrow(ConflictError) + // One wait round before the deadline trips: initial POST + one retry, with + // one cancel between. + expect(deployPosts).toBe(2) + const cancelCalls = vi.mocked(api.post).mock.calls.filter(([url]) => String(url).includes('/cancel')) + expect(cancelCalls).toHaveLength(1) + } finally { + nowSpy.mockRestore() + } + }) +}) diff --git a/packages/cli/src/rest/errors.ts b/packages/cli/src/rest/errors.ts index eb0b0f156..c22065620 100644 --- a/packages/cli/src/rest/errors.ts +++ b/packages/cli/src/rest/errors.ts @@ -114,6 +114,11 @@ export interface ErrorData { error: string errorCode?: string message: string + /** + * Set on a 409 from the async project-deploy endpoint: the id of the in-flight + * deployment that blocked this one. Lets the client attach to or cancel it. + */ + deploymentId?: string } function isErrorData (value: any): value is ErrorData { diff --git a/packages/cli/src/rest/projects.ts b/packages/cli/src/rest/projects.ts index 26f069996..8c5bde6c3 100644 --- a/packages/cli/src/rest/projects.ts +++ b/packages/cli/src/rest/projects.ts @@ -1,8 +1,9 @@ -import { type AxiosInstance } from 'axios' +import { type AxiosInstance, isAxiosError } from 'axios' +import { Readable } from 'node:stream' import type { GitInformation } from '../services/util.js' import { compressJSONPayload } from './util.js' import { SharedFile } from '../constructs/index.js' -import { ConflictError, ForbiddenError, NotFoundError } from './errors.js' +import { ConflictError, ForbiddenError, handleErrorResponse, NotFoundError, RequestTimeoutError } from './errors.js' export interface Project { name: string @@ -70,11 +71,101 @@ export interface ProjectSync { repoInfo: GitInformation | null } +// The project echoed back in a deploy result: identity fields + timestamps. The +// timestamps are camelCase to match the deployment envelope (the project CRUD +// endpoints return snake_case — see ProjectResponse). +export interface DeployedProject extends Project { + id: string + createdAt: string + updatedAt: string | null +} + export interface ProjectDeployResponse { - project: Project + project: DeployedProject diff: Array } +export type ProjectDeploymentStatus = 'PENDING' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'CANCELLED' + +export interface ProjectDeployment { + id: string + logicalId: string + status: ProjectDeploymentStatus + dryRun: boolean + /** Opaque progress percentage (0-100). */ + progress: number + error: { code: string, message: string } | null + /** The applied { project, diff }; present once the deployment has succeeded. */ + result: ProjectDeployResponse | null + createdAt: string + startedAt: string | null + endedAt: string | null + /** When cancellation was requested for this deployment, or null if it was not. */ + cancelRequestedAt: string | null +} + +export class ProjectDeployFailedError extends Error { + constructor (message: string, options?: ErrorOptions) { + super(message, options) + this.name = 'ProjectDeployFailedError' + } +} + +/** The deployment was cancelled before it finished (e.g. superseded by a newer deploy). */ +export class ProjectDeployCancelledError extends Error { + constructor (message: string, options?: ErrorOptions) { + super(message, options) + this.name = 'ProjectDeployCancelledError' + } +} + +/** Internal: the SSE stream ended before a terminal event (eligible for reconnect). */ +class DeploymentStreamInterruptedError extends Error { + constructor () { + super('The deployment event stream ended before completion.') + this.name = 'DeploymentStreamInterruptedError' + } +} + +interface SseFrame { + event: string + data: any +} + +function streamToString (stream: Readable): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = [] + stream.on('data', chunk => chunks.push(Buffer.from(chunk))) + stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))) + stream.on('error', reject) + }) +} + +/** Parse one SSE frame ("event: x\ndata: {...}"), assuming LF line endings. + * Returns null for keep-alive comments or frames without parseable JSON data. */ +function parseSseFrame (raw: string): SseFrame | null { + let event = 'message' + const dataLines: string[] = [] + for (const line of raw.split('\n')) { + if (line.startsWith(':')) { + continue // keep-alive comment + } + if (line.startsWith('event:')) { + event = line.slice('event:'.length).trim() + } else if (line.startsWith('data:')) { + dataLines.push(line.slice('data:'.length).trim()) + } + } + if (dataLines.length === 0) { + return null + } + try { + return { event, data: JSON.parse(dataLines.join('\n')) } + } catch { + return null + } +} + export interface ImportPlanFilter { type: 'include' | 'exclude' resource?: { @@ -149,6 +240,11 @@ export class InvalidImportPlanStateError extends Error { } } +// How long deploy() will keep waiting-and-retrying behind an in-progress +// deployment before giving up and surfacing the 409. Generous, since a large +// predecessor deploy can legitimately run for many minutes. +const DEPLOY_CONFLICT_WAIT_DEADLINE_MS = 30 * 60_000 + class Projects { api: AxiosInstance constructor (api: AxiosInstance) { @@ -208,12 +304,271 @@ class Projects { } } - deploy (resources: ProjectSync, { dryRun = false, scheduleOnDeploy = true } = {}) { - return this.api.post( - `/next-v2/projects/deploy?dryRun=${dryRun}&scheduleOnDeploy=${scheduleOnDeploy}`, + /** + * Deploy a project. The deployment runs asynchronously on the backend: this + * submits it, then follows its progress stream to completion, so large projects + * are no longer bound by the API gateway request timeout. A dry run returns the + * preview diff synchronously without starting a deployment. + * + * @throws {ProjectDeployFailedError} If the deployment finishes unsuccessfully. + */ + async deploy ( + resources: ProjectSync, + { dryRun = false, scheduleOnDeploy = true, cancelInProgress = false, onProgress, onStatus }: { + dryRun?: boolean + scheduleOnDeploy?: boolean + /** + * On a 409 (another deployment is already in progress), cancel that + * deployment instead of waiting for it to finish, then retry. + */ + cancelInProgress?: boolean + onProgress?: (progress: number) => void + /** Human-readable status updates (e.g. while waiting on a predecessor). */ + onStatus?: (message: string) => void + } = {}, + ): Promise<{ data: ProjectDeployResponse }> { + const logicalId = resources.project.logicalId + + // On a 409 the project already has a deployment in progress. By default we + // wait for it to finish then retry; with cancelInProgress we cancel it first. + // resolveInProgressDeployment only returns once the predecessor has reached a + // final state, so we re-POST the (potentially large) payload exactly once per + // predecessor — never while it is still running. Bound by an overall deadline + // so a stuck predecessor can't make us wait forever. + const deadlineAt = Date.now() + DEPLOY_CONFLICT_WAIT_DEADLINE_MS + for (;;) { + try { + return await this.submitDeployment(resources, { dryRun, scheduleOnDeploy, onProgress }) + } catch (err) { + if ( + dryRun + || !(err instanceof ConflictError) + || typeof err.data.deploymentId !== 'string' + || Date.now() >= deadlineAt + ) { + throw err + } + await this.resolveInProgressDeployment(logicalId, err.data.deploymentId, { + cancel: cancelInProgress, + onStatus, + deadlineAt, + }) + // loop → re-POST, now that the predecessor has reached a final state + } + } + } + + private async submitDeployment ( + resources: ProjectSync, + { dryRun, scheduleOnDeploy, onProgress }: { + dryRun: boolean + scheduleOnDeploy: boolean + onProgress?: (progress: number) => void + }, + ): Promise<{ data: ProjectDeployResponse }> { + const { data } = await this.api.post( + `/v1/projects/deploy?dryRun=${dryRun}&scheduleOnDeploy=${scheduleOnDeploy}`, resources, { transformRequest: compressJSONPayload }, ) + + // A dry run responds synchronously with the preview diff. + if (dryRun) { + return { data: data as ProjectDeployResponse } + } + + // A real deploy responds with a deployment to follow to completion. + const deployment = data as ProjectDeployment + const completed = await this.streamDeploymentEvents(resources.project.logicalId, deployment.id, { onProgress }) + + if (completed.status === 'CANCELLED') { + throw new ProjectDeployCancelledError( + 'A newer deployment may have cancelled yours. Try deploying again if you still need to apply your changes.', + ) + } + + if (completed.status !== 'SUCCEEDED' || completed.result === null) { + throw new ProjectDeployFailedError(completed.error?.message ?? 'The deployment did not complete successfully.') + } + + return { data: completed.result } + } + + /** + * Resolve a collision with an in-progress deployment so the caller can retry: + * optionally cancel it, then wait until it reaches a final state (or is gone) + * before returning — so the caller re-POSTs only when the slot is actually + * free, never re-uploading the payload while the predecessor is still running. + * Returns early if the overall `deadlineAt` passes (the caller then re-POSTs + * once and surfaces the conflict). + */ + private async resolveInProgressDeployment ( + logicalId: string, + deploymentId: string, + { cancel, onStatus, deadlineAt }: { cancel: boolean, onStatus?: (message: string) => void, deadlineAt: number }, + ): Promise { + if (cancel) { + onStatus?.('Cancelling an in-progress deployment...') + try { + await this.cancelDeployment(logicalId, deploymentId) + } catch (err) { + // Already gone → nothing to cancel; proceed to retry. + if (!(err instanceof NotFoundError)) { + throw err + } + return + } + } else { + onStatus?.('Waiting for an in-progress deployment to finish...') + } + + // Poll the completion endpoint until the predecessor is final. Pacing comes + // from the server-side long-poll (~maxWaitSeconds per call), so this is not a + // busy loop; the deadline bounds the total wait. + for (;;) { + try { + await this.awaitDeploymentCompletion(logicalId, deploymentId) + return // reached a final state → slot free + } catch (err) { + if (err instanceof NotFoundError) { + return // gone → slot free + } + // 408 = still running after the long-poll window. Keep waiting unless the + // overall deadline has passed, in which case return and let the caller + // re-POST once and surface the conflict. + if (err instanceof RequestTimeoutError) { + if (Date.now() >= deadlineAt) { + return + } + continue + } + throw err + } + } + } + + getDeployment (logicalId: string, deploymentId: string) { + return this.api.get( + `/v1/projects/${encodeURIComponent(logicalId)}/deployments/${encodeURIComponent(deploymentId)}`, + ) + } + + /** Request cancellation of an in-flight deployment (idempotent on the server). */ + cancelDeployment (logicalId: string, deploymentId: string) { + return this.api.post( + `/v1/projects/${encodeURIComponent(logicalId)}/deployments/${encodeURIComponent(deploymentId)}/cancel`, + ) + } + + /** + * Long-poll the completion endpoint once: the server blocks up to + * `maxWaitSeconds` and returns the deployment when it reaches a final state, or + * 408 (`RequestTimeoutError`) if it is still running when that window elapses. + * The retry cadence lives in the caller, not here. + */ + async awaitDeploymentCompletion ( + logicalId: string, + deploymentId: string, + { maxWaitSeconds = 30 }: { maxWaitSeconds?: number } = {}, + ): Promise { + const { data } = await this.api.get( + `/v1/projects/${encodeURIComponent(logicalId)}/deployments/${encodeURIComponent(deploymentId)}/completion`, + { params: { maxWaitSeconds } }, + ) + return data + } + + /** + * Follow a deployment to completion over its Server-Sent Events stream, + * invoking `onProgress` as progress frames arrive and resolving with the final + * deployment on the terminal `complete` frame. If the stream drops before a + * terminal frame (a transient network blip), it reconnects up to `maxReconnects` + * times — the server is stateless and re-reads current state, so resuming needs + * no cursor. + */ + async streamDeploymentEvents ( + logicalId: string, + deploymentId: string, + { onProgress, maxReconnects = 5 }: { onProgress?: (progress: number) => void, maxReconnects?: number } = {}, + ): Promise { + let reconnects = 0 + for (;;) { + try { + return await this.consumeEventStream(logicalId, deploymentId, onProgress) + } catch (err) { + if (err instanceof DeploymentStreamInterruptedError && reconnects < maxReconnects) { + reconnects += 1 + continue + } + throw err + } + } + } + + private async openEventStream (logicalId: string, deploymentId: string): Promise { + try { + const { data } = await this.api.get( + `/v1/projects/${encodeURIComponent(logicalId)}/deployments/${encodeURIComponent(deploymentId)}/events`, + { responseType: 'stream', headers: { Accept: 'text/event-stream' } }, + ) + return data + } catch (err) { + // On an HTTP error the body arrives as an unparsed stream (responseType + // 'stream'), so the response interceptor couldn't classify it. Buffer it and + // re-run the classifier to surface the typed error (NotFoundError, etc.). + if (isAxiosError(err) && err.response && err.response.data instanceof Readable) { + err.response.data = await streamToString(err.response.data) + handleErrorResponse(err) + } + throw err + } + } + + private async consumeEventStream ( + logicalId: string, + deploymentId: string, + onProgress?: (progress: number) => void, + ): Promise { + const stream = await this.openEventStream(logicalId, deploymentId) + + return new Promise((resolve, reject) => { + let buffer = '' + let settled = false + const settle = (action: () => void) => { + if (settled) { + return + } + settled = true + stream.destroy() + action() + } + + stream.on('data', (chunk: Buffer) => { + buffer += chunk.toString('utf8') + let boundary = buffer.indexOf('\n\n') + while (boundary !== -1) { + const frame = parseSseFrame(buffer.slice(0, boundary)) + buffer = buffer.slice(boundary + 2) + if (frame?.event === 'progress') { + if (onProgress !== undefined && typeof frame.data?.progress === 'number') { + onProgress(frame.data.progress) + } + } else if (frame?.event === 'complete') { + settle(() => resolve(frame.data as ProjectDeployment)) + } else if (frame?.event === 'error') { + const message = typeof frame.data?.message === 'string' + ? frame.data.message + : 'The deployment event stream reported an error.' + settle(() => reject(new ProjectDeployFailedError(message))) + } + boundary = buffer.indexOf('\n\n') + } + }) + // Both a clean EOF before a terminal frame and a socket error (the common + // mid-deploy drop, e.g. ECONNRESET) are interruptions eligible for reconnect. + stream.on('end', () => settle(() => reject(new DeploymentStreamInterruptedError()))) + stream.on('error', () => settle(() => reject(new DeploymentStreamInterruptedError()))) + }) } /**