refactor(web): consume api v1 contracts

This commit is contained in:
2026-07-21 16:29:42 +08:00
parent 80bec26839
commit c56ada3708
8 changed files with 311 additions and 149 deletions

View File

@@ -21,6 +21,8 @@ const requiredFiles = [
'src/api/client.ts',
'src/api/projects.ts',
'src/api/mappers.tsx',
'src/api/search.ts',
'src/api/inbox.ts',
]
const failures = requiredFiles.filter((file) => !existsSync(file)).map((file) => `missing ${file}`)
@@ -47,6 +49,43 @@ if (!projectRailSource.includes('projectNameLabel(project.name)')) {
failures.push('project rail labels must derive from the project name')
}
const apiFiles = ['client.ts', 'projects.ts', 'mappers.tsx', 'search.ts', 'inbox.ts'].map((name) => `src/api/${name}`)
const apiSource = apiFiles.map((file) => readFileSync(file, 'utf8')).join('\n')
for (const forbidden of [
{ pattern: /\bID\s*\?\s*:/, label: 'optional PascalCase ID compatibility field' },
{ pattern: /\bName\s*\?\s*:/, label: 'optional PascalCase Name compatibility field' },
{ pattern: /['"`]\/api\/projects/, label: 'legacy /api/projects path' },
{
pattern: /\b(?:project|task|inbox)Id\s*\??\s*:\s*(?:string\s*\|\s*)?number\b/i,
label: 'numeric project/task/inbox identity',
},
]) {
if (forbidden.pattern.test(apiSource)) failures.push(`src/api contains ${forbidden.label}`)
}
const clientSource = readFileSync('src/api/client.ts', 'utf8')
if (!clientSource.includes("'/api/v1/auth/login'")) failures.push('login must use /api/v1/auth/login')
if (!clientSource.includes('export class ApiError extends Error')) failures.push('client must export ApiError')
const projectsSource = readFileSync('src/api/projects.ts', 'utf8')
if (!projectsSource.includes("'/api/v1/projects'")) failures.push('project API must use /api/v1/projects')
if (/['"`]\/api\/(?!v1\/)/.test(apiSource)) failures.push('src/api paths must use the /api/v1 prefix')
for (const match of apiSource.matchAll(/export type (\w+DTO)\s*=\s*\{([\s\S]*?)\n\}/g)) {
const [, name, body] = match
if (/^\s*\w+\s*\?\s*:/m.test(body)) failures.push(`${name} fields must be required`)
if (/^\s*[A-Z]\w*\s*:/m.test(body)) failures.push(`${name} fields must use camelCase`)
if (/^\s*(?:id|\w+Id)\s*:\s*number\b/im.test(body)) failures.push(`${name} identities must be strings`)
}
const searchSource = readFileSync('src/api/search.ts', 'utf8')
if (!searchSource.includes("'/api/v1/search'")) failures.push('search API must use /api/v1/search')
const inboxSource = readFileSync('src/api/inbox.ts', 'utf8')
for (const path of ['/api/v1/projects/', '/api/v1/inbox/']) {
if (!inboxSource.includes(path)) failures.push(`inbox API must use ${path}`)
}
if (existsSync('src/App.tsx')) {
failures.push('legacy src/App.tsx should be removed after page split')
}

View File

@@ -9,6 +9,25 @@ type RequestOptions = {
token?: string
}
type ErrorEnvelope = {
error: {
code: string
message: string
}
}
export class ApiError extends Error {
status: number
code: string
constructor(status: number, code: string, message: string) {
super(message)
this.name = 'ApiError'
this.status = status
this.code = code
}
}
let configuredBaseUrl = normalizeBaseUrl(import.meta.env.VITE_API_BASE_URL ?? 'http://127.0.0.1:18080')
export function setApiBaseUrl(baseUrl: string) {
@@ -20,7 +39,7 @@ export function getApiBaseUrl() {
}
export async function login(email: string, password: string): Promise<ApiSession> {
const response = await apiRequest<{ token: string }>('/api/auth/login', {
const response = await apiRequest<{ token: string }>('/api/v1/auth/login', {
method: 'POST',
body: { email, password },
})
@@ -30,24 +49,30 @@ export async function login(email: string, password: string): Promise<ApiSession
export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
const isFormData = typeof FormData !== 'undefined' && options.body instanceof FormData
const requestBody = options.body === undefined ? undefined : isFormData ? options.body : JSON.stringify(options.body)
const response = await fetch(`${configuredBaseUrl}${path}`, {
method: options.method ?? 'GET',
headers: {
...(isFormData ? {} : { 'Content-Type': 'application/json' }),
...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
},
body: requestBody as BodyInit | undefined,
})
let response: Response
try {
response = await fetch(`${configuredBaseUrl}${path}`, {
method: options.method ?? 'GET',
headers: {
...(isFormData ? {} : { 'Content-Type': 'application/json' }),
...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
},
body: requestBody as BodyInit | undefined,
})
} catch {
throw new ApiError(0, 'network_error', '无法连接服务器,请检查地址和网络后重试')
}
if (!response.ok) {
let message = `请求失败:${response.status}`
let envelope: ErrorEnvelope | null = null
try {
const payload = await response.json()
if (typeof payload.error === 'string') message = payload.error
const payload: unknown = await response.json()
if (isErrorEnvelope(payload)) envelope = payload
} catch {
// Keep the status-based message when the server does not return JSON.
// 非 JSON 响应统一使用状态码文案,避免把服务端内部信息展示给用户。
}
throw new Error(message)
if (envelope) throw new ApiError(response.status, envelope.error.code, envelope.error.message)
throw new ApiError(response.status, fallbackErrorCode(response.status), fallbackErrorMessage(response.status))
}
if (response.status === 204) {
@@ -61,6 +86,39 @@ export async function apiRequest<T>(path: string, options: RequestOptions = {}):
return JSON.parse(text) as T
}
function isErrorEnvelope(value: unknown): value is ErrorEnvelope {
if (!value || typeof value !== 'object' || !('error' in value)) return false
const error = value.error
return (
!!error &&
typeof error === 'object' &&
'code' in error &&
typeof error.code === 'string' &&
error.code.trim() !== '' &&
'message' in error &&
typeof error.message === 'string' &&
error.message.trim() !== ''
)
}
function fallbackErrorCode(status: number) {
if (status === 401) return 'unauthorized'
if (status === 403) return 'forbidden'
if (status === 404) return 'not_found'
if (status === 409) return 'conflict'
if (status >= 500) return 'internal_error'
return 'request_failed'
}
function fallbackErrorMessage(status: number) {
if (status === 401) return '登录已失效,请重新登录'
if (status === 403) return '没有权限执行此操作'
if (status === 404) return '请求的内容不存在'
if (status === 409) return '操作冲突,请刷新后重试'
if (status >= 500) return '服务器暂时无法处理请求,请稍后重试'
return '请求失败,请稍后重试'
}
function normalizeBaseUrl(value: string) {
const trimmed = value.trim()
return trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed

View File

@@ -1,21 +1,24 @@
import { apiRequest, type ApiSession } from './client'
export type BackendInboxItem = {
id?: number
ID?: number
project_id?: number
projectID?: number
source_type?: string
sourceType?: string
title?: string
body?: string
status?: string
}
export type InboxSuggestion = {
type: string
export type InboxItemDTO = {
id: string
projectId: string
sourceType: string
title: string
body: string
status: string
createdAt: string
updatedAt: string
}
export type InboxSuggestionDTO = {
kind: 'task' | 'note' | 'source'
title: string
body: string
}
export type AnalyzeInboxResponseDTO = {
suggestions: InboxSuggestionDTO[]
}
export type CaptureInboxInput = {
@@ -24,27 +27,23 @@ export type CaptureInboxInput = {
body: string
}
export async function captureProjectInbox(session: ApiSession, projectId: string | number, input: CaptureInboxInput) {
return apiRequest<BackendInboxItem>(`/api/projects/${projectId}/inbox`, {
export async function captureProjectInbox(session: ApiSession, projectId: string, input: CaptureInboxInput) {
return apiRequest<InboxItemDTO>(`/api/v1/projects/${projectId}/inbox`, {
method: 'POST',
token: session.token,
body: {
source_type: input.sourceType,
title: input.title,
body: input.body,
},
body: input,
})
}
export async function analyzeInboxItem(session: ApiSession, inboxId: string | number) {
return apiRequest<{ suggestions: InboxSuggestion[] }>(`/api/inbox/${inboxId}/analyze`, {
export async function analyzeInboxItem(session: ApiSession, inboxId: string) {
return apiRequest<AnalyzeInboxResponseDTO>(`/api/v1/inbox/${inboxId}/analyze`, {
method: 'POST',
token: session.token,
})
}
export async function confirmInboxItem(session: ApiSession, inboxId: string | number, suggestions: InboxSuggestion[]) {
return apiRequest<void>(`/api/inbox/${inboxId}/confirm`, {
export async function confirmInboxItem(session: ApiSession, inboxId: string, suggestions: InboxSuggestionDTO[]) {
return apiRequest<void>(`/api/v1/inbox/${inboxId}/confirm`, {
method: 'POST',
token: session.token,
body: { suggestions },

View File

@@ -1,20 +1,20 @@
import { IconClockCircle, IconEmail, IconFile, IconHome, IconLink, IconList, IconRobot, IconUserGroup } from '@arco-design/web-react/icon'
import type {
BackendAISession,
BackendChannel,
BackendCronPlan,
BackendInboxMessage,
BackendNoteSource,
BackendProjectWorkspace,
BackendTask,
ProjectWorkspaceDTO,
WorkspaceAISessionDTO,
WorkspaceChannelDTO,
WorkspaceCronPlanDTO,
WorkspaceInboxDTO,
WorkspaceNoteSourceDTO,
WorkspaceTaskDTO,
} from './projects'
import type { AISession, ChannelKey, CronJob, InboxItem, NoteSource, Project, ProjectChannel, ProjectWorkspace, TaskItem } from '../pages/projects/project-types'
const projectColors = ['#165DFF', '#00B42A', '#722ED1', '#FF7D00', '#14C9C9', '#F53F3F']
export function mapWorkspace(payload: BackendProjectWorkspace, index = 0): ProjectWorkspace {
export function mapWorkspace(payload: ProjectWorkspaceDTO, index = 0): ProjectWorkspace {
const project: Project = {
id: String(payload.project.id),
id: payload.project.id,
name: payload.project.name,
identifier: payload.project.identifier ?? '',
icon: payload.project.icon ?? '',
@@ -29,7 +29,7 @@ export function mapWorkspace(payload: BackendProjectWorkspace, index = 0): Proje
return {
project,
channels: payload.channels.filter((channel) => channel.type !== 'inbox').map(mapChannel),
tags: payload.tags.map((tag) => (tag === 'all' ? '全部' : tag)),
tags: payload.tags.map((tag) => tag.name),
recentSessions: payload.recentSessions.map(mapAISession),
inbox: payload.inbox.map(mapInbox),
tasks: payload.tasks.map(mapTask),
@@ -55,7 +55,7 @@ function projectColor(background: string, index: number) {
return projectColors[index % projectColors.length]
}
function mapChannel(channel: BackendChannel): ProjectChannel {
function mapChannel(channel: WorkspaceChannelDTO): ProjectChannel {
return {
id: channel.id,
key: mapChannelKey(channel.type, channel.id),
@@ -86,7 +86,7 @@ function mapChannelKey(type: string, id = ''): ChannelKey {
}
}
function channelLabel(channel: BackendChannel) {
function channelLabel(channel: WorkspaceChannelDTO) {
switch (channel.type) {
case 'overview':
return '概况'
@@ -105,7 +105,7 @@ function channelLabel(channel: BackendChannel) {
}
}
function channelIcon(channel: BackendChannel) {
function channelIcon(channel: WorkspaceChannelDTO) {
switch (channel.type) {
case 'overview':
return <IconHome />
@@ -124,36 +124,36 @@ function channelIcon(channel: BackendChannel) {
}
}
function mapInbox(item: BackendInboxMessage): InboxItem {
function mapInbox(item: WorkspaceInboxDTO): InboxItem {
return {
id: item.id,
title: item.title,
meta: `来源:${item.source}`,
owner: '系统',
time: item.time,
time: formatBackendDate(item.time),
tag: item.tag || item.status,
summary: item.summary,
status: item.status,
}
}
function mapTask(task: BackendTask): TaskItem {
function mapTask(task: WorkspaceTaskDTO): TaskItem {
return {
id: task.id,
title: task.title,
owner: task.owner,
progress: task.completed ? '100%' : '60%',
due: task.due || '未设置',
createdAt: formatCreatedAt(task.createdAt),
completedAt: task.completedAt ? formatCreatedAt(task.completedAt) : '',
duration: task.duration || '',
progress: task.completed ? '100%' : '',
due: task.due ? formatBackendDate(task.due) : '未设置',
createdAt: formatBackendDate(task.createdAt),
completedAt: task.completedAt ? formatBackendDate(task.completedAt) : '',
duration: '',
tag: task.tag || '',
summary: task.summary,
completed: task.completed,
}
}
function formatCreatedAt(value: string) {
function formatBackendDate(value: string) {
const date = parseBackendDate(value)
if (!date) return '未知时间'
@@ -184,35 +184,35 @@ function pad(value: number) {
return String(value).padStart(2, '0')
}
function mapAISession(session: BackendAISession): AISession {
function mapAISession(session: WorkspaceAISessionDTO): AISession {
return {
id: session.id,
title: session.title,
summary: session.summary,
owner: 'AI',
time: session.updatedAt,
time: formatBackendDate(session.updatedAt),
}
}
function mapNoteSource(note: BackendNoteSource): NoteSource {
function mapNoteSource(note: WorkspaceNoteSourceDTO): NoteSource {
return {
id: note.id,
title: note.title,
type: note.kind === 'note' ? '笔记' : note.source,
updated: note.updatedAt,
updated: formatBackendDate(note.updatedAt),
owner: note.source,
source: note.source,
}
}
function mapCronPlan(plan: BackendCronPlan): CronJob {
function mapCronPlan(plan: WorkspaceCronPlanDTO): CronJob {
return {
id: plan.id,
name: plan.title,
expr: plan.schedule,
status: plan.enabled ? '运行中' : '暂停',
lastRun: plan.lastResult,
nextRun: plan.nextRun || '暂停中',
nextRun: plan.nextRun ? formatBackendDate(plan.nextRun) : '暂停中',
owner: plan.owner,
enabled: plan.enabled,
}

View File

@@ -1,44 +1,44 @@
import { apiRequest, type ApiSession } from './client'
export type BackendProject = {
ID?: number
id?: number
Name?: string
name?: string
Identifier?: string
identifier?: string
Icon?: string
icon?: string
Background?: string
background?: string
Description?: string
description?: string
export type ProjectDTO = {
id: string
name: string
identifier: string
icon: string
background: string
description: string
}
export type BackendWorkspaceProject = {
id: number
export type WorkspaceProjectDTO = {
id: string
name: string
identifier?: string
icon?: string
background?: string
identifier: string
icon: string
background: string
description: string
initials: string
unreadCount: number
}
export type BackendChannel = {
export type WorkspaceChannelDTO = {
id: string
projectID: number
projectId: string
type: string
title: string
icon: string
count?: number
url?: string
count: number | undefined
url: string | undefined
sortOrder: number
}
export type BackendInboxMessage = {
export type WorkspaceTagDTO = {
id: string
name: string
}
export type WorkspaceInboxDTO = {
id: string
projectId: string
source: string
title: string
summary: string
@@ -47,29 +47,32 @@ export type BackendInboxMessage = {
time: string
}
export type BackendTask = {
export type WorkspaceTaskDTO = {
id: string
projectId: string
title: string
summary: string
completed: boolean
owner: string
due: string
due: string | null
createdAt: string
completedAt: string
duration: string
completedAt: string | null
tagId: string | null
tag: string
}
export type BackendAISession = {
export type WorkspaceAISessionDTO = {
id: string
projectId: string
title: string
summary: string
updatedAt: string
references: string[]
}
export type BackendNoteSource = {
export type WorkspaceNoteSourceDTO = {
id: string
projectId: string
kind: string
title: string
updatedAt: string
@@ -77,55 +80,77 @@ export type BackendNoteSource = {
source: string
}
export type BackendCronPlan = {
export type WorkspaceCronPlanDTO = {
id: string
projectId: string
title: string
schedule: string
nextRun: string
nextRun: string | null
enabled: boolean
lastResult: string
owner: string
}
export type BackendProjectWorkspace = {
project: BackendWorkspaceProject
channels: BackendChannel[]
tags: string[]
recentSessions: BackendAISession[]
inbox: BackendInboxMessage[]
tasks: BackendTask[]
aiSessions: BackendAISession[]
notesSources: BackendNoteSource[]
cronPlans: BackendCronPlan[]
export type ProjectWorkspaceDTO = {
project: WorkspaceProjectDTO
channels: WorkspaceChannelDTO[]
tags: WorkspaceTagDTO[]
recentSessions: WorkspaceAISessionDTO[]
inbox: WorkspaceInboxDTO[]
tasks: WorkspaceTaskDTO[]
aiSessions: WorkspaceAISessionDTO[]
notesSources: WorkspaceNoteSourceDTO[]
cronPlans: WorkspaceCronPlanDTO[]
}
export type BackendTag = {
ID?: number
id?: number
Name?: string
name?: string
export type TaskDTO = {
id: string
projectId: string
title: string
description: string
status: string
completed: boolean
dueAt: string | null
assigneeId: string | null
tagId: string | null
tag: string
sourceInboxItemId: string | null
createdAt: string
updatedAt: string
}
export async function fetchProjects(session: ApiSession) {
return apiRequest<BackendProject[]>('/api/projects', { token: session.token })
export type SourceDTO = {
id: string
projectId: string
kind: string
title: string
filePath: string
createdAt: string
updatedAt: string
}
export async function fetchProjectWorkspace(session: ApiSession, projectId: string | number) {
return apiRequest<BackendProjectWorkspace>(`/api/projects/${projectId}/workspace`, { token: session.token })
}
export async function fetchProjectTags(session: ApiSession, projectId: string | number) {
return apiRequest<BackendTag[]>(`/api/projects/${projectId}/tags`, { token: session.token })
export type CronPlanDTO = {
id: string
projectId: string
title: string
schedule: string
nextRunAt: string | null
enabled: boolean
lastResult: string
createdAt: string
updatedAt: string
}
export type CreateProjectInput = {
name: string
identifier?: string
icon?: string
background?: string
description?: string
identifier: string
icon: string
background: string
description: string
}
export type UpdateProjectInput = Partial<CreateProjectInput>
export type CreateTaskInput = {
title: string
description?: string
@@ -137,8 +162,9 @@ export type CreateTaskInput = {
export type UpdateTaskInput = {
title: string
description?: string
status?: string
completed: boolean
nextProjectId?: number
nextProjectId?: string
tag?: string
}
@@ -158,51 +184,71 @@ export type CreateProjectTagInput = {
name: string
}
export async function fetchProjects(session: ApiSession) {
return apiRequest<ProjectDTO[]>('/api/v1/projects', { token: session.token })
}
export async function fetchProjectWorkspace(session: ApiSession, projectId: string) {
return apiRequest<ProjectWorkspaceDTO>(`/api/v1/projects/${projectId}/workspace`, { token: session.token })
}
export async function fetchProjectTags(session: ApiSession, projectId: string) {
return apiRequest<WorkspaceTagDTO[]>(`/api/v1/projects/${projectId}/tags`, { token: session.token })
}
export async function createProject(session: ApiSession, input: CreateProjectInput) {
return apiRequest<BackendProject>('/api/projects', {
return apiRequest<ProjectDTO>('/api/v1/projects', {
method: 'POST',
token: session.token,
body: input,
})
}
export async function createTask(session: ApiSession, projectId: string | number, input: CreateTaskInput) {
return apiRequest<BackendTask>(`/api/projects/${projectId}/tasks`, {
method: 'POST',
token: session.token,
body: input,
})
}
export async function updateTask(session: ApiSession, projectId: string | number, taskId: string | number, input: UpdateTaskInput) {
return apiRequest<BackendTask>(`/api/projects/${projectId}/tasks/${taskId}`, {
export async function updateProject(session: ApiSession, projectId: string, input: UpdateProjectInput) {
return apiRequest<ProjectDTO>(`/api/v1/projects/${projectId}`, {
method: 'PATCH',
token: session.token,
body: input,
})
}
export async function uploadSource(session: ApiSession, projectId: string | number, input: UploadSourceInput) {
export async function createTask(session: ApiSession, projectId: string, input: CreateTaskInput) {
return apiRequest<TaskDTO>(`/api/v1/projects/${projectId}/tasks`, {
method: 'POST',
token: session.token,
body: input,
})
}
export async function updateTask(session: ApiSession, projectId: string, taskId: string, input: UpdateTaskInput) {
return apiRequest<TaskDTO>(`/api/v1/projects/${projectId}/tasks/${taskId}`, {
method: 'PATCH',
token: session.token,
body: input,
})
}
export async function uploadSource(session: ApiSession, projectId: string, input: UploadSourceInput) {
const body = new FormData()
body.append('file', input.file)
if (input.title) body.append('title', input.title)
return apiRequest<BackendNoteSource>(`/api/projects/${projectId}/sources`, {
return apiRequest<SourceDTO>(`/api/v1/projects/${projectId}/sources`, {
method: 'POST',
token: session.token,
body,
})
}
export async function createCronPlan(session: ApiSession, projectId: string | number, input: CreateCronPlanInput) {
return apiRequest<BackendCronPlan>(`/api/projects/${projectId}/cron-plans`, {
export async function createCronPlan(session: ApiSession, projectId: string, input: CreateCronPlanInput) {
return apiRequest<CronPlanDTO>(`/api/v1/projects/${projectId}/cron-plans`, {
method: 'POST',
token: session.token,
body: input,
})
}
export async function createProjectTag(session: ApiSession, projectId: string | number, input: CreateProjectTagInput) {
return apiRequest<BackendTag>(`/api/projects/${projectId}/tags`, {
export async function createProjectTag(session: ApiSession, projectId: string, input: CreateProjectTagInput) {
return apiRequest<WorkspaceTagDTO>(`/api/v1/projects/${projectId}/tags`, {
method: 'POST',
token: session.token,
body: input,

View File

@@ -0,0 +1,20 @@
import { apiRequest, type ApiSession } from './client'
export type SearchResultDTO = {
type: 'project' | 'task' | 'note' | 'source' | 'inbox' | 'ai_session'
id: string
projectId: string
title: string
snippet: string
}
export type SearchResponseDTO = {
items: SearchResultDTO[]
}
const searchPath = '/api/v1/search'
export async function searchWorkspace(session: ApiSession, query: string) {
const search = new URLSearchParams({ q: query })
return apiRequest<SearchResponseDTO>(`${searchPath}?${search.toString()}`, { token: session.token })
}

View File

@@ -42,7 +42,7 @@ function App() {
async function loadWorkspaces(nextSession: ApiSession, preferredProjectID = activeProjectID) {
const backendProjects = await fetchProjects(nextSession)
const backendWorkspaces = await Promise.all(
backendProjects.map((project, index) => fetchProjectWorkspace(nextSession, project.ID ?? project.id ?? '').then((workspace) => mapWorkspace(workspace, index))),
backendProjects.map((project, index) => fetchProjectWorkspace(nextSession, project.id).then((workspace) => mapWorkspace(workspace, index))),
)
setWorkspaces(backendWorkspaces)
const nextProjectID = backendWorkspaces.find((workspace) => workspace.project.id === preferredProjectID)?.project.id ?? backendWorkspaces[0]?.project.id ?? ''
@@ -114,7 +114,7 @@ function App() {
background: draft.background.trim(),
description: draft.description.trim(),
})
const nextProjectID = String(created.ID ?? created.id ?? '')
const nextProjectID = created.id
await refreshAfterAction(nextProjectID)
if (nextProjectID) {
setActiveProjectID(nextProjectID)
@@ -203,7 +203,7 @@ function App() {
title: update.title,
description: update.summary,
completed: update.completed,
nextProjectId: Number(update.nextProjectId),
nextProjectId: update.nextProjectId,
tag: update.tag,
})
await refreshAfterAction(update.nextProjectId)

View File

@@ -118,10 +118,10 @@ async function checkServerStatus(
setStatus('checking')
try {
const response = await fetch(`${baseUrl}/api/status`, { signal })
const response = await fetch(`${baseUrl}/api/v1/status`, { signal })
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const payload = await response.json() as { timestamp_ms?: number }
if (typeof payload.timestamp_ms !== 'number') throw new Error('invalid status payload')
const payload = await response.json() as { timestamp?: string }
if (typeof payload.timestamp !== 'string') throw new Error('invalid status payload')
setStatus('online')
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') return