proper productionize project

This commit is contained in:
2026-07-15 23:54:53 -07:00
parent b8850c87a4
commit edb6d2b5b4
46 changed files with 11346 additions and 1560 deletions
+225
View File
@@ -0,0 +1,225 @@
import type {
AuthUser,
ComponentSummary,
CookieCloudSource,
Invitation,
OverlaySettings,
Session,
TotpEnrollment,
} from './types'
export class ApiError extends Error {
readonly status: number
readonly code?: string
readonly fieldErrors?: Record<string, string>
constructor(status: number, message: string, code?: string, fieldErrors?: Record<string, string>) {
super(message)
this.name = 'ApiError'
this.status = status
this.code = code
this.fieldErrors = fieldErrors
}
}
async function parseResponse(response: Response): Promise<unknown> {
if (response.status === 204) return undefined
const contentType = response.headers.get('content-type') ?? ''
if (contentType.includes('application/json')) return response.json()
const text = await response.text()
return text ? { message: text } : undefined
}
export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
const method = (init.method ?? 'GET').toUpperCase()
if (!navigator.onLine && !['GET', 'HEAD'].includes(method)) {
throw new ApiError(0, '当前处于离线状态,操作没有提交;联网后请重试。', 'offline')
}
const headers = new Headers(init.headers)
if (init.body != null && !headers.has('content-type')) headers.set('content-type', 'application/json')
headers.set('accept', 'application/json')
const response = await fetch(path, {
...init,
headers,
credentials: 'same-origin',
})
const payload = await parseResponse(response)
if (!response.ok) {
if (response.status === 401 && !path.startsWith('/api/v1/auth/')) {
window.dispatchEvent(new Event('lxc:session-expired'))
}
const error = payload && typeof payload === 'object' ? payload as Record<string, unknown> : {}
throw new ApiError(
response.status,
String(error.message ?? error.error ?? `请求失败(HTTP ${response.status})`),
typeof error.code === 'string' ? error.code : undefined,
error.fieldErrors && typeof error.fieldErrors === 'object'
? error.fieldErrors as Record<string, string>
: undefined,
)
}
return payload as T
}
export function json(method: string, body?: unknown): RequestInit {
return {
method,
body: body === undefined ? undefined : JSON.stringify(body),
}
}
function object(value: unknown): Record<string, unknown> {
return value != null && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: {}
}
export function normalizeSession(value: unknown): Session {
const root = object(value)
const candidate = root.user
?? (root.authenticated === true || typeof root.id === 'string' || typeof root.username === 'string' ? root : null)
const raw = object(candidate)
const hasUser = typeof raw.id === 'string' || typeof raw.username === 'string'
const user: AuthUser | null = hasUser ? {
id: String(raw.id ?? ''),
username: String(raw.username ?? ''),
roomId: typeof raw.roomId === 'string' ? raw.roomId : undefined,
displayName: typeof raw.displayName === 'string' ? raw.displayName : undefined,
role: typeof raw.role === 'string' ? raw.role : 'user',
totpEnabled: typeof raw.totpEnabled === 'boolean' ? raw.totpEnabled : undefined,
} : null
return { user, setupRequired: root.setupRequired === true }
}
export function normalizeEnrollment(value: unknown): TotpEnrollment {
const root = object(value)
const totp = object(root.totp ?? root.enrollment)
const enrollmentToken = root.enrollmentToken ?? totp.enrollmentToken ?? root.flowId ?? root.id
return {
enrollmentToken: String(enrollmentToken ?? ''),
qrSvg: typeof totp.qrSvg === 'string' ? totp.qrSvg : typeof root.qrSvg === 'string' ? root.qrSvg : undefined,
qrDataUrl: typeof totp.qrDataUrl === 'string'
? totp.qrDataUrl
: typeof totp.qrCodeDataUrl === 'string'
? totp.qrCodeDataUrl
: typeof root.qrDataUrl === 'string'
? root.qrDataUrl
: undefined,
otpauthUri: typeof totp.otpauthUri === 'string'
? totp.otpauthUri
: typeof root.otpauthUri === 'string'
? root.otpauthUri
: undefined,
manualKey: String(totp.manualKey ?? totp.secret ?? root.manualKey ?? root.secret ?? ''),
expiresAt: typeof root.expiresAt === 'string' ? root.expiresAt : undefined,
}
}
export function normalizeRecoveryCodes(value: unknown): string[] {
const root = object(value)
const codes = root.recoveryCodes ?? object(root.recovery).codes
return Array.isArray(codes) ? codes.filter((code): code is string => typeof code === 'string') : []
}
export function normalizeComponents(value: unknown): ComponentSummary[] {
const root = object(value)
const list = Array.isArray(value) ? value : Array.isArray(root.components) ? root.components : []
return list.map((entry) => {
const item = object(entry)
return {
id: String(item.id ?? ''),
publicId: String(item.publicId ?? item.public_id ?? item.id ?? ''),
kind: String(item.kind ?? item.type ?? 'danmaku'),
name: String(item.name ?? '弹幕姬'),
enabled: typeof item.enabled === 'boolean' ? item.enabled : undefined,
settings: item.settings as OverlaySettings | undefined,
updatedAt: typeof item.updatedAt === 'string' ? item.updatedAt : undefined,
}
}).filter(item => item.id)
}
export function normalizeSettings(value: unknown): OverlaySettings {
const root = object(value)
return (root.settings ?? value) as OverlaySettings
}
export function normalizeSource(value: unknown): CookieCloudSource {
const root = object(value)
const source = object(root.source ?? root)
const cookieCloud = object(source.cookieCloud ?? source.cookiecloud ?? root.cookieCloud ?? root.cookiecloud)
const status = object(source.status)
return {
roomId: String(source.roomId ?? root.roomId ?? ''),
cookieCloud: {
host: String(cookieCloud.host ?? ''),
key: '',
keyConfigured: cookieCloud.keyConfigured === true
|| (typeof cookieCloud.key === 'string' && cookieCloud.key.length > 0),
passwordConfigured: cookieCloud.passwordConfigured === true
|| cookieCloud.configured === true
|| (typeof cookieCloud.password === 'string' && cookieCloud.password.length > 0),
},
connected: typeof source.connected === 'boolean'
? source.connected
: typeof status.connected === 'boolean'
? status.connected
: undefined,
detail: typeof source.detail === 'string'
? source.detail
: typeof status.detail === 'string'
? status.detail
: undefined,
updatedAt: typeof source.updatedAt === 'string' ? source.updatedAt : undefined,
}
}
export function normalizeInvitations(value: unknown): Invitation[] {
const root = object(value)
const list = Array.isArray(value) ? value : Array.isArray(root.invitations) ? root.invitations : []
return list.map((entry) => {
const item = object(entry)
return {
id: String(item.id ?? ''),
code: typeof item.code === 'string' ? item.code : undefined,
codePrefix: typeof item.codePrefix === 'string' ? item.codePrefix : undefined,
roomId: String(item.roomId ?? ''),
createdBy: typeof item.createdBy === 'string' ? item.createdBy : undefined,
createdAt: typeof item.createdAt === 'string' ? item.createdAt : undefined,
expiresAt: typeof item.expiresAt === 'string' ? item.expiresAt : undefined,
consumedAt: typeof item.consumedAt === 'string' || item.consumedAt === null ? item.consumedAt : undefined,
revokedAt: typeof item.revokedAt === 'string' || item.revokedAt === null ? item.revokedAt : undefined,
}
}).filter(item => item.id)
}
export function errorMessage(error: unknown, fallback = '操作失败,请稍后再试'): string {
return error instanceof Error && error.message ? error.message : fallback
}
export async function copyToClipboard(text: string): Promise<boolean> {
if (window.isSecureContext && navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
// Fall through to the compatibility path used by OBS' embedded browser.
}
}
const input = document.createElement('textarea')
input.value = text
input.readOnly = true
input.style.position = 'fixed'
input.style.left = '-9999px'
input.style.opacity = '0'
document.body.appendChild(input)
input.focus()
input.select()
let copied = false
try {
copied = document.execCommand('copy')
} finally {
input.remove()
}
return copied
}