From 7b7405364ae332907a9fc38a555014b797b0172d Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Wed, 5 Aug 2026 01:37:01 -0700 Subject: [PATCH] feat(apps): in-process local execution for backend functions Executes a backend function by importing its real *.backend.ts file directly (via an injected loadModule), inside the Vite dev server's own process -- no bundling, no forked child process. The dev server is already the isolation boundary from production, so a crash or hang here only affects the developer's own dev server; process-level isolation is deliberately not added preemptively. $.Actions calls resolve through a Proxy (ported from the render.ts $.Actions logic) that invokes an injected executeAction function directly -- no IPC needed, since there's no separate process to cross. The same executeAction backs an action-catalog typed-wrapper registration and an apps-backend runtime-context registration, both loaded through the same loadModule (so they resolve against the customer's own project, not build-plugins' dependency tree) mirroring what the removed generated wrapper module used to do textually. The remote call itself is still a stub pending the single-action execution endpoint. The $ context passed to the customer's module -- exposed via globalThis.$, since the customer's own function takes its own real arguments rather than a $ parameter -- carries only backendFunctionArgs, Actions, and Source, verified by test, so that once a real auth token is wired in for real action execution, it can live in a module-private closure the customer's imported code has no way to reach. --- .../apps/src/vite/local-execution.test.ts | 273 ++++++++++++++++++ .../plugins/apps/src/vite/local-execution.ts | 220 ++++++++++++++ 2 files changed, 493 insertions(+) create mode 100644 packages/plugins/apps/src/vite/local-execution.test.ts create mode 100644 packages/plugins/apps/src/vite/local-execution.ts 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..c04f34184 --- /dev/null +++ b/packages/plugins/apps/src/vite/local-execution.test.ts @@ -0,0 +1,273 @@ +// 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 { mockLogger } from '@dd/tests/_jest/helpers/mocks'; + +import type { BackendFunction } from '../backend/types'; + +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 stubExecuteAction: ExecuteAction = async (fqn) => ({ data: null, stub: true, fqn }); + +/** + * A `loadModule` double that resolves the customer's own function from a map + * and rejects anything else (e.g. the action-catalog/apps-backend probes), + * matching the common case where neither package is installed. + */ +function loadModuleReturning(exports: Record): LoadModule { + return async (specifier: string) => { + if (specifier === func.absolutePath) { + return exports; + } + throw new Error(`Cannot find module '${specifier}'`); + }; +} + +describe('local-execution — executeScriptLocally', () => { + test('Should run a simple function in-process and return its result', async () => { + const result = await executeScriptLocally( + func, + [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, + [], + stubExecuteAction, + loadModuleReturning({ example: () => 1 }), + mockLogger, + ); + const second = await executeScriptLocally( + func, + [], + 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, + [], + stubExecuteAction, + loadModuleReturning({ somethingElse: () => 1 }), + mockLogger, + ), + ).rejects.toThrow(`"example" is not a function exported from ${func.absolutePath}`); + }); + + 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( + func, + [], + executeAction, + loadModuleReturning({ + example: () => + (globalThis as Record).$.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 reject when the action call is missing an inputs field', async () => { + await expect( + executeScriptLocally( + func, + [], + stubExecuteAction, + loadModuleReturning({ + example: () => + (globalThis as Record).$.Actions.slack.chat.postMessage({}), + }), + mockLogger, + ), + ).rejects.toThrow(/must have an inputs field/); + }); + + test('Should reject with the thrown message when the customer function throws synchronously', async () => { + await expect( + executeScriptLocally( + func, + [], + stubExecuteAction, + loadModuleReturning({ + example: () => { + throw new Error('boom'); + }, + }), + mockLogger, + ), + ).rejects.toThrow('boom'); + }); + + test('Should reject with the rejection reason when the customer function rejects asynchronously', async () => { + await expect( + executeScriptLocally( + func, + [], + 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, + [], + stubExecuteAction, + loadModuleReturning({ example: () => new Promise(() => {}) }), + mockLogger, + 50, + ), + ).rejects.toThrow(/timed out after 50ms/); + }); + + test('Should never expose an auth token via globalThis', async () => { + const result = await executeScriptLocally( + func, + [], + stubExecuteAction, + loadModuleReturning({ + example: () => + Object.keys(globalThis).some((k) => k.toLowerCase().includes('token')), + }), + 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, + [], + stubExecuteAction, + loadModuleReturning({ example: () => (globalThis as Record).$.Source }), + mockLogger, + ); + expect(result).toEqual({ + data: { + initiator: { id: 'local-dev', orgId: 'local-dev-org' }, + runAsUser: { id: 'local-dev', orgId: 'local-dev-org' }, + }, + }); + }); + + describe('action-catalog / apps-backend registration', () => { + test('Should silently skip registration when neither package is installed', async () => { + // loadModuleReturning already rejects every specifier other than + // func.absolutePath — this just confirms that doesn't surface as + // an execution failure. + const result = await executeScriptLocally( + func, + [], + stubExecuteAction, + loadModuleReturning({ example: () => 'fine' }), + mockLogger, + ); + expect(result).toEqual({ data: 'fine' }); + }); + + test('Should route an action-catalog typed-wrapper call through the same injected executeAction', async () => { + 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) { + 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; + }, + }; + } + throw new Error(`Cannot find module '${specifier}'`); + }; + + const result = await executeScriptLocally( + func, + [], + executeAction, + loadModule, + mockLogger, + ); + expect(result).toEqual({ data: { ok: true } }); + expect(executeAction).toHaveBeenCalledWith( + 'com.datadoghq.slack.chat.postMessage', + { text: 'hi' }, + 'conn-1', + ); + }); + }); + + 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, + [], + stubExecuteAction, + loadModuleReturning({ example: delayedResult('A', 20) }), + mockLogger, + ), + executeScriptLocally( + func, + [], + stubExecuteAction, + loadModuleReturning({ example: delayedResult('B', 0) }), + mockLogger, + ), + ]); + expect([resultA, resultB]).toEqual([{ data: 'A' }, { data: 'B' }]); + }); + }); +}); 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..eb978d44a --- /dev/null +++ b/packages/plugins/apps/src/vite/local-execution.ts @@ -0,0 +1,220 @@ +// 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 */ + +/** + * Local Node execution for backend functions. The customer's real + * `*.backend.ts` file is imported directly via `loadModule` — no bundling, + * no wrapper module — and its exported function is called with its own real + * arguments, inside this process (the Vite dev server's own process). The + * dev server is already the isolation boundary from production, so a crash + * or hang here only affects the developer's own dev server. + * + * Parallel structure to executeScriptViaDatadog in dev-server.ts: same + * BackendOutputs return shape, so it's a drop-in alternate implementation + * behind the same contract, not a protocol change. + */ + +import type { Logger } from '@dd/core/types'; + +import type { BackendFunction } from '../backend/types'; + +type BackendOutputs = { data: unknown }; + +interface ActionCallArgs { + inputs: Record; + connectionId?: string; +} + +const DEFAULT_TIMEOUT_MS = 10_000; + +/** + * Loads a module by specifier — either the customer's own backend-function + * file (an absolute path) or a bare npm specifier (e.g. + * `@datadog/action-catalog/action-execution`). Either way this must resolve + * against the customer's own project, not build-plugins' own dependency + * tree — build-plugins doesn't (and shouldn't) depend on either package + * itself. The dev server passes its Vite instance's own `ssrLoadModule` here, + * which resolves and transforms against the customer's project exactly like + * a real bundle would, with an HMR-aware module cache so edited files are + * re-transformed automatically — no content-addressing trick needed. + */ +export type LoadModule = (specifier: string) => Promise>; + +/** + * Makes a real `$.Actions.foo.bar(...)` call (and, transitively, any + * @datadog/action-catalog typed-wrapper call — see + * `registerActionCatalogIfInstalled`) happen. The dev server supplies the + * real implementation (a single-action `preview-async` call, using its own + * auth) — this module never holds or sees a credential itself, only this + * function signature. + */ +export type ExecuteAction = ( + fqn: string, + inputs: unknown, + connectionId: string | undefined, +) => Promise; + +/** + * Fake local-dev identity for `$.Source`. No real user session exists on a + * developer's own machine, so this is a fixed, synthetic value — not derived + * from any real auth. It's enough for `@datadog/apps-backend`'s + * `getInitiatingUser()`/`getExecutionUser()` (and raw `$.Source.initiator` + * access) to resolve instead of throwing, which is all local dev needs from + * it today. + */ +const LOCAL_DEV_SOURCE = { + initiator: { id: 'local-dev', orgId: 'local-dev-org' }, + runAsUser: { id: 'local-dev', orgId: 'local-dev-org' }, +}; + +/** + * Build the $.Actions Proxy. Resolves any nested property path (e.g. + * $.Actions.slack.chat.postMessage) to a callable that invokes + * `executeAction` directly, in-process — no IPC serialization needed, since + * there's no separate process to cross. + */ +function makeActionsProxy(executeAction: ExecuteAction, pathParts: string[] = []): unknown { + return new Proxy(function () {}, { + get(_target, prop) { + return makeActionsProxy(executeAction, pathParts.concat(String(prop))); + }, + apply(_target, _thisArg, args: unknown[]) { + if (args.length === 0) { + return Promise.reject( + new Error(`No arguments provided to action $.Actions.${pathParts.join('.')}`), + ); + } + const { inputs, connectionId } = (args[0] ?? {}) as Partial; + if (typeof inputs !== 'object' || !inputs) { + return Promise.reject( + new Error( + `First argument to action $.Actions.${pathParts.join('.')} must have an inputs field`, + ), + ); + } + const fqn = `com.datadoghq.${pathParts.join('.')}`; + return executeAction(fqn, inputs, connectionId); + }, + }); +} + +/** + * Mirrors production's SET_EXECUTE_ACTION_SNIPPET (previously injected as + * text into a generated wrapper module): registers action-catalog's + * module-level executeAction implementation, so a customer's typed-wrapper + * import (e.g. `import { request } from '@datadog/action-catalog/http/http'`) + * calls the same `executeAction` a raw `$.Actions.*` call would. A silent + * no-op if @datadog/action-catalog isn't installed in the customer's app, + * matching production's own isActionCatalogInstalled gate. + */ +async function registerActionCatalogIfInstalled( + loadModule: LoadModule, + executeAction: ExecuteAction, +): Promise { + let mod: Record; + try { + mod = await loadModule('@datadog/action-catalog/action-execution'); + } catch { + return; + } + const setExecuteActionImplementation = mod.setExecuteActionImplementation; + if (typeof setExecuteActionImplementation !== 'function') { + return; + } + setExecuteActionImplementation(async (actionId: string, request: unknown) => { + const { inputs, connectionId } = (request ?? {}) as Partial; + return executeAction(actionId, inputs, connectionId); + }); +} + +/** + * Mirrors production's SET_BACKEND_CONTEXT_SNIPPET (previously injected as + * text into a generated wrapper module): registers @datadog/apps-backend's + * runtime context so a customer's typed accessor calls (e.g. + * getInitiatingUser()) resolve against `$.Source` instead of throwing. A + * silent no-op if @datadog/apps-backend isn't installed, matching + * production's own isDatadogAppsBackendInstalled gate. + */ +async function registerBackendRuntimeIfInstalled( + loadModule: LoadModule, + $: unknown, +): Promise { + let jsFunctionWithActionsModule: Record; + let runtimeModule: Record; + try { + [jsFunctionWithActionsModule, runtimeModule] = await Promise.all([ + loadModule('@datadog/apps-backend/runtime/jsFunctionWithActions'), + loadModule('@datadog/apps-backend/runtime'), + ]); + } catch { + return; + } + const buildRuntimeFromJsFunctionWithActions = + jsFunctionWithActionsModule.buildRuntimeFromJsFunctionWithActions; + const setBackend = runtimeModule.setBackend; + if ( + typeof buildRuntimeFromJsFunctionWithActions !== 'function' || + typeof setBackend !== 'function' + ) { + return; + } + setBackend(buildRuntimeFromJsFunctionWithActions($)); +} + +/** + * Execute a backend function in-process by importing its real file directly + * — no bundling, no generated wrapper module. `globalThis.$` and the + * action-catalog/apps-backend registrations above stand in for what the + * removed `main($)` wrapper used to do textually; everything else about the + * call is just invoking the customer's exported function with its own real + * arguments. + */ +export async function executeScriptLocally( + func: BackendFunction, + args: unknown[], + executeAction: ExecuteAction, + loadModule: LoadModule, + log: Logger, + timeoutMs: number = DEFAULT_TIMEOUT_MS, +): Promise { + log.debug(`Executing "${func.name}" in-process with args=${JSON.stringify(args)}`); + + const $ = { + backendFunctionArgs: args, + Actions: makeActionsProxy(executeAction), + Source: LOCAL_DEV_SOURCE, + }; + + const run = async (): Promise => { + (globalThis as Record).$ = $; + await Promise.all([ + registerActionCatalogIfInstalled(loadModule, executeAction), + registerBackendRuntimeIfInstalled(loadModule, $), + ]); + + const mod = await loadModule(func.absolutePath); + const fn = mod[func.name]; + if (typeof fn !== 'function') { + throw new Error(`"${func.name}" is not a function exported from ${func.absolutePath}`); + } + + const result = await fn(...args); + return { data: result }; + }; + + 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); + }); + + try { + return await Promise.race([run(), timeout]); + } finally { + clearTimeout(timer); + } +}