app.ts
3,849 bytes
| 1 | import cors from '@fastify/cors' |
|---|---|
| 2 | import multipart from '@fastify/multipart' |
| 3 | import fastifyStatic from '@fastify/static' |
| 4 | import Fastify, { type FastifyInstance } from 'fastify' |
| 5 | import { createLazyInterviewLlm, createLazySttProvider } from './providers/factory' |
| 6 | import type { InterviewLlm, SttProvider } from './providers/types' |
| 7 | import { registerAudioRoutes } from './routes/audio' |
| 8 | import { registerBlockerRoutes } from './routes/blockers' |
| 9 | import { registerConfigRoutes } from './routes/config' |
| 10 | import { registerExportRoutes } from './routes/export' |
| 11 | import { registerFsRoutes } from './routes/fs' |
| 12 | import { registerGenerateRoutes } from './routes/generate' |
| 13 | import { registerSessionRoutes } from './routes/sessions' |
| 14 | import { createDefaultSessionStore, SessionStore } from './store/sessionStore' |
| 15 | |
| 16 | export interface AppDeps { |
| 17 | store: SessionStore |
| 18 | llm: InterviewLlm |
| 19 | stt: SttProvider |
| 20 | demoLocked: boolean |
| 21 | // Overridable so tests never write the real .env of this repository. |
| 22 | envPath?: string |
| 23 | env?: NodeJS.ProcessEnv |
| 24 | /** |
| 25 | * Public deployment mode: the app is served to strangers on the internet |
| 26 | * rather than to the one person sitting at the machine it runs on. |
| 27 | * |
| 28 | * Everything VoiceTask does with the local disk assumes the second case, so |
| 29 | * this mode takes those powers away: no folder browsing, no writing to a path |
| 30 | * the browser chose, no editing the provider keys, and no CORS reflection. |
| 31 | */ |
| 32 | publicDemo: boolean |
| 33 | /** Sandbox every session writes into. Required when publicDemo is set. */ |
| 34 | sandboxRoot?: string |
| 35 | /** Built client to serve from the same origin as the API. */ |
| 36 | clientDir?: string |
| 37 | } |
| 38 | |
| 39 | export function buildApp(deps: Partial<AppDeps> = {}): FastifyInstance { |
| 40 | const resolved: AppDeps = { |
| 41 | store: deps.store ?? createDefaultSessionStore(), |
| 42 | llm: deps.llm ?? createLazyInterviewLlm(), |
| 43 | stt: deps.stt ?? createLazySttProvider(), |
| 44 | demoLocked: deps.demoLocked ?? false, |
| 45 | envPath: deps.envPath, |
| 46 | env: deps.env, |
| 47 | publicDemo: deps.publicDemo ?? false, |
| 48 | sandboxRoot: deps.sandboxRoot, |
| 49 | clientDir: deps.clientDir, |
| 50 | } |
| 51 | |
| 52 | if (resolved.publicDemo && !resolved.sandboxRoot) { |
| 53 | throw new Error('publicDemo needs a sandboxRoot; refusing to let the browser pick a directory') |
| 54 | } |
| 55 | |
| 56 | const app = Fastify({ logger: false }) |
| 57 | // A public instance is served from one origin and needs no cross-origin |
| 58 | // access at all; reflecting every Origin back would let any page on the web |
| 59 | // drive it from a visitor's browser. |
| 60 | void app.register(cors, { origin: resolved.publicDemo ? false : true }) |
| 61 | void app.register(multipart, { limits: { fileSize: 25 * 1024 * 1024 } }) |
| 62 | |
| 63 | app.get('/api/health', async () => ({ ok: true })) |
| 64 | registerSessionRoutes(app, resolved) |
| 65 | registerAudioRoutes(app, resolved) |
| 66 | registerGenerateRoutes(app, resolved) |
| 67 | registerBlockerRoutes(app, resolved) |
| 68 | // Browsing and creating folders is a local convenience. On a public host it |
| 69 | // is an unauthenticated read of the container filesystem, so it is left out. |
| 70 | if (!resolved.publicDemo) registerFsRoutes(app) |
| 71 | registerExportRoutes(app, resolved) |
| 72 | registerConfigRoutes(app, { |
| 73 | demoLocked: resolved.demoLocked, |
| 74 | envPath: resolved.envPath, |
| 75 | env: resolved.env, |
| 76 | publicDemo: resolved.publicDemo, |
| 77 | }) |
| 78 | |
| 79 | if (resolved.clientDir) registerClient(app, resolved.clientDir) |
| 80 | |
| 81 | return app |
| 82 | } |
| 83 | |
| 84 | /** |
| 85 | * Serve the built client beside the API so both live on one origin. Unknown |
| 86 | * non-API paths fall back to index.html, which is what a single-page app needs |
| 87 | * for a deep link to survive a reload. |
| 88 | */ |
| 89 | function registerClient(app: FastifyInstance, root: string): void { |
| 90 | void app.register(fastifyStatic, { root, wildcard: false }) |
| 91 | app.setNotFoundHandler((request, reply) => { |
| 92 | if (request.url.startsWith('/api/')) return reply.code(404).send({ error: 'not found' }) |
| 93 | return reply.sendFile('index.html') |
| 94 | }) |
| 95 | } |
| 96 | |