@tsdiapi/server
Version:
A fully ESM-based, modular TypeScript server built on Fastify
408 lines (372 loc) ⢠17.4 kB
text/typescript
import 'reflect-metadata';
import fastifyMultipart, { FastifyMultipartAttachFieldsToBodyOptions } from '@fastify/multipart';
import Fastify, { FastifyInstance, FastifyRequest, FastifyServerOptions } from 'fastify';
import { AppContext, AppMainOptions, AppOptions, UploadFile } from './types.js';
import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox';
import { setupCors } from './cors.js';
import { setupHelmet } from './helmet.js';
import { setupRateLimit } from './rate-limit.js';
import { setupSwagger } from './swagger.js';
import { initApp } from './app.js';
import { pastel, rainbow, cristal, vice, passion } from 'gradient-string';
import path from 'path';
import { ensureDir } from 'fsesm';
import { findAvailablePort } from './find-port.js';
import fileLoader, { fileLoaderWithContext } from './file-loader.js';
import { makeLoadPath, removeTrailingSlash } from './utils.js';
import { setupStatic } from './static.js';
import { Container } from 'typedi';
import { RouteBuilder, StatusSchemas } from './route.js';
import { TSchema } from '@sinclair/typebox';
import cors from '@fastify/cors';
import helmet from '@fastify/helmet';
import rateLimit from '@fastify/rate-limit';
import fastifySwagger from '@fastify/swagger';
import fastifySwaggerUi from '@fastify/swagger-ui';
import fastifyStatic from '@fastify/static';
import { getSyncQueueProvider } from "@tsdiapi/syncqueue";
export * from './types.js';
export * from './route.js';
export * from './meta.js';
export * from './response.js';
let context: AppContext | null = null;
export function getContext(): AppContext | null {
return context;
}
function setContext(newContext: AppContext): void {
context = newContext;
}
export async function createApp<T extends object = Record<string, any>>(options: AppOptions<T> = {}): Promise<AppContext<T> | null> {
const fastifyOptions = 'function' === typeof options.fastifyOptions ? options.fastifyOptions : (defaultOptions: FastifyServerOptions) => defaultOptions;
const fastify = Fastify(fastifyOptions({
logger: options.logger ?? false,
ajv: {
customOptions: { strict: false }
}
})).withTypeProvider<TypeBoxTypeProvider>();
fastify.addHook('onClose', (_, done) => {
console.log(cristal('š Bye bye! Fastify server is shutting down...'));
done();
});
try {
console.log(pastel.multiline("š Welcome to TSDIAPI!"));
console.log(rainbow("⨠Starting the server..."));
const cwd = process.cwd();
const multipartOptions = 'function' === typeof options.multipartOptions ? options.multipartOptions : (defaultOptions: Partial<FastifyMultipartAttachFieldsToBodyOptions>) => defaultOptions;
options.corsOptions = await setupCors(options.corsOptions);
options.rateLimitOptions = setupRateLimit(options.rateLimitOptions);
options.multipartOptions = multipartOptions({
limits: {
fileSize: 50 * 1024 * 1024,
},
attachFieldsToBody: 'keyValues',
onFile: async function (this: FastifyRequest, part) {
const bufferPromise = await part.toBuffer();
const uniqId = `${part.fieldname}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const file: UploadFile = {
id: uniqId,
fieldname: part.fieldname,
filename: part.filename,
encoding: part.encoding,
mimetype: part.mimetype,
buffer: bufferPromise,
filesize: bufferPromise.byteLength,
}
this.tempFiles = this.tempFiles || [];
this.tempFiles.push(file);
}
}) as FastifyMultipartAttachFieldsToBodyOptions;
options.helmetOptions = setupHelmet(options.helmetOptions);
const context = await initApp<T>(cwd, options, fastify) as AppContext<T>;
setContext(context);
if (options.fileLoader) {
context.fileLoader = options.fileLoader;
}
const pendingBuilds: Array<Promise<void>> = [];
function useRoute<
Params extends TSchema = TSchema,
Body extends TSchema = TSchema,
Query extends TSchema = TSchema,
Headers extends TSchema = TSchema,
TResponses extends StatusSchemas = {},
TState = unknown
>(controller?: string): RouteBuilder<Params, Body, Query, Headers, TResponses, TState> {
const builder = new RouteBuilder<
Params,
Body,
Query,
Headers,
TResponses,
TState
>(context);
if (controller) {
builder.controller(controller);
}
const originalBuild = builder.build;
builder.build = async function () {
const buildPromise = originalBuild.call(builder);
pendingBuilds.push(buildPromise);
await buildPromise;
};
return builder;
}
context.useRoute = useRoute;
const host = context.projectConfig.get('HOST', 'localhost') as string;
const appOptions: AppMainOptions = {
PORT: await findAvailablePort(host, context.projectConfig.get('PORT', 3000) as number),
HOST: host,
APP_NAME: context.projectPackage.name || context.projectConfig.get('APP_NAME', 'TSDIAPI Server'),
APP_VERSION: context.projectPackage.version || context.projectConfig.get('APP_VERSION', '1.0.0'),
};
const { swaggerOptions, swaggerUiOptions } = setupSwagger(options, appOptions);
context.options.swaggerOptions = swaggerOptions;
context.options.swaggerUiOptions = swaggerUiOptions;
context.options.staticOptions = setupStatic(context, options);
const loadExtensions = [];
if (options?.plugins && options.plugins.length > 0) {
for (const plugin of options.plugins) {
if (plugin) {
// Add the all plugins to the context before calling onInit
context.plugins[plugin.name] = plugin;
}
}
for (const plugin of options.plugins) {
if (plugin.onInit) {
try {
await plugin.onInit(context);
} catch (error) {
console.error(`ā ļø Plugin ${plugin.name} failed to initialize: ${error.message}`);
}
}
if (plugin?.services?.length) {
for (const service of plugin.services) {
Container.get(service);
}
}
}
}
if (options?.onInit) {
try {
await options.onInit(context);
} catch (error) {
console.error(`OnInit error:`, error);
process.exit(1);
}
}
// Add custom JSON parser to handle empty body for application/json requests
const defaultJsonParser = fastify.getDefaultJsonParser(undefined, undefined);
fastify.addContentTypeParser("application/json", { parseAs: "string" }, (request, body, done) => {
// Don't handle multipart requests with custom JSON parser
if (request.isMultipart()) {
return done(null, body);
}
if (body === '' || body == null || (Buffer.isBuffer(body) && body.length === 0)) {
return done(null, {});
}
return defaultJsonParser(request, body as string, done);
});
await fastify.register(fastifyMultipart, context.options.multipartOptions as FastifyMultipartAttachFieldsToBodyOptions);
if (context.options.corsOptions) {
await fastify.register(cors, context.options.corsOptions as cors.FastifyCorsOptions);
}
if (context.options.helmetOptions) {
await fastify.register(helmet, context.options.helmetOptions as helmet.FastifyHelmetOptions);
}
if (context.options.rateLimitOptions && typeof context.options.rateLimitOptions === 'object') {
await fastify.register(rateLimit, context.options.rateLimitOptions as any);
}
if (context.options.swaggerOptions) {
await fastify.register(fastifySwagger, context.options.swaggerOptions);
}
if (context.options.swaggerUiOptions) {
await fastify.register(fastifySwaggerUi, context.options.swaggerUiOptions);
}
if (context.options.staticOptions) {
await fastify.register(fastifyStatic, context.options.staticOptions);
fastify.get("/", async (_req, reply) => {
return reply.sendFile("index.html");
});
}
const apiDir = path.join(context.appDir, options.apiDir || 'api');
const apiRelativePath = removeTrailingSlash(path.relative(context.appDir, apiDir));
await ensureDir(apiDir);
await fileLoader(makeLoadPath(apiRelativePath, 'service'), context.appDir);
fastify.get("/404", function (_, res) {
res.status(404).send({ status: 404, message: "Page Not Found!" });
});
// Add preParsing hook to handle empty body for JSON requests
fastify.addHook('preParsing', async (req, _reply, payload) => {
// Handle empty body for JSON requests (but not multipart)
if (req.headers['content-type']?.includes('application/json') && !req.isMultipart()) {
if (!payload) {
return '{}';
}
if (payload && typeof payload === 'string' && (payload as string)?.trim() === '') {
return '{}';
}
}
return payload;
});
fastify.addHook('preHandler', async (req, _reply) => {
// Handle empty body for JSON requests (but not multipart)
if (req.headers['content-type']?.includes('application/json') && !req.isMultipart() && !req.body) {
req.body = {};
}
if (req.body && typeof req.body === 'object') {
req.body = convertDates(req.body);
}
});
function convertDates(obj: any): any {
if (Array.isArray(obj)) {
return obj.map(convertDates);
} else if (obj !== null && typeof obj === 'object') {
return Object.fromEntries(
Object.entries(obj).map(([key, value]) => {
if (typeof value === 'string' && isISODate(value)) {
return [key, new Date(value)];
} else if (typeof value === 'object') {
return [key, convertDates(value)];
}
return [key, value];
})
);
}
return obj;
}
function isISODate(_value: string): boolean {
try {
if (!_value) return false;
const value = _value.trim();
if (value.length < 10 || !value.includes('T')) return false;
if (!/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2})?/.test(value)) return false;
const date = new Date(value);
return !isNaN(date.getTime());
} catch (error) {
return false;
}
}
fastify.addHook('preValidation', async (req) => {
// Handle empty body for JSON requests (but not multipart)
if (req.headers['content-type']?.includes('application/json') && !req.isMultipart() && !req.body) {
req.body = {};
}
if (req.isMultipart()) {
const body: Record<string, any> = req.body as Record<string, any>;
for (const key in body) {
const value = body[key];
if (Array.isArray(value) && value[0] instanceof Buffer) {
const files = value.map((v: any) => req.tempFiles.find((f: UploadFile) => f.buffer === v));
body[key] = files.map((file: UploadFile) => file ? file.id : "file not found");
} else if (value instanceof Buffer) {
const file = req.tempFiles.find((f: UploadFile) => f.buffer === value);
body[key] = file ? file.id : "file not found";
} else if ('string' === typeof value) { // FIX MULTIPART STRING JSON
{
try {
body[key] = JSON.parse(value);
} catch (error) {
}
}
}
}
}
});
loadExtensions.push('extra');
loadExtensions.push('module');
loadExtensions.push('load');
for (const ext of loadExtensions) {
const extdi = `${ext}.di`;
await fileLoader(makeLoadPath(apiRelativePath, extdi), context.appDir, true);
await fileLoaderWithContext(makeLoadPath("", ext), context, context.appDir);
}
if (options?.plugins && options.plugins.length > 0) {
for (const plugin of options.plugins) {
if (plugin.beforeStart) {
try {
await plugin.beforeStart(context);
} catch (error) {
console.error(`ā ļø Error in plugin "${plugin.name}" during beforeStart:\n`, error.stack || error);
}
}
}
}
if (options?.beforeStart) {
try {
await options.beforeStart(context);
} catch (error) {
console.error(`ā ļø Error in beforeStart:\n`, error.stack || error);
}
}
try {
await getSyncQueueProvider().resolveAll();
await Promise.all(pendingBuilds);
try {
if (options?.preReady) {
await options.preReady(context);
}
if (options?.plugins && options.plugins.length > 0) {
for (const plugin of options.plugins) {
if (plugin.preReady) {
await plugin.preReady(context);
}
}
}
} catch (error) {
console.error(`Error in preReady:`, error);
}
await fastify.ready()
fastify.swagger();
const port = appOptions.PORT;
const appHost = appOptions.HOST;
const environment = context.environment;
await fastify.listen({ port });
console.log(passion(`š Server started at http://${appHost}:${port}\nšØļø Environment: ${environment}`));
console.log(vice(`Swagger UI is available at http://${appHost}:${port}${context.options?.swaggerUiOptions?.routePrefix}`));
if (options?.afterStart) {
try {
await options.afterStart(context);
} catch (error) {
console.error(error);
}
}
if (options?.plugins && options.plugins.length > 0) {
for (const plugin of options.plugins) {
if (plugin.afterStart) {
try {
await plugin.afterStart(context);
} catch (error) {
console.error(error);
}
}
}
}
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
try {
["SIGINT", "SIGTERM"].forEach(signal => {
process.on(signal, async () => {
console.log(
cristal(`
š Forced shutdown due to timeout.
š Some processes didn't close in time!
š Terminating immediately...
`))
await gracefulShutdown(fastify);
});
});
} catch (error) {
console.error(cristal("ā Error starting the server:"), error);
}
return context
} catch (error) {
console.error(cristal("ā Error starting the server:"), error);
}
return null;
}
const gracefulShutdown = async (server: FastifyInstance) => {
console.log(rainbow("⨠Almost done, cleaning up resources..."));
await server.close();
process.exit(0);
};