TypeScript 61.9%
HTML 37.2%
SQL 0.7%
1import Fastify, { type FastifyInstance } from 'fastify';2import cors from '@fastify/cors';3import compress from '@fastify/compress';4import etag from '@fastify/etag';5import rateLimit from '@fastify/rate-limit';6import { randomUUID } from 'node:crypto';7import { getSql } from '@rareindex/database';8import { authPlugin, rateKeyFor, rateLimitFor } from './plugins/auth.js';9import { v1Routes } from './routes/v1.js';10import { internalRoutes } from './routes/internal.js';11import { problem } from './lib/envelope.js';1213export interface BuildOptions {14 logger?: boolean;15}1617export async function buildApp(opts: BuildOptions = {}): Promise<FastifyInstance> {18 const app = Fastify({19 logger: opts.logger === false ? false : { level: process.env.LOG_LEVEL ?? 'info', redact: ['req.headers.authorization'] },20 genReqId: (req) => (req.headers['x-request-id'] as string | undefined) ?? randomUUID(),21 trustProxy: true,22 });2324 await app.register(cors, { origin: true, methods: ['GET', 'HEAD', 'OPTIONS'], exposedHeaders: ['x-request-id', 'x-ratelimit-limit', 'x-ratelimit-remaining', 'x-ratelimit-reset'] });25 await app.register(compress, { global: true, encodings: ['gzip', 'br', 'deflate'] });26 await app.register(etag);27 await app.register(authPlugin);28 await app.register(rateLimit, {29 global: true,30 max: (req) => rateLimitFor(req),31 timeWindow: '1 minute',32 keyGenerator: (req) => rateKeyFor(req),33 errorResponseBuilder: (req, ctx) => ({34 statusCode: 429,35 type: 'https://www.rareindex.io/api-docs#errors',36 title: 'Rate limit exceeded',37 status: 429,38 detail: `Limit ${ctx.max} requests per ${ctx.after}. Retry after ${ctx.ttl} ms.`,39 instance: req.url,40 request_id: req.id,41 }),42 });4344 app.addHook('onSend', async (req, reply, payload) => {45 reply.header('x-request-id', req.id);46 return payload;47 });4849 app.setErrorHandler((err: Error & { statusCode?: number; status?: number; title?: string; detail?: string }, req, reply) => {50 const status = err.statusCode ?? err.status ?? 500;51 if (status >= 500) req.log.error({ err }, 'unhandled api error');52 const title = status >= 500 ? 'Internal error' : (err.title ?? err.message);53 const detail = status >= 500 ? 'The request could not be completed.' : (err.detail ?? err.message);54 return problem(reply, status, title, detail);55 });56 app.setNotFoundHandler((req, reply) => problem(reply, 404, 'Not found', `No route for ${req.method} ${req.url}`));5758 app.get('/healthz', async (_req, reply) => {59 try {60 await getSql()`select 1`;61 return { ok: true, service: 'rareindex-api', time: new Date().toISOString() };62 } catch (err) {63 return reply.code(503).send({ ok: false, service: 'rareindex-api', error: err instanceof Error ? err.message : String(err) });64 }65 });66 app.get('/', async () => ({ name: 'RareIndex Public API', docs: 'https://www.rareindex.io/api-docs', openapi: '/v1/openapi.json' }));6768 await app.register(v1Routes);69 await app.register(internalRoutes);70 return app;71}72