refactor(tauri): share HTTP abort handling
- Make the desktop HTTP adapter honor standard AbortSignal cancellation - Use AbortSignal.timeout directly for Figma batch and image requests - Cover resolved, pre-aborted, and in-flight abort behavior
This commit is contained in:
parent
585b80d023
commit
a3127e716b
|
|
@ -35,31 +35,6 @@ async function sha1Hex(bytes: Uint8Array): Promise<string> {
|
|||
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(
|
||||
fetcher: ClipboardImageFetch,
|
||||
input: RequestInfo | URL,
|
||||
init: RequestInit | undefined,
|
||||
timeoutMs: number
|
||||
) {
|
||||
const controller = new AbortController()
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined
|
||||
const timeoutPromise = new Promise<never>((_resolve, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
controller.abort()
|
||||
reject(new Error('Figma image request timed out'))
|
||||
}, timeoutMs)
|
||||
})
|
||||
|
||||
try {
|
||||
return await Promise.race([
|
||||
fetcher(input, { ...init, signal: controller.signal }),
|
||||
timeoutPromise
|
||||
])
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveFigmaClipboardImages(
|
||||
fileKey: string,
|
||||
hashes: string[],
|
||||
|
|
@ -69,15 +44,14 @@ export async function resolveFigmaClipboardImages(
|
|||
const uniqueHashes = [...new Set(hashes)]
|
||||
if (uniqueHashes.length === 0) return new Map()
|
||||
|
||||
const batchResponse = await fetchWithTimeout(
|
||||
fetcher,
|
||||
const batchResponse = await fetcher(
|
||||
`https://www.figma.com/file/${encodeURIComponent(fileKey)}/image/batch`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ sha1s: uniqueHashes, needs_compressed_textures: false })
|
||||
},
|
||||
timeoutMs
|
||||
body: JSON.stringify({ sha1s: uniqueHashes, needs_compressed_textures: false }),
|
||||
signal: AbortSignal.timeout(timeoutMs)
|
||||
}
|
||||
)
|
||||
if (!batchResponse.ok) {
|
||||
throw new Error(`Figma image request failed with status ${batchResponse.status}`)
|
||||
|
|
@ -94,7 +68,7 @@ export async function resolveFigmaClipboardImages(
|
|||
const url = urls[hash]
|
||||
if (!url) return
|
||||
try {
|
||||
const response = await fetchWithTimeout(fetcher, url, undefined, timeoutMs)
|
||||
const response = await fetcher(url, { signal: AbortSignal.timeout(timeoutMs) })
|
||||
if (!response.ok) throw new Error(`status ${response.status}`)
|
||||
const bytes = new Uint8Array(await response.arrayBuffer())
|
||||
if ((await sha1Hex(bytes)) !== hash.toLowerCase()) {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,39 @@ function headersToProxyHeaders(headers: Headers): ProxyHttpHeader[] {
|
|||
return [...headers.entries()].map(([name, value]) => ({ name, value }))
|
||||
}
|
||||
|
||||
function abortReason(signal: AbortSignal): Error {
|
||||
return signal.reason instanceof Error
|
||||
? signal.reason
|
||||
: new DOMException('The operation was aborted', 'AbortError')
|
||||
}
|
||||
|
||||
export function withAbortSignal<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
||||
if (signal.aborted) return Promise.reject(abortReason(signal))
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const cleanup = () => signal.removeEventListener('abort', onAbort)
|
||||
const onAbort = () => {
|
||||
cleanup()
|
||||
reject(abortReason(signal))
|
||||
}
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
void (async () => {
|
||||
try {
|
||||
const value = await promise
|
||||
cleanup()
|
||||
resolve(value)
|
||||
} catch (error) {
|
||||
cleanup()
|
||||
reject(
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error('Desktop HTTP request failed', { cause: error })
|
||||
)
|
||||
}
|
||||
})()
|
||||
})
|
||||
}
|
||||
|
||||
async function bodyToBytes(body: BodyInit | null | undefined): Promise<number[] | undefined> {
|
||||
if (body == null) return undefined
|
||||
if (typeof body === 'string') return [...new TextEncoder().encode(body)]
|
||||
|
|
@ -36,6 +69,7 @@ async function bodyToBytes(body: BodyInit | null | undefined): Promise<number[]
|
|||
|
||||
export async function tauriFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||
const request = new Request(input, init)
|
||||
request.signal.throwIfAborted()
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
const payload: ProxyHttpRequest = {
|
||||
url: request.url,
|
||||
|
|
@ -43,7 +77,11 @@ export async function tauriFetch(input: RequestInfo | URL, init?: RequestInit):
|
|||
headers: headersToProxyHeaders(request.headers),
|
||||
body: await bodyToBytes(init?.body)
|
||||
}
|
||||
const response = await invoke<ProxyHttpResponse>('proxy_http_request', { request: payload })
|
||||
request.signal.throwIfAborted()
|
||||
const response = await withAbortSignal(
|
||||
invoke<ProxyHttpResponse>('proxy_http_request', { request: payload }),
|
||||
request.signal
|
||||
)
|
||||
return new Response(new Uint8Array(response.body), {
|
||||
status: response.status,
|
||||
headers: response.headers.map(({ name, value }): [string, string] => [name, value])
|
||||
|
|
|
|||
|
|
@ -9,6 +9,17 @@ async function sha1Hex(bytes: Uint8Array) {
|
|||
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
function pendingResponseUntilAbort(_input: RequestInfo | URL, init?: RequestInit) {
|
||||
return new Promise<Response>((_resolve, reject) => {
|
||||
const signal = init?.signal
|
||||
if (!signal) {
|
||||
reject(new Error('Expected request signal'))
|
||||
return
|
||||
}
|
||||
signal.addEventListener('abort', () => reject(signal.reason), { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
describe('resolveFigmaClipboardImages', () => {
|
||||
test('resolves signed URLs, verifies bytes, and deduplicates hashes', async () => {
|
||||
const bytes = new Uint8Array([1, 2, 3])
|
||||
|
|
@ -71,13 +82,8 @@ describe('resolveFigmaClipboardImages', () => {
|
|||
|
||||
test('times out stalled batch requests', async () => {
|
||||
await expect(
|
||||
resolveFigmaClipboardImages(
|
||||
'file-key',
|
||||
['hash'],
|
||||
() => Promise.withResolvers<Response>().promise,
|
||||
5
|
||||
)
|
||||
).rejects.toThrow('timed out')
|
||||
resolveFigmaClipboardImages('file-key', ['hash'], pendingResponseUntilAbort, 5)
|
||||
).rejects.toMatchObject({ name: 'TimeoutError' })
|
||||
})
|
||||
|
||||
test('drops images whose signed URL request times out', async () => {
|
||||
|
|
@ -85,7 +91,7 @@ describe('resolveFigmaClipboardImages', () => {
|
|||
const images = await resolveFigmaClipboardImages(
|
||||
'file-key',
|
||||
['1111111111111111111111111111111111111111'],
|
||||
(input) => {
|
||||
(input, init) => {
|
||||
if (String(input).includes('/image/batch')) {
|
||||
return Promise.resolve(
|
||||
Response.json({
|
||||
|
|
@ -99,7 +105,7 @@ describe('resolveFigmaClipboardImages', () => {
|
|||
})
|
||||
)
|
||||
}
|
||||
return Promise.withResolvers<Response>().promise
|
||||
return pendingResponseUntilAbort(input, init)
|
||||
},
|
||||
5
|
||||
)
|
||||
|
|
|
|||
33
tests/engine/app/tauri/http.test.ts
Normal file
33
tests/engine/app/tauri/http.test.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { withAbortSignal } from '@/app/tauri/http'
|
||||
|
||||
describe('withAbortSignal', () => {
|
||||
test('resolves with the wrapped promise', async () => {
|
||||
const controller = new AbortController()
|
||||
|
||||
await expect(withAbortSignal(Promise.resolve('ok'), controller.signal)).resolves.toBe('ok')
|
||||
})
|
||||
|
||||
test('rejects immediately when the signal is already aborted', async () => {
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('cancelled')
|
||||
controller.abort(reason)
|
||||
|
||||
await expect(
|
||||
withAbortSignal(Promise.withResolvers<string>().promise, controller.signal)
|
||||
).rejects.toBe(reason)
|
||||
})
|
||||
|
||||
test('rejects a pending promise when the signal aborts', async () => {
|
||||
const controller = new AbortController()
|
||||
const pending = Promise.withResolvers<string>()
|
||||
const result = withAbortSignal(pending.promise, controller.signal)
|
||||
const reason = new Error('cancelled')
|
||||
|
||||
controller.abort(reason)
|
||||
|
||||
await expect(result).rejects.toBe(reason)
|
||||
pending.resolve('late result')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue