Files
gitmost/apps/server/src/main.ts
T
agent_vscode 888c87f984 feat(config): lower MCP timeouts and raise JSON body limit
Reduce the default external-MCP silence timeout from 5 min to 1 min and the
overall call timeout from 15 min to 2 min. Update related tests and comments.
Increase Fastify's JSON body limit to 25 MiB (configurable via
HTTP_JSON_BODY_LIMIT) to accommodate large AI‑chat payloads.

BREAKING CHANGE: shorter MCP timeouts may abort long‑running tool calls that
previously succeeded with the older defaults.
2026-07-07 04:19:26 +03:00

219 lines
7.2 KiB
TypeScript

import { NestFactory, Reflector } from '@nestjs/core';
import { AppModule } from './app.module';
import {
FastifyAdapter,
NestFastifyApplication,
} from '@nestjs/platform-fastify';
import { Logger, NotFoundException, ValidationPipe } from '@nestjs/common';
import { Logger as PinoLogger } from 'nestjs-pino';
import { TransformHttpResponseInterceptor } from './common/interceptors/http-response.interceptor';
import { WsRedisIoAdapter } from './ws/adapter/ws-redis.adapter';
import fastifyMultipart from '@fastify/multipart';
import fastifyCookie from '@fastify/cookie';
import fastifyIp from 'fastify-ip';
import { InternalLogFilter } from './common/logger/internal-log-filter';
import { EnvironmentService } from './integrations/environment/environment.service';
import { SANDBOX_API_PATH } from './integrations/sandbox/sandbox.constants';
import { resolveFrameHeader } from './common/helpers';
import { resolveTrustProxy } from './integrations/environment/trust-proxy.util';
import { isMetricsEnabled } from './integrations/metrics/metrics.registry';
import { recordHttpResponse } from './integrations/metrics/http-metrics.hook';
import { startMetricsServer } from './integrations/metrics/metrics.server';
async function bootstrap() {
// Fastify JSON body cap. Fastify defaults to 1 MiB, which a long AI-chat
// research turn exceeds: the client resends the FULL message history (every
// tool call + search result) on each turn, so a deep conversation's POST to
// /api/ai-chat/stream can be several MB and would otherwise be rejected with
// FST_ERR_CTP_BODY_TOO_LARGE (413). Raise the cap; override with
// HTTP_JSON_BODY_LIMIT (bytes). A missing/invalid/non-positive value keeps the
// 25 MiB default. Multipart uploads are unaffected (their own @fastify/multipart
// limits apply); this only bounds JSON/urlencoded request bodies.
const bodyLimitEnv = Number(process.env.HTTP_JSON_BODY_LIMIT);
const bodyLimit =
Number.isFinite(bodyLimitEnv) && bodyLimitEnv > 0
? bodyLimitEnv
: 25 * 1024 * 1024;
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter({
trustProxy: resolveTrustProxy(process.env.TRUST_PROXY),
bodyLimit,
routerOptions: {
maxParamLength: 1000,
ignoreTrailingSlash: true,
ignoreDuplicateSlashes: true,
},
}),
{
rawBody: true,
// captures NestJS internal errors
logger: new InternalLogFilter(),
// bufferLogs must be false else pino will fail
// to log OnApplicationBootstrap logs
bufferLogs: false,
},
);
app.useLogger(app.get(PinoLogger));
app.setGlobalPrefix('api', {
exclude: [
'robots.txt',
'share/:shareId/p/:pageSlug',
// Vanity link resolver lives outside /api so /l/<alias> is a clean
// public URL that 302s to the canonical share page.
'l/:alias',
'mcp',
],
});
const reflector = app.get(Reflector);
const redisIoAdapter = new WsRedisIoAdapter(app);
await redisIoAdapter.connectToRedis();
app.useWebSocketAdapter(redisIoAdapter);
await app.register(fastifyIp);
await app.register(fastifyMultipart);
await app.register(fastifyCookie);
const environmentService = app.get(EnvironmentService);
const frameHeader = resolveFrameHeader(
environmentService.isIframeEmbedAllowed(),
environmentService.getIframeAllowedOrigins(),
);
if (frameHeader) {
// Skipped routes:
// /api/files/ - attachment controller sets its own CSP we'd overwrite
// /share/ 0 public share pages are safe to embed
const frameHeaderSkippedPrefixes = ['/api/files/', '/share/'];
app
.getHttpAdapter()
.getInstance()
.addHook('onSend', (req, reply, payload, done) => {
if (frameHeaderSkippedPrefixes.some((p) => req.url.startsWith(p))) {
return done(null, payload);
}
reply.header(frameHeader.name, frameHeader.value);
done(null, payload);
});
}
app
.getHttpAdapter()
.getInstance()
.addHook('onRequest', (request, _reply, done) => {
(request.raw as any).ip = request.ip;
done();
});
// #355 — HTTP request-duration histogram. Registered ONLY when METRICS_PORT is
// set (otherwise no collector runs at all). Uses the bounded route template
// label and excludes SSE/streaming responses (see recordHttpResponse).
if (isMetricsEnabled()) {
app
.getHttpAdapter()
.getInstance()
.addHook('onResponse', (req, reply, done) => {
recordHttpResponse(req, reply);
done();
});
}
app
.getHttpAdapter()
.getInstance()
.addContentTypeParser(
'application/scim+json',
{ parseAs: 'string' },
(_, body, done) => {
try {
const json = JSON.parse(body.toString());
done(null, json);
} catch (err: any) {
done(err);
}
},
);
app
.getHttpAdapter()
.getInstance()
.decorateReply('setHeader', function (name: string, value: unknown) {
this.header(name, value);
})
.decorateReply('end', function () {
this.send('');
})
.addHook('preHandler', function (req, reply, done) {
// don't require workspaceId for the following paths
const excludedPaths = [
'/api/auth/setup',
'/api/health',
'/api/billing/stripe/webhook',
'/api/workspace/check-hostname',
'/api/sso/google',
'/api/workspace/create',
'/api/workspace/joined',
'/api/workspace/find-by-email',
// Public client perf-telemetry sink: browsers post it without a
// resolved workspace host, so the workspace-resolution gate must not 404 it.
'/api/telemetry/vitals',
// Anonymous in-RAM blob sandbox: a remote consumer fetches blobs by an
// unguessable UUID without any workspace host context, so the
// workspace-resolution gate must not apply.
SANDBOX_API_PATH,
];
if (
req.originalUrl.startsWith('/api') &&
!excludedPaths.some((path) => req.originalUrl.startsWith(path))
) {
if (!req.raw?.['workspaceId'] && req.originalUrl !== '/api') {
throw new NotFoundException('Workspace not found');
}
done();
} else {
done();
}
});
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
stopAtFirstError: true,
transform: true,
}),
);
app.enableCors();
app.useGlobalInterceptors(new TransformHttpResponseInterceptor(reflector));
app.enableShutdownHooks();
const logger = new Logger('NestApplication');
process.on('unhandledRejection', (reason, promise) => {
logger.error(`UnhandledRejection, reason: ${reason}`, promise);
});
process.on('uncaughtException', (error) => {
logger.error('UncaughtException:', error);
});
const port = process.env.PORT || 3000;
const host = process.env.HOST || '0.0.0.0';
await app.listen(port, host, () => {
logger.log(
`Listening on http://127.0.0.1:${port} / ${process.env.APP_URL}`,
);
});
// #355 — Prometheus scrape endpoint on a SEPARATE port (METRICS_PORT),
// started after the app is up. No default port: a no-op when METRICS_PORT is
// unset. Closed on shutdown by MetricsServerLifecycle (MetricsModule).
startMetricsServer();
}
bootstrap();