perf(api): кэш-заголовки статики, CORS по allowlist, раздельные health/ready, compress в Traefik

This commit is contained in:
Denozordec committed 2026-10-23 16:55:59 +07:00
1 parent 30d331c633
commit 1d7ea35a23
9 files changed
+269 -83

No files matched your search

+43 -12
View File
@@ -3,7 +3,6 @@ import Fastify from 'fastify'
import cors from '@fastify/cors'
import helmet from '@fastify/helmet'
import rateLimit from '@fastify/rate-limit'
import sensible from '@fastify/sensible'
import formbody from '@fastify/formbody'
import fjwt from '@fastify/jwt'
import fastifyStatic from '@fastify/static'
@@ -16,6 +15,7 @@ import {
import {
closeDb,
createDb,
getAppSwitcherConfig,
getUserApps,
healthCheck,
migrateSchema,
@@ -35,10 +35,17 @@ import { oidcRoutes } from './routes/oidc.js'
import { webauthnRoutes } from './routes/webauthn.js'
import { startAuditRetentionJob } from './services/audit-retention.js'
import { startHousekeepingJob } from './services/housekeeping.js'
import { ensureOidcSigningKey, resetOidcKeyCache } from './lib/oidc/keys.js'
import {
ensureOidcSigningKey,
isOidcKeyReady,
resetOidcKeyCache,
} from './lib/oidc/keys.js'
import { createOriginChecker } from './lib/cors.js'
/** Пути сервера: для них SPA-fallback не применяется (404 JSON вместо index.html). */
const API_PREFIXES = ['/api', '/oauth', '/.well-known']
const HASHED_ASSET_RE = /[\\/]assets[\\/]/
const INDEX_HTML_RE = /[\\/]index\.html$/
declare module 'fastify' {
interface FastifyInstance {
@@ -140,9 +147,14 @@ export async function buildApp(opts: {
})
await app.register(sensible)
const originAllowed = createOriginChecker(config, () =>
getAppSwitcherConfig(db).apps.map((a) => a.url),
)
await app.register(formbody)
await app.register(cors, { origin: true, credentials: true })
await app.register(cors, {
origin: (origin, cb) => cb(null, !origin || originAllowed(origin)),
credentials: true,
})
await app.register(helmet, { contentSecurityPolicy: false })
await app.register(rateLimit, {
max: 200,
@@ -152,13 +164,20 @@ export async function buildApp(opts: {
})
await app.register(fjwt, { secret: config.jwtSecret })
app.get('/health', async () => {
healthCheck(sqlite)
return { ok: true }
})
// liveness: процесс жив и отвечает; БД не трогаем, чтобы пробы не рестартили
// контейнер из-за временной блокировки.
app.get('/health', async () => ({ ok: true }))
app.get('/ready', async () => {
healthCheck(sqlite)
// readiness: БД отвечает и ключ подписи OIDC загружен.
app.get('/ready', async (_request, reply) => {
try {
healthCheck(sqlite)
} catch {
return reply.status(503).send({ ok: false, reason: 'db' })
}
if (!isOidcKeyReady()) {
return reply.status(503).send({ ok: false, reason: 'oidc_key' })
}
return { ok: true }
})
@@ -187,9 +206,21 @@ export async function buildApp(opts: {
await app.register(fastifyStatic, {
root: resolve(config.staticDir),
wildcard: false,
// Traefik перед приложением не сжимает; .gz/.br для статики готовит
// Dockerfile на этапе сборки (find … gzip/brotli), здесь только отдаём.
// .gz/.br для статики готовит Dockerfile на этапе сборки, здесь только отдаём.
preCompressed: true,
// Cache-Control выставляем сами (иначе плагин перезаписывает заголовок).
cacheControl: false,
setHeaders(res, filePath) {
if (HASHED_ASSET_RE.test(filePath)) {
// Vite кладёт хеш в имя файла → кэш на год без ревалидации.
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
} else if (INDEX_HTML_RE.test(filePath)) {
// Точка входа должна ревалидироваться, иначе клиент застрянет на старых чанках.
res.setHeader('Cache-Control', 'no-cache')
} else {
res.setHeader('Cache-Control', 'public, max-age=0')
}
},
})
}
const spa = Boolean(config.staticDir && existsSync(config.staticDir))