68c33a37ad
## What & why Node's HTTP server defaults `keepAliveTimeout` to **5s**, which is shorter than the idle keep-alive timeout of common reverse proxies / load balancers (nginx `upstream-keepalive-timeout` and AWS ALB both default to **60s**). twenty-server currently calls `app.listen()` without overriding these, so it runs on the 5s default. When Node closes an idle keep-alive socket that the proxy still has pooled, the proxy's next request races the close and gets a TCP reset. nginx logs: ``` recv() failed (104: Connection reset by peer) while reading response header from upstream ``` and returns a **502** to the client. This is payload- and endpoint-independent: in prod it hit `/graphql`, `/metadata`, `/mcp` and the app-publish tarball upload alike, at a low continuous rate, on healthy pods (no restarts, ~64% memory, no CPU throttling). This is the well-documented "Node behind ALB/nginx 502" race. The fix is the standard one: make the **server** idle timeout **longer** than the proxy's, so the proxy is always the side that closes idle connections. ## Changes - Set `server.keepAliveTimeout` / `server.headersTimeout` in `main.ts` from config. - Add two env-overridable config vars (`SERVER_CONFIG` group), with safe defaults above the typical 60s proxy timeout: - `SERVER_KEEP_ALIVE_TIMEOUT_MS` (default **65000**) - `SERVER_HEADERS_TIMEOUT_MS` (default **66000**) - `headersTimeout` is clamped to `keepAliveTimeout + 1s` at startup, since Node requires `headersTimeout >= keepAliveTimeout` (otherwise it re-introduces the same race). - Document both in `.env.example`. Defaults fix the issue out of the box. The env vars exist because self-hosters sit behind many proxies (Cloudflare, Traefik, ALB, nginx) with different idle timeouts — mirroring how Next.js exposes `--keepAliveTimeout`, and how Fastify (72s) and Kestrel (130s) ship safe-by-default values. ## Test - `environment-config.driver.spec.ts` passes. - `nx typecheck twenty-server` clean for the changed files (only a pre-existing, unrelated `ical-generator` module-resolution error remains). <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22327?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
120 lines
4.1 KiB
TypeScript
120 lines
4.1 KiB
TypeScript
import { NestFactory } from '@nestjs/core';
|
|
import { type NestExpressApplication } from '@nestjs/platform-express';
|
|
|
|
import fs from 'fs';
|
|
import { inspect } from 'util';
|
|
|
|
import bytes from 'bytes';
|
|
import { useContainer } from 'class-validator';
|
|
import session from 'express-session';
|
|
import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs';
|
|
|
|
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
|
|
|
|
import { setPgDateTypeParser } from 'src/database/pg/set-pg-date-type-parser';
|
|
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
|
|
import { LoggerService } from 'src/engine/core-modules/logger/logger.service';
|
|
import { getSessionStorageOptions } from 'src/engine/core-modules/session-storage/session-storage.module-factory';
|
|
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
|
import { configTransformers } from 'src/engine/core-modules/twenty-config/utils/config-transformers.util';
|
|
import { shouldCaptureException } from 'src/engine/utils/global-exception-handler.util';
|
|
import { UnhandledExceptionFilter } from 'src/filters/unhandled-exception.filter';
|
|
|
|
import { AppModule } from './app.module';
|
|
import './instrument';
|
|
|
|
import { settings } from './engine/constants/settings';
|
|
import { generateFrontConfig } from './utils/generate-front-config';
|
|
|
|
// Trigger
|
|
const bootstrap = async () => {
|
|
setPgDateTypeParser();
|
|
|
|
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
|
// Expose WWW-Authenticate so browser-based MCP clients can read the
|
|
// resource_metadata pointer on 401. Required by MCP authorization spec.
|
|
cors: { exposedHeaders: ['WWW-Authenticate'] },
|
|
bufferLogs: process.env.LOGGER_IS_BUFFER_ENABLED === 'true',
|
|
rawBody: true,
|
|
snapshot: process.env.NODE_ENV === NodeEnvironment.DEVELOPMENT,
|
|
...(process.env.SSL_KEY_PATH && process.env.SSL_CERT_PATH
|
|
? {
|
|
httpsOptions: {
|
|
key: fs.readFileSync(process.env.SSL_KEY_PATH),
|
|
cert: fs.readFileSync(process.env.SSL_CERT_PATH),
|
|
},
|
|
}
|
|
: {}),
|
|
});
|
|
const logger = app.get(LoggerService);
|
|
const twentyConfigService = app.get(TwentyConfigService);
|
|
const exceptionHandlerService = app.get(ExceptionHandlerService);
|
|
|
|
process.on('unhandledRejection', (reason) => {
|
|
const error =
|
|
reason instanceof Error
|
|
? reason
|
|
: new Error(typeof reason === 'string' ? reason : inspect(reason));
|
|
|
|
if (shouldCaptureException(error)) {
|
|
exceptionHandlerService.captureExceptions([error]);
|
|
}
|
|
});
|
|
|
|
const trustProxyRaw = twentyConfigService.get('TRUST_PROXY');
|
|
const trustProxy = /^\d+$/.test(trustProxyRaw)
|
|
? Number(trustProxyRaw)
|
|
: (configTransformers.boolean(trustProxyRaw) ?? trustProxyRaw);
|
|
|
|
app.set('trust proxy', trustProxy);
|
|
|
|
app.use(session(getSessionStorageOptions(twentyConfigService)));
|
|
|
|
// Apply class-validator container so that we can use injection in validators
|
|
useContainer(app.select(AppModule), { fallbackOnErrors: true });
|
|
|
|
// Use our logger
|
|
app.useLogger(logger);
|
|
|
|
app.useGlobalFilters(new UnhandledExceptionFilter());
|
|
|
|
app.useBodyParser('json', { limit: settings.storage.maxFileSize });
|
|
app.useBodyParser('urlencoded', {
|
|
limit: settings.storage.maxFileSize,
|
|
extended: true,
|
|
});
|
|
app.useBodyParser('text', { type: 'text/plain', limit: '1024kb' });
|
|
|
|
// Graphql file upload
|
|
app.use(
|
|
'/graphql',
|
|
graphqlUploadExpress({
|
|
maxFieldSize: bytes(settings.storage.maxFileSize)!,
|
|
maxFiles: 10,
|
|
}),
|
|
);
|
|
|
|
app.use(
|
|
'/metadata',
|
|
graphqlUploadExpress({
|
|
maxFieldSize: bytes(settings.storage.maxFileSize)!,
|
|
maxFiles: 10,
|
|
}),
|
|
);
|
|
|
|
// Inject the server url in the frontend page
|
|
generateFrontConfig();
|
|
|
|
const keepAliveTimeout = twentyConfigService.get(
|
|
'SERVER_KEEP_ALIVE_TIMEOUT_MS',
|
|
);
|
|
const httpServer = app.getHttpServer();
|
|
|
|
httpServer.keepAliveTimeout = keepAliveTimeout;
|
|
httpServer.headersTimeout = keepAliveTimeout + 1000;
|
|
|
|
await app.listen(twentyConfigService.get('NODE_PORT'));
|
|
};
|
|
|
|
void bootstrap();
|