mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
4b86ff73cf
Run the dev API on 6262 and Vite on 6263 with strictPort; production defaults stay on 3000. Define the dev client origin once and read the typed appConfig instead of repeating its fallback in every gateway.
136 lines
5.1 KiB
TypeScript
136 lines
5.1 KiB
TypeScript
import { NestFactory } from '@nestjs/core';
|
|
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
|
|
import { ValidationPipe } from '@nestjs/common';
|
|
import { IoAdapter } from '@nestjs/platform-socket.io';
|
|
import { Logger } from 'nestjs-pino';
|
|
import type { ConfigType } from '@nestjs/config';
|
|
import { AppModule } from './app.module';
|
|
import { GlobalExceptionFilter } from './common/filters/http-exception.filter';
|
|
import { join } from 'path';
|
|
import fastifyCookie from '@fastify/cookie';
|
|
import fastifyMultipart from '@fastify/multipart';
|
|
import fastifyStatic from '@fastify/static';
|
|
import fastifyHelmet from '@fastify/helmet';
|
|
import fastifyCompress from '@fastify/compress';
|
|
import type { FastifyInstance } from 'fastify';
|
|
import { appConfig } from './config/config';
|
|
import { DEV_CLIENT_ORIGIN } from './config/dev-client-origin';
|
|
import { setupSwaggerDocs } from './swagger';
|
|
import {
|
|
parseBooleanEnv,
|
|
parseTrustProxy,
|
|
buildHelmetOptions,
|
|
buildEmptyJsonBodyStream,
|
|
registerConditionalHsts,
|
|
registerDeclaredBodyLimits,
|
|
registerEmptyBodyContentTypeParser,
|
|
shouldInjectEmptyJsonBody,
|
|
shouldServeSpaFallback,
|
|
} from './common/utils/bootstrap.utils';
|
|
|
|
const MAX_COVER_BYTES = 20 * 1024 * 1024;
|
|
|
|
async function bootstrap() {
|
|
const allowCloudflareInsights = parseBooleanEnv(process.env.CSP_ALLOW_CLOUDFLARE_INSIGHTS, false);
|
|
|
|
const adapter = new FastifyAdapter({ logger: false, trustProxy: parseTrustProxy(process.env.TRUST_PROXY), maxParamLength: 1000 });
|
|
const app = await NestFactory.create<NestFastifyApplication>(AppModule, adapter, { bufferLogs: true });
|
|
app.useLogger(app.get(Logger));
|
|
|
|
const fastify = adapter.getInstance();
|
|
// Nest adds originalUrl to the raw request, but these helpers only use standard Fastify APIs.
|
|
const standardFastify = fastify as unknown as FastifyInstance;
|
|
|
|
registerDeclaredBodyLimits(fastify);
|
|
|
|
// Fastify's default JSON parser rejects empty bodies, so we inject '{}' before parsing.
|
|
fastify.addHook('preParsing', (request, _reply, payload, done) => {
|
|
if (shouldInjectEmptyJsonBody(request.method, request.headers)) {
|
|
done(null, buildEmptyJsonBodyStream(request.headers));
|
|
return;
|
|
}
|
|
done(null, payload);
|
|
});
|
|
// Reverse proxies can forward empty mutating requests with chunked transfer or an unsupported content type.
|
|
registerEmptyBodyContentTypeParser(standardFastify);
|
|
|
|
// Echo pino-http's request ID so clients can correlate errors with server logs.
|
|
fastify.addHook('onSend', (_request, reply, _payload, done) => {
|
|
const id = reply.request.id;
|
|
if (id !== undefined && id !== null) {
|
|
void reply.header('X-Request-Id', String(id));
|
|
}
|
|
done();
|
|
});
|
|
|
|
app.useWebSocketAdapter(new IoAdapter(app));
|
|
|
|
app.setGlobalPrefix('api/v1', {
|
|
exclude: ['api/kobo/:deviceToken/(.*)', 'api/v3/(.*)', 'api/UserStorage/(.*)'],
|
|
});
|
|
|
|
app.useGlobalPipes(
|
|
new ValidationPipe({
|
|
whitelist: true,
|
|
forbidNonWhitelisted: true,
|
|
transform: true,
|
|
}),
|
|
);
|
|
|
|
app.useGlobalFilters(new GlobalExceptionFilter());
|
|
|
|
const appConfiguration = app.get<ConfigType<typeof appConfig>>(appConfig.KEY);
|
|
if (appConfiguration.swaggerEnabled) {
|
|
await setupSwaggerDocs(app, appConfiguration);
|
|
}
|
|
|
|
await app.register(fastifyHelmet as never, buildHelmetOptions({ allowCloudflareInsights }));
|
|
registerConditionalHsts(standardFastify);
|
|
|
|
await app.register(fastifyCompress as never, { encodings: ['gzip', 'br'] });
|
|
|
|
await app.register(fastifyCookie as never);
|
|
await app.register(fastifyMultipart as never, { limits: { fileSize: MAX_COVER_BYTES } });
|
|
|
|
if (process.env.NODE_ENV !== 'production') {
|
|
app.enableCors({
|
|
origin: process.env.CLIENT_URL ?? DEV_CLIENT_ORIGIN,
|
|
credentials: true,
|
|
});
|
|
}
|
|
|
|
if (process.env.NODE_ENV === 'production') {
|
|
// NestJS calls setNotFoundHandler during init — intercept it so non-API 404s
|
|
// fall back to index.html instead of returning a JSON 404 (SPA routing support).
|
|
const adapterAny = adapter as any;
|
|
adapterAny.setNotFoundHandler = (nestHandler: (req: unknown, res: unknown) => void) => {
|
|
fastify.setNotFoundHandler(async (request, reply) => {
|
|
if (request.url.startsWith('/api')) {
|
|
return nestHandler(request, reply);
|
|
}
|
|
if (!shouldServeSpaFallback(request.url)) {
|
|
return reply.status(404).send({ statusCode: 404, message: 'Not Found', path: request.url });
|
|
}
|
|
// Nest's SWC type checker does not retain @fastify/static's reply augmentation
|
|
// through the adapter callback, even though the plugin decorates this reply at runtime.
|
|
const staticReply = reply as typeof reply & { sendFile(filename: string): typeof reply };
|
|
return staticReply.sendFile('index.html');
|
|
});
|
|
};
|
|
|
|
await app.register(fastifyStatic as never, {
|
|
root: join(__dirname, '..', 'public'),
|
|
prefix: '/',
|
|
});
|
|
}
|
|
|
|
app.enableShutdownHooks();
|
|
await app.listen(process.env.PORT ?? 3000, appConfiguration.host);
|
|
}
|
|
|
|
bootstrap().catch((err: unknown) => {
|
|
const message = err instanceof Error ? (err.stack ?? err.message) : String(err);
|
|
process.stderr.write(`BookOrbit startup failed:\n${message}\n`);
|
|
process.exit(1);
|
|
});
|