From 85cfba675ac52ec8710d2d0e5d0e6da9fe3e6982 Mon Sep 17 00:00:00 2001 From: Omar Shahine Date: Mon, 13 Apr 2026 11:03:47 -0700 Subject: [PATCH] fix(bluebubbles): lazy-refresh Private API status on send (#43764) (#65447) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(bluebubbles): lazy refresh Private API cache on send to prevent silent reply threading degradation (#43764) When the 10-minute server info cache expires, sends requesting reply threading or effects silently degrade to plain messages. Add a lazy async refresh of the cache in the send path when Private API features are needed but status is unknown, preserving graceful degradation if the refresh fails. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(bluebubbles): apply lazy Private API refresh to attachment sends and add missing test coverage (#43764) Attachment sends had the same cache-expiry bug as text sends: when the 10-minute Private API status cache TTL expired, reply threading metadata was silently dropped. Apply the same lazy-refresh pattern from send.ts. Also add the missing "refresh succeeds with private_api: false" test case for both send.ts and attachments.ts — proves effects throw and reply threading degrades without the "unknown" warning when the API is explicitly disabled. Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update no-raw-channel-fetch allowlist for test-harness line shift Adding fetchBlueBubblesServerInfo to the probe mock module shifted globalThis.fetch in test-harness.ts from line 128 to 130. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Lobster Co-authored-by: Claude Opus 4.6 (1M context) --- .../bluebubbles/src/attachments.test.ts | 135 +++++++++++- extensions/bluebubbles/src/attachments.ts | 23 +- extensions/bluebubbles/src/send.test.ts | 199 +++++++++++++++++- extensions/bluebubbles/src/send.ts | 22 +- extensions/bluebubbles/src/test-harness.ts | 2 + scripts/check-no-raw-channel-fetch.mjs | 2 +- 6 files changed, 378 insertions(+), 5 deletions(-) diff --git a/extensions/bluebubbles/src/attachments.test.ts b/extensions/bluebubbles/src/attachments.test.ts index 419aabc917c..735e832cc13 100644 --- a/extensions/bluebubbles/src/attachments.test.ts +++ b/extensions/bluebubbles/src/attachments.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import "./test-mocks.js"; import { downloadBlueBubblesAttachment, sendBlueBubblesAttachment } from "./attachments.js"; -import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; +import { fetchBlueBubblesServerInfo, getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; import type { PluginRuntime } from "./runtime-api.js"; import { setBlueBubblesRuntime } from "./runtime.js"; import { @@ -13,6 +13,7 @@ import { import type { BlueBubblesAttachment } from "./types.js"; const mockFetch = vi.fn(); +const fetchServerInfoMock = vi.mocked(fetchBlueBubblesServerInfo); const fetchRemoteMediaMock = vi.fn( async (params: { url: string; @@ -381,6 +382,8 @@ describe("sendBlueBubblesAttachment", () => { vi.stubGlobal("fetch", mockFetch); mockFetch.mockReset(); fetchRemoteMediaMock.mockClear(); + fetchServerInfoMock.mockReset(); + fetchServerInfoMock.mockResolvedValue(null); setBlueBubblesRuntime(runtimeStub); vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReset(); mockBlueBubblesPrivateApiStatus( @@ -620,6 +623,136 @@ describe("sendBlueBubblesAttachment", () => { expect(attachText).toContain("iMessage;-;+15557654321"); }); + describe("lazy private API refresh (#43764)", () => { + const privateApiStatusMock = vi.mocked(getCachedBlueBubblesPrivateApiStatus); + + it("refreshes cache when expired and reply threading is requested", async () => { + privateApiStatusMock.mockReturnValueOnce(null).mockReturnValueOnce(true); + fetchServerInfoMock.mockResolvedValueOnce({ private_api: true }); + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(JSON.stringify({ data: { guid: "msg-refreshed" } })), + }); + + const result = await sendBlueBubblesAttachment({ + to: "chat_guid:iMessage;-;+15551234567", + buffer: new Uint8Array([1, 2, 3]), + filename: "photo.jpg", + contentType: "image/jpeg", + replyToMessageGuid: "reply-guid-456", + opts: { serverUrl: "http://localhost:1234", password: "test" }, + }); + + expect(result.messageId).toBe("msg-refreshed"); + expect(fetchServerInfoMock).toHaveBeenCalledTimes(1); + const body = mockFetch.mock.calls[0][1]?.body as Uint8Array; + const bodyText = decodeBody(body); + expect(bodyText).toContain('name="method"'); + expect(bodyText).toContain("private-api"); + expect(bodyText).toContain('name="selectedMessageGuid"'); + }); + + it("does not refresh when cache is populated (cache hit)", async () => { + mockBlueBubblesPrivateApiStatusOnce( + privateApiStatusMock, + BLUE_BUBBLES_PRIVATE_API_STATUS.enabled, + ); + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(JSON.stringify({ data: { guid: "msg-cached" } })), + }); + + await sendBlueBubblesAttachment({ + to: "chat_guid:iMessage;-;+15551234567", + buffer: new Uint8Array([1, 2, 3]), + filename: "photo.jpg", + contentType: "image/jpeg", + replyToMessageGuid: "reply-guid-123", + opts: { serverUrl: "http://localhost:1234", password: "test" }, + }); + + expect(fetchServerInfoMock).not.toHaveBeenCalled(); + }); + + it("degrades gracefully when refresh fails", async () => { + fetchServerInfoMock.mockRejectedValueOnce(new Error("network error")); + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(JSON.stringify({ data: { guid: "msg-degraded" } })), + }); + + const runtimeLog = vi.fn(); + setBlueBubblesRuntime({ + ...runtimeStub, + log: runtimeLog, + } as unknown as PluginRuntime); + + const result = await sendBlueBubblesAttachment({ + to: "chat_guid:iMessage;-;+15551234567", + buffer: new Uint8Array([1, 2, 3]), + filename: "photo.jpg", + contentType: "image/jpeg", + replyToMessageGuid: "reply-guid-789", + opts: { serverUrl: "http://localhost:1234", password: "test" }, + }); + + expect(result.messageId).toBe("msg-degraded"); + expect(fetchServerInfoMock).toHaveBeenCalledTimes(1); + expect(runtimeLog).toHaveBeenCalledTimes(1); + expect(runtimeLog.mock.calls[0]?.[0]).toContain("Private API status unknown"); + }); + + it("degrades reply threading when refresh succeeds with private_api: false", async () => { + privateApiStatusMock.mockReturnValueOnce(null).mockReturnValueOnce(false); + fetchServerInfoMock.mockResolvedValueOnce({ private_api: false }); + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(JSON.stringify({ data: { guid: "msg-disabled" } })), + }); + + const runtimeLog = vi.fn(); + setBlueBubblesRuntime({ + ...runtimeStub, + log: runtimeLog, + } as unknown as PluginRuntime); + + const result = await sendBlueBubblesAttachment({ + to: "chat_guid:iMessage;-;+15551234567", + buffer: new Uint8Array([1, 2, 3]), + filename: "photo.jpg", + contentType: "image/jpeg", + replyToMessageGuid: "reply-guid-disabled", + opts: { serverUrl: "http://localhost:1234", password: "test" }, + }); + + expect(result.messageId).toBe("msg-disabled"); + expect(fetchServerInfoMock).toHaveBeenCalledTimes(1); + // No warning — status is known (disabled), not unknown + expect(runtimeLog).not.toHaveBeenCalled(); + const body = mockFetch.mock.calls[0][1]?.body as Uint8Array; + const bodyText = decodeBody(body); + expect(bodyText).not.toContain('name="selectedMessageGuid"'); + expect(bodyText).not.toContain('name="method"'); + }); + + it("does not refresh when no reply threading is requested", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(JSON.stringify({ data: { guid: "msg-plain" } })), + }); + + await sendBlueBubblesAttachment({ + to: "chat_guid:iMessage;-;+15551234567", + buffer: new Uint8Array([1, 2, 3]), + filename: "photo.jpg", + contentType: "image/jpeg", + opts: { serverUrl: "http://localhost:1234", password: "test" }, + }); + + expect(fetchServerInfoMock).not.toHaveBeenCalled(); + }); + }); + it("still throws for non-handle targets when chatGuid is not found", async () => { mockFetch.mockResolvedValueOnce({ ok: true, diff --git a/extensions/bluebubbles/src/attachments.ts b/extensions/bluebubbles/src/attachments.ts index 685fd936c73..83124b61b84 100644 --- a/extensions/bluebubbles/src/attachments.ts +++ b/extensions/bluebubbles/src/attachments.ts @@ -10,6 +10,7 @@ import { import { resolveBlueBubblesServerAccount } from "./account-resolve.js"; import { assertMultipartActionOk, postMultipartFormData } from "./multipart.js"; import { + fetchBlueBubblesServerInfo, getCachedBlueBubblesPrivateApiStatus, isBlueBubblesPrivateApiStatusEnabled, } from "./probe.js"; @@ -171,7 +172,27 @@ export async function sendBlueBubblesAttachment(params: { filename = sanitizeFilename(filename, fallbackName); contentType = normalizeOptionalString(contentType); const { baseUrl, password, accountId, allowPrivateNetwork } = resolveAccount(opts); - const privateApiStatus = getCachedBlueBubblesPrivateApiStatus(accountId); + let privateApiStatus = getCachedBlueBubblesPrivateApiStatus(accountId); + + // Lazy refresh: when the cache has expired and Private API features are needed, + // fetch server info before making the decision. This prevents silent degradation + // of reply threading after the 10-minute cache TTL expires. (#43764) + const wantsReplyThread = Boolean(replyToMessageGuid?.trim()); + if (privateApiStatus === null && wantsReplyThread) { + try { + await fetchBlueBubblesServerInfo({ + baseUrl, + password, + accountId, + timeoutMs: opts.timeoutMs ?? 5000, + allowPrivateNetwork, + }); + privateApiStatus = getCachedBlueBubblesPrivateApiStatus(accountId); + } catch { + // Refresh failed — proceed with null status (existing graceful degradation) + } + } + const privateApiEnabled = isBlueBubblesPrivateApiStatusEnabled(privateApiStatus); // Validate voice memo format when requested (BlueBubbles converts MP3 -> CAF when isAudioMessage). diff --git a/extensions/bluebubbles/src/send.test.ts b/extensions/bluebubbles/src/send.test.ts index e2f501f3fe4..3253e841ef0 100644 --- a/extensions/bluebubbles/src/send.test.ts +++ b/extensions/bluebubbles/src/send.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import "./test-mocks.js"; -import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; +import { fetchBlueBubblesServerInfo, getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; import type { PluginRuntime } from "./runtime-api.js"; import { clearBlueBubblesRuntime, setBlueBubblesRuntime } from "./runtime.js"; import { sendMessageBlueBubbles, resolveChatGuidForTarget, createChatForHandle } from "./send.js"; @@ -14,6 +14,7 @@ import { _setFetchGuardForTesting, type BlueBubblesSendTarget } from "./types.js const mockFetch = vi.fn(); const privateApiStatusMock = vi.mocked(getCachedBlueBubblesPrivateApiStatus); +const fetchServerInfoMock = vi.mocked(fetchBlueBubblesServerInfo); const setFetchGuardPassthrough = createBlueBubblesFetchGuardPassthroughInstaller(); installBlueBubblesFetchTestHooks({ @@ -378,6 +379,8 @@ describe("send", () => { describe("sendMessageBlueBubbles", () => { beforeEach(() => { mockFetch.mockReset(); + fetchServerInfoMock.mockReset(); + fetchServerInfoMock.mockResolvedValue(null); }); it("throws when text is empty", async () => { @@ -826,6 +829,200 @@ describe("send", () => { expect(typeof body.tempGuid).toBe("string"); expect(body.tempGuid.length).toBeGreaterThan(0); }); + + describe("lazy private API refresh (#43764)", () => { + it("does not refresh when cache is populated (cache hit)", async () => { + mockBlueBubblesPrivateApiStatusOnce( + privateApiStatusMock, + BLUE_BUBBLES_PRIVATE_API_STATUS.enabled, + ); + mockResolvedHandleTarget(); + mockSendResponse({ data: { guid: "msg-cached" } }); + + const result = await sendMessageBlueBubbles("+15551234567", "Replying", { + serverUrl: "http://localhost:1234", + password: "test", + replyToMessageGuid: "reply-guid-123", + }); + + expect(result.messageId).toBe("msg-cached"); + expect(fetchServerInfoMock).not.toHaveBeenCalled(); + const sendCall = mockFetch.mock.calls[1]; + const body = JSON.parse(sendCall[1].body); + expect(body.method).toBe("private-api"); + expect(body.selectedMessageGuid).toBe("reply-guid-123"); + }); + + it("refreshes cache when expired and reply threading is requested", async () => { + // First call returns null (cache expired), after refresh returns enabled + privateApiStatusMock.mockReturnValueOnce(null).mockReturnValueOnce(true); + fetchServerInfoMock.mockResolvedValueOnce({ private_api: true }); + mockResolvedHandleTarget(); + mockSendResponse({ data: { guid: "msg-refreshed" } }); + + const result = await sendMessageBlueBubbles("+15551234567", "Replying", { + serverUrl: "http://localhost:1234", + password: "test", + replyToMessageGuid: "reply-guid-456", + }); + + expect(result.messageId).toBe("msg-refreshed"); + expect(fetchServerInfoMock).toHaveBeenCalledTimes(1); + expect(fetchServerInfoMock).toHaveBeenCalledWith( + expect.objectContaining({ + baseUrl: expect.stringContaining("localhost"), + password: "test", + accountId: expect.any(String), + allowPrivateNetwork: expect.any(Boolean), + }), + ); + const sendCall = mockFetch.mock.calls[1]; + const body = JSON.parse(sendCall[1].body); + expect(body.method).toBe("private-api"); + expect(body.selectedMessageGuid).toBe("reply-guid-456"); + }); + + it("refreshes cache when expired and effect is requested", async () => { + privateApiStatusMock.mockReturnValueOnce(null).mockReturnValueOnce(true); + fetchServerInfoMock.mockResolvedValueOnce({ private_api: true }); + mockResolvedHandleTarget(); + mockSendResponse({ data: { guid: "msg-effect-refreshed" } }); + + const result = await sendMessageBlueBubbles("+15551234567", "Party!", { + serverUrl: "http://localhost:1234", + password: "test", + effectId: "confetti", + }); + + expect(result.messageId).toBe("msg-effect-refreshed"); + expect(fetchServerInfoMock).toHaveBeenCalledTimes(1); + const sendCall = mockFetch.mock.calls[1]; + const body = JSON.parse(sendCall[1].body); + expect(body.method).toBe("private-api"); + expect(body.effectId).toBe("com.apple.messages.effect.CKConfettiEffect"); + }); + + it("degrades gracefully when refresh fails", async () => { + // Cache expired, refresh throws — should fall back to existing behavior + fetchServerInfoMock.mockRejectedValueOnce(new Error("network error")); + mockResolvedHandleTarget(); + mockSendResponse({ data: { guid: "msg-degraded" } }); + + const runtimeLog = vi.fn(); + setBlueBubblesRuntime({ log: runtimeLog } as unknown as PluginRuntime); + + try { + const result = await sendMessageBlueBubbles("+15551234567", "Reply fallback", { + serverUrl: "http://localhost:1234", + password: "test", + replyToMessageGuid: "reply-guid-789", + }); + + expect(result.messageId).toBe("msg-degraded"); + expect(fetchServerInfoMock).toHaveBeenCalledTimes(1); + // Should warn about unknown status and send without threading + expect(runtimeLog).toHaveBeenCalledTimes(1); + expect(runtimeLog.mock.calls[0]?.[0]).toContain("Private API status unknown"); + const sendCall = mockFetch.mock.calls[1]; + const body = JSON.parse(sendCall[1].body); + expect(body.method).toBeUndefined(); + expect(body.selectedMessageGuid).toBeUndefined(); + } finally { + clearBlueBubblesRuntime(); + } + }); + + it("throws for effects when refresh succeeds with private_api: false", async () => { + // Cache expired, refresh succeeds but Private API is explicitly disabled + privateApiStatusMock.mockReturnValueOnce(null).mockReturnValueOnce(false); + fetchServerInfoMock.mockResolvedValueOnce({ private_api: false }); + mockResolvedHandleTarget(); + + await expect( + sendMessageBlueBubbles("+15551234567", "Party!", { + serverUrl: "http://localhost:1234", + password: "test", + effectId: "confetti", + }), + ).rejects.toThrow("Private API"); + + expect(fetchServerInfoMock).toHaveBeenCalledTimes(1); + }); + + it("degrades reply threading when refresh succeeds with private_api: false", async () => { + // Cache expired, refresh succeeds but Private API is explicitly disabled + // Should degrade without the "unknown" warning (status is known: disabled) + privateApiStatusMock.mockReturnValueOnce(null).mockReturnValueOnce(false); + fetchServerInfoMock.mockResolvedValueOnce({ private_api: false }); + mockResolvedHandleTarget(); + mockSendResponse({ data: { guid: "msg-disabled-after-refresh" } }); + + const runtimeLog = vi.fn(); + setBlueBubblesRuntime({ log: runtimeLog } as unknown as PluginRuntime); + + try { + const result = await sendMessageBlueBubbles("+15551234567", "Reply fallback", { + serverUrl: "http://localhost:1234", + password: "test", + replyToMessageGuid: "reply-guid-disabled", + }); + + expect(result.messageId).toBe("msg-disabled-after-refresh"); + expect(fetchServerInfoMock).toHaveBeenCalledTimes(1); + // No warning — status is known (disabled), not unknown + expect(runtimeLog).not.toHaveBeenCalled(); + const sendCall = mockFetch.mock.calls[1]; + const body = JSON.parse(sendCall[1].body); + expect(body.method).toBeUndefined(); + expect(body.selectedMessageGuid).toBeUndefined(); + } finally { + clearBlueBubblesRuntime(); + } + }); + + it("does not refresh when no reply or effect is requested", async () => { + // Cache expired but no Private API features needed — skip refresh + mockResolvedHandleTarget(); + mockSendResponse({ data: { guid: "msg-plain" } }); + + const result = await sendMessageBlueBubbles("+15551234567", "Plain message", { + serverUrl: "http://localhost:1234", + password: "test", + }); + + expect(result.messageId).toBe("msg-plain"); + expect(fetchServerInfoMock).not.toHaveBeenCalled(); + const sendCall = mockFetch.mock.calls[1]; + const body = JSON.parse(sendCall[1].body); + expect(body.method).toBeUndefined(); + }); + + it("degrades gracefully when refresh returns null (server unreachable)", async () => { + // Cache expired, refresh returns null (server info unavailable) + fetchServerInfoMock.mockResolvedValueOnce(null); + mockResolvedHandleTarget(); + mockSendResponse({ data: { guid: "msg-null-refresh" } }); + + const runtimeLog = vi.fn(); + setBlueBubblesRuntime({ log: runtimeLog } as unknown as PluginRuntime); + + try { + const result = await sendMessageBlueBubbles("+15551234567", "Reply attempt", { + serverUrl: "http://localhost:1234", + password: "test", + replyToMessageGuid: "reply-guid-000", + }); + + expect(result.messageId).toBe("msg-null-refresh"); + expect(fetchServerInfoMock).toHaveBeenCalledTimes(1); + // privateApiStatus still null after failed refresh → warning + degradation + expect(runtimeLog).toHaveBeenCalledTimes(1); + expect(runtimeLog.mock.calls[0]?.[0]).toContain("Private API status unknown"); + } finally { + clearBlueBubblesRuntime(); + } + }); + }); }); describe("createChatForHandle", () => { diff --git a/extensions/bluebubbles/src/send.ts b/extensions/bluebubbles/src/send.ts index b62f7d1ac44..eec056a467b 100644 --- a/extensions/bluebubbles/src/send.ts +++ b/extensions/bluebubbles/src/send.ts @@ -7,6 +7,7 @@ import { } from "openclaw/plugin-sdk/text-runtime"; import { resolveBlueBubblesServerAccount } from "./account-resolve.js"; import { + fetchBlueBubblesServerInfo, getCachedBlueBubblesPrivateApiStatus, isBlueBubblesPrivateApiStatusEnabled, } from "./probe.js"; @@ -456,7 +457,7 @@ export async function sendMessageBlueBubbles( serverUrl: opts.serverUrl, password: opts.password, }); - const privateApiStatus = getCachedBlueBubblesPrivateApiStatus(accountId); + let privateApiStatus = getCachedBlueBubblesPrivateApiStatus(accountId); const target = resolveBlueBubblesSendTarget(to); const chatGuid = await resolveChatGuidForTarget({ @@ -486,6 +487,25 @@ export async function sendMessageBlueBubbles( const effectId = resolveEffectId(opts.effectId); const wantsReplyThread = normalizeOptionalString(opts.replyToMessageGuid) !== undefined; const wantsEffect = Boolean(effectId); + + // Lazy refresh: when the cache has expired and Private API features are needed, + // fetch server info before making the decision. This prevents silent degradation + // of reply threading and effects after the 10-minute cache TTL expires. (#43764) + if (privateApiStatus === null && (wantsReplyThread || wantsEffect)) { + try { + await fetchBlueBubblesServerInfo({ + baseUrl, + password, + accountId, + timeoutMs: opts.timeoutMs ?? 5000, + allowPrivateNetwork, + }); + privateApiStatus = getCachedBlueBubblesPrivateApiStatus(accountId); + } catch { + // Refresh failed — proceed with null status (existing graceful degradation) + } + } + const privateApiDecision = resolvePrivateApiDecision({ privateApiStatus, wantsReplyThread, diff --git a/extensions/bluebubbles/src/test-harness.ts b/extensions/bluebubbles/src/test-harness.ts index 57732ee980b..000d080c8b7 100644 --- a/extensions/bluebubbles/src/test-harness.ts +++ b/extensions/bluebubbles/src/test-harness.ts @@ -82,12 +82,14 @@ export function createBlueBubblesAccountsMockModule() { } type BlueBubblesProbeMockModule = { + fetchBlueBubblesServerInfo: Mock<() => Promise | null>>; getCachedBlueBubblesPrivateApiStatus: Mock<() => boolean | null>; isBlueBubblesPrivateApiStatusEnabled: Mock<(status: boolean | null) => boolean>; }; export function createBlueBubblesProbeMockModule(): BlueBubblesProbeMockModule { return { + fetchBlueBubblesServerInfo: vi.fn().mockResolvedValue(null), getCachedBlueBubblesPrivateApiStatus: vi .fn() .mockReturnValue(BLUE_BUBBLES_PRIVATE_API_STATUS.unknown), diff --git a/scripts/check-no-raw-channel-fetch.mjs b/scripts/check-no-raw-channel-fetch.mjs index 132bc3c5772..0d019d8eda4 100644 --- a/scripts/check-no-raw-channel-fetch.mjs +++ b/scripts/check-no-raw-channel-fetch.mjs @@ -14,7 +14,7 @@ const sourceRoots = ["src/channels", "src/routing", "src/line", "extensions"]; // Temporary allowlist for legacy callsites. New raw fetch callsites in channel/plugin runtime // code should be rejected and migrated to fetchWithSsrFGuard/shared channel helpers. const allowedRawFetchCallsites = new Set([ - bundledPluginCallsite("bluebubbles", "src/test-harness.ts", 128), + bundledPluginCallsite("bluebubbles", "src/test-harness.ts", 130), bundledPluginCallsite("bluebubbles", "src/types.ts", 181), bundledPluginCallsite("browser", "src/browser/cdp.helpers.ts", 268), bundledPluginCallsite("browser", "src/browser/client-fetch.ts", 192),