Allow storefront deploys to wait up to 30 minutes.

Node fetch was aborting at undici's 5-minute headers timeout, so Super Admin marked long npm ci / next builds as failed while the websites VM was still compiling.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-04 15:22:23 +03:30
co-authored by Cursor
parent d14f91a606
commit 53fe8b72bd
2 changed files with 56 additions and 4 deletions
+14
View File
@@ -153,6 +153,19 @@ server {
client_max_body_size 15M;
# Storefront deploy (npm ci + next build) often exceeds 5 minutes
location ~ ^/api/v1/domains/[^/]+/deploy$ {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_send_timeout 1800s;
proxy_read_timeout 1800s;
}
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
@@ -160,6 +173,7 @@ server {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
}
}
```
@@ -2,6 +2,43 @@ import { Injectable, ServiceUnavailableException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
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.
*/
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'
);
}
@Injectable()
export class WebsiteDeployAgentService {
constructor(private readonly config: ConfigService) {}
@@ -60,12 +97,13 @@ export class WebsiteDeployAgentService {
'X-Deploy-Token': token,
},
body: JSON.stringify({ slug, wait }),
signal: AbortSignal.timeout(15 * 60 * 1000),
});
signal: AbortSignal.timeout(DEPLOY_WAIT_MS),
dispatcher: deployWaitAgent,
} as RequestInit);
} catch (err) {
if (err instanceof Error && err.name === 'TimeoutError') {
if (isFetchWaitTimeout(err)) {
throw new ServiceUnavailableException(
'Deploy timed out waiting for websites server (15m)',
'Deploy timed out waiting for websites server (30m)',
);
}
throw new ServiceUnavailableException('Could not reach website deploy agent');