From 2c7712f017e1dbd372fa2178209bfb4239e211a4 Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Fri, 7 Aug 2026 01:26:53 -0700 Subject: [PATCH 1/5] 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 3fcf482742b279f093fa49a3d65c76d0c0736837 Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Mon, 10 Aug 2026 14:49:29 -0700 Subject: [PATCH 2/5] 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 907dfda97..b8b8923a2 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 b012bf09d9d35b18adf12970f7e5e59363bbf51d Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Tue, 11 Aug 2026 12:34:32 -0700 Subject: [PATCH 3/5] 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 19fd67398..e7087b2f7 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 c143c754b..de3157c72 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 }; @@ -406,7 +407,9 @@ async function runScriptLocally( poisonBackendRuntimeRegistration(); } - 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 b8b8923a2..39fd4c686 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}'`); From d598f25f3a9ba18126b71ddd784fdce9de8d3d35 Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Thu, 20 Aug 2026 18:08:10 -0400 Subject: [PATCH 4/5] fix(apps): run static safety checks before the local-execution early-return The transform handler's this.parse(code) call ran after the LOCAL_EXECUTION_LOAD_SUFFIX early-return, so any static safety check inserted at that point (e.g. rejecting Node builtin imports) would silently never run for the one path that actually executes the code. Moving the parse ahead of the early-return closes that gap and makes a future merge with such a check conflict loudly instead of merging clean. Also corrects dev-server.integration.test.ts's doc comment, which claimed to exercise the transform hook's production wiring even though its createServer() call never registers getVitePlugin()'s plugin. --- .../src/vite/dev-server.integration.test.ts | 17 ++++++++++++----- packages/plugins/apps/src/vite/index.ts | 9 ++++++++- .../apps/src/vite/local-execution.test.ts | 2 +- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/packages/plugins/apps/src/vite/dev-server.integration.test.ts b/packages/plugins/apps/src/vite/dev-server.integration.test.ts index c30dd94d8..45fd02668 100644 --- a/packages/plugins/apps/src/vite/dev-server.integration.test.ts +++ b/packages/plugins/apps/src/vite/dev-server.integration.test.ts @@ -3,15 +3,22 @@ // Copyright 2019-Present Datadog, Inc. /** - * Real end-to-end coverage for the local-execution path: no mocked + * Real coverage for the local-execution path's module resolution: 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. + * directly and execute it via the real `/__dd/executeAction` HTTP handler, + * including resolving `@datadog/apps-backend` from the fixture's own project + * root rather than build-plugins' own dependency tree. + * + * Does NOT register `getVitePlugin()`'s own transform hook on this server — + * `createServer` here has no `plugins:` array — so this does not exercise + * `vite/index.ts`'s `.backend.ts` → RPC-proxy transform or its interaction + * with `LOCAL_EXECUTION_LOAD_SUFFIX`; `index.test.ts` covers that hook + * directly instead. Registering the real plugin here (so this test also + * catches a regression in the plugin's own filter/handler wiring, not just + * the handler function in isolation) is a valuable, real follow-up. * * Uses `@datadog/apps-backend` (the fixture already has it as a real, * locally-resolvable dependency — see `packages/tests/src/_jest/fixtures/ diff --git a/packages/plugins/apps/src/vite/index.ts b/packages/plugins/apps/src/vite/index.ts index a59a52d8e..cad3c72a9 100644 --- a/packages/plugins/apps/src/vite/index.ts +++ b/packages/plugins/apps/src/vite/index.ts @@ -150,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) { + // Parsed and validated unconditionally, before the suffix check below — + // any static safety check added here (e.g. rejecting Node builtin + // imports or restricted globals) must run for every id, suffixed or + // not, since a suffixed load is exactly the path that actually + // executes the code. Only the proxy-generation step past this point + // is specific to a plain (non-suffixed) frontend import. + const ast = this.parse(code); + 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 @@ -158,7 +166,6 @@ export const getVitePlugin = ({ return null; } - const ast = this.parse(code); const exportNames = extractExportedFunctions(ast, id); if (exportNames.length === 0) { log.warn( diff --git a/packages/plugins/apps/src/vite/local-execution.test.ts b/packages/plugins/apps/src/vite/local-execution.test.ts index e7087b2f7..14f4f81c4 100644 --- a/packages/plugins/apps/src/vite/local-execution.test.ts +++ b/packages/plugins/apps/src/vite/local-execution.test.ts @@ -581,7 +581,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 () => { await new Promise((resolve) => setTimeout(resolve, 100)); From dc334004dab38a12de806d9f2802fbab01b6ef16 Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Thu, 20 Aug 2026 19:15:51 -0400 Subject: [PATCH 5/5] fix(apps): address code review findings (stale comment, any escape hatch) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit submitQuery's doc comment referenced executeSingleActionRemotely, a function that never existed under that name — the real one is makeExecuteActionRemotely, already correctly named elsewhere in this file. mockLoadModuleReturning's fn parameter used any[] as an escape hatch; (...args: never[]) => unknown accepts the same range of test callback signatures without it, since never is assignable to any parameter type a caller's own lambda declares. --- packages/plugins/apps/src/vite/dev-server.test.ts | 3 +-- packages/plugins/apps/src/vite/dev-server.ts | 2 +- packages/plugins/apps/src/vite/local-execution.test.ts | 4 ++-- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/plugins/apps/src/vite/dev-server.test.ts b/packages/plugins/apps/src/vite/dev-server.test.ts index db6d8781c..b1529f7ee 100644 --- a/packages/plugins/apps/src/vite/dev-server.test.ts +++ b/packages/plugins/apps/src/vite/dev-server.test.ts @@ -161,8 +161,7 @@ function mockBuildWithParsedBackend(code = '// code') { * 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) { +function mockLoadModuleReturning(func: BackendFunction, fn: (...args: never[]) => unknown) { mockLoadModule.mockImplementation(moduleResolverFor(func, { [func.name]: fn })); } diff --git a/packages/plugins/apps/src/vite/dev-server.ts b/packages/plugins/apps/src/vite/dev-server.ts index 2896f1059..678ddb521 100644 --- a/packages/plugins/apps/src/vite/dev-server.ts +++ b/packages/plugins/apps/src/vite/dev-server.ts @@ -130,7 +130,7 @@ async function bundleBackendFunction( * 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` + * real action's own `{fqn, inputs}` directly (see `makeExecuteActionRemotely` * below) — `submitQuery` itself doesn't care which. */ async function submitQuery( diff --git a/packages/plugins/apps/src/vite/local-execution.test.ts b/packages/plugins/apps/src/vite/local-execution.test.ts index 14f4f81c4..2b5d6b331 100644 --- a/packages/plugins/apps/src/vite/local-execution.test.ts +++ b/packages/plugins/apps/src/vite/local-execution.test.ts @@ -276,7 +276,7 @@ describe('local-execution — executeScriptLocally', () => { let registeredBackend: { get: () => unknown } | undefined; const loadModule: LoadModule = async (specifier: string) => { - if (specifier === func.absolutePath) { + if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { return { example: async () => { await new Promise((resolve) => setTimeout(resolve, 100)); @@ -642,7 +642,7 @@ describe('local-execution — executeScriptLocally', () => { const makeLoadModule = (exampleImpl: () => Promise): LoadModule => { return async (specifier: string) => { - if (specifier === func.absolutePath) { + if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { return { example: exampleImpl }; } if (specifier === '@datadog/action-catalog/action-execution') {