import Fastify, { type FastifyInstance } from 'fastify'; import cors from '@fastify/cors'; import compress from '@fastify/compress'; import etag from '@fastify/etag'; import rateLimit from '@fastify/rate-limit'; import { randomUUID } from 'node:crypto'; import { getSql } from '@rareindex/database'; import { authPlugin, rateKeyFor, rateLimitFor } from './plugins/auth.js'; import { v1Routes } from './routes/v1.js'; import { internalRoutes } from './routes/internal.js'; import { problem } from './lib/envelope.js'; export interface BuildOptions { logger?: boolean; } export async function buildApp(opts: BuildOptions = {}): Promise { const app = Fastify({ logger: opts.logger === false ? false : { level: process.env.LOG_LEVEL ?? 'info', redact: ['req.headers.authorization'] }, genReqId: (req) => (req.headers['x-request-id'] as string | undefined) ?? randomUUID(), trustProxy: true, }); await app.register(cors, { origin: true, methods: ['GET', 'HEAD', 'OPTIONS'], exposedHeaders: ['x-request-id', 'x-ratelimit-limit', 'x-ratelimit-remaining', 'x-ratelimit-reset'] }); await app.register(compress, { global: true, encodings: ['gzip', 'br', 'deflate'] }); await app.register(etag); await app.register(authPlugin); await app.register(rateLimit, { global: true, max: (req) => rateLimitFor(req), timeWindow: '1 minute', keyGenerator: (req) => rateKeyFor(req), errorResponseBuilder: (req, ctx) => ({ statusCode: 429, type: 'https://www.rareindex.io/api-docs#errors', title: 'Rate limit exceeded', status: 429, detail: `Limit ${ctx.max} requests per ${ctx.after}. Retry after ${ctx.ttl} ms.`, instance: req.url, request_id: req.id, }), }); app.addHook('onSend', async (req, reply, payload) => { reply.header('x-request-id', req.id); return payload; }); app.setErrorHandler((err: Error & { statusCode?: number; status?: number; title?: string; detail?: string }, req, reply) => { const status = err.statusCode ?? err.status ?? 500; if (status >= 500) req.log.error({ err }, 'unhandled api error'); const title = status >= 500 ? 'Internal error' : (err.title ?? err.message); const detail = status >= 500 ? 'The request could not be completed.' : (err.detail ?? err.message); return problem(reply, status, title, detail); }); app.setNotFoundHandler((req, reply) => problem(reply, 404, 'Not found', `No route for ${req.method} ${req.url}`)); app.get('/healthz', async (_req, reply) => { try { await getSql()`select 1`; return { ok: true, service: 'rareindex-api', time: new Date().toISOString() }; } catch (err) { return reply.code(503).send({ ok: false, service: 'rareindex-api', error: err instanceof Error ? err.message : String(err) }); } }); app.get('/', async () => ({ name: 'RareIndex Public API', docs: 'https://www.rareindex.io/api-docs', openapi: '/v1/openapi.json' })); await app.register(v1Routes); await app.register(internalRoutes); return app; }