/** * 数据源:前端默认和后端同源(Cookie 会话)。也可以指到别的后端(如本机跑前端、连线上的 `https://mo.oeo.one`): * 地址和令牌存在浏览器里(每个前端各存一份令牌),也可以用构建时的 `VITE_API_BASE` 给个默认地址。 * 跨域时不带 Cookie,只用 `Authorization: Bearer `;后端要在 `Monitor__CorsOrigins` 里放行这个前端的来源。 */ const BASE_KEY = 'monitor.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 function getToken(): string { return read(`monitor.api_token.${app}`) } export function setSource(base: string, token: string) { write(BASE_KEY, normalizeBase(base)) write(`monitor.api_token.${app}`, token.trim()) } export function setToken(token: string) { write(`monitor.api_token.${app}`, token.trim()) } export const isRemote = () => getBase() !== '' /** 拼出请求地址:同源时原样返回。 */ export function resolveUrl(path: string): string { return getBase() + path } export function authHeaders(): Record { const token = getToken() return isRemote() && token ? { Authorization: `Bearer ${token}` } : {} } export interface LiveSource { onopen: (() => void) | null onerror: (() => void) | null addEventListener: (type: string, listener: (event: MessageEvent) => 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) => 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) } } } catch { // 下面统一报错并重连 } if (closed) return source.onerror?.() await new Promise((resolve) => setTimeout(resolve, 3000)) } } void run() return source }