From ae53df1cae7d2c17612c92b2e20ad3ec5d570b67 Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Fri, 7 Aug 2026 01:26:53 -0700 Subject: [PATCH 1/3] feat(apps): wire local execution into the real dev server Wires the new direct-import local-execution path (local-execution.ts) into the real Vite dev server: threads server.ssrLoadModule through as the loadModule dependency, drops the bundling step from /__dd/executeAction entirely (debugBundle and executeActionViaCloud still bundle, unchanged), and forwards connectionId end-to-end through makeExecuteActionRemotely so a $.Actions call naming a specific connection actually reaches it instead of being silently dropped. Also forces @datadog/apps-backend and @datadog/action-catalog through Vite's SSR transform pipeline (ssr.noExternal) rather than letting the dev server's default node_modules externalization `require()` them directly -- both ship ESM-only, so an externalized `require()` throws "Cannot use import statement outside a module". --- .../src/vite/dev-server.integration.test.ts | 130 +++++++++ .../plugins/apps/src/vite/dev-server.test.ts | 253 +++++++++++++++++- packages/plugins/apps/src/vite/dev-server.ts | 218 ++++++++++++--- packages/plugins/apps/src/vite/index.test.ts | 17 ++ packages/plugins/apps/src/vite/index.ts | 17 ++ 5 files changed, 584 insertions(+), 51 deletions(-) create mode 100644 packages/plugins/apps/src/vite/dev-server.integration.test.ts diff --git a/packages/plugins/apps/src/vite/dev-server.integration.test.ts b/packages/plugins/apps/src/vite/dev-server.integration.test.ts new file mode 100644 index 000000000..c30dd94d8 --- /dev/null +++ b/packages/plugins/apps/src/vite/dev-server.integration.test.ts @@ -0,0 +1,130 @@ +// 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. + +/** + * Real end-to-end coverage for the local-execution path: no mocked + * `viteBuild`/`loadModule`, no hand-written stand-in module. This spins up + * a real Vite dev server (`createServer`, middleware mode — no port bound) + * rooted at the same `apps_backend_project` fixture `backend/integration.test.ts` + * uses, and lets its real `ssrLoadModule` import a real `.backend.ts` file + * directly and execute it via the real `/__dd/executeAction` HTTP handler — + * exactly the resolution path `vite/index.ts`'s `configureServer` wires up + * in production, including resolving `@datadog/apps-backend` from the + * fixture's own project root rather than build-plugins' own dependency tree. + * + * Uses `@datadog/apps-backend` (the fixture already has it as a real, + * locally-resolvable dependency — see `packages/tests/src/_jest/fixtures/ + * node_modules/@datadog/apps-backend`) rather than `@datadog/action-catalog` + * (no equivalent local fixture package exists yet for it). + * `local-execution.test.ts` already separately proves a raw + * `$.Actions.foo.bar(...)` call and an action-catalog typed-wrapper call — + * which reduce to the same injected `executeAction` under the hood — route + * correctly. Building a real local `@datadog/action-catalog` fixture package + * is a reasonable, cheap follow-up, not required for this coverage to be + * meaningful. + */ + +import { createDevServerMiddleware } from '@dd/apps-plugin/vite/dev-server'; +import { getMockLogger } from '@dd/tests/_jest/helpers/mocks'; +import { EventEmitter } from 'events'; +import type { IncomingMessage, ServerResponse } from 'http'; +import path from 'path'; +import { build, createServer, type ViteDevServer } from 'vite'; + +import { encodeQueryName } from '../backend/encodeQueryName'; +import type { BackendFunction } from '../backend/types'; + +const FIXTURE_ROOT = path.resolve( + __dirname, + '../../../../tests/src/_jest/fixtures/apps_backend_project', +); + +const getRuntimeUsersFunc: BackendFunction = { + relativePath: 'getRuntimeUsers', + name: 'getRuntimeUsers', + absolutePath: path.join(FIXTURE_ROOT, 'getRuntimeUsers.backend.ts'), + allowedConnectionIds: [], +}; + +function createMockRequest(url: string, body: Record): IncomingMessage { + const req = new EventEmitter() as unknown as IncomingMessage; + req.method = 'POST'; + req.url = url; + process.nextTick(() => { + (req as unknown as EventEmitter).emit('data', Buffer.from(JSON.stringify(body))); + (req as unknown as EventEmitter).emit('end'); + }); + return req; +} + +function createMockResponse() { + let body = ''; + let resolveDone: () => void; + const done = new Promise((resolve) => { + resolveDone = resolve; + }); + const res = { + statusCode: 200, + setHeader: jest.fn(), + end: jest.fn((data: string) => { + body = data || ''; + resolveDone(); + }), + getBody() { + return body; + }, + done, + }; + return res as typeof res & ServerResponse; +} + +describe('Dev Server Middleware — real end-to-end local execution', () => { + let server: ViteDevServer; + + beforeAll(async () => { + server = await createServer({ + configFile: false, + root: FIXTURE_ROOT, + logLevel: 'silent', + server: { middlewareMode: true, hmr: false }, + ssr: { noExternal: true }, + }); + }); + + afterAll(async () => { + await server.close(); + }); + + test('Should import a real backend function directly via the real Vite dev server and execute it locally, with a real @datadog/apps-backend typed import resolving $.Source correctly', async () => { + const middleware = createDevServerMiddleware( + build, + server.ssrLoadModule.bind(server), + () => [getRuntimeUsersFunc], + { site: 'datadoghq.com' }, + undefined, // no auth configured — this function never calls $.Actions + FIXTURE_ROOT, + getMockLogger(), + ); + + const req = createMockRequest('/__dd/executeAction', { + functionName: encodeQueryName(getRuntimeUsersFunc), + args: ['e2e-test'], + }); + const res = createMockResponse(); + + middleware(req, res, jest.fn()); + await res.done; + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.getBody()); + expect(body.success).toBe(true); + expect(body.result).toEqual({ + data: { + label: 'e2e-test', + executionUser: { id: 'local-dev', orgId: 'local-dev-org' }, + initiatingUser: { id: 'local-dev', orgId: 'local-dev-org' }, + }, + }); + }, 30000); +}); diff --git a/packages/plugins/apps/src/vite/dev-server.test.ts b/packages/plugins/apps/src/vite/dev-server.test.ts index 263df3e89..63a226001 100644 --- a/packages/plugins/apps/src/vite/dev-server.test.ts +++ b/packages/plugins/apps/src/vite/dev-server.test.ts @@ -2,6 +2,8 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. +/* global globalThis */ + import { getAuthenticatedRequest } from '@dd/apps-plugin/auth'; import { createDevServerMiddleware } from '@dd/apps-plugin/vite/dev-server'; import type { AuthOptionsWithDefaults } from '@dd/core/types'; @@ -28,6 +30,13 @@ jest.mock('@dd/core/helpers/oauth-request', () => ({ const mockViteBuild = jest.fn(); +/** + * Stands in for the real `server.ssrLoadModule` — the local executeAction + * path no longer bundles, so tests exercising it configure this directly + * instead of `mockBuildWithParsedBackend`. + */ +const mockLoadModule = jest.fn(); + const DD_API_ORIGIN = 'https://api.datadoghq.com'; const mockFunctions: BackendFunction[] = [ @@ -147,10 +156,26 @@ function mockBuildWithParsedBackend(code = '// code') { }); } +/** + * Configures `mockLoadModule` to resolve `func`'s absolute path to a module + * exporting a single named function, matching what the real `ssrLoadModule` + * returns for a real backend-function file. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function mockLoadModuleReturning(func: BackendFunction, fn: (...args: any[]) => unknown) { + mockLoadModule.mockImplementation(async (specifier: string) => { + if (specifier === func.absolutePath) { + return { [func.name]: fn }; + } + throw new Error(`Cannot find module '${specifier}'`); + }); +} + describe('Dev Server Middleware', () => { beforeEach(() => { jest.clearAllMocks(); mockViteBuild.mockReset(); + mockLoadModule.mockReset(); }); afterEach(() => { @@ -160,6 +185,7 @@ describe('Dev Server Middleware', () => { describe('createDevServerMiddleware routing', () => { const middleware = createDevServerMiddleware( mockViteBuild, + mockLoadModule, () => mockFunctions, mockAuth, getApiKeyRequest(), @@ -208,7 +234,28 @@ describe('Dev Server Middleware', () => { expect(res.end).toHaveBeenCalled(); }); - test('Should handle /__dd/executeAction POST', async () => { + test('Should handle /__dd/executeAction POST by running the function directly, no bundling, no network call', async () => { + mockLoadModuleReturning(mockFunctions[0], (arg) => arg); + + const req = createMockRequest('/__dd/executeAction', { + functionName: encodeQueryName(mockFunctions[0]), + args: ['world'], + }); + const res = createMockResponse(); + const next = jest.fn(); + + middleware(req, res, next); + expect(next).not.toHaveBeenCalled(); + + await res.done; + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.getBody()); + expect(body.success).toBe(true); + expect(body.result).toEqual({ data: 'world' }); + }); + + test('Should handle /__dd/executeActionViaCloud POST', async () => { mockBuildWithParsedBackend(); // Mock the Datadog API via nock. @@ -225,7 +272,7 @@ describe('Dev Server Middleware', () => { }, }); - const req = createMockRequest('/__dd/executeAction', { + const req = createMockRequest('/__dd/executeActionViaCloud', { functionName: encodeQueryName(mockFunctions[0]), args: ['world'], }); @@ -248,6 +295,7 @@ describe('Dev Server Middleware', () => { describe('debugBundle handler', () => { const middleware = createDevServerMiddleware( mockViteBuild, + mockLoadModule, () => mockFunctions, mockAuth, getApiKeyRequest(), @@ -321,9 +369,10 @@ describe('Dev Server Middleware', () => { }); }); - describe('executeAction handler', () => { + describe('executeActionViaCloud handler', () => { const middleware = createDevServerMiddleware( mockViteBuild, + mockLoadModule, () => mockFunctions, mockAuth, getApiKeyRequest(), @@ -332,7 +381,7 @@ describe('Dev Server Middleware', () => { ); test('Should return 400 for missing functionRef', async () => { - const req = createMockRequest('/__dd/executeAction', {}); + const req = createMockRequest('/__dd/executeActionViaCloud', {}); const res = createMockResponse(); middleware(req, res, jest.fn()); @@ -342,7 +391,7 @@ describe('Dev Server Middleware', () => { }); test('Should return 404 for unknown function', async () => { - const req = createMockRequest('/__dd/executeAction', { + const req = createMockRequest('/__dd/executeActionViaCloud', { functionName: 'nonexistent.nonexistent', }); const res = createMockResponse(); @@ -369,7 +418,7 @@ describe('Dev Server Middleware', () => { .post('/api/v2/app-builder/queries/preview-async') .reply(403, 'Forbidden'); - const req = createMockRequest('/__dd/executeAction', { + const req = createMockRequest('/__dd/executeActionViaCloud', { functionName: encodeQueryName(mockFunctions[0]), args: [], }); @@ -421,7 +470,7 @@ describe('Dev Server Middleware', () => { data: { attributes: { done: true, outputs: { data: { value: 42 } } } }, }); - const req = createMockRequest('/__dd/executeAction', { + const req = createMockRequest('/__dd/executeActionViaCloud', { functionName: encodeQueryName(mockFunctions[0]), args: ['hello', 42], }); @@ -449,6 +498,7 @@ describe('Dev Server Middleware', () => { const oauthMiddleware = createDevServerMiddleware( mockViteBuild, + mockLoadModule, () => mockFunctions, mockOauthOnlyAuth, getOAuthRequest(), @@ -469,7 +519,7 @@ describe('Dev Server Middleware', () => { data: { attributes: { done: true, outputs: { data: { ok: true } } } }, }); - const req = createMockRequest('/__dd/executeAction', { + const req = createMockRequest('/__dd/executeActionViaCloud', { functionName: encodeQueryName(mockFunctions[0]), args: [], }); @@ -488,6 +538,7 @@ describe('Dev Server Middleware', () => { test('Should return 400 with auth guidance when explicit API-key auth is missing keys', async () => { const noKeyMiddleware = createDevServerMiddleware( mockViteBuild, + mockLoadModule, () => mockFunctions, mockOauthOnlyAuth, undefined, @@ -495,7 +546,7 @@ describe('Dev Server Middleware', () => { mockLog, ); - const req = createMockRequest('/__dd/executeAction', { + const req = createMockRequest('/__dd/executeActionViaCloud', { functionName: encodeQueryName(mockFunctions[0]), args: [], }); @@ -546,7 +597,7 @@ describe('Dev Server Middleware', () => { }); const trickyArgs = ["don't break", "'); alert(1); //", '😀']; - const req = createMockRequest('/__dd/executeAction', { + const req = createMockRequest('/__dd/executeActionViaCloud', { functionName: encodeQueryName(mockFunctions[0]), args: trickyArgs, }); @@ -575,6 +626,7 @@ describe('Dev Server Middleware', () => { ]; const middlewareWithAllowlist = createDevServerMiddleware( mockViteBuild, + mockLoadModule, () => functionsWithAllowlist, mockAuth, getApiKeyRequest(), @@ -605,7 +657,7 @@ describe('Dev Server Middleware', () => { data: { attributes: { done: true, outputs: { data: { ok: true } } } }, }); - const req = createMockRequest('/__dd/executeAction', { + const req = createMockRequest('/__dd/executeActionViaCloud', { functionName: encodeQueryName(functionsWithAllowlist[1]), args: [], }); @@ -660,7 +712,7 @@ describe('Dev Server Middleware', () => { data: { attributes: { done: true, outputs: { data: { ok: true } } } }, }); - const req = createMockRequest('/__dd/executeAction', { + const req = createMockRequest('/__dd/executeActionViaCloud', { functionName: encodeQueryName(mockFunctions[0]), args: [], }); @@ -687,7 +739,7 @@ describe('Dev Server Middleware', () => { errors: [{ title: 'ExecutionFailed', detail: 'Script threw an error' }], }); - const req = createMockRequest('/__dd/executeAction', { + const req = createMockRequest('/__dd/executeActionViaCloud', { functionName: encodeQueryName(mockFunctions[0]), args: [], }); @@ -715,7 +767,7 @@ describe('Dev Server Middleware', () => { data: { attributes: { done: true, outputs: { data: { ok: true } } } }, }); - const req = createMockRequest('/__dd/executeAction', { + const req = createMockRequest('/__dd/executeActionViaCloud', { functionName: encodeQueryName(mockFunctions[0]), args: [], }); @@ -732,11 +784,184 @@ describe('Dev Server Middleware', () => { }); }); + describe('executeAction handler (local)', () => { + const middleware = createDevServerMiddleware( + mockViteBuild, + mockLoadModule, + () => mockFunctions, + mockAuth, + getApiKeyRequest(), + '/project', + mockLog, + ); + + test('Should return 400 for missing functionRef', async () => { + const req = createMockRequest('/__dd/executeAction', {}); + const res = createMockResponse(); + + middleware(req, res, jest.fn()); + await res.done; + + expect(res.statusCode).toBe(400); + }); + + test('Should return 404 for unknown function', async () => { + const req = createMockRequest('/__dd/executeAction', { + functionName: 'nonexistent.nonexistent', + }); + const res = createMockResponse(); + + middleware(req, res, jest.fn()); + await res.done; + + expect(res.statusCode).toBe(404); + }); + + test('Should run the function directly in-process and return its result, with no bundling and no network call', async () => { + mockLoadModuleReturning(mockFunctions[0], (arg: number) => arg * 2); + + const req = createMockRequest('/__dd/executeAction', { + functionName: encodeQueryName(mockFunctions[0]), + args: [21], + }); + const res = createMockResponse(); + + middleware(req, res, jest.fn()); + await res.done; + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.getBody()); + expect(body.success).toBe(true); + expect(body.result).toEqual({ data: 42 }); + expect(mockViteBuild).not.toHaveBeenCalled(); + }); + + test('Should work with no auth configured at all, for a function that never calls $.Actions', async () => { + const noAuthMiddleware = createDevServerMiddleware( + mockViteBuild, + mockLoadModule, + () => mockFunctions, + mockOauthOnlyAuth, + undefined, + '/project', + mockLog, + ); + mockLoadModuleReturning(mockFunctions[0], () => 1); + + const req = createMockRequest('/__dd/executeAction', { + functionName: encodeQueryName(mockFunctions[0]), + args: [], + }); + const res = createMockResponse(); + + noAuthMiddleware(req, res, jest.fn()); + await res.done; + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.getBody()); + expect(body.success).toBe(true); + expect(body.result).toEqual({ data: 1 }); + }); + + test('Should return a clear error when a function calls $.Actions with no auth configured', async () => { + const noAuthMiddleware = createDevServerMiddleware( + mockViteBuild, + mockLoadModule, + () => mockFunctions, + mockOauthOnlyAuth, + undefined, + '/project', + mockLog, + ); + mockLoadModuleReturning(mockFunctions[0], () => + (globalThis as Record).$.Actions.slack.chat.postMessage({ + inputs: { text: 'hi' }, + }), + ); + + const req = createMockRequest('/__dd/executeAction', { + functionName: encodeQueryName(mockFunctions[0]), + args: [], + }); + const res = createMockResponse(); + + noAuthMiddleware(req, res, jest.fn()); + await res.done; + + expect(res.statusCode).toBe(500); + const body = JSON.parse(res.getBody()); + expect(body.success).toBe(false); + expect(body.error).toContain('Auth credentials not configured'); + }); + + test('Should route a real $.Actions call (including connectionId) through a direct single-action preview-async query, not the jsFunctionWithActions wrapper', async () => { + mockLoadModuleReturning(mockFunctions[0], () => + (globalThis as Record).$.Actions.slack.chat.postMessage({ + inputs: { text: 'hi' }, + connectionId: 'conn-1', + }), + ); + + type PreviewAsyncBody = { + data: { + attributes: { + query: { + properties: { + spec: { + fqn: string; + inputs: Record; + connectionId?: string; + }; + }; + }; + }; + }; + }; + let capturedBody: PreviewAsyncBody | undefined; + const apiScope = nock(DD_API_ORIGIN) + .post('/api/v2/app-builder/queries/preview-async', (body) => { + capturedBody = body as PreviewAsyncBody; + return true; + }) + .reply(200, { data: { id: 'receipt-action' } }) + .get('/api/v2/app-builder/queries/execution-long-polling/receipt-action') + .reply(200, { + data: { attributes: { done: true, outputs: { ok: true, ts: '123' } } }, + }); + + const req = createMockRequest('/__dd/executeAction', { + functionName: encodeQueryName(mockFunctions[0]), + args: [], + }); + const res = createMockResponse(); + + middleware(req, res, jest.fn()); + await res.done; + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.getBody()); + expect(body.success).toBe(true); + // The action's raw output ({ok, ts}, its own schema, not wrapped + // by preview-async itself) is what $.Actions.foo.bar() resolves + // to; the outer {data: ...} comes from the function's own return + // value going through executeScriptLocally's usual wrapping, not + // from anything action-specific. + expect(body.result).toEqual({ data: { ok: true, ts: '123' } }); + expect(apiScope.isDone()).toBe(true); + expect(capturedBody?.data.attributes.query.properties.spec).toEqual({ + fqn: 'com.datadoghq.slack.chat.postMessage', + inputs: { text: 'hi' }, + connectionId: 'conn-1', + }); + }); + }); + describe('dynamic discovery', () => { test('Should not find stale function after re-transform (HMR)', async () => { let currentFunctions: BackendFunction[] = [...mockFunctions]; const middleware = createDevServerMiddleware( mockViteBuild, + mockLoadModule, () => currentFunctions, mockAuth, getApiKeyRequest(), diff --git a/packages/plugins/apps/src/vite/dev-server.ts b/packages/plugins/apps/src/vite/dev-server.ts index 3d0c78d58..2896f1059 100644 --- a/packages/plugins/apps/src/vite/dev-server.ts +++ b/packages/plugins/apps/src/vite/dev-server.ts @@ -18,6 +18,8 @@ import { generateDevVirtualEntryContent } from '../backend/virtual-entry'; import { createBackendConnectionIdCollector } from './backend-connection-id-collector'; import { getBaseBackendBuildConfig } from './build-config'; +import type { ExecuteAction, LoadModule } from './local-execution'; +import { executeScriptLocally } from './local-execution'; interface BundleResult { func: BackendFunction; @@ -125,18 +127,20 @@ async function bundleBackendFunction( } /** - * Execute a script via Datadog's app-builder queries API. + * Submit a query to Datadog's app-builder `preview-async` endpoint and + * return its receipt ID. `querySpec` is the query's own `spec` object — + * either the `jsFunctionWithActions` wrapper (a whole script) or a single + * real action's own `{fqn, inputs}` directly (see `executeSingleActionRemotely` + * below) — `submitQuery` itself doesn't care which. */ -async function executeScriptViaDatadog( - scriptBody: string, - func: BackendFunction, - args: unknown[], +async function submitQuery( + querySpec: Record, + displayName: string, auth: AuthConfig, doAuthenticatedRequest: DoAuthenticatedRequest, log: Logger, -): Promise { +): Promise { const endpoint = `https://api.${auth.site}/api/v2/app-builder/queries/preview-async`; - const displayName = formatRef(func); log.debug(`Calling Datadog API: ${endpoint}`); @@ -149,14 +153,7 @@ async function executeScriptViaDatadog( name: displayName, type: 'action', properties: { - spec: { - fqn: 'com.datadoghq.datatransformation.jsFunctionWithActions', - inputs: { - script: scriptBody, - allowedConnectionIds: func.allowedConnectionIds, - context: { backendFunctionArgs: args }, - }, - }, + spec: querySpec, onlyTriggerManually: true, }, }, @@ -183,36 +180,107 @@ async function executeScriptViaDatadog( log.debug(`Query execution started with receipt: ${receiptId}`); - return pollQueryExecution(receiptId, auth, doAuthenticatedRequest, log); + return receiptId; +} + +/** + * Execute a script via Datadog's app-builder queries API — the existing + * production round trip, unchanged. Wraps the whole script as a + * `jsFunctionWithActions` query. + */ +async function executeScriptViaDatadog( + scriptBody: string, + func: BackendFunction, + args: unknown[], + auth: AuthConfig, + doAuthenticatedRequest: DoAuthenticatedRequest, + log: Logger, +): Promise { + const displayName = formatRef(func); + + const receiptId = await submitQuery( + { + fqn: 'com.datadoghq.datatransformation.jsFunctionWithActions', + inputs: { + script: scriptBody, + allowedConnectionIds: func.allowedConnectionIds, + context: { backendFunctionArgs: args }, + }, + }, + displayName, + auth, + doAuthenticatedRequest, + log, + ); + + const outputs = await pollQueryExecution(receiptId, auth, doAuthenticatedRequest, log); + if (typeof outputs !== 'object' || outputs === null || !('data' in outputs)) { + throw new Error('Query execution completed without a "data" field in its outputs'); + } + return outputs as BackendOutputs; +} + +/** + * Build the real `$.Actions` implementation local execution injects: each + * call submits its own direct, single-action `preview-async` query — the + * action's own `{fqn, inputs, connectionId}`, not wrapped in a + * `jsFunctionWithActions` script — and polls it the same way the whole-script + * path does. This is the v1 mechanism decided in the RFC's Decisions and + * Trade-Offs: it needs nothing new from Action Platform and works today. No + * auth check happens until an action call is actually made — a script that + * never calls `$.Actions` runs locally with no auth configured at all. + */ +function makeExecuteActionRemotely( + auth: AuthConfig, + doAuthenticatedRequest: DoAuthenticatedRequest | undefined, + log: Logger, +): ExecuteAction { + return async ( + fqn: string, + inputs: unknown, + connectionId: string | undefined, + ): Promise => { + if (!doAuthenticatedRequest) { + throw new Error(`Auth credentials not configured. ${AUTH_GUIDANCE}`); + } + const receiptId = await submitQuery( + connectionId ? { fqn, inputs, connectionId } : { fqn, inputs }, + fqn, + auth, + doAuthenticatedRequest, + log, + ); + return pollQueryExecution(receiptId, auth, doAuthenticatedRequest, log); + }; } interface PollResult { - data?: { attributes?: { done?: boolean; outputs?: BackendOutputs } }; + data?: { attributes?: { done?: boolean; outputs?: unknown } }; errors?: Array<{ detail?: string; title?: string }>; } +/** + * Long-poll Datadog API until a submitted query's execution completes or + * times out. Returns the raw `outputs` value — shape varies by query type + * (a `jsFunctionWithActions` query wraps its result as `{data: }`; + * a direct single-action query's `outputs` is that action's own defined + * output schema) — callers interpret it accordingly. + * + * The server holds each poll connection open (~30s) and responds with + * done: true when the result is ready, or done: false when its long-poll + * window expires. This loop handles application-level re-polling + * (done: false), not HTTP retries — doRequest already retries transient + * HTTP failures (5xx, network errors) internally. + */ async function pollQueryExecution( receiptId: string, auth: AuthConfig, doAuthenticatedRequest: DoAuthenticatedRequest, log: Logger, -): Promise { +): Promise { const endpoint = `https://api.${auth.site}/api/v2/app-builder/queries/execution-long-polling/${receiptId}`; const maxRetries = 10; - /* - * Long-poll Datadog API until the query execution completes or times out. - * - * Executing an action works in two phases: - * 1. executeScriptViaDatadog sends a POST to preview-async, which starts the - * query and returns a receipt ID immediately. - * 2. This function polls the execution-long-polling endpoint with that receipt ID. - * The server holds the connection open (~30s) and responds with done: true when - * the result is ready, or done: false when its long-poll window expires. - * - * This loop handles application-level re-polling (done: false), not HTTP retries. - * doRequest already retries transient HTTP failures (5xx, network errors) internally. - */ for (let attempt = 0; attempt < maxRetries; attempt++) { log.debug(`Long-poll attempt ${attempt + 1}/${maxRetries}...`); @@ -231,7 +299,7 @@ async function pollQueryExecution( log.debug(`Long-poll response, done: ${attrs?.done}`); if (attrs?.done) { - if (!attrs.outputs) { + if (attrs.outputs === undefined) { throw new Error('Query execution completed without outputs'); } return attrs.outputs; @@ -308,9 +376,72 @@ async function handleDebugBundle( } /** - * Handle POST /__dd/executeAction — bundles a backend function and executes it via Datadog API. + * Parse the request body and look up the backend function by encoded query + * name — the same validation `validateAndBundle` does, minus the bundle step + * `handleExecuteAction` no longer needs. + */ +async function parseAndLookupFunction( + req: IncomingMessage, + functionsByName: Map, +): Promise<{ func: BackendFunction; args: unknown[] }> { + const { functionName, args = [] } = await parseRequestBody(req); + + if (!functionName || typeof functionName !== 'string') { + throw new HttpError(400, 'Missing or invalid functionName'); + } + + const func = functionsByName.get(functionName); + if (!func) { + throw new HttpError(404, `Backend function "${functionName}" not found`); + } + + return { func, args }; +} + +/** + * Handle POST /__dd/executeAction — imports a backend function's real file + * directly and executes it in-process (see local-execution.ts); no bundling + * on this path. Customer-facing default: no auth required upfront, since the + * script itself doesn't need it — only a real `$.Actions` call does, and + * that's checked lazily (see makeExecuteActionRemotely). */ async function handleExecuteAction( + req: IncomingMessage, + res: ServerResponse, + functionsByName: Map, + auth: AuthConfig, + doAuthenticatedRequest: DoAuthenticatedRequest | undefined, + loadModule: LoadModule, + log: Logger, +): Promise { + try { + const { func, args } = await parseAndLookupFunction(req, functionsByName); + const displayName = formatRef(func); + + log.debug(`Executing action locally: ${displayName} with args`); + + const executeAction = makeExecuteActionRemotely(auth, doAuthenticatedRequest, log); + const result = await executeScriptLocally(func, args, executeAction, loadModule, log); + + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ success: true, result } satisfies ExecuteActionResponse)); + } catch (error: unknown) { + const statusCode = error instanceof HttpError ? error.statusCode : 500; + const message = error instanceof Error ? error.message : 'Internal server error'; + log.debug(`Error handling executeAction: ${message}`); + sendError(res, statusCode, message); + } +} + +/** + * Handle POST /__dd/executeActionViaCloud — bundles a backend function and + * executes it via the existing production round trip (queue + Deno + * subprocess). Same behavior as `/__dd/executeAction` before this project: + * kept as a distinctly-purposed command (`npm run dev:verify`, Milestone 3) + * for pre-publish parity checks, not a mode flag on the same endpoint. + */ +async function handleExecuteActionViaCloud( req: IncomingMessage, res: ServerResponse, functionsByName: Map, @@ -323,7 +454,7 @@ async function handleExecuteAction( const { func, code, args } = await validateAndBundle(req, functionsByName, bundle); const displayName = formatRef(func); - log.debug(`Executing action: ${displayName} with args`); + log.debug(`Executing action via cloud: ${displayName} with args`); const result = await executeScriptViaDatadog( code, @@ -340,7 +471,7 @@ async function handleExecuteAction( } catch (error: unknown) { const statusCode = error instanceof HttpError ? error.statusCode : 500; const message = error instanceof Error ? error.message : 'Internal server error'; - log.debug(`Error handling executeAction: ${message}`); + log.debug(`Error handling executeActionViaCloud: ${message}`); sendError(res, statusCode, message); } } @@ -362,6 +493,7 @@ function buildFunctionMap(backendFunctions: BackendFunction[]): Map BackendFunction[], auth: AuthConfig, doAuthenticatedRequest: DoAuthenticatedRequest | undefined, @@ -380,7 +512,7 @@ export function createDevServerMiddleware( if (!doAuthenticatedRequest) { log.warn( - `Auth credentials not configured. The /__dd/executeAction endpoint will be unavailable. ${AUTH_GUIDANCE}`, + `Auth credentials not configured. Backend functions that call $.Actions will fail; the /__dd/executeActionViaCloud endpoint will be unavailable. ${AUTH_GUIDANCE}`, ); } @@ -397,11 +529,23 @@ export function createDevServerMiddleware( sendError(res, 500, 'Unexpected error'); }); } else if (req.url === '/__dd/executeAction') { + handleExecuteAction( + req, + res, + functionsByName, + auth, + doAuthenticatedRequest, + loadModule, + log, + ).catch(() => { + sendError(res, 500, 'Unexpected error'); + }); + } else if (req.url === '/__dd/executeActionViaCloud') { if (!doAuthenticatedRequest) { sendError(res, 400, `Auth credentials not configured. ${AUTH_GUIDANCE}`); return; } - handleExecuteAction( + handleExecuteActionViaCloud( req, res, functionsByName, diff --git a/packages/plugins/apps/src/vite/index.test.ts b/packages/plugins/apps/src/vite/index.test.ts index 3a798312f..bb5c1baf5 100644 --- a/packages/plugins/apps/src/vite/index.test.ts +++ b/packages/plugins/apps/src/vite/index.test.ts @@ -169,4 +169,21 @@ describe('Backend Functions - getVitePlugin', () => { value: expect.stringMatching(/[/\\]apps-runtime\.mjs$/), }); }); + + test('Should force @datadog/apps-backend and @datadog/action-catalog through the SSR transform pipeline instead of externalizing them', () => { + // These SDKs ship ESM-only. Vite's dev-server SSR mode externalizes + // node_modules by default (a plain require(), for speed), which + // throws "Cannot use import statement outside a module" for an + // ESM-only package -- ssr.noExternal is what the local executeAction + // path's server.ssrLoadModule call depends on to load them correctly. + const plugin = getVitePlugin(defaultOptions); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const config = (plugin as any).config(); + + expect(config).toEqual({ + ssr: { + noExternal: ['@datadog/apps-backend', '@datadog/action-catalog'], + }, + }); + }); }); diff --git a/packages/plugins/apps/src/vite/index.ts b/packages/plugins/apps/src/vite/index.ts index 831ce75c2..b5e3dfbf0 100644 --- a/packages/plugins/apps/src/vite/index.ts +++ b/packages/plugins/apps/src/vite/index.ts @@ -118,6 +118,22 @@ export const getVitePlugin = ({ const { setBackendFunctions, getBackendFunctions } = createBackendFunctionRegistry(); return { + // The dev server's local-execution path loads backend-function + // dependencies (e.g. @datadog/apps-backend, @datadog/action-catalog) + // via `server.ssrLoadModule`, which by default externalizes + // node_modules packages (a plain `require()`, for speed) rather than + // transforming them. Those two SDKs ship ESM-only, so an externalized + // `require()` throws "Cannot use import statement outside a module". + // `ssr.noExternal` forces Vite's SSR transform pipeline to handle + // them instead, matching how the production bundling path already + // inlines every dependency by default. + config() { + return { + ssr: { + noExternal: ['@datadog/apps-backend', '@datadog/action-catalog'], + }, + }; + }, transform: { filter: { id: { @@ -203,6 +219,7 @@ export const getVitePlugin = ({ server.middlewares.use( createDevServerMiddleware( bundler.build, + server.ssrLoadModule.bind(server), getBackendFunctions, auth, doAuthenticatedRequest, From 8ddc3c02f8be669fcc2e896bec8bf4e2e4fa936c Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Mon, 10 Aug 2026 14:49:29 -0700 Subject: [PATCH 2/3] test(apps): extract shared loadModule test double into @dd/tests mocks dev-server.test.ts and local-execution.test.ts (build-plugins#480/#484) each defined their own near-identical LoadModule resolver double. Factor the common resolve-or-throw logic into moduleResolverFor in the shared mocks helper so both can build on it instead of duplicating it. --- .../plugins/apps/src/vite/dev-server.test.ts | 9 ++------- packages/tests/src/_jest/helpers/mocks.ts | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/packages/plugins/apps/src/vite/dev-server.test.ts b/packages/plugins/apps/src/vite/dev-server.test.ts index 63a226001..db6d8781c 100644 --- a/packages/plugins/apps/src/vite/dev-server.test.ts +++ b/packages/plugins/apps/src/vite/dev-server.test.ts @@ -7,7 +7,7 @@ import { getAuthenticatedRequest } from '@dd/apps-plugin/auth'; import { createDevServerMiddleware } from '@dd/apps-plugin/vite/dev-server'; import type { AuthOptionsWithDefaults } from '@dd/core/types'; -import { getMockLogger } from '@dd/tests/_jest/helpers/mocks'; +import { getMockLogger, moduleResolverFor } from '@dd/tests/_jest/helpers/mocks'; import { EventEmitter } from 'events'; import type { IncomingMessage, ServerResponse } from 'http'; import nock from 'nock'; @@ -163,12 +163,7 @@ function mockBuildWithParsedBackend(code = '// code') { */ // eslint-disable-next-line @typescript-eslint/no-explicit-any function mockLoadModuleReturning(func: BackendFunction, fn: (...args: any[]) => unknown) { - mockLoadModule.mockImplementation(async (specifier: string) => { - if (specifier === func.absolutePath) { - return { [func.name]: fn }; - } - throw new Error(`Cannot find module '${specifier}'`); - }); + mockLoadModule.mockImplementation(moduleResolverFor(func, { [func.name]: fn })); } describe('Dev Server Middleware', () => { diff --git a/packages/tests/src/_jest/helpers/mocks.ts b/packages/tests/src/_jest/helpers/mocks.ts index decb1924f..8ea0981d1 100644 --- a/packages/tests/src/_jest/helpers/mocks.ts +++ b/packages/tests/src/_jest/helpers/mocks.ts @@ -2,6 +2,8 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. +import type { BackendFunction } from '@dd/apps-plugin/backend/types'; +import type { LoadModule } from '@dd/apps-plugin/vite/local-execution'; import { DEFAULT_SITE } from '@dd/core/constants'; import { checkFile, @@ -120,6 +122,23 @@ export const getMockTimeLogger = (overrides: Partial = {}): TimeLogg return mockTimer; }; +/** + * Builds a `loadModule`-shaped resolver that returns `exports` for `func`'s + * absolute path and rejects any other specifier — matching what a real + * module loader returns when only the target module is actually resolvable. + */ +export const moduleResolverFor = ( + func: BackendFunction, + exports: Record, +): LoadModule => { + return async (specifier: string) => { + if (specifier === func.absolutePath) { + return exports; + } + throw new Error(`Cannot find module '${specifier}'`); + }; +}; + export const mockLogFn = jest.fn((text: any, level: LogLevel) => {}); export const getMockLogger = (overrides: Partial = {}): Logger => ({ getLogger: jest.fn(), From 1c7f1e010f841c8a7790bce08429a986adb4aac0 Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Tue, 11 Aug 2026 12:34:32 -0700 Subject: [PATCH 3/3] fix(apps): let local execution's ssrLoadModule bypass the frontend RPC-proxy transform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit server.ssrLoadModule(func.absolutePath) went through the same transform hook that rewrites *.backend.ts into the client-side RPC-proxy stub (globalThis.DD_APPS_RUNTIME.executeBackendFunction(...)) — so local execution's "real" import was actually still the proxy stub, which crashes immediately since that global doesn't exist server-side. Every existing test mocked loadModule directly, so none of them exercised the real transform pipeline and caught this. Mark local execution's own load with a query suffix (matching Vite's own ?raw/?url convention) and have the transform hook skip proxy generation for that specific marked request, deferring to Vite's normal TS/esbuild transform instead. Checking the marker rather than the generic Vite-supplied options.ssr flag keeps this from also affecting any other, unrelated future SSR-context load of the same file. --- packages/plugins/apps/src/constants.ts | 12 +++++ packages/plugins/apps/src/vite/index.test.ts | 50 +++++++++++++++++++ packages/plugins/apps/src/vite/index.ts | 17 ++++++- .../apps/src/vite/local-execution.test.ts | 12 ++--- .../plugins/apps/src/vite/local-execution.ts | 5 +- packages/tests/src/_jest/helpers/mocks.ts | 9 ++-- 6 files changed, 91 insertions(+), 14 deletions(-) diff --git a/packages/plugins/apps/src/constants.ts b/packages/plugins/apps/src/constants.ts index db612df45..b7bc9d897 100644 --- a/packages/plugins/apps/src/constants.ts +++ b/packages/plugins/apps/src/constants.ts @@ -10,6 +10,18 @@ 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 local execution appends to its `loadModule()`/`ssrLoadModule()` + * call so the transform hook below can tell "give me the real function body to + * run in-process" apart from a normal frontend import of the same file (which + * needs the client-side RPC-proxy stub instead). Follows Vite's own `?raw`/ + * `?url`-style query-suffix convention rather than branching on the generic + * `options.ssr` flag, which would also match unrelated future SSR-context + * loads of the same file. + */ +export const LOCAL_EXECUTION_LOAD_SUFFIX = '?dd-local-exec'; +export const LOCAL_EXECUTION_LOAD_RE = /\.backend\.(ts|tsx|js|jsx)\?dd-local-exec$/; export const BACKEND_CODE_EXTENSIONS = [ '.ts', '.tsx', diff --git a/packages/plugins/apps/src/vite/index.test.ts b/packages/plugins/apps/src/vite/index.test.ts index bb5c1baf5..d4f8bf095 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[] = [ { @@ -160,6 +161,55 @@ describe('Backend Functions - getVitePlugin', () => { expect(assets.collectAssets).toHaveBeenCalledWith(['dist/**/*'], '/build'); }); + test('Should skip proxy generation for a suffixed local-execution load, returning the real source untouched', async () => { + // Regression test: server.ssrLoadModule() runs through this exact + // transform hook (it's registered on the same dev server as the + // frontend's own module graph). Before the suffix check, local + // execution's "real" import silently got back the client-side + // RPC-proxy stub instead of the customer's actual function body — + // invisible to every other test in this file/local-execution.test.ts + // because they all inject a mocked loadModule that never runs this + // transform at all. + const plugin = getVitePlugin(defaultOptions); + const transform = plugin!.transform as { + handler: (code: string, id: string) => unknown; + }; + + const realSource = 'export function myHandler() { return 42; }'; + const result = await transform.handler.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}`, + ); + + expect(result).toBeNull(); + }); + + test('Should still generate the frontend RPC-proxy for a normal (unsuffixed) import of the same file', async () => { + const plugin = getVitePlugin(defaultOptions); + const transform = plugin!.transform as { + handler: (code: string, id: string) => unknown; + }; + + const result = (await transform.handler.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')); + }); + 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 b5e3dfbf0..a59a52d8e 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, + LOCAL_EXECUTION_LOAD_RE, + LOCAL_EXECUTION_LOAD_SUFFIX, + PLUGIN_NAME, +} from '../constants'; import type { AppsOptionsWithDefaults } from '../types'; import { buildBackendFunctions } from './build-backend-functions'; @@ -137,7 +142,7 @@ export const getVitePlugin = ({ transform: { filter: { id: { - include: [BACKEND_FILE_RE], + include: [BACKEND_FILE_RE, LOCAL_EXECUTION_LOAD_RE], exclude: [/node_modules/, /[/\\]dist[/\\]/], }, }, @@ -145,6 +150,14 @@ export const getVitePlugin = ({ // them as backend functions, and replace the module with a // frontend proxy that calls executeBackendFunction at runtime. handler(code, id) { + if (id.endsWith(LOCAL_EXECUTION_LOAD_SUFFIX)) { + // Local execution's ssrLoadModule() call marks its own request with + // this suffix so it can be told apart from a normal frontend import + // of the same file — local execution needs the real function body to + // run in-process, not the client-side RPC-proxy stub generated below. + return null; + } + const ast = this.parse(code); const exportNames = extractExportedFunctions(ast, id); if (exportNames.length === 0) { diff --git a/packages/plugins/apps/src/vite/local-execution.test.ts b/packages/plugins/apps/src/vite/local-execution.test.ts index 16b23e6d9..330795d87 100644 --- a/packages/plugins/apps/src/vite/local-execution.test.ts +++ b/packages/plugins/apps/src/vite/local-execution.test.ts @@ -4,9 +4,10 @@ /* global globalThis */ -import { mockLogger } from '@dd/tests/_jest/helpers/mocks'; +import { mockLogger, moduleResolverFor } from '@dd/tests/_jest/helpers/mocks'; 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'; @@ -26,12 +27,7 @@ const stubExecuteAction: ExecuteAction = async (fqn) => ({ data: null, stub: tru * 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}'`); - }; + return moduleResolverFor(func, exports); } const ORDER_MARKER = '__ddLocalExecutionTestOrder'; @@ -235,7 +231,7 @@ describe('local-execution — executeScriptLocally', () => { | undefined; const loadModule: LoadModule = async (specifier: string) => { - if (specifier === func.absolutePath) { + if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { return { example: async () => registeredImpl?.('com.datadoghq.slack.chat.postMessage', { diff --git a/packages/plugins/apps/src/vite/local-execution.ts b/packages/plugins/apps/src/vite/local-execution.ts index 13ad5a4ec..9e7f1d7e3 100644 --- a/packages/plugins/apps/src/vite/local-execution.ts +++ b/packages/plugins/apps/src/vite/local-execution.ts @@ -20,6 +20,7 @@ import type { Logger } from '@dd/core/types'; import type { BackendFunction } from '../backend/types'; +import { LOCAL_EXECUTION_LOAD_SUFFIX } from '../constants'; type BackendOutputs = { data: unknown }; @@ -264,7 +265,9 @@ async function runScriptLocally( registerBackendRuntimeIfInstalled(loadModule, $), ]); - const mod = await loadModule(func.absolutePath); + // The suffix marks this as local execution's own request for the real + // function body — see LOCAL_EXECUTION_LOAD_SUFFIX's doc comment. + 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}`); diff --git a/packages/tests/src/_jest/helpers/mocks.ts b/packages/tests/src/_jest/helpers/mocks.ts index 8ea0981d1..626be2530 100644 --- a/packages/tests/src/_jest/helpers/mocks.ts +++ b/packages/tests/src/_jest/helpers/mocks.ts @@ -3,6 +3,7 @@ // Copyright 2019-Present Datadog, Inc. import type { BackendFunction } from '@dd/apps-plugin/backend/types'; +import { LOCAL_EXECUTION_LOAD_SUFFIX } from '@dd/apps-plugin/constants'; import type { LoadModule } from '@dd/apps-plugin/vite/local-execution'; import { DEFAULT_SITE } from '@dd/core/constants'; import { @@ -124,15 +125,17 @@ export const getMockTimeLogger = (overrides: Partial = {}): TimeLogg /** * Builds a `loadModule`-shaped resolver that returns `exports` for `func`'s - * absolute path and rejects any other specifier — matching what a real - * module loader returns when only the target module is actually resolvable. + * absolute path (as requested by local execution's own suffixed specifier — + * see `LOCAL_EXECUTION_LOAD_SUFFIX`) and rejects any other specifier — + * matching what a real module loader returns when only the target module is + * actually resolvable. */ export const moduleResolverFor = ( func: BackendFunction, exports: Record, ): LoadModule => { return async (specifier: string) => { - if (specifier === func.absolutePath) { + if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { return exports; } throw new Error(`Cannot find module '${specifier}'`);