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 <joeycumines@gmail.com>
This commit is contained in:
parent
c7b944d103
commit
6d81d53d1d
|
|
@ -61,17 +61,23 @@ pub async fn proxy_http_request(request: ProxyHttpRequest) -> Result<ProxyHttpRe
|
|||
} else {
|
||||
reqwest::redirect::Policy::none()
|
||||
};
|
||||
let mut client_builder = reqwest::Client::builder().redirect(redirect_policy);
|
||||
if let Some(timeout_ms) = request.timeout_ms {
|
||||
client_builder = client_builder.timeout(Duration::from_millis(timeout_ms));
|
||||
}
|
||||
let client = client_builder.build().map_err(|e| e.to_string())?;
|
||||
let client = reqwest::Client::builder()
|
||||
.redirect(redirect_policy)
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut builder = client
|
||||
.request(request_method(request.method)?, parsed)
|
||||
.headers(request_headers(request.headers));
|
||||
if let Some(body) = request.body {
|
||||
builder = builder.body(body);
|
||||
}
|
||||
if let Some(timeout_ms) = request.timeout_ms {
|
||||
if timeout_ms == 0 {
|
||||
return Err("timeoutMs must be greater than 0".into());
|
||||
}
|
||||
builder = builder.timeout(Duration::from_millis(timeout_ms));
|
||||
}
|
||||
|
||||
let mut response = builder.send().await.map_err(|e| e.to_string())?;
|
||||
let status = response.status().as_u16();
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
interface ProxyHttpHeader {
|
||||
export interface ProxyHttpHeader {
|
||||
name: string
|
||||
value: string
|
||||
}
|
||||
|
||||
interface ProxyHttpRequest {
|
||||
export interface ProxyHttpRequest {
|
||||
url: string
|
||||
method?: string
|
||||
headers?: ProxyHttpHeader[]
|
||||
|
|
@ -13,13 +13,20 @@ interface ProxyHttpRequest {
|
|||
timeout_ms?: number
|
||||
}
|
||||
|
||||
interface ProxyHttpResponse {
|
||||
export interface ProxyHttpResponse {
|
||||
status: number
|
||||
headers: ProxyHttpHeader[]
|
||||
body: number[]
|
||||
url: string
|
||||
}
|
||||
|
||||
const NULL_BODY_STATUS_CODES = new Set([204, 205, 304])
|
||||
|
||||
function responseBodyForStatus(response: ProxyHttpResponse): BodyInit | null {
|
||||
if (NULL_BODY_STATUS_CODES.has(response.status)) return null
|
||||
return Uint8Array.from(response.body)
|
||||
}
|
||||
|
||||
function headersToProxyHeaders(headers: Headers): ProxyHttpHeader[] {
|
||||
return [...headers.entries()].map(([name, value]) => ({ name, value }))
|
||||
}
|
||||
|
|
@ -60,6 +67,15 @@ export function withAbortSignal<T>(promise: Promise<T>, 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<ProxyHttpResponse>('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])
|
||||
})
|
||||
|
|
|
|||
176
tests/engine/tauri/http.test.ts
Normal file
176
tests/engine/tauri/http.test.ts
Normal file
|
|
@ -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<T>(callback: () => Promise<T>): Promise<T> {
|
||||
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<void>((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<ProxyHttpResponse>((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')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue