fix: 串行化凭据所有权并响应桌面请求取消
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||
const { hostInvoke } = vi.hoisted(() => ({ hostInvoke: vi.fn() }))
|
||||
vi.mock('./platform/desktop', () => ({ isDesktop: () => true, hostInvoke }))
|
||||
import apiClient from './apiClient'
|
||||
beforeEach(() => {
|
||||
hostInvoke.mockReset()
|
||||
hostInvoke.mockImplementation(command => command === 'core_request_prepare' ? Promise.resolve('reservation') :
|
||||
command === 'core_request_cancel' ? Promise.resolve() : new Promise(() => {}))
|
||||
})
|
||||
afterEach(() => vi.useRealTimers())
|
||||
it('never invokes Host for a pre-aborted mutation', async () => {
|
||||
const abort = new AbortController(); abort.abort()
|
||||
await expect(apiClient.post('/api/tasks', {}, { signal: abort.signal })).rejects.toMatchObject({ code: 'REQUEST_CANCELLED', details: { outcome: 'not_sent' } })
|
||||
expect(hostInvoke).not.toHaveBeenCalled()
|
||||
})
|
||||
it('rejects on deadline and cancels the native work rather than awaiting its response', async () => {
|
||||
vi.useFakeTimers()
|
||||
const result = expect(apiClient.post('/api/tasks', {}, { timeoutMs: 10 })).rejects.toMatchObject({ code: 'REQUEST_TIMEOUT', details: { outcome: 'unknown' } })
|
||||
await vi.advanceTimersByTimeAsync(10); await result
|
||||
expect(hostInvoke).toHaveBeenCalledWith('core_request_cancel', { requestId: 'reservation' })
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
it('cancels a late reservation without dispatching after the caller has aborted', async () => {
|
||||
let reserve!: (id: string) => void
|
||||
hostInvoke.mockImplementationOnce(() => new Promise(resolve => { reserve = resolve }))
|
||||
const abort = new AbortController()
|
||||
const result = expect(apiClient.post('/api/tasks', {}, { signal: abort.signal })).rejects.toMatchObject({ code: 'REQUEST_CANCELLED' })
|
||||
abort.abort(); await result; reserve('late-reservation')
|
||||
await vi.waitFor(() => expect(hostInvoke).toHaveBeenCalledWith('core_request_cancel', { requestId: 'late-reservation' }))
|
||||
expect(hostInvoke.mock.calls.some(([command]) => command === 'core_request')).toBe(false)
|
||||
})
|
||||
it('propagates native deadline errors even when the browser timer has not fired', async () => {
|
||||
hostInvoke.mockImplementation(command => command === 'core_request_prepare' ? Promise.resolve('reservation') : command === 'core_request' ? Promise.reject({code:'REQUEST_TIMEOUT'}) : Promise.resolve())
|
||||
await expect(apiClient.get('/api/status')).rejects.toMatchObject({code:'REQUEST_TIMEOUT'})
|
||||
})
|
||||
it('dispatches the frozen request envelope and clears its deadline after success', async () => {
|
||||
vi.useFakeTimers()
|
||||
hostInvoke.mockImplementation(command => Promise.resolve(command === 'core_request_prepare' ? 'reservation' : {status:200,content_type:'application/json',body:'{"ok":true}'}))
|
||||
expect(await apiClient.post('/api/tasks', {title:'fixture'}, {token:'not-forwarded'})).toEqual({ok:true})
|
||||
expect(hostInvoke).toHaveBeenCalledWith('core_request', {request: expect.objectContaining({requestId:'reservation',method:'POST',path:'/api/tasks',body:{title:'fixture'}})})
|
||||
expect(JSON.stringify(hostInvoke.mock.calls)).not.toContain('not-forwarded')
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
it('retains binary bytes and media type through the cancellable transport', async () => {
|
||||
hostInvoke.mockImplementation(command => Promise.resolve(command === 'core_request_prepare' ? 'reservation' : {status:200,content_type:'application/json',body:'{}'}))
|
||||
await apiClient.postBinary('/api/packages',new Blob([new Uint8Array([0,255,128])]))
|
||||
expect(hostInvoke).toHaveBeenCalledWith('core_request', {request: expect.objectContaining({requestId:'reservation',bodyBase64:'AP+A',contentType:'application/zip'})})
|
||||
})
|
||||
@@ -6,7 +6,10 @@ vi.mock('./platform/desktop', () => ({ isDesktop: () => true, hostInvoke }))
|
||||
|
||||
import apiClient from './apiClient'
|
||||
|
||||
beforeEach(() => hostInvoke.mockReset())
|
||||
beforeEach(() => {
|
||||
hostInvoke.mockReset()
|
||||
hostInvoke.mockResolvedValueOnce('test-reservation')
|
||||
})
|
||||
|
||||
it('restores binary desktop responses as browser-compatible response objects', async () => {
|
||||
hostInvoke.mockResolvedValue({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ApiError, ErrorResponse } from '@/contracts'
|
||||
import { hostInvoke, isDesktop } from './platform/desktop'
|
||||
import { isDesktop } from './platform/desktop'
|
||||
import { coreRequest, type RequestProgress } from './platform/coreRequest'
|
||||
|
||||
// 所有 HTTP 请求都经过此边界,以统一地址、请求追踪和错误契约。
|
||||
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? import.meta.env.VITE_API_BASE ?? ''
|
||||
@@ -50,12 +51,18 @@ export class ApiErrorClass extends Error {
|
||||
|
||||
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const { params, token, headers, timeoutMs, ...rest } = options
|
||||
const controller = timeoutMs ? new AbortController() : null
|
||||
const desktop = isDesktop()
|
||||
const deadlineMs = timeoutMs ?? (desktop ? 30_000 : undefined)
|
||||
if (deadlineMs !== undefined && (!Number.isFinite(deadlineMs) || deadlineMs <= 0 || (desktop && deadlineMs > 600_000))) {
|
||||
throw new ApiErrorClass('CORE_TIMEOUT_INVALID', '请求超时设置无效')
|
||||
}
|
||||
const controller = new AbortController()
|
||||
const progress: RequestProgress = { issued: false }
|
||||
let timedOut = false
|
||||
const abort = () => controller?.abort()
|
||||
const abort = () => controller.abort()
|
||||
if (rest.signal?.aborted) abort()
|
||||
rest.signal?.addEventListener('abort', abort, { once: true })
|
||||
const timer = timeoutMs ? setTimeout(() => { timedOut = true; controller?.abort() }, timeoutMs) : undefined
|
||||
const timer = deadlineMs ? setTimeout(() => { timedOut = true; controller.abort() }, deadlineMs) : undefined
|
||||
|
||||
let url = resolveApiUrl(path)
|
||||
|
||||
@@ -81,7 +88,8 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
|
||||
reqHeaders['X-Request-Id'] = reqId
|
||||
|
||||
try {
|
||||
if (isDesktop()) {
|
||||
controller.signal.throwIfAborted()
|
||||
if (desktop) {
|
||||
const parsed = new URL(url, 'http://localhost')
|
||||
let bodyBase64: string | undefined
|
||||
if (rest.body instanceof Blob) {
|
||||
@@ -91,14 +99,14 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
|
||||
for (let offset = 0; offset < bytes.length; offset += 16384) parts.push(String.fromCharCode(...bytes.subarray(offset, offset + 16384)))
|
||||
bodyBase64 = btoa(parts.join(''))
|
||||
}
|
||||
const response = await hostInvoke<DesktopCoreResponse>('core_request', {
|
||||
const response = await coreRequest<DesktopCoreResponse>({
|
||||
method: rest.method ?? 'GET',
|
||||
path: `${parsed.pathname}${parsed.search}`,
|
||||
body: typeof rest.body === 'string' ? JSON.parse(rest.body) : undefined,
|
||||
bodyBase64,
|
||||
contentType: new Headers(reqHeaders).get('Content-Type'),
|
||||
idempotencyKey: new Headers(reqHeaders).get('Idempotency-Key') ?? undefined,
|
||||
})
|
||||
}, controller.signal, Math.ceil(deadlineMs!), progress)
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
if (response.status === 204) return undefined as T
|
||||
return (response.content_type.includes('application/json')
|
||||
@@ -111,7 +119,7 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
|
||||
}
|
||||
const resp = await fetch(url, {
|
||||
...rest,
|
||||
signal: controller?.signal ?? rest.signal,
|
||||
signal: controller.signal,
|
||||
headers: reqHeaders,
|
||||
})
|
||||
|
||||
@@ -136,7 +144,11 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
|
||||
|
||||
throw new ApiErrorClass(code, message, details)
|
||||
} catch (e) {
|
||||
if (timedOut) throw new ApiErrorClass('REQUEST_TIMEOUT', '请求超时,请检查后端状态后重试。')
|
||||
const nativeCode = (e as { code?: string })?.code
|
||||
const uncertain = desktop && progress.issued && !['GET', 'HEAD'].includes(rest.method ?? 'GET')
|
||||
const details = desktop ? { request_id: progress.requestId, outcome: progress.issued ? 'unknown' : 'not_sent' } : undefined
|
||||
if (timedOut || nativeCode === 'REQUEST_TIMEOUT') throw new ApiErrorClass('REQUEST_TIMEOUT', uncertain ? '请求超时,变更可能已提交,请先检查结果。' : '请求超时,请检查连接。', details)
|
||||
if (controller.signal.aborted || nativeCode === 'REQUEST_CANCELLED') throw new ApiErrorClass('REQUEST_CANCELLED', uncertain ? '请求已取消,变更可能已提交,请先检查结果。' : '请求已取消。', details)
|
||||
if (e instanceof ApiErrorClass) throw e
|
||||
throw new ApiErrorClass('NETWORK_ERROR', (e as Error).message || 'Network error')
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { hostInvoke } from './desktop'
|
||||
|
||||
export interface RequestProgress { issued: boolean; requestId?: string }
|
||||
|
||||
/** A reservation makes cancel-before-dispatch definitive even across IPC ordering. */
|
||||
export function coreRequest<T>(args: Record<string, unknown>, signal: AbortSignal, timeoutMs: number, progress: RequestProgress): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false
|
||||
let requestId: string | undefined
|
||||
const cancel = () => {
|
||||
if (requestId) void hostInvoke('core_request_cancel', { requestId }).catch(() => {})
|
||||
}
|
||||
const abort = () => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
signal.removeEventListener('abort', abort)
|
||||
cancel()
|
||||
reject(new DOMException('Request aborted', 'AbortError'))
|
||||
}
|
||||
if (signal.aborted) { abort(); return }
|
||||
signal.addEventListener('abort', abort, { once: true })
|
||||
void (async () => {
|
||||
try {
|
||||
requestId = await hostInvoke<string>('core_request_prepare', { timeoutMs })
|
||||
progress.requestId = requestId
|
||||
if (settled || signal.aborted) { cancel(); return }
|
||||
progress.issued = true
|
||||
const result = await hostInvoke<T>('core_request', { request: { ...args, requestId } })
|
||||
if (!settled) { settled = true; resolve(result) }
|
||||
} catch (error) {
|
||||
if (!settled) { settled = true; reject(error) }
|
||||
} finally {
|
||||
signal.removeEventListener('abort', abort)
|
||||
// Also discard a reservation if dispatch failed before Rust claimed it.
|
||||
cancel()
|
||||
}
|
||||
})()
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user