From 0b45237f93a6a630164d6ac3007f4fa8280b71f7 Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Thu, 22 Oct 2026 16:58:12 -0400 Subject: [PATCH 1/2] feat(apps): return allowed connection IDs from debugBundle as JSON POST /__dd/debugBundle answers with { code, allowedConnectionIds } when the Accept header lists application/json without q=0, and keeps the plain-text bundle otherwise. The route path and response type live in the shared backend protocol so the browser client can use them. Co-Authored-By: Claude Opus 5.5 --- packages/plugins/apps/src/backend/protocol.ts | 7 + .../src/vite/dev-server.integration.test.ts | 37 +++++ .../plugins/apps/src/vite/dev-server.test.ts | 149 ++++++++++++++++-- packages/plugins/apps/src/vite/dev-server.ts | 50 ++++-- 4 files changed, 224 insertions(+), 19 deletions(-) diff --git a/packages/plugins/apps/src/backend/protocol.ts b/packages/plugins/apps/src/backend/protocol.ts index 04e8751fa..ff6ccbeff 100644 --- a/packages/plugins/apps/src/backend/protocol.ts +++ b/packages/plugins/apps/src/backend/protocol.ts @@ -18,6 +18,13 @@ export interface ExecuteActionRequest { args: unknown[]; } +export const DEBUG_BUNDLE_PATH = '/__dd/debugBundle'; + +export interface DebugBundleResponse { + code: string; + allowedConnectionIds: string[]; +} + /** * Response from executing a backend function. * diff --git a/packages/plugins/apps/src/vite/dev-server.integration.test.ts b/packages/plugins/apps/src/vite/dev-server.integration.test.ts index f3dabb765..a504ed31f 100644 --- a/packages/plugins/apps/src/vite/dev-server.integration.test.ts +++ b/packages/plugins/apps/src/vite/dev-server.integration.test.ts @@ -45,6 +45,7 @@ import { import { extractConnectionIdsFromModuleGraph } from '../backend/ast-parsing/extract-connection-ids-from-module-graph'; import { encodeQueryName } from '../backend/encodeQueryName'; +import { DEBUG_BUNDLE_PATH } from '../backend/protocol'; import type { BackendFunction } from '../backend/types'; import { DEV_VERIFY_MODE } from '../constants'; @@ -463,6 +464,42 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { expect(apiScope.isDone()).toBe(true); }, 30000); + test('Should return a real bundle with the connection IDs it uses from /__dd/debugBundle as JSON', async () => { + const loadModule = server.ssrLoadModule.bind(server); + const log = getMockLogger(); + const middleware = createDevServerMiddleware( + build, + loadModule, + () => [actionCatalogCallFunc], + async () => [], + { site: 'datadoghq.com' }, + undefined, + mockLongPolling, + FIXTURE_ROOT, + log, + 'development', + createViteHostCheck(server.config), + ); + + const functionName = encodeQueryName(actionCatalogCallFunc); + const req = createMockRequest( + DEBUG_BUNDLE_PATH, + { functionName }, + { accept: 'application/json' }, + ); + const res = createMockResponse(); + + const next = jest.fn(); + middleware(req, res, next); + await res.done; + + expect(res.statusCode).toBe(200); + const rawBody = res.getBody(); + const body = JSON.parse(rawBody); + expect(body.allowedConnectionIds).toEqual(['conn-1']); + expect(body.code).toContain('conn-1'); + }, 30000); + // A dynamic import sits between two static ones, so resolution must come from the AST // itself rather than node.importedModules's undocumented ordering for mixed imports. test('Should recognize a connectionId-scoped action-catalog call even when a top-level dynamic import sits between two static imports', async () => { diff --git a/packages/plugins/apps/src/vite/dev-server.test.ts b/packages/plugins/apps/src/vite/dev-server.test.ts index cbd9c34ab..27a2bfaa9 100644 --- a/packages/plugins/apps/src/vite/dev-server.test.ts +++ b/packages/plugins/apps/src/vite/dev-server.test.ts @@ -31,6 +31,7 @@ import nock from 'nock'; import type { InlineConfig } from 'vite'; import { encodeQueryName } from '../backend/encodeQueryName'; +import { DEBUG_BUNDLE_PATH } from '../backend/protocol'; import type { BackendFunction } from '../backend/types'; import { DEV_VERIFY_MODE, LOCAL_EXECUTION_LOAD_SUFFIX } from '../constants'; import type { AppsOptionsWithDefaults } from '../types'; @@ -326,7 +327,7 @@ describe('Dev Server Middleware', () => { const middleware = createTestMiddleware(); test('Should call next() for non-POST requests', () => { - const req = { method: 'GET', url: '/__dd/debugBundle' } as unknown as IncomingMessage; + const req = { method: 'GET', url: DEBUG_BUNDLE_PATH } as unknown as IncomingMessage; const res = createMockResponse(); const next = jest.fn(); @@ -351,7 +352,7 @@ describe('Dev Server Middleware', () => { test('Should handle /__dd/debugBundle POST', async () => { mockBuildWithParsedBackend(); - const req = createMockRequest('/__dd/debugBundle', { + const req = createMockRequest(DEBUG_BUNDLE_PATH, { functionName: encodeQueryName(mockFunctions[0]), }); const res = createMockResponse(); @@ -561,7 +562,7 @@ describe('Dev Server Middleware', () => { const middleware = createTestMiddleware(); test('Should return 400 for missing functionRef', async () => { - const req = createMockRequest('/__dd/debugBundle', {}); + const req = createMockRequest(DEBUG_BUNDLE_PATH, {}); const res = createMockResponse(); middleware(req, res, jest.fn()); @@ -572,7 +573,7 @@ describe('Dev Server Middleware', () => { }); test('Should return 404 for unknown function', async () => { - const req = createMockRequest('/__dd/debugBundle', { + const req = createMockRequest(DEBUG_BUNDLE_PATH, { functionName: 'nonexistent.nonexistent', }); const res = createMockResponse(); @@ -587,7 +588,7 @@ describe('Dev Server Middleware', () => { test('Should return bundled code as text/plain', async () => { mockBuildWithParsedBackend('export function main($) {}'); - const req = createMockRequest('/__dd/debugBundle', { + const req = createMockRequest(DEBUG_BUNDLE_PATH, { functionName: encodeQueryName(mockFunctions[0]), }); const res = createMockResponse(); @@ -600,6 +601,136 @@ describe('Dev Server Middleware', () => { expect(res.getBody()).toContain('export function main($)'); }); + const plainTextCases = [ + { description: 'no Accept header', headers: {} }, + { description: 'a wildcard Accept header', headers: { accept: '*/*' } }, + { description: 'a text/plain Accept header', headers: { accept: 'text/plain' } }, + { + description: 'an Accept header that refuses JSON with q=0', + headers: { accept: 'application/json;q=0, text/plain' }, + }, + ]; + + test.each(plainTextCases)( + 'Should keep returning plain-text code for a request with $description', + async ({ headers }) => { + mockBuildWithParsedBackend('export function main($) {}'); + + const functionName = encodeQueryName(mockFunctions[0]); + const req = createMockRequest(DEBUG_BUNDLE_PATH, { functionName }, headers); + const res = createMockResponse(); + + const next = jest.fn(); + middleware(req, res, next); + await res.done; + + expect(res.statusCode).toBe(200); + expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'text/plain'); + const body = res.getBody(); + expect(body).toBe('export function main($) {}'); + }, + ); + + const jsonAcceptCases = [ + { description: 'application/json', accept: 'application/json' }, + { description: 'application/json with parameters', accept: 'application/json; q=1' }, + { description: 'mixed-case application/json', accept: 'Application/JSON' }, + { + description: 'application/json among other media types', + accept: 'text/html, application/json;q=0.9', + }, + ]; + + test.each(jsonAcceptCases)( + 'Should return code and no connection IDs as JSON for an Accept header of $description', + async ({ accept }) => { + mockBuildWithParsedBackend('export function main($) {}'); + + const functionName = encodeQueryName(mockFunctions[0]); + const req = createMockRequest(DEBUG_BUNDLE_PATH, { functionName }, { accept }); + const res = createMockResponse(); + + const next = jest.fn(); + middleware(req, res, next); + await res.done; + + expect(res.statusCode).toBe(200); + expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'application/json'); + const rawBody = res.getBody(); + const body = JSON.parse(rawBody); + expect(body).toEqual({ + code: 'export function main($) {}', + allowedConnectionIds: [], + }); + }, + ); + + test('Should return the connection IDs the bundled function and its helpers use as JSON', async () => { + const helperId = '/project/backend/helpers/slack.js'; + const entryCode = ` + import { request } from '@datadog/action-catalog/http/http'; + import { notify } from './helpers/slack.js'; + export function greet() { + request({ connectionId: 'conn-http', inputs: {} }); + return notify(); + } + `; + const helperCode = ` + import { sendSlackMessage } from '@datadog/action-catalog'; + export function notify() { + return sendSlackMessage({ connectionId: 'conn-slack', inputs: {} }); + } + `; + mockViteBuild.mockImplementation(async (config) => { + await emitModuleParsed(config, mockFunctions[0].absolutePath, entryCode); + await emitModuleParsed(config, helperId, helperCode); + return mockBuildResult('// bundled'); + }); + + const functionName = encodeQueryName(mockFunctions[0]); + const req = createMockRequest( + DEBUG_BUNDLE_PATH, + { functionName }, + { accept: 'application/json' }, + ); + const res = createMockResponse(); + + const next = jest.fn(); + middleware(req, res, next); + await res.done; + + expect(res.statusCode).toBe(200); + const rawBody = res.getBody(); + const body = JSON.parse(rawBody); + expect(body).toEqual({ + code: '// bundled', + allowedConnectionIds: ['conn-http', 'conn-slack'], + }); + }); + + test('Should return a JSON 404 for an unknown query name when JSON is requested', async () => { + const req = createMockRequest( + DEBUG_BUNDLE_PATH, + { functionName: 'nonexistent.nonexistent' }, + { accept: 'application/json' }, + ); + const res = createMockResponse(); + + const next = jest.fn(); + middleware(req, res, next); + await res.done; + + expect(res.statusCode).toBe(404); + expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'application/json'); + const rawBody = res.getBody(); + const body = JSON.parse(rawBody); + expect(body).toEqual({ + success: false, + error: 'Backend function "nonexistent.nonexistent" not found', + }); + expect(mockViteBuild).not.toHaveBeenCalled(); + }); + test('Should reject a bundle whose imported helper module has a restricted import or global', async () => { // Emits moduleParsed for both the entry and a helper module, to prove the static-checks plugin covers the helper too, not just the entry file. mockViteBuild.mockImplementation(async (config) => { @@ -617,7 +748,7 @@ describe('Dev Server Middleware', () => { }); const encodedFunctionName = encodeQueryName(mockFunctions[0]); - const req = createMockRequest('/__dd/debugBundle', { + const req = createMockRequest(DEBUG_BUNDLE_PATH, { functionName: encodedFunctionName, }); const res = createMockResponse(); @@ -637,7 +768,7 @@ describe('Dev Server Middleware', () => { test('Should call vite.build with configFile: false and write: false', async () => { mockBuildWithParsedBackend(); - const req = createMockRequest('/__dd/debugBundle', { + const req = createMockRequest(DEBUG_BUNDLE_PATH, { functionName: encodeQueryName(mockFunctions[0]), args: [1, 2], }); @@ -2035,7 +2166,7 @@ describe('Dev Server Middleware', () => { ]; // Old name should 404. - const oldReq = createMockRequest('/__dd/debugBundle', { + const oldReq = createMockRequest(DEBUG_BUNDLE_PATH, { functionName: encodeQueryName({ relativePath: 'backend/greet', name: 'greet' }), }); const oldRes = createMockResponse(); @@ -2048,7 +2179,7 @@ describe('Dev Server Middleware', () => { // New name should resolve. mockBuildWithParsedBackend('// greetV2 code'); - const newReq = createMockRequest('/__dd/debugBundle', { + const newReq = createMockRequest(DEBUG_BUNDLE_PATH, { functionName: encodeQueryName({ relativePath: 'backend/greet', name: 'greetV2' }), }); const newRes = createMockResponse(); diff --git a/packages/plugins/apps/src/vite/dev-server.ts b/packages/plugins/apps/src/vite/dev-server.ts index e5d63156e..81bb70d25 100644 --- a/packages/plugins/apps/src/vite/dev-server.ts +++ b/packages/plugins/apps/src/vite/dev-server.ts @@ -13,7 +13,12 @@ import type { build } from 'vite'; import { AUTH_GUIDANCE } from '../auth'; import type { DoAuthenticatedRequest } from '../auth'; import { encodeQueryName } from '../backend/encodeQueryName'; -import type { ExecuteActionRequest, ExecuteActionResponse } from '../backend/protocol'; +import { DEBUG_BUNDLE_PATH } from '../backend/protocol'; +import type { + DebugBundleResponse, + ExecuteActionRequest, + ExecuteActionResponse, +} from '../backend/protocol'; import type { BackendFunction, BackendOutputs } from '../backend/types'; import { generateDevVirtualEntryContent } from '../backend/virtual-entry'; import { DEV_VERIFY_MODE } from '../constants'; @@ -37,7 +42,7 @@ type BundleFn = (func: BackendFunction) => Promise; const DEV_VIRTUAL_PREFIX = 'virtual:dd-backend-dev:'; -export const DEBUG_BUNDLE_ROUTE = '/__dd/debugBundle'; +export const DEBUG_BUNDLE_ROUTE = DEBUG_BUNDLE_PATH; export const EXECUTE_ACTION_ROUTE = '/__dd/executeAction'; export const EXECUTE_ACTION_VIA_CLOUD_ROUTE = '/__dd/executeActionViaCloud'; @@ -505,22 +510,27 @@ async function pollQueryExecution( throw new Error('Query execution timed out'); } +function sendJson(res: ServerResponse, statusCode: number, body: unknown): void { + const serializedBody = JSON.stringify(body); + res.statusCode = statusCode; + res.setHeader('Content-Type', 'application/json'); + res.end(serializedBody); +} + /** * Send a JSON error response. */ function sendError(res: ServerResponse, statusCode: number, message: string): void { - res.statusCode = statusCode; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ success: false, error: message } satisfies ExecuteActionResponse)); + const body = { success: false, error: message } satisfies ExecuteActionResponse; + sendJson(res, statusCode, body); } /** * Send a JSON success response. */ function sendSuccess(res: ServerResponse, result: { data: unknown }): void { - res.statusCode = 200; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ success: true, result } satisfies ExecuteActionResponse)); + const body = { success: true, result } satisfies ExecuteActionResponse; + sendJson(res, 200, body); } /** @@ -605,8 +615,20 @@ async function validateAndBundle( return { ...bundled, args }; } +function acceptsJson(req: IncomingMessage): boolean { + const accept = req.headers.accept ?? ''; + return accept.split(',').some((mediaRange) => { + const [mediaType, ...parameters] = mediaRange.split(';'); + const isRefused = parameters.some((parameter) => + /^\s*q\s*=\s*0(\.0*)?\s*$/i.test(parameter), + ); + return mediaType.trim().toLowerCase() === 'application/json' && !isRefused; + }); +} + /** - * Handle POST /__dd/debugBundle — returns the bundled script for inspection. + * Handle POST /__dd/debugBundle — returns the bundled script, as plain text by default or as + * `{ code, allowedConnectionIds }` when the Accept header lists `application/json` without q=0. */ async function handleDebugBundle( req: IncomingMessage, @@ -615,8 +637,16 @@ async function handleDebugBundle( bundle: BundleFn, ): Promise { try { - const { code } = await validateAndBundle(req, functionsByName, bundle); + const { func, code } = await validateAndBundle(req, functionsByName, bundle); + if (acceptsJson(req)) { + const body: DebugBundleResponse = { + code, + allowedConnectionIds: func.allowedConnectionIds, + }; + sendJson(res, 200, body); + return; + } res.statusCode = 200; res.setHeader('Content-Type', 'text/plain'); res.end(code); From 477b4b9e5502068deecb709100a182cdc982e774 Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Thu, 22 Oct 2026 16:58:28 -0400 Subject: [PATCH 2/2] feat(apps): attach the dev server bundle to framed backend calls A dev client, built as apps-dev-client, installs a hook the postMessage transport calls before each run-query. When the page is framed by a Datadog page, found through ancestorOrigins or, on Firefox, from the origin of the parent's answer to a ping, the hook fetches the function's bundle from /__dd/debugBundle and returns it with the parent's exact origin. A hook that fails, throws or stalls never stops the call. A parent with an opaque ("null") origin gets the call without a bundle, and the page warns once. The Vite plugin prepends a module script for virtual:dd-apps-dev-client to served HTML, ahead of Vite's base rewrite, and loads the built apps-dev-client file for it. The virtual id resolves only for client requests while the dev server runs. The transport posts a bundle only to an exact origin and accepts a run-query response only from window.parent and, when it posted to an exact origin, only from that origin. It copies the bundle's allowedConnectionIds by index into a plain array before checking them, so an array subclass can't choose what gets posted through Symbol.species. Co-Authored-By: Claude Opus 5.5 --- packages/plugins/apps/package.json | 6 + .../client/dev-client/dev-client.test.ts | 822 ++++++++++++++++++ .../backend/client/dev-client/dev-client.ts | 221 +++++ .../client/execute-backend-function.test.ts | 8 +- .../plugins/apps/src/backend/client/guards.ts | 30 + .../apps/src/backend/client/request-id.ts | 15 + .../post-message-transport.test.ts | 437 ++++++++++ .../post-message-transport.ts | 179 +++- .../post-message-transport/types.ts | 18 +- packages/plugins/apps/src/backend/protocol.ts | 21 + .../plugins/apps/src/built/apps-dev-client.ts | 9 + .../apps/src/vite/dev-client-build.test.ts | 28 + .../src/vite/dev-server.integration.test.ts | 86 +- packages/plugins/apps/src/vite/index.test.ts | 173 +++- packages/plugins/apps/src/vite/index.ts | 46 +- .../tests/src/_jest/helpers/fakeWindow.ts | 104 +++ 16 files changed, 2144 insertions(+), 59 deletions(-) create mode 100644 packages/plugins/apps/src/backend/client/dev-client/dev-client.test.ts create mode 100644 packages/plugins/apps/src/backend/client/dev-client/dev-client.ts create mode 100644 packages/plugins/apps/src/backend/client/guards.ts create mode 100644 packages/plugins/apps/src/backend/client/request-id.ts create mode 100644 packages/plugins/apps/src/backend/client/transports/post-message-transport/post-message-transport.test.ts create mode 100644 packages/plugins/apps/src/built/apps-dev-client.ts create mode 100644 packages/plugins/apps/src/vite/dev-client-build.test.ts create mode 100644 packages/tests/src/_jest/helpers/fakeWindow.ts diff --git a/packages/plugins/apps/package.json b/packages/plugins/apps/package.json index 432167336..11ac0e81b 100644 --- a/packages/plugins/apps/package.json +++ b/packages/plugins/apps/package.json @@ -19,6 +19,12 @@ "format": [ "esm" ] + }, + "apps-dev-client": { + "entry": "./src/built/apps-dev-client.ts", + "format": [ + "esm" + ] } } }, diff --git a/packages/plugins/apps/src/backend/client/dev-client/dev-client.test.ts b/packages/plugins/apps/src/backend/client/dev-client/dev-client.test.ts new file mode 100644 index 000000000..30172312f --- /dev/null +++ b/packages/plugins/apps/src/backend/client/dev-client/dev-client.test.ts @@ -0,0 +1,822 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +/* global globalThis */ + +import { + ARGS, + BUNDLE, + DATADOG_PARENT, + MESSAGE_WITHOUT_BUNDLE, + QUERY_NAME, + installFakeWindow, + mockFetchResponse, +} from '@dd/tests/_jest/helpers/fakeWindow'; +import type { FakeResponse, PostedMessage } from '@dd/tests/_jest/helpers/fakeWindow'; + +import { DEBUG_BUNDLE_PATH, DEV_CLIENT_HOOK, PING, PONG, RUN_QUERY } from '../../protocol'; +import { + POSTMESSAGE_TIMEOUT_MS, + postMessageTransport, +} from '../transports/post-message-transport/post-message-transport'; + +import { + BUNDLE_FETCH_TIMEOUT_MS, + OPAQUE_PARENT_ORIGIN_REASON, + MAX_UNANSWERED_PINGS, + PARENT_PING_TIMEOUT_MS, + prepareCall, + resetDevClient, +} from './dev-client'; + +describe('dev client', () => { + let originalFetch: typeof fetch; + let warn: jest.SpyInstance; + + beforeEach(() => { + originalFetch = global.fetch; + resetDevClient(); + Reflect.set(globalThis, DEV_CLIENT_HOOK, prepareCall); + warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + global.fetch = originalFetch; + Reflect.deleteProperty(globalThis, 'window'); + Reflect.deleteProperty(globalThis, DEV_CLIENT_HOOK); + warn.mockRestore(); + jest.useRealTimers(); + }); + + test('Should attach its own dev server bundle to the run-query message', async () => { + const { posted, reply } = installFakeWindow(); + mockFetchResponse({ ok: true, json: async () => BUNDLE }); + + const result = postMessageTransport(QUERY_NAME, ARGS); + const message = await posted; + reply(message.requestId, { greeting: 'hi' }); + + await expect(result).resolves.toEqual({ greeting: 'hi' }); + expect(message).toEqual({ ...MESSAGE_WITHOUT_BUNDLE, bundle: BUNDLE }); + expect(warn).not.toHaveBeenCalled(); + }); + + const datadogParents = [ + 'https://app.datadoghq.com', + 'https://dd.datad0g.com', + 'https://app.datadoghq.eu', + 'https://us3.datadoghq.com', + 'https://myorg.us5.datadoghq.com', + 'https://app.ddog-gov.com', + 'https://app.datadoghq.com.', + ]; + + test.each(datadogParents)('Should attach the bundle for a %s parent', async (parentOrigin) => { + const { postMessage, posted, reply } = installFakeWindow({ + ancestorOrigins: [parentOrigin], + }); + mockFetchResponse({ ok: true, json: async () => BUNDLE }); + + const result = postMessageTransport(QUERY_NAME, ARGS); + const message = await posted; + reply(message.requestId, 'ok'); + await result; + + expect(message).toHaveProperty('bundle', BUNDLE); + expect(postMessage).toHaveBeenCalledWith(message, parentOrigin); + }); + + const nonDatadogParents = [ + { description: 'a plain-http Datadog host', parentOrigin: 'http://app.datadoghq.com' }, + { + description: 'a lookalike host ending in a Datadog site', + parentOrigin: 'https://app.datadoghq.com.evil.example', + }, + { + description: 'a host that only contains a site name', + parentOrigin: 'https://evildatadoghq.com', + }, + { + description: 'a Datadog host on a non-default port', + parentOrigin: 'https://app.datadoghq.com:8443', + }, + { description: 'a local page', parentOrigin: 'http://localhost:5173' }, + ]; + + test.each(nonDatadogParents)( + 'Should send the call without fetching a bundle for $description', + async ({ parentOrigin }) => { + const { postMessage, posted, reply } = installFakeWindow({ + ancestorOrigins: [parentOrigin], + }); + mockFetchResponse({ ok: true, json: async () => BUNDLE }); + + const result = postMessageTransport(QUERY_NAME, ARGS); + const message = await posted; + reply(message.requestId, 'ok'); + + await expect(result).resolves.toBe('ok'); + expect(global.fetch).not.toHaveBeenCalled(); + expect(message).not.toHaveProperty('bundle'); + expect(postMessage).toHaveBeenCalledWith(message, '*'); + expect(warn).not.toHaveBeenCalled(); + }, + ); + + test('Should warn once and send without a bundle when ancestorOrigins reports an opaque parent origin', async () => { + const { postMessage, reply } = installFakeWindow({ ancestorOrigins: ['null'] }); + mockFetchResponse({ ok: true, json: async () => BUNDLE }); + + for (const expected of ['first', 'second']) { + const result = postMessageTransport(QUERY_NAME, ARGS); + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => { + setImmediate(resolve); + }); + const [message] = postMessage.mock.calls.at(-1); + reply(message.requestId, expected); + // eslint-disable-next-line no-await-in-loop + await expect(result).resolves.toBe(expected); + expect(message).not.toHaveProperty('bundle'); + expect(postMessage).toHaveBeenCalledWith(message, '*'); + } + + expect(global.fetch).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith(expect.stringContaining(OPAQUE_PARENT_ORIGIN_REASON)); + }); + + // Firefox has no ancestorOrigins, so the parent's origin comes from its answer to a ping. + describe('without ancestorOrigins', () => { + // Settles pending promise callbacks without advancing timers, so it also works under fake timers. + const flushPing = async (turns = 10): Promise => { + if (turns === 0) { + return; + } + await Promise.resolve(); + await flushPing(turns - 1); + }; + + test('Should attach the bundle for a Datadog parent that answers its ping', async () => { + const { postMessage, posted, reply, pings, pong } = installFakeWindow({}); + mockFetchResponse({ ok: true, json: async () => BUNDLE }); + + const result = postMessageTransport(QUERY_NAME, ARGS); + await flushPing(); + const [ping, ...otherPings] = pings(); + expect(otherPings).toHaveLength(0); + expect(postMessage).toHaveBeenCalledWith(ping, '*'); + expect(global.fetch).toHaveBeenCalledTimes(1); + pong(DATADOG_PARENT); + const message = await posted; + reply(message.requestId, 'ok'); + + await expect(result).resolves.toBe('ok'); + expect(message).toEqual({ ...MESSAGE_WITHOUT_BUNDLE, bundle: BUNDLE }); + expect(postMessage).toHaveBeenCalledWith(message, DATADOG_PARENT); + }); + + test('Should ignore a response from another origin than the one its parent answered from', async () => { + const { posted, reply, pong, parent } = installFakeWindow({}); + mockFetchResponse({ ok: true, json: async () => BUNDLE }); + + const result = postMessageTransport(QUERY_NAME, ARGS); + await flushPing(); + pong(DATADOG_PARENT); + const message = await posted; + reply(message.requestId, 'forged', parent, 'https://example.com'); + const stillPending = new Promise((resolve) => { + setImmediate(resolve, 'pending'); + }); + const earlyOutcome = await Promise.race([result, stillPending]); + expect(earlyOutcome).toBe('pending'); + reply(message.requestId, 'ok'); + + await expect(result).resolves.toBe('ok'); + }); + + test('Should ping the parent when ancestorOrigins is null', async () => { + const { posted, reply, pings, pong } = installFakeWindow({ ancestorOrigins: null }); + mockFetchResponse({ ok: true, json: async () => BUNDLE }); + + const result = postMessageTransport(QUERY_NAME, ARGS); + await flushPing(); + pong(DATADOG_PARENT); + const message = await posted; + reply(message.requestId, 'ok'); + + await expect(result).resolves.toBe('ok'); + const sentPings = pings(); + expect(sentPings).toHaveLength(1); + expect(message).toEqual({ ...MESSAGE_WITHOUT_BUNDLE, bundle: BUNDLE }); + }); + + test('Should send the call without a bundle when a non-Datadog parent answers', async () => { + const { postMessage, posted, reply, pong } = installFakeWindow( + {}, + 'https://example.com', + ); + mockFetchResponse({ ok: true, json: async () => BUNDLE }); + + const result = postMessageTransport(QUERY_NAME, ARGS); + await flushPing(); + pong(); + const message = await posted; + reply(message.requestId, 'ok'); + + await expect(result).resolves.toBe('ok'); + expect(message).toEqual(MESSAGE_WITHOUT_BUNDLE); + expect(postMessage).toHaveBeenCalledWith(message, '*'); + }); + + test('Should not warn about the bundle fetch when the parent is not a Datadog page', async () => { + const { posted, reply, pong } = installFakeWindow({}, 'https://example.com'); + const networkError = new Error('Failed to fetch'); + global.fetch = jest.fn().mockRejectedValue(networkError); + + const result = postMessageTransport(QUERY_NAME, ARGS); + await flushPing(); + pong(); + const message = await posted; + reply(message.requestId, 'ok'); + + await expect(result).resolves.toBe('ok'); + expect(message).toEqual(MESSAGE_WITHOUT_BUNDLE); + expect(warn).not.toHaveBeenCalled(); + }); + + test('Should ignore a pong from any window but the parent', async () => { + jest.useFakeTimers(); + const { postMessage, posted, reply, pong } = installFakeWindow({}); + mockFetchResponse({ ok: true, json: async () => BUNDLE }); + + const result = postMessageTransport(QUERY_NAME, ARGS); + await flushPing(); + pong(DATADOG_PARENT, {}); + await jest.advanceTimersByTimeAsync(PARENT_PING_TIMEOUT_MS); + const message = await posted; + reply(message.requestId, 'ok'); + + await expect(result).resolves.toBe('ok'); + expect(message).toEqual(MESSAGE_WITHOUT_BUNDLE); + expect(postMessage).toHaveBeenCalledWith(message, '*'); + }); + + const unsupportedParentMessages = [ + { description: 'null', data: null }, + { description: 'a number', data: 42 }, + { description: 'a bare message type string', data: PONG }, + { description: 'an object without a type', data: {} }, + { description: 'an object of another type', data: { type: 'something-else' } }, + ]; + + test.each(unsupportedParentMessages)( + 'Should keep waiting for a pong when the parent sends $description', + async ({ data }) => { + jest.useFakeTimers(); + const { postMessage, posted, reply, listeners, parent } = installFakeWindow({}); + mockFetchResponse({ ok: true, json: async () => BUNDLE }); + + const result = postMessageTransport(QUERY_NAME, ARGS); + await flushPing(); + for (const listener of listeners()) { + listener({ data, origin: DATADOG_PARENT, source: parent }); + } + await jest.advanceTimersByTimeAsync(PARENT_PING_TIMEOUT_MS); + const message = await posted; + reply(message.requestId, 'ok'); + + await expect(result).resolves.toBe('ok'); + expect(message).toEqual(MESSAGE_WITHOUT_BUNDLE); + expect(postMessage).toHaveBeenCalledWith(message, '*'); + }, + ); + + test('Should send the call without a bundle when the parent never answers', async () => { + jest.useFakeTimers(); + const { postMessage, posted, reply, listenerCount } = installFakeWindow({}); + mockFetchResponse({ ok: true, json: async () => BUNDLE }); + + const result = postMessageTransport(QUERY_NAME, ARGS); + await flushPing(); + await jest.advanceTimersByTimeAsync(PARENT_PING_TIMEOUT_MS); + const message = await posted; + const listenersWhileWaiting = listenerCount(); + reply(message.requestId, 'ok'); + + await expect(result).resolves.toBe('ok'); + expect(message).toEqual(MESSAGE_WITHOUT_BUNDLE); + expect(postMessage).toHaveBeenCalledWith(message, '*'); + // The call's own listener, plus the one that still accepts a late answer from the parent. + expect(listenersWhileWaiting).toBe(2); + const remainingListeners = listenerCount(); + expect(remainingListeners).toBe(0); + }); + + test('Should ping the parent once per page', async () => { + const { postMessage, posted, reply, pings, pong } = installFakeWindow({}); + mockFetchResponse({ ok: true, json: async () => BUNDLE }); + + const first = postMessageTransport(QUERY_NAME, ARGS); + await flushPing(); + pong(DATADOG_PARENT); + const firstMessage = await posted; + reply(firstMessage.requestId, 'first'); + await expect(first).resolves.toBe('first'); + + const second = postMessageTransport(QUERY_NAME, ARGS); + await new Promise((resolve) => setImmediate(resolve)); + const [secondMessage] = postMessage.mock.calls.at(-1); + reply(secondMessage.requestId, 'second'); + + await expect(second).resolves.toBe('second'); + const sentPings = pings(); + expect(sentPings).toHaveLength(1); + expect(secondMessage).toEqual({ ...MESSAGE_WITHOUT_BUNDLE, bundle: BUNDLE }); + }); + }); + + describe('without ancestorOrigins, across calls', () => { + test('Should share one ping between calls made before the parent answers', async () => { + const { postMessage, reply, pings, pong } = installFakeWindow({}); + mockFetchResponse({ ok: true, json: async () => BUNDLE }); + + const first = postMessageTransport(QUERY_NAME, ARGS); + const second = postMessageTransport(QUERY_NAME, ARGS); + await Promise.resolve(); + const sentPings = pings(); + expect(sentPings).toHaveLength(1); + pong(DATADOG_PARENT); + await new Promise((resolve) => setImmediate(resolve)); + + const queries = postMessage.mock.calls + .map(([message]: [PostedMessage]) => message) + .filter((message) => message.type === RUN_QUERY); + expect(queries).toHaveLength(2); + for (const query of queries) { + expect(query).toEqual({ ...MESSAGE_WITHOUT_BUNDLE, bundle: BUNDLE }); + reply(query.requestId, 'ok'); + } + await expect(first).resolves.toBe('ok'); + await expect(second).resolves.toBe('ok'); + }); + + test('Should learn the parent origin from its response when the ping goes unanswered', async () => { + jest.useFakeTimers(); + const { postMessage, reply, pings } = installFakeWindow({}); + mockFetchResponse({ ok: true, json: async () => BUNDLE }); + + const first = postMessageTransport(QUERY_NAME, ARGS); + await jest.advanceTimersByTimeAsync(PARENT_PING_TIMEOUT_MS); + const [firstQuery] = postMessage.mock.calls.at(-1); + expect(firstQuery).toEqual(MESSAGE_WITHOUT_BUNDLE); + reply(firstQuery.requestId, 'first'); + await expect(first).resolves.toBe('first'); + + const second = postMessageTransport(QUERY_NAME, ARGS); + await jest.advanceTimersByTimeAsync(0); + const [secondQuery] = postMessage.mock.calls.at(-1); + expect(secondQuery).toEqual({ ...MESSAGE_WITHOUT_BUNDLE, bundle: BUNDLE }); + reply(secondQuery.requestId, 'second'); + + await expect(second).resolves.toBe('second'); + expect(postMessage).toHaveBeenCalledWith(secondQuery, DATADOG_PARENT); + const sentPings = pings(); + expect(sentPings).toHaveLength(1); + }); + + const timedOut = { message: expect.stringContaining('timed out') }; + const settle = (call: Promise) => call.catch((error: unknown) => error); + + const callUnanswered = async () => { + const result = postMessageTransport(QUERY_NAME, ARGS); + const outcome = settle(result); + await jest.advanceTimersByTimeAsync(PARENT_PING_TIMEOUT_MS + POSTMESSAGE_TIMEOUT_MS); + const error = await outcome; + expect(error).toMatchObject(timedOut); + }; + + const exhaustPings = async (remaining = MAX_UNANSWERED_PINGS): Promise => { + if (remaining === 0) { + return; + } + await callUnanswered(); + await exhaustPings(remaining - 1); + }; + + test('Should ping again on a later call after the parent answered nothing', async () => { + jest.useFakeTimers(); + const { pings } = installFakeWindow({}); + mockFetchResponse({ ok: true, json: async () => BUNDLE }); + + await callUnanswered(); + await callUnanswered(); + + const sentPings = pings(); + expect(sentPings).toHaveLength(2); + }); + + test('Should stop pinging and send at once after its last unanswered ping', async () => { + jest.useFakeTimers(); + const { postMessage, reply, pings } = installFakeWindow({}); + mockFetchResponse({ ok: true, json: async () => BUNDLE }); + await exhaustPings(); + + const third = postMessageTransport(QUERY_NAME, ARGS); + await jest.advanceTimersByTimeAsync(0); + const [thirdQuery] = postMessage.mock.calls.at(-1); + expect(thirdQuery).toEqual(MESSAGE_WITHOUT_BUNDLE); + reply(thirdQuery.requestId, 'third'); + + await expect(third).resolves.toBe('third'); + const sentPings = pings(); + expect(sentPings).toHaveLength(MAX_UNANSWERED_PINGS); + expect(postMessage).toHaveBeenCalledWith(thirdQuery, '*'); + expect(global.fetch).toHaveBeenCalledTimes(sentPings.length); + }); + + test('Should warn once per page when the parent leaves its pings unanswered', async () => { + jest.useFakeTimers(); + const { postMessage, reply } = installFakeWindow({}); + mockFetchResponse({ ok: true, json: async () => BUNDLE }); + await exhaustPings(); + + const later = postMessageTransport(QUERY_NAME, ARGS); + await jest.advanceTimersByTimeAsync(0); + const [laterQuery] = postMessage.mock.calls.at(-1); + reply(laterQuery.requestId, 'later'); + await expect(later).resolves.toBe('later'); + + const namesTheFunction = expect.stringContaining(`"${QUERY_NAME}"`); + const namesTheCause = expect.stringContaining( + 'no answer came back from the parent page', + ); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith(namesTheFunction); + expect(warn).toHaveBeenCalledWith(namesTheCause); + }); + + test('Should end a pending ping when reset', async () => { + jest.useFakeTimers(); + const { postMessage, reply } = installFakeWindow({}); + mockFetchResponse({ ok: true, json: async () => BUNDLE }); + + const result = postMessageTransport(QUERY_NAME, ARGS); + await jest.advanceTimersByTimeAsync(0); + resetDevClient(); + await jest.advanceTimersByTimeAsync(0); + const [query] = postMessage.mock.calls.at(-1); + expect(query).toEqual(MESSAGE_WITHOUT_BUNDLE); + reply(query.requestId, 'ok'); + + await expect(result).resolves.toBe('ok'); + const remainingTimers = jest.getTimerCount(); + expect(remainingTimers).toBe(0); + }); + + test('Should stop listening for a pong once reset', async () => { + jest.useFakeTimers(); + const { postMessage, reply, pings, pong } = installFakeWindow({}); + mockFetchResponse({ ok: true, json: async () => BUNDLE }); + + const first = postMessageTransport(QUERY_NAME, ARGS); + await jest.advanceTimersByTimeAsync(0); + resetDevClient(); + pong(DATADOG_PARENT); + const second = postMessageTransport(QUERY_NAME, ARGS); + await jest.advanceTimersByTimeAsync(PARENT_PING_TIMEOUT_MS); + + const sentPings = pings(); + expect(sentPings).toHaveLength(2); + const queries = postMessage.mock.calls + .map(([message]: [PostedMessage]) => message) + .filter((message) => message.type === RUN_QUERY); + for (const query of queries) { + reply(query.requestId, 'ok'); + } + await expect(first).resolves.toBe('ok'); + await expect(second).resolves.toBe('ok'); + }); + + const lateAnswers = [ + { + description: 'attach the bundle once a Datadog parent answers after giving up', + origin: DATADOG_PARENT, + expected: { + message: { ...MESSAGE_WITHOUT_BUNDLE, bundle: BUNDLE }, + target: DATADOG_PARENT, + }, + }, + { + description: + 'still send without a bundle when a non-Datadog parent answers after giving up', + origin: 'https://example.com', + expected: { message: MESSAGE_WITHOUT_BUNDLE, target: '*' }, + }, + ]; + + test.each(lateAnswers)('Should $description', async ({ origin, expected }) => { + jest.useFakeTimers(); + const { postMessage, reply, pings, pong, listenerCount } = installFakeWindow( + {}, + origin, + ); + mockFetchResponse({ ok: true, json: async () => BUNDLE }); + await exhaustPings(); + pong(); + + const later = postMessageTransport(QUERY_NAME, ARGS); + await jest.advanceTimersByTimeAsync(0); + const [laterQuery] = postMessage.mock.calls.at(-1); + expect(laterQuery).toEqual(expected.message); + reply(laterQuery.requestId, 'later'); + + await expect(later).resolves.toBe('later'); + expect(postMessage).toHaveBeenCalledWith(laterQuery, expected.target); + const sentPings = pings(); + expect(sentPings).toHaveLength(MAX_UNANSWERED_PINGS); + const remainingListeners = listenerCount(); + expect(remainingListeners).toBe(0); + }); + + test('Should use a late answer to an earlier ping', async () => { + jest.useFakeTimers(); + const { postMessage, reply, pings, pong } = installFakeWindow({}); + mockFetchResponse({ ok: true, json: async () => BUNDLE }); + + const first = postMessageTransport(QUERY_NAME, ARGS); + const firstOutcome = settle(first); + await jest.advanceTimersByTimeAsync(PARENT_PING_TIMEOUT_MS); + pong(DATADOG_PARENT); + await jest.advanceTimersByTimeAsync(POSTMESSAGE_TIMEOUT_MS); + const firstError = await firstOutcome; + expect(firstError).toMatchObject(timedOut); + + const second = postMessageTransport(QUERY_NAME, ARGS); + await jest.advanceTimersByTimeAsync(0); + const [secondQuery] = postMessage.mock.calls.at(-1); + expect(secondQuery).toEqual({ ...MESSAGE_WITHOUT_BUNDLE, bundle: BUNDLE }); + reply(secondQuery.requestId, 'second'); + + await expect(second).resolves.toBe('second'); + const sentPings = pings(); + expect(sentPings).toHaveLength(1); + }); + + test('Should count a ping that cannot be posted as unanswered', async () => { + jest.useFakeTimers(); + const { postMessage, pings } = installFakeWindow({}); + postMessage.mockImplementation((message: PostedMessage) => { + if (message.type === PING) { + throw new Error('could not be posted'); + } + }); + mockFetchResponse({ ok: true, json: async () => BUNDLE }); + + const first = postMessageTransport(QUERY_NAME, ARGS); + await jest.advanceTimersByTimeAsync(0); + const [firstQuery] = postMessage.mock.calls.at(-1); + expect(firstQuery).toEqual(MESSAGE_WITHOUT_BUNDLE); + const firstOutcome = settle(first); + await jest.advanceTimersByTimeAsync(POSTMESSAGE_TIMEOUT_MS); + const firstError = await firstOutcome; + expect(firstError).toMatchObject(timedOut); + await exhaustPings(); + + const sentPings = pings(); + expect(sentPings).toHaveLength(MAX_UNANSWERED_PINGS); + }); + }); + + test('Should not ping a parent that ancestorOrigins already names', async () => { + const { postMessage, posted, reply, pings } = installFakeWindow(); + mockFetchResponse({ ok: true, json: async () => BUNDLE }); + + const result = postMessageTransport(QUERY_NAME, ARGS); + const message = await posted; + reply(message.requestId, 'ok'); + + await expect(result).resolves.toBe('ok'); + const sentPings = pings(); + expect(sentPings).toHaveLength(0); + expect(message).toHaveProperty('bundle', BUNDLE); + expect(postMessage).toHaveBeenCalledWith(message, DATADOG_PARENT); + }); + + test('Should neither ping nor fetch a bundle when ancestorOrigins is empty', async () => { + const { posted, reply, pings } = installFakeWindow({ ancestorOrigins: [] }); + mockFetchResponse({ ok: true, json: async () => BUNDLE }); + + const result = postMessageTransport(QUERY_NAME, ARGS); + const message = await posted; + reply(message.requestId, 'ok'); + + await expect(result).resolves.toBe('ok'); + const sentPings = pings(); + expect(sentPings).toHaveLength(0); + expect(global.fetch).not.toHaveBeenCalled(); + expect(warn).not.toHaveBeenCalled(); + }); + + test('Should request the bundle as JSON', async () => { + const { posted, reply } = installFakeWindow(); + mockFetchResponse({ ok: true, json: async () => BUNDLE }); + + const result = postMessageTransport(QUERY_NAME, ARGS); + const message = await posted; + reply(message.requestId, 'ok'); + await result; + + const expectedBody = JSON.stringify({ functionName: QUERY_NAME }); + const anySignal = expect.any(AbortSignal); + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(global.fetch).toHaveBeenCalledWith(DEBUG_BUNDLE_PATH, { + method: 'POST', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: expectedBody, + signal: anySignal, + }); + }); + + test('Should forward only the bundle fields the parent reads', async () => { + const { posted, reply } = installFakeWindow(); + mockFetchResponse({ ok: true, json: async () => ({ ...BUNDLE, extra: 'ignored' }) }); + + const result = postMessageTransport(QUERY_NAME, ARGS); + const message = await posted; + reply(message.requestId, 'ok'); + await result; + + expect(message).toHaveProperty('bundle', BUNDLE); + }); + + const failureCases = [ + { + description: 'the fetch rejects', + setup: () => { + const networkError = new Error('Failed to fetch'); + global.fetch = jest.fn().mockRejectedValue(networkError); + }, + }, + { + description: 'the dev server responds with an error status', + setup: () => + mockFetchResponse({ + ok: false, + status: 500, + json: async () => ({ error: 'boom' }), + }), + }, + { + description: 'the dev server responds with an error status and no JSON body', + setup: () => + mockFetchResponse({ + ok: false, + status: 502, + json: async () => { + throw new SyntaxError('Unexpected token'); + }, + }), + }, + { + description: 'the response is not JSON', + setup: () => + mockFetchResponse({ + ok: true, + json: async () => { + throw new SyntaxError('Unexpected token'); + }, + }), + }, + { + description: 'the response lacks connection IDs', + setup: () => mockFetchResponse({ ok: true, json: async () => ({ code: '// code' }) }), + }, + { + description: 'the bundled code is empty', + setup: () => + mockFetchResponse({ + ok: true, + json: async () => ({ code: '', allowedConnectionIds: [] }), + }), + }, + { + description: 'the connection IDs are not all strings', + setup: () => + mockFetchResponse({ + ok: true, + json: async () => ({ code: '// code', allowedConnectionIds: [1] }), + }), + }, + ]; + + test.each(failureCases)( + 'Should still send the call without a bundle when $description', + async ({ setup }) => { + const { postMessage, posted, reply } = installFakeWindow(); + setup(); + + const result = postMessageTransport(QUERY_NAME, ARGS); + const message = await posted; + reply(message.requestId, 'ok'); + + await expect(result).resolves.toBe('ok'); + expect(message).toEqual(MESSAGE_WITHOUT_BUNDLE); + expect(postMessage).toHaveBeenCalledWith(message, DATADOG_PARENT); + expect(warn).toHaveBeenCalledTimes(1); + const namesTheFunction = expect.stringContaining(QUERY_NAME); + expect(warn).toHaveBeenCalledWith(namesTheFunction); + }, + ); + + test("Should warn with the dev server's error when it rejects the bundle request", async () => { + const { postMessage, posted, reply } = installFakeWindow(); + const serverError = 'Backend function "greet" imports restricted module "fs"'; + mockFetchResponse({ + ok: false, + status: 400, + json: async () => ({ success: false, error: serverError }), + }); + + const result = postMessageTransport(QUERY_NAME, ARGS); + const message = await posted; + reply(message.requestId, 'ok'); + + await expect(result).resolves.toBe('ok'); + expect(message).toEqual(MESSAGE_WITHOUT_BUNDLE); + expect(postMessage).toHaveBeenCalledWith(message, DATADOG_PARENT); + expect(warn).toHaveBeenCalledTimes(1); + const namesTheStatus = expect.stringContaining('400'); + const namesTheServerError = expect.stringContaining(serverError); + expect(warn).toHaveBeenCalledWith(namesTheStatus); + expect(warn).toHaveBeenCalledWith(namesTheServerError); + }); + + test('Should warn with the network error when the bundle request fails', async () => { + const { postMessage, posted, reply } = installFakeWindow(); + const networkError = new TypeError('Failed to fetch'); + global.fetch = jest.fn().mockRejectedValue(networkError); + + const result = postMessageTransport(QUERY_NAME, ARGS); + const message = await posted; + reply(message.requestId, 'ok'); + + await expect(result).resolves.toBe('ok'); + expect(message).toEqual(MESSAGE_WITHOUT_BUNDLE); + expect(postMessage).toHaveBeenCalledWith(message, DATADOG_PARENT); + expect(warn).toHaveBeenCalledTimes(1); + const namesTheNetworkError = expect.stringContaining('Failed to fetch'); + expect(warn).toHaveBeenCalledWith(namesTheNetworkError); + }); + + test('Should abort a stalled bundle fetch and send the call without a bundle', async () => { + jest.useFakeTimers(); + const { posted, reply } = installFakeWindow(); + let fetchSignal: AbortSignal | undefined; + global.fetch = jest.fn().mockImplementation( + (_url: string, init: { signal: AbortSignal }) => + new Promise((_resolve, reject) => { + fetchSignal = init.signal; + init.signal.addEventListener('abort', () => reject(init.signal.reason)); + }), + ); + + const result = postMessageTransport(QUERY_NAME, ARGS); + await jest.advanceTimersByTimeAsync(BUNDLE_FETCH_TIMEOUT_MS); + const message = await posted; + reply(message.requestId, 'ok'); + + await expect(result).resolves.toBe('ok'); + expect(fetchSignal?.aborted).toBe(true); + expect(message).not.toHaveProperty('bundle'); + expect(warn).toHaveBeenCalledTimes(1); + const timeoutWarning = expect.stringContaining( + `timed out after ${BUNDLE_FETCH_TIMEOUT_MS}ms`, + ); + expect(warn).toHaveBeenCalledWith(timeoutWarning); + }); + + test('Should give the parent its full response window after the bundle arrives', async () => { + jest.useFakeTimers(); + const { posted, reply } = installFakeWindow(); + const bundleDelayMs = BUNDLE_FETCH_TIMEOUT_MS - 1; + global.fetch = jest.fn().mockImplementation( + () => + new Promise((resolve) => { + setTimeout( + () => resolve({ ok: true, json: async () => BUNDLE }), + bundleDelayMs, + ); + }), + ); + + const result = postMessageTransport(QUERY_NAME, ARGS); + await jest.advanceTimersByTimeAsync(bundleDelayMs); + const message = await posted; + await jest.advanceTimersByTimeAsync(POSTMESSAGE_TIMEOUT_MS - 1); + reply(message.requestId, 'ok'); + + await expect(result).resolves.toBe('ok'); + }); +}); diff --git a/packages/plugins/apps/src/backend/client/dev-client/dev-client.ts b/packages/plugins/apps/src/backend/client/dev-client/dev-client.ts new file mode 100644 index 000000000..ec0ba1bce --- /dev/null +++ b/packages/plugins/apps/src/backend/client/dev-client/dev-client.ts @@ -0,0 +1,221 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +/* eslint-env browser */ + +import { parseSite } from '@dd/core/helpers/site'; + +import { DEBUG_BUNDLE_PATH, PING, PONG, RUN_QUERY_RESPONSE } from '../../protocol'; +import type { + DebugBundleResponse, + ExecuteActionRequest, + IframeQueryPing, + PrepareCall, +} from '../../protocol'; +import { isDebugBundle, isRecord } from '../guards'; +import { generateRequestId } from '../request-id'; + +export const BUNDLE_FETCH_TIMEOUT_MS = 60_000; +export const PARENT_PING_TIMEOUT_MS = 2_000; + +// The bridge requires a ping's requestId, but any answer from the parent will do: the frame only needs its origin. +function isParentAnswer(data: unknown): boolean { + return ( + isRecord(data) && 'type' in data && (data.type === PONG || data.type === RUN_QUERY_RESPONSE) + ); +} + +function isDatadogOrigin(origin: string): boolean { + let url: URL; + try { + url = new URL(origin); + } catch { + return false; + } + // A fully qualified hostname keeps its trailing root dot, which parseSite doesn't expect. + const hostname = url.hostname.endsWith('.') ? url.hostname.slice(0, -1) : url.hostname; + return url.protocol === 'https:' && url.port === '' && parseSite(hostname) !== undefined; +} + +export const MAX_UNANSWERED_PINGS = 2; +export const OPAQUE_PARENT_ORIGIN_REASON = + 'the parent page has an opaque origin ("null"), so it can\'t be confirmed as a Datadog page'; + +let learnedParentOrigin: string | undefined; +let unansweredPings = 0; +let pendingPing: { answered: Promise; end: () => void } | undefined; +let warnedUnansweredPing = false; +let warnedOpaqueParentOrigin = false; + +/** @internal Exposed only for tests to reset what the frame learned about its parent between cases. */ +export const resetDevClient = (): void => { + pendingPing?.end(); + if (typeof window !== 'undefined') { + window.removeEventListener('message', handleParentMessage); + } + learnedParentOrigin = undefined; + unansweredPings = 0; + warnedUnansweredPing = false; + warnedOpaqueParentOrigin = false; +}; + +// The browser stamps the sender's origin on each message, so a pong or response from the parent reveals it. +function handleParentMessage(event: MessageEvent): void { + if (event.source !== window.parent || !isParentAnswer(event.data)) { + return; + } + learnedParentOrigin = event.origin; + window.removeEventListener('message', handleParentMessage); + pendingPing?.end(); +} + +/** Shares one ping between calls; after a timeout, its listener stays for a late answer. */ +function pingParent(): Promise { + if (pendingPing !== undefined) { + return pendingPing.answered; + } + window.addEventListener('message', handleParentMessage); + let resolveAnswered = (): void => {}; + const answered = new Promise((resolve) => { + resolveAnswered = resolve; + }); + const timeoutId = setTimeout(miss, PARENT_PING_TIMEOUT_MS); + + function end(): void { + clearTimeout(timeoutId); + pendingPing = undefined; + resolveAnswered(); + } + + function miss(): void { + unansweredPings += 1; + end(); + } + + pendingPing = { answered, end }; + const ping: IframeQueryPing = { type: PING, requestId: generateRequestId() }; + try { + window.parent.postMessage(ping, '*'); + } catch { + miss(); + } + return answered; +} + +// Firefox has no ancestorOrigins; App Builder's bridge answers a ping from its frame. +// After MAX_UNANSWERED_PINGS misses, calls stop pinging; a later pong or response still sets the origin. +function needsParentPing(): boolean { + return ( + !window.location.ancestorOrigins && + window.parent !== window && + learnedParentOrigin === undefined && + unansweredPings < MAX_UNANSWERED_PINGS + ); +} + +async function getParentOrigin(shouldPing: boolean): Promise { + const ancestorOrigins = window.location.ancestorOrigins; + if (ancestorOrigins) { + return ancestorOrigins[0]; + } + if (shouldPing) { + await pingParent(); + } + return learnedParentOrigin; +} + +async function describeErrorResponse(response: Response): Promise { + const status = `HTTP ${response.status}`; + try { + const body: unknown = await response.json(); + if (isRecord(body) && 'error' in body && typeof body.error === 'string') { + return `${status}: ${body.error}`; + } + return status; + } catch { + return status; + } +} + +function warnMissingBundle(functionName: string, reason: string): void { + // eslint-disable-next-line no-console + console.warn(`[dd-apps] Sending "${functionName}" without its dev server bundle: ${reason}`); +} + +function warnUnansweredPingOnce(functionName: string): void { + if (warnedUnansweredPing) { + return; + } + warnedUnansweredPing = true; + warnMissingBundle( + functionName, + "no answer came back from the parent page, so it can't be confirmed as a Datadog page", + ); +} + +function warnOpaqueParentOriginOnce(functionName: string): void { + if (warnedOpaqueParentOrigin) { + return; + } + warnedOpaqueParentOrigin = true; + warnMissingBundle(functionName, OPAQUE_PARENT_ORIGIN_REASON); +} + +type BundleFetch = { bundle: DebugBundleResponse } | { failure: string }; + +/** Never rejects: a call without a usable bundle is still sent, so a failure is returned for the caller to report. */ +async function fetchOwnBundle(functionName: string): Promise { + const request: Pick = { functionName }; + const requestBody = JSON.stringify(request); + const controller = new AbortController(); + const timeoutId = setTimeout(() => { + const timeoutError = new Error(`timed out after ${BUNDLE_FETCH_TIMEOUT_MS}ms`); + controller.abort(timeoutError); + }, BUNDLE_FETCH_TIMEOUT_MS); + try { + const response = await fetch(DEBUG_BUNDLE_PATH, { + method: 'POST', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: requestBody, + signal: controller.signal, + }); + if (!response.ok) { + const errorDescription = await describeErrorResponse(response); + return { failure: errorDescription }; + } + const body: unknown = await response.json(); + if (!isDebugBundle(body)) { + return { failure: 'the dev server response is not a bundle' }; + } + return { bundle: { code: body.code, allowedConnectionIds: body.allowedConnectionIds } }; + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { failure: errorMessage }; + } finally { + clearTimeout(timeoutId); + } +} + +/** The function's bundle and the exact origin to post it to, when this dev page is framed by a Datadog page. */ +export const prepareCall: PrepareCall = async (functionName) => { + const isPinging = needsParentPing(); + // The build runs while the parent answers its ping, and is dropped unless a Datadog parent answers. + const earlyFetch = isPinging ? fetchOwnBundle(functionName) : undefined; + const recipientOrigin = await getParentOrigin(isPinging); + if (recipientOrigin === undefined && isPinging) { + warnUnansweredPingOnce(functionName); + } + if (recipientOrigin === 'null') { + warnOpaqueParentOriginOnce(functionName); + } + if (recipientOrigin === undefined || !isDatadogOrigin(recipientOrigin)) { + return {}; + } + const result = await (earlyFetch ?? fetchOwnBundle(functionName)); + if ('failure' in result) { + warnMissingBundle(functionName, result.failure); + return { targetOrigin: recipientOrigin }; + } + return { targetOrigin: recipientOrigin, bundle: result.bundle }; +}; diff --git a/packages/plugins/apps/src/backend/client/execute-backend-function.test.ts b/packages/plugins/apps/src/backend/client/execute-backend-function.test.ts index be1504dde..88309ca6f 100644 --- a/packages/plugins/apps/src/backend/client/execute-backend-function.test.ts +++ b/packages/plugins/apps/src/backend/client/execute-backend-function.test.ts @@ -2,12 +2,8 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -// Only the fetch (dev-server) transport is exercised here. The iframe -// postMessage transport requires a DOM (`window`, `MessageEvent`) that this -// repo's node-only jest harness doesn't provide — adding jsdom collides with -// the shared `setupAfterEnv.ts` (nock → TextEncoder). postMessage coverage -// lives with the original tests in web-ui's @datadog/apps-function-query -// until a DOM-enabled harness is introduced. +// Only the fetch (dev-server) transport is exercised here; the iframe +// postMessage transport is covered by post-message-transport.test.ts. import { executeBackendFunction } from './execute-backend-function'; import { BackendFunctionError } from './types'; diff --git a/packages/plugins/apps/src/backend/client/guards.ts b/packages/plugins/apps/src/backend/client/guards.ts new file mode 100644 index 000000000..1748e2db8 --- /dev/null +++ b/packages/plugins/apps/src/backend/client/guards.ts @@ -0,0 +1,30 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import type { DebugBundleResponse } from '../protocol'; + +export function isRecord(data: unknown): data is object { + return data !== null && typeof data === 'object'; +} + +// Reads by index into a new array, so none of the source array's own methods (or its `Symbol.species`) run. +export function copyByIndex(list: unknown[]): unknown[] { + const copy: unknown[] = []; + for (let index = 0; index < list.length; index++) { + copy.push(list[index]); + } + return copy; +} + +export function isDebugBundle(data: unknown): data is DebugBundleResponse { + return ( + isRecord(data) && + 'code' in data && + typeof data.code === 'string' && + data.code.length > 0 && + 'allowedConnectionIds' in data && + Array.isArray(data.allowedConnectionIds) && + data.allowedConnectionIds.every((id: unknown) => typeof id === 'string') + ); +} diff --git a/packages/plugins/apps/src/backend/client/request-id.ts b/packages/plugins/apps/src/backend/client/request-id.ts new file mode 100644 index 000000000..66f009265 --- /dev/null +++ b/packages/plugins/apps/src/backend/client/request-id.ts @@ -0,0 +1,15 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +let requestCounter = 0; + +export function generateRequestId(): string { + if (typeof crypto !== 'undefined' && crypto.randomUUID) { + return crypto.randomUUID(); + } + requestCounter += 1; + // The runtime and the dev client each bundle this counter, so a random part keeps their ids apart. + const randomPart = Math.random().toString(36).slice(2); + return `req-${Date.now()}-${requestCounter}-${randomPart}`; +} diff --git a/packages/plugins/apps/src/backend/client/transports/post-message-transport/post-message-transport.test.ts b/packages/plugins/apps/src/backend/client/transports/post-message-transport/post-message-transport.test.ts new file mode 100644 index 000000000..39d766f2d --- /dev/null +++ b/packages/plugins/apps/src/backend/client/transports/post-message-transport/post-message-transport.test.ts @@ -0,0 +1,437 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +/* global globalThis */ + +import { + ARGS, + BUNDLE, + DATADOG_PARENT, + MESSAGE_WITHOUT_BUNDLE, + QUERY_NAME, + installFakeWindow, + mockFetchResponse, +} from '@dd/tests/_jest/helpers/fakeWindow'; + +import { DEV_CLIENT_HOOK, RUN_QUERY_RESPONSE } from '../../../protocol'; +import { BackendFunctionError } from '../../types'; + +import { POSTMESSAGE_TIMEOUT_MS, postMessageTransport } from './post-message-transport'; + +describe('postMessageTransport', () => { + let originalFetch: typeof fetch; + + beforeEach(() => { + originalFetch = global.fetch; + mockFetchResponse({ ok: true, json: async () => BUNDLE }); + }); + + afterEach(() => { + global.fetch = originalFetch; + Reflect.deleteProperty(globalThis, 'window'); + Reflect.deleteProperty(globalThis, DEV_CLIENT_HOOK); + jest.useRealTimers(); + }); + + test('Should post the call at once, with no ping or bundle fetch, when no dev client is installed', async () => { + const { postMessage, reply, pings } = installFakeWindow({}); + + const result = postMessageTransport(QUERY_NAME, ARGS); + const [message] = postMessage.mock.calls.at(-1); + reply(message.requestId, 'ok'); + + await expect(result).resolves.toBe('ok'); + expect(message).toEqual(MESSAGE_WITHOUT_BUNDLE); + expect(postMessage).toHaveBeenCalledWith(message, '*'); + const sentPings = pings(); + expect(sentPings).toHaveLength(0); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + test("Should post the dev client's bundle to the origin it names", async () => { + const { postMessage, posted, reply } = installFakeWindow(); + const prepareCall = jest + .fn() + .mockResolvedValue({ targetOrigin: DATADOG_PARENT, bundle: BUNDLE }); + Reflect.set(globalThis, DEV_CLIENT_HOOK, prepareCall); + + const result = postMessageTransport(QUERY_NAME, ARGS); + const message = await posted; + reply(message.requestId, 'ok'); + + await expect(result).resolves.toBe('ok'); + expect(prepareCall).toHaveBeenCalledWith(QUERY_NAME); + expect(message).toEqual({ ...MESSAGE_WITHOUT_BUNDLE, bundle: BUNDLE }); + expect(postMessage).toHaveBeenCalledWith(message, DATADOG_PARENT); + }); + + const inexactTargets = [ + { description: 'no target origin', preparedCall: { bundle: BUNDLE } }, + { + description: "the '*' target origin", + preparedCall: { targetOrigin: '*', bundle: BUNDLE }, + }, + ]; + + test.each(inexactTargets)( + 'Should not post a bundle with $description', + async ({ preparedCall }) => { + const { postMessage, posted, reply } = installFakeWindow(); + const prepareCall = jest.fn().mockResolvedValue(preparedCall); + Reflect.set(globalThis, DEV_CLIENT_HOOK, prepareCall); + + const result = postMessageTransport(QUERY_NAME, ARGS); + const message = await posted; + reply(message.requestId, 'ok'); + + await expect(result).resolves.toBe('ok'); + expect(message).toEqual(MESSAGE_WITHOUT_BUNDLE); + expect(postMessage).toHaveBeenCalledWith(message, '*'); + }, + ); + + test("Should post only the checked fields of the dev client's bundle", async () => { + const { posted, reply } = installFakeWindow(); + const extendedBundle = { ...BUNDLE, extra: () => 'not cloneable' }; + const prepareCall = jest + .fn() + .mockResolvedValue({ targetOrigin: DATADOG_PARENT, bundle: extendedBundle }); + Reflect.set(globalThis, DEV_CLIENT_HOOK, prepareCall); + + const result = postMessageTransport(QUERY_NAME, ARGS); + const message = await posted; + reply(message.requestId, 'ok'); + + await expect(result).resolves.toBe('ok'); + expect(message).toEqual({ ...MESSAGE_WITHOUT_BUNDLE, bundle: BUNDLE }); + }); + + test('Should post the call without a bundle when reading the hook itself throws', async () => { + const { postMessage, posted, reply } = installFakeWindow(); + Object.defineProperty(globalThis, DEV_CLIENT_HOOK, { + configurable: true, + get(): never { + throw new Error('unreadable'); + }, + }); + + const result = postMessageTransport(QUERY_NAME, ARGS); + const message = await posted; + reply(message.requestId, 'ok'); + + await expect(result).resolves.toBe('ok'); + expect(postMessage).toHaveBeenCalledWith(message, '*'); + }); + + test('Should post the call without a bundle when reading the hook result throws', async () => { + const { postMessage, posted, reply } = installFakeWindow(); + const throwingResult = { + get targetOrigin(): string { + throw new Error('unreadable'); + }, + }; + const prepareCall = jest.fn().mockResolvedValue(throwingResult); + Reflect.set(globalThis, DEV_CLIENT_HOOK, prepareCall); + + const result = postMessageTransport(QUERY_NAME, ARGS); + const message = await posted; + reply(message.requestId, 'ok'); + + await expect(result).resolves.toBe('ok'); + expect(message).toEqual(MESSAGE_WITHOUT_BUNDLE); + expect(postMessage).toHaveBeenCalledWith(message, '*'); + }); + + test('Should check the connection ids it posts, not what their iterator yields', async () => { + const { posted, reply } = installFakeWindow(); + const allowedConnectionIds = ['conn-1']; + Object.defineProperty(allowedConnectionIds, Symbol.iterator, { + value: function* yieldsAnObject() { + yield { not: 'a string' }; + }, + }); + const bundle = { code: BUNDLE.code, allowedConnectionIds }; + const prepareCall = jest.fn().mockResolvedValue({ targetOrigin: DATADOG_PARENT, bundle }); + Reflect.set(globalThis, DEV_CLIENT_HOOK, prepareCall); + + const result = postMessageTransport(QUERY_NAME, ARGS); + const message = await posted; + reply(message.requestId, 'ok'); + + await expect(result).resolves.toBe('ok'); + expect(message).toEqual({ ...MESSAGE_WITHOUT_BUNDLE, bundle: BUNDLE }); + }); + + test('Should copy the connection ids it posts without running their own array methods', async () => { + const { posted, reply } = installFakeWindow(); + class ForgingArray extends Array {} + Reflect.defineProperty(ForgingArray, Symbol.species, { + get: () => + function forgedArray() { + return new globalThis.Proxy(['conn-forged'], { + defineProperty: () => true, + set: () => true, + }); + }, + }); + const allowedConnectionIds = ForgingArray.from(['conn-1']); + const bundle = { code: BUNDLE.code, allowedConnectionIds }; + const prepareCall = jest.fn().mockResolvedValue({ targetOrigin: DATADOG_PARENT, bundle }); + Reflect.set(globalThis, DEV_CLIENT_HOOK, prepareCall); + + const result = postMessageTransport(QUERY_NAME, ARGS); + const message = await posted; + reply(message.requestId, 'ok'); + + await expect(result).resolves.toBe('ok'); + expect(message).toEqual({ ...MESSAGE_WITHOUT_BUNDLE, bundle: BUNDLE }); + }); + + const malformedPreparedCalls = [ + { + description: 'an origin without a scheme', + preparedCall: { targetOrigin: 'app.datadoghq.com', bundle: BUNDLE }, + expectedTarget: '*', + }, + { + description: 'a non-string origin', + preparedCall: { targetOrigin: 123, bundle: BUNDLE }, + expectedTarget: '*', + }, + { + description: 'a bundle that is not a bundle', + preparedCall: { targetOrigin: DATADOG_PARENT, bundle: { code: 1 } }, + expectedTarget: DATADOG_PARENT, + }, + ]; + + test.each(malformedPreparedCalls)( + 'Should post the call without a bundle when the dev client hook returns $description', + async ({ preparedCall, expectedTarget }) => { + const { postMessage, posted, reply } = installFakeWindow(); + const prepareCall = jest.fn().mockResolvedValue(preparedCall); + Reflect.set(globalThis, DEV_CLIENT_HOOK, prepareCall); + + const result = postMessageTransport(QUERY_NAME, ARGS); + const message = await posted; + reply(message.requestId, 'ok'); + + await expect(result).resolves.toBe('ok'); + expect(message).toEqual(MESSAGE_WITHOUT_BUNDLE); + expect(postMessage).toHaveBeenCalledWith(message, expectedTarget); + }, + ); + + const hookError = new Error('broken'); + const failingHooks = [ + { description: 'rejects', prepareCall: jest.fn().mockRejectedValue(hookError) }, + { description: 'returns no promise', prepareCall: jest.fn().mockReturnValue(undefined) }, + ]; + + test.each(failingHooks)( + 'Should still post the call without a bundle when the dev client hook $description', + async ({ prepareCall }) => { + const { postMessage, posted, reply } = installFakeWindow(); + Reflect.set(globalThis, DEV_CLIENT_HOOK, prepareCall); + + const result = postMessageTransport(QUERY_NAME, ARGS); + const message = await posted; + reply(message.requestId, 'ok'); + + await expect(result).resolves.toBe('ok'); + expect(message).toEqual(MESSAGE_WITHOUT_BUNDLE); + expect(postMessage).toHaveBeenCalledWith(message, '*'); + }, + ); + + test('Should post the call without a bundle when the dev client hook never settles', async () => { + jest.useFakeTimers(); + const { postMessage } = installFakeWindow(); + const pendingForever = new Promise(() => {}); + const prepareCall = jest.fn().mockReturnValue(pendingForever); + Reflect.set(globalThis, DEV_CLIENT_HOOK, prepareCall); + + postMessageTransport(QUERY_NAME, ARGS).catch(() => {}); + await jest.advanceTimersByTimeAsync(POSTMESSAGE_TIMEOUT_MS); + + const [message, targetOrigin] = postMessage.mock.calls.at(-1) ?? []; + expect(message).toEqual(MESSAGE_WITHOUT_BUNDLE); + expect(targetOrigin).toBe('*'); + }); + + test('Should resolve a success whose result has no data as undefined', async () => { + const { posted, listeners, parent } = installFakeWindow(); + + const result = postMessageTransport(QUERY_NAME, ARGS); + const message = await posted; + const response = { + type: RUN_QUERY_RESPONSE, + requestId: message.requestId, + success: true, + result: {}, + }; + for (const listener of listeners()) { + listener({ data: response, source: parent }); + } + + await expect(result).resolves.toBeUndefined(); + }); + + const malformedResponses = [ + { + description: 'a success that carries no result', + fields: { success: true }, + expectedMessage: `Backend function "${QUERY_NAME}" failed`, + }, + { + description: 'a truthy success that is not true', + fields: { success: 1 }, + expectedMessage: `Backend function "${QUERY_NAME}" failed`, + }, + { + description: 'an error without a success field', + fields: { error: 'Permission denied' }, + expectedMessage: 'Permission denied', + }, + { + description: 'a failure whose error is not a string', + fields: { success: false, error: { code: 403 } }, + expectedMessage: `Backend function "${QUERY_NAME}" failed`, + }, + ]; + + test.each(malformedResponses)( + 'Should reject at once on $description', + async ({ fields, expectedMessage }) => { + const { posted, listeners, parent } = installFakeWindow(); + + const result = postMessageTransport(QUERY_NAME, ARGS); + const outcome = result.catch((error: unknown) => error); + const message = await posted; + const response = { type: RUN_QUERY_RESPONSE, requestId: message.requestId, ...fields }; + for (const listener of listeners()) { + listener({ data: response, source: parent }); + } + + const error = await outcome; + expect(error).toBeInstanceOf(BackendFunctionError); + expect(error).toMatchObject({ message: expectedMessage }); + }, + ); + + test('Should ignore a run-query response from any window but the parent', async () => { + const { posted, reply } = installFakeWindow(); + + const result = postMessageTransport(QUERY_NAME, ARGS); + const message = await posted; + reply(message.requestId, 'forged', {}); + const stillPending = new Promise((resolve) => { + setImmediate(resolve, 'pending'); + }); + const earlyOutcome = await Promise.race([result, stillPending]); + expect(earlyOutcome).toBe('pending'); + reply(message.requestId, 'from-parent'); + + await expect(result).resolves.toBe('from-parent'); + }); + + const OTHER_ORIGIN = 'https://example.com'; + + const installTargetedCall = () => { + const fakeWindow = installFakeWindow(); + const prepareCall = jest + .fn() + .mockResolvedValue({ targetOrigin: DATADOG_PARENT, bundle: BUNDLE }); + Reflect.set(globalThis, DEV_CLIENT_HOOK, prepareCall); + return fakeWindow; + }; + + test('Should ignore a response from the parent window at another origin than it posted to', async () => { + const { posted, reply, parent } = installTargetedCall(); + + const result = postMessageTransport(QUERY_NAME, ARGS); + const message = await posted; + reply(message.requestId, 'forged', parent, OTHER_ORIGIN); + const stillPending = new Promise((resolve) => { + setImmediate(resolve, 'pending'); + }); + const earlyOutcome = await Promise.race([result, stillPending]); + expect(earlyOutcome).toBe('pending'); + reply(message.requestId, 'from-parent', parent, DATADOG_PARENT); + + await expect(result).resolves.toBe('from-parent'); + }); + + test('Should time out when only another origin answers a call posted to an exact origin', async () => { + jest.useFakeTimers(); + const { posted, reply, parent, listenerCount } = installTargetedCall(); + + const result = postMessageTransport(QUERY_NAME, ARGS); + const outcome = result.catch((error: unknown) => error); + const message = await posted; + reply(message.requestId, 'forged', parent, OTHER_ORIGIN); + await jest.advanceTimersByTimeAsync(POSTMESSAGE_TIMEOUT_MS); + + const error = await outcome; + const timedOut = { message: expect.stringContaining('timed out') }; + expect(error).toBeInstanceOf(BackendFunctionError); + expect(error).toMatchObject(timedOut); + const remainingListeners = listenerCount(); + expect(remainingListeners).toBe(0); + }); + + test("Should accept the parent's response from any origin when it posted with '*'", async () => { + const { postMessage, posted, reply, parent } = installFakeWindow({}); + + const result = postMessageTransport(QUERY_NAME, ARGS); + const message = await posted; + reply(message.requestId, 'ok', parent, OTHER_ORIGIN); + + await expect(result).resolves.toBe('ok'); + expect(postMessage).toHaveBeenCalledWith(message, '*'); + }); + + test('Should settle each call only with the response carrying its own request id', async () => { + const { postMessage, reply } = installFakeWindow(); + + const first = postMessageTransport(QUERY_NAME, ['first']); + const second = postMessageTransport(QUERY_NAME, ['second']); + const [[firstQuery], [secondQuery]] = postMessage.mock.calls; + expect(firstQuery).toMatchObject({ args: ['first'] }); + expect(secondQuery).toMatchObject({ args: ['second'] }); + expect(firstQuery.requestId).not.toBe(secondQuery.requestId); + + reply('unknown-request-id', 'stray'); + const stillPending = new Promise((resolve) => { + setImmediate(resolve, 'pending'); + }); + const earlyOutcomes = await Promise.all([ + Promise.race([first, stillPending]), + Promise.race([second, stillPending]), + ]); + expect(earlyOutcomes).toEqual(['pending', 'pending']); + reply(secondQuery.requestId, 'second-result'); + reply(firstQuery.requestId, 'first-result'); + + await expect(first).resolves.toBe('first-result'); + await expect(second).resolves.toBe('second-result'); + }); + + test('Should reject the call and stop waiting when the message cannot be posted', async () => { + jest.useFakeTimers(); + const { postMessage, listenerCount } = installFakeWindow(); + const cloneError = new Error('could not be cloned'); + postMessage.mockImplementation(() => { + throw cloneError; + }); + + const result = postMessageTransport(QUERY_NAME, ARGS); + + await expect(result).rejects.toBe(cloneError); + const remainingListeners = listenerCount(); + const remainingTimers = jest.getTimerCount(); + expect(remainingListeners).toBe(0); + expect(remainingTimers).toBe(0); + }); +}); diff --git a/packages/plugins/apps/src/backend/client/transports/post-message-transport/post-message-transport.ts b/packages/plugins/apps/src/backend/client/transports/post-message-transport/post-message-transport.ts index f08666659..dc5334c01 100644 --- a/packages/plugins/apps/src/backend/client/transports/post-message-transport/post-message-transport.ts +++ b/packages/plugins/apps/src/backend/client/transports/post-message-transport/post-message-transport.ts @@ -3,46 +3,91 @@ // Copyright 2019-Present Datadog, Inc. /* eslint-env browser */ +/* global globalThis */ +import { DEV_CLIENT_HOOK, RUN_QUERY, RUN_QUERY_RESPONSE } from '../../../protocol'; +import type { DebugBundleResponse, PreparedCall } from '../../../protocol'; +import { copyByIndex, isDebugBundle, isRecord } from '../../guards'; +import { generateRequestId } from '../../request-id'; import type { BackendFunctionTransport } from '../../types'; import { BackendFunctionError } from '../../types'; -import type { IframeQueryResponse } from './types'; +import type { IframeQueryRequest, IframeQueryResponse } from './types'; -const POSTMESSAGE_TIMEOUT_MS = 120_000; +export const POSTMESSAGE_TIMEOUT_MS = 120_000; -let requestCounter = 0; - -function generateRequestId(): string { - if (typeof crypto !== 'undefined' && crypto.randomUUID) { - return crypto.randomUUID(); - } - requestCounter += 1; - return `req-${Date.now()}-${requestCounter}`; -} - -function isQueryResponse(data: unknown, requestId: string): data is IframeQueryResponse { +function isQueryResponse( + data: unknown, + requestId: string, +): data is IframeQueryResponse { return ( - data !== null && - typeof data === 'object' && + isRecord(data) && 'type' in data && - data.type === 'app-builder:run-query:response' && + data.type === RUN_QUERY_RESPONSE && 'requestId' in data && data.requestId === requestId ); } -/** - * Transport for executing backend functions via `postMessage` when the app - * is hosted inside an iframe (e.g. App Builder preview). Sends a - * `app-builder:run-query` message to the parent window and listens for a - * matching `app-builder:run-query:response` reply. Rejects if no response - * arrives within {@link POSTMESSAGE_TIMEOUT_MS}. - */ -export const postMessageTransport: BackendFunctionTransport = ( +// Only an exact target origin restricts who receives the message, so a bundle is never posted with '*'. +function isExactOrigin(targetOrigin: unknown): targetOrigin is string { + if (typeof targetOrigin !== 'string') { + return false; + } + try { + return new URL(targetOrigin).origin === targetOrigin; + } catch { + return false; + } +} + +function copyBundle(candidate: unknown): DebugBundleResponse | undefined { + if (!isRecord(candidate)) { + return undefined; + } + const ids = 'allowedConnectionIds' in candidate ? candidate.allowedConnectionIds : undefined; + // Checked on a copy, so only the code and connection ids reach postMessage. + const copy = { + code: 'code' in candidate ? candidate.code : undefined, + allowedConnectionIds: Array.isArray(ids) ? copyByIndex(ids) : undefined, + }; + return isDebugBundle(copy) ? copy : undefined; +} + +// Any page script could install the hook, so its result is checked before anything is posted. +function toPreparedCall(value: unknown): PreparedCall { + try { + if (!isRecord(value)) { + return {}; + } + const targetOrigin = 'targetOrigin' in value ? value.targetOrigin : undefined; + if (!isExactOrigin(targetOrigin)) { + return {}; + } + const bundle = copyBundle('bundle' in value ? value.bundle : undefined); + return bundle === undefined ? { targetOrigin } : { targetOrigin, bundle }; + } catch { + return {}; + } +} + +function readDevClientHook(): unknown { + try { + return Reflect.get(globalThis, DEV_CLIENT_HOOK); + } catch { + return undefined; + } +} + +function isPrepareCall(value: unknown): value is (functionName: string) => unknown { + return typeof value === 'function'; +} + +function postRunQuery( functionName: string, args: unknown[], -): Promise => { + { targetOrigin, bundle }: PreparedCall, +): Promise { const requestId = generateRequestId(); return new Promise((resolve, reject) => { @@ -53,25 +98,33 @@ export const postMessageTransport: BackendFunctionTransport = ( clearTimeout(timeoutId); } - function handleMessage(event: MessageEvent): void { - if (!isQueryResponse(event.data, requestId)) { + function isFromRecipient(event: MessageEvent): boolean { + return ( + event.source === window.parent && + (targetOrigin === undefined || event.origin === targetOrigin) + ); + } + + function handleMessage(event: MessageEvent): void { + const response = event.data; + if (!isFromRecipient(event) || !isQueryResponse(response, requestId)) { return; } cleanup(); - const response = event.data as IframeQueryResponse; - - if (response.success) { + // The parent's reply is checked here, so a malformed one settles the call instead of throwing. + if (response.success === true && isRecord(response.result)) { resolve(response.result.data); - } else { - reject( - new BackendFunctionError( - response.error ?? `Backend function "${functionName}" failed`, - functionName, - ), - ); + return; } + const parentError = 'error' in response ? response.error : undefined; + const errorMessage = + typeof parentError === 'string' + ? parentError + : `Backend function "${functionName}" failed`; + const failure = new BackendFunctionError(errorMessage, functionName); + reject(failure); } window.addEventListener('message', handleMessage); @@ -86,14 +139,48 @@ export const postMessageTransport: BackendFunctionTransport = ( ); }, POSTMESSAGE_TIMEOUT_MS); - window.parent.postMessage( - { - type: 'app-builder:run-query', - requestId, - queryName: functionName, - args, - }, - '*', - ); + const message: IframeQueryRequest = { + type: RUN_QUERY, + requestId, + queryName: functionName, + args, + ...(bundle && { bundle }), + }; + try { + window.parent.postMessage(message, targetOrigin ?? '*'); + } catch (error) { + cleanup(); + reject(error); + } + }); +} + +/** + * Transport for executing backend functions via `postMessage` when the app is framed (e.g. App + * Builder preview): posts `app-builder:run-query` to the parent and waits up to + * {@link POSTMESSAGE_TIMEOUT_MS} for the matching response. On a dev server framed by a Datadog + * page, the injected dev client first adds the function's bundle and the parent's exact origin, + * which can take up to another {@link POSTMESSAGE_TIMEOUT_MS} before the call is sent without them. + */ +export const postMessageTransport: BackendFunctionTransport = ( + functionName: string, + args: unknown[], +): Promise => { + const prepareCall = readDevClientHook(); + if (!isPrepareCall(prepareCall)) { + return postRunQuery(functionName, args, {}); + } + // A failing or stuck hook must not stop the call, which is then sent as it would be outside dev. + let giveUpTimeoutId: ReturnType | undefined; + const givenUp = new Promise((resolve) => { + giveUpTimeoutId = setTimeout(() => resolve({}), POSTMESSAGE_TIMEOUT_MS); + }); + const prepared = Promise.resolve() + .then(() => prepareCall(functionName)) + .catch(() => ({})); + return Promise.race([prepared, givenUp]).then((preparedCall) => { + clearTimeout(giveUpTimeoutId); + const checkedCall = toPreparedCall(preparedCall); + return postRunQuery(functionName, args, checkedCall); }); }; diff --git a/packages/plugins/apps/src/backend/client/transports/post-message-transport/types.ts b/packages/plugins/apps/src/backend/client/transports/post-message-transport/types.ts index e1e735f46..f52a22119 100644 --- a/packages/plugins/apps/src/backend/client/transports/post-message-transport/types.ts +++ b/packages/plugins/apps/src/backend/client/transports/post-message-transport/types.ts @@ -2,11 +2,25 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import type { ExecuteActionResponse } from '../../../protocol'; +import type { + DebugBundleResponse, + ExecuteActionResponse, + RUN_QUERY, + RUN_QUERY_RESPONSE, +} from '../../../protocol'; + +// Request: iframe → parent. +export type IframeQueryRequest = { + type: typeof RUN_QUERY; + requestId: string; + queryName: string; + args: unknown[]; + bundle?: DebugBundleResponse; +}; // Response: parent → iframe, for the `app-builder:run-query` messages the // transport sends to the parent window. export type IframeQueryResponse = { - type: 'app-builder:run-query:response'; + type: typeof RUN_QUERY_RESPONSE; requestId: string; } & ExecuteActionResponse; diff --git a/packages/plugins/apps/src/backend/protocol.ts b/packages/plugins/apps/src/backend/protocol.ts index ff6ccbeff..14eb8978a 100644 --- a/packages/plugins/apps/src/backend/protocol.ts +++ b/packages/plugins/apps/src/backend/protocol.ts @@ -20,11 +20,32 @@ export interface ExecuteActionRequest { export const DEBUG_BUNDLE_PATH = '/__dd/debugBundle'; +// Message types of the frame ↔ parent postMessage protocol. +export const RUN_QUERY = 'app-builder:run-query'; +export const RUN_QUERY_RESPONSE = 'app-builder:run-query:response'; +export const PING = 'app-builder:ping'; +export const PONG = 'app-builder:pong'; + +// The `globalThis` key where the dev client the dev server injects installs its call hook. +export const DEV_CLIENT_HOOK = 'DD_APPS_PREPARE_CALL'; + export interface DebugBundleResponse { code: string; allowedConnectionIds: string[]; } +// Asks the parent to identify itself: its pong's `event.origin` is set by the browser. +export type IframeQueryPing = { + type: typeof PING; + requestId: string; +}; + +// What the dev client adds to a call before the transport posts it. +export type PreparedCall = { targetOrigin?: string; bundle?: DebugBundleResponse }; + +// The hook the dev client installs under DEV_CLIENT_HOOK. +export type PrepareCall = (functionName: string) => Promise; + /** * Response from executing a backend function. * diff --git a/packages/plugins/apps/src/built/apps-dev-client.ts b/packages/plugins/apps/src/built/apps-dev-client.ts new file mode 100644 index 000000000..4bc1f16e7 --- /dev/null +++ b/packages/plugins/apps/src/built/apps-dev-client.ts @@ -0,0 +1,9 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +/* global globalThis */ +import { prepareCall } from '../backend/client/dev-client/dev-client'; +import { DEV_CLIENT_HOOK } from '../backend/protocol'; + +Reflect.set(globalThis, DEV_CLIENT_HOOK, prepareCall); diff --git a/packages/plugins/apps/src/vite/dev-client-build.test.ts b/packages/plugins/apps/src/vite/dev-client-build.test.ts new file mode 100644 index 000000000..30b00413e --- /dev/null +++ b/packages/plugins/apps/src/vite/dev-client-build.test.ts @@ -0,0 +1,28 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import { DEV_CLIENT_HOOK } from '@dd/apps-plugin/backend/protocol'; +import { build } from 'esbuild'; +import path from 'path'; +import vm from 'vm'; + +describe('apps-dev-client entry', () => { + test('Should install the dev client hook when the bundled entry runs', async () => { + const entryPoint = path.join(__dirname, '../built/apps-dev-client.ts'); + const result = await build({ + entryPoints: [entryPoint], + bundle: true, + format: 'esm', + platform: 'browser', + write: false, + }); + const [output] = result.outputFiles; + const sandbox = vm.createContext({}); + + vm.runInContext(output.text, sandbox); + + const hook: unknown = Reflect.get(sandbox, DEV_CLIENT_HOOK); + expect(typeof hook).toBe('function'); + }); +}); diff --git a/packages/plugins/apps/src/vite/dev-server.integration.test.ts b/packages/plugins/apps/src/vite/dev-server.integration.test.ts index a504ed31f..71962b61e 100644 --- a/packages/plugins/apps/src/vite/dev-server.integration.test.ts +++ b/packages/plugins/apps/src/vite/dev-server.integration.test.ts @@ -18,8 +18,9 @@ import { DEBUG_BUNDLE_ROUTE, EXECUTE_ACTION_ROUTE, } from '@dd/apps-plugin/vite/dev-server'; -import { getVitePlugin, SSR_WARMUP_SETTING } from '@dd/apps-plugin/vite/index'; +import { DEV_CLIENT_SRC, getVitePlugin, SSR_WARMUP_SETTING } from '@dd/apps-plugin/vite/index'; import { FS_WRITE_BLOCKED_MESSAGE } from '@dd/apps-plugin/vite/network-guard'; +import * as fsHelpers from '@dd/core/helpers/fs'; import { outputFileSync, readFileSync, rmSync } from '@dd/core/helpers/fs'; import type { AuthOptionsWithDefaults } from '@dd/core/types'; import { getTempWorkingDir } from '@dd/tests/_jest/helpers/env'; @@ -479,6 +480,7 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { log, 'development', createViteHostCheck(server.config), + defaultServerConditions, ); const functionName = encodeQueryName(actionCatalogCallFunc); @@ -765,6 +767,88 @@ describe('Dev Server Middleware — dev:verify bundles', () => { // would race other test files that copy that whole tree in parallel. const FAKE_SDK_NAME = 'dd-fake-stateful-sdk'; +describe('Dev client module', () => { + const startDevServer = async (base: string, extraPlugins: Plugin[] = []) => { + const appsPlugin: Plugin = { + name: 'dd-apps-test', + enforce: 'post', + ...getVitePlugin({ + bundler: { build }, + context: getContextMock({ buildRoot: FIXTURE_ROOT }), + options: { include: [], longPolling: mockLongPolling }, + }), + }; + const devServer = await createServer({ + configFile: false, + root: FIXTURE_ROOT, + base, + logLevel: 'silent', + server: { middlewareMode: true, hmr: false }, + plugins: [appsPlugin, ...extraPlugins], + optimizeDeps: { noDiscovery: true }, + }); + const httpServer = http.createServer(devServer.middlewares); + await new Promise((resolve) => { + httpServer.listen(0, '127.0.0.1', resolve); + }); + const address = httpServer.address(); + const port = typeof address === 'object' && address !== null ? address.port : 0; + const close = async () => { + await new Promise((resolve) => { + httpServer.close(resolve); + }); + await devServer.close(); + }; + return { devServer, origin: `http://127.0.0.1:${port}`, close }; + }; + + beforeEach(() => { + nock.enableNetConnect('127.0.0.1'); + }); + + afterEach(() => { + nock.disableNetConnect(); + jest.restoreAllMocks(); + }); + + const bases = [ + { description: 'at the root', base: '/' }, + { description: 'under a base path', base: '/myapp/' }, + ]; + + test.each(bases)( + 'Should serve the dev client its page links to $description', + async ({ base }) => { + jest.spyOn(fsHelpers, 'readFile').mockResolvedValue( + 'globalThis.devClientLoaded = true;', + ); + const loadsEveryId: Plugin = { + name: 'loads-every-id', + load: () => 'export const unexpected = true;', + }; + const { devServer, origin, close } = await startDevServer(base, [loadsEveryId]); + + try { + const html = await devServer.transformIndexHtml( + '/index.html', + '', + ); + const devClientSrc = path.posix.join(base, DEV_CLIENT_SRC); + const devClientUrl = `${origin}${devClientSrc}`; + const response = await fetch(devClientUrl); + const devClient = await response.text(); + + expect(html).toContain(``); + expect(response.status).toBe(200); + expect(devClient).toContain('devClientLoaded'); + } finally { + await close(); + } + }, + 30000, + ); +}); + describe('Dev Server Middleware — editing files between local executions', () => { let editRoot: string | undefined; let editServer: ViteDevServer; diff --git a/packages/plugins/apps/src/vite/index.test.ts b/packages/plugins/apps/src/vite/index.test.ts index ebb6b5984..cca9e698e 100644 --- a/packages/plugins/apps/src/vite/index.test.ts +++ b/packages/plugins/apps/src/vite/index.test.ts @@ -3,9 +3,18 @@ // Copyright 2019-Present Datadog, Inc. import type { HostCheckConfig } from '@dd/apps-plugin/vite/dev-server-request-guard'; -import { getVitePlugin, SSR_WARMUP_SETTING } from '@dd/apps-plugin/vite/index'; +import { + APPS_DEV_CLIENT_PATH, + DEV_CLIENT_MODULE_ID, + DEV_CLIENT_RESOLVED_ID, + DEV_CLIENT_RESOLVED_RE, + DEV_CLIENT_SRC, + getVitePlugin, + SSR_WARMUP_SETTING, +} from '@dd/apps-plugin/vite/index'; import type { ViteBundler } from '@dd/apps-plugin/vite/index'; import { localExecutionResolutionContext } from '@dd/apps-plugin/vite/local-execution'; +import * as fsHelpers from '@dd/core/helpers/fs'; import { rmSync } from '@dd/core/helpers/fs'; import { InjectPosition } from '@dd/core/types'; import { clearDatadogEnv } from '@dd/tests/_jest/helpers/datadogEnv'; @@ -116,6 +125,49 @@ function getConfigureServer( }; } +// Narrows `plugin.transformIndexHtml` to its object-hook form, which must run before Vite rewrites +// script srcs for the configured `base`. +function getTransformIndexHtml( + plugin: ReturnType, +): (ctx: { server?: object }) => unknown { + const { transformIndexHtml } = plugin ?? {}; + if ( + typeof transformIndexHtml !== 'object' || + transformIndexHtml === null || + !('handler' in transformIndexHtml) || + typeof transformIndexHtml.handler !== 'function' || + !('order' in transformIndexHtml) || + transformIndexHtml.order !== 'pre' + ) { + throw new Error("Expected plugin.transformIndexHtml to be an object hook with order 'pre'"); + } + const { handler } = transformIndexHtml; + return function callTransformIndexHtml(ctx: { server?: object }): unknown { + return Reflect.apply(handler, undefined, ['', ctx]); + }; +} + +// Narrows `plugin.load` to its object-hook form, whose filter includes the dev client module. +function getLoad( + plugin: ReturnType, + pluginContext: object, +): (id: string) => Promise { + const { load } = plugin ?? {}; + if ( + typeof load !== 'object' || + load === null || + !('handler' in load) || + typeof load.handler !== 'function' || + !('filter' in load) + ) { + throw new Error('Expected plugin.load to be a filtered object hook'); + } + const { handler } = load; + return async function callLoad(id: string): Promise { + return Reflect.apply(handler, pluginContext, [id]); + }; +} + function isDevServerMiddleware(value: unknown): value is DevServerMiddleware { return typeof value === 'function'; } @@ -272,6 +324,125 @@ describe('Backend Functions - getVitePlugin', () => { nock.cleanAll(); }); + test('Should load the dev client as a module on pages the dev server serves', () => { + const plugin = getVitePlugin(defaultOptions); + const transformIndexHtml = getTransformIndexHtml(plugin); + + const tags = transformIndexHtml({ server: {} }); + + expect(tags).toEqual([ + { + tag: 'script', + attrs: { type: 'module', src: DEV_CLIENT_SRC }, + injectTo: 'head-prepend', + }, + ]); + }); + + test('Should not load the dev client in a production build', () => { + const plugin = getVitePlugin(defaultOptions); + const transformIndexHtml = getTransformIndexHtml(plugin); + + const tags = transformIndexHtml({}); + + expect(tags).toEqual([]); + }); + + const serveFakeDevServer = (plugin: ReturnType) => { + const configureServer = getConfigureServer(plugin); + configureServer({ + middlewares: { use: jest.fn() }, + ssrLoadModule: jest.fn(), + config: fakeServerConfig('development'), + }); + }; + + const devClientResolutions = [ + { + description: 'resolve in the browser graph of a dev server', + serve: true, + ssr: false, + expected: DEV_CLIENT_RESOLVED_ID, + }, + { description: 'not resolve in the SSR graph', serve: true, ssr: true, expected: null }, + { + description: 'not resolve outside a dev server', + serve: false, + ssr: false, + expected: null, + }, + ]; + + test.each(devClientResolutions)( + 'Should $description for the dev client module', + async ({ serve, ssr, expected }) => { + const plugin = getVitePlugin(defaultOptions); + if (serve) { + serveFakeDevServer(plugin); + } + const resolveIdHandler = getResolveIdHandler(plugin); + + const resolved = await resolveIdHandler.call({}, DEV_CLIENT_MODULE_ID, undefined, { + ssr, + }); + + expect(resolved).toBe(expected); + }, + ); + + test('Should include the dev client module id in the load hook filter', () => { + const plugin = getVitePlugin(defaultOptions); + const load = plugin?.load; + const filter = + typeof load === 'object' && load !== null && 'filter' in load ? load.filter : undefined; + const matchesResolvedId = DEV_CLIENT_RESOLVED_RE.test(DEV_CLIENT_RESOLVED_ID); + + expect(filter).toEqual({ + id: { include: expect.arrayContaining([DEV_CLIENT_RESOLVED_RE]) }, + }); + expect(matchesResolvedId).toBe(true); + }); + + test('Should build the load filter from the resolved dev client id', () => { + const expectedSource = `^${DEV_CLIENT_RESOLVED_ID}$`; + const matchesPrefixed = DEV_CLIENT_RESOLVED_RE.test(`x${DEV_CLIENT_RESOLVED_ID}`); + const matchesSuffixed = DEV_CLIENT_RESOLVED_RE.test(`${DEV_CLIENT_RESOLVED_ID}x`); + + expect(DEV_CLIENT_RESOLVED_RE.source).toBe(expectedSource); + expect(matchesPrefixed).toBe(false); + expect(matchesSuffixed).toBe(false); + }); + + test('Should serve the built dev client as that module', async () => { + const readFile = jest + .spyOn(fsHelpers, 'readFile') + .mockResolvedValue('globalThis.devClientLoaded = true;'); + const plugin = getVitePlugin(defaultOptions); + serveFakeDevServer(plugin); + const load = getLoad(plugin, {}); + + const code = await load(DEV_CLIENT_RESOLVED_ID); + const otherModule = await load('/src/main.ts'); + const unresolvedId = await load(DEV_CLIENT_MODULE_ID); + + expect(code).toBe('globalThis.devClientLoaded = true;'); + expect(readFile).toHaveBeenCalledWith(APPS_DEV_CLIENT_PATH); + expect(otherModule).toBeNull(); + expect(unresolvedId).toBeNull(); + }); + + test('Should fail the dev client module with its cause when the built file is missing', async () => { + const missingFile = new Error('ENOENT: no such file or directory'); + jest.spyOn(fsHelpers, 'readFile').mockRejectedValue(missingFile); + const plugin = getVitePlugin(defaultOptions); + serveFakeDevServer(plugin); + const load = getLoad(plugin, {}); + + const loading = load(DEV_CLIENT_RESOLVED_ID); + + await expect(loading).rejects.toThrow('ENOENT'); + }); + test('Should return a vite plugin object with closeBundle', () => { const plugin = getVitePlugin(defaultOptions); expect(plugin).toBeDefined(); diff --git a/packages/plugins/apps/src/vite/index.ts b/packages/plugins/apps/src/vite/index.ts index d98e3b0da..101f2e6e6 100644 --- a/packages/plugins/apps/src/vite/index.ts +++ b/packages/plugins/apps/src/vite/index.ts @@ -2,7 +2,7 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import { rm } from '@dd/core/helpers/fs'; +import { readFile, rm } from '@dd/core/helpers/fs'; import type { GlobalContext, Logger, PluginOptions } from '@dd/core/types'; import { InjectPosition } from '@dd/core/types'; import { createHash } from 'crypto'; @@ -126,6 +126,12 @@ function createBackendFunctionRegistry() { } const APPS_RUNTIME_PATH = path.join(__dirname, './apps-runtime.mjs'); +export const APPS_DEV_CLIENT_PATH = path.join(__dirname, './apps-dev-client.mjs'); +export const DEV_CLIENT_MODULE_ID = 'virtual:dd-apps-dev-client'; +// The `\0` prefix is Vite's virtual-module convention, which most plugins' id filters skip. +export const DEV_CLIENT_RESOLVED_ID = `\0${DEV_CLIENT_MODULE_ID}`; +export const DEV_CLIENT_SRC = `/@id/${DEV_CLIENT_MODULE_ID}`; +export const DEV_CLIENT_RESOLVED_RE = new RegExp(`^${DEV_CLIENT_RESOLVED_ID}$`); export const SSR_WARMUP_SETTING = 'environments.ssr.dev.preTransformRequests'; // The `\0` prefix is the bundler convention for "not a real file": Rollup and Rolldown both leave @@ -350,6 +356,12 @@ export const getVitePlugin = ({ // first, short-circuiting the hook chain before this plugin ever sees it. order: 'pre', async handler(source, importer, resolveOptions) { + if (source === DEV_CLIENT_MODULE_ID) { + return devServerActive && resolveOptions.ssr !== true + ? DEV_CLIENT_RESOLVED_ID + : null; + } + // In a frontend build, each client-side import of a backend function file resolves // to a proxy module of its own (see load below). Under the backend file's id, the // stub would keep the sourcemaps earlier transforms attached to that file, whose @@ -384,7 +396,7 @@ export const getVitePlugin = ({ return { ...resolved, id: proxyId }; } - // Top-level guard (not folded into each branch) so any future branch added below + // Guard (not folded into each branch) so any future local-execution branch below // inherits it automatically: local execution's traversal is always SSR, so without // this a client-mode resolution could inherit the marker and leak real backend code. if (resolveOptions.ssr !== true) { @@ -434,8 +446,20 @@ export const getVitePlugin = ({ }, }, load: { - filter: { id: { include: [FRONTEND_PROXY_ID_RE] } }, + // Ahead of other plugins' load hooks, which may claim any id. + order: 'pre', + filter: { id: { include: [DEV_CLIENT_RESOLVED_RE, FRONTEND_PROXY_ID_RE] } }, async handler(id) { + if (id === DEV_CLIENT_RESOLVED_ID) { + try { + return await readFile(APPS_DEV_CLIENT_PATH); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error); + throw new Error( + `Framed backend calls won't carry their bundle, the dev client didn't load: ${errorMessage}`, + ); + } + } // Vite < 6.3 ignores hook filters. if (!FRONTEND_PROXY_ID_RE.test(id)) { return null; @@ -625,6 +649,22 @@ export const getVitePlugin = ({ } } }, + transformIndexHtml: { + // Before Vite's own HTML rewrite, which prefixes the dev client's src with the configured `base`. + order: 'pre', + handler(_html, ctx) { + if (!ctx.server) { + return []; + } + return [ + { + tag: 'script', + attrs: { type: 'module', src: DEV_CLIENT_SRC }, + injectTo: 'head-prepend', + }, + ]; + }, + }, configureServer(server) { devServerActive = true; if (server.environments?.ssr?.config.dev.preTransformRequests) { diff --git a/packages/tests/src/_jest/helpers/fakeWindow.ts b/packages/tests/src/_jest/helpers/fakeWindow.ts new file mode 100644 index 000000000..ad863428b --- /dev/null +++ b/packages/tests/src/_jest/helpers/fakeWindow.ts @@ -0,0 +1,104 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +/* global globalThis */ + +import { PING, PONG, RUN_QUERY, RUN_QUERY_RESPONSE } from '@dd/apps-plugin/backend/protocol'; + +type MessageListener = (event: { data: unknown; origin?: string; source?: unknown }) => void; +export type PostedMessage = { type: string; requestId: string }; + +export const QUERY_NAME = 'backend/greet.greet'; +export const ARGS = ['world', 42]; +export const BUNDLE = { + code: 'export async function main($) {}', + allowedConnectionIds: ['conn-1'], +}; +export const DATADOG_PARENT = 'https://app.datadoghq.com'; + +// The node-only jest harness has no DOM, so a minimal stand-in covers what the transport touches. +export function installFakeWindow( + location: { ancestorOrigins?: string[] | null } = { ancestorOrigins: [DATADOG_PARENT] }, + parentOrigin = location.ancestorOrigins?.[0] ?? DATADOG_PARENT, +) { + const listeners = new Set(); + const postMessage = jest.fn(); + const fakeWindow = { + addEventListener: (_type: string, listener: MessageListener) => listeners.add(listener), + removeEventListener: (_type: string, listener: MessageListener) => + listeners.delete(listener), + parent: { postMessage }, + location, + }; + Object.defineProperty(globalThis, 'window', { + value: fakeWindow, + configurable: true, + writable: true, + }); + + const posted = new Promise<{ requestId: string }>((resolve) => { + postMessage.mockImplementation((message: PostedMessage) => { + if (message.type === RUN_QUERY) { + resolve(message); + } + }); + }); + const pings = () => + postMessage.mock.calls + .map(([message]: [PostedMessage]) => message) + .filter((message) => message.type === PING); + const pong = (origin = parentOrigin, source: unknown = fakeWindow.parent) => { + const ping = pings().at(-1); + for (const listener of [...listeners]) { + listener({ + data: { type: PONG, requestId: ping?.requestId }, + origin, + source, + }); + } + }; + const reply = ( + requestId: string, + data: unknown, + source: unknown = fakeWindow.parent, + origin = parentOrigin, + ) => { + for (const listener of [...listeners]) { + listener({ + data: { + type: RUN_QUERY_RESPONSE, + requestId, + success: true, + result: { data }, + }, + origin, + source, + }); + } + }; + + return { + postMessage, + posted, + reply, + pings, + pong, + listeners: () => [...listeners], + parent: fakeWindow.parent, + listenerCount: () => listeners.size, + }; +} + +export type FakeResponse = { ok: boolean; status?: number; json: () => Promise }; + +export function mockFetchResponse(response: FakeResponse) { + global.fetch = jest.fn().mockResolvedValue(response); +} + +export const MESSAGE_WITHOUT_BUNDLE = { + type: RUN_QUERY, + requestId: expect.any(String), + queryName: QUERY_NAME, + args: ARGS, +};