From 46dbe725de66107e9b2ef27a3cca26c85550969c Mon Sep 17 00:00:00 2001 From: kkfluous Date: Fri, 7 Aug 2026 15:04:47 +0800 Subject: [PATCH] perf: deduplicate concurrent API reads --- src/auth/api-client.test.ts | 61 +++++++++++++++++++++++++++++++++++++ src/auth/api-client.ts | 26 ++++++++++++++-- 2 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 src/auth/api-client.test.ts diff --git a/src/auth/api-client.test.ts b/src/auth/api-client.test.ts new file mode 100644 index 0000000..dfdc571 --- /dev/null +++ b/src/auth/api-client.test.ts @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + _clearApiClientInflightForTests, + fetchJson, + setTokenGetter, +} from './api-client.js'; + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); +} + +test('deduplicates concurrent GET requests for the same URL and token', async () => { + const originalFetch = globalThis.fetch; + let calls = 0; + let resolveFetch: ((response: Response) => void) | undefined; + globalThis.fetch = ((_: string | URL | Request, init?: RequestInit) => { + calls += 1; + assert.equal(new Headers(init?.headers).get('authorization'), 'Bearer test-token'); + return new Promise(resolve => { resolveFetch = resolve; }); + }) as typeof fetch; + setTokenGetter(() => 'test-token'); + _clearApiClientInflightForTests(); + + try { + const first = fetchJson<{ value: number }>('/api/test'); + const second = fetchJson<{ value: number }>('/api/test'); + assert.equal(calls, 1); + resolveFetch?.(jsonResponse({ value: 42 })); + assert.deepEqual(await first, { value: 42 }); + assert.deepEqual(await second, { value: 42 }); + } finally { + globalThis.fetch = originalFetch; + setTokenGetter(() => null); + _clearApiClientInflightForTests(); + } +}); + +test('does not retain a failed GET request', async () => { + const originalFetch = globalThis.fetch; + let calls = 0; + globalThis.fetch = (async () => { + calls += 1; + if (calls === 1) throw new Error('network down'); + return jsonResponse({ ok: true }); + }) as typeof fetch; + setTokenGetter(() => null); + _clearApiClientInflightForTests(); + + try { + await assert.rejects(fetchJson('/api/retry'), /network down/); + assert.deepEqual(await fetchJson('/api/retry'), { ok: true }); + assert.equal(calls, 2); + } finally { + globalThis.fetch = originalFetch; + _clearApiClientInflightForTests(); + } +}); diff --git a/src/auth/api-client.ts b/src/auth/api-client.ts index a200605..d2df265 100644 --- a/src/auth/api-client.ts +++ b/src/auth/api-client.ts @@ -1,13 +1,13 @@ /** 全局认证 fetch 客户端 */ let tokenGetter: () => string | null = () => null; +const inflightGetRequests = new Map>(); export function setTokenGetter(fn: () => string | null) { tokenGetter = fn; } -export async function fetchJson(url: string, options?: RequestInit): Promise { - const token = tokenGetter(); +async function executeJsonRequest(url: string, options: RequestInit | undefined, token: string | null): Promise { const res = await fetch(url, { ...options, headers: { @@ -22,3 +22,25 @@ export async function fetchJson(url: string, options?: RequestInit): Promise< if (!res.ok) throw new Error(`API error: ${res.status} ${res.statusText}`); return res.json(); } + +export async function fetchJson(url: string, options?: RequestInit): Promise { + const token = tokenGetter(); + const method = (options?.method || 'GET').toUpperCase(); + const canDedupe = method === 'GET' && options?.body == null && options?.signal == null; + if (!canDedupe) return executeJsonRequest(url, options, token); + + const key = `${token || 'anonymous'}:${url}`; + const existing = inflightGetRequests.get(key) as Promise | undefined; + if (existing) return existing; + + const request = executeJsonRequest(url, options, token) + .finally(() => { + if (inflightGetRequests.get(key) === request) inflightGetRequests.delete(key); + }); + inflightGetRequests.set(key, request); + return request; +} + +export function _clearApiClientInflightForTests(): void { + inflightGetRequests.clear(); +}