[verified] refactor: establish service and frontend boundaries
This commit is contained in:
+22
-143
@@ -1,151 +1,30 @@
|
||||
import Fastify from 'fastify'
|
||||
import fastifyStatic from '@fastify/static'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import {
|
||||
addInstanceToConfig,
|
||||
buildClients,
|
||||
collectInstanceStatus,
|
||||
createSimAdminClient,
|
||||
deleteInstanceFromConfig,
|
||||
loadConfig,
|
||||
proxyToInstance,
|
||||
redactInstance,
|
||||
updateInstanceInConfig,
|
||||
} from './core.js'
|
||||
import { buildApp } from './app.js'
|
||||
import { FileConfigStore } from './config/file-config-store.js'
|
||||
import { ClientRegistry } from './clients/client-registry.js'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const ROOT = path.resolve(__dirname, '..')
|
||||
const PUBLIC_DIR = path.join(ROOT, 'public')
|
||||
const CONFIG_PATH = process.env.MULTI_SIMADMIN_CONFIG || path.join(ROOT, 'config.json')
|
||||
const DEFAULT_CONFIG_PATH = path.join(ROOT, 'config.example.json')
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const configPath = process.env.MULTI_SIMADMIN_CONFIG || path.join(root, 'config.json')
|
||||
const examplePath = path.join(root, 'config.example.json')
|
||||
const configStore = await FileConfigStore.open({ configPath, examplePath })
|
||||
const clientRegistry = new ClientRegistry().reconcile(configStore.snapshot.instances)
|
||||
const app = await buildApp({ configStore, clientRegistry, logger: true })
|
||||
|
||||
const config = await loadConfig({ configPath: CONFIG_PATH, defaultConfigPath: DEFAULT_CONFIG_PATH })
|
||||
const clients = buildClients(config.instances)
|
||||
const app = Fastify({ logger: true, bodyLimit: 60 * 1024 * 1024 })
|
||||
|
||||
await app.register(fastifyStatic, { root: PUBLIC_DIR, prefix: '/' })
|
||||
|
||||
function clientById(id, reply) {
|
||||
const client = clients.get(id)
|
||||
if (!client) reply.code(404).send({ error: 'instance not found' })
|
||||
return client
|
||||
let closing = false
|
||||
async function shutdown(signal) {
|
||||
if (closing) return
|
||||
closing = true
|
||||
app.log.info({ signal }, 'shutting down')
|
||||
await app.close()
|
||||
}
|
||||
|
||||
function sendConfigError(reply, error) {
|
||||
const message = String(error?.message || error)
|
||||
const status = /not found/.test(message) ? 404 : /duplicate|required|invalid|URL/.test(message) ? 400 : 500
|
||||
return reply.code(status).send({ error: message })
|
||||
}
|
||||
|
||||
function refreshClientFor(instance) {
|
||||
clients.set(instance.id, createSimAdminClient(instance))
|
||||
}
|
||||
|
||||
app.get('/api/config', async () => ({
|
||||
configPath: config.configPath,
|
||||
instances: config.instances.map(redactInstance),
|
||||
features: [
|
||||
'passwordless-status',
|
||||
'password-session-login',
|
||||
'read-write-api-proxy',
|
||||
'device-sim-network-sms-data-ota-summary',
|
||||
'iframe-fallback',
|
||||
],
|
||||
}))
|
||||
|
||||
app.get('/api/instances', async () => ({ instances: config.instances.map(redactInstance) }))
|
||||
|
||||
app.post('/api/instances', async (request, reply) => {
|
||||
try {
|
||||
const instance = await addInstanceToConfig(config, request.body || {})
|
||||
refreshClientFor(instance)
|
||||
return { instance: redactInstance(instance), configPath: config.configPath }
|
||||
} catch (error) {
|
||||
return sendConfigError(reply, error)
|
||||
}
|
||||
})
|
||||
|
||||
app.put('/api/instances/:id', async (request, reply) => {
|
||||
try {
|
||||
const oldId = request.params.id
|
||||
const instance = await updateInstanceInConfig(config, oldId, request.body || {})
|
||||
if (instance.id !== oldId) clients.delete(oldId)
|
||||
refreshClientFor(instance)
|
||||
return { instance: redactInstance(instance), configPath: config.configPath }
|
||||
} catch (error) {
|
||||
return sendConfigError(reply, error)
|
||||
}
|
||||
})
|
||||
|
||||
app.delete('/api/instances/:id', async (request, reply) => {
|
||||
try {
|
||||
const removed = await deleteInstanceFromConfig(config, request.params.id)
|
||||
clients.delete(removed.id)
|
||||
return { removed: redactInstance(removed), configPath: config.configPath }
|
||||
} catch (error) {
|
||||
return sendConfigError(reply, error)
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/status', async () => ({
|
||||
instances: await Promise.all([...clients.values()].map(collectInstanceStatus)),
|
||||
}))
|
||||
|
||||
app.get('/api/status/:id', async (request, reply) => {
|
||||
const client = clientById(request.params.id, reply)
|
||||
if (!client) return
|
||||
return collectInstanceStatus(client)
|
||||
})
|
||||
|
||||
app.post('/api/instances/:id/login', async (request, reply) => {
|
||||
const client = clientById(request.params.id, reply)
|
||||
if (!client) return
|
||||
const password = request.body?.password || client.instance.auth?.password || ''
|
||||
if (password) client.instance.auth.password = String(password)
|
||||
const result = await client.ensureAuthenticated()
|
||||
return { ...result, hasSavedPassword: Boolean(client.instance.auth?.password) }
|
||||
})
|
||||
|
||||
app.post('/api/instances/:id/logout', async (request, reply) => {
|
||||
const client = clientById(request.params.id, reply)
|
||||
if (!client) return
|
||||
try { await client.fetchJson('/api/auth/logout', { method: 'POST' }) } catch {}
|
||||
client.jar.clear()
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
app.all('/api/proxy/:id/*', async (request, reply) => {
|
||||
const client = clientById(request.params.id, reply)
|
||||
if (!client) return
|
||||
return proxyToInstance({ client, request, reply, rest: request.params['*'] })
|
||||
})
|
||||
|
||||
app.get('/api/catalog', async () => ({
|
||||
groups: [
|
||||
{ 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'] },
|
||||
],
|
||||
}))
|
||||
|
||||
app.get('/api/reload-note', async () => ({ message: 'Edit config.json and restart this process to apply changes.' }))
|
||||
|
||||
app.setNotFoundHandler(async (request, reply) => {
|
||||
if (request.url.startsWith('/api/')) return reply.code(404).send({ error: 'not found' })
|
||||
return reply.sendFile('index.html')
|
||||
})
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => shutdown(signal).catch(error => { app.log.error(error); process.exitCode = 1 }))
|
||||
|
||||
try {
|
||||
await app.listen({ host: config.server.host, port: config.server.port })
|
||||
app.log.info(`Multi SimAdmin listening on http://${config.server.host}:${config.server.port}`)
|
||||
app.log.info(`Loaded ${config.instances.length} instance(s) from ${config.configPath}`)
|
||||
} catch (err) {
|
||||
app.log.error(err)
|
||||
process.exit(1)
|
||||
await app.listen({ ...configStore.snapshot.server })
|
||||
app.log.info(`Loaded ${configStore.snapshot.instances.length} instance(s) from ${configPath}`)
|
||||
} catch (error) {
|
||||
app.log.error(error)
|
||||
await app.close().catch(() => {})
|
||||
process.exitCode = 1
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user