import Fastify from 'fastify' import fastifyStatic from '@fastify/static' import path from 'node:path' import { fileURLToPath } from 'node:url' import { redactInstance } from './core.js' import { registerStatusRoutes } from './status/routes.js' import { registerProxyRoutes } from './proxy/routes.js' import { ConfigNotFoundError, ConfigValidationError } from './config/errors.js' const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const FEATURES = ['passwordless-status', 'password-session-login', 'read-write-api-proxy', 'device-sim-network-sms-data-ota-summary', 'iframe-fallback'] const CATALOG = [ { id: 'overview', name: '总览', endpoints: ['/api/device', '/api/sim', '/api/network', '/api/stats', '/api/connectivity'] }, { id: 'sms', name: '短信', endpoints: ['/api/sms/stats', '/api/sms/list', '/api/sms/send', '/api/sms/conversation'] }, { id: 'network', name: '网络/小区', endpoints: ['/api/data', '/api/roaming', '/api/airplane-mode', '/api/radio-mode', '/api/band-lock', '/api/cell-lock', '/api/apn', '/api/cells'] }, { id: 'device-network', name: '设备网络', endpoints: ['/api/device-network/ddns/status', '/api/device-network/ddns/config', '/api/device-network/wlan/status', '/api/device-network/wlan/profiles'] }, { id: 'calls', name: '电话', endpoints: ['/api/calls', '/api/call/history', '/api/ims/status', '/api/voicemail/status'] }, { id: 'esim', name: 'eSIM', endpoints: ['/api/work-mode', '/api/esim/config', '/api/esim/lpac/status', '/api/esim/euicc', '/api/esim/profiles'] }, { id: 'notify-auto-ota', name: '通知/自动化/升级', endpoints: ['/api/notifications/config', '/api/notifications/logs', '/api/automation/config', '/api/automation/logs', '/api/ota/status'] }, ] export async function buildApp({ configStore, clientRegistry, logger = false, staticFiles = true, publicDir = path.join(ROOT, 'public'), reconcileRetryMs = 100 } = {}) { if (!configStore || !clientRegistry) throw new TypeError('configStore and clientRegistry are required') const app = Fastify({ logger, bodyLimit: 60 * 1024 * 1024 }) app.addContentTypeParser('application/octet-stream', { parseAs: 'buffer' }, (_request, body, done) => done(null, body)) app.addContentTypeParser(/^multipart\//, { parseAs: 'buffer' }, (_request, body, done) => done(null, body)) if (staticFiles) await app.register(fastifyStatic, { root: publicDir, prefix: '/' }) const findClient = (id, reply) => { const client = clientRegistry.get(id) if (!client) reply.code(404).send({ error: 'instance not found' }) return client } const configError = (reply, error) => reply.code(error instanceof ConfigNotFoundError ? 404 : error instanceof ConfigValidationError ? 400 : 500).send({ error: String(error.message || error) }) let reconcileError = null let retryTimer = null const reconcile = () => { try { clientRegistry.reconcile(configStore.snapshot.instances); reconcileError = null; return true } catch (error) { reconcileError = error if (!retryTimer) retryTimer = setTimeout(() => { retryTimer = null; reconcile() }, reconcileRetryMs) return false } } const committed = async action => { const result = await action() return { result, reconciled: reconcile() } } app.get('/api/health', async () => ({ ok: true })) app.get('/api/ready', async () => reconcileError ? ({ ready: false, degraded: true, reason: 'registry_reconcile_pending' }) : ({ ready: true })) app.get('/api/config', async () => ({ configPath: configStore.configPath, instances: configStore.snapshot.instances.map(redactInstance), features: FEATURES })) app.get('/api/instances', async () => ({ instances: configStore.snapshot.instances.map(redactInstance) })) app.post('/api/instances', async (request, reply) => { try { const { result, reconciled } = await committed(() => configStore.add(request.body || {})); return { instance: redactInstance(result), configPath: configStore.configPath, ...(reconciled ? {} : { reconcilePending: true }) } } catch (error) { return configError(reply, error) } }) app.put('/api/instances/:id', async (request, reply) => { try { const { result, reconciled } = await committed(() => configStore.update(request.params.id, request.body || {})); return { instance: redactInstance(result), configPath: configStore.configPath, ...(reconciled ? {} : { reconcilePending: true }) } } catch (error) { return configError(reply, error) } }) app.delete('/api/instances/:id', async (request, reply) => { try { const { result, reconciled } = await committed(() => configStore.delete(request.params.id)); return { removed: redactInstance(result), configPath: configStore.configPath, ...(reconciled ? {} : { reconcilePending: true }) } } catch (error) { return configError(reply, error) } }) registerStatusRoutes(app, { registry: clientRegistry, findClient }) app.post('/api/instances/:id/login', async (request, reply) => { const client = findClient(request.params.id, reply); if (!client) return const supplied = Object.hasOwn(request.body || {}, 'password') return { ...(await client.ensureAuthenticated(supplied ? { credential: request.body.password } : {})), hasSavedPassword: Boolean(configStore.snapshot.instances.find(item => item.id === request.params.id)?.auth?.password) } }) app.post('/api/instances/:id/logout', async (request, reply) => { const client = findClient(request.params.id, reply); if (!client) return try { await client.fetchJson('/api/auth/logout', { method: 'POST' }) } catch {} client.jar.clear(); client.clearEphemeralSecret?.(); return { ok: true } }) registerProxyRoutes(app, { findClient }) app.get('/api/catalog', async () => ({ groups: CATALOG })) app.get('/api/reload-note', async () => ({ message: 'Configuration changes are applied immediately.' })) app.setNotFoundHandler(async (request, reply) => request.url.startsWith('/api/') ? reply.code(404).send({ error: 'not found' }) : staticFiles ? reply.sendFile('index.html') : reply.code(404).send({ error: 'not found' })) app.addHook('onClose', async () => { if (retryTimer) clearTimeout(retryTimer); clientRegistry.close() }) return app }