diff --git a/src/website-deploy/website-deploy-agent.service.ts b/src/website-deploy/website-deploy-agent.service.ts index e4d51fb..43a35f5 100644 --- a/src/website-deploy/website-deploy-agent.service.ts +++ b/src/website-deploy/website-deploy-agent.service.ts @@ -1,42 +1,62 @@ import { Injectable, ServiceUnavailableException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import * as http from 'node:http'; +import * as https from 'node:https'; import { provisionUrlFromDeployUrl, sslUrlFromDeployUrl, parkUrlFromDeployUrl, unparkUrlFromDeployUrl } from './website-deploy.util'; /** Cold `npm ci` + `next build` often exceeds 5–10 minutes. */ const DEPLOY_WAIT_MS = 30 * 60 * 1000; /** - * Node `fetch` (undici) defaults `headersTimeout` to 5 minutes. AbortSignal - * does not override that — without this agent, Nest aborts while the websites - * VM is still compiling and Super Admin shows a false "Deploy failed". - * `node:undici` is built into Node 20; this project's @types/node has no types. + * Long POST without `fetch`. This API VM's Node 20 build has no `node:undici`, + * and undici's default headersTimeout is 5 minutes even when it is present. */ -const deployWaitAgent = new ( - require('node:undici') as { - Agent: new (opts: { - headersTimeout?: number; - bodyTimeout?: number; - connectTimeout?: number; - }) => object; - } -).Agent({ - headersTimeout: DEPLOY_WAIT_MS, - bodyTimeout: DEPLOY_WAIT_MS, - connectTimeout: 30_000, -}); - -function isFetchWaitTimeout(err: unknown): boolean { - if (!(err instanceof Error)) return false; - if (err.name === 'TimeoutError' || err.name === 'HeadersTimeoutError' || err.name === 'BodyTimeoutError') { - return true; - } - const cause = (err as { cause?: { code?: string; name?: string } }).cause; - return ( - cause?.code === 'UND_ERR_HEADERS_TIMEOUT' || - cause?.code === 'UND_ERR_BODY_TIMEOUT' || - cause?.name === 'HeadersTimeoutError' || - cause?.name === 'BodyTimeoutError' - ); +function postJson( + url: string, + headers: Record, + bodyObj: unknown, + timeoutMs: number, +): Promise<{ ok: boolean; status: number; text: string }> { + return new Promise((resolve, reject) => { + const body = JSON.stringify(bodyObj); + const parsed = new URL(url); + const lib = parsed.protocol === 'https:' ? https : http; + const req = lib.request( + { + protocol: parsed.protocol, + hostname: parsed.hostname, + port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80), + path: `${parsed.pathname}${parsed.search}`, + method: 'POST', + headers: { + ...headers, + 'Content-Type': 'application/json', + 'Content-Length': String(Buffer.byteLength(body)), + }, + }, + (res) => { + const chunks: Buffer[] = []; + res.on('data', (chunk: Buffer) => chunks.push(chunk)); + res.on('end', () => { + const status = res.statusCode ?? 0; + resolve({ + ok: status >= 200 && status < 300, + status, + text: Buffer.concat(chunks).toString('utf8'), + }); + }); + }, + ); + req.setTimeout(timeoutMs, () => { + req.destroy(); + const err = new Error('TimeoutError'); + err.name = 'TimeoutError'; + reject(err); + }); + req.on('error', reject); + req.write(body); + req.end(); + }); } @Injectable() @@ -88,20 +108,16 @@ export class WebsiteDeployAgentService { const { deployUrl, token } = this.credentials(); const wait = options?.wait !== false; - let response: Response; + let result: { ok: boolean; status: number; text: string }; try { - response = await fetch(deployUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Deploy-Token': token, - }, - body: JSON.stringify({ slug, wait }), - signal: AbortSignal.timeout(DEPLOY_WAIT_MS), - dispatcher: deployWaitAgent, - } as RequestInit); + result = await postJson( + deployUrl, + { 'X-Deploy-Token': token }, + { slug, wait }, + DEPLOY_WAIT_MS, + ); } catch (err) { - if (isFetchWaitTimeout(err)) { + if (err instanceof Error && err.name === 'TimeoutError') { throw new ServiceUnavailableException( 'Deploy timed out waiting for websites server (30m)', ); @@ -109,7 +125,6 @@ export class WebsiteDeployAgentService { throw new ServiceUnavailableException('Could not reach website deploy agent'); } - const text = await response.text().catch(() => ''); let payload: { status?: string; slug?: string; @@ -117,15 +132,15 @@ export class WebsiteDeployAgentService { error?: string; } = {}; try { - payload = text ? (JSON.parse(text) as typeof payload) : {}; + payload = result.text ? (JSON.parse(result.text) as typeof payload) : {}; } catch { /* keep raw */ } - if (!response.ok) { - const detail = payload.detail || payload.error || text; + if (!result.ok) { + const detail = payload.detail || payload.error || result.text; throw new ServiceUnavailableException( - `Deploy failed (${response.status})${detail ? `: ${detail}` : ''}`, + `Deploy failed (${result.status})${detail ? `: ${detail}` : ''}`, ); }