diff --git a/packages/plugins/apps/src/backend/types.ts b/packages/plugins/apps/src/backend/types.ts index edce3b5f6..2022e8faa 100644 --- a/packages/plugins/apps/src/backend/types.ts +++ b/packages/plugins/apps/src/backend/types.ts @@ -12,3 +12,6 @@ export interface BackendFunction { /** Connection IDs this backend function is allowed to use. */ allowedConnectionIds: string[]; } + +/** Shape of a backend function's result, shared by the remote (dev-server.ts) and in-process (local-execution.ts) paths — mirrors the app-builder query response's `{ data: }` wrapper. */ +export type BackendOutputs = { data: unknown }; diff --git a/packages/plugins/apps/src/constants.ts b/packages/plugins/apps/src/constants.ts index db612df45..0c16433d8 100644 --- a/packages/plugins/apps/src/constants.ts +++ b/packages/plugins/apps/src/constants.ts @@ -10,6 +10,13 @@ export const PLUGIN_NAME: PluginName = 'datadog-apps-plugin' as const; export const APPS_API_PATH = 'api/unstable/app-builder-code/apps'; export const ARCHIVE_FILENAME = 'datadog-apps-assets.zip'; export const BACKEND_FILE_RE = /\.backend\.(ts|tsx|js|jsx)$/; + +/** Query suffix marking a local-execution load, so the transform hook can target it directly instead of matching on the broader `options.ssr` flag. */ +export const LOCAL_EXECUTION_LOAD_SUFFIX = '?dd-local-exec'; +// Matches a backend file with any (or no) trailing query string — scoping only to the exact local-execution suffix would let an unrecognized query slip past this filter and leak the real backend source instead of the safe proxy stub; the handler decides safety per case. +export const BACKEND_FILE_WITH_QUERY_RE = new RegExp( + `${BACKEND_FILE_RE.source.slice(0, -1)}(\\?.*)?$`, +); export const BACKEND_CODE_EXTENSIONS = [ '.ts', '.tsx', diff --git a/packages/plugins/apps/src/vite/dev-server.ts b/packages/plugins/apps/src/vite/dev-server.ts index 3d0c78d58..f982275e9 100644 --- a/packages/plugins/apps/src/vite/dev-server.ts +++ b/packages/plugins/apps/src/vite/dev-server.ts @@ -13,7 +13,7 @@ import { AUTH_GUIDANCE } from '../auth'; import type { DoAuthenticatedRequest } from '../auth'; import { encodeQueryName } from '../backend/encodeQueryName'; import type { ExecuteActionRequest, ExecuteActionResponse } from '../backend/protocol'; -import type { BackendFunction } from '../backend/types'; +import type { BackendFunction, BackendOutputs } from '../backend/types'; import { generateDevVirtualEntryContent } from '../backend/virtual-entry'; import { createBackendConnectionIdCollector } from './backend-connection-id-collector'; @@ -30,11 +30,6 @@ const DEV_VIRTUAL_PREFIX = 'virtual:dd-backend-dev:'; type AuthConfig = AuthOptionsWithDefaults; -/** Shape of the `outputs` field in a Datadog app-builder query response — - * the API wraps a JS action's return value as `{ data: }`. - */ -type BackendOutputs = { data: unknown }; - /** * Format a BackendFunction for display in log/error messages. */ diff --git a/packages/plugins/apps/src/vite/index.test.ts b/packages/plugins/apps/src/vite/index.test.ts index 3a798312f..3b8e6b049 100644 --- a/packages/plugins/apps/src/vite/index.test.ts +++ b/packages/plugins/apps/src/vite/index.test.ts @@ -12,6 +12,7 @@ import { parseAst } from 'rollup/parseAst'; import { encodeQueryName } from '../backend/encodeQueryName'; import type { BackendFunction } from '../backend/types'; +import { LOCAL_EXECUTION_LOAD_SUFFIX } from '../constants'; const functions: BackendFunction[] = [ { @@ -31,6 +32,20 @@ const functions: BackendFunction[] = [ const bundleName1 = encodeQueryName(functions[0]); const bundleName2 = encodeQueryName(functions[1]); +/** Narrows a Vite plugin's `transform` hook to its full-object form (`{ handler, ... }`) so tests can call it directly — throws with a clear message if it's the short-form function or missing, since these tests always configure the object form. */ +function getTransformHandler(plugin: ReturnType): Function { + const transform = plugin?.transform; + if ( + typeof transform !== 'object' || + transform === null || + !('handler' in transform) || + typeof transform.handler !== 'function' + ) { + throw new Error('Expected plugin.transform to be an object with a handler function.'); + } + return transform.handler; +} + const mockViteBuild = jest.fn(); const mockVite = { build: mockViteBuild, @@ -135,11 +150,9 @@ describe('Backend Functions - getVitePlugin', () => { test('Should build backend functions and then upload in closeBundle', async () => { const plugin = getVitePlugin(defaultOptions); - const transform = plugin!.transform as { - handler: (code: string, id: string) => unknown; - }; + const transformHandler = getTransformHandler(plugin); - await transform.handler.call( + await transformHandler.call( { parse: parseAst, resolve: jest.fn(async () => null), @@ -160,6 +173,101 @@ describe('Backend Functions - getVitePlugin', () => { expect(assets.collectAssets).toHaveBeenCalledWith(['dist/**/*'], '/build'); }); + // Regression test: without the suffix check, ssrLoadModule() would get the proxy stub instead of the real function body. + test('Should skip proxy generation for a suffixed local-execution load made from SSR context, returning the real source untouched', async () => { + const plugin = getVitePlugin(defaultOptions); + const transformHandler = getTransformHandler(plugin); + + const realSource = 'export function myHandler() { return 42; }'; + const result = await transformHandler.call( + { + parse: parseAst, + resolve: jest.fn(async () => null), + load: jest.fn(async () => null), + addWatchFile: jest.fn(), + }, + realSource, + `/build/src/backend/myHandler.backend.ts${LOCAL_EXECUTION_LOAD_SUFFIX}`, + { ssr: true }, + ); + + expect(result).toBeNull(); + }); + + // Regression test: the suffix alone must not bypass proxy generation — a spoofed client-side import reusing it still gets the safe proxy stub, never the real backend module body. + test('Should still generate the frontend RPC-proxy for a suffixed import made outside SSR context', async () => { + const plugin = getVitePlugin(defaultOptions); + const transformHandler = getTransformHandler(plugin); + + const result = (await transformHandler.call( + { + parse: parseAst, + resolve: jest.fn(async () => null), + load: jest.fn(async () => null), + addWatchFile: jest.fn(), + }, + 'export function myHandler() { return 42; }', + `/build/src/backend/myHandler.backend.ts${LOCAL_EXECUTION_LOAD_SUFFIX}`, + )) as { code: string } | null; + + expect(result?.code).toEqual(expect.stringContaining('executeBackendFunction')); + }); + + test('Should still generate the frontend RPC-proxy for a normal (unsuffixed) import of the same file', async () => { + const plugin = getVitePlugin(defaultOptions); + const transformHandler = getTransformHandler(plugin); + + const result = (await transformHandler.call( + { + parse: parseAst, + resolve: jest.fn(async () => null), + load: jest.fn(async () => null), + addWatchFile: jest.fn(), + }, + 'export function myHandler() { return 42; }', + '/build/src/backend/myHandler.backend.ts', + )) as { code: string } | null; + + expect(result?.code).toEqual(expect.stringContaining('executeBackendFunction')); + }); + + // Regression test: an unrecognized query string must still be caught by the transform filter, or Vite falls back to its default loader and leaks the real backend source. + test('Transform filter should match a backend file carrying an unrecognized query string', () => { + const plugin = getVitePlugin(defaultOptions); + const filter = (plugin!.transform as { filter?: { id?: { include?: RegExp[] } } }).filter; + const includePatterns = filter?.id?.include ?? []; + + const idsThatMustMatch = [ + '/build/src/backend/myHandler.backend.ts', + `/build/src/backend/myHandler.backend.ts${LOCAL_EXECUTION_LOAD_SUFFIX}`, + '/build/src/backend/myHandler.backend.ts?x', + `/build/src/backend/myHandler.backend.ts${LOCAL_EXECUTION_LOAD_SUFFIX}&x`, + ]; + + for (const id of idsThatMustMatch) { + expect(includePatterns.some((pattern) => pattern.test(id))).toBe(true); + } + }); + + // Regression test: an unrecognized query must still default to the safe proxy stub, not the real backend source. + test('Should still generate the frontend RPC-proxy for an import with an unrecognized query string', async () => { + const plugin = getVitePlugin(defaultOptions); + const transformHandler = getTransformHandler(plugin); + + const result = (await transformHandler.call( + { + parse: parseAst, + resolve: jest.fn(async () => null), + load: jest.fn(async () => null), + addWatchFile: jest.fn(), + }, + 'export function myHandler() { return 42; }', + '/build/src/backend/myHandler.backend.ts?x', + )) as { code: string } | null; + + expect(result?.code).toEqual(expect.stringContaining('executeBackendFunction')); + }); + test('Should inject the apps runtime', () => { getVitePlugin(defaultOptions); diff --git a/packages/plugins/apps/src/vite/index.ts b/packages/plugins/apps/src/vite/index.ts index 831ce75c2..6002db081 100644 --- a/packages/plugins/apps/src/vite/index.ts +++ b/packages/plugins/apps/src/vite/index.ts @@ -17,7 +17,12 @@ import { extractExportedFunctions } from '../backend/ast-parsing/extract-backend import { encodeQueryName } from '../backend/encodeQueryName'; import { generateProxyModule } from '../backend/proxy-codegen'; import type { BackendFunction } from '../backend/types'; -import { BACKEND_FILE_RE, PLUGIN_NAME } from '../constants'; +import { + BACKEND_FILE_RE, + BACKEND_FILE_WITH_QUERY_RE, + LOCAL_EXECUTION_LOAD_SUFFIX, + PLUGIN_NAME, +} from '../constants'; import type { AppsOptionsWithDefaults } from '../types'; import { buildBackendFunctions } from './build-backend-functions'; @@ -121,34 +126,42 @@ export const getVitePlugin = ({ transform: { filter: { id: { - include: [BACKEND_FILE_RE], + include: [BACKEND_FILE_WITH_QUERY_RE], exclude: [/node_modules/, /[/\\]dist[/\\]/], }, }, // For each .backend.* file, parse its named exports, register // them as backend functions, and replace the module with a // frontend proxy that calls executeBackendFunction at runtime. - handler(code, id) { + handler(code, id, transformOptions) { + if (id.endsWith(LOCAL_EXECUTION_LOAD_SUFFIX) && transformOptions?.ssr) { + // Local execution needs the real function body, not the proxy stub below — real loads always go through ssrLoadModule, which runs in SSR, so this only fires for that legitimate path. + return null; + } + // Any other case (no query, a spoofed client-side import reusing the suffix, or an unrecognized query) falls through to the safe proxy-stub generation below. Strip the query first so it registers under the file's real (unsuffixed) relativePath/query-name, not a duplicate. + const queryIndex = id.indexOf('?'); + const normalizedId = queryIndex === -1 ? id : id.slice(0, queryIndex); + const ast = this.parse(code); - const exportNames = extractExportedFunctions(ast, id); + const exportNames = extractExportedFunctions(ast, normalizedId); if (exportNames.length === 0) { log.warn( - `Backend file ${id} has no exported functions. ` + + `Backend file ${normalizedId} has no exported functions. ` + `Did you forget to add a named export?`, ); // Clear any previously registered functions for this file // so stale entries don't persist across HMR re-transforms. - setBackendFunctions(id, []); + setBackendFunctions(normalizedId, []); return { code: '', map: null }; } const { functions, proxyCode } = buildProxyModule( exportNames, - id, + normalizedId, context.buildRoot, ); - setBackendFunctions(id, functions); - log.debug(`Generated proxy for ${id} with ${functions.length} export(s)`); + setBackendFunctions(normalizedId, functions); + log.debug(`Generated proxy for ${normalizedId} with ${functions.length} export(s)`); return { code: proxyCode, map: null }; }, diff --git a/packages/plugins/apps/src/vite/local-execution.test.ts b/packages/plugins/apps/src/vite/local-execution.test.ts new file mode 100644 index 000000000..ce411dd31 --- /dev/null +++ b/packages/plugins/apps/src/vite/local-execution.test.ts @@ -0,0 +1,784 @@ +// 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, NodeJS */ + +import { mockLogFn, mockLogger } from '@dd/tests/_jest/helpers/mocks'; + +import * as shared from '../backend/shared'; +import type { BackendFunction } from '../backend/types'; +import { LOCAL_EXECUTION_LOAD_SUFFIX } from '../constants'; + +import type { ExecuteAction, LoadModule } from './local-execution'; +import { executeScriptLocally } from './local-execution'; + +const func: BackendFunction = { + relativePath: 'src/example', + name: 'example', + absolutePath: '/src/example.backend.ts', + allowedConnectionIds: [], +}; + +const funcWithConnection: BackendFunction = { ...func, allowedConnectionIds: ['conn-1'] }; + +const TEST_PROJECT_ROOT = '/project'; + +interface TestGlobalDollar { + backendFunctionArgs: unknown[]; + // Left untyped: $.Actions is a Proxy of unbounded, dynamic depth ($.Actions.....(...)), the same shape a real customer's untyped code sees. + Actions: any; + Source: { initiator: { id: string; orgId: string }; runAsUser: { id: string; orgId: string } }; +} + +/** Reads the `$` this module installs on `globalThis`, from the customer-code perspective these tests simulate — untyped since it's a runtime-only property (see `setGlobalDollar`). Centralized here instead of repeating the cast at each call site. */ +function testDollar(): TestGlobalDollar { + return (globalThis as unknown as { $: TestGlobalDollar }).$; +} + +beforeEach(() => { + // Neither optional SDK is installed by default; tests exercising the "installed" path override this. + jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(false); + jest.spyOn(shared, 'isDatadogAppsBackendInstalled').mockReturnValue(false); +}); + +const stubExecuteAction: ExecuteAction = async (fqn) => ({ data: null, stub: true, fqn }); + +/** A `loadModule` double that resolves the customer's function from a map and rejects anything else with a module-not-found error, matching the common case where neither optional package is installed. */ +function loadModuleReturning(exports: Record): LoadModule { + return async (specifier: string) => { + if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { + return exports; + } + const error: NodeJS.ErrnoException = new Error(`Cannot find module '${specifier}'`); + error.code = 'MODULE_NOT_FOUND'; + throw error; + }; +} + +describe('local-execution — executeScriptLocally', () => { + test('Should run a simple function in-process and return its result', async () => { + const result = await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [21], + stubExecuteAction, + loadModuleReturning({ example: (n: number) => n * 2 }), + mockLogger, + ); + expect(result).toEqual({ data: 42 }); + }); + + test('Should pick up a changed loadModule result on a subsequent call, not a stale cached result', async () => { + const first = await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ example: () => 1 }), + mockLogger, + ); + const second = await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ example: () => 2 }), + mockLogger, + ); + expect(first).toEqual({ data: 1 }); + expect(second).toEqual({ data: 2 }); + }); + + test('Should reject with a clear error when the named export is missing from the loaded module', async () => { + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ somethingElse: () => 1 }), + mockLogger, + ), + ).rejects.toThrow(`"example" is not a function exported from ${func.absolutePath}`); + }); + + test('Should load and evaluate the customer module before installing globalThis.$, matching production module-evaluation order', async () => { + let dollarDuringModuleLoad: unknown = 'not captured'; + const loadModule: LoadModule = async (specifier) => { + if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { + // Captures globalThis.$ at module-evaluation time — production's static import runs before its wrapper installs $, so code reaching for $ during top-level evaluation must see the same absence locally. + dollarDuringModuleLoad = (globalThis as Record).$; + return { example: () => 'done' }; + } + const notFoundError: NodeJS.ErrnoException = new Error( + `Cannot find module '${specifier}'`, + ); + notFoundError.code = 'MODULE_NOT_FOUND'; + throw notFoundError; + }; + + const result = await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModule, + mockLogger, + ); + + expect(result).toEqual({ data: 'done' }); + expect(dollarDuringModuleLoad).toBeUndefined(); + }); + + test('Should resolve a $.Actions.foo.bar(...) call through the injected executeAction, including connectionId', async () => { + const executeAction = jest.fn().mockResolvedValue({ ok: true }); + const result = await executeScriptLocally( + funcWithConnection, + TEST_PROJECT_ROOT, + [], + executeAction, + loadModuleReturning({ + example: () => + testDollar().Actions.slack.chat.postMessage({ + inputs: { text: 'hi' }, + connectionId: 'conn-1', + }), + }), + mockLogger, + ); + expect(result).toEqual({ data: { ok: true } }); + expect(executeAction).toHaveBeenCalledWith( + 'com.datadoghq.slack.chat.postMessage', + { text: 'hi' }, + 'conn-1', + ); + }); + + test('Should not hang when a customer function returns an un-invoked $.Actions reference instead of calling it', async () => { + // $.Actions.slack.chat is itself a callable Proxy; returning it without the trailing .postMessage(...) call must not make `await fn(...args)` treat it as a thenable and hang. + const result = await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ + example: () => testDollar().Actions.slack.chat, + }), + mockLogger, + 20, + ); + expect(result.data).toBeDefined(); + }); + + test('Should resolve a single-segment $.Actions.foo(...) call to a single-segment fqn', async () => { + const executeAction = jest.fn().mockResolvedValue({ ok: true }); + const result = await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + executeAction, + loadModuleReturning({ + example: () => testDollar().Actions.foo({ inputs: { text: 'hi' } }), + }), + mockLogger, + ); + expect(result).toEqual({ data: { ok: true } }); + expect(executeAction).toHaveBeenCalledWith('com.datadoghq.foo', { text: 'hi' }, undefined); + }); + + test('Should resolve a $.Actions(...) call with no property access to a trailing-dot fqn with no action name segment', async () => { + // Documents current behavior: pathParts is empty at this call site, so `com.datadoghq.${pathParts.join('.')}` yields a malformed fqn rather than being rejected. + const executeAction = jest.fn().mockResolvedValue({ ok: true }); + const result = await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + executeAction, + loadModuleReturning({ + example: () => testDollar().Actions({ inputs: { text: 'hi' } }), + }), + mockLogger, + ); + expect(result).toEqual({ data: { ok: true } }); + expect(executeAction).toHaveBeenCalledWith('com.datadoghq.', { text: 'hi' }, undefined); + }); + + test('Should resolve a deeply nested $.Actions.a.b.c.d(...) call to its full dotted fqn', async () => { + const executeAction = jest.fn().mockResolvedValue({ ok: true }); + const result = await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + executeAction, + loadModuleReturning({ + example: () => testDollar().Actions.a.b.c.d({ inputs: { text: 'hi' } }), + }), + mockLogger, + ); + expect(result).toEqual({ data: { ok: true } }); + expect(executeAction).toHaveBeenCalledWith( + 'com.datadoghq.a.b.c.d', + { text: 'hi' }, + undefined, + ); + }); + + test("Should reject a $.Actions call whose connectionId isn't in the function's allowedConnectionIds", async () => { + const executeAction = jest.fn().mockResolvedValue({ ok: true }); + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + executeAction, + loadModuleReturning({ + example: () => + testDollar().Actions.slack.chat.postMessage({ + inputs: { text: 'hi' }, + connectionId: 'conn-not-allowed', + }), + }), + mockLogger, + ), + ).rejects.toThrow(/not in this function's allowed connections/); + expect(executeAction).not.toHaveBeenCalled(); + }); + + test('Should allow a $.Actions call with no connectionId regardless of allowedConnectionIds', async () => { + const executeAction = jest.fn().mockResolvedValue({ ok: true }); + const result = await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + executeAction, + loadModuleReturning({ + example: () => + testDollar().Actions.slack.chat.postMessage({ + inputs: { text: 'hi' }, + }), + }), + mockLogger, + ); + expect(result).toEqual({ data: { ok: true } }); + }); + + test('Should reject when the action call is missing an inputs field', async () => { + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ + example: () => testDollar().Actions.slack.chat.postMessage({}), + }), + mockLogger, + ), + ).rejects.toThrow(/must have an inputs field/); + }); + + test('Should currently accept an array as inputs without a validation error, since typeof [] === "object"', async () => { + // Documents a known, accepted gap: inputs is semantically a plain object of named parameters, but validateActionCall's `typeof inputs !== 'object'` check also passes an array through unchanged. + const executeAction = jest.fn().mockResolvedValue({ ok: true }); + const result = await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + executeAction, + loadModuleReturning({ + example: () => testDollar().Actions.slack.chat.postMessage({ inputs: ['a', 'b'] }), + }), + mockLogger, + ); + expect(result).toEqual({ data: { ok: true } }); + expect(executeAction).toHaveBeenCalledWith( + 'com.datadoghq.slack.chat.postMessage', + ['a', 'b'], + undefined, + ); + }); + + test('Should reject with the thrown message when the customer function throws synchronously', async () => { + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ + example: () => { + throw new Error('boom'); + }, + }), + mockLogger, + ), + ).rejects.toThrow('boom'); + }); + + // Regression test: the "late failure" log fires only for an execution abandoned after the caller stopped waiting (see the test below) — here the caller is still waiting and gets the error via `rejects.toThrow` above. + test('Should not log a "caller had already stopped waiting" message for an ordinary, timely rejection', async () => { + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ + example: () => { + throw new Error('boom'); + }, + }), + mockLogger, + ), + ).rejects.toThrow('boom'); + + expect(mockLogFn).not.toHaveBeenCalledWith( + expect.stringContaining('already stopped waiting'), + 'debug', + ); + }); + + test('Should reject with the rejection reason when the customer function rejects asynchronously', async () => { + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ example: () => Promise.reject(new Error('async boom')) }), + mockLogger, + ), + ).rejects.toThrow('async boom'); + }); + + test('Should time out a hung async function with an explicit, attributed error', async () => { + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ example: () => new Promise(() => {}) }), + mockLogger, + 50, + ), + ).rejects.toThrow(/timed out after 50ms/); + }); + + // The caller already moved on after the timeout rejection above; this covers the abandoned execution's own eventual failure, which has no caller left to report it to. + test('Should log a late failure from an abandoned execution instead of swallowing it silently', async () => { + let rejectHung: ((error: Error) => void) | undefined; + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ + example: () => + new Promise((_resolve, reject) => { + rejectHung = reject; + }), + }), + mockLogger, + 50, + ), + ).rejects.toThrow(/timed out after 50ms/); + + rejectHung?.(new Error('late failure after caller stopped waiting')); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(mockLogFn).toHaveBeenCalledWith( + expect.stringContaining('late failure after caller stopped waiting'), + 'debug', + ); + }); + + // Asserts $'s exact key set, since a token added inside globalThis.$ wouldn't be caught by the weaker top-level check below. + test('Should never expose an auth token to the customer module — only backendFunctionArgs, Actions, and Source are visible on globalThis.$', async () => { + const result = await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ + example: () => Object.keys(testDollar()).sort(), + }), + mockLogger, + ); + expect(result).toEqual({ data: ['Actions', 'Source', 'backendFunctionArgs'] }); + }); + + test('Should never expose an auth token via globalThis, including nested inside $.Source', async () => { + const result = await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ + example: () => { + // Recurses into $.Source (a plain data object) but not $.Actions (a Proxy dispatch mechanism, not a data container we'd leak a token into). + const containsTokenKey = (value: unknown): boolean => + typeof value === 'object' && + value !== null && + Object.entries(value).some( + ([key, nested]) => + key.toLowerCase().includes('token') || containsTokenKey(nested), + ); + const dollar = testDollar(); + return ( + Object.keys(globalThis).some((k) => k.toLowerCase().includes('token')) || + containsTokenKey(dollar.Source) + ); + }, + }), + mockLogger, + ); + expect(result).toEqual({ data: false }); + }); + + test('Should populate $.Source with a synthetic local-dev identity, reachable via globalThis.$', async () => { + const result = await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ example: () => testDollar().Source }), + mockLogger, + ); + expect(result).toEqual({ + data: { + initiator: { id: 'local-dev', orgId: 'local-dev-org' }, + runAsUser: { id: 'local-dev', orgId: 'local-dev-org' }, + }, + }); + }); + + test("Should give each execution its own $.Source object, so one execution mutating it can't corrupt a later execution's identity", async () => { + const first = await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ + example: () => { + testDollar().Source.initiator.id = 'hacked'; + return 'first done'; + }, + }), + mockLogger, + ); + expect(first).toEqual({ data: 'first done' }); + + const second = await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ example: () => testDollar().Source }), + mockLogger, + ); + expect(second).toEqual({ + data: { + initiator: { id: 'local-dev', orgId: 'local-dev-org' }, + runAsUser: { id: 'local-dev', orgId: 'local-dev-org' }, + }, + }); + }); + + test('Should restore a pre-existing globalThis.$ (e.g. from zx/globals) once the execution completes, not leave the execution context in place permanently', async () => { + const preExisting = { notOurs: true }; + (globalThis as Record).$ = preExisting; + try { + const result = await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ + example: () => testDollar().backendFunctionArgs, + }), + mockLogger, + ); + expect(result).toEqual({ data: [] }); + // Compares via a plain boolean, not .toBe() directly — $.Actions's get trap returns a Proxy for every property, which crashes Jest's diff formatting if this assertion ever fails. + expect(Object.is((globalThis as Record).$, preExisting)).toBe(true); + } finally { + delete (globalThis as Record).$; + } + }); + + test("Should restore a pre-existing globalThis.$ even when the customer function throws, not leave the execution's context behind", async () => { + const preExisting = { notOurs: true }; + (globalThis as Record).$ = preExisting; + try { + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ + example: () => { + throw new Error('customer function failed'); + }, + }), + mockLogger, + ), + ).rejects.toThrow('customer function failed'); + expect(Object.is((globalThis as Record).$, preExisting)).toBe(true); + } finally { + delete (globalThis as Record).$; + } + }); + + test('Should remove globalThis.$ once the execution completes when nothing was previously defined there', async () => { + delete (globalThis as Record).$; + await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ example: () => 'done' }), + mockLogger, + ); + expect(Object.prototype.hasOwnProperty.call(globalThis, '$')).toBe(false); + }); + + describe('action-catalog / apps-backend registration', () => { + test('Should silently skip registration when neither package is installed', async () => { + // Confirms loadModuleReturning's rejection of other specifiers doesn't surface as an execution failure. + const result = await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ example: () => 'fine' }), + mockLogger, + ); + expect(result).toEqual({ data: 'fine' }); + }); + + test('Should propagate a real load failure from an installed action-catalog package, not treat it as absent', async () => { + jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(true); + const loadModule: LoadModule = async (specifier: string) => { + if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { + return { example: () => 'unreachable' }; + } + if (specifier === '@datadog/action-catalog/action-execution') { + // A real transform/evaluation failure, not a module-not-found error — must not be swallowed as "not installed". + throw new Error('Unexpected token in action-catalog/action-execution'); + } + const error: NodeJS.ErrnoException = new Error(`Cannot find module '${specifier}'`); + error.code = 'MODULE_NOT_FOUND'; + throw error; + }; + + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModule, + mockLogger, + ), + ).rejects.toThrow('Unexpected token in action-catalog/action-execution'); + }); + + test('Should route an action-catalog typed-wrapper call through the same injected executeAction', async () => { + jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(true); + const executeAction = jest.fn().mockResolvedValue({ ok: true }); + let registeredImpl: + | ((actionId: string, request: unknown) => Promise) + | undefined; + + const loadModule: LoadModule = async (specifier: string) => { + if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { + return { + example: async () => + registeredImpl?.('com.datadoghq.slack.chat.postMessage', { + inputs: { text: 'hi' }, + connectionId: 'conn-1', + }), + }; + } + if (specifier === '@datadog/action-catalog/action-execution') { + return { + setExecuteActionImplementation: ( + impl: (actionId: string, request: unknown) => Promise, + ) => { + registeredImpl = impl; + }, + }; + } + const error: NodeJS.ErrnoException = new Error(`Cannot find module '${specifier}'`); + error.code = 'MODULE_NOT_FOUND'; + throw error; + }; + + const result = await executeScriptLocally( + funcWithConnection, + TEST_PROJECT_ROOT, + [], + executeAction, + loadModule, + mockLogger, + ); + expect(result).toEqual({ data: { ok: true } }); + expect(executeAction).toHaveBeenCalledWith( + 'com.datadoghq.slack.chat.postMessage', + { text: 'hi' }, + 'conn-1', + ); + }); + + test("Should reject an action-catalog typed-wrapper call whose connectionId isn't in the function's allowedConnectionIds", async () => { + jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(true); + const executeAction = jest.fn().mockResolvedValue({ ok: true }); + let registeredImpl: + | ((actionId: string, request: unknown) => Promise) + | undefined; + + const loadModule: LoadModule = async (specifier: string) => { + if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { + return { + example: async () => + registeredImpl?.('com.datadoghq.slack.chat.postMessage', { + inputs: { text: 'hi' }, + connectionId: 'conn-not-allowed', + }), + }; + } + if (specifier === '@datadog/action-catalog/action-execution') { + return { + setExecuteActionImplementation: ( + impl: (actionId: string, request: unknown) => Promise, + ) => { + registeredImpl = impl; + }, + }; + } + const error: NodeJS.ErrnoException = new Error(`Cannot find module '${specifier}'`); + error.code = 'MODULE_NOT_FOUND'; + throw error; + }; + + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + executeAction, + loadModule, + mockLogger, + ), + ).rejects.toThrow(/not in this function's allowed connections/); + expect(executeAction).not.toHaveBeenCalled(); + }); + + test('Should reject an action-catalog typed-wrapper call missing an inputs field, same as a raw $.Actions call', async () => { + jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(true); + const executeAction = jest.fn().mockResolvedValue({ ok: true }); + let registeredImpl: + | ((actionId: string, request: unknown) => Promise) + | undefined; + + const loadModule: LoadModule = async (specifier: string) => { + if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { + return { + example: async () => + registeredImpl?.('com.datadoghq.slack.chat.postMessage', { + connectionId: 'conn-1', + }), + }; + } + if (specifier === '@datadog/action-catalog/action-execution') { + return { + setExecuteActionImplementation: ( + impl: (actionId: string, request: unknown) => Promise, + ) => { + registeredImpl = impl; + }, + }; + } + const error: NodeJS.ErrnoException = new Error(`Cannot find module '${specifier}'`); + error.code = 'MODULE_NOT_FOUND'; + throw error; + }; + + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + executeAction, + loadModule, + mockLogger, + ), + ).rejects.toThrow(/must have an inputs field/); + expect(executeAction).not.toHaveBeenCalled(); + }); + }); + + describe('serialization of concurrent executions', () => { + function delayedResult(label: T, delayMs: number): () => Promise { + return () => new Promise((resolve) => setTimeout(() => resolve(label), delayMs)); + } + + test("Should allow two independent calls to run without cross-contaminating each other's result", async () => { + const [resultA, resultB] = await Promise.all([ + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ example: delayedResult('A', 20) }), + mockLogger, + ), + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ example: delayedResult('B', 0) }), + mockLogger, + ), + ]); + expect([resultA, resultB]).toEqual([{ data: 'A' }, { data: 'B' }]); + }); + + // Reads $.backendFunctionArgs after a delay, which is what would surface cross-contamination between concurrent calls' globalThis.$. + function readOwnArgsAfterDelay(delayMs: number): () => Promise { + return () => + new Promise((resolve) => + setTimeout(() => resolve(testDollar().backendFunctionArgs), delayMs), + ); + } + + // Known race: two concurrent calls both write globalThis.$ synchronously, so the second write wins for both — skip until calls are serialized through an execution queue. + test.skip("Should let each concurrent call see its OWN backendFunctionArgs via globalThis.$, not the other call's", async () => { + const [resultA, resultB] = await Promise.all([ + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + ['A-arg'], + stubExecuteAction, + loadModuleReturning({ example: readOwnArgsAfterDelay(20) }), + mockLogger, + ), + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + ['B-arg'], + stubExecuteAction, + loadModuleReturning({ example: readOwnArgsAfterDelay(0) }), + mockLogger, + ), + ]); + expect(resultA).toEqual({ data: ['A-arg'] }); + expect(resultB).toEqual({ data: ['B-arg'] }); + }); + }); +}); diff --git a/packages/plugins/apps/src/vite/local-execution.ts b/packages/plugins/apps/src/vite/local-execution.ts new file mode 100644 index 000000000..4e315b08e --- /dev/null +++ b/packages/plugins/apps/src/vite/local-execution.ts @@ -0,0 +1,251 @@ +// 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 Proxy, globalThis */ + +/** Executes a backend function's file directly in-process inside the Vite dev server, mirroring executeScriptViaDatadog's `BackendOutputs` contract in dev-server.ts as a drop-in alternate implementation. */ + +import type { Logger } from '@dd/core/types'; + +import { isActionCatalogInstalled, isDatadogAppsBackendInstalled } from '../backend/shared'; +import type { BackendFunction, BackendOutputs } from '../backend/types'; +import { LOCAL_EXECUTION_LOAD_SUFFIX } from '../constants'; + +interface ActionCallArgs { + inputs: Record; + connectionId?: string; +} + +/** Narrows an unknown value enough to read named properties off it by key. */ +function isIndexableRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +/** `globalThis.$` is a runtime-only property `typeof globalThis` doesn't know about; `Reflect.get` reads it without a type assertion, like `deleteGlobalDollar` does for deletion. */ +function getGlobalDollar(): unknown { + return Reflect.get(globalThis, '$'); +} + +/** `Object.assign`'s signature doesn't require its source object's keys to already exist on the target, so this installs `$` without asserting `globalThis`'s type. */ +function setGlobalDollar(value: unknown): void { + Object.assign(globalThis, { $: value }); +} + +function deleteGlobalDollar(): void { + Reflect.deleteProperty(globalThis, '$'); +} + +const DEFAULT_TIMEOUT_MS = 10_000; + +/** Loads a module by specifier, resolved against the customer's own project rather than build-plugins' dependency tree — the dev server passes its Vite instance's `ssrLoadModule` here. */ +export type LoadModule = (specifier: string) => Promise>; + +/** Executes a real `$.Actions.foo.bar(...)` call; the dev server supplies the implementation using its own auth, so this module never holds or sees a credential itself. */ +export type ExecuteAction = ( + fqn: string, + inputs: unknown, + connectionId: string | undefined, +) => Promise; + +/** Synthetic local-dev identity for `$.Source` — a fresh object per call, since customer code could otherwise mutate a shared singleton and corrupt every later execution's identity. */ +function makeLocalDevSource() { + return { + initiator: { id: 'local-dev', orgId: 'local-dev-org' }, + runAsUser: { id: 'local-dev', orgId: 'local-dev-org' }, + }; +} + +/** Mirrors the cloud path's server-side allowedConnectionIds restriction, so local dev enforces the same connection scoping as production. */ +function assertConnectionIdAllowed( + connectionId: string | undefined, + allowedConnectionIds: string[], + actionDescription: string, +): void { + if (connectionId !== undefined && !allowedConnectionIds.includes(connectionId)) { + throw new Error( + `Action ${actionDescription} used connection "${connectionId}", which is not in this function's allowed connections: [${allowedConnectionIds.join(', ')}]`, + ); + } +} + +/** Shared validation for both $.Actions entry points (raw proxy and action-catalog typed wrapper) — extracted so a contract change can't be applied to one path and missed on the other. */ +function validateActionCall( + call: Partial, + allowedConnectionIds: string[], + actionDescription: string, +): { inputs: Record; connectionId: string | undefined } { + const { inputs, connectionId } = call; + if (typeof inputs !== 'object' || !inputs) { + throw new Error(`Action ${actionDescription} must have an inputs field`); + } + assertConnectionIdAllowed(connectionId, allowedConnectionIds, actionDescription); + return { inputs, connectionId }; +} + +/** Resolves a nested property path (e.g. $.Actions.slack.chat.postMessage) to a callable that invokes `executeAction` directly — no IPC needed since there's no separate process to cross. */ +function makeActionsProxy( + executeAction: ExecuteAction, + allowedConnectionIds: string[], + pathParts: string[] = [], +): unknown { + return new Proxy(function () {}, { + get(_target, prop) { + // An un-invoked reference (e.g. $.Actions.foo.bar with no call) must not look like a thenable, or Promise's resolution protocol calls .then() on it and hangs until timeout. + if (prop === 'then') { + return undefined; + } + const nestedPathParts = pathParts.concat(String(prop)); + return makeActionsProxy(executeAction, allowedConnectionIds, nestedPathParts); + }, + async apply(_target, _thisArg, args: unknown[]) { + if (args.length === 0) { + throw new Error(`No arguments provided to action $.Actions.${pathParts.join('.')}`); + } + const call: Partial = isIndexableRecord(args[0]) ? args[0] : {}; + const { inputs, connectionId } = validateActionCall( + call, + allowedConnectionIds, + `$.Actions.${pathParts.join('.')}`, + ); + const fqn = `com.datadoghq.${pathParts.join('.')}`; + return executeAction(fqn, inputs, connectionId); + }, + }); +} + +/** No-ops if @datadog/action-catalog isn't installed; checks `isActionCatalogInstalled` up front rather than catching a load failure, since `loadModule` doesn't guarantee an error code for a missing bare specifier. */ +async function registerActionCatalogIfInstalled( + loadModule: LoadModule, + projectRoot: string, + executeAction: ExecuteAction, + allowedConnectionIds: string[], +): Promise { + if (!isActionCatalogInstalled(projectRoot)) { + return; + } + const mod = await loadModule('@datadog/action-catalog/action-execution'); + const setExecuteActionImplementation = mod.setExecuteActionImplementation; + if (typeof setExecuteActionImplementation !== 'function') { + return; + } + setExecuteActionImplementation(async (actionId: string, request: unknown) => { + const call: Partial = isIndexableRecord(request) ? request : {}; + const { inputs, connectionId } = validateActionCall( + call, + allowedConnectionIds, + `"${actionId}"`, + ); + return executeAction(actionId, inputs, connectionId); + }); +} + +/** No-ops if @datadog/apps-backend isn't installed; see `registerActionCatalogIfInstalled` for why this checks installedness up front rather than catching a load failure. */ +async function registerBackendRuntimeIfInstalled( + loadModule: LoadModule, + projectRoot: string, + $: unknown, +): Promise { + if (!isDatadogAppsBackendInstalled(projectRoot)) { + return; + } + const [jsFunctionWithActionsModule, runtimeModule] = await Promise.all([ + loadModule('@datadog/apps-backend/runtime/jsFunctionWithActions'), + loadModule('@datadog/apps-backend/runtime'), + ]); + const buildRuntimeFromJsFunctionWithActions = + jsFunctionWithActionsModule.buildRuntimeFromJsFunctionWithActions; + const setBackend = runtimeModule.setBackend; + if ( + typeof buildRuntimeFromJsFunctionWithActions !== 'function' || + typeof setBackend !== 'function' + ) { + return; + } + const backendRuntime = buildRuntimeFromJsFunctionWithActions($); + setBackend(backendRuntime); +} + +/** `globalThis.$` and the registrations above provide the same customer-visible bindings production's generated wrapper module sets up via text injection. */ +export async function executeScriptLocally( + func: BackendFunction, + projectRoot: string, + args: unknown[], + executeAction: ExecuteAction, + loadModule: LoadModule, + log: Logger, + timeoutMs: number = DEFAULT_TIMEOUT_MS, +): Promise { + // Never log the args themselves — they may carry secrets/PII, matching dev-server.ts's cloud path. + log.debug(`Executing "${func.name}" in-process with args`); + + const $ = { + backendFunctionArgs: args, + Actions: makeActionsProxy(executeAction, func.allowedConnectionIds), + Source: makeLocalDevSource(), + }; + + const run = async (): Promise => { + // Loads the customer module before installing $ and the SDK bridges, matching production's import order (backend/virtual-entry.ts) — code reaching for $ during top-level evaluation fails the same way locally as in Datadog, instead of succeeding early. + const mod = await loadModule(func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX); + const fn = mod[func.name]; + if (typeof fn !== 'function') { + throw new Error(`"${func.name}" is not a function exported from ${func.absolutePath}`); + } + + // Restores whatever globalThis.$ held before this call (or removes it) once the execution settles, so a pre-existing global (e.g. zx/globals) isn't clobbered and this execution's context isn't left reachable afterward. + const hadPreviousDollar = Object.prototype.hasOwnProperty.call(globalThis, '$'); + const previousDollar = getGlobalDollar(); + setGlobalDollar($); + try { + const actionCatalogRegistration = registerActionCatalogIfInstalled( + loadModule, + projectRoot, + executeAction, + func.allowedConnectionIds, + ); + const backendRuntimeRegistration = registerBackendRuntimeIfInstalled( + loadModule, + projectRoot, + $, + ); + await Promise.all([actionCatalogRegistration, backendRuntimeRegistration]); + + const result = await fn(...args); + return { data: result }; + } finally { + if (hadPreviousDollar) { + setGlobalDollar(previousDollar); + } else { + deleteGlobalDollar(); + } + } + }; + + let timer: ReturnType | undefined; + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout(() => { + reject(new Error(`Local execution of "${func.name}" timed out after ${timeoutMs}ms`)); + }, timeoutMs); + }); + + // Racing the timeout only stops the caller from waiting — run() keeps executing afterward, so a resumed customer function can still fire real $.Actions side effects; true cancellation would need a Worker thread, not possible in-process. + const runPromise = run(); + // Set once the race settles, so the handler below can tell an abandoned rejection (caller already gone) from an ordinary one the caller is about to receive normally. + let raceSettled = false; + // Nothing awaits runPromise once the timeout wins the race, so a later rejection would otherwise crash the dev server as unhandled — logged instead so a slow real failure stays diagnosable. + runPromise.catch((error: unknown) => { + if (!raceSettled) { + return; + } + const message = error instanceof Error ? error.message : String(error); + log.debug(`"${func.name}" failed after its caller had already stopped waiting: ${message}`); + }); + + try { + return await Promise.race([runPromise, timeout]); + } finally { + raceSettled = true; + clearTimeout(timer); + } +}