From 6d81d53d1da911d888ef858768fbdd6651ec7c28 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Thu, 13 Aug 2026 18:31:36 +0300 Subject: [PATCH] fix(tauri): preserve HTTP timeout and response semantics - Apply validated request timeouts without overriding the shared client default - Preserve abort behavior and null-body response statuses Co-authored-by: Joseph Cumines --- desktop/src/http.rs | 16 ++- src/app/tauri/http.ts | 25 ++++- tests/engine/tauri/http.test.ts | 176 ++++++++++++++++++++++++++++++++ 3 files changed, 208 insertions(+), 9 deletions(-) create mode 100644 tests/engine/tauri/http.test.ts diff --git a/desktop/src/http.rs b/desktop/src/http.rs index 8d5cb2127..a6a2a4df0 100644 --- a/desktop/src/http.rs +++ b/desktop/src/http.rs @@ -61,17 +61,23 @@ pub async fn proxy_http_request(request: ProxyHttpRequest) -> Result ({ name, value })) } @@ -60,6 +67,15 @@ export function withAbortSignal(promise: Promise, signal: AbortSignal): Pr }) } +export interface TauriFetchOptions { + timeoutMs?: number + maxResponseBytes?: number +} + +export function createTauriFetch(options: TauriFetchOptions = {}): typeof fetch { + return (input, init) => tauriFetch(input, init, options.maxResponseBytes, options.timeoutMs) +} + export async function tauriFetch( input: RequestInfo | URL, init?: RequestInit, @@ -69,6 +85,7 @@ export async function tauriFetch( const request = new Request(input, init) request.signal.throwIfAborted() const { invoke } = await import('@tauri-apps/api/core') + request.signal.throwIfAborted() const payload: ProxyHttpRequest = { url: request.url, method: request.method, @@ -83,7 +100,7 @@ export async function tauriFetch( invoke('proxy_http_request', { request: payload }), request.signal ) - const proxiedResponse = new Response(new Uint8Array(response.body), { + const proxiedResponse = new Response(responseBodyForStatus(response), { status: response.status, headers: response.headers.map(({ name, value }): [string, string] => [name, value]) }) diff --git a/tests/engine/tauri/http.test.ts b/tests/engine/tauri/http.test.ts new file mode 100644 index 000000000..453d9035b --- /dev/null +++ b/tests/engine/tauri/http.test.ts @@ -0,0 +1,176 @@ +import { afterEach, describe, expect, test } from 'bun:test' + +import { + createTauriFetch, + tauriFetch, + type ProxyHttpRequest, + type ProxyHttpResponse +} from '@/app/tauri/http' + +import { clearTauriMocks, mockTauriIPC } from '#tests/helpers/tauri/mocks' + +type InvokeArgs = { request: ProxyHttpRequest } + +function proxyBodyText(request: ProxyHttpRequest): string { + return new TextDecoder().decode(new Uint8Array(request.body ?? [])) +} + +function proxyHeaderValue(request: ProxyHttpRequest, name: string): string | null { + return request.headers?.find((header) => header.name.toLowerCase() === name)?.value ?? null +} + +async function withBrowserStrictNullBodyResponse(callback: () => Promise): Promise { + const originalResponse = globalThis.Response + const StrictResponse = class extends originalResponse { + constructor(body?: BodyInit | null, init?: ResponseInit) { + const status = init?.status + if ((status === 204 || status === 205 || status === 304) && body != null) { + throw new TypeError('Response with null body status cannot have body') + } + super(body, init) + } + } as typeof Response + + globalThis.Response = StrictResponse + try { + return await callback() + } finally { + globalThis.Response = originalResponse + } +} + +afterEach(async () => { + await clearTauriMocks() +}) + +describe('tauriFetch', () => { + test('passes request timeout metadata to the desktop HTTP command', async () => { + let captured: InvokeArgs | null = null + await mockTauriIPC((command, args) => { + expect(command).toBe('proxy_http_request') + captured = args as InvokeArgs + return { + status: 201, + headers: [{ name: 'x-open-pencil', value: 'ok' }], + body: [...new TextEncoder().encode('OK')] + } + }) + + const response = await createTauriFetch({ timeoutMs: 15_000 })('https://example.test/check', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{"ok":true}' + }) + + if (!captured) throw new Error('Expected proxy_http_request to be invoked') + expect(response.status).toBe(201) + expect(response.headers.get('x-open-pencil')).toBe('ok') + expect(await response.text()).toBe('OK') + expect(captured.request.url).toBe('https://example.test/check') + expect(captured.request.method).toBe('POST') + expect(captured.request.timeout_ms).toBe(15_000) + expect(captured.request.body).toEqual([...new TextEncoder().encode('{"ok":true}')]) + }) + + test('forwards bodies from Request inputs', async () => { + let captured: InvokeArgs | null = null + await mockTauriIPC((command, args) => { + expect(command).toBe('proxy_http_request') + captured = args as InvokeArgs + return { status: 204, headers: [], body: [] } + }) + + const request = new Request('https://example.test/from-request', { + method: 'POST', + body: 'from-request' + }) + + const response = await tauriFetch(request) + + if (!captured) throw new Error('Expected proxy_http_request to be invoked') + expect(response.status).toBe(204) + expect(captured.request.method).toBe('POST') + expect(proxyBodyText(captured.request)).toBe('from-request') + }) + + test('constructs null bodies for browser null-body response statuses', async () => { + await mockTauriIPC((command) => { + expect(command).toBe('proxy_http_request') + return { status: 204, headers: [{ name: 'x-no-content', value: '1' }], body: [] } + }) + + await withBrowserStrictNullBodyResponse(async () => { + const response = await tauriFetch('https://example.test/no-content') + + expect(response.status).toBe(204) + expect(response.headers.get('x-no-content')).toBe('1') + expect(await response.text()).toBe('') + }) + }) + + test('forwards FormData bytes with the Request-generated content boundary', async () => { + let captured: InvokeArgs | null = null + await mockTauriIPC((command, args) => { + expect(command).toBe('proxy_http_request') + captured = args as InvokeArgs + return { status: 204, headers: [], body: [] } + }) + + const formData = new FormData() + formData.append('family', 'Inter') + + await tauriFetch('https://example.test/upload', { method: 'POST', body: formData }) + + if (!captured) throw new Error('Expected proxy_http_request to be invoked') + const contentType = proxyHeaderValue(captured.request, 'content-type') + const boundary = contentType?.match(/boundary=(.+)$/)?.[1] + if (!boundary) throw new Error(`Expected multipart boundary in content-type: ${contentType}`) + + const body = proxyBodyText(captured.request) + expect(body).toContain(`--${boundary}`) + expect(body).toContain('name="family"') + expect(body).toContain('Inter') + }) + + test('rejects already-aborted requests before invoking the desktop command', async () => { + let calls = 0 + await mockTauriIPC(() => { + calls += 1 + return { status: 204, headers: [], body: [] } + }) + const controller = new AbortController() + controller.abort() + + await expect( + tauriFetch('https://example.test/slow', { signal: controller.signal }) + ).rejects.toHaveProperty('name', 'AbortError') + expect(calls).toBe(0) + }) + + test('rejects when an in-flight desktop command is aborted', async () => { + let calls = 0 + let markStarted: (() => void) | null = null + let resolvePendingResponse: ((value: ProxyHttpResponse) => void) | null = null + const started = new Promise((resolve) => { + markStarted = resolve + }) + await mockTauriIPC(() => { + calls += 1 + const resolve = markStarted + if (!resolve) throw new Error('Expected start resolver to be installed') + resolve() + return new Promise((pendingResolve) => { + resolvePendingResponse = pendingResolve + }) + }) + const controller = new AbortController() + + const request = tauriFetch('https://example.test/slow', { signal: controller.signal }) + await started + controller.abort() + + await expect(request).rejects.toHaveProperty('name', 'AbortError') + expect(calls).toBe(1) + expect(resolvePendingResponse).toBeTypeOf('function') + }) +})