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 { 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<string, string>,
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}` : ''}`,
);
}