Files
MoShiro/web/shared/api/source.ts
T
justme 83e5120377 refactor!: rename the project from Monitor to MoShiro
Namespaces, assemblies, projects and directories, the solution, binaries,
images, systemd units, config paths, environment variables, protocol method
names, the media type and the raw chunk magic all move to MoShiro/moshiro.
Existing data and deployments are not compatible and must be recreated.
Also fix the architecture tests after the solution layout change and make
the update tests independent of their order.
2026-10-07 01:49:04 +08:00

193 lines
6.7 KiB
TypeScript

/**
* 数据源:前端默认和后端同源(Cookie 会话)。也可以指到别的后端(如本机跑前端、连线上的 `https://mo.oeo.one`):
* 地址和令牌存在浏览器里(每个前端各存一份令牌),也可以用构建时的 `VITE_API_BASE` 给个默认地址。
* 跨域时不带 Cookie,只用 `Authorization: Bearer <API 令牌>`;后端要在 `MoShiro__CorsOrigins` 里放行这个前端的来源。
*/
const BASE_KEY = 'moshiro.api_base'
let app = 'app'
/** 每个前端启动时调用一次,令牌按前端分开存(展示端用只读令牌,管理端用管理令牌)。 */
export function configureSource(name: string) {
app = name
}
function read(key: string): string {
try {
return localStorage.getItem(key) ?? ''
} catch {
return ''
}
}
function write(key: string, value: string) {
try {
if (value) localStorage.setItem(key, value)
else localStorage.removeItem(key)
} catch {
// 浏览器禁用存储时只是不记住
}
}
export function normalizeBase(input: string): string {
const text = input.trim().replace(/\/+$/, '')
if (!text) return ''
return /^https?:\/\//i.test(text) ? text : `https://${text}`
}
/** 后端地址(不带末尾斜杠);空串 = 同源。 */
export function getBase(): string {
return normalizeBase(read(BASE_KEY) || (import.meta.env.VITE_API_BASE as string | undefined) || '')
}
export interface SavedSource {
base: string
/** 在这个后端登录之后拿到的会话(只存在浏览器里);空 = 还没登录。 */
token: string
}
// v2:以前保存的是手填的管理 Token,现在是登录后拿到的会话,旧的不再使用
const listKey = () => `moshiro.sources.v2.${app}`
function readList(): SavedSource[] {
try {
const parsed = JSON.parse(read(listKey()) || '[]') as unknown
return Array.isArray(parsed) ? (parsed as SavedSource[]).filter((x) => typeof x?.base === 'string' && x.base !== '') : []
} catch {
return []
}
}
/** 保存过的后端(每个前端各存各的登录会话);当前连接的那个一定在里面。不含这个页面自己所在的后端。 */
export function listSources(): SavedSource[] {
const list = readList()
const active = getBase()
if (active && !list.some((x) => x.base === active)) list.push({ base: active, token: '' })
return list
}
function writeList(list: SavedSource[]) {
write(listKey(), JSON.stringify(list))
}
/** 当前连接的后端的登录会话;这个页面自己所在的后端用 Cookie,没有。 */
export function getToken(): string {
const active = getBase()
return active ? (readList().find((x) => x.base === active)?.token ?? '') : ''
}
/** 保存(或更新)一个后端和它的登录会话,并连过去。地址留空 = 这个页面自己所在的后端。 */
export function setSource(base: string, token: string) {
const next = normalizeBase(base)
if (next) writeList([...readList().filter((x) => x.base !== next), { base: next, token }])
write(BASE_KEY, next)
}
/** 切换到已经保存过的后端(空串 = 这个页面自己所在的后端)。 */
export function activateSource(base: string) {
write(BASE_KEY, normalizeBase(base))
}
/** 忘掉一个保存过的后端;正连着的话回到这个页面自己所在的后端。 */
export function removeSource(base: string) {
writeList(readList().filter((x) => x.base !== base))
if (getBase() === base) write(BASE_KEY, '')
}
/** 只改当前连接的后端的登录会话(过期、退出时清掉)。 */
export function setToken(token: string) {
const active = getBase()
if (!active) return
writeList([...readList().filter((x) => x.base !== active), { base: active, token }])
}
export const isRemote = () => getBase() !== ''
/** 拼出请求地址:同源时原样返回。 */
export function resolveUrl(path: string): string {
return getBase() + path
}
export function authHeaders(): Record<string, string> {
const token = getToken()
return isRemote() && token ? { Authorization: `Bearer ${token}` } : {}
}
/**
* 当前后端连不连得上:只看有没有响应(跨域时拿不到内容也没关系,所以用 no-cors);网络不通、域名解析失败、连接被拒才算失败。
*/
export async function pingBackend(): Promise<boolean> {
try {
await fetch(resolveUrl('/healthz'), { mode: 'no-cors', cache: 'no-store' })
return true
} catch {
return false
}
}
export interface LiveSource {
onopen: (() => void) | null
onerror: (() => void) | null
addEventListener: (type: string, listener: (event: MessageEvent<string>) => void) => void
close: () => void
}
/**
* SSE 订阅。同源用浏览器的 EventSource;跨域时 EventSource 带不了 Authorization 头,
* 所以用 fetch 读流并自己解析,断开后 3 秒重连。
*/
export function openLive(path: string): LiveSource {
if (!isRemote()) return new EventSource(path) as unknown as LiveSource
const listeners = new Map<string, ((event: MessageEvent<string>) => void)[]>()
const source: LiveSource = {
onopen: null,
onerror: null,
addEventListener: (type, listener) => listeners.set(type, [...(listeners.get(type) ?? []), listener]),
close: () => {
closed = true
controller?.abort()
},
}
let closed = false
let controller: AbortController | undefined
const run = async () => {
while (!closed) {
controller = new AbortController()
try {
const response = await fetch(resolveUrl(path), { headers: { Accept: 'text/event-stream', ...authHeaders() }, signal: controller.signal })
if (!response.ok || !response.body) throw new Error(String(response.status))
source.onopen?.()
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader()
let buffer = ''
for (;;) {
const { value, done } = await reader.read()
if (done) break
buffer += value
let at: number
while ((at = buffer.search(/\r?\n\r?\n/)) >= 0) {
const block = buffer.slice(0, at)
buffer = buffer.slice(at).replace(/^\r?\n\r?\n/, '')
let type = 'message'
const data: string[] = []
for (const line of block.split(/\r?\n/)) {
if (line.startsWith('event:')) type = line.slice(6).trim()
else if (line.startsWith('data:')) data.push(line.slice(5).replace(/^ /, ''))
}
if (data.length > 0) for (const l of listeners.get(type) ?? []) l({ data: data.join('\n') } as MessageEvent<string>)
}
}
} catch {
// 下面统一报错并重连
}
if (closed) return
source.onerror?.()
await new Promise((resolve) => setTimeout(resolve, 3000))
}
}
void run()
return source
}