@@ -157,50 +161,61 @@ const structuredData = generateStructuredData({ - -
- + + + -
+ ', + ), + ).toBe('
Post
'); + }); + + it('shares one timeout budget across endpoint attempts', async () => { + const fetchMock = vi.fn( + (_url: string | URL | Request, init?: RequestInit) => + new Promise((_resolve, reject) => { + if (init?.signal?.aborted) { + reject(init.signal.reason); + return; + } + init?.signal?.addEventListener('abort', () => reject(init.signal?.reason), { + once: true, + }); + }), + ); + + await expect( + fetchThreadsOEmbed('https://www.threads.com/t/ABC_123', 540, { + fetchImpl: fetchMock as unknown as typeof fetch, + timeoutMs: 10, + }), + ).rejects.toBeTruthy(); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[0]?.[1]?.signal).toBeInstanceOf(AbortSignal); + }); +}); diff --git a/src/utils/threadsEmbed.ts b/src/utils/threadsEmbed.ts index cdbc5f11..00ad2dd8 100644 --- a/src/utils/threadsEmbed.ts +++ b/src/utils/threadsEmbed.ts @@ -10,6 +10,12 @@ const OEMBED_ENDPOINTS = [ const SCRIPT_TAG_PATTERN = /)<[^<]*)*<\/script>/gi; const THREADS_POST_PATH = /threads\.(?:com|net)\/(?:@[^/]+\/post\/|t\/)([A-Za-z0-9_-]+)/i; +const THREADS_FETCH_TIMEOUT_MS = 8_000; + +type ThreadsFetchOptions = { + fetchImpl?: typeof fetch; + timeoutMs?: number; +}; /** Canonical post URL for oEmbed (strips tracking query params). */ export function normalizeThreadsPostUrl(url: string): string { @@ -21,8 +27,14 @@ export function normalizeThreadsPostUrl(url: string): string { return trimmed.split('?')[0] ?? trimmed; } -export async function fetchThreadsOEmbed(url: string, maxwidth = 540): Promise { +export async function fetchThreadsOEmbed( + url: string, + maxwidth = 540, + options: ThreadsFetchOptions = {}, +): Promise { const canonicalUrl = normalizeThreadsPostUrl(url); + const fetchImpl = options.fetchImpl ?? fetch; + const signal = AbortSignal.timeout(options.timeoutMs ?? THREADS_FETCH_TIMEOUT_MS); let lastError: Error | null = null; for (const base of OEMBED_ENDPOINTS) { @@ -31,7 +43,7 @@ export async function fetchThreadsOEmbed(url: string, maxwidth = 540): Promise