Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions src/lib/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import { isTauri } from '@/lib/platform'
import { getCapabilities, isTauri } from '@/lib/platform'
import { getLocalSetting } from '@/stores/local-settings-store'

/**
Expand All @@ -17,8 +17,15 @@ export const fetch = async (input: RequestInfo | URL, init?: RequestInit): Promi
}

if (getLocalSetting('isNativeFetchEnabled')) {
const { fetch: tauriFetch } = await import('@tauri-apps/plugin-http')
return tauriFetch(input, init)
// The "Use Native Fetch" dev setting can only be toggled on in builds
// compiled with `--features native_fetch`; guard against stale `true`
// values from prior builds so we don't invoke an unregistered plugin
// ("plugin http not found").
const { native_fetch: nativeFetchAvailable } = await getCapabilities()
if (nativeFetchAvailable) {
const { fetch: tauriFetch } = await import('@tauri-apps/plugin-http')
return tauriFetch(input, init)
}
}

return globalThis.fetch(input, init)
Expand Down
25 changes: 24 additions & 1 deletion src/lib/proxy-fetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,14 +67,15 @@ describe('createProxyFetch — Hosted mode', () => {
})

describe('createProxyFetch — Standalone (Tauri) mode', () => {
it('calls Tauri fetch directly without rewriting headers when toggle is off (default)', async () => {
it('calls Tauri fetch directly without rewriting headers when toggle is off and native_fetch is available', async () => {
const tauriFetchMock = mock(async () => new Response('tauri-direct', { status: 200 }))

const proxyFetch = createProxyFetch({
cloudUrl: 'http://localhost:8000/v1',
isStandalone: () => true,
tauriFetch: tauriFetchMock as unknown as typeof fetch,
getProxyEnabled: () => false,
getNativeFetchCapability: () => Promise.resolve(true),
})

await proxyFetch('https://example.com/api', {
Expand All @@ -88,6 +89,28 @@ describe('createProxyFetch — Standalone (Tauri) mode', () => {
const h = new Headers(calledInit.headers)
expect(h.get('authorization')).toBe('Bearer abc')
})

it('falls back to the hosted proxy when native_fetch is not compiled into the build', async () => {
const tauriFetchMock = mock(async () => new Response('should-not-be-called', { status: 500 }))
const hostedFetchMock = mock(async () => new Response('hosted', { status: 200 }))

const proxyFetch = createProxyFetch({
cloudUrl: 'http://localhost:8000/v1',
isStandalone: () => true,
tauriFetch: tauriFetchMock as unknown as typeof fetch,
fetchImpl: hostedFetchMock as unknown as typeof fetch,
getProxyEnabled: () => false,
getNativeFetchCapability: () => Promise.resolve(false),
})

await proxyFetch('https://example.com/api', { method: 'GET' })

expect(tauriFetchMock).toHaveBeenCalledTimes(0)
expect(hostedFetchMock).toHaveBeenCalledTimes(1)
const [hostedReq] = hostedFetchMock.mock.calls[0] as unknown as [Request]
expect(hostedReq.url).toBe('http://localhost:8000/v1/proxy')
expect(hostedReq.headers.get('x-proxy-target-url')).toBe('https://example.com/api')
})
})

describe('createProxyFetch — proxy_enabled toggle', () => {
Expand Down
20 changes: 17 additions & 3 deletions src/lib/proxy-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import {
} from '@shared/proxy-protocol'
import { encodeWsBearer, wsBearerSubprotocolPrefix, wsCarrierSubprotocol } from '@shared/ws-bearer'
import { getAuthToken } from './auth-token'
import { isTauri } from './platform'
import { getCapabilities, isTauri } from './platform'

const defaultIsStandalone = isTauri
const defaultReadProxyEnabled = (): string | null =>
Expand Down Expand Up @@ -157,16 +157,30 @@ export type ProxyFetchOptions = {
* in `src/ai/fetch.ts`) pass a getter that reads the `proxy_enabled` localStorage
* key + derives the effective value per platform. */
getProxyEnabled?: () => boolean
/** Whether `tauri-plugin-http` is registered in the current build. Defaults
* to `getCapabilities()` (memoised) — tests inject a stub. When false, the
* Tauri-direct path is unreachable (the plugin's JS shim would throw
* "plugin http not found"), so we fall through to the hosted proxy even on
* Tauri with `proxy_enabled=false`. Universal Proxy (THU-467) is the
* intended path; this guard makes the toggle a no-op in builds that ship
* without the native fetch capability. */
getNativeFetchCapability?: () => Promise<boolean>
}

/** Build a fetch implementation that hides Hosted/Standalone mode from callers. */
export const createProxyFetch = (options: ProxyFetchOptions): FetchFn => {
const proxyUrl = `${options.cloudUrl.replace(/\/$/, '')}/proxy`
const readNativeFetchCapability =
options.getNativeFetchCapability ?? (() => getCapabilities().then((c) => c.native_fetch))
const proxyFetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const standalone = (options.isStandalone ?? isTauri)()
const proxyEnabled = options.getProxyEnabled?.() ?? true
if (standalone && !proxyEnabled) {
// Standalone + toggle off: hit the upstream directly through Tauri's HTTP plugin.
if (standalone && !proxyEnabled && (await readNativeFetchCapability())) {
// Standalone + toggle off + plugin compiled in: hit the upstream directly
// through Tauri's HTTP plugin. Builds without `--features native_fetch`
// fall through to the hosted proxy (THU-467 Universal Proxy is the
// canonical path; the Tauri-direct branch is opt-in for builds that
// want to keep the user's IP off our backend).
const tFetch = options.tauriFetch ?? (tauriFetch as unknown as typeof fetch)
return tFetch(input as RequestInfo, init ?? {}) as unknown as Response
}
Expand Down
Loading