[verified] refactor: establish service and frontend boundaries

This commit is contained in:
chick
2026-07-15 02:43:41 +08:00
parent 0ac6faa6a3
commit e04a16e817
29 changed files with 917 additions and 598 deletions
+6
View File
@@ -0,0 +1,6 @@
export class ConfigValidationError extends Error {
constructor(message) { super(message); this.name = 'ConfigValidationError' }
}
export class ConfigNotFoundError extends Error {
constructor(message) { super(message); this.name = 'ConfigNotFoundError' }
}
+89
View File
@@ -0,0 +1,89 @@
import { readFile, open, rename, unlink } from 'node:fs/promises'
import path from 'node:path'
import { normalizeConfig, normalizeInstance, serializeConfig, immutableSnapshot } from './schema.js'
import { ConfigNotFoundError, ConfigValidationError } from './errors.js'
export async function atomicWriteJson(destination, value) {
const temp = path.join(path.dirname(destination), `.${path.basename(destination)}.${process.pid}.${crypto.randomUUID()}.tmp`)
try {
const handle = await open(temp, 'wx', 0o600)
try {
await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`)
await handle.sync()
} finally { await handle.close() }
await rename(temp, destination)
try {
const directory = await open(path.dirname(destination), 'r')
try { await directory.sync() } finally { await directory.close() }
} catch (error) {
if (!['EINVAL', 'ENOTSUP', 'EISDIR', 'EPERM', 'EACCES'].includes(error.code)) throw error
}
} catch (error) {
await unlink(temp).catch(() => {})
throw error
}
}
export class FileConfigStore {
#snapshot
#queue = Promise.resolve()
constructor({ configPath, examplePath, snapshot, atomicWrite = atomicWriteJson, env = process.env }) {
this.configPath = configPath
this.examplePath = examplePath
this.atomicWrite = atomicWrite
this.env = { ...env }
this.#snapshot = immutableSnapshot({ ...snapshot, configPath })
}
static async open({ configPath, examplePath, atomicWrite, env } = {}) {
let text
try { text = await readFile(configPath, 'utf8') }
catch (error) {
if (error.code !== 'ENOENT') throw error
if (!examplePath) throw new ConfigNotFoundError(`config not found: ${configPath}`)
text = await readFile(examplePath, 'utf8')
}
const effectiveEnv = env || process.env
return new FileConfigStore({ configPath, examplePath, atomicWrite, env: effectiveEnv, snapshot: normalizeConfig(JSON.parse(text), effectiveEnv) })
}
get snapshot() { return this.#snapshot }
transact(mutator) {
const operation = this.#queue.then(async () => {
const draft = structuredClone(this.#snapshot)
const result = await mutator(draft)
const normalized = normalizeConfig(serializeConfig(draft), this.env)
await this.atomicWrite(this.configPath, serializeConfig(normalized))
this.#snapshot = immutableSnapshot({ ...normalized, configPath: this.configPath })
return result?.id ? this.#snapshot.instances.find(item => item.id === result.id) || result : result
})
this.#queue = operation.catch(() => {})
return operation
}
add(payload) {
return this.transact(draft => {
const next = normalizeInstance(payload, draft.instances.length)
if (draft.instances.some(item => item.id === next.id)) throw new ConfigValidationError(`duplicate instance id: ${next.id}`)
draft.instances.push(next)
return next
})
}
update(id, payload) {
return this.transact(draft => {
const index = draft.instances.findIndex(item => item.id === id)
if (index < 0) throw new ConfigNotFoundError(`instance not found: ${id}`)
const current = draft.instances[index]
const merged = { ...current, ...payload, id: payload.id ? String(payload.id).trim() : current.id,
auth: payload.auth !== undefined || payload.password !== undefined ? payload.auth || { password: payload.password || '' } : current.auth }
const next = normalizeInstance(merged, index)
if (next.id !== id && draft.instances.some(item => item.id === next.id)) throw new ConfigValidationError(`duplicate instance id: ${next.id}`)
draft.instances[index] = next
return next
})
}
delete(id) {
return this.transact(draft => {
const index = draft.instances.findIndex(item => item.id === id)
if (index < 0) throw new ConfigNotFoundError(`instance not found: ${id}`)
return draft.instances.splice(index, 1)[0]
})
}
}
+71
View File
@@ -0,0 +1,71 @@
import { ConfigValidationError } from './errors.js'
export function normalizeBaseUrl(rawUrl) {
let url
try { url = new URL(rawUrl) } catch { throw new ConfigValidationError('instance URL is invalid') }
if (!['http:', 'https:'].includes(url.protocol)) throw new ConfigValidationError('instance URL must use http or https')
if (url.username || url.password) throw new ConfigValidationError('instance URL must not contain credentials')
url.hash = ''
url.search = ''
return url.toString().replace(/\/$/, '')
}
export function normalizeInstance(instance, index = 0) {
if (!instance || typeof instance !== 'object') throw new ConfigValidationError(`instances[${index}] must be an object`)
const id = String(instance.id || '').trim()
const name = String(instance.name || id || `SimAdmin ${index + 1}`).trim()
const rawUrl = String(instance.url || '').trim()
if (!id || !/^[a-zA-Z0-9_.-]+$/.test(id)) throw new ConfigValidationError(`instances[${index}].id is required and may only contain letters, numbers, _, -, .`)
if (!rawUrl) throw new ConfigValidationError(`instances[${index}].url is required`)
const password = instance.auth?.password ?? instance.password ?? ''
const mode = password ? 'password' : String(instance.auth?.mode || 'none')
if (!['none', 'password'].includes(mode)) throw new ConfigValidationError(`instances[${index}].auth.mode must be none or password`)
return {
id, name, url: normalizeBaseUrl(rawUrl),
description: String(instance.description || '').trim(),
tags: Array.isArray(instance.tags) ? instance.tags.map(String) : [],
auth: { mode, password: password ? String(password) : '' },
capabilities: Array.isArray(instance.capabilities) ? instance.capabilities.map(String) : [],
}
}
export function normalizeConfig(raw = {}, env = process.env) {
const instances = Array.isArray(raw.instances) ? raw.instances.map(normalizeInstance) : []
const ids = new Set()
for (const item of instances) {
if (ids.has(item.id)) throw new ConfigValidationError(`duplicate instance id: ${item.id}`)
ids.add(item.id)
}
const port = Number(env.PORT ?? raw.server?.port ?? 8788)
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new ConfigValidationError('server.port must be an integer from 1 to 65535')
return {
server: { host: raw.server?.host ?? env.HOST ?? '127.0.0.1', port },
instances,
raw: { ...raw, instances },
}
}
export function serializeConfig(snapshot) {
return {
...(snapshot.raw || {}),
server: snapshot.raw?.server || snapshot.server,
instances: snapshot.instances.map(instance => ({
id: instance.id, name: instance.name, url: instance.url,
description: instance.description || '', tags: instance.tags || [],
auth: instance.auth?.password ? { mode: 'password', password: instance.auth.password } : { mode: 'none' },
capabilities: instance.capabilities || [],
})),
}
}
export function immutableSnapshot(value) {
const clone = structuredClone(value)
const freeze = object => {
if (object && typeof object === 'object' && !Object.isFrozen(object)) {
Object.freeze(object)
for (const item of Object.values(object)) freeze(item)
}
return object
}
return freeze(clone)
}