diff --git a/src/utils/axios-client.ts b/src/utils/axios-client.ts index 957835c..d81597b 100644 --- a/src/utils/axios-client.ts +++ b/src/utils/axios-client.ts @@ -131,6 +131,64 @@ function safeErrorLog(prefix: string, error: unknown) { } } +/** + * Convert an arbitrary value into something the structured clone algorithm + * (used by `postMessage`) can always serialize. Request/response bodies may + * contain functions, streams, or other host objects that make `postMessage` + * throw a `DataCloneError`; a JSON round-trip drops those, and a string + * fallback covers circular references. + */ +export function toSerializable(value: unknown): unknown { + if (value === null || value === undefined) return value; + const type = typeof value; + if (type === "string" || type === "number" || type === "boolean") return value; + try { + return JSON.parse(JSON.stringify(value)); + } catch { + try { + return String(value); + } catch { + return "[unserializable]"; + } + } +} + +/** + * Post a request-activity message to the parent window (the host builder's + * Activity Monitor). Posting the raw bodies can throw a `DataCloneError` when + * they aren't structured-cloneable; if that happens we retry with a sanitized + * payload so the start/end status is never lost. A dropped `api-request-end` + * leaves the Activity Monitor stuck on "Pending" for a request that actually + * completed (e.g. a backend function returning 200). + */ +function postActivityMessage(message: { + type: string; + requestId: string; + data: Record; +}) { + if (!isInIFrame) return; + try { + window.parent.postMessage(message, "*"); + } catch { + try { + window.parent.postMessage( + { ...message, data: toSerializable(message.data) }, + "*" + ); + } catch { + // Drop the bodies entirely but still deliver the status signal. + window.parent.postMessage( + { + type: message.type, + requestId: message.requestId, + data: { statusCode: message.data?.statusCode }, + }, + "*" + ); + } + } +} + /** * Creates an axios client with default configuration and interceptors. * @@ -179,27 +237,16 @@ export function createAxiosClient({ } const requestId = uuidv4(); (config as any).requestId = requestId; - if (isInIFrame) { - try { - window.parent.postMessage( - { - type: "api-request-start", - requestId, - data: { - url: baseURL + config.url, - method: config.method, - body: - config.data instanceof FormData - ? "[FormData object]" - : config.data, - }, - }, - "*" - ); - } catch { - /* skip the logging */ - } - } + postActivityMessage({ + type: "api-request-start", + requestId, + data: { + url: baseURL + config.url, + method: config.method, + body: + config.data instanceof FormData ? "[FormData object]" : config.data, + }, + }); return config; }); @@ -208,27 +255,34 @@ export function createAxiosClient({ client.interceptors.response.use( (response) => { const requestId = (response.config as any)?.requestId; - try { - if (isInIFrame && requestId) { - window.parent.postMessage( - { - type: "api-request-end", - requestId, - data: { - statusCode: response.status, - response: response.data, - }, - }, - "*" - ); - } - } catch { - /* do nothing */ + if (requestId) { + postActivityMessage({ + type: "api-request-end", + requestId, + data: { + statusCode: response.status, + response: response.data, + }, + }); } return response.data; }, (error) => { + // Resolve the Activity Monitor entry on failure too, so a failed + // request doesn't stay stuck on "Pending". + const requestId = (error.config as any)?.requestId; + if (requestId) { + postActivityMessage({ + type: "api-request-end", + requestId, + data: { + statusCode: error.response?.status ?? 0, + response: error.response?.data ?? { error: error.message }, + }, + }); + } + const message = error.response?.data?.message || error.response?.data?.detail || diff --git a/tests/unit/axios-client.test.ts b/tests/unit/axios-client.test.ts new file mode 100644 index 0000000..73c5176 --- /dev/null +++ b/tests/unit/axios-client.test.ts @@ -0,0 +1,101 @@ +import { describe, test, expect, beforeEach, afterEach, vi } from "vitest"; + +// The Activity Monitor postMessage path only runs inside an iframe. The unit +// test env is "node", so force isInIFrame on and provide a window. +vi.mock("../../src/utils/common.js", async () => { + const actual = + await vi.importActual( + "../../src/utils/common.js" + ); + return { ...actual, isInIFrame: true }; +}); + +import { createAxiosClient, toSerializable } from "../../src/utils/axios-client.ts"; + +describe("axios-client Activity Monitor logging", () => { + let posted: Array>; + let originalWindow: any; + + beforeEach(() => { + posted = []; + originalWindow = (globalThis as any).window; + (globalThis as any).window = { + location: { href: "https://docker-pr-12518.velino.org/" }, + // Faithfully simulate the browser: postMessage runs the structured clone + // algorithm and throws on non-cloneable payloads. + parent: { + postMessage: vi.fn((message: any) => { + structuredClone(message); + posted.push(message); + }), + }, + }; + }); + + afterEach(() => { + (globalThis as any).window = originalWindow; + }); + + const runWithResponse = async (responseData: unknown) => { + const client = createAxiosClient({ + baseURL: "https://docker-pr-12518.velino.org/api/apps/app-id/functions", + }); + client.defaults.adapter = async (config) => ({ + data: responseData, + status: 200, + statusText: "OK", + headers: {}, + config, + }); + await client.post("/searchClients", { q: "acme" }); + }; + + test("delivers api-request-end (200) even when the response body is not cloneable", async () => { + // A function field makes the raw postMessage throw DataCloneError. + await runWithResponse({ ok: true, retry: () => {} }); + + const ends = posted.filter((m) => m.type === "api-request-end"); + expect(ends).toHaveLength(1); + expect(ends[0].data.statusCode).toBe(200); + }); + + test("start and end share the same requestId", async () => { + await runWithResponse({ ok: true }); + + const start = posted.find((m) => m.type === "api-request-start"); + const end = posted.find((m) => m.type === "api-request-end"); + expect(start?.requestId).toBeTruthy(); + expect(end?.requestId).toBe(start?.requestId); + }); + + test("preserves a cloneable response body unchanged", async () => { + await runWithResponse({ ok: true, rows: [1, 2, 3] }); + + const end = posted.find((m) => m.type === "api-request-end"); + expect(end?.data.response).toEqual({ ok: true, rows: [1, 2, 3] }); + }); +}); + +describe("toSerializable", () => { + const cloneable = (v: unknown) => + expect(() => structuredClone(v)).not.toThrow(); + + test("passes primitives through", () => { + expect(toSerializable("x")).toBe("x"); + expect(toSerializable(42)).toBe(42); + expect(toSerializable(true)).toBe(true); + expect(toSerializable(null)).toBe(null); + }); + + test("strips function fields and yields a cloneable object", () => { + const result = toSerializable({ ok: true, retry: () => {} }); + expect(result).toEqual({ ok: true }); + cloneable(result); + }); + + test("collapses circular references to a cloneable value", () => { + const circular: any = { a: 1 }; + circular.self = circular; + cloneable(toSerializable(circular)); + }); +});