Use Node http for long storefront deploys instead of undici.

This API VM's Node 20 build has no node:undici, so require() crash-looped the process and took login down.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-04 15:51:07 +03:30
co-authored by Cursor
parent 53fe8b72bd
commit 5c701d0841
@@ -1,42 +1,62 @@
import { Injectable, ServiceUnavailableException } from '@nestjs/common'; import { Injectable, ServiceUnavailableException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; 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'; import { provisionUrlFromDeployUrl, sslUrlFromDeployUrl, parkUrlFromDeployUrl, unparkUrlFromDeployUrl } from './website-deploy.util';
/** Cold `npm ci` + `next build` often exceeds 5–10 minutes. */ /** Cold `npm ci` + `next build` often exceeds 5–10 minutes. */
const DEPLOY_WAIT_MS = 30 * 60 * 1000; const DEPLOY_WAIT_MS = 30 * 60 * 1000;
/** /**
* Node `fetch` (undici) defaults `headersTimeout` to 5 minutes. AbortSignal * Long POST without `fetch`. This API VM's Node 20 build has no `node:undici`,
* does not override that — without this agent, Nest aborts while the websites * and undici's default headersTimeout is 5 minutes even when it is present.
* 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.
*/ */
const deployWaitAgent = new ( function postJson(
require('node:undici') as { url: string,
Agent: new (opts: { headers: Record<string, string>,
headersTimeout?: number; bodyObj: unknown,
bodyTimeout?: number; timeoutMs: number,
connectTimeout?: number; ): Promise<{ ok: boolean; status: number; text: string }> {
}) => object; return new Promise((resolve, reject) => {
} const body = JSON.stringify(bodyObj);
).Agent({ const parsed = new URL(url);
headersTimeout: DEPLOY_WAIT_MS, const lib = parsed.protocol === 'https:' ? https : http;
bodyTimeout: DEPLOY_WAIT_MS, const req = lib.request(
connectTimeout: 30_000, {
}); protocol: parsed.protocol,
hostname: parsed.hostname,
function isFetchWaitTimeout(err: unknown): boolean { port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
if (!(err instanceof Error)) return false; path: `${parsed.pathname}${parsed.search}`,
if (err.name === 'TimeoutError' || err.name === 'HeadersTimeoutError' || err.name === 'BodyTimeoutError') { method: 'POST',
return true; headers: {
} ...headers,
const cause = (err as { cause?: { code?: string; name?: string } }).cause; 'Content-Type': 'application/json',
return ( 'Content-Length': String(Buffer.byteLength(body)),
cause?.code === 'UND_ERR_HEADERS_TIMEOUT' || },
cause?.code === 'UND_ERR_BODY_TIMEOUT' || },
cause?.name === 'HeadersTimeoutError' || (res) => {
cause?.name === 'BodyTimeoutError' 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() @Injectable()
@@ -88,20 +108,16 @@ export class WebsiteDeployAgentService {
const { deployUrl, token } = this.credentials(); const { deployUrl, token } = this.credentials();
const wait = options?.wait !== false; const wait = options?.wait !== false;
let response: Response; let result: { ok: boolean; status: number; text: string };
try { try {
response = await fetch(deployUrl, { result = await postJson(
method: 'POST', deployUrl,
headers: { { 'X-Deploy-Token': token },
'Content-Type': 'application/json', { slug, wait },
'X-Deploy-Token': token, DEPLOY_WAIT_MS,
}, );
body: JSON.stringify({ slug, wait }),
signal: AbortSignal.timeout(DEPLOY_WAIT_MS),
dispatcher: deployWaitAgent,
} as RequestInit);
} catch (err) { } catch (err) {
if (isFetchWaitTimeout(err)) { if (err instanceof Error && err.name === 'TimeoutError') {
throw new ServiceUnavailableException( throw new ServiceUnavailableException(
'Deploy timed out waiting for websites server (30m)', 'Deploy timed out waiting for websites server (30m)',
); );
@@ -109,7 +125,6 @@ export class WebsiteDeployAgentService {
throw new ServiceUnavailableException('Could not reach website deploy agent'); throw new ServiceUnavailableException('Could not reach website deploy agent');
} }
const text = await response.text().catch(() => '');
let payload: { let payload: {
status?: string; status?: string;
slug?: string; slug?: string;
@@ -117,15 +132,15 @@ export class WebsiteDeployAgentService {
error?: string; error?: string;
} = {}; } = {};
try { try {
payload = text ? (JSON.parse(text) as typeof payload) : {}; payload = result.text ? (JSON.parse(result.text) as typeof payload) : {};
} catch { } catch {
/* keep raw */ /* keep raw */
} }
if (!response.ok) { if (!result.ok) {
const detail = payload.detail || payload.error || text; const detail = payload.detail || payload.error || result.text;
throw new ServiceUnavailableException( throw new ServiceUnavailableException(
`Deploy failed (${response.status})${detail ? `: ${detail}` : ''}`, `Deploy failed (${result.status})${detail ? `: ${detail}` : ''}`,
); );
} }