diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 3f0962b..15603cb 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -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; } } ``` diff --git a/src/website-deploy/website-deploy-agent.service.ts b/src/website-deploy/website-deploy-agent.service.ts index e2b51ee..e4d51fb 100644 --- a/src/website-deploy/website-deploy-agent.service.ts +++ b/src/website-deploy/website-deploy-agent.service.ts @@ -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');