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:
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}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user