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:
Danila Poyarkov 2026-07-18 04:40:15 +03:00
parent 585b80d023
commit a3127e716b
4 changed files with 92 additions and 41 deletions

View file

@ -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()) {

View file

@ -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])

View file

@ -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
)

View 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')
})
})