From ba70a6e0a69673277e544abee624cea66ad76c07 Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Thu, 27 Aug 2026 19:23:44 -0400 Subject: [PATCH 01/30] fix(apps): match backend files with any query string in the transform filter An include filter scoped only to no-query and the exact `?dd-local-exec` suffix lets an unrecognized query (e.g. `?x`, or a malformed `?dd-local-exec&x`) bypass the transform filter entirely, so Vite falls back to its default loader instead of the safe RPC-proxy stub. Matching every query on a backend file and deciding safety in the handler closes that gap. --- packages/plugins/apps/src/constants.ts | 10 +++-- packages/plugins/apps/src/vite/index.test.ts | 40 ++++++++++++++++++++ packages/plugins/apps/src/vite/index.ts | 25 +++++++----- 3 files changed, 62 insertions(+), 13 deletions(-) diff --git a/packages/plugins/apps/src/constants.ts b/packages/plugins/apps/src/constants.ts index 53732b39c..d8b56acab 100644 --- a/packages/plugins/apps/src/constants.ts +++ b/packages/plugins/apps/src/constants.ts @@ -13,9 +13,13 @@ export const BACKEND_FILE_RE = /\.backend\.(ts|tsx|js|jsx)$/; /** Query suffix marking a local-execution load, so the transform hook below can skip proxy generation for it instead of matching via the broader `options.ssr` flag. */ export const LOCAL_EXECUTION_LOAD_SUFFIX = '?dd-local-exec'; -// Derived from BACKEND_FILE_RE plus the escaped suffix (its only regex-special character is the leading `?`), so the two can't drift apart if either the extension list or the suffix ever changes. -export const LOCAL_EXECUTION_LOAD_RE = new RegExp( - `${BACKEND_FILE_RE.source.slice(0, -1)}\\${LOCAL_EXECUTION_LOAD_SUFFIX}$`, +// Matches a backend file with or without ANY trailing query string. A filter scoped only to +// the exact local-execution suffix would let an unrecognized query (e.g. `?x`, or a malformed +// `?dd-local-exec&x`) bypass the transform filter entirely, leaving Vite to load the real +// backend source unprocessed instead of the safe RPC-proxy stub. Matching every query here and +// deciding safety in the handler closes that gap. +export const BACKEND_FILE_WITH_QUERY_RE = new RegExp( + `${BACKEND_FILE_RE.source.slice(0, -1)}(\\?.*)?$`, ); export const BACKEND_CODE_EXTENSIONS = [ '.ts', diff --git a/packages/plugins/apps/src/vite/index.test.ts b/packages/plugins/apps/src/vite/index.test.ts index f4bff1099..4daf39267 100644 --- a/packages/plugins/apps/src/vite/index.test.ts +++ b/packages/plugins/apps/src/vite/index.test.ts @@ -234,6 +234,46 @@ describe('Backend Functions - getVitePlugin', () => { expect(result?.code).toEqual(expect.stringContaining('executeBackendFunction')); }); + // Regression test: an unrecognized query string (not exactly the local-execution suffix) + // must still be caught by the transform filter, otherwise Vite falls back to its default + // loader and leaks the real backend source instead of the safe RPC-proxy stub. + test('Transform filter should match a backend file carrying an unrecognized query string', () => { + const plugin = getVitePlugin(defaultOptions); + const filter = (plugin!.transform as { filter?: { id?: { include?: RegExp[] } } }).filter; + const includePatterns = filter?.id?.include ?? []; + + const idsThatMustMatch = [ + '/build/src/backend/myHandler.backend.ts', + `/build/src/backend/myHandler.backend.ts${LOCAL_EXECUTION_LOAD_SUFFIX}`, + '/build/src/backend/myHandler.backend.ts?x', + `/build/src/backend/myHandler.backend.ts${LOCAL_EXECUTION_LOAD_SUFFIX}&x`, + ]; + + for (const id of idsThatMustMatch) { + expect(includePatterns.some((pattern) => pattern.test(id))).toBe(true); + } + }); + + // Regression test: even though the filter now lets an unrecognized query through, the + // handler must still default to the safe proxy stub for it, not the real backend source. + test('Should still generate the frontend RPC-proxy for an import with an unrecognized query string', async () => { + const plugin = getVitePlugin(defaultOptions); + const transformHandler = getTransformHandler(plugin); + + const result = (await transformHandler.call( + { + parse: parseAst, + resolve: jest.fn(async () => null), + load: jest.fn(async () => null), + addWatchFile: jest.fn(), + }, + 'export function myHandler() { return 42; }', + '/build/src/backend/myHandler.backend.ts?x', + )) as { code: string } | null; + + expect(result?.code).toEqual(expect.stringContaining('executeBackendFunction')); + }); + test('Should inject the apps runtime', () => { getVitePlugin(defaultOptions); diff --git a/packages/plugins/apps/src/vite/index.ts b/packages/plugins/apps/src/vite/index.ts index eaa6aeb7c..8c268f226 100644 --- a/packages/plugins/apps/src/vite/index.ts +++ b/packages/plugins/apps/src/vite/index.ts @@ -19,7 +19,7 @@ import { generateProxyModule } from '../backend/proxy-codegen'; import type { BackendFunction } from '../backend/types'; import { BACKEND_FILE_RE, - LOCAL_EXECUTION_LOAD_RE, + BACKEND_FILE_WITH_QUERY_RE, LOCAL_EXECUTION_LOAD_SUFFIX, PLUGIN_NAME, } from '../constants'; @@ -126,7 +126,7 @@ export const getVitePlugin = ({ transform: { filter: { id: { - include: [BACKEND_FILE_RE, LOCAL_EXECUTION_LOAD_RE], + include: [BACKEND_FILE_WITH_QUERY_RE], exclude: [/node_modules/, /[/\\]dist[/\\]/], }, }, @@ -134,15 +134,20 @@ export const getVitePlugin = ({ // them as backend functions, and replace the module with a // frontend proxy that calls executeBackendFunction at runtime. handler(code, id, transformOptions) { - let normalizedId = id; - if (id.endsWith(LOCAL_EXECUTION_LOAD_SUFFIX)) { - if (transformOptions?.ssr) { - // Local execution needs the real function body, not the RPC-proxy stub generated below. - return null; - } - // A spoofed client-side import like `./secrets.backend.ts?dd-local-exec` falls through to the same safe proxy-stub generation as any other backend file instead — real local-execution loads always go through ssrLoadModule, which runs in SSR context. Strips the suffix first so this registers under the same relativePath/query-name as the file's real (unsuffixed) import, not a second, corrupted entry. - normalizedId = id.slice(0, -LOCAL_EXECUTION_LOAD_SUFFIX.length); + if (id.endsWith(LOCAL_EXECUTION_LOAD_SUFFIX) && transformOptions?.ssr) { + // Local execution needs the real function body, not the RPC-proxy stub generated below. + // Real local-execution loads always go through ssrLoadModule, which runs in SSR + // context, so this only ever fires for that legitimate path. + return null; } + // Any other query on a backend file — no query, the local-execution suffix seen + // outside SSR (a spoofed client-side import like `./secrets.backend.ts?dd-local-exec`), + // or an unrecognized query (`./secrets.backend.ts?x`) — falls through to the same + // safe proxy-stub generation below. Strip the query first so it registers under the + // same relativePath/query-name as the file's real (unsuffixed) import, not a corrupted + // duplicate entry. + const queryIndex = id.indexOf('?'); + const normalizedId = queryIndex === -1 ? id : id.slice(0, queryIndex); const ast = this.parse(code); const exportNames = extractExportedFunctions(ast, normalizedId); From 58c68176d7309df75b827ca2883c38a0c601394f Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Fri, 28 Aug 2026 03:56:15 -0400 Subject: [PATCH 02/30] style(apps): tighten comment prose in local-execution.ts Several comments across this PR's diff were multi-sentence and restated code already visible below them. Trim to one tight sentence each, keeping only the WHY. --- packages/plugins/apps/src/backend/types.ts | 2 +- packages/plugins/apps/src/constants.ts | 8 ++------ packages/plugins/apps/src/vite/index.test.ts | 14 ++++---------- packages/plugins/apps/src/vite/index.ts | 11 ++--------- .../apps/src/vite/local-execution.test.ts | 14 ++++++-------- .../plugins/apps/src/vite/local-execution.ts | 16 ++++++++-------- 6 files changed, 23 insertions(+), 42 deletions(-) diff --git a/packages/plugins/apps/src/backend/types.ts b/packages/plugins/apps/src/backend/types.ts index 95f228d33..2022e8faa 100644 --- a/packages/plugins/apps/src/backend/types.ts +++ b/packages/plugins/apps/src/backend/types.ts @@ -13,5 +13,5 @@ export interface BackendFunction { allowedConnectionIds: string[]; } -/** Shape of a backend function's result, shared by the remote (dev-server.ts) and in-process (local-execution.ts) execution paths — mirrors the Datadog app-builder query response, which wraps a JS action's return value as `{ data: }`. */ +/** Shape of a backend function's result, shared by the remote (dev-server.ts) and in-process (local-execution.ts) paths — mirrors the app-builder query response's `{ data: }` wrapper. */ export type BackendOutputs = { data: unknown }; diff --git a/packages/plugins/apps/src/constants.ts b/packages/plugins/apps/src/constants.ts index d8b56acab..0c16433d8 100644 --- a/packages/plugins/apps/src/constants.ts +++ b/packages/plugins/apps/src/constants.ts @@ -11,13 +11,9 @@ export const APPS_API_PATH = 'api/unstable/app-builder-code/apps'; export const ARCHIVE_FILENAME = 'datadog-apps-assets.zip'; export const BACKEND_FILE_RE = /\.backend\.(ts|tsx|js|jsx)$/; -/** Query suffix marking a local-execution load, so the transform hook below can skip proxy generation for it instead of matching via the broader `options.ssr` flag. */ +/** Query suffix marking a local-execution load, so the transform hook can target it directly instead of matching on the broader `options.ssr` flag. */ export const LOCAL_EXECUTION_LOAD_SUFFIX = '?dd-local-exec'; -// Matches a backend file with or without ANY trailing query string. A filter scoped only to -// the exact local-execution suffix would let an unrecognized query (e.g. `?x`, or a malformed -// `?dd-local-exec&x`) bypass the transform filter entirely, leaving Vite to load the real -// backend source unprocessed instead of the safe RPC-proxy stub. Matching every query here and -// deciding safety in the handler closes that gap. +// Matches a backend file with any (or no) trailing query string — scoping only to the exact local-execution suffix would let an unrecognized query slip past this filter and leak the real backend source instead of the safe proxy stub; the handler decides safety per case. export const BACKEND_FILE_WITH_QUERY_RE = new RegExp( `${BACKEND_FILE_RE.source.slice(0, -1)}(\\?.*)?$`, ); diff --git a/packages/plugins/apps/src/vite/index.test.ts b/packages/plugins/apps/src/vite/index.test.ts index 4daf39267..3b8e6b049 100644 --- a/packages/plugins/apps/src/vite/index.test.ts +++ b/packages/plugins/apps/src/vite/index.test.ts @@ -173,7 +173,7 @@ describe('Backend Functions - getVitePlugin', () => { expect(assets.collectAssets).toHaveBeenCalledWith(['dist/**/*'], '/build'); }); - // Regression test: without the suffix check, ssrLoadModule() would get the RPC-proxy stub instead of the real function body. + // Regression test: without the suffix check, ssrLoadModule() would get the proxy stub instead of the real function body. test('Should skip proxy generation for a suffixed local-execution load made from SSR context, returning the real source untouched', async () => { const plugin = getVitePlugin(defaultOptions); const transformHandler = getTransformHandler(plugin); @@ -194,10 +194,7 @@ describe('Backend Functions - getVitePlugin', () => { expect(result).toBeNull(); }); - // Regression test: the suffix alone must not bypass proxy generation — only real local-execution - // loads (via ssrLoadModule, always SSR context) get the real source; a spoofed client-side import - // using the same suffix (e.g. `./secrets.backend.ts?dd-local-exec`) still gets the safe RPC-proxy - // stub, never the real backend module body. + // Regression test: the suffix alone must not bypass proxy generation — a spoofed client-side import reusing it still gets the safe proxy stub, never the real backend module body. test('Should still generate the frontend RPC-proxy for a suffixed import made outside SSR context', async () => { const plugin = getVitePlugin(defaultOptions); const transformHandler = getTransformHandler(plugin); @@ -234,9 +231,7 @@ describe('Backend Functions - getVitePlugin', () => { expect(result?.code).toEqual(expect.stringContaining('executeBackendFunction')); }); - // Regression test: an unrecognized query string (not exactly the local-execution suffix) - // must still be caught by the transform filter, otherwise Vite falls back to its default - // loader and leaks the real backend source instead of the safe RPC-proxy stub. + // Regression test: an unrecognized query string must still be caught by the transform filter, or Vite falls back to its default loader and leaks the real backend source. test('Transform filter should match a backend file carrying an unrecognized query string', () => { const plugin = getVitePlugin(defaultOptions); const filter = (plugin!.transform as { filter?: { id?: { include?: RegExp[] } } }).filter; @@ -254,8 +249,7 @@ describe('Backend Functions - getVitePlugin', () => { } }); - // Regression test: even though the filter now lets an unrecognized query through, the - // handler must still default to the safe proxy stub for it, not the real backend source. + // Regression test: an unrecognized query must still default to the safe proxy stub, not the real backend source. test('Should still generate the frontend RPC-proxy for an import with an unrecognized query string', async () => { const plugin = getVitePlugin(defaultOptions); const transformHandler = getTransformHandler(plugin); diff --git a/packages/plugins/apps/src/vite/index.ts b/packages/plugins/apps/src/vite/index.ts index 8c268f226..6002db081 100644 --- a/packages/plugins/apps/src/vite/index.ts +++ b/packages/plugins/apps/src/vite/index.ts @@ -135,17 +135,10 @@ export const getVitePlugin = ({ // frontend proxy that calls executeBackendFunction at runtime. handler(code, id, transformOptions) { if (id.endsWith(LOCAL_EXECUTION_LOAD_SUFFIX) && transformOptions?.ssr) { - // Local execution needs the real function body, not the RPC-proxy stub generated below. - // Real local-execution loads always go through ssrLoadModule, which runs in SSR - // context, so this only ever fires for that legitimate path. + // Local execution needs the real function body, not the proxy stub below — real loads always go through ssrLoadModule, which runs in SSR, so this only fires for that legitimate path. return null; } - // Any other query on a backend file — no query, the local-execution suffix seen - // outside SSR (a spoofed client-side import like `./secrets.backend.ts?dd-local-exec`), - // or an unrecognized query (`./secrets.backend.ts?x`) — falls through to the same - // safe proxy-stub generation below. Strip the query first so it registers under the - // same relativePath/query-name as the file's real (unsuffixed) import, not a corrupted - // duplicate entry. + // Any other case (no query, a spoofed client-side import reusing the suffix, or an unrecognized query) falls through to the safe proxy-stub generation below. Strip the query first so it registers under the file's real (unsuffixed) relativePath/query-name, not a duplicate. const queryIndex = id.indexOf('?'); const normalizedId = queryIndex === -1 ? id : id.slice(0, queryIndex); diff --git a/packages/plugins/apps/src/vite/local-execution.test.ts b/packages/plugins/apps/src/vite/local-execution.test.ts index fac1fce34..c445397fb 100644 --- a/packages/plugins/apps/src/vite/local-execution.test.ts +++ b/packages/plugins/apps/src/vite/local-execution.test.ts @@ -31,7 +31,7 @@ interface TestGlobalDollar { Source: { initiator: { id: string; orgId: string }; runAsUser: { id: string; orgId: string } }; } -/** Reads the `$` this module installs onto `globalThis` during an execution, from the customer-code perspective these tests simulate — genuinely untyped from TypeScript's static perspective since it's a runtime-only property (see local-execution.ts's `setGlobalDollar`). Centralized here instead of repeating the same cast at each call site. */ +/** Reads the `$` this module installs on `globalThis`, from the customer-code perspective these tests simulate — untyped since it's a runtime-only property (see `setGlobalDollar`). Centralized here instead of repeating the cast at each call site. */ function testDollar(): TestGlobalDollar { return (globalThis as unknown as { $: TestGlobalDollar }).$; } @@ -107,7 +107,7 @@ describe('local-execution — executeScriptLocally', () => { let dollarDuringModuleLoad: unknown = 'not captured'; const loadModule: LoadModule = async (specifier) => { if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { - // Captures globalThis.$ at module-evaluation time — production's static customer-module import runs before its wrapper installs $, so a customer module reaching for $ during its own top-level evaluation must see the same absence locally, not this execution's own $ installed early. + // Captures globalThis.$ at module-evaluation time — production's static import runs before its wrapper installs $, so code reaching for $ during top-level evaluation must see the same absence locally. dollarDuringModuleLoad = (globalThis as Record).$; return { example: () => 'done' }; } @@ -156,7 +156,7 @@ describe('local-execution — executeScriptLocally', () => { }); test('Should not hang when a customer function returns an un-invoked $.Actions reference instead of calling it', async () => { - // $.Actions.slack.chat is itself a callable Proxy; forgetting the trailing .postMessage(...) call and just returning it must not make `await fn(...args)` treat it as a thenable and hang until the timeout. + // $.Actions.slack.chat is itself a callable Proxy; returning it without the trailing .postMessage(...) call must not make `await fn(...args)` treat it as a thenable and hang. const result = await executeScriptLocally( func, TEST_PROJECT_ROOT, @@ -242,9 +242,7 @@ describe('local-execution — executeScriptLocally', () => { ).rejects.toThrow('boom'); }); - // Regression test: the "late failure" log is meant for an execution abandoned after the caller's own - // await already gave up (see the test below), not every rejection — this one's caller is still waiting - // and receives the same error normally via its own `rejects.toThrow` above. + // Regression test: the "late failure" log fires only for an execution abandoned after the caller stopped waiting (see the test below) — here the caller is still waiting and gets the error via `rejects.toThrow` above. test('Should not log a "caller had already stopped waiting" message for an ordinary, timely rejection', async () => { await expect( executeScriptLocally( @@ -430,7 +428,7 @@ describe('local-execution — executeScriptLocally', () => { mockLogger, ); expect(result).toEqual({ data: [] }); - // Compares via a plain boolean, not a direct .toBe() on the value — $.Actions is a Proxy whose get trap returns another Proxy for every property (including well-known symbols), which crashes Jest's diff formatting if this assertion ever fails and needs to pretty-print it. + // Compares via a plain boolean, not .toBe() directly — $.Actions's get trap returns a Proxy for every property, which crashes Jest's diff formatting if this assertion ever fails. expect(Object.is((globalThis as Record).$, preExisting)).toBe(true); } finally { delete (globalThis as Record).$; @@ -685,7 +683,7 @@ describe('local-execution — executeScriptLocally', () => { ); } - // Known race: two concurrent calls both write globalThis.$ synchronously, so the second write wins for both calls' duration. Skip until calls are serialized through an execution queue. + // Known race: two concurrent calls both write globalThis.$ synchronously, so the second write wins for both — skip until calls are serialized through an execution queue. test.skip("Should let each concurrent call see its OWN backendFunctionArgs via globalThis.$, not the other call's", async () => { const [resultA, resultB] = await Promise.all([ executeScriptLocally( diff --git a/packages/plugins/apps/src/vite/local-execution.ts b/packages/plugins/apps/src/vite/local-execution.ts index d23580de7..cc094e58a 100644 --- a/packages/plugins/apps/src/vite/local-execution.ts +++ b/packages/plugins/apps/src/vite/local-execution.ts @@ -22,7 +22,7 @@ function isIndexableRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; } -/** `globalThis.$` is a runtime-only property TypeScript's built-in `typeof globalThis` has no way to know about — `Reflect.get` reads it without a type assertion, the same way `deleteGlobalDollar` below already avoids one for deletion. */ +/** `globalThis.$` is a runtime-only property `typeof globalThis` doesn't know about; `Reflect.get` reads it without a type assertion, like `deleteGlobalDollar` does for deletion. */ function getGlobalDollar(): unknown { return Reflect.get(globalThis, '$'); } @@ -69,7 +69,7 @@ function assertConnectionIdAllowed( } } -/** Shared validation for both $.Actions entry points (the raw proxy and the action-catalog typed-wrapper dispatcher) — extracted so a future change to this contract can't be applied to one and missed on the other, the exact gap that let the action-catalog path silently forward `inputs: undefined`. */ +/** Shared validation for both $.Actions entry points (raw proxy and action-catalog typed wrapper) — extracted so a contract change can't be applied to one and missed on the other, as happened when the action-catalog path silently forwarded `inputs: undefined`. */ function validateActionCall( call: Partial, allowedConnectionIds: string[], @@ -91,7 +91,7 @@ function makeActionsProxy( ): unknown { return new Proxy(function () {}, { get(_target, prop) { - // A customer function that returns an un-invoked reference (e.g. $.Actions.foo.bar without the trailing call) must not be treated as a thenable — Promise's resolution protocol would call .then() on it and hang until the timeout, since apply() below never settles it. + // An un-invoked reference (e.g. $.Actions.foo.bar with no call) must not look like a thenable, or Promise's resolution protocol calls .then() on it and hangs until timeout. if (prop === 'then') { return undefined; } @@ -188,14 +188,14 @@ export async function executeScriptLocally( }; const run = async (): Promise => { - // Loads and evaluates the customer's module BEFORE installing $ and the SDK bridges below, matching production's own ordering (backend/virtual-entry.ts statically imports the customer module before its wrapper installs $ and the SDK bridges) — code that reaches for $ or a typed action during its own top-level evaluation fails the same way locally as it would in Datadog, instead of silently succeeding against bindings production wouldn't have installed yet. + // Loads the customer module before installing $ and the SDK bridges, matching production's import order (backend/virtual-entry.ts) — code reaching for $ during top-level evaluation fails the same way locally as in Datadog, instead of succeeding early. const mod = await loadModule(func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX); const fn = mod[func.name]; if (typeof fn !== 'function') { throw new Error(`"${func.name}" is not a function exported from ${func.absolutePath}`); } - // Restores whatever globalThis.$ held before this call (or removes it entirely if nothing did) once the execution settles, so a pre-existing global (e.g. from zx/globals) isn't permanently clobbered and a completed execution's own context isn't left reachable by unrelated process code. + // Restores whatever globalThis.$ held before this call (or removes it) once the execution settles, so a pre-existing global (e.g. zx/globals) isn't clobbered and this execution's context isn't left reachable afterward. const hadPreviousDollar = Object.prototype.hasOwnProperty.call(globalThis, '$'); const previousDollar = getGlobalDollar(); setGlobalDollar($); @@ -228,11 +228,11 @@ export async function executeScriptLocally( }, timeoutMs); }); - // Racing against the timeout only stops the caller from waiting — run() keeps executing in-process afterward, so a customer function that resumes post-timeout can still fire real $.Actions side effects. True cancellation requires terminating a Worker thread, not possible for in-process execution. + // Racing the timeout only stops the caller from waiting — run() keeps executing afterward, so a resumed customer function can still fire real $.Actions side effects; true cancellation would need a Worker thread, not possible in-process. const runPromise = run(); - // Set once the race below has settled, so the handler right after can tell a genuinely abandoned rejection (caller already gone) from an ordinary one the caller's own `await Promise.race` is about to receive normally. + // Set once the race settles, so the handler below can tell an abandoned rejection (caller already gone) from an ordinary one the caller is about to receive normally. let raceSettled = false; - // Nothing awaits runPromise once the timeout has already settled the race — an unhandled rejection from it later would otherwise crash the whole dev server process. Logged (not swallowed silently) so a slow real failure is still diagnosable after the caller has already moved on. + // Nothing awaits runPromise once the timeout wins the race, so a later rejection would otherwise crash the dev server as unhandled — logged instead so a slow real failure stays diagnosable. runPromise.catch((error: unknown) => { if (!raceSettled) { return; From 8c20643313da5c25beec662ad5c428d18f1e0984 Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Thu, 27 Aug 2026 01:43:39 -0400 Subject: [PATCH 03/30] =?UTF-8?q?feat(apps):=20harden=20local=20execution?= =?UTF-8?q?=20=E2=80=94=20serialization,=20Source,=20edge=20cases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Serializes concurrent executions to prevent one call's globalThis.$/registration state from leaking into another, gives each execution its own $.Source object, and closes confused-deputy and zombie-execution registration-poisoning gaps where a completed or abandoned execution could still influence a later one's action-catalog or apps-backend dispatch. Also treats .toJSON as a probed property on the $.Actions proxy so JSON.stringify($) doesn't hang. --- .../apps/src/vite/execution-epoch.test.ts | 89 ++ .../plugins/apps/src/vite/execution-epoch.ts | 49 + .../apps/src/vite/local-execution.test.ts | 869 ++++++++++++++++-- .../plugins/apps/src/vite/local-execution.ts | 293 ++++-- 4 files changed, 1194 insertions(+), 106 deletions(-) create mode 100644 packages/plugins/apps/src/vite/execution-epoch.test.ts create mode 100644 packages/plugins/apps/src/vite/execution-epoch.ts diff --git a/packages/plugins/apps/src/vite/execution-epoch.test.ts b/packages/plugins/apps/src/vite/execution-epoch.test.ts new file mode 100644 index 000000000..14935b46d --- /dev/null +++ b/packages/plugins/apps/src/vite/execution-epoch.test.ts @@ -0,0 +1,89 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import { createEpochGuard } from '@dd/apps-plugin/vite/execution-epoch'; + +describe('execution-epoch — createEpochGuard', () => { + test('Should report a fresh scope as current and report no active scope before any start()', () => { + const guard = createEpochGuard(); + expect(guard.hasActiveScope()).toBe(false); + + const scope = guard.start(); + expect(scope.isCurrent()).toBe(true); + expect(guard.hasActiveScope()).toBe(true); + }); + + test('Should invalidate an older scope once a newer one starts', () => { + const guard = createEpochGuard(); + const older = guard.start(); + expect(older.isCurrent()).toBe(true); + + const newer = guard.start(); + expect(older.isCurrent()).toBe(false); + expect(newer.isCurrent()).toBe(true); + expect(guard.hasActiveScope()).toBe(true); + }); + + test('Should make concludeIfCurrent a no-op returning false for an already-superseded scope', () => { + const guard = createEpochGuard(); + const older = guard.start(); + guard.start(); + + expect(older.concludeIfCurrent()).toBe(false); + // The newer scope must be unaffected by the older one's no-op conclude. + expect(guard.hasActiveScope()).toBe(true); + }); + + test('Should conclude a still-current scope, clearing hasActiveScope', () => { + const guard = createEpochGuard(); + const scope = guard.start(); + + expect(scope.concludeIfCurrent()).toBe(true); + expect(scope.isCurrent()).toBe(false); + expect(guard.hasActiveScope()).toBe(false); + }); + + test('Should make a second concludeIfCurrent call on the same scope a no-op', () => { + const guard = createEpochGuard(); + const scope = guard.start(); + + expect(scope.concludeIfCurrent()).toBe(true); + expect(scope.concludeIfCurrent()).toBe(false); + }); + + test('Should invalidate the active scope and clear hasActiveScope on forceInvalidate, without starting a new one', () => { + const guard = createEpochGuard(); + const scope = guard.start(); + + guard.forceInvalidate(); + + expect(scope.isCurrent()).toBe(false); + expect(guard.hasActiveScope()).toBe(false); + }); + + test('Should make forceInvalidate followed by a fresh start() behave like an ordinary new scope', () => { + const guard = createEpochGuard(); + const abandoned = guard.start(); + guard.forceInvalidate(); + + const current = guard.start(); + + expect(abandoned.isCurrent()).toBe(false); + expect(current.isCurrent()).toBe(true); + expect(guard.hasActiveScope()).toBe(true); + + // The abandoned scope's late conclude must not corrupt the new one. + expect(abandoned.concludeIfCurrent()).toBe(false); + expect(current.isCurrent()).toBe(true); + }); + + test('Should keep independently-created guards from sharing any state', () => { + const guardA = createEpochGuard(); + const guardB = createEpochGuard(); + + const scopeA = guardA.start(); + expect(guardB.hasActiveScope()).toBe(false); + expect(scopeA.isCurrent()).toBe(true); + }); +}); diff --git a/packages/plugins/apps/src/vite/execution-epoch.ts b/packages/plugins/apps/src/vite/execution-epoch.ts new file mode 100644 index 000000000..f48762b96 --- /dev/null +++ b/packages/plugins/apps/src/vite/execution-epoch.ts @@ -0,0 +1,49 @@ +// 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. + +/** Generation-counter guard so an abandoned scope's late cleanup can't touch a shared resource a newer scope now owns (used by `network-guard.ts`, `env-guard.ts`, `local-execution.ts`). */ +export interface EpochScope { + /** True until a newer scope starts, or this one (or every scope) is concluded/invalidated. */ + isCurrent(): boolean; + /** Marks no scope active and returns true if still current, otherwise a no-op returning false — call in a `finally` to gate cleanup on still owning the resource. */ + concludeIfCurrent(): boolean; +} + +export interface EpochGuard { + /** Starts a new scope, superseding whichever one was previously active. */ + start(): EpochScope; + /** True if some started scope hasn't yet been concluded or superseded (e.g. for `network-guard.ts`'s `runAllowed`). */ + hasActiveScope(): boolean; + /** Unconditionally invalidates the active scope without starting a new one — the backstop for a scope whose own `fn` never settles. */ + forceInvalidate(): void; +} + +export function createEpochGuard(): EpochGuard { + let currentGeneration = 0; + let activeGeneration: number | null = null; + + return { + start() { + const myGeneration = ++currentGeneration; + activeGeneration = myGeneration; + return { + isCurrent: () => activeGeneration === myGeneration, + concludeIfCurrent: () => { + if (activeGeneration === myGeneration) { + activeGeneration = null; + return true; + } + return false; + }, + }; + }, + hasActiveScope() { + return activeGeneration !== null; + }, + forceInvalidate() { + currentGeneration += 1; + activeGeneration = null; + }, + }; +} diff --git a/packages/plugins/apps/src/vite/local-execution.test.ts b/packages/plugins/apps/src/vite/local-execution.test.ts index c445397fb..6121d5c45 100644 --- a/packages/plugins/apps/src/vite/local-execution.test.ts +++ b/packages/plugins/apps/src/vite/local-execution.test.ts @@ -56,6 +56,8 @@ function loadModuleReturning(exports: Record): LoadModule { }; } +const ORDER_MARKER = '__ddLocalExecutionTestOrder'; + describe('local-execution — executeScriptLocally', () => { test('Should run a simple function in-process and return its result', async () => { const result = await executeScriptLocally( @@ -131,6 +133,23 @@ describe('local-execution — executeScriptLocally', () => { expect(dollarDuringModuleLoad).toBeUndefined(); }); + test('Should reject when loadModule itself rejects, same as a native-module load failure would', async () => { + // Simulates a native addon failing to load at require()/import time, before the function is ever reached — not a customer function throwing. + const loadModule: LoadModule = async () => { + throw new Error('cannot find native module'); + }; + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModule, + mockLogger, + ), + ).rejects.toThrow('cannot find native module'); + }); + test('Should resolve a $.Actions.foo.bar(...) call through the injected executeAction, including connectionId', async () => { const executeAction = jest.fn().mockResolvedValue({ ok: true }); const result = await executeScriptLocally( @@ -155,20 +174,21 @@ describe('local-execution — executeScriptLocally', () => { ); }); - test('Should not hang when a customer function returns an un-invoked $.Actions reference instead of calling it', async () => { - // $.Actions.slack.chat is itself a callable Proxy; returning it without the trailing .postMessage(...) call must not make `await fn(...args)` treat it as a thenable and hang. - const result = await executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ - example: () => testDollar().Actions.slack.chat, - }), - mockLogger, - 20, - ); - expect(result.data).toBeDefined(); + test('Should reject with a clear error, not hang, when a customer function returns an un-invoked $.Actions reference instead of calling it', async () => { + // $.Actions.slack.chat is itself a callable Proxy; forgetting the trailing .postMessage(...) call and just returning it must not make `await fn(...args)` treat it as a thenable and hang until the timeout, nor make assertJsonSerializable's JSON.stringify probe for .toJSON() leak an unhandled rejection — it should surface the same clear, synchronous "can't be serialized" error as any other bare function result. + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ + example: () => testDollar().Actions.slack.chat, + }), + mockLogger, + 20, + ), + ).rejects.toThrow(/JSON\.stringify silently drops/); }); test("Should reject a $.Actions call whose connectionId isn't in the function's allowedConnectionIds", async () => { @@ -413,7 +433,24 @@ describe('local-execution — executeScriptLocally', () => { }); }); - test('Should restore a pre-existing globalThis.$ (e.g. from zx/globals) once the execution completes, not leave the execution context in place permanently', async () => { + test('Should allow a customer module to assign to globalThis.$ (e.g. importing zx/globals) without throwing', async () => { + const result = await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ + example: () => { + (globalThis as Record).$ = { notOurs: true }; + return 'done'; + }, + }), + mockLogger, + ); + expect(result).toEqual({ data: 'done' }); + }); + + test('Should restore a pre-existing globalThis.$ (e.g. from zx/globals) once the execution completes, even if the customer function reassigned it', async () => { const preExisting = { notOurs: true }; (globalThis as Record).$ = preExisting; try { @@ -423,53 +460,59 @@ describe('local-execution — executeScriptLocally', () => { [], stubExecuteAction, loadModuleReturning({ - example: () => testDollar().backendFunctionArgs, + example: () => { + (globalThis as Record).$ = { reassigned: true }; + return 'done'; + }, }), mockLogger, ); - expect(result).toEqual({ data: [] }); - // Compares via a plain boolean, not .toBe() directly — $.Actions's get trap returns a Proxy for every property, which crashes Jest's diff formatting if this assertion ever fails. + expect(result).toEqual({ data: 'done' }); expect(Object.is((globalThis as Record).$, preExisting)).toBe(true); } finally { - delete (globalThis as Record).$; + (globalThis as Record).$ = undefined; } }); - test("Should restore a pre-existing globalThis.$ even when the customer function throws, not leave the execution's context behind", async () => { - const preExisting = { notOurs: true }; - (globalThis as Record).$ = preExisting; - try { - await expect( - executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ - example: () => { - throw new Error('customer function failed'); - }, - }), - mockLogger, - ), - ).rejects.toThrow('customer function failed'); - expect(Object.is((globalThis as Record).$, preExisting)).toBe(true); - } finally { - delete (globalThis as Record).$; - } + test('Should read globalThis.$ as undefined once the execution completes when nothing was defined before it started', async () => { + (globalThis as Record).$ = undefined; + await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ example: () => 'done' }), + mockLogger, + ); + expect((globalThis as Record).$).toBeUndefined(); }); - test('Should remove globalThis.$ once the execution completes when nothing was previously defined there', async () => { - delete (globalThis as Record).$; + test("Should not leak one execution's globalThis.$ override into a later, separately-queued execution", async () => { await executeScriptLocally( func, TEST_PROJECT_ROOT, [], stubExecuteAction, - loadModuleReturning({ example: () => 'done' }), + loadModuleReturning({ + example: () => { + (globalThis as Record).$ = { fromFirstExecution: true }; + return 'first'; + }, + }), + mockLogger, + ); + + const second = await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ + example: () => Object.keys((globalThis as Record).$).sort(), + }), mockLogger, ); - expect(Object.prototype.hasOwnProperty.call(globalThis, '$')).toBe(false); + expect(second).toEqual({ data: ['Actions', 'Source', 'backendFunctionArgs'] }); }); describe('action-catalog / apps-backend registration', () => { @@ -513,6 +556,55 @@ describe('local-execution — executeScriptLocally', () => { ).rejects.toThrow('Unexpected token in action-catalog/action-execution'); }); + // A sibling registration genuinely failing doesn't affect the action-catalog adapter — it's stable and execution-agnostic, so a call made once no execution is active correctly rejects on its own, with no special-case coordination needed between the two registrations. + test('Should still reject a typed-wrapper call through a successfully-registered action-catalog implementation after the sibling apps-backend registration genuinely fails and the execution concludes', async () => { + jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(true); + jest.spyOn(shared, 'isDatadogAppsBackendInstalled').mockReturnValue(true); + let registeredImpl: + | ((actionId: string, request: unknown) => Promise) + | undefined; + + const loadModule: LoadModule = async (specifier: string) => { + if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { + return { example: () => 'unreachable' }; + } + if (specifier === '@datadog/action-catalog/action-execution') { + return { + setExecuteActionImplementation: ( + impl: (actionId: string, request: unknown) => Promise, + ) => { + registeredImpl = impl; + }, + }; + } + if (specifier === '@datadog/apps-backend/runtime/jsFunctionWithActions') { + // A real transform/evaluation failure, not module-not-found — must not be swallowed as "package isn't installed". + throw new Error( + 'Unexpected token in apps-backend/runtime/jsFunctionWithActions', + ); + } + const error: NodeJS.ErrnoException = new Error(`Cannot find module '${specifier}'`); + error.code = 'MODULE_NOT_FOUND'; + throw error; + }; + + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModule, + mockLogger, + ), + ).rejects.toThrow('Unexpected token in apps-backend/runtime/jsFunctionWithActions'); + + expect(registeredImpl).toBeDefined(); + await expect( + registeredImpl?.('com.datadoghq.slack.chat.postMessage', { inputs: {} }), + ).rejects.toThrow(/no active local execution/i); + }); + test('Should route an action-catalog typed-wrapper call through the same injected executeAction', async () => { jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(true); const executeAction = jest.fn().mockResolvedValue({ ok: true }); @@ -646,21 +738,185 @@ describe('local-execution — executeScriptLocally', () => { ).rejects.toThrow(/must have an inputs field/); expect(executeAction).not.toHaveBeenCalled(); }); + + // Mirrors the action-catalog abandonment test — apps-backend's setBackend has the same shared-module-level-setter hazard. + test("Should reject an abandoned execution's apps-backend accessor call once concluded", async () => { + jest.spyOn(shared, 'isDatadogAppsBackendInstalled').mockReturnValue(true); + let abandonedCallOutcome: 'pending' | 'resolved' | { rejected: string } = 'pending'; + let registeredBackend: { get: () => unknown } | undefined; + + const loadModule: LoadModule = async (specifier: string) => { + if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { + return { + example: async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + try { + registeredBackend?.get(); + abandonedCallOutcome = 'resolved'; + } catch (err) { + abandonedCallOutcome = { + rejected: err instanceof Error ? err.message : String(err), + }; + } + return { data: 'abandoned' }; + }, + }; + } + if (specifier === '@datadog/apps-backend/runtime/jsFunctionWithActions') { + return { + // Mirrors the real package's synchronous $.Source validation, so a poisoned proxy passed through here fails the same way. + buildRuntimeFromJsFunctionWithActions: ($: unknown) => { + const source = ($ as Record).Source as + | { initiator?: unknown } + | undefined; + if (!source || typeof source.initiator !== 'object') { + throw new Error( + 'Invalid $.Source supplied to buildRuntimeFromJsFunctionWithActions', + ); + } + return { get: () => source }; + }, + }; + } + if (specifier === '@datadog/apps-backend/runtime') { + return { + setBackend: (runtime: { get: () => unknown }) => { + registeredBackend = runtime; + }, + }; + } + const notFoundError: NodeJS.ErrnoException = new Error( + `Cannot find module '${specifier}'`, + ); + notFoundError.code = 'MODULE_NOT_FOUND'; + throw notFoundError; + }; + + const abandoned = executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModule, + mockLogger, + 20, + ); + await expect(abandoned).rejects.toThrow(/timed out after 20ms/); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(abandonedCallOutcome).toEqual({ + rejected: expect.stringContaining('already concluded'), + }); + }); + }); + + describe('non-serializable results', () => { + test('Should reject with a clear, attributed error when the result has a circular reference', async () => { + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ + example: () => { + const o: Record = {}; + o.self = o; + return o; + }, + }), + mockLogger, + ), + ).rejects.toThrow(/example.*can't be serialized to JSON/); + }); + + test('Should reject with a clear, attributed error when the result contains a BigInt', async () => { + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ example: () => BigInt(10) }), + mockLogger, + ), + ).rejects.toThrow(/example.*can't be serialized to JSON/); + }); + + test('Should reject with a clear, attributed error when the result is a bare function (silently dropped by JSON.stringify)', async () => { + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ example: () => function notSerializable() {} }), + mockLogger, + ), + ).rejects.toThrow(/example.*JSON.stringify silently drops/); + }); + + test('Should allow an explicit undefined result through unchanged', async () => { + const result = await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ example: () => undefined }), + mockLogger, + ); + expect(result).toEqual({ data: undefined }); + }); + + // dev-server.ts serializes the result again for the HTTP response — returning the original (not the parsed round-trip) would invoke a custom toJSON() twice. + test('Should return the JSON-round-tripped value, not the original, so a custom toJSON() is only invoked once', async () => { + let toJsonCallCount = 0; + const result = await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ + example: () => ({ + toJSON() { + toJsonCallCount += 1; + return { callNumber: toJsonCallCount }; + }, + }), + }), + mockLogger, + ); + expect(result).toEqual({ data: { callNumber: 1 } }); + expect(toJsonCallCount).toBe(1); + }); }); describe('serialization of concurrent executions', () => { - function delayedResult(label: T, delayMs: number): () => Promise { - return () => new Promise((resolve) => setTimeout(() => resolve(label), delayMs)); + beforeEach(() => { + delete (globalThis as Record)[ORDER_MARKER]; + }); + + function recordingOrder(label: string, delayMs: number): () => Promise { + return async () => { + const marker = + ((globalThis as Record)[ORDER_MARKER] as string[]) ?? []; + (globalThis as Record)[ORDER_MARKER] = marker; + marker.push(`start-${label}`); + await new Promise((r) => setTimeout(r, delayMs)); + marker.push(`end-${label}`); + return label; + }; } - test("Should allow two independent calls to run without cross-contaminating each other's result", async () => { + test('Should never interleave two concurrent executions — the second never starts until the first fully finishes', async () => { const [resultA, resultB] = await Promise.all([ executeScriptLocally( func, TEST_PROJECT_ROOT, [], stubExecuteAction, - loadModuleReturning({ example: delayedResult('A', 20) }), + loadModuleReturning({ example: recordingOrder('A', 20) }), mockLogger, ), executeScriptLocally( @@ -668,23 +924,40 @@ describe('local-execution — executeScriptLocally', () => { TEST_PROJECT_ROOT, [], stubExecuteAction, - loadModuleReturning({ example: delayedResult('B', 0) }), + loadModuleReturning({ example: recordingOrder('B', 0) }), mockLogger, ), ]); + expect([resultA, resultB]).toEqual([{ data: 'A' }, { data: 'B' }]); + const order = (globalThis as Record)[ORDER_MARKER]; + // Whichever call runs first, its start/end pair must be adjacent — a real race would interleave as [start-A, start-B, end-B, end-A]. + expect(order).toEqual([ + expect.stringMatching(/^start-/), + expect.stringMatching(/^end-/), + expect.stringMatching(/^start-/), + expect.stringMatching(/^end-/), + ]); + expect((order as string[])[0].slice('start-'.length)).toEqual( + (order as string[])[1].slice('end-'.length), + ); + expect((order as string[])[2].slice('start-'.length)).toEqual( + (order as string[])[3].slice('end-'.length), + ); }); - // Reads $.backendFunctionArgs after a delay, which is what would surface cross-contamination between concurrent calls' globalThis.$. function readOwnArgsAfterDelay(delayMs: number): () => Promise { return () => new Promise((resolve) => - setTimeout(() => resolve(testDollar().backendFunctionArgs), delayMs), + setTimeout( + () => resolve((globalThis as Record).$.backendFunctionArgs), + delayMs, + ), ); } - // Known race: two concurrent calls both write globalThis.$ synchronously, so the second write wins for both — skip until calls are serialized through an execution queue. - test.skip("Should let each concurrent call see its OWN backendFunctionArgs via globalThis.$, not the other call's", async () => { + // globalThis.$ is scoped per call via AsyncLocalStorage, independent of the enqueue queue (which exists for the action-catalog/apps-backend module-singleton race). + test("Should let each concurrent call see its OWN backendFunctionArgs via globalThis.$, not the other call's", async () => { const [resultA, resultB] = await Promise.all([ executeScriptLocally( func, @@ -706,5 +979,493 @@ describe('local-execution — executeScriptLocally', () => { expect(resultA).toEqual({ data: ['A-arg'] }); expect(resultB).toEqual({ data: ['B-arg'] }); }); + + test('Should still run the next queued execution after an earlier one rejects', async () => { + const first = executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ + example: () => { + throw new Error('first fails'); + }, + }), + mockLogger, + ); + const second = executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ example: () => 2 }), + mockLogger, + ); + + await expect(first).rejects.toThrow('first fails'); + await expect(second).resolves.toEqual({ data: 2 }); + }); + + // Covers the raw-$.Actions path: a captured Actions reference (e.g. const { Actions } = $) must reject once its own execution is abandoned, even after globalThis.$ is overwritten by a newer execution. + test('Should reject a captured $.Actions reference once its own execution is abandoned, even after a newer execution has taken over', async () => { + let abandonedCallOutcome: 'pending' | 'resolved' | { rejected: string } = 'pending'; + + const abandoned = executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ + example: async () => { + // Captured BEFORE the timeout fires — this execution's own Actions proxy, not whatever globalThis.$ points to later. + const { Actions } = (globalThis as Record).$; + // Outlives the 20ms timeout below, so the caller already sees a rejection by the time this line runs. + await new Promise((resolve) => setTimeout(resolve, 100)); + try { + await Actions.foo.bar({ inputs: {} }); + abandonedCallOutcome = 'resolved'; + } catch (err) { + abandonedCallOutcome = { + rejected: err instanceof Error ? err.message : String(err), + }; + } + return { data: 'abandoned' }; + }, + }), + mockLogger, + 20, + ); + await expect(abandoned).rejects.toThrow(/timed out after 20ms/); + + // The queue is free as soon as the timeout wins — the second execution starts and completes normally, becoming "current". + const second = await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ example: () => 'second' }), + mockLogger, + ); + expect(second).toEqual({ data: 'second' }); + + // Give the abandoned execution's background timer room to fire its action call before asserting on the outcome. + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(abandonedCallOutcome).toEqual({ + rejected: expect.stringContaining('already concluded'), + }); + }); + + test("Should resolve a zombie execution's FRESH read of globalThis.$ to its OWN identity, never a newer execution's — even while that newer execution is still in flight", async () => { + const funcA: BackendFunction = { ...func, allowedConnectionIds: ['conn-A'] }; + const funcB: BackendFunction = { ...func, allowedConnectionIds: ['conn-B'] }; + const executeAction = jest.fn().mockResolvedValue({ ok: true }); + + let zombieOutcome: 'pending' | 'resolved' | { rejected: string } = 'pending'; + + const abandoned = executeScriptLocally( + funcA, + TEST_PROJECT_ROOT, + [], + executeAction, + loadModuleReturning({ + example: async () => { + // Fires ~60ms in, squarely inside funcB's in-flight window — a fresh $ read here needs AsyncLocalStorage, not the abandoned closure check, or it would resolve to funcB's $. + await new Promise((resolve) => setTimeout(resolve, 60)); + const $ = (globalThis as Record).$; + try { + // funcB's own connectionId, not funcA's — only valid if this call incorrectly runs under funcB's still-live identity. + await $.Actions.foo.bar({ inputs: {}, connectionId: 'conn-B' }); + zombieOutcome = 'resolved'; + } catch (err) { + zombieOutcome = { + rejected: err instanceof Error ? err.message : String(err), + }; + } + return 'zombie-done'; + }, + }), + mockLogger, + 20, + ); + await expect(abandoned).rejects.toThrow(/timed out after 20ms/); + + // Starts as soon as the queue frees and stays "current" for 80ms, overlapping the zombie's 60ms wakeup; never itself calls $.Actions, so any observed call must be the zombie's. + const second = executeScriptLocally( + funcB, + TEST_PROJECT_ROOT, + [], + executeAction, + loadModuleReturning({ + example: async () => { + await new Promise((resolve) => setTimeout(resolve, 80)); + return 'second'; + }, + }), + mockLogger, + ); + await expect(second).resolves.toEqual({ data: 'second' }); + + // The zombie's fresh read resolved to its OWN $ (funcA's allowedConnectionIds) — funcB's connectionId under funcA's identity is rejected before reaching executeAction. + expect(zombieOutcome).toEqual({ + rejected: expect.stringContaining("not in this function's allowed connections"), + }); + expect(executeAction).not.toHaveBeenCalled(); + }); + + // Action-catalog holds one executeAction implementation in shared module state — a per-closure abandoned guard can't protect a typed-wrapper call once a newer execution re-registers, so poisonActionCatalogRegistration proactively replaces it with a rejecting stub on conclusion. + test("Should reject an abandoned execution's action-catalog typed-wrapper call, not silently run it under a newer registration", async () => { + jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(true); + let abandonedCallOutcome: 'pending' | 'resolved' | { rejected: string } = 'pending'; + let registeredImpl: + | ((actionId: string, request: unknown) => Promise) + | undefined; + + const loadModule: LoadModule = async (specifier: string) => { + if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { + return { + example: async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + try { + await registeredImpl?.('com.datadoghq.foo.bar', { inputs: {} }); + abandonedCallOutcome = 'resolved'; + } catch (err) { + abandonedCallOutcome = { + rejected: err instanceof Error ? err.message : String(err), + }; + } + return { data: 'abandoned' }; + }, + }; + } + if (specifier === '@datadog/action-catalog/action-execution') { + return { + setExecuteActionImplementation: ( + impl: (actionId: string, request: unknown) => Promise, + ) => { + registeredImpl = impl; + }, + }; + } + const notFoundError: NodeJS.ErrnoException = new Error( + `Cannot find module '${specifier}'`, + ); + notFoundError.code = 'MODULE_NOT_FOUND'; + throw notFoundError; + }; + + const abandoned = executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModule, + mockLogger, + 20, + ); + await expect(abandoned).rejects.toThrow(/timed out after 20ms/); + + // registeredImpl now points at the abandoned execution's own implementation, poisoned by the timeout handler — deliberately no second execution here, to isolate the poison step. + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(abandonedCallOutcome).toEqual({ + rejected: expect.stringContaining('already concluded'), + }); + }); + + // Poisoning only protects the window before a newer execution registers — once it does, its own register() call (correctly, from its own perspective) overwrites the poison stub. A zombie action-catalog call made after that point must still be rejected, not routed through the newer execution's identity/allowedConnectionIds. + test("Should reject a zombie execution's action-catalog typed-wrapper call even after a newer execution has legitimately re-registered its own implementation", async () => { + jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(true); + const funcA: BackendFunction = { ...func, allowedConnectionIds: ['conn-A'] }; + const funcB: BackendFunction = { ...func, allowedConnectionIds: ['conn-B'] }; + const executeAction = jest.fn().mockResolvedValue({ ok: true }); + let registeredImpl: + | ((actionId: string, request: unknown) => Promise) + | undefined; + let zombieOutcome: 'pending' | 'resolved' | { rejected: string } = 'pending'; + + const makeLoadModule = (exampleImpl: () => Promise): LoadModule => { + return async (specifier: string) => { + if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { + return { example: exampleImpl }; + } + if (specifier === '@datadog/action-catalog/action-execution') { + return { + setExecuteActionImplementation: ( + impl: (actionId: string, request: unknown) => Promise, + ) => { + registeredImpl = impl; + }, + }; + } + const notFoundError: NodeJS.ErrnoException = new Error( + `Cannot find module '${specifier}'`, + ); + notFoundError.code = 'MODULE_NOT_FOUND'; + throw notFoundError; + }; + }; + + // Times out at 20ms, then calls the typed wrapper ~60ms in — squarely inside funcB's own in-flight window (funcB registers immediately but doesn't complete, and self-poison, until 80ms) — using conn-B, a connection funcA itself is never allowed to use. + const abandoned = executeScriptLocally( + funcA, + TEST_PROJECT_ROOT, + [], + executeAction, + makeLoadModule(async () => { + await new Promise((resolve) => setTimeout(resolve, 60)); + try { + await registeredImpl?.('com.datadoghq.foo.bar', { + inputs: {}, + connectionId: 'conn-B', + }); + zombieOutcome = 'resolved'; + } catch (err) { + zombieOutcome = { + rejected: err instanceof Error ? err.message : String(err), + }; + } + return 'zombie-done'; + }), + mockLogger, + 20, + ); + await expect(abandoned).rejects.toThrow(/timed out after 20ms/); + + // Starts as soon as the queue frees, registers immediately, but doesn't complete (and self-poison on conclusion) until 80ms — overlapping funcA's 60ms zombie wakeup. + const second = executeScriptLocally( + funcB, + TEST_PROJECT_ROOT, + [], + executeAction, + makeLoadModule(() => new Promise((resolve) => setTimeout(() => resolve('B'), 80))), + mockLogger, + ); + await expect(second).resolves.toEqual({ data: 'B' }); + + // funcB's own registration checks conn-B against funcB's allowedConnectionIds, which passes — the zombie call must not be allowed to reach that registration at all. + expect(zombieOutcome).toEqual({ + rejected: expect.stringContaining('already concluded'), + }); + expect(executeAction).not.toHaveBeenCalled(); + }); + + // The apps-backend loadModule call hangs forever here — a post-Promise.all destructuring assignment would never run, so publishing each handle via .then() is what lets the completed action-catalog registration still get poisoned. + test('Should still register the action-catalog adapter even when the sibling apps-backend registration never settles, and reject a call once no execution is active', async () => { + jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(true); + jest.spyOn(shared, 'isDatadogAppsBackendInstalled').mockReturnValue(true); + let registeredImpl: + | ((actionId: string, request: unknown) => Promise) + | undefined; + + const loadModule: LoadModule = async (specifier: string) => { + if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { + return { example: () => 'unused' }; + } + if (specifier === '@datadog/action-catalog/action-execution') { + return { + setExecuteActionImplementation: ( + impl: (actionId: string, request: unknown) => Promise, + ) => { + registeredImpl = impl; + }, + }; + } + if ( + specifier === '@datadog/apps-backend/runtime/jsFunctionWithActions' || + specifier === '@datadog/apps-backend/runtime' + ) { + return new Promise(() => {}); + } + const notFoundError: NodeJS.ErrnoException = new Error( + `Cannot find module '${specifier}'`, + ); + notFoundError.code = 'MODULE_NOT_FOUND'; + throw notFoundError; + }; + + const abandoned = executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModule, + mockLogger, + 20, + ); + await expect(abandoned).rejects.toThrow(/timed out after 20ms/); + + expect(registeredImpl).toBeDefined(); + await expect(registeredImpl?.('com.datadoghq.foo.bar', { inputs: {} })).rejects.toThrow( + /no active local execution/i, + ); + }); + + // An abandoned execution's fn() can settle normally later — its finally block must not re-poison the registration over whatever a newer execution already put there. + test("Should not let a late-settling abandoned execution's own conclusion clobber a newer execution's already-registered action-catalog implementation", async () => { + jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(true); + let registeredImpl: + | ((actionId: string, request: unknown) => Promise) + | undefined; + + const makeLoadModule = (exampleImpl: () => Promise): LoadModule => { + return async (specifier: string) => { + if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { + return { example: exampleImpl }; + } + if (specifier === '@datadog/action-catalog/action-execution') { + return { + setExecuteActionImplementation: ( + impl: (actionId: string, request: unknown) => Promise, + ) => { + registeredImpl = impl; + }, + }; + } + const notFoundError: NodeJS.ErrnoException = new Error( + `Cannot find module '${specifier}'`, + ); + notFoundError.code = 'MODULE_NOT_FOUND'; + throw notFoundError; + }; + }; + + // Times out at 20ms, but its own fn() resolves normally ~100ms later, well after being abandoned. + const abandoned = executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + makeLoadModule( + () => new Promise((resolve) => setTimeout(() => resolve('A-late'), 100)), + ), + mockLogger, + 20, + ); + await expect(abandoned).rejects.toThrow(/timed out after 20ms/); + + // The queue is free as soon as the timeout wins — the second execution registers and finishes well before the abandoned one's 100ms sleep is up. + const second = await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + makeLoadModule(() => Promise.resolve('B')), + mockLogger, + ); + expect(second).toEqual({ data: 'B' }); + + // Captures whatever B's own conclusion left registered — B poisoning its own registration on completion is fine; nothing else must overwrite it. + const registeredAfterB = registeredImpl; + + // Give the abandoned execution's late-settling fn() and its finally block room to run. + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(registeredImpl).toBe(registeredAfterB); + }); + + // A's slow-to-resolve registration re-installs the same stable, execution-agnostic dispatcher B's own registration already put in place — replacing the closure instance is harmless, since either one resolves a call against whichever execution is actually on the AsyncLocalStorage-scoped call stack, not against whichever registered it. + test("Should still dispatch correctly after a stale execution's slow-to-resolve registration re-installs the adapter following a newer execution's own registration", async () => { + jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(true); + let registeredImpl: + | ((actionId: string, request: unknown) => Promise) + | undefined; + + const makeLoadModule = (actionCatalogDelayMs: number): LoadModule => { + return async (specifier: string) => { + if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { + return { example: () => 'result' }; + } + if (specifier === '@datadog/action-catalog/action-execution') { + if (actionCatalogDelayMs > 0) { + await new Promise((resolve) => + setTimeout(resolve, actionCatalogDelayMs), + ); + } + return { + setExecuteActionImplementation: ( + impl: (actionId: string, request: unknown) => Promise, + ) => { + registeredImpl = impl; + }, + }; + } + const notFoundError: NodeJS.ErrnoException = new Error( + `Cannot find module '${specifier}'`, + ); + notFoundError.code = 'MODULE_NOT_FOUND'; + throw notFoundError; + }; + }; + + // Times out at 20ms, well before its own 100ms-delayed action-catalog module load resolves. + const abandoned = executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + makeLoadModule(100), + mockLogger, + 20, + ); + await expect(abandoned).rejects.toThrow(/timed out after 20ms/); + + // The queue is free as soon as the timeout wins — the second execution registers with no artificial delay, well before A's slow load resolves. + const second = await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + makeLoadModule(0), + mockLogger, + ); + expect(second).toEqual({ data: 'result' }); + + // Give A's slow action-catalog load room to finally resolve and re-install the adapter. + await new Promise((resolve) => setTimeout(resolve, 150)); + + // No execution is active at this point — either closure instance correctly rejects the same way. + await expect(registeredImpl?.('com.datadoghq.foo.bar', { inputs: {} })).rejects.toThrow( + /no active local execution/i, + ); + }); + + // An abandoned execution's loadModule/registration steps might still resolve after timeout — proves the customer function is never invoked once already known-stale. + test('Should never invoke the customer function once already known to be abandoned before it starts', async () => { + let callCount = 0; + const slowLoadModule: LoadModule = async (specifier: string) => { + if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { + // Slower than the 20ms timeout below — by the time this resolves, the execution is already known-abandoned. + await new Promise((resolve) => setTimeout(resolve, 100)); + return { + example: () => { + callCount += 1; + return 'should never run'; + }, + }; + } + const notFoundError: NodeJS.ErrnoException = new Error( + `Cannot find module '${specifier}'`, + ); + notFoundError.code = 'MODULE_NOT_FOUND'; + throw notFoundError; + }; + + const abandoned = executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + slowLoadModule, + mockLogger, + 20, + ); + await expect(abandoned).rejects.toThrow(/timed out after 20ms/); + + // Give the slow loadModule call room to actually resolve. + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(callCount).toBe(0); + }); }); }); diff --git a/packages/plugins/apps/src/vite/local-execution.ts b/packages/plugins/apps/src/vite/local-execution.ts index cc094e58a..95e1d8069 100644 --- a/packages/plugins/apps/src/vite/local-execution.ts +++ b/packages/plugins/apps/src/vite/local-execution.ts @@ -7,11 +7,58 @@ /** Executes a backend function's file directly in-process inside the Vite dev server, mirroring executeScriptViaDatadog's `BackendOutputs` contract in dev-server.ts as a drop-in alternate implementation. */ import type { Logger } from '@dd/core/types'; +import { AsyncLocalStorage } from 'node:async_hooks'; import { isActionCatalogInstalled, isDatadogAppsBackendInstalled } from '../backend/shared'; import type { BackendFunction, BackendOutputs } from '../backend/types'; import { LOCAL_EXECUTION_LOAD_SUFFIX } from '../constants'; +import { createEpochGuard } from './execution-epoch'; + +type BackendGlobals = { + backendFunctionArgs: unknown[]; + Actions: unknown; + Source: ReturnType; +}; + +/** Boxed so a customer module assigning to `globalThis.$` (e.g. importing `zx/globals`, which does exactly this) mutates only its own execution's box, never a concurrent or zombie execution's. */ +type BackendGlobalsBox = { value: unknown }; + +/** Scopes `globalThis.$` per execution via AsyncLocalStorage, not a plain mutable property, so a zombie execution's late "fresh" `globalThis.$` read resolves to its own `$`, never a newer execution's identity/`allowedConnectionIds`. */ +const backendGlobalsContext = new AsyncLocalStorage(); + +/** Backs `globalThis.$` for reads/writes that happen with no execution box on the AsyncLocalStorage-scoped call stack (e.g. this module's own import-time state) — an ordinary mutable slot, since there's no per-execution box to isolate it into. */ +let globalDollarOutsideExecution: unknown; + +Object.defineProperty(globalThis, '$', { + configurable: true, + enumerable: true, + get: () => { + const box = backendGlobalsContext.getStore(); + return box ? box.value : globalDollarOutsideExecution; + }, + set: (value: unknown) => { + const box = backendGlobalsContext.getStore(); + if (box) { + box.value = value; + } else { + globalDollarOutsideExecution = value; + } + }, +}); + +/** What the stable, once-ever-registered action-catalog/apps-backend adapters (below) need to dispatch a typed-wrapper call to the execution that's actually on the AsyncLocalStorage-scoped call stack — kept out of `BackendGlobals` since that object is also `globalThis.$`, directly visible to customer code. */ +type ExecutionDispatch = { + executeAction: ExecuteAction; + allowedConnectionIds: string[]; + isAbandoned: () => boolean; + functionName: string; + $: BackendGlobals; +}; + +/** Distinct from `backendGlobalsContext` so dispatch-only fields (the real `executeAction`, `allowedConnectionIds`) never leak onto `globalThis.$`. */ +const executionDispatchContext = new AsyncLocalStorage(); + interface ActionCallArgs { inputs: Record; connectionId?: string; @@ -22,20 +69,6 @@ function isIndexableRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; } -/** `globalThis.$` is a runtime-only property `typeof globalThis` doesn't know about; `Reflect.get` reads it without a type assertion, like `deleteGlobalDollar` does for deletion. */ -function getGlobalDollar(): unknown { - return Reflect.get(globalThis, '$'); -} - -/** `Object.assign`'s signature doesn't require its source object's keys to already exist on the target, so this installs `$` without asserting `globalThis`'s type. */ -function setGlobalDollar(value: unknown): void { - Object.assign(globalThis, { $: value }); -} - -function deleteGlobalDollar(): void { - Reflect.deleteProperty(globalThis, '$'); -} - const DEFAULT_TIMEOUT_MS = 10_000; /** Loads a module by specifier, resolved against the customer's own project rather than build-plugins' dependency tree — the dev server passes its Vite instance's `ssrLoadModule` here. */ @@ -83,6 +116,21 @@ function validateActionCall( return { inputs, connectionId }; } +/** Local executions are serialized since action-catalog/apps-backend register runtime context via a shared, module-level setter a concurrent execution would clobber, silently redirecting the first's in-flight calls to the wrong identity. */ +let queueTail: Promise = Promise.resolve(); + +function enqueue(run: () => Promise): Promise { + const result = queueTail.then(run); + queueTail = result.then( + () => undefined, + () => undefined, + ); + return result; +} + +/** One shared guard across all local executions — `enqueue` already serializes them, so starting a new scope always supersedes the previous one only after it has already concluded, but the guard's own generation counter is a belt-and-suspenders backstop if that invariant is ever violated. */ +const executionEpoch = createEpochGuard(); + /** Resolves a nested property path (e.g. $.Actions.slack.chat.postMessage) to a callable that invokes `executeAction` directly — no IPC needed since there's no separate process to cross. */ function makeActionsProxy( executeAction: ExecuteAction, @@ -91,8 +139,8 @@ function makeActionsProxy( ): unknown { return new Proxy(function () {}, { get(_target, prop) { - // An un-invoked reference (e.g. $.Actions.foo.bar with no call) must not look like a thenable, or Promise's resolution protocol calls .then() on it and hangs until timeout. - if (prop === 'then') { + // A customer function that returns an un-invoked reference (e.g. $.Actions.foo.bar without the trailing call) must not be mistaken for a thenable or a custom-serializable object — Promise's resolution protocol probes .then(), and JSON.stringify (assertJsonSerializable) probes .toJSON(); either probe calling into the async apply() below would hang until timeout or leak an unhandled rejection instead of surfacing assertJsonSerializable's clear "can't be serialized" error. + if (prop === 'then' || prop === 'toJSON') { return undefined; } return makeActionsProxy( @@ -117,12 +165,29 @@ function makeActionsProxy( }); } -/** No-ops if @datadog/action-catalog isn't installed; checks `isActionCatalogInstalled` up front rather than catching a load failure, since `loadModule` doesn't guarantee an error code for a missing bare specifier. */ -async function registerActionCatalogIfInstalled( +/** Keyed by `loadModule` identity, not a bare module-level flag — a real dev server reuses the same Vite `ssrLoadModule` for its whole lifetime (giving true once-ever registration), while each test constructs its own `loadModule` closure (keeping tests isolated from each other's registration state). A rejection is evicted so the next execution retries, rather than permanently poisoning every later execution with one transient load failure. */ +const actionCatalogRegistrations = new WeakMap>(); + +/** No-ops if @datadog/action-catalog isn't installed. Registers ONE stable dispatcher for the process lifetime — it reads `executionDispatchContext.getStore()` at call time to resolve whichever execution is actually on the AsyncLocalStorage-scoped call stack, so a zombie execution's typed-wrapper call can never be routed through a newer execution's identity/allowedConnectionIds just because that execution's own registration is the one currently live. */ +function registerActionCatalogIfInstalled( + loadModule: LoadModule, + projectRoot: string, +): Promise { + const existing = actionCatalogRegistrations.get(loadModule); + if (existing) { + return existing; + } + const registration = registerActionCatalogOnce(loadModule, projectRoot).catch((err) => { + actionCatalogRegistrations.delete(loadModule); + throw err; + }); + actionCatalogRegistrations.set(loadModule, registration); + return registration; +} + +async function registerActionCatalogOnce( loadModule: LoadModule, projectRoot: string, - executeAction: ExecuteAction, - allowedConnectionIds: string[], ): Promise { if (!isActionCatalogInstalled(projectRoot)) { return; @@ -133,21 +198,49 @@ async function registerActionCatalogIfInstalled( return; } setExecuteActionImplementation(async (actionId: string, request: unknown) => { + const dispatch = executionDispatchContext.getStore(); + if (!dispatch) { + throw new Error(`No active local execution to run "${actionId}" under.`); + } + if (dispatch.isAbandoned()) { + throw new Error( + `Execution of "${dispatch.functionName}" already concluded; refusing to run ` + + `"${actionId}" as this stale execution to avoid using a newer execution's identity.`, + ); + } const call: Partial = isIndexableRecord(request) ? request : {}; const { inputs, connectionId } = validateActionCall( call, - allowedConnectionIds, + dispatch.allowedConnectionIds, `"${actionId}"`, ); - return executeAction(actionId, inputs, connectionId); + return dispatch.executeAction(actionId, inputs, connectionId); + }); +} + +/** Mirrors `actionCatalogRegistrations` — see its doc comment for why keying on `loadModule` identity is safe across both real dev-server reuse and per-test isolation. */ +const backendRuntimeRegistrations = new WeakMap>(); + +/** No-ops if @datadog/apps-backend isn't installed. Registers ONE stable runtime Proxy for the process lifetime — every accessor call resolves whichever execution's `$` is on the AsyncLocalStorage-scoped call stack (or rejects if that execution has concluded), rather than a runtime bound to a specific execution's `$` at registration time. */ +function registerBackendRuntimeIfInstalled( + loadModule: LoadModule, + projectRoot: string, +): Promise { + const existing = backendRuntimeRegistrations.get(loadModule); + if (existing) { + return existing; + } + const registration = registerBackendRuntimeOnce(loadModule, projectRoot).catch((err) => { + backendRuntimeRegistrations.delete(loadModule); + throw err; }); + backendRuntimeRegistrations.set(loadModule, registration); + return registration; } -/** No-ops if @datadog/apps-backend isn't installed; see `registerActionCatalogIfInstalled` for why this checks installedness up front rather than catching a load failure. */ -async function registerBackendRuntimeIfInstalled( +async function registerBackendRuntimeOnce( loadModule: LoadModule, projectRoot: string, - $: unknown, ): Promise { if (!isDatadogAppsBackendInstalled(projectRoot)) { return; @@ -165,10 +258,63 @@ async function registerBackendRuntimeIfInstalled( ) { return; } - setBackend(buildRuntimeFromJsFunctionWithActions($)); + // Built once per execution (cached by dispatch identity), not once per accessor call — dispatch.$ is fixed for its whole execution, so rebuilding on every property access wasted work without changing the result. + const runtimeByDispatch = new WeakMap(); + // Every property access returns a callable, not a value — the real package calls specific methods (e.g. getInitiatingUser()), not just reads properties. + const backendRuntimeProxy = new Proxy( + {}, + { + get(_target, prop) { + return (...args: unknown[]) => { + const dispatch = executionDispatchContext.getStore(); + if (!dispatch || dispatch.isAbandoned()) { + throw new Error( + `Execution of "${dispatch?.functionName ?? 'unknown'}" already concluded; ` + + `refusing to resolve a further apps-backend accessor under its identity.`, + ); + } + let runtime = runtimeByDispatch.get(dispatch); + if (runtime === undefined) { + runtime = buildRuntimeFromJsFunctionWithActions(dispatch.$); + runtimeByDispatch.set(dispatch, runtime); + } + const method = isIndexableRecord(runtime) ? runtime[String(prop)] : undefined; + if (typeof method !== 'function') { + throw new Error(`apps-backend runtime has no method "${String(prop)}"`); + } + return method.apply(runtime, args); + }; + }, + }, + ); + setBackend(backendRuntimeProxy); } -/** `globalThis.$` and the registrations above provide the same customer-visible bindings production's generated wrapper module sets up via text injection. */ +/** Rejects a non-JSON-serializable result (circular reference/`BigInt`, or a bare function/`Symbol` that `JSON.stringify` silently drops) here with a clear error, instead of failing downstream when serialized for the HTTP response. */ +function assertJsonSerializable(result: unknown, func: BackendFunction): unknown { + let serialized: string | undefined; + try { + serialized = JSON.stringify(result); + } catch (err) { + throw new Error( + `Local execution of "${func.name}" returned a value that can't be serialized to JSON: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + if (serialized === undefined) { + if (result !== undefined) { + throw new Error( + `Local execution of "${func.name}" returned a ${typeof result} value, which JSON.stringify silently drops instead of serializing — return a plain JSON-compatible value instead.`, + ); + } + return undefined; + } + // Return the parsed-and-reserialized value, not the original — the caller serializes again for the HTTP response, and the original would invoke a custom toJSON() a second time. + return JSON.parse(serialized); +} + +/** `globalThis.$` and the action-catalog/apps-backend registrations above provide the same customer-visible bindings production's generated wrapper module sets up via text injection; serialized via `enqueue`. */ export async function executeScriptLocally( func: BackendFunction, projectRoot: string, @@ -177,16 +323,58 @@ export async function executeScriptLocally( loadModule: LoadModule, log: Logger, timeoutMs: number = DEFAULT_TIMEOUT_MS, +): Promise { + return enqueue(() => + runScriptLocally(func, projectRoot, args, executeAction, loadModule, log, timeoutMs), + ); +} + +async function runScriptLocally( + func: BackendFunction, + projectRoot: string, + args: unknown[], + executeAction: ExecuteAction, + loadModule: LoadModule, + log: Logger, + timeoutMs: number, ): Promise { // Never log the args themselves — they may carry secrets/PII, matching dev-server.ts's cloud path. log.debug(`Executing "${func.name}" in-process with args`); + // A timed-out execution is abandoned, not cancelled — its fn() may keep running and must not act under a newer execution's identity. The scope's isCurrent() is checked both directly (this execution's own captured `$.Actions` closure) and via `executionDispatchContext` (the stable, shared action-catalog/apps-backend adapters resolve the CALLING execution's own dispatch info from AsyncLocalStorage at call time, so a zombie's call can never be serviced by whichever execution's registration happens to be live). + const scope = executionEpoch.start(); + + const guardedExecuteAction: ExecuteAction = (fqn, inputs, connectionId) => { + if (!scope.isCurrent()) { + // A concluded execution's scope stays concluded forever, not just "not the latest," so the wording stays conclusion-neutral rather than claiming a timeout that may not have happened. + return Promise.reject( + new Error( + `Execution of "${func.name}" already concluded; refusing to run ` + + `"${fqn}" as this stale execution to avoid using a newer execution's identity.`, + ), + ); + } + return executeAction(fqn, inputs, connectionId); + }; + + const concludeExecution = () => { + scope.concludeIfCurrent(); + }; + const $ = { backendFunctionArgs: args, - Actions: makeActionsProxy(executeAction, func.allowedConnectionIds), + Actions: makeActionsProxy(guardedExecuteAction, func.allowedConnectionIds), Source: makeLocalDevSource(), }; + const dispatch: ExecutionDispatch = { + executeAction: guardedExecuteAction, + allowedConnectionIds: func.allowedConnectionIds, + isAbandoned: () => !scope.isCurrent(), + functionName: func.name, + $, + }; + const run = async (): Promise => { // Loads the customer module before installing $ and the SDK bridges, matching production's import order (backend/virtual-entry.ts) — code reaching for $ during top-level evaluation fails the same way locally as in Datadog, instead of succeeding early. const mod = await loadModule(func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX); @@ -195,35 +383,36 @@ export async function executeScriptLocally( throw new Error(`"${func.name}" is not a function exported from ${func.absolutePath}`); } - // Restores whatever globalThis.$ held before this call (or removes it) once the execution settles, so a pre-existing global (e.g. zx/globals) isn't clobbered and this execution's context isn't left reachable afterward. - const hadPreviousDollar = Object.prototype.hasOwnProperty.call(globalThis, '$'); - const previousDollar = getGlobalDollar(); - setGlobalDollar($); - try { - await Promise.all([ - registerActionCatalogIfInstalled( - loadModule, - projectRoot, - executeAction, - func.allowedConnectionIds, - ), - registerBackendRuntimeIfInstalled(loadModule, projectRoot, $), - ]); - - const result = await fn(...args); - return { data: result }; - } finally { - if (hadPreviousDollar) { - setGlobalDollar(previousDollar); - } else { - deleteGlobalDollar(); - } - } + // Scopes globalThis.$ and the action-catalog/apps-backend dispatch info to this call's own async continuation chain — see backendGlobalsContext's and executionDispatchContext's doc comments. + return backendGlobalsContext.run({ value: $ }, () => + executionDispatchContext.run(dispatch, async () => { + try { + // The action-catalog/apps-backend adapters are stable and idempotent to re-register — see their own doc comments — so no coordination is needed between the two registrations or across executions. + await Promise.all([ + registerActionCatalogIfInstalled(loadModule, projectRoot), + registerBackendRuntimeIfInstalled(loadModule, projectRoot), + ]); + + if (!scope.isCurrent()) { + // Already known-abandoned before the customer function was reached — no point invoking it now. + throw new Error( + `Execution of "${func.name}" was abandoned after timing out before it could start.`, + ); + } + const result = await fn(...args); + return { data: assertJsonSerializable(result, func) }; + } finally { + // However this execution ends, mark it concluded so any further dispatch through it — direct or via the shared adapters — is rejected. + concludeExecution(); + } + }), + ); }; let timer: ReturnType | undefined; const timeout = new Promise((_resolve, reject) => { timer = setTimeout(() => { + concludeExecution(); reject(new Error(`Local execution of "${func.name}" timed out after ${timeoutMs}ms`)); }, timeoutMs); }); From 729d8fb766d6186e54efca07e958419126105de0 Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Thu, 27 Aug 2026 01:57:48 -0400 Subject: [PATCH 04/30] fix(apps): forward the real apps-backend runtime's own property shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stable Proxy wrapped every property access in a synthetic callable, assuming the real @datadog/apps-backend runtime is a flat set of methods. It isn't — e.g. user identity is a nested `.user.getExecutionUser()` namespace — so any nested accessor threw "is not a function". Forward each property straight through to the real, dispatch-cached runtime instead. --- .../plugins/apps/src/vite/local-execution.ts | 34 ++++++++----------- 1 file changed, 14 insertions(+), 20 deletions(-) diff --git a/packages/plugins/apps/src/vite/local-execution.ts b/packages/plugins/apps/src/vite/local-execution.ts index 95e1d8069..f2bed0a9a 100644 --- a/packages/plugins/apps/src/vite/local-execution.ts +++ b/packages/plugins/apps/src/vite/local-execution.ts @@ -260,30 +260,24 @@ async function registerBackendRuntimeOnce( } // Built once per execution (cached by dispatch identity), not once per accessor call — dispatch.$ is fixed for its whole execution, so rebuilding on every property access wasted work without changing the result. const runtimeByDispatch = new WeakMap(); - // Every property access returns a callable, not a value — the real package calls specific methods (e.g. getInitiatingUser()), not just reads properties. + // Forwards to whatever shape the real runtime's own property has — a nested namespace (e.g. `.user.getExecutionUser()`) as well as a flat method — rather than assuming every property is itself a callable, which the real @datadog/apps-backend runtime is not. const backendRuntimeProxy = new Proxy( {}, { get(_target, prop) { - return (...args: unknown[]) => { - const dispatch = executionDispatchContext.getStore(); - if (!dispatch || dispatch.isAbandoned()) { - throw new Error( - `Execution of "${dispatch?.functionName ?? 'unknown'}" already concluded; ` + - `refusing to resolve a further apps-backend accessor under its identity.`, - ); - } - let runtime = runtimeByDispatch.get(dispatch); - if (runtime === undefined) { - runtime = buildRuntimeFromJsFunctionWithActions(dispatch.$); - runtimeByDispatch.set(dispatch, runtime); - } - const method = isIndexableRecord(runtime) ? runtime[String(prop)] : undefined; - if (typeof method !== 'function') { - throw new Error(`apps-backend runtime has no method "${String(prop)}"`); - } - return method.apply(runtime, args); - }; + const dispatch = executionDispatchContext.getStore(); + if (!dispatch || dispatch.isAbandoned()) { + throw new Error( + `Execution of "${dispatch?.functionName ?? 'unknown'}" already concluded; ` + + `refusing to resolve a further apps-backend accessor under its identity.`, + ); + } + let runtime = runtimeByDispatch.get(dispatch); + if (runtime === undefined) { + runtime = buildRuntimeFromJsFunctionWithActions(dispatch.$); + runtimeByDispatch.set(dispatch, runtime); + } + return isIndexableRecord(runtime) ? runtime[String(prop)] : undefined; }, }, ); From 2b723291e1a28ea0d956cb8a736d6f619aa04cb4 Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Thu, 27 Aug 2026 02:29:10 -0400 Subject: [PATCH 05/30] fix(apps): address PR review findings on $ seeding, test types, and stale docs Seeds globalDollarOutsideExecution from any globalThis.$ already installed before this module loads (e.g. zx/globals), so installing the accessor doesn't silently discard a pre-existing value. Replaces 4 remaining any-casts in the test file with the existing testDollar() helper, and rewords 10 comments across local-execution.test.ts and execution-epoch.ts that still described the removed poisoning mechanism or named consumer files that don't exist yet. --- .../plugins/apps/src/vite/execution-epoch.ts | 4 +- .../apps/src/vite/local-execution.test.ts | 46 +++++++++++++------ .../plugins/apps/src/vite/local-execution.ts | 4 +- 3 files changed, 35 insertions(+), 19 deletions(-) diff --git a/packages/plugins/apps/src/vite/execution-epoch.ts b/packages/plugins/apps/src/vite/execution-epoch.ts index f48762b96..0578c75c1 100644 --- a/packages/plugins/apps/src/vite/execution-epoch.ts +++ b/packages/plugins/apps/src/vite/execution-epoch.ts @@ -2,7 +2,7 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -/** Generation-counter guard so an abandoned scope's late cleanup can't touch a shared resource a newer scope now owns (used by `network-guard.ts`, `env-guard.ts`, `local-execution.ts`). */ +/** Generation-counter guard so an abandoned scope's late cleanup can't touch a shared resource a newer scope now owns (used by `local-execution.ts`). */ export interface EpochScope { /** True until a newer scope starts, or this one (or every scope) is concluded/invalidated. */ isCurrent(): boolean; @@ -13,7 +13,7 @@ export interface EpochScope { export interface EpochGuard { /** Starts a new scope, superseding whichever one was previously active. */ start(): EpochScope; - /** True if some started scope hasn't yet been concluded or superseded (e.g. for `network-guard.ts`'s `runAllowed`). */ + /** True if some started scope hasn't yet been concluded or superseded. */ hasActiveScope(): boolean; /** Unconditionally invalidates the active scope without starting a new one — the backstop for a scope whose own `fn` never settles. */ forceInvalidate(): void; diff --git a/packages/plugins/apps/src/vite/local-execution.test.ts b/packages/plugins/apps/src/vite/local-execution.test.ts index 6121d5c45..2681fc746 100644 --- a/packages/plugins/apps/src/vite/local-execution.test.ts +++ b/packages/plugins/apps/src/vite/local-execution.test.ts @@ -474,6 +474,25 @@ describe('local-execution — executeScriptLocally', () => { } }); + test('Should seed the outside-execution slot from a globalThis.$ that already existed before this module was first loaded', () => { + const originalDescriptor = Object.getOwnPropertyDescriptor(globalThis, '$'); + const preExisting = { fromZxGlobals: true }; + (globalThis as Record).$ = preExisting; + try { + jest.isolateModules(() => { + // A fresh module instance re-runs its top-level Object.defineProperty, which must read + // the current globalThis.$ (still `preExisting`, via the outer instance's own getter) + // before replacing the descriptor with its own — not start from an empty slot. + require('./local-execution'); + }); + expect((globalThis as Record).$).toBe(preExisting); + } finally { + if (originalDescriptor) { + Object.defineProperty(globalThis, '$', originalDescriptor); + } + } + }); + test('Should read globalThis.$ as undefined once the execution completes when nothing was defined before it started', async () => { (globalThis as Record).$ = undefined; await executeScriptLocally( @@ -508,7 +527,7 @@ describe('local-execution — executeScriptLocally', () => { [], stubExecuteAction, loadModuleReturning({ - example: () => Object.keys((globalThis as Record).$).sort(), + example: () => Object.keys(testDollar()).sort(), }), mockLogger, ); @@ -949,10 +968,7 @@ describe('local-execution — executeScriptLocally', () => { function readOwnArgsAfterDelay(delayMs: number): () => Promise { return () => new Promise((resolve) => - setTimeout( - () => resolve((globalThis as Record).$.backendFunctionArgs), - delayMs, - ), + setTimeout(() => resolve(testDollar().backendFunctionArgs), delayMs), ); } @@ -1018,7 +1034,7 @@ describe('local-execution — executeScriptLocally', () => { loadModuleReturning({ example: async () => { // Captured BEFORE the timeout fires — this execution's own Actions proxy, not whatever globalThis.$ points to later. - const { Actions } = (globalThis as Record).$; + const { Actions } = testDollar(); // Outlives the 20ms timeout below, so the caller already sees a rejection by the time this line runs. await new Promise((resolve) => setTimeout(resolve, 100)); try { @@ -1072,7 +1088,7 @@ describe('local-execution — executeScriptLocally', () => { example: async () => { // Fires ~60ms in, squarely inside funcB's in-flight window — a fresh $ read here needs AsyncLocalStorage, not the abandoned closure check, or it would resolve to funcB's $. await new Promise((resolve) => setTimeout(resolve, 60)); - const $ = (globalThis as Record).$; + const $ = testDollar(); try { // funcB's own connectionId, not funcA's — only valid if this call incorrectly runs under funcB's still-live identity. await $.Actions.foo.bar({ inputs: {}, connectionId: 'conn-B' }); @@ -1113,7 +1129,7 @@ describe('local-execution — executeScriptLocally', () => { expect(executeAction).not.toHaveBeenCalled(); }); - // Action-catalog holds one executeAction implementation in shared module state — a per-closure abandoned guard can't protect a typed-wrapper call once a newer execution re-registers, so poisonActionCatalogRegistration proactively replaces it with a rejecting stub on conclusion. + // Action-catalog's registered dispatcher is stable and execution-agnostic — it resolves the calling execution's own dispatch from AsyncLocalStorage at call time, so a per-closure guard alone (bypassed once a newer execution re-registers) isn't what protects a stale typed-wrapper call. test("Should reject an abandoned execution's action-catalog typed-wrapper call, not silently run it under a newer registration", async () => { jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(true); let abandonedCallOutcome: 'pending' | 'resolved' | { rejected: string } = 'pending'; @@ -1165,7 +1181,7 @@ describe('local-execution — executeScriptLocally', () => { ); await expect(abandoned).rejects.toThrow(/timed out after 20ms/); - // registeredImpl now points at the abandoned execution's own implementation, poisoned by the timeout handler — deliberately no second execution here, to isolate the poison step. + // registeredImpl still points at this (only) execution's own registration — no second execution registers here. The call is rejected because the dispatcher resolves this execution's own dispatch, already concluded by the 20ms timeout. await new Promise((resolve) => setTimeout(resolve, 100)); expect(abandonedCallOutcome).toEqual({ @@ -1173,7 +1189,7 @@ describe('local-execution — executeScriptLocally', () => { }); }); - // Poisoning only protects the window before a newer execution registers — once it does, its own register() call (correctly, from its own perspective) overwrites the poison stub. A zombie action-catalog call made after that point must still be rejected, not routed through the newer execution's identity/allowedConnectionIds. + // registeredImpl comes to point at funcB's own registration once it registers, but a call made from within funcA's own continuation still resolves funcA's own (concluded) dispatch via AsyncLocalStorage — it must still be rejected, not routed through funcB's identity/allowedConnectionIds just because funcB's registration is the one currently referenced. test("Should reject a zombie execution's action-catalog typed-wrapper call even after a newer execution has legitimately re-registered its own implementation", async () => { jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(true); const funcA: BackendFunction = { ...func, allowedConnectionIds: ['conn-A'] }; @@ -1206,7 +1222,7 @@ describe('local-execution — executeScriptLocally', () => { }; }; - // Times out at 20ms, then calls the typed wrapper ~60ms in — squarely inside funcB's own in-flight window (funcB registers immediately but doesn't complete, and self-poison, until 80ms) — using conn-B, a connection funcA itself is never allowed to use. + // Times out at 20ms, then calls the typed wrapper ~60ms in — squarely inside funcB's own in-flight window (funcB registers immediately but doesn't conclude until 80ms) — using conn-B, a connection funcA itself is never allowed to use. const abandoned = executeScriptLocally( funcA, TEST_PROJECT_ROOT, @@ -1232,7 +1248,7 @@ describe('local-execution — executeScriptLocally', () => { ); await expect(abandoned).rejects.toThrow(/timed out after 20ms/); - // Starts as soon as the queue frees, registers immediately, but doesn't complete (and self-poison on conclusion) until 80ms — overlapping funcA's 60ms zombie wakeup. + // Starts as soon as the queue frees, registers immediately, but doesn't conclude until 80ms — overlapping funcA's 60ms zombie wakeup. const second = executeScriptLocally( funcB, TEST_PROJECT_ROOT, @@ -1250,7 +1266,7 @@ describe('local-execution — executeScriptLocally', () => { expect(executeAction).not.toHaveBeenCalled(); }); - // The apps-backend loadModule call hangs forever here — a post-Promise.all destructuring assignment would never run, so publishing each handle via .then() is what lets the completed action-catalog registration still get poisoned. + // The apps-backend loadModule call hangs forever here — a post-Promise.all destructuring assignment would never run, so publishing each handle via .then() as its own promise resolves is what lets the completed action-catalog registration still take effect. test('Should still register the action-catalog adapter even when the sibling apps-backend registration never settles, and reject a call once no execution is active', async () => { jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(true); jest.spyOn(shared, 'isDatadogAppsBackendInstalled').mockReturnValue(true); @@ -1301,7 +1317,7 @@ describe('local-execution — executeScriptLocally', () => { ); }); - // An abandoned execution's fn() can settle normally later — its finally block must not re-poison the registration over whatever a newer execution already put there. + // An abandoned execution's fn() can settle normally later — its finally block's conclude step must not disturb whatever a newer execution's own registration already put in place. test("Should not let a late-settling abandoned execution's own conclusion clobber a newer execution's already-registered action-catalog implementation", async () => { jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(true); let registeredImpl: @@ -1355,7 +1371,7 @@ describe('local-execution — executeScriptLocally', () => { ); expect(second).toEqual({ data: 'B' }); - // Captures whatever B's own conclusion left registered — B poisoning its own registration on completion is fine; nothing else must overwrite it. + // Captures whatever B's own conclusion left registered — B's own registration staying in place after it concludes is fine; nothing else must overwrite it. const registeredAfterB = registeredImpl; // Give the abandoned execution's late-settling fn() and its finally block room to run. diff --git a/packages/plugins/apps/src/vite/local-execution.ts b/packages/plugins/apps/src/vite/local-execution.ts index f2bed0a9a..b671af865 100644 --- a/packages/plugins/apps/src/vite/local-execution.ts +++ b/packages/plugins/apps/src/vite/local-execution.ts @@ -27,8 +27,8 @@ type BackendGlobalsBox = { value: unknown }; /** Scopes `globalThis.$` per execution via AsyncLocalStorage, not a plain mutable property, so a zombie execution's late "fresh" `globalThis.$` read resolves to its own `$`, never a newer execution's identity/`allowedConnectionIds`. */ const backendGlobalsContext = new AsyncLocalStorage(); -/** Backs `globalThis.$` for reads/writes that happen with no execution box on the AsyncLocalStorage-scoped call stack (e.g. this module's own import-time state) — an ordinary mutable slot, since there's no per-execution box to isolate it into. */ -let globalDollarOutsideExecution: unknown; +/** Backs `globalThis.$` for reads/writes that happen with no execution box on the AsyncLocalStorage-scoped call stack (e.g. this module's own import-time state) — an ordinary mutable slot, since there's no per-execution box to isolate it into. Seeded from any `$` already installed before this module loaded (e.g. `zx/globals`, which assigns `globalThis.$` at its own import time), so installing the accessor below doesn't silently discard it. */ +let globalDollarOutsideExecution: unknown = Reflect.get(globalThis, '$'); Object.defineProperty(globalThis, '$', { configurable: true, From c6ae0dc676c53a687f85d09d5d149354a526f979 Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Thu, 27 Aug 2026 03:00:17 -0400 Subject: [PATCH 06/30] fix(apps): reject Map/Set results instead of silently flattening them to {} MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JSON.stringify(new Map(...)) and JSON.stringify(new Set(...)) both return '{}' — a defined string, not undefined — so assertJsonSerializable's existing undefined-check never caught them, silently dropping all of a Map's/Set's entries instead of surfacing the same clear error given to other non-serializable shapes (BigInt, functions, circular references). --- .../apps/src/vite/local-execution.test.ts | 26 +++++++++++++++++++ .../plugins/apps/src/vite/local-execution.ts | 7 ++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/packages/plugins/apps/src/vite/local-execution.test.ts b/packages/plugins/apps/src/vite/local-execution.test.ts index 2681fc746..6f7e73ae9 100644 --- a/packages/plugins/apps/src/vite/local-execution.test.ts +++ b/packages/plugins/apps/src/vite/local-execution.test.ts @@ -876,6 +876,32 @@ describe('local-execution — executeScriptLocally', () => { ).rejects.toThrow(/example.*JSON.stringify silently drops/); }); + test('Should reject with a clear, attributed error when the result is a Map (silently flattened to "{}" by JSON.stringify)', async () => { + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ example: () => new Map([['a', 1]]) }), + mockLogger, + ), + ).rejects.toThrow(/example.*silently flattens/); + }); + + test('Should reject with a clear, attributed error when the result is a Set (silently flattened to "{}" by JSON.stringify)', async () => { + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ example: () => new Set([1, 2, 3]) }), + mockLogger, + ), + ).rejects.toThrow(/example.*silently flattens/); + }); + test('Should allow an explicit undefined result through unchanged', async () => { const result = await executeScriptLocally( func, diff --git a/packages/plugins/apps/src/vite/local-execution.ts b/packages/plugins/apps/src/vite/local-execution.ts index b671af865..91cd85a9c 100644 --- a/packages/plugins/apps/src/vite/local-execution.ts +++ b/packages/plugins/apps/src/vite/local-execution.ts @@ -284,8 +284,13 @@ async function registerBackendRuntimeOnce( setBackend(backendRuntimeProxy); } -/** Rejects a non-JSON-serializable result (circular reference/`BigInt`, or a bare function/`Symbol` that `JSON.stringify` silently drops) here with a clear error, instead of failing downstream when serialized for the HTTP response. */ +/** Rejects a non-JSON-serializable result (circular reference/`BigInt`, a bare function/`Symbol` that `JSON.stringify` silently drops, or a `Map`/`Set` that it silently flattens to `{}` since neither exposes its entries as own enumerable properties) here with a clear error, instead of failing downstream when serialized for the HTTP response. */ function assertJsonSerializable(result: unknown, func: BackendFunction): unknown { + if (result instanceof Map || result instanceof Set) { + throw new Error( + `Local execution of "${func.name}" returned a ${result.constructor.name}, which JSON.stringify silently flattens to "{}" instead of serializing its entries — return a plain array or object instead.`, + ); + } let serialized: string | undefined; try { serialized = JSON.stringify(result); From 377f52919e4a7460b36e59b8f188befd85a9c480 Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Thu, 27 Aug 2026 10:05:30 -0400 Subject: [PATCH 07/30] fix(apps): re-check installedness on every call instead of caching a negative result MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit registerActionCatalogIfInstalled/registerBackendRuntimeIfInstalled cached the 'not installed' outcome in the same WeakMap as a successful registration, keyed by loadModule identity — a dev server reuses the same loadModule for its whole lifetime, so once neither package was found, a customer installing it mid-session (without restarting) got permanently skipped instead of picked up on the next execution. The uncached installedness check is a cheap require.resolve probe; only a *successful* registration needs the once-ever WeakMap treatment. --- .../apps/src/vite/local-execution.test.ts | 49 +++++++++++++++++++ .../plugins/apps/src/vite/local-execution.ts | 30 +++++------- 2 files changed, 61 insertions(+), 18 deletions(-) diff --git a/packages/plugins/apps/src/vite/local-execution.test.ts b/packages/plugins/apps/src/vite/local-execution.test.ts index 6f7e73ae9..055cc923e 100644 --- a/packages/plugins/apps/src/vite/local-execution.test.ts +++ b/packages/plugins/apps/src/vite/local-execution.test.ts @@ -548,6 +548,55 @@ describe('local-execution — executeScriptLocally', () => { expect(result).toEqual({ data: 'fine' }); }); + test('Should pick up action-catalog on the very next execution after it becomes installed mid-session, not stay permanently skipped', async () => { + const isInstalledSpy = jest + .spyOn(shared, 'isActionCatalogInstalled') + .mockReturnValue(false); + let registeredImpl: + | ((actionId: string, request: unknown) => Promise) + | undefined; + const loadModule: LoadModule = async (specifier: string) => { + if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { + return { example: () => 'fine' }; + } + if (specifier === '@datadog/action-catalog/action-execution') { + return { + setExecuteActionImplementation: ( + impl: (actionId: string, request: unknown) => Promise, + ) => { + registeredImpl = impl; + }, + }; + } + const error: NodeJS.ErrnoException = new Error(`Cannot find module '${specifier}'`); + error.code = 'MODULE_NOT_FOUND'; + throw error; + }; + + // Not installed yet — registration is skipped, same as the "neither package installed" case. + await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModule, + mockLogger, + ); + expect(registeredImpl).toBeUndefined(); + + // Simulates `npm install @datadog/action-catalog` without restarting the dev server — the very next execution must register it, not stay permanently skipped from the first (uncached) negative check. + isInstalledSpy.mockReturnValue(true); + await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModule, + mockLogger, + ); + expect(registeredImpl).toBeDefined(); + }); + test('Should propagate a real load failure from an installed action-catalog package, not treat it as absent', async () => { jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(true); const loadModule: LoadModule = async (specifier: string) => { diff --git a/packages/plugins/apps/src/vite/local-execution.ts b/packages/plugins/apps/src/vite/local-execution.ts index 91cd85a9c..918f49098 100644 --- a/packages/plugins/apps/src/vite/local-execution.ts +++ b/packages/plugins/apps/src/vite/local-execution.ts @@ -168,16 +168,19 @@ function makeActionsProxy( /** Keyed by `loadModule` identity, not a bare module-level flag — a real dev server reuses the same Vite `ssrLoadModule` for its whole lifetime (giving true once-ever registration), while each test constructs its own `loadModule` closure (keeping tests isolated from each other's registration state). A rejection is evicted so the next execution retries, rather than permanently poisoning every later execution with one transient load failure. */ const actionCatalogRegistrations = new WeakMap>(); -/** No-ops if @datadog/action-catalog isn't installed. Registers ONE stable dispatcher for the process lifetime — it reads `executionDispatchContext.getStore()` at call time to resolve whichever execution is actually on the AsyncLocalStorage-scoped call stack, so a zombie execution's typed-wrapper call can never be routed through a newer execution's identity/allowedConnectionIds just because that execution's own registration is the one currently live. */ +/** No-ops if @datadog/action-catalog isn't installed — re-checked on every call, uncached, so installing the package mid-session (without restarting the dev server) is picked up on the very next execution instead of staying permanently no-op. Once installed, registers ONE stable dispatcher for the process lifetime — it reads `executionDispatchContext.getStore()` at call time to resolve whichever execution is actually on the AsyncLocalStorage-scoped call stack, so a zombie execution's typed-wrapper call can never be routed through a newer execution's identity/allowedConnectionIds just because that execution's own registration is the one currently live. */ function registerActionCatalogIfInstalled( loadModule: LoadModule, projectRoot: string, ): Promise { + if (!isActionCatalogInstalled(projectRoot)) { + return Promise.resolve(); + } const existing = actionCatalogRegistrations.get(loadModule); if (existing) { return existing; } - const registration = registerActionCatalogOnce(loadModule, projectRoot).catch((err) => { + const registration = registerActionCatalogOnce(loadModule).catch((err) => { actionCatalogRegistrations.delete(loadModule); throw err; }); @@ -185,13 +188,7 @@ function registerActionCatalogIfInstalled( return registration; } -async function registerActionCatalogOnce( - loadModule: LoadModule, - projectRoot: string, -): Promise { - if (!isActionCatalogInstalled(projectRoot)) { - return; - } +async function registerActionCatalogOnce(loadModule: LoadModule): Promise { const mod = await loadModule('@datadog/action-catalog/action-execution'); const setExecuteActionImplementation = mod.setExecuteActionImplementation; if (typeof setExecuteActionImplementation !== 'function') { @@ -221,16 +218,19 @@ async function registerActionCatalogOnce( /** Mirrors `actionCatalogRegistrations` — see its doc comment for why keying on `loadModule` identity is safe across both real dev-server reuse and per-test isolation. */ const backendRuntimeRegistrations = new WeakMap>(); -/** No-ops if @datadog/apps-backend isn't installed. Registers ONE stable runtime Proxy for the process lifetime — every accessor call resolves whichever execution's `$` is on the AsyncLocalStorage-scoped call stack (or rejects if that execution has concluded), rather than a runtime bound to a specific execution's `$` at registration time. */ +/** No-ops if @datadog/apps-backend isn't installed — re-checked on every call, uncached, so installing the package mid-session (without restarting the dev server) is picked up on the very next execution instead of staying permanently no-op. Once installed, registers ONE stable runtime Proxy for the process lifetime — every accessor call resolves whichever execution's `$` is on the AsyncLocalStorage-scoped call stack (or rejects if that execution has concluded), rather than a runtime bound to a specific execution's `$` at registration time. */ function registerBackendRuntimeIfInstalled( loadModule: LoadModule, projectRoot: string, ): Promise { + if (!isDatadogAppsBackendInstalled(projectRoot)) { + return Promise.resolve(); + } const existing = backendRuntimeRegistrations.get(loadModule); if (existing) { return existing; } - const registration = registerBackendRuntimeOnce(loadModule, projectRoot).catch((err) => { + const registration = registerBackendRuntimeOnce(loadModule).catch((err) => { backendRuntimeRegistrations.delete(loadModule); throw err; }); @@ -238,13 +238,7 @@ function registerBackendRuntimeIfInstalled( return registration; } -async function registerBackendRuntimeOnce( - loadModule: LoadModule, - projectRoot: string, -): Promise { - if (!isDatadogAppsBackendInstalled(projectRoot)) { - return; - } +async function registerBackendRuntimeOnce(loadModule: LoadModule): Promise { const [jsFunctionWithActionsModule, runtimeModule] = await Promise.all([ loadModule('@datadog/apps-backend/runtime/jsFunctionWithActions'), loadModule('@datadog/apps-backend/runtime'), From aa26cd554c789d7a44e702964d4335b7b4d85d83 Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Thu, 27 Aug 2026 14:49:43 -0400 Subject: [PATCH 08/30] fix(apps): reject non-finite numbers as a local-execution result MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JSON.stringify silently converts NaN/Infinity to "null" without throwing, unlike every other non-serializable shape this check already catches (Map/Set/BigInt/function/symbol) — a customer bug that produces a non-finite result was returning a silent null instead of a clear, attributed error. --- .../apps/src/vite/local-execution.test.ts | 26 +++++++++++++++++++ .../plugins/apps/src/vite/local-execution.ts | 5 ++++ 2 files changed, 31 insertions(+) diff --git a/packages/plugins/apps/src/vite/local-execution.test.ts b/packages/plugins/apps/src/vite/local-execution.test.ts index 055cc923e..ffb04f04f 100644 --- a/packages/plugins/apps/src/vite/local-execution.test.ts +++ b/packages/plugins/apps/src/vite/local-execution.test.ts @@ -951,6 +951,32 @@ describe('local-execution — executeScriptLocally', () => { ).rejects.toThrow(/example.*silently flattens/); }); + test('Should reject with a clear, attributed error when the result is NaN (silently converted to "null" by JSON.stringify)', async () => { + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ example: () => NaN }), + mockLogger, + ), + ).rejects.toThrow(/example.*silently converts to "null"/); + }); + + test('Should reject with a clear, attributed error when the result is Infinity (silently converted to "null" by JSON.stringify)', async () => { + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ example: () => Infinity }), + mockLogger, + ), + ).rejects.toThrow(/example.*silently converts to "null"/); + }); + test('Should allow an explicit undefined result through unchanged', async () => { const result = await executeScriptLocally( func, diff --git a/packages/plugins/apps/src/vite/local-execution.ts b/packages/plugins/apps/src/vite/local-execution.ts index 918f49098..f04c67d19 100644 --- a/packages/plugins/apps/src/vite/local-execution.ts +++ b/packages/plugins/apps/src/vite/local-execution.ts @@ -285,6 +285,11 @@ function assertJsonSerializable(result: unknown, func: BackendFunction): unknown `Local execution of "${func.name}" returned a ${result.constructor.name}, which JSON.stringify silently flattens to "{}" instead of serializing its entries — return a plain array or object instead.`, ); } + if (typeof result === 'number' && !Number.isFinite(result)) { + throw new Error( + `Local execution of "${func.name}" returned ${result}, which JSON.stringify silently converts to "null" instead of throwing — return a finite number instead.`, + ); + } let serialized: string | undefined; try { serialized = JSON.stringify(result); From 8e27bc34f2816b7ab0ade7fad9a9d31cfefdde32 Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Thu, 27 Aug 2026 16:25:46 -0400 Subject: [PATCH 09/30] fix(apps): preserve this-binding on a flat apps-backend runtime method Reading a runtime property directly off the proxy's target lost its this-binding when called as backend.someMethod(), breaking any real accessor that reads its own state via this instead of a closure. Also distinguishes the apps-backend accessor's "no active execution" case from "execution already concluded" the same way the action-catalog dispatcher already does, instead of reporting a timeout that may not have happened. --- .../apps/src/vite/local-execution.test.ts | 62 +++++++++++++++++++ .../plugins/apps/src/vite/local-execution.ts | 20 +++++- 2 files changed, 79 insertions(+), 3 deletions(-) diff --git a/packages/plugins/apps/src/vite/local-execution.test.ts b/packages/plugins/apps/src/vite/local-execution.test.ts index ffb04f04f..0a99998cf 100644 --- a/packages/plugins/apps/src/vite/local-execution.test.ts +++ b/packages/plugins/apps/src/vite/local-execution.test.ts @@ -877,6 +877,68 @@ describe('local-execution — executeScriptLocally', () => { rejected: expect.stringContaining('already concluded'), }); }); + + // A flat method reading its own internal state via `this` (a real, common accessor + // pattern) must still work when called through the backend-runtime proxy — not just + // arrow-function methods that close over data instead, which every other test here uses. + test('Should preserve `this` when a flat apps-backend runtime method reads its own internal state', async () => { + jest.spyOn(shared, 'isDatadogAppsBackendInstalled').mockReturnValue(true); + let registeredBackend: { getUserId(): string } | undefined; + let capturedUserId: unknown; + + const loadModule: LoadModule = async (specifier: string) => { + if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { + return { + example: () => { + capturedUserId = registeredBackend?.getUserId(); + return 'done'; + }, + }; + } + if (specifier === '@datadog/apps-backend/runtime/jsFunctionWithActions') { + return { + buildRuntimeFromJsFunctionWithActions: () => ({ + userId: 'real-user-id', + // A real accessor pattern: reads its own instance state via `this`, + // not a closure — throws if called unbound. + getUserId() { + if ( + !this || + typeof (this as { userId?: unknown }).userId !== 'string' + ) { + throw new Error('getUserId called with no `this`'); + } + return (this as { userId: string }).userId; + }, + }), + }; + } + if (specifier === '@datadog/apps-backend/runtime') { + return { + setBackend: (runtime: { getUserId(): string }) => { + registeredBackend = runtime; + }, + }; + } + const notFoundError: NodeJS.ErrnoException = new Error( + `Cannot find module '${specifier}'`, + ); + notFoundError.code = 'MODULE_NOT_FOUND'; + throw notFoundError; + }; + + const result = await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModule, + mockLogger, + ); + + expect(result).toEqual({ data: 'done' }); + expect(capturedUserId).toBe('real-user-id'); + }); }); describe('non-serializable results', () => { diff --git a/packages/plugins/apps/src/vite/local-execution.ts b/packages/plugins/apps/src/vite/local-execution.ts index f04c67d19..b5c9ee71b 100644 --- a/packages/plugins/apps/src/vite/local-execution.ts +++ b/packages/plugins/apps/src/vite/local-execution.ts @@ -260,9 +260,14 @@ async function registerBackendRuntimeOnce(loadModule: LoadModule): Promise { get(_target, prop) { const dispatch = executionDispatchContext.getStore(); - if (!dispatch || dispatch.isAbandoned()) { + if (!dispatch) { throw new Error( - `Execution of "${dispatch?.functionName ?? 'unknown'}" already concluded; ` + + `No active local execution to resolve an apps-backend accessor under.`, + ); + } + if (dispatch.isAbandoned()) { + throw new Error( + `Execution of "${dispatch.functionName}" already concluded; ` + `refusing to resolve a further apps-backend accessor under its identity.`, ); } @@ -271,7 +276,16 @@ async function registerBackendRuntimeOnce(loadModule: LoadModule): Promise runtime = buildRuntimeFromJsFunctionWithActions(dispatch.$); runtimeByDispatch.set(dispatch, runtime); } - return isIndexableRecord(runtime) ? runtime[String(prop)] : undefined; + if (!isIndexableRecord(runtime)) { + return undefined; + } + const value = runtime[String(prop)]; + // A flat method (e.g. .getExecutionUser()) reads its own internal state via + // `this` — returning it unbound would call it with `this` bound to this Proxy's + // empty target instead of the real runtime object. A nested namespace property + // (e.g. .user) is returned as-is; its own methods keep correct `this` since the + // real sub-object, not this proxy, is what ends up receiving the call. + return typeof value === 'function' ? value.bind(runtime) : value; }, }, ); From 4594b5692953bd989991df8bba4973f33b5c836f Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Thu, 27 Aug 2026 17:41:58 -0400 Subject: [PATCH 10/30] fix(apps): reject a Map/Set/non-finite number nested anywhere in a local-execution result assertJsonSerializable only rejected a Map, Set, NaN, or Infinity at the top level of a returned result. A JSON.stringify replacer runs on every key/value pair it visits (root included), so checking there catches the same values nested inside a plain object or array too, where JSON.stringify would otherwise silently flatten them to "{}" or "null" instead of throwing. --- .../apps/src/vite/local-execution.test.ts | 39 +++++++++++++++++++ .../plugins/apps/src/vite/local-execution.ts | 31 +++++++++------ 2 files changed, 59 insertions(+), 11 deletions(-) diff --git a/packages/plugins/apps/src/vite/local-execution.test.ts b/packages/plugins/apps/src/vite/local-execution.test.ts index 0a99998cf..b57d9df1d 100644 --- a/packages/plugins/apps/src/vite/local-execution.test.ts +++ b/packages/plugins/apps/src/vite/local-execution.test.ts @@ -1039,6 +1039,45 @@ describe('local-execution — executeScriptLocally', () => { ).rejects.toThrow(/example.*silently converts to "null"/); }); + test('Should reject a Map nested inside a plain object, not just at the top level', async () => { + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ example: () => ({ data: new Map([['a', 1]]) }) }), + mockLogger, + ), + ).rejects.toThrow(/example.*silently flattens/); + }); + + test('Should reject a Set nested inside an array, not just at the top level', async () => { + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ example: () => [1, new Set([1, 2, 3])] }), + mockLogger, + ), + ).rejects.toThrow(/example.*silently flattens/); + }); + + test('Should reject a NaN nested inside a plain object, not just at the top level', async () => { + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ example: () => ({ score: NaN }) }), + mockLogger, + ), + ).rejects.toThrow(/example.*silently converts to "null"/); + }); + test('Should allow an explicit undefined result through unchanged', async () => { const result = await executeScriptLocally( func, diff --git a/packages/plugins/apps/src/vite/local-execution.ts b/packages/plugins/apps/src/vite/local-execution.ts index b5c9ee71b..ac45a5050 100644 --- a/packages/plugins/apps/src/vite/local-execution.ts +++ b/packages/plugins/apps/src/vite/local-execution.ts @@ -293,21 +293,30 @@ async function registerBackendRuntimeOnce(loadModule: LoadModule): Promise } /** Rejects a non-JSON-serializable result (circular reference/`BigInt`, a bare function/`Symbol` that `JSON.stringify` silently drops, or a `Map`/`Set` that it silently flattens to `{}` since neither exposes its entries as own enumerable properties) here with a clear error, instead of failing downstream when serialized for the HTTP response. */ +// Thrown from inside assertJsonSerializable's replacer to carry an already-specific, attributed message straight through the outer catch below, rather than being re-wrapped in its generic "can't be serialized" fallback. +class UnsupportedJsonValueError extends Error {} + function assertJsonSerializable(result: unknown, func: BackendFunction): unknown { - if (result instanceof Map || result instanceof Set) { - throw new Error( - `Local execution of "${func.name}" returned a ${result.constructor.name}, which JSON.stringify silently flattens to "{}" instead of serializing its entries — return a plain array or object instead.`, - ); - } - if (typeof result === 'number' && !Number.isFinite(result)) { - throw new Error( - `Local execution of "${func.name}" returned ${result}, which JSON.stringify silently converts to "null" instead of throwing — return a finite number instead.`, - ); - } let serialized: string | undefined; try { - serialized = JSON.stringify(result); + // A replacer runs on every key/value pair JSON.stringify visits, root included, so a Map/Set/non-finite number nested arbitrarily deep inside the result (e.g. `{ data: new Map() }`) is caught the same way a top-level one is — JSON.stringify would otherwise silently flatten either to "{}" or "null" instead of throwing. + serialized = JSON.stringify(result, (key, value) => { + if (value instanceof Map || value instanceof Set) { + throw new UnsupportedJsonValueError( + `Local execution of "${func.name}" returned a ${value.constructor.name}${key ? ` (at "${key}")` : ''}, which JSON.stringify silently flattens to "{}" instead of serializing its entries — return a plain array or object instead.`, + ); + } + if (typeof value === 'number' && !Number.isFinite(value)) { + throw new UnsupportedJsonValueError( + `Local execution of "${func.name}" returned ${value}${key ? ` (at "${key}")` : ''}, which JSON.stringify silently converts to "null" instead of throwing — return a finite number instead.`, + ); + } + return value; + }); } catch (err) { + if (err instanceof UnsupportedJsonValueError) { + throw err; + } throw new Error( `Local execution of "${func.name}" returned a value that can't be serialized to JSON: ${ err instanceof Error ? err.message : String(err) From b15ff752d77cebc55967372e4efd6978069c6e6d Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Thu, 27 Aug 2026 17:58:10 -0400 Subject: [PATCH 11/30] docs(apps): fix testDollar()'s stale reference to a removed helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit testDollar()'s doc comment pointed at local-execution.ts's setGlobalDollar, which no longer exists — globalThis.$ is now backed by an Object.defineProperty accessor scoped through AsyncLocalStorage, not a plain get/set/delete helper trio. --- packages/plugins/apps/src/vite/local-execution.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/plugins/apps/src/vite/local-execution.test.ts b/packages/plugins/apps/src/vite/local-execution.test.ts index b57d9df1d..f2cfe1e3f 100644 --- a/packages/plugins/apps/src/vite/local-execution.test.ts +++ b/packages/plugins/apps/src/vite/local-execution.test.ts @@ -31,7 +31,7 @@ interface TestGlobalDollar { Source: { initiator: { id: string; orgId: string }; runAsUser: { id: string; orgId: string } }; } -/** Reads the `$` this module installs on `globalThis`, from the customer-code perspective these tests simulate — untyped since it's a runtime-only property (see `setGlobalDollar`). Centralized here instead of repeating the cast at each call site. */ +/** Reads the `$` this module installs onto `globalThis` during an execution, from the customer-code perspective these tests simulate — genuinely untyped from TypeScript's static perspective since it's a runtime-only accessor property local-execution.ts defines via `Object.defineProperty`. Centralized here instead of repeating the same cast at each call site. */ function testDollar(): TestGlobalDollar { return (globalThis as unknown as { $: TestGlobalDollar }).$; } From b5e4076d7b60b72a6ff064363460ae9776d9858c Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Thu, 27 Aug 2026 19:32:17 -0400 Subject: [PATCH 12/30] fix(apps): bound a registration load so it can't permanently poison later executions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A real dev server reuses the same loadModule for its whole process lifetime, memoizing the action-catalog/apps-backend registration per loadModule identity. If the underlying package load never settles (a broken/circular module graph, not just a slow one), the cached promise stays pending forever, and every later execution sharing that loadModule hangs on it until its own timeout — never actually running its function, with no recovery short of a restart. Bounding the load to the execution's own timeoutMs turns an unbounded hang into a rejection, which the existing eviction-on-rejection logic already handles correctly. --- .../apps/src/vite/local-execution.test.ts | 53 +++++++++++++++++ .../plugins/apps/src/vite/local-execution.ts | 58 ++++++++++++++----- 2 files changed, 98 insertions(+), 13 deletions(-) diff --git a/packages/plugins/apps/src/vite/local-execution.test.ts b/packages/plugins/apps/src/vite/local-execution.test.ts index f2cfe1e3f..652a31f11 100644 --- a/packages/plugins/apps/src/vite/local-execution.test.ts +++ b/packages/plugins/apps/src/vite/local-execution.test.ts @@ -1519,6 +1519,59 @@ describe('local-execution — executeScriptLocally', () => { ); }); + // A real dev server reuses the same loadModule for its whole lifetime — a registration load that never settles must not permanently poison every later execution sharing it, so this deliberately reuses one loadModule across two calls instead of each test's usual per-call closure. + test('Should let a later execution register and run after an earlier one shared the same loadModule with a registration load that never settles', async () => { + jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(true); + + let actionCatalogLoadCount = 0; + const loadModule: LoadModule = async (specifier: string) => { + if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { + return { example: () => 'ok' }; + } + if (specifier === '@datadog/action-catalog/action-execution') { + actionCatalogLoadCount += 1; + if (actionCatalogLoadCount === 1) { + // Simulates a genuinely broken/circular module graph, not just a slow one. + return new Promise(() => {}); + } + return { setExecuteActionImplementation: () => {} }; + } + const notFoundError: NodeJS.ErrnoException = new Error( + `Cannot find module '${specifier}'`, + ); + notFoundError.code = 'MODULE_NOT_FOUND'; + throw notFoundError; + }; + + const first = executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModule, + mockLogger, + 20, + ); + await expect(first).rejects.toThrow(/timed out after 20ms/); + + // Gives the first attempt's own registration timeout (also ~20ms, started microseconds after + // the execution's own timeout above) room to fire and evict its cache entry, the same way a + // real dev server's next request would naturally arrive well after that — not racing the two. + await new Promise((resolve) => setTimeout(resolve, 30)); + + // Without evicting the first attempt's still-pending registration, this would hang until it also times out — never actually invoking its own function. + const second = await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModule, + mockLogger, + 50, + ); + expect(second).toEqual({ data: 'ok' }); + }); + // An abandoned execution's fn() can settle normally later — its finally block's conclude step must not disturb whatever a newer execution's own registration already put in place. test("Should not let a late-settling abandoned execution's own conclusion clobber a newer execution's already-registered action-catalog implementation", async () => { jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(true); diff --git a/packages/plugins/apps/src/vite/local-execution.ts b/packages/plugins/apps/src/vite/local-execution.ts index ac45a5050..55b3f6d58 100644 --- a/packages/plugins/apps/src/vite/local-execution.ts +++ b/packages/plugins/apps/src/vite/local-execution.ts @@ -165,13 +165,33 @@ function makeActionsProxy( }); } -/** Keyed by `loadModule` identity, not a bare module-level flag — a real dev server reuses the same Vite `ssrLoadModule` for its whole lifetime (giving true once-ever registration), while each test constructs its own `loadModule` closure (keeping tests isolated from each other's registration state). A rejection is evicted so the next execution retries, rather than permanently poisoning every later execution with one transient load failure. */ +/** Bounds a registration's underlying `loadModule` call to `timeoutMs` so a load that never settles (a broken/circular module graph, not just a slow one) rejects instead of leaving its cache entry pending forever — the existing eviction-on-rejection below only fires once the promise actually settles, and an unbounded load never does. Doesn't cancel the underlying promise (not possible for a plain `Promise`), so a load that eventually does settle still runs its side effects late; see the registration functions' own doc comments for why that's harmless here. */ +function withTimeout(promise: Promise, timeoutMs: number, what: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`Loading ${what} timed out after ${timeoutMs}ms`)); + }, timeoutMs); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (err: unknown) => { + clearTimeout(timer); + reject(err); + }, + ); + }); +} + +/** Keyed by `loadModule` identity, not a bare module-level flag — a real dev server reuses the same Vite `ssrLoadModule` for its whole lifetime (giving true once-ever registration), while each test constructs its own `loadModule` closure (keeping tests isolated from each other's registration state). A rejection is evicted so the next execution retries, rather than permanently poisoning every later execution with one transient load failure — including a load that never settles at all, since `withTimeout` below turns that into a rejection too. */ const actionCatalogRegistrations = new WeakMap>(); /** No-ops if @datadog/action-catalog isn't installed — re-checked on every call, uncached, so installing the package mid-session (without restarting the dev server) is picked up on the very next execution instead of staying permanently no-op. Once installed, registers ONE stable dispatcher for the process lifetime — it reads `executionDispatchContext.getStore()` at call time to resolve whichever execution is actually on the AsyncLocalStorage-scoped call stack, so a zombie execution's typed-wrapper call can never be routed through a newer execution's identity/allowedConnectionIds just because that execution's own registration is the one currently live. */ function registerActionCatalogIfInstalled( loadModule: LoadModule, projectRoot: string, + timeoutMs: number, ): Promise { if (!isActionCatalogInstalled(projectRoot)) { return Promise.resolve(); @@ -180,7 +200,7 @@ function registerActionCatalogIfInstalled( if (existing) { return existing; } - const registration = registerActionCatalogOnce(loadModule).catch((err) => { + const registration = registerActionCatalogOnce(loadModule, timeoutMs).catch((err) => { actionCatalogRegistrations.delete(loadModule); throw err; }); @@ -188,8 +208,12 @@ function registerActionCatalogIfInstalled( return registration; } -async function registerActionCatalogOnce(loadModule: LoadModule): Promise { - const mod = await loadModule('@datadog/action-catalog/action-execution'); +async function registerActionCatalogOnce(loadModule: LoadModule, timeoutMs: number): Promise { + const mod = await withTimeout( + loadModule('@datadog/action-catalog/action-execution'), + timeoutMs, + '@datadog/action-catalog/action-execution', + ); const setExecuteActionImplementation = mod.setExecuteActionImplementation; if (typeof setExecuteActionImplementation !== 'function') { return; @@ -215,13 +239,14 @@ async function registerActionCatalogOnce(loadModule: LoadModule): Promise }); } -/** Mirrors `actionCatalogRegistrations` — see its doc comment for why keying on `loadModule` identity is safe across both real dev-server reuse and per-test isolation. */ +/** Mirrors `actionCatalogRegistrations` — see its doc comment for why keying on `loadModule` identity is safe across both real dev-server reuse and per-test isolation, and for why an unbounded load is treated as a rejection via `withTimeout`. */ const backendRuntimeRegistrations = new WeakMap>(); /** No-ops if @datadog/apps-backend isn't installed — re-checked on every call, uncached, so installing the package mid-session (without restarting the dev server) is picked up on the very next execution instead of staying permanently no-op. Once installed, registers ONE stable runtime Proxy for the process lifetime — every accessor call resolves whichever execution's `$` is on the AsyncLocalStorage-scoped call stack (or rejects if that execution has concluded), rather than a runtime bound to a specific execution's `$` at registration time. */ function registerBackendRuntimeIfInstalled( loadModule: LoadModule, projectRoot: string, + timeoutMs: number, ): Promise { if (!isDatadogAppsBackendInstalled(projectRoot)) { return Promise.resolve(); @@ -230,7 +255,7 @@ function registerBackendRuntimeIfInstalled( if (existing) { return existing; } - const registration = registerBackendRuntimeOnce(loadModule).catch((err) => { + const registration = registerBackendRuntimeOnce(loadModule, timeoutMs).catch((err) => { backendRuntimeRegistrations.delete(loadModule); throw err; }); @@ -238,11 +263,18 @@ function registerBackendRuntimeIfInstalled( return registration; } -async function registerBackendRuntimeOnce(loadModule: LoadModule): Promise { - const [jsFunctionWithActionsModule, runtimeModule] = await Promise.all([ - loadModule('@datadog/apps-backend/runtime/jsFunctionWithActions'), - loadModule('@datadog/apps-backend/runtime'), - ]); +async function registerBackendRuntimeOnce( + loadModule: LoadModule, + timeoutMs: number, +): Promise { + const [jsFunctionWithActionsModule, runtimeModule] = await withTimeout( + Promise.all([ + loadModule('@datadog/apps-backend/runtime/jsFunctionWithActions'), + loadModule('@datadog/apps-backend/runtime'), + ]), + timeoutMs, + '@datadog/apps-backend/runtime', + ); const buildRuntimeFromJsFunctionWithActions = jsFunctionWithActionsModule.buildRuntimeFromJsFunctionWithActions; const setBackend = runtimeModule.setBackend; @@ -410,8 +442,8 @@ async function runScriptLocally( try { // The action-catalog/apps-backend adapters are stable and idempotent to re-register — see their own doc comments — so no coordination is needed between the two registrations or across executions. await Promise.all([ - registerActionCatalogIfInstalled(loadModule, projectRoot), - registerBackendRuntimeIfInstalled(loadModule, projectRoot), + registerActionCatalogIfInstalled(loadModule, projectRoot, timeoutMs), + registerBackendRuntimeIfInstalled(loadModule, projectRoot, timeoutMs), ]); if (!scope.isCurrent()) { From 5db2f30102ddb527b4780b616022bda90072eefa Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Thu, 27 Aug 2026 21:44:28 -0400 Subject: [PATCH 13/30] fix(apps): fail loudly on globalThis.$ access outside an active execution A customer module's own top-level evaluation runs before this execution's box exists, and previously fell back to a plain undefined read instead of failing the way a real Datadog deployment does at that same point. Also reinstalls the accessor if a prior execution's customer code deleted globalThis.$, so that deletion doesn't permanently break every later execution in the same dev-server process. --- .../apps/src/vite/local-execution.test.ts | 173 +++++++++++++++++- .../plugins/apps/src/vite/local-execution.ts | 89 +++++++-- 2 files changed, 238 insertions(+), 24 deletions(-) diff --git a/packages/plugins/apps/src/vite/local-execution.test.ts b/packages/plugins/apps/src/vite/local-execution.test.ts index 652a31f11..2c5be8124 100644 --- a/packages/plugins/apps/src/vite/local-execution.test.ts +++ b/packages/plugins/apps/src/vite/local-execution.test.ts @@ -105,12 +105,18 @@ describe('local-execution — executeScriptLocally', () => { ).rejects.toThrow(`"example" is not a function exported from ${func.absolutePath}`); }); - test('Should load and evaluate the customer module before installing globalThis.$, matching production module-evaluation order', async () => { - let dollarDuringModuleLoad: unknown = 'not captured'; + test('Should throw when a customer module reaches for $ during its own top-level evaluation, matching production module-evaluation order', async () => { + let dollarAccessError: unknown = 'not captured'; const loadModule: LoadModule = async (specifier) => { if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { - // Captures globalThis.$ at module-evaluation time — production's static import runs before its wrapper installs $, so code reaching for $ during top-level evaluation must see the same absence locally. - dollarDuringModuleLoad = (globalThis as Record).$; + // Production's static customer-module import runs before its wrapper installs $, so a + // customer module reaching for $ during its own top-level evaluation fails there too — + // this must fail the same way locally instead of silently resolving to undefined. + try { + dollarAccessError = (globalThis as Record).$; + } catch (error) { + dollarAccessError = error; + } return { example: () => 'done' }; } const notFoundError: NodeJS.ErrnoException = new Error( @@ -130,7 +136,125 @@ describe('local-execution — executeScriptLocally', () => { ); expect(result).toEqual({ data: 'done' }); - expect(dollarDuringModuleLoad).toBeUndefined(); + expect(dollarAccessError).toBeInstanceOf(Error); + expect((dollarAccessError as Error).message).toBe( + 'No active local execution to resolve $ under.', + ); + }); + + test("Should return a pre-existing globalThis.$ during a customer module's top-level evaluation when something (e.g. zx/globals) seeded it before this module loaded", async () => { + const originalDescriptor = Object.getOwnPropertyDescriptor(globalThis, '$'); + const preExisting = { fromZxGlobals: true }; + (globalThis as Record).$ = preExisting; + let isolatedExecuteScriptLocally!: typeof executeScriptLocally; + try { + jest.isolateModules(() => { + // A fresh module instance re-runs its top-level Reflect.has check with preExisting + // already in place, capturing hadPreexistingDollar=true — the outer instance every other + // test in this file uses was imported before any test set globalThis.$, so it can't + // exercise this path. + isolatedExecuteScriptLocally = require('./local-execution').executeScriptLocally; + }); + + let dollarDuringModuleLoad: unknown = 'not captured'; + const loadModule: LoadModule = async (specifier) => { + if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { + dollarDuringModuleLoad = (globalThis as Record).$; + return { example: () => 'done' }; + } + throw new Error(`Cannot find module '${specifier}'`); + }; + + const result = await isolatedExecuteScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModule, + mockLogger, + ); + expect(result).toEqual({ data: 'done' }); + expect(dollarDuringModuleLoad).toBe(preExisting); + } finally { + if (originalDescriptor) { + Object.defineProperty(globalThis, '$', originalDescriptor); + } else { + delete (globalThis as Record).$; + } + } + }); + + test('Should reinstall the $ accessor if a customer execution deleted globalThis.$, so a later execution can still use it', async () => { + await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ + example: () => { + delete (globalThis as Record).$; + return 'first'; + }, + }), + mockLogger, + ); + + const second = await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ example: () => testDollar().backendFunctionArgs }), + mockLogger, + ); + expect(second).toEqual({ data: [] }); + }); + + test("Should not leak one execution's top-level zx/globals-style $ write into a later execution's own top-level load", async () => { + await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + (async (specifier: string) => { + if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { + // Simulates a customer module's own top-level side effect (e.g. `import 'zx/globals'`) writing $ before this execution's box exists. + (globalThis as Record).$ = { + fromFirstExecutionTopLevel: true, + }; + return { example: () => 'first' }; + } + throw new Error(`Cannot find module '${specifier}'`); + }) as LoadModule, + mockLogger, + ); + + let dollarDuringSecondLoad: unknown = 'not captured'; + let secondLoadError: unknown; + await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + (async (specifier: string) => { + if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { + try { + dollarDuringSecondLoad = (globalThis as Record).$; + } catch (error) { + secondLoadError = error; + } + return { example: () => 'second' }; + } + throw new Error(`Cannot find module '${specifier}'`); + }) as LoadModule, + mockLogger, + ); + + expect(dollarDuringSecondLoad).toBe('not captured'); + expect(secondLoadError).toBeInstanceOf(Error); + expect((secondLoadError as Error).message).toBe( + 'No active local execution to resolve $ under.', + ); }); test('Should reject when loadModule itself rejects, same as a native-module load failure would', async () => { @@ -1078,6 +1202,45 @@ describe('local-execution — executeScriptLocally', () => { ).rejects.toThrow(/example.*silently converts to "null"/); }); + test('Should reject a function nested inside a plain object, not just at the top level', async () => { + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ example: () => ({ status: 'ok', callback: () => {} }) }), + mockLogger, + ), + ).rejects.toThrow(/example.*JSON.stringify silently drops/); + }); + + test('Should reject an explicit undefined nested inside a plain object, not just at the top level', async () => { + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ example: () => ({ status: 'ok', extra: undefined }) }), + mockLogger, + ), + ).rejects.toThrow(/example.*JSON.stringify silently drops/); + }); + + test('Should reject a Symbol nested inside an array, not just at the top level', async () => { + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ example: () => [1, Symbol('unsupported')] }), + mockLogger, + ), + ).rejects.toThrow(/example.*JSON.stringify silently drops/); + }); + test('Should allow an explicit undefined result through unchanged', async () => { const result = await executeScriptLocally( func, diff --git a/packages/plugins/apps/src/vite/local-execution.ts b/packages/plugins/apps/src/vite/local-execution.ts index 55b3f6d58..c70af43e0 100644 --- a/packages/plugins/apps/src/vite/local-execution.ts +++ b/packages/plugins/apps/src/vite/local-execution.ts @@ -27,25 +27,63 @@ type BackendGlobalsBox = { value: unknown }; /** Scopes `globalThis.$` per execution via AsyncLocalStorage, not a plain mutable property, so a zombie execution's late "fresh" `globalThis.$` read resolves to its own `$`, never a newer execution's identity/`allowedConnectionIds`. */ const backendGlobalsContext = new AsyncLocalStorage(); -/** Backs `globalThis.$` for reads/writes that happen with no execution box on the AsyncLocalStorage-scoped call stack (e.g. this module's own import-time state) — an ordinary mutable slot, since there's no per-execution box to isolate it into. Seeded from any `$` already installed before this module loaded (e.g. `zx/globals`, which assigns `globalThis.$` at its own import time), so installing the accessor below doesn't silently discard it. */ +/** Whether something (e.g. `zx/globals`, which assigns `globalThis.$` at its own import time) installed `$` before this module's own accessor below — distinguishes that legitimate passthrough from a customer module reaching for `$` during its own top-level evaluation, which has no such prior value and should fail the same way production does. */ +const hadPreexistingDollar = Reflect.has(globalThis, '$'); + +/** Marks specifically the window where a customer module's own top-level code (import-time side effects, evaluated before this execution's box exists) is loading — narrower than "no box on the call stack," which is also true genuinely between executions, where the old undefined-returning fallback below is still correct. Carries its own mutable box (not just a boolean marker) so a top-level write during this window — e.g. `zx/globals`, which assigns `globalThis.$` at its own import time — lands in a box scoped to *this* module's own load, not the shared `globalDollarOutsideExecution` slot a later, unrelated execution's own top-level load would also read from. */ +const customerModuleLoadContext = new AsyncLocalStorage<{ assigned: boolean; value: unknown }>(); + +/** Backs `globalThis.$` for reads/writes that happen with no execution box on the AsyncLocalStorage-scoped call stack (e.g. this module's own import-time state) — an ordinary mutable slot, since there's no per-execution box to isolate it into. Seeded from any `$` already installed before this module loaded, so installing the accessor below doesn't silently discard a legitimate `zx/globals`-style passthrough. */ let globalDollarOutsideExecution: unknown = Reflect.get(globalThis, '$'); -Object.defineProperty(globalThis, '$', { - configurable: true, - enumerable: true, - get: () => { - const box = backendGlobalsContext.getStore(); - return box ? box.value : globalDollarOutsideExecution; - }, - set: (value: unknown) => { - const box = backendGlobalsContext.getStore(); - if (box) { - box.value = value; - } else { - globalDollarOutsideExecution = value; +function ensureDollarAccessorInstalled(): void { + if (Object.getOwnPropertyDescriptor(globalThis, '$')?.get === dollarGetter) { + return; + } + Object.defineProperty(globalThis, '$', { + configurable: true, + enumerable: true, + get: dollarGetter, + set: dollarSetter, + }); +} + +function dollarGetter(): unknown { + const box = backendGlobalsContext.getStore(); + if (box) { + return box.value; + } + const loadBox = customerModuleLoadContext.getStore(); + if (loadBox) { + if (loadBox.assigned) { + return loadBox.value; + } + if (hadPreexistingDollar) { + return globalDollarOutsideExecution; } - }, -}); + // Matches production: a customer module's own top-level evaluation runs before production installs $, so referencing it fails loudly there too, instead of silently resolving to undefined. + throw new Error('No active local execution to resolve $ under.'); + } + return globalDollarOutsideExecution; +} + +function dollarSetter(value: unknown): void { + const box = backendGlobalsContext.getStore(); + if (box) { + box.value = value; + return; + } + const loadBox = customerModuleLoadContext.getStore(); + if (loadBox) { + // Scoped to this one module load, not the shared globalDollarOutsideExecution slot — otherwise a customer module's own top-level write (e.g. zx/globals) would leak into every later, unrelated execution's own top-level load instead of staying local to this one. + loadBox.assigned = true; + loadBox.value = value; + return; + } + globalDollarOutsideExecution = value; +} + +ensureDollarAccessorInstalled(); /** What the stable, once-ever-registered action-catalog/apps-backend adapters (below) need to dispatch a typed-wrapper call to the execution that's actually on the AsyncLocalStorage-scoped call stack — kept out of `BackendGlobals` since that object is also `globalThis.$`, directly visible to customer code. */ type ExecutionDispatch = { @@ -331,7 +369,7 @@ class UnsupportedJsonValueError extends Error {} function assertJsonSerializable(result: unknown, func: BackendFunction): unknown { let serialized: string | undefined; try { - // A replacer runs on every key/value pair JSON.stringify visits, root included, so a Map/Set/non-finite number nested arbitrarily deep inside the result (e.g. `{ data: new Map() }`) is caught the same way a top-level one is — JSON.stringify would otherwise silently flatten either to "{}" or "null" instead of throwing. + // A replacer runs on every key/value pair JSON.stringify visits, root included, so a Map/Set/non-finite number/function/Symbol/undefined nested arbitrarily deep inside the result (e.g. `{ data: new Map() }` or `{ status: 'ok', callback: () => {} }`) is caught the same way a top-level one is — JSON.stringify would otherwise silently flatten, convert, omit, or null out the offending value instead of throwing. The root call (`key === ''`) is excluded from the function/Symbol/undefined check below since a root result of exactly one of those types is a distinct, allowed case handled after this call via the `serialized === undefined` branch. serialized = JSON.stringify(result, (key, value) => { if (value instanceof Map || value instanceof Set) { throw new UnsupportedJsonValueError( @@ -343,6 +381,14 @@ function assertJsonSerializable(result: unknown, func: BackendFunction): unknown `Local execution of "${func.name}" returned ${value}${key ? ` (at "${key}")` : ''}, which JSON.stringify silently converts to "null" instead of throwing — return a finite number instead.`, ); } + if ( + key !== '' && + (typeof value === 'function' || typeof value === 'symbol' || value === undefined) + ) { + throw new UnsupportedJsonValueError( + `Local execution of "${func.name}" returned a ${typeof value} (at "${key}"), which JSON.stringify silently drops instead of serializing — return a plain JSON-compatible value instead.`, + ); + } return value; }); } catch (err) { @@ -429,13 +475,18 @@ async function runScriptLocally( }; const run = async (): Promise => { - // Loads the customer module before installing $ and the SDK bridges, matching production's import order (backend/virtual-entry.ts) — code reaching for $ during top-level evaluation fails the same way locally as in Datadog, instead of succeeding early. - const mod = await loadModule(func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX); + // Loads and evaluates the customer's module BEFORE installing $ and the SDK bridges below, matching production's own ordering (backend/virtual-entry.ts statically imports the customer module before its wrapper installs $ and the SDK bridges) — code that reaches for $ or a typed action during its own top-level evaluation fails the same way locally as it would in Datadog, instead of silently succeeding against bindings production wouldn't have installed yet. + const mod = await customerModuleLoadContext.run({ assigned: false, value: undefined }, () => + 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}`); } + // Reinstalls the accessor if a prior execution's customer code deleted globalThis.$ — otherwise this execution's box below would be unreachable through globalThis.$ for its whole lifetime, not just for whichever execution did the deleting. Only closes the gap between executions: a deletion made by one execution WHILE another is still concurrently running (its fn() hasn't returned yet) can't be recovered mid-flight — there is no way to intercept a property access on a since-deleted globalThis property without wrapping the global object itself, which isn't possible for a live, already-running process. That narrower case is accepted as-is. + ensureDollarAccessorInstalled(); + // Scopes globalThis.$ and the action-catalog/apps-backend dispatch info to this call's own async continuation chain — see backendGlobalsContext's and executionDispatchContext's doc comments. return backendGlobalsContext.run({ value: $ }, () => executionDispatchContext.run(dispatch, async () => { From 7702cc67cabd13c1563e219ad308daaff18ea8c2 Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Fri, 28 Aug 2026 01:36:39 -0400 Subject: [PATCH 14/30] fix(apps): stop conflating a real empty-string JSON key with assertJsonSerializable's root call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The function/Symbol/undefined-drop check was exempted from the JSON root via key === '', but a real object property can also be named the empty string ({ '': ... }) — that property silently lost its value the same way the check exists to prevent, instead of throwing. Tracked via a one-shot flag set on the replacer's first invocation instead, since JSON.stringify always visits the root first regardless of its key. Also un-inlines two loadModule/Promise.all calls passed directly into withTimeout, per the repo's no-inlined-function-call-argument convention. --- .../apps/src/vite/local-execution.test.ts | 26 +++++++++++++++++++ .../plugins/apps/src/vite/local-execution.ts | 19 +++++++++----- 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/packages/plugins/apps/src/vite/local-execution.test.ts b/packages/plugins/apps/src/vite/local-execution.test.ts index 2c5be8124..2025b3255 100644 --- a/packages/plugins/apps/src/vite/local-execution.test.ts +++ b/packages/plugins/apps/src/vite/local-execution.test.ts @@ -1228,6 +1228,32 @@ describe('local-execution — executeScriptLocally', () => { ).rejects.toThrow(/example.*JSON.stringify silently drops/); }); + test('Should reject an explicit undefined at a property literally named the empty string, not mistake it for the JSON root', async () => { + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ example: () => ({ '': undefined, other: 'ok' }) }), + mockLogger, + ), + ).rejects.toThrow(/example.*JSON.stringify silently drops/); + }); + + test('Should reject a function at a property literally named the empty string, not mistake it for the JSON root', async () => { + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ example: () => ({ '': () => {}, other: 'ok' }) }), + mockLogger, + ), + ).rejects.toThrow(/example.*JSON.stringify silently drops/); + }); + test('Should reject a Symbol nested inside an array, not just at the top level', async () => { await expect( executeScriptLocally( diff --git a/packages/plugins/apps/src/vite/local-execution.ts b/packages/plugins/apps/src/vite/local-execution.ts index c70af43e0..0398b04af 100644 --- a/packages/plugins/apps/src/vite/local-execution.ts +++ b/packages/plugins/apps/src/vite/local-execution.ts @@ -247,8 +247,9 @@ function registerActionCatalogIfInstalled( } async function registerActionCatalogOnce(loadModule: LoadModule, timeoutMs: number): Promise { + const loadPromise = loadModule('@datadog/action-catalog/action-execution'); const mod = await withTimeout( - loadModule('@datadog/action-catalog/action-execution'), + loadPromise, timeoutMs, '@datadog/action-catalog/action-execution', ); @@ -305,11 +306,12 @@ async function registerBackendRuntimeOnce( loadModule: LoadModule, timeoutMs: number, ): Promise { + const loadPromise = Promise.all([ + loadModule('@datadog/apps-backend/runtime/jsFunctionWithActions'), + loadModule('@datadog/apps-backend/runtime'), + ]); const [jsFunctionWithActionsModule, runtimeModule] = await withTimeout( - Promise.all([ - loadModule('@datadog/apps-backend/runtime/jsFunctionWithActions'), - loadModule('@datadog/apps-backend/runtime'), - ]), + loadPromise, timeoutMs, '@datadog/apps-backend/runtime', ); @@ -369,8 +371,11 @@ class UnsupportedJsonValueError extends Error {} function assertJsonSerializable(result: unknown, func: BackendFunction): unknown { let serialized: string | undefined; try { - // A replacer runs on every key/value pair JSON.stringify visits, root included, so a Map/Set/non-finite number/function/Symbol/undefined nested arbitrarily deep inside the result (e.g. `{ data: new Map() }` or `{ status: 'ok', callback: () => {} }`) is caught the same way a top-level one is — JSON.stringify would otherwise silently flatten, convert, omit, or null out the offending value instead of throwing. The root call (`key === ''`) is excluded from the function/Symbol/undefined check below since a root result of exactly one of those types is a distinct, allowed case handled after this call via the `serialized === undefined` branch. + // A replacer runs on every key/value pair JSON.stringify visits, root included, so a Map/Set/non-finite number/function/Symbol/undefined nested arbitrarily deep inside the result (e.g. `{ data: new Map() }` or `{ status: 'ok', callback: () => {} }`) is caught the same way a top-level one is — JSON.stringify would otherwise silently flatten, convert, omit, or null out the offending value instead of throwing. The root call is excluded from the function/Symbol/undefined check below since a root result of exactly one of those types is a distinct, allowed case handled after this call via the `serialized === undefined` branch. Tracked via a one-shot flag rather than `key === ''`, since a real property can also be named the empty string (`{ '': ... }`) and isn't the root. + let isRootCall = true; serialized = JSON.stringify(result, (key, value) => { + const wasRootCall = isRootCall; + isRootCall = false; if (value instanceof Map || value instanceof Set) { throw new UnsupportedJsonValueError( `Local execution of "${func.name}" returned a ${value.constructor.name}${key ? ` (at "${key}")` : ''}, which JSON.stringify silently flattens to "{}" instead of serializing its entries — return a plain array or object instead.`, @@ -382,7 +387,7 @@ function assertJsonSerializable(result: unknown, func: BackendFunction): unknown ); } if ( - key !== '' && + !wasRootCall && (typeof value === 'function' || typeof value === 'symbol' || value === undefined) ) { throw new UnsupportedJsonValueError( From aa173a5c450e3fc270fc1cdf509dc7a207f9d371 Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Fri, 28 Aug 2026 03:16:14 -0400 Subject: [PATCH 15/30] fix(apps): correct stale concurrency-safety comments on enqueue serialization and the epoch guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two doc comments described mechanisms that no longer match the code: enqueue's own comment blamed a "shared module-level setter a concurrent execution would clobber," but action-catalog/apps-backend registration is now WeakMap-guarded and idempotent, so no concurrent execution clobbers it — the real hazard enqueue guards against is a customer function deleting globalThis.$ while another execution is still mid-flight. Separately, the epoch guard's own comment framed it as a "belt-and-suspenders backstop" redundant with enqueue's serialization, when it's actually the only thing rejecting a timed-out execution's late dispatch during the overlap window enqueue deliberately permits (the queue advances on timeout while the abandoned fn() keeps running). Also replaces two `as Error` casts in local-execution.test.ts with a narrowing assertion helper, and fixes a UK spelling ("cancelled"). --- .../apps/src/vite/local-execution.test.ts | 17 +++++++++++------ .../plugins/apps/src/vite/local-execution.ts | 6 +++--- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/plugins/apps/src/vite/local-execution.test.ts b/packages/plugins/apps/src/vite/local-execution.test.ts index 2025b3255..5bedfcea7 100644 --- a/packages/plugins/apps/src/vite/local-execution.test.ts +++ b/packages/plugins/apps/src/vite/local-execution.test.ts @@ -36,6 +36,13 @@ function testDollar(): TestGlobalDollar { return (globalThis as unknown as { $: TestGlobalDollar }).$; } +/** Narrows a caught `unknown` to `Error` without an `as` cast — pairs with a preceding `expect(value).toBeInstanceOf(Error)` so the failure is reported there rather than as a thrown TypeError, and avoids `eslint-plugin-jest`'s no-conditional-expect rule that a plain `if (value instanceof Error)` guard around a second `expect(...)` would trip. */ +function assertIsError(value: unknown): asserts value is Error { + if (!(value instanceof Error)) { + throw new Error(`Expected an Error, got: ${String(value)}`); + } +} + beforeEach(() => { // Neither optional SDK is installed by default; tests exercising the "installed" path override this. jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(false); @@ -137,9 +144,8 @@ describe('local-execution — executeScriptLocally', () => { expect(result).toEqual({ data: 'done' }); expect(dollarAccessError).toBeInstanceOf(Error); - expect((dollarAccessError as Error).message).toBe( - 'No active local execution to resolve $ under.', - ); + assertIsError(dollarAccessError); + expect(dollarAccessError.message).toBe('No active local execution to resolve $ under.'); }); test("Should return a pre-existing globalThis.$ during a customer module's top-level evaluation when something (e.g. zx/globals) seeded it before this module loaded", async () => { @@ -252,9 +258,8 @@ describe('local-execution — executeScriptLocally', () => { expect(dollarDuringSecondLoad).toBe('not captured'); expect(secondLoadError).toBeInstanceOf(Error); - expect((secondLoadError as Error).message).toBe( - 'No active local execution to resolve $ under.', - ); + assertIsError(secondLoadError); + expect(secondLoadError.message).toBe('No active local execution to resolve $ under.'); }); test('Should reject when loadModule itself rejects, same as a native-module load failure would', async () => { diff --git a/packages/plugins/apps/src/vite/local-execution.ts b/packages/plugins/apps/src/vite/local-execution.ts index 0398b04af..dfa5554fc 100644 --- a/packages/plugins/apps/src/vite/local-execution.ts +++ b/packages/plugins/apps/src/vite/local-execution.ts @@ -154,7 +154,7 @@ function validateActionCall( return { inputs, connectionId }; } -/** Local executions are serialized since action-catalog/apps-backend register runtime context via a shared, module-level setter a concurrent execution would clobber, silently redirecting the first's in-flight calls to the wrong identity. */ +/** Local executions are serialized since a customer function deleting `globalThis.$` (see `ensureDollarAccessorInstalled`'s own doc comment) would otherwise break `$` access for any other execution concurrently mid-flight, with no way to recover until that other execution's own next run reinstalls the accessor. */ let queueTail: Promise = Promise.resolve(); function enqueue(run: () => Promise): Promise { @@ -166,7 +166,7 @@ function enqueue(run: () => Promise): Promise { return result; } -/** One shared guard across all local executions — `enqueue` already serializes them, so starting a new scope always supersedes the previous one only after it has already concluded, but the guard's own generation counter is a belt-and-suspenders backstop if that invariant is ever violated. */ +/** One shared guard across all local executions — `enqueue` only serializes each execution's *start*, not its full lifetime: a timed-out execution's `fn()` keeps running in the background (see the "abandoned, not canceled" comment below) while the queue advances and a new execution starts, so the two genuinely overlap. This guard's generation counter is what rejects the abandoned execution's late `$.Actions`/adapter dispatch during that overlap window, not a redundant backstop for something serialization already prevents. */ const executionEpoch = createEpochGuard(); /** Resolves a nested property path (e.g. $.Actions.slack.chat.postMessage) to a callable that invokes `executeAction` directly — no IPC needed since there's no separate process to cross. */ @@ -445,7 +445,7 @@ async function runScriptLocally( // Never log the args themselves — they may carry secrets/PII, matching dev-server.ts's cloud path. log.debug(`Executing "${func.name}" in-process with args`); - // A timed-out execution is abandoned, not cancelled — its fn() may keep running and must not act under a newer execution's identity. The scope's isCurrent() is checked both directly (this execution's own captured `$.Actions` closure) and via `executionDispatchContext` (the stable, shared action-catalog/apps-backend adapters resolve the CALLING execution's own dispatch info from AsyncLocalStorage at call time, so a zombie's call can never be serviced by whichever execution's registration happens to be live). + // A timed-out execution is abandoned, not canceled — its fn() may keep running and must not act under a newer execution's identity. The scope's isCurrent() is checked both directly (this execution's own captured `$.Actions` closure) and via `executionDispatchContext` (the stable, shared action-catalog/apps-backend adapters resolve the CALLING execution's own dispatch info from AsyncLocalStorage at call time, so a zombie's call can never be serviced by whichever execution's registration happens to be live). const scope = executionEpoch.start(); const guardedExecuteAction: ExecuteAction = (fqn, inputs, connectionId) => { From acd24b1ef7c9badf5e84726eea6bb4518ec38af1 Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Fri, 28 Aug 2026 03:59:25 -0400 Subject: [PATCH 16/30] style(apps): tighten comment prose in local-execution.ts and execution-epoch.ts Comments added by this branch had grown into multi-sentence paragraphs restating the same invariant several ways; compress each to one tight sentence (two only for the few comments carrying a genuinely compound invariant) without dropping the underlying WHY. --- .../plugins/apps/src/vite/execution-epoch.ts | 2 +- .../apps/src/vite/local-execution.test.ts | 101 ++++++++---------- .../plugins/apps/src/vite/local-execution.ts | 58 +++++----- 3 files changed, 74 insertions(+), 87 deletions(-) diff --git a/packages/plugins/apps/src/vite/execution-epoch.ts b/packages/plugins/apps/src/vite/execution-epoch.ts index 0578c75c1..82d87de2b 100644 --- a/packages/plugins/apps/src/vite/execution-epoch.ts +++ b/packages/plugins/apps/src/vite/execution-epoch.ts @@ -4,7 +4,7 @@ /** Generation-counter guard so an abandoned scope's late cleanup can't touch a shared resource a newer scope now owns (used by `local-execution.ts`). */ export interface EpochScope { - /** True until a newer scope starts, or this one (or every scope) is concluded/invalidated. */ + /** True until a newer scope starts, or this scope is concluded or invalidated. */ isCurrent(): boolean; /** Marks no scope active and returns true if still current, otherwise a no-op returning false — call in a `finally` to gate cleanup on still owning the resource. */ concludeIfCurrent(): boolean; diff --git a/packages/plugins/apps/src/vite/local-execution.test.ts b/packages/plugins/apps/src/vite/local-execution.test.ts index 5bedfcea7..85f2c6c95 100644 --- a/packages/plugins/apps/src/vite/local-execution.test.ts +++ b/packages/plugins/apps/src/vite/local-execution.test.ts @@ -31,12 +31,12 @@ interface TestGlobalDollar { Source: { initiator: { id: string; orgId: string }; runAsUser: { id: string; orgId: string } }; } -/** Reads the `$` this module installs onto `globalThis` during an execution, from the customer-code perspective these tests simulate — genuinely untyped from TypeScript's static perspective since it's a runtime-only accessor property local-execution.ts defines via `Object.defineProperty`. Centralized here instead of repeating the same cast at each call site. */ +/** Reads the `$` local-execution.ts installs onto `globalThis` via `Object.defineProperty` — genuinely untyped, so the cast is centralized here instead of repeated at each call site. */ function testDollar(): TestGlobalDollar { return (globalThis as unknown as { $: TestGlobalDollar }).$; } -/** Narrows a caught `unknown` to `Error` without an `as` cast — pairs with a preceding `expect(value).toBeInstanceOf(Error)` so the failure is reported there rather than as a thrown TypeError, and avoids `eslint-plugin-jest`'s no-conditional-expect rule that a plain `if (value instanceof Error)` guard around a second `expect(...)` would trip. */ +/** Narrows a caught `unknown` to `Error` without an `as` cast, pairing with a preceding `toBeInstanceOf(Error)` so a mismatch is reported there rather than tripping `eslint-plugin-jest`'s no-conditional-expect rule. */ function assertIsError(value: unknown): asserts value is Error { if (!(value instanceof Error)) { throw new Error(`Expected an Error, got: ${String(value)}`); @@ -116,9 +116,7 @@ describe('local-execution — executeScriptLocally', () => { let dollarAccessError: unknown = 'not captured'; const loadModule: LoadModule = async (specifier) => { if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { - // Production's static customer-module import runs before its wrapper installs $, so a - // customer module reaching for $ during its own top-level evaluation fails there too — - // this must fail the same way locally instead of silently resolving to undefined. + // Production's static import also runs before its wrapper installs $, so this must fail the same way locally instead of resolving to undefined. try { dollarAccessError = (globalThis as Record).$; } catch (error) { @@ -155,10 +153,7 @@ describe('local-execution — executeScriptLocally', () => { let isolatedExecuteScriptLocally!: typeof executeScriptLocally; try { jest.isolateModules(() => { - // A fresh module instance re-runs its top-level Reflect.has check with preExisting - // already in place, capturing hadPreexistingDollar=true — the outer instance every other - // test in this file uses was imported before any test set globalThis.$, so it can't - // exercise this path. + // A fresh module instance re-runs its Reflect.has check with preExisting already set; the outer instance was imported too early to exercise this path. isolatedExecuteScriptLocally = require('./local-execution').executeScriptLocally; }); @@ -217,42 +212,44 @@ describe('local-execution — executeScriptLocally', () => { }); test("Should not leak one execution's top-level zx/globals-style $ write into a later execution's own top-level load", async () => { + const firstLoadModule: LoadModule = async (specifier) => { + if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { + // Simulates a top-level side effect (e.g. `import 'zx/globals'`) writing $ before this execution's box exists. + (globalThis as Record).$ = { + fromFirstExecutionTopLevel: true, + }; + return { example: () => 'first' }; + } + throw new Error(`Cannot find module '${specifier}'`); + }; await executeScriptLocally( func, TEST_PROJECT_ROOT, [], stubExecuteAction, - (async (specifier: string) => { - if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { - // Simulates a customer module's own top-level side effect (e.g. `import 'zx/globals'`) writing $ before this execution's box exists. - (globalThis as Record).$ = { - fromFirstExecutionTopLevel: true, - }; - return { example: () => 'first' }; - } - throw new Error(`Cannot find module '${specifier}'`); - }) as LoadModule, + firstLoadModule, mockLogger, ); let dollarDuringSecondLoad: unknown = 'not captured'; let secondLoadError: unknown; + const secondLoadModule: LoadModule = async (specifier) => { + if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { + try { + dollarDuringSecondLoad = (globalThis as Record).$; + } catch (error) { + secondLoadError = error; + } + return { example: () => 'second' }; + } + throw new Error(`Cannot find module '${specifier}'`); + }; await executeScriptLocally( func, TEST_PROJECT_ROOT, [], stubExecuteAction, - (async (specifier: string) => { - if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { - try { - dollarDuringSecondLoad = (globalThis as Record).$; - } catch (error) { - secondLoadError = error; - } - return { example: () => 'second' }; - } - throw new Error(`Cannot find module '${specifier}'`); - }) as LoadModule, + secondLoadModule, mockLogger, ); @@ -263,7 +260,7 @@ describe('local-execution — executeScriptLocally', () => { }); test('Should reject when loadModule itself rejects, same as a native-module load failure would', async () => { - // Simulates a native addon failing to load at require()/import time, before the function is ever reached — not a customer function throwing. + // Simulates a native addon failing to load at import time — not a customer function throwing. const loadModule: LoadModule = async () => { throw new Error('cannot find native module'); }; @@ -304,7 +301,7 @@ describe('local-execution — executeScriptLocally', () => { }); test('Should reject with a clear error, not hang, when a customer function returns an un-invoked $.Actions reference instead of calling it', async () => { - // $.Actions.slack.chat is itself a callable Proxy; forgetting the trailing .postMessage(...) call and just returning it must not make `await fn(...args)` treat it as a thenable and hang until the timeout, nor make assertJsonSerializable's JSON.stringify probe for .toJSON() leak an unhandled rejection — it should surface the same clear, synchronous "can't be serialized" error as any other bare function result. + // Returning $.Actions.slack.chat un-invoked must not be mistaken for a thenable (hang) or leak an unhandled rejection — just the ordinary "can't be serialized" error. await expect( executeScriptLocally( func, @@ -609,9 +606,7 @@ describe('local-execution — executeScriptLocally', () => { (globalThis as Record).$ = preExisting; try { jest.isolateModules(() => { - // A fresh module instance re-runs its top-level Object.defineProperty, which must read - // the current globalThis.$ (still `preExisting`, via the outer instance's own getter) - // before replacing the descriptor with its own — not start from an empty slot. + // A fresh module instance re-runs its top-level Object.defineProperty and must read the current $ (still `preExisting`) rather than start from an empty slot. require('./local-execution'); }); expect((globalThis as Record).$).toBe(preExisting); @@ -713,7 +708,7 @@ describe('local-execution — executeScriptLocally', () => { ); expect(registeredImpl).toBeUndefined(); - // Simulates `npm install @datadog/action-catalog` without restarting the dev server — the very next execution must register it, not stay permanently skipped from the first (uncached) negative check. + // Simulates a mid-session install — the very next execution must register it, not stay skipped from the earlier uncached check. isInstalledSpy.mockReturnValue(true); await executeScriptLocally( func, @@ -753,7 +748,7 @@ describe('local-execution — executeScriptLocally', () => { ).rejects.toThrow('Unexpected token in action-catalog/action-execution'); }); - // A sibling registration genuinely failing doesn't affect the action-catalog adapter — it's stable and execution-agnostic, so a call made once no execution is active correctly rejects on its own, with no special-case coordination needed between the two registrations. + // The sibling registration failing doesn't affect this adapter — it's stable and execution-agnostic, so it rejects on its own once no execution is active. test('Should still reject a typed-wrapper call through a successfully-registered action-catalog implementation after the sibling apps-backend registration genuinely fails and the execution concludes', async () => { jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(true); jest.spyOn(shared, 'isDatadogAppsBackendInstalled').mockReturnValue(true); @@ -1345,7 +1340,7 @@ describe('local-execution — executeScriptLocally', () => { ]); expect([resultA, resultB]).toEqual([{ data: 'A' }, { data: 'B' }]); - const order = (globalThis as Record)[ORDER_MARKER]; + const order = (globalThis as Record)[ORDER_MARKER] as string[]; // Whichever call runs first, its start/end pair must be adjacent — a real race would interleave as [start-A, start-B, end-B, end-A]. expect(order).toEqual([ expect.stringMatching(/^start-/), @@ -1353,12 +1348,8 @@ describe('local-execution — executeScriptLocally', () => { expect.stringMatching(/^start-/), expect.stringMatching(/^end-/), ]); - expect((order as string[])[0].slice('start-'.length)).toEqual( - (order as string[])[1].slice('end-'.length), - ); - expect((order as string[])[2].slice('start-'.length)).toEqual( - (order as string[])[3].slice('end-'.length), - ); + expect(order[0].slice('start-'.length)).toEqual(order[1].slice('end-'.length)); + expect(order[2].slice('start-'.length)).toEqual(order[3].slice('end-'.length)); }); function readOwnArgsAfterDelay(delayMs: number): () => Promise { @@ -1418,7 +1409,7 @@ describe('local-execution — executeScriptLocally', () => { await expect(second).resolves.toEqual({ data: 2 }); }); - // Covers the raw-$.Actions path: a captured Actions reference (e.g. const { Actions } = $) must reject once its own execution is abandoned, even after globalThis.$ is overwritten by a newer execution. + // Covers the raw-$.Actions path: a captured Actions reference must reject once abandoned, even after globalThis.$ is overwritten by a newer execution. test('Should reject a captured $.Actions reference once its own execution is abandoned, even after a newer execution has taken over', async () => { let abandonedCallOutcome: 'pending' | 'resolved' | { rejected: string } = 'pending'; @@ -1482,7 +1473,7 @@ describe('local-execution — executeScriptLocally', () => { executeAction, loadModuleReturning({ example: async () => { - // Fires ~60ms in, squarely inside funcB's in-flight window — a fresh $ read here needs AsyncLocalStorage, not the abandoned closure check, or it would resolve to funcB's $. + // Fires ~60ms in, inside funcB's in-flight window — a fresh $ read here needs AsyncLocalStorage or it would resolve to funcB's $. await new Promise((resolve) => setTimeout(resolve, 60)); const $ = testDollar(); try { @@ -1502,7 +1493,7 @@ describe('local-execution — executeScriptLocally', () => { ); await expect(abandoned).rejects.toThrow(/timed out after 20ms/); - // Starts as soon as the queue frees and stays "current" for 80ms, overlapping the zombie's 60ms wakeup; never itself calls $.Actions, so any observed call must be the zombie's. + // Stays "current" for 80ms, overlapping the zombie's 60ms wakeup; never calls $.Actions itself, so any observed call must be the zombie's. const second = executeScriptLocally( funcB, TEST_PROJECT_ROOT, @@ -1518,14 +1509,14 @@ describe('local-execution — executeScriptLocally', () => { ); await expect(second).resolves.toEqual({ data: 'second' }); - // The zombie's fresh read resolved to its OWN $ (funcA's allowedConnectionIds) — funcB's connectionId under funcA's identity is rejected before reaching executeAction. + // The zombie's fresh read resolved to its own $ (funcA's allowedConnectionIds), so funcB's connectionId is rejected before reaching executeAction. expect(zombieOutcome).toEqual({ rejected: expect.stringContaining("not in this function's allowed connections"), }); expect(executeAction).not.toHaveBeenCalled(); }); - // Action-catalog's registered dispatcher is stable and execution-agnostic — it resolves the calling execution's own dispatch from AsyncLocalStorage at call time, so a per-closure guard alone (bypassed once a newer execution re-registers) isn't what protects a stale typed-wrapper call. + // The dispatcher resolves the calling execution's dispatch from AsyncLocalStorage at call time — a per-closure guard alone would be bypassed once a newer execution re-registers. test("Should reject an abandoned execution's action-catalog typed-wrapper call, not silently run it under a newer registration", async () => { jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(true); let abandonedCallOutcome: 'pending' | 'resolved' | { rejected: string } = 'pending'; @@ -1577,7 +1568,7 @@ describe('local-execution — executeScriptLocally', () => { ); await expect(abandoned).rejects.toThrow(/timed out after 20ms/); - // registeredImpl still points at this (only) execution's own registration — no second execution registers here. The call is rejected because the dispatcher resolves this execution's own dispatch, already concluded by the 20ms timeout. + // No second execution registers here — the call is rejected because the dispatcher resolves this execution's own dispatch, already concluded by the 20ms timeout. await new Promise((resolve) => setTimeout(resolve, 100)); expect(abandonedCallOutcome).toEqual({ @@ -1585,7 +1576,7 @@ describe('local-execution — executeScriptLocally', () => { }); }); - // registeredImpl comes to point at funcB's own registration once it registers, but a call made from within funcA's own continuation still resolves funcA's own (concluded) dispatch via AsyncLocalStorage — it must still be rejected, not routed through funcB's identity/allowedConnectionIds just because funcB's registration is the one currently referenced. + // registeredImpl points at funcB's registration once it registers, but a call from within funcA's own continuation must still resolve funcA's concluded dispatch via AsyncLocalStorage and be rejected, not routed through funcB's identity. test("Should reject a zombie execution's action-catalog typed-wrapper call even after a newer execution has legitimately re-registered its own implementation", async () => { jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(true); const funcA: BackendFunction = { ...func, allowedConnectionIds: ['conn-A'] }; @@ -1618,7 +1609,7 @@ describe('local-execution — executeScriptLocally', () => { }; }; - // Times out at 20ms, then calls the typed wrapper ~60ms in — squarely inside funcB's own in-flight window (funcB registers immediately but doesn't conclude until 80ms) — using conn-B, a connection funcA itself is never allowed to use. + // Times out at 20ms, then calls the typed wrapper ~60ms in — inside funcB's in-flight window — using conn-B, a connection funcA is never allowed to use. const abandoned = executeScriptLocally( funcA, TEST_PROJECT_ROOT, @@ -1662,7 +1653,7 @@ describe('local-execution — executeScriptLocally', () => { expect(executeAction).not.toHaveBeenCalled(); }); - // The apps-backend loadModule call hangs forever here — a post-Promise.all destructuring assignment would never run, so publishing each handle via .then() as its own promise resolves is what lets the completed action-catalog registration still take effect. + // The apps-backend loadModule hangs forever, so a post-Promise.all destructuring would never run — publishing each handle via its own .then() is what lets the completed action-catalog registration still take effect. test('Should still register the action-catalog adapter even when the sibling apps-backend registration never settles, and reject a call once no execution is active', async () => { jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(true); jest.spyOn(shared, 'isDatadogAppsBackendInstalled').mockReturnValue(true); @@ -1713,7 +1704,7 @@ describe('local-execution — executeScriptLocally', () => { ); }); - // A real dev server reuses the same loadModule for its whole lifetime — a registration load that never settles must not permanently poison every later execution sharing it, so this deliberately reuses one loadModule across two calls instead of each test's usual per-call closure. + // Deliberately reuses one loadModule across both calls (not the usual per-call closure) — a real dev server does the same, so a load that never settles must not permanently poison later executions sharing it. test('Should let a later execution register and run after an earlier one shared the same loadModule with a registration load that never settles', async () => { jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(true); @@ -1829,7 +1820,7 @@ describe('local-execution — executeScriptLocally', () => { expect(registeredImpl).toBe(registeredAfterB); }); - // A's slow-to-resolve registration re-installs the same stable, execution-agnostic dispatcher B's own registration already put in place — replacing the closure instance is harmless, since either one resolves a call against whichever execution is actually on the AsyncLocalStorage-scoped call stack, not against whichever registered it. + // A's slow-to-resolve registration re-installs the same stable dispatcher B already put in place — harmless, since either closure resolves a call against whichever execution is on the AsyncLocalStorage call stack, not against whichever registered it. test("Should still dispatch correctly after a stale execution's slow-to-resolve registration re-installs the adapter following a newer execution's own registration", async () => { jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(true); let registeredImpl: diff --git a/packages/plugins/apps/src/vite/local-execution.ts b/packages/plugins/apps/src/vite/local-execution.ts index dfa5554fc..a6d3eec10 100644 --- a/packages/plugins/apps/src/vite/local-execution.ts +++ b/packages/plugins/apps/src/vite/local-execution.ts @@ -21,19 +21,19 @@ type BackendGlobals = { Source: ReturnType; }; -/** Boxed so a customer module assigning to `globalThis.$` (e.g. importing `zx/globals`, which does exactly this) mutates only its own execution's box, never a concurrent or zombie execution's. */ +/** Boxed so a customer module assigning to `globalThis.$` (e.g. `zx/globals`) mutates only its own execution's box, never a concurrent or zombie execution's. */ type BackendGlobalsBox = { value: unknown }; -/** Scopes `globalThis.$` per execution via AsyncLocalStorage, not a plain mutable property, so a zombie execution's late "fresh" `globalThis.$` read resolves to its own `$`, never a newer execution's identity/`allowedConnectionIds`. */ +/** Scopes `globalThis.$` per execution via AsyncLocalStorage so a zombie execution's late "fresh" read resolves to its own `$`, never a newer execution's identity. */ const backendGlobalsContext = new AsyncLocalStorage(); -/** Whether something (e.g. `zx/globals`, which assigns `globalThis.$` at its own import time) installed `$` before this module's own accessor below — distinguishes that legitimate passthrough from a customer module reaching for `$` during its own top-level evaluation, which has no such prior value and should fail the same way production does. */ +/** Whether `$` was installed (e.g. by `zx/globals`) before this module's own accessor below — distinguishes that legitimate passthrough from a customer module reaching for `$` with no prior value, which should fail like production does. */ const hadPreexistingDollar = Reflect.has(globalThis, '$'); -/** Marks specifically the window where a customer module's own top-level code (import-time side effects, evaluated before this execution's box exists) is loading — narrower than "no box on the call stack," which is also true genuinely between executions, where the old undefined-returning fallback below is still correct. Carries its own mutable box (not just a boolean marker) so a top-level write during this window — e.g. `zx/globals`, which assigns `globalThis.$` at its own import time — lands in a box scoped to *this* module's own load, not the shared `globalDollarOutsideExecution` slot a later, unrelated execution's own top-level load would also read from. */ +/** Marks the window where a customer module's own top-level code is loading, narrower than "no execution box on the call stack" (also true between executions, where the undefined-returning fallback below is correct). Carries its own mutable box so a top-level `$` write (e.g. `zx/globals`) lands scoped to this module's own load, not the shared `globalDollarOutsideExecution` slot a later, unrelated load would also read from. */ const customerModuleLoadContext = new AsyncLocalStorage<{ assigned: boolean; value: unknown }>(); -/** Backs `globalThis.$` for reads/writes that happen with no execution box on the AsyncLocalStorage-scoped call stack (e.g. this module's own import-time state) — an ordinary mutable slot, since there's no per-execution box to isolate it into. Seeded from any `$` already installed before this module loaded, so installing the accessor below doesn't silently discard a legitimate `zx/globals`-style passthrough. */ +/** Backs `globalThis.$` outside any execution box (e.g. this module's own import-time state); seeded from any `$` already installed before this module loaded so the accessor below doesn't discard a legitimate `zx/globals`-style passthrough. */ let globalDollarOutsideExecution: unknown = Reflect.get(globalThis, '$'); function ensureDollarAccessorInstalled(): void { @@ -61,7 +61,7 @@ function dollarGetter(): unknown { if (hadPreexistingDollar) { return globalDollarOutsideExecution; } - // Matches production: a customer module's own top-level evaluation runs before production installs $, so referencing it fails loudly there too, instead of silently resolving to undefined. + // Matches production, where a customer module's top-level evaluation also runs before $ is installed and fails loudly rather than resolving to undefined. throw new Error('No active local execution to resolve $ under.'); } return globalDollarOutsideExecution; @@ -75,7 +75,7 @@ function dollarSetter(value: unknown): void { } const loadBox = customerModuleLoadContext.getStore(); if (loadBox) { - // Scoped to this one module load, not the shared globalDollarOutsideExecution slot — otherwise a customer module's own top-level write (e.g. zx/globals) would leak into every later, unrelated execution's own top-level load instead of staying local to this one. + // Scoped to this module load, not the shared globalDollarOutsideExecution slot — otherwise a top-level write (e.g. zx/globals) would leak into every later, unrelated load. loadBox.assigned = true; loadBox.value = value; return; @@ -85,7 +85,7 @@ function dollarSetter(value: unknown): void { ensureDollarAccessorInstalled(); -/** What the stable, once-ever-registered action-catalog/apps-backend adapters (below) need to dispatch a typed-wrapper call to the execution that's actually on the AsyncLocalStorage-scoped call stack — kept out of `BackendGlobals` since that object is also `globalThis.$`, directly visible to customer code. */ +/** What the stable, once-ever-registered adapters below need to dispatch a call to whichever execution is on the AsyncLocalStorage call stack — kept out of `BackendGlobals` since that object is also `globalThis.$`, visible to customer code. */ type ExecutionDispatch = { executeAction: ExecuteAction; allowedConnectionIds: string[]; @@ -154,7 +154,7 @@ function validateActionCall( return { inputs, connectionId }; } -/** Local executions are serialized since a customer function deleting `globalThis.$` (see `ensureDollarAccessorInstalled`'s own doc comment) would otherwise break `$` access for any other execution concurrently mid-flight, with no way to recover until that other execution's own next run reinstalls the accessor. */ +/** Serializes local executions — a customer function deleting `globalThis.$` mid-flight would otherwise break `$` access for any other execution concurrently in progress (see `ensureDollarAccessorInstalled`). */ let queueTail: Promise = Promise.resolve(); function enqueue(run: () => Promise): Promise { @@ -166,7 +166,7 @@ function enqueue(run: () => Promise): Promise { return result; } -/** One shared guard across all local executions — `enqueue` only serializes each execution's *start*, not its full lifetime: a timed-out execution's `fn()` keeps running in the background (see the "abandoned, not canceled" comment below) while the queue advances and a new execution starts, so the two genuinely overlap. This guard's generation counter is what rejects the abandoned execution's late `$.Actions`/adapter dispatch during that overlap window, not a redundant backstop for something serialization already prevents. */ +/** One shared guard across all executions — `enqueue` only serializes each execution's *start*; a timed-out `fn()` keeps running afterward (see "abandoned, not canceled" below), so this guard's generation counter is what rejects that zombie's late dispatch during the overlap, not a redundant backstop. */ const executionEpoch = createEpochGuard(); /** Resolves a nested property path (e.g. $.Actions.slack.chat.postMessage) to a callable that invokes `executeAction` directly — no IPC needed since there's no separate process to cross. */ @@ -177,7 +177,7 @@ function makeActionsProxy( ): unknown { return new Proxy(function () {}, { get(_target, prop) { - // A customer function that returns an un-invoked reference (e.g. $.Actions.foo.bar without the trailing call) must not be mistaken for a thenable or a custom-serializable object — Promise's resolution protocol probes .then(), and JSON.stringify (assertJsonSerializable) probes .toJSON(); either probe calling into the async apply() below would hang until timeout or leak an unhandled rejection instead of surfacing assertJsonSerializable's clear "can't be serialized" error. + // An un-invoked $.Actions.foo.bar reference must not be mistaken for a thenable (Promise probes .then()) or serializable (assertJsonSerializable probes .toJSON()) — either probe hitting apply() below would hang or leak a rejection instead of a clear error. if (prop === 'then' || prop === 'toJSON') { return undefined; } @@ -203,7 +203,7 @@ function makeActionsProxy( }); } -/** Bounds a registration's underlying `loadModule` call to `timeoutMs` so a load that never settles (a broken/circular module graph, not just a slow one) rejects instead of leaving its cache entry pending forever — the existing eviction-on-rejection below only fires once the promise actually settles, and an unbounded load never does. Doesn't cancel the underlying promise (not possible for a plain `Promise`), so a load that eventually does settle still runs its side effects late; see the registration functions' own doc comments for why that's harmless here. */ +/** Bounds a registration's `loadModule` call so a load that never settles (a broken/circular module graph) rejects instead of leaving its cache entry pending forever — eviction-on-rejection below only fires once a promise settles. Can't cancel the underlying promise, so a load that eventually settles still runs its side effects late; see the registration functions for why that's harmless. */ function withTimeout(promise: Promise, timeoutMs: number, what: string): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => { @@ -222,10 +222,10 @@ function withTimeout(promise: Promise, timeoutMs: number, what: string): P }); } -/** Keyed by `loadModule` identity, not a bare module-level flag — a real dev server reuses the same Vite `ssrLoadModule` for its whole lifetime (giving true once-ever registration), while each test constructs its own `loadModule` closure (keeping tests isolated from each other's registration state). A rejection is evicted so the next execution retries, rather than permanently poisoning every later execution with one transient load failure — including a load that never settles at all, since `withTimeout` below turns that into a rejection too. */ +/** Keyed by `loadModule` identity, not a module-level flag, so a real dev server's reused `ssrLoadModule` gets true once-ever registration while each test's own closure stays isolated. A rejection (including a load `withTimeout` turns into one) is evicted so the next execution retries instead of staying permanently poisoned. */ const actionCatalogRegistrations = new WeakMap>(); -/** No-ops if @datadog/action-catalog isn't installed — re-checked on every call, uncached, so installing the package mid-session (without restarting the dev server) is picked up on the very next execution instead of staying permanently no-op. Once installed, registers ONE stable dispatcher for the process lifetime — it reads `executionDispatchContext.getStore()` at call time to resolve whichever execution is actually on the AsyncLocalStorage-scoped call stack, so a zombie execution's typed-wrapper call can never be routed through a newer execution's identity/allowedConnectionIds just because that execution's own registration is the one currently live. */ +/** No-ops if @datadog/action-catalog isn't installed — the check is re-run uncached on every call, so a mid-session install is picked up on the very next execution. Once installed, registers ONE stable dispatcher that reads `executionDispatchContext.getStore()` at call time, so a zombie's typed-wrapper call can never dispatch under a newer execution's identity just because that execution's registration is the one currently live. */ function registerActionCatalogIfInstalled( loadModule: LoadModule, projectRoot: string, @@ -278,10 +278,10 @@ async function registerActionCatalogOnce(loadModule: LoadModule, timeoutMs: numb }); } -/** Mirrors `actionCatalogRegistrations` — see its doc comment for why keying on `loadModule` identity is safe across both real dev-server reuse and per-test isolation, and for why an unbounded load is treated as a rejection via `withTimeout`. */ +/** Mirrors `actionCatalogRegistrations` — same keying and timeout-eviction rationale. */ const backendRuntimeRegistrations = new WeakMap>(); -/** No-ops if @datadog/apps-backend isn't installed — re-checked on every call, uncached, so installing the package mid-session (without restarting the dev server) is picked up on the very next execution instead of staying permanently no-op. Once installed, registers ONE stable runtime Proxy for the process lifetime — every accessor call resolves whichever execution's `$` is on the AsyncLocalStorage-scoped call stack (or rejects if that execution has concluded), rather than a runtime bound to a specific execution's `$` at registration time. */ +/** Mirrors `registerActionCatalogIfInstalled`'s no-op/re-check/once-ever-registration behavior for @datadog/apps-backend; the registered runtime Proxy resolves whichever execution's `$` is live on the AsyncLocalStorage call stack, rather than binding to one execution's `$` at registration time. */ function registerBackendRuntimeIfInstalled( loadModule: LoadModule, projectRoot: string, @@ -324,9 +324,9 @@ async function registerBackendRuntimeOnce( ) { return; } - // Built once per execution (cached by dispatch identity), not once per accessor call — dispatch.$ is fixed for its whole execution, so rebuilding on every property access wasted work without changing the result. + // Cached by dispatch identity, not rebuilt per accessor call — dispatch.$ is fixed for the whole execution. const runtimeByDispatch = new WeakMap(); - // Forwards to whatever shape the real runtime's own property has — a nested namespace (e.g. `.user.getExecutionUser()`) as well as a flat method — rather than assuming every property is itself a callable, which the real @datadog/apps-backend runtime is not. + // Forwards whatever shape the real runtime's property has (nested namespace or flat method) rather than assuming every property is callable. const backendRuntimeProxy = new Proxy( {}, { @@ -352,11 +352,7 @@ async function registerBackendRuntimeOnce( return undefined; } const value = runtime[String(prop)]; - // A flat method (e.g. .getExecutionUser()) reads its own internal state via - // `this` — returning it unbound would call it with `this` bound to this Proxy's - // empty target instead of the real runtime object. A nested namespace property - // (e.g. .user) is returned as-is; its own methods keep correct `this` since the - // real sub-object, not this proxy, is what ends up receiving the call. + // A flat method must be bound to the real runtime object, not this Proxy's empty target; a nested namespace is returned as-is since its own methods already bind correctly. return typeof value === 'function' ? value.bind(runtime) : value; }, }, @@ -364,14 +360,14 @@ async function registerBackendRuntimeOnce( setBackend(backendRuntimeProxy); } -/** Rejects a non-JSON-serializable result (circular reference/`BigInt`, a bare function/`Symbol` that `JSON.stringify` silently drops, or a `Map`/`Set` that it silently flattens to `{}` since neither exposes its entries as own enumerable properties) here with a clear error, instead of failing downstream when serialized for the HTTP response. */ -// Thrown from inside assertJsonSerializable's replacer to carry an already-specific, attributed message straight through the outer catch below, rather than being re-wrapped in its generic "can't be serialized" fallback. +/** Rejects a non-JSON-serializable result (circular reference, `BigInt`, a dropped function/`Symbol`, a `Map`/`Set` flattened to `{}`) here with a clear error, instead of failing downstream when serialized for the HTTP response. */ +// Lets the replacer's already-specific message pass through the outer catch below unwrapped, instead of being replaced by its generic fallback. class UnsupportedJsonValueError extends Error {} function assertJsonSerializable(result: unknown, func: BackendFunction): unknown { let serialized: string | undefined; try { - // A replacer runs on every key/value pair JSON.stringify visits, root included, so a Map/Set/non-finite number/function/Symbol/undefined nested arbitrarily deep inside the result (e.g. `{ data: new Map() }` or `{ status: 'ok', callback: () => {} }`) is caught the same way a top-level one is — JSON.stringify would otherwise silently flatten, convert, omit, or null out the offending value instead of throwing. The root call is excluded from the function/Symbol/undefined check below since a root result of exactly one of those types is a distinct, allowed case handled after this call via the `serialized === undefined` branch. Tracked via a one-shot flag rather than `key === ''`, since a real property can also be named the empty string (`{ '': ... }`) and isn't the root. + // A replacer visits every key/value pair including the root, so a disallowed value nested arbitrarily deep is caught the same way a top-level one is, instead of JSON.stringify silently flattening/converting/dropping it. The root is excluded from the function/Symbol/undefined check below (handled separately via `serialized === undefined`) and tracked with a one-shot flag, not `key === ''`, since a real property can itself be named `''`. let isRootCall = true; serialized = JSON.stringify(result, (key, value) => { const wasRootCall = isRootCall; @@ -445,12 +441,12 @@ async function runScriptLocally( // Never log the args themselves — they may carry secrets/PII, matching dev-server.ts's cloud path. log.debug(`Executing "${func.name}" in-process with args`); - // A timed-out execution is abandoned, not canceled — its fn() may keep running and must not act under a newer execution's identity. The scope's isCurrent() is checked both directly (this execution's own captured `$.Actions` closure) and via `executionDispatchContext` (the stable, shared action-catalog/apps-backend adapters resolve the CALLING execution's own dispatch info from AsyncLocalStorage at call time, so a zombie's call can never be serviced by whichever execution's registration happens to be live). + // A timed-out execution is abandoned, not canceled — its fn() may keep running and must not act under a newer execution's identity. isCurrent() gates both this execution's own captured `$.Actions` closure and the shared adapters, which resolve the calling execution's dispatch from AsyncLocalStorage rather than whichever registration is currently live. const scope = executionEpoch.start(); const guardedExecuteAction: ExecuteAction = (fqn, inputs, connectionId) => { if (!scope.isCurrent()) { - // A concluded execution's scope stays concluded forever, not just "not the latest," so the wording stays conclusion-neutral rather than claiming a timeout that may not have happened. + // A concluded scope stays concluded forever, not just "not the latest" — the wording stays conclusion-neutral rather than claiming a timeout that may not have happened. return Promise.reject( new Error( `Execution of "${func.name}" already concluded; refusing to run ` + @@ -489,14 +485,14 @@ async function runScriptLocally( throw new Error(`"${func.name}" is not a function exported from ${func.absolutePath}`); } - // Reinstalls the accessor if a prior execution's customer code deleted globalThis.$ — otherwise this execution's box below would be unreachable through globalThis.$ for its whole lifetime, not just for whichever execution did the deleting. Only closes the gap between executions: a deletion made by one execution WHILE another is still concurrently running (its fn() hasn't returned yet) can't be recovered mid-flight — there is no way to intercept a property access on a since-deleted globalThis property without wrapping the global object itself, which isn't possible for a live, already-running process. That narrower case is accepted as-is. + // Reinstalls the accessor if a prior execution's customer code deleted globalThis.$, so this execution's box stays reachable. Only closes the gap between executions — a deletion made mid-flight by a still-running concurrent execution can't be recovered, since there's no way to intercept access on a since-deleted global property; that narrower case is accepted as-is. ensureDollarAccessorInstalled(); - // Scopes globalThis.$ and the action-catalog/apps-backend dispatch info to this call's own async continuation chain — see backendGlobalsContext's and executionDispatchContext's doc comments. + // Scopes globalThis.$ and the dispatch info to this call's own async continuation chain. return backendGlobalsContext.run({ value: $ }, () => executionDispatchContext.run(dispatch, async () => { try { - // The action-catalog/apps-backend adapters are stable and idempotent to re-register — see their own doc comments — so no coordination is needed between the two registrations or across executions. + // Both adapters are stable and idempotent to re-register, so no coordination is needed between them or across executions. await Promise.all([ registerActionCatalogIfInstalled(loadModule, projectRoot, timeoutMs), registerBackendRuntimeIfInstalled(loadModule, projectRoot, timeoutMs), From a4e96114d3395647fdec5abc0ad59ba9d67f2733 Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Fri, 28 Aug 2026 14:02:23 -0400 Subject: [PATCH 17/30] fix(apps): stop throwing on typeof $ outside an execution, catch Symbol-keyed results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit globalThis.$ isn't a property at all in production until main() assigns it, so an unresolvable $ reads as undefined per typeof's spec-defined behavior on unresolvable references — it never throws. Locally, $ is a real accessor property, so throwing from its getter broke that parity for feature-detection code like `typeof $ !== 'undefined'`. Return undefined instead when no execution or prior value has claimed $. Also close a gap in assertJsonSerializable: JSON.stringify's replacer is never invoked for a Symbol-KEYED property (only Symbol-valued ones under a string key) — such properties were silently omitted with no callback at all, defeating the "reject anything JSON.stringify would silently drop" check. Added a dedicated recursive walk for this case. --- .../apps/src/vite/local-execution.test.ts | 67 +++++++++++-------- .../plugins/apps/src/vite/local-execution.ts | 24 ++++++- 2 files changed, 61 insertions(+), 30 deletions(-) diff --git a/packages/plugins/apps/src/vite/local-execution.test.ts b/packages/plugins/apps/src/vite/local-execution.test.ts index 85f2c6c95..f4af7abd6 100644 --- a/packages/plugins/apps/src/vite/local-execution.test.ts +++ b/packages/plugins/apps/src/vite/local-execution.test.ts @@ -36,13 +36,6 @@ function testDollar(): TestGlobalDollar { return (globalThis as unknown as { $: TestGlobalDollar }).$; } -/** Narrows a caught `unknown` to `Error` without an `as` cast, pairing with a preceding `toBeInstanceOf(Error)` so a mismatch is reported there rather than tripping `eslint-plugin-jest`'s no-conditional-expect rule. */ -function assertIsError(value: unknown): asserts value is Error { - if (!(value instanceof Error)) { - throw new Error(`Expected an Error, got: ${String(value)}`); - } -} - beforeEach(() => { // Neither optional SDK is installed by default; tests exercising the "installed" path override this. jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(false); @@ -112,16 +105,14 @@ describe('local-execution — executeScriptLocally', () => { ).rejects.toThrow(`"example" is not a function exported from ${func.absolutePath}`); }); - test('Should throw when a customer module reaches for $ during its own top-level evaluation, matching production module-evaluation order', async () => { - let dollarAccessError: unknown = 'not captured'; + test('Should read $ as undefined when a customer module reaches for it during its own top-level evaluation, matching production module-evaluation order', async () => { + let dollarDuringModuleLoad: unknown = 'not captured'; const loadModule: LoadModule = async (specifier) => { if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { - // Production's static import also runs before its wrapper installs $, so this must fail the same way locally instead of resolving to undefined. - try { - dollarAccessError = (globalThis as Record).$; - } catch (error) { - dollarAccessError = error; - } + // Production's static import also runs before its wrapper installs $, so $ isn't a + // global property yet — reading it must resolve to undefined the same way locally, + // not throw (typeof $ never throws on an unresolvable reference in production). + dollarDuringModuleLoad = (globalThis as Record).$; return { example: () => 'done' }; } const notFoundError: NodeJS.ErrnoException = new Error( @@ -141,9 +132,7 @@ describe('local-execution — executeScriptLocally', () => { ); expect(result).toEqual({ data: 'done' }); - expect(dollarAccessError).toBeInstanceOf(Error); - assertIsError(dollarAccessError); - expect(dollarAccessError.message).toBe('No active local execution to resolve $ under.'); + expect(dollarDuringModuleLoad).toBeUndefined(); }); test("Should return a pre-existing globalThis.$ during a customer module's top-level evaluation when something (e.g. zx/globals) seeded it before this module loaded", async () => { @@ -232,14 +221,9 @@ describe('local-execution — executeScriptLocally', () => { ); let dollarDuringSecondLoad: unknown = 'not captured'; - let secondLoadError: unknown; const secondLoadModule: LoadModule = async (specifier) => { if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { - try { - dollarDuringSecondLoad = (globalThis as Record).$; - } catch (error) { - secondLoadError = error; - } + dollarDuringSecondLoad = (globalThis as Record).$; return { example: () => 'second' }; } throw new Error(`Cannot find module '${specifier}'`); @@ -253,10 +237,7 @@ describe('local-execution — executeScriptLocally', () => { mockLogger, ); - expect(dollarDuringSecondLoad).toBe('not captured'); - expect(secondLoadError).toBeInstanceOf(Error); - assertIsError(secondLoadError); - expect(secondLoadError.message).toBe('No active local execution to resolve $ under.'); + expect(dollarDuringSecondLoad).toBeUndefined(); }); test('Should reject when loadModule itself rejects, same as a native-module load failure would', async () => { @@ -1215,6 +1196,36 @@ describe('local-execution — executeScriptLocally', () => { ).rejects.toThrow(/example.*JSON.stringify silently drops/); }); + test('Should reject a Symbol-keyed property, which JSON.stringify silently omits with no replacer call at all', async () => { + const secretSymbol = Symbol('secret'); + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ + example: () => ({ status: 'ok', [secretSymbol]: 'leaked' }), + }), + mockLogger, + ), + ).rejects.toThrow(/example.*Symbol-keyed property/); + }); + + test('Should reject a Symbol-keyed property nested inside an array, not just at the top level', async () => { + const secretSymbol = Symbol('secret'); + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ example: () => [{ [secretSymbol]: 'leaked' }] }), + mockLogger, + ), + ).rejects.toThrow(/example.*Symbol-keyed property/); + }); + test('Should reject an explicit undefined nested inside a plain object, not just at the top level', async () => { await expect( executeScriptLocally( diff --git a/packages/plugins/apps/src/vite/local-execution.ts b/packages/plugins/apps/src/vite/local-execution.ts index a6d3eec10..e175b4109 100644 --- a/packages/plugins/apps/src/vite/local-execution.ts +++ b/packages/plugins/apps/src/vite/local-execution.ts @@ -61,8 +61,11 @@ function dollarGetter(): unknown { if (hadPreexistingDollar) { return globalDollarOutsideExecution; } - // Matches production, where a customer module's top-level evaluation also runs before $ is installed and fails loudly rather than resolving to undefined. - throw new Error('No active local execution to resolve $ under.'); + // Matches production: $ isn't a global property at all until main() assigns it, so an + // unresolvable `$` reads as undefined rather than throwing (per typeof's spec-defined + // behavior on unresolvable references) — returning undefined here keeps that true even + // though $ is a real accessor property locally, not a genuinely absent one. + return undefined; } return globalDollarOutsideExecution; } @@ -364,7 +367,24 @@ async function registerBackendRuntimeOnce( // Lets the replacer's already-specific message pass through the outer catch below unwrapped, instead of being replaced by its generic fallback. class UnsupportedJsonValueError extends Error {} +/** `JSON.stringify`'s replacer never runs for a symbol-KEYED property (only symbol-valued ones under a string key) — it silently omits them with no callback at all, so they need their own recursive check. */ +function findSymbolKeyedObject(value: unknown, visited: Set): boolean { + if (typeof value !== 'object' || value === null || visited.has(value)) { + return false; + } + if (Object.getOwnPropertySymbols(value).length > 0) { + return true; + } + visited.add(value); + return Object.values(value).some((child) => findSymbolKeyedObject(child, visited)); +} + function assertJsonSerializable(result: unknown, func: BackendFunction): unknown { + if (findSymbolKeyedObject(result, new Set())) { + throw new Error( + `Local execution of "${func.name}" returned a value with a Symbol-keyed property, which JSON.stringify silently drops instead of serializing — return a plain JSON-compatible value instead.`, + ); + } let serialized: string | undefined; try { // A replacer visits every key/value pair including the root, so a disallowed value nested arbitrarily deep is caught the same way a top-level one is, instead of JSON.stringify silently flattening/converting/dropping it. The root is excluded from the function/Symbol/undefined check below (handled separately via `serialized === undefined`) and tracked with a one-shot flag, not `key === ''`, since a real property can itself be named `''`. From 17b64764a354a96b3da61ffc18dd7a7cec72e83c Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Thu, 27 Aug 2026 12:42:41 -0400 Subject: [PATCH 18/30] feat(apps): wire local execution into the real dev server --- .../src/vite/dev-server.integration.test.ts | 137 ++++++++++ .../plugins/apps/src/vite/dev-server.test.ts | 249 ++++++++++++++++-- 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 ++ .../apps/src/vite/local-execution.test.ts | 11 +- packages/tests/src/_jest/helpers/mocks.ts | 26 ++ 7 files changed, 614 insertions(+), 61 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..45fd02668 --- /dev/null +++ b/packages/plugins/apps/src/vite/dev-server.integration.test.ts @@ -0,0 +1,137 @@ +// 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 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, + * 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/ + * 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..b1529f7ee 100644 --- a/packages/plugins/apps/src/vite/dev-server.test.ts +++ b/packages/plugins/apps/src/vite/dev-server.test.ts @@ -2,10 +2,12 @@ // 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'; -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'; @@ -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,20 @@ 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. + */ +function mockLoadModuleReturning(func: BackendFunction, fn: (...args: never[]) => unknown) { + mockLoadModule.mockImplementation(moduleResolverFor(func, { [func.name]: fn })); +} + describe('Dev Server Middleware', () => { beforeEach(() => { jest.clearAllMocks(); mockViteBuild.mockReset(); + mockLoadModule.mockReset(); }); afterEach(() => { @@ -160,6 +179,7 @@ describe('Dev Server Middleware', () => { describe('createDevServerMiddleware routing', () => { const middleware = createDevServerMiddleware( mockViteBuild, + mockLoadModule, () => mockFunctions, mockAuth, getApiKeyRequest(), @@ -208,7 +228,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 +266,7 @@ describe('Dev Server Middleware', () => { }, }); - const req = createMockRequest('/__dd/executeAction', { + const req = createMockRequest('/__dd/executeActionViaCloud', { functionName: encodeQueryName(mockFunctions[0]), args: ['world'], }); @@ -248,6 +289,7 @@ describe('Dev Server Middleware', () => { describe('debugBundle handler', () => { const middleware = createDevServerMiddleware( mockViteBuild, + mockLoadModule, () => mockFunctions, mockAuth, getApiKeyRequest(), @@ -321,9 +363,10 @@ describe('Dev Server Middleware', () => { }); }); - describe('executeAction handler', () => { + describe('executeActionViaCloud handler', () => { const middleware = createDevServerMiddleware( mockViteBuild, + mockLoadModule, () => mockFunctions, mockAuth, getApiKeyRequest(), @@ -332,7 +375,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 +385,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 +412,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 +464,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 +492,7 @@ describe('Dev Server Middleware', () => { const oauthMiddleware = createDevServerMiddleware( mockViteBuild, + mockLoadModule, () => mockFunctions, mockOauthOnlyAuth, getOAuthRequest(), @@ -469,7 +513,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 +532,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 +540,7 @@ describe('Dev Server Middleware', () => { mockLog, ); - const req = createMockRequest('/__dd/executeAction', { + const req = createMockRequest('/__dd/executeActionViaCloud', { functionName: encodeQueryName(mockFunctions[0]), args: [], }); @@ -546,7 +591,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 +620,7 @@ describe('Dev Server Middleware', () => { ]; const middlewareWithAllowlist = createDevServerMiddleware( mockViteBuild, + mockLoadModule, () => functionsWithAllowlist, mockAuth, getApiKeyRequest(), @@ -605,7 +651,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 +706,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 +733,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 +761,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 +778,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 f982275e9..801aaaf12 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; @@ -120,18 +122,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 `makeExecuteActionRemotely` + * 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}`); @@ -144,14 +148,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, }, }, @@ -178,36 +175,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; +} + +/** + * 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}...`); @@ -226,7 +294,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; @@ -303,9 +371,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, @@ -318,7 +449,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, @@ -335,7 +466,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); } } @@ -357,6 +488,7 @@ function buildFunctionMap(backendFunctions: BackendFunction[]): Map BackendFunction[], auth: AuthConfig, doAuthenticatedRequest: DoAuthenticatedRequest | undefined, @@ -375,7 +507,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}`, ); } @@ -392,11 +524,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 3b8e6b049..2f6e86df0 100644 --- a/packages/plugins/apps/src/vite/index.test.ts +++ b/packages/plugins/apps/src/vite/index.test.ts @@ -277,4 +277,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 6002db081..2a6360456 100644 --- a/packages/plugins/apps/src/vite/index.ts +++ b/packages/plugins/apps/src/vite/index.ts @@ -123,6 +123,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: { @@ -216,6 +232,7 @@ export const getVitePlugin = ({ server.middlewares.use( createDevServerMiddleware( bundler.build, + server.ssrLoadModule.bind(server), getBackendFunctions, auth, doAuthenticatedRequest, diff --git a/packages/plugins/apps/src/vite/local-execution.test.ts b/packages/plugins/apps/src/vite/local-execution.test.ts index f4af7abd6..238ab2980 100644 --- a/packages/plugins/apps/src/vite/local-execution.test.ts +++ b/packages/plugins/apps/src/vite/local-execution.test.ts @@ -4,7 +4,7 @@ /* global globalThis, NodeJS */ -import { mockLogFn, mockLogger } from '@dd/tests/_jest/helpers/mocks'; +import { mockLogFn, mockLogger, moduleResolverFor } from '@dd/tests/_jest/helpers/mocks'; import * as shared from '../backend/shared'; import type { BackendFunction } from '../backend/types'; @@ -46,14 +46,7 @@ const stubExecuteAction: ExecuteAction = async (fqn) => ({ data: null, stub: tru /** A `loadModule` double that resolves the customer's function from a map and rejects anything else with a module-not-found error, matching the common case where neither optional package is installed. */ function loadModuleReturning(exports: Record): LoadModule { - return async (specifier: string) => { - if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { - return exports; - } - const error: NodeJS.ErrnoException = new Error(`Cannot find module '${specifier}'`); - error.code = 'MODULE_NOT_FOUND'; - throw error; - }; + return moduleResolverFor(func, exports); } const ORDER_MARKER = '__ddLocalExecutionTestOrder'; diff --git a/packages/tests/src/_jest/helpers/mocks.ts b/packages/tests/src/_jest/helpers/mocks.ts index 907dfda97..b92a13683 100644 --- a/packages/tests/src/_jest/helpers/mocks.ts +++ b/packages/tests/src/_jest/helpers/mocks.ts @@ -2,6 +2,11 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. +/* global NodeJS */ + +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 { checkFile, @@ -120,6 +125,27 @@ export const getMockTimeLogger = (overrides: Partial = {}): TimeLogg return mockTimer; }; +/** + * Builds a `loadModule`-shaped resolver that returns `exports` for `func`'s + * 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 + LOCAL_EXECUTION_LOAD_SUFFIX) { + return exports; + } + const error: NodeJS.ErrnoException = new Error(`Cannot find module '${specifier}'`); + error.code = 'MODULE_NOT_FOUND'; + throw error; + }; +}; + export const mockLogFn = jest.fn((text: any, level: LogLevel) => {}); export const getMockLogger = (overrides: Partial = {}): Logger => ({ getLogger: jest.fn(), From 9c48f6c809ec3e28f1e68926a87c17b36e406e2e Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Thu, 27 Aug 2026 12:42:57 -0400 Subject: [PATCH 19/30] feat(apps): surface $.Actions result/error detail to the local console --- .../src/vite/dev-server.integration.test.ts | 72 ++++++++++++++++--- .../plugins/apps/src/vite/dev-server.test.ts | 69 +++++++++++++++++- packages/plugins/apps/src/vite/dev-server.ts | 30 +++++--- packages/plugins/apps/src/vite/index.ts | 43 +++++++++++ .../apps/src/vite/local-execution.test.ts | 48 +++++++++++++ .../plugins/apps/src/vite/local-execution.ts | 43 +++++++++-- .../nestedImport.backend.ts | 9 +++ 7 files changed, 289 insertions(+), 25 deletions(-) create mode 100644 packages/tests/src/_jest/fixtures/apps_backend_project/nestedImport.backend.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 index 45fd02668..d33c1de58 100644 --- a/packages/plugins/apps/src/vite/dev-server.integration.test.ts +++ b/packages/plugins/apps/src/vite/dev-server.integration.test.ts @@ -12,13 +12,11 @@ * 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. + * The nested-import test below registers the real `getVitePlugin()` hooks + * on this server (previous versions of this file didn't, and left that as a + * documented follow-up) — needed specifically to exercise `resolveId`'s + * `LOCAL_EXECUTION_LOAD_SUFFIX` propagation against Vite's own real module + * resolution, which a mocked `this.resolve()` can't reproduce. * * Uses `@datadog/apps-backend` (the fixture already has it as a real, * locally-resolvable dependency — see `packages/tests/src/_jest/fixtures/ @@ -33,11 +31,12 @@ */ import { createDevServerMiddleware } from '@dd/apps-plugin/vite/dev-server'; -import { getMockLogger } from '@dd/tests/_jest/helpers/mocks'; +import { getVitePlugin } from '@dd/apps-plugin/vite/index'; +import { getContextMock, 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 { build, createServer, type Plugin, type ViteDevServer } from 'vite'; import { encodeQueryName } from '../backend/encodeQueryName'; import type { BackendFunction } from '../backend/types'; @@ -86,16 +85,37 @@ function createMockResponse() { return res as typeof res & ServerResponse; } +const nestedImportFunc: BackendFunction = { + relativePath: 'nestedImport', + name: 'usesNestedImport', + absolutePath: path.join(FIXTURE_ROOT, 'nestedImport.backend.ts'), + allowedConnectionIds: [], +}; + describe('Dev Server Middleware — real end-to-end local execution', () => { let server: ViteDevServer; beforeAll(async () => { + const appsPlugin: Plugin = { + name: 'dd-apps-test', + ...getVitePlugin({ + bundler: { build }, + context: getContextMock({ buildRoot: FIXTURE_ROOT }), + options: { + authOverrides: { method: 'apiKey' }, + include: [], + dryRun: true, + }, + }), + }; + server = await createServer({ configFile: false, root: FIXTURE_ROOT, logLevel: 'silent', server: { middlewareMode: true, hmr: false }, ssr: { noExternal: true }, + plugins: [appsPlugin], }); }); @@ -134,4 +154,38 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { }, }); }, 30000); + + // Real coverage for the resolveId propagation fix in vite/index.ts: + // nestedImport.backend.ts statically imports plainEcho from + // getRuntimeUsers.backend.ts. Without propagating + // LOCAL_EXECUTION_LOAD_SUFFIX onto that nested import, Vite would + // resolve it unsuffixed, the transform hook would replace it with the + // frontend RPC-proxy stub (calling globalThis.DD_APPS_RUNTIME, which + // doesn't exist server-side), and this would throw instead of returning + // the real value. + test('Should preserve real code for a nested *.backend.ts import, not swap it for the frontend RPC-proxy stub', async () => { + const middleware = createDevServerMiddleware( + build, + server.ssrLoadModule.bind(server), + () => [nestedImportFunc], + { site: 'datadoghq.com' }, + undefined, + FIXTURE_ROOT, + getMockLogger(), + ); + + const req = createMockRequest('/__dd/executeAction', { + functionName: encodeQueryName(nestedImportFunc), + args: ['nested-value'], + }); + 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: { value: 'nested-value' } }); + }, 30000); }); diff --git a/packages/plugins/apps/src/vite/dev-server.test.ts b/packages/plugins/apps/src/vite/dev-server.test.ts index b1529f7ee..a0a2ca9ca 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, moduleResolverFor } from '@dd/tests/_jest/helpers/mocks'; +import { getMockLogger, mockLogFn, moduleResolverFor } from '@dd/tests/_jest/helpers/mocks'; import { EventEmitter } from 'events'; import type { IncomingMessage, ServerResponse } from 'http'; import nock from 'nock'; @@ -948,6 +948,73 @@ describe('Dev Server Middleware', () => { connectionId: 'conn-1', }); }); + + test("Should surface a successful $.Actions call's result to the local console", async () => { + mockLoadModuleReturning(mockFunctions[0], () => + (globalThis as Record).$.Actions.slack.chat.postMessage({ + inputs: { text: 'hi' }, + }), + ); + + nock(DD_API_ORIGIN) + .post('/api/v2/app-builder/queries/preview-async') + .reply(200, { data: { id: 'receipt-success' } }) + .get('/api/v2/app-builder/queries/execution-long-polling/receipt-success') + .reply(200, { + data: { attributes: { done: true, outputs: { ok: true } } }, + }); + + 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); + expect(mockLogFn).toHaveBeenCalledWith( + expect.stringContaining('com.datadoghq.slack.chat.postMessage'), + 'info', + ); + expect(mockLogFn).toHaveBeenCalledWith(expect.stringContaining('"ok":true'), 'info'); + }); + + test("Should surface a failed $.Actions call's error detail to the local console", async () => { + mockLoadModuleReturning(mockFunctions[0], () => + (globalThis as Record).$.Actions.slack.chat.postMessage({ + inputs: { text: 'hi' }, + }), + ); + + nock(DD_API_ORIGIN) + .post('/api/v2/app-builder/queries/preview-async') + .reply(200, { data: { id: 'receipt-failure' } }) + .get('/api/v2/app-builder/queries/execution-long-polling/receipt-failure') + .reply(200, { + errors: [{ detail: 'Connection is not authorized for this action' }], + }); + + 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(500); + expect(mockLogFn).toHaveBeenCalledWith( + expect.stringContaining('com.datadoghq.slack.chat.postMessage'), + 'error', + ); + expect(mockLogFn).toHaveBeenCalledWith( + expect.stringContaining('Connection is not authorized for this action'), + 'error', + ); + }); }); describe('dynamic discovery', () => { diff --git a/packages/plugins/apps/src/vite/dev-server.ts b/packages/plugins/apps/src/vite/dev-server.ts index 801aaaf12..a7bcd2670 100644 --- a/packages/plugins/apps/src/vite/dev-server.ts +++ b/packages/plugins/apps/src/vite/dev-server.ts @@ -224,6 +224,12 @@ async function executeScriptViaDatadog( * 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. + * + * Logs the resolved result or error detail at info/error level — the + * Telemetry milestone's primary deliverable. Production's own equivalent + * signal only reaches Datadog's backend; a developer watching `npm run dev` + * would otherwise see no result at all for an action call beyond the + * `log.debug` breadcrumbs `submitQuery`/`pollQueryExecution` already emit. */ function makeExecuteActionRemotely( auth: AuthConfig, @@ -238,14 +244,22 @@ function makeExecuteActionRemotely( 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); + try { + const receiptId = await submitQuery( + connectionId ? { fqn, inputs, connectionId } : { fqn, inputs }, + fqn, + auth, + doAuthenticatedRequest, + log, + ); + const result = await pollQueryExecution(receiptId, auth, doAuthenticatedRequest, log); + log.info(`$.Actions call to "${fqn}" succeeded: ${JSON.stringify(result)}`); + return result; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + log.error(`$.Actions call to "${fqn}" failed: ${message}`); + throw error; + } }; } diff --git a/packages/plugins/apps/src/vite/index.ts b/packages/plugins/apps/src/vite/index.ts index 2a6360456..b94f446ce 100644 --- a/packages/plugins/apps/src/vite/index.ts +++ b/packages/plugins/apps/src/vite/index.ts @@ -139,6 +139,49 @@ export const getVitePlugin = ({ }, }; }, + // Propagates the LOCAL_EXECUTION_LOAD_SUFFIX marker through the + // backend-file dependency graph: `ssrLoadModule` only tags the one + // entry module it's called with, so without this hook, a + // `.backend.ts` file statically importing another `.backend.ts` file + // would resolve that nested import unsuffixed, hitting transform's + // "not suffixed" branch below and getting replaced with the frontend + // RPC-proxy stub — breaking local execution for a multi-backend-file + // import graph. Only propagates when the importer itself was + // suffixed (this is local execution's own module graph, not a + // regular frontend import) and only onto another `.backend.ts` file + // (a plain helper module never hits the proxy-vs-real-code branching + // this marker exists to disambiguate, so it needs no suffix). + resolveId: { + // Must run before Vite's own built-in resolver: a plain relative + // specifier like `./other.backend` is fully resolvable by Vite's + // internal filesystem-based resolution alone, which — running at + // its default, unenforced order — would resolve and short-circuit + // the hook chain before this plugin's own resolveId ever saw it. + // `pre` guarantees this hook gets first look at every id. + order: 'pre', + async handler(source, importer, resolveOptions) { + if (!importer || !importer.endsWith(LOCAL_EXECUTION_LOAD_SUFFIX)) { + return null; + } + + const resolved = await this.resolve(source, importer, { + ...resolveOptions, + skipSelf: true, + }); + if (!resolved || resolved.external) { + return resolved; + } + + if ( + BACKEND_FILE_RE.test(resolved.id) && + !resolved.id.endsWith(LOCAL_EXECUTION_LOAD_SUFFIX) + ) { + return { ...resolved, id: resolved.id + LOCAL_EXECUTION_LOAD_SUFFIX }; + } + + return resolved; + }, + }, transform: { filter: { id: { diff --git a/packages/plugins/apps/src/vite/local-execution.test.ts b/packages/plugins/apps/src/vite/local-execution.test.ts index 238ab2980..b0f7942a0 100644 --- a/packages/plugins/apps/src/vite/local-execution.test.ts +++ b/packages/plugins/apps/src/vite/local-execution.test.ts @@ -441,7 +441,55 @@ describe('local-execution — executeScriptLocally', () => { ); }); + // executeAction stands in for dev-server.ts's real makeExecuteActionRemotely, whose long-poll can legitimately outlast a short hang-detection timeout — that's network wait time, not a hung customer function. + test('Should not time out while a real $.Actions call is still legitimately in flight, even past the configured timeout', async () => { + const slowExecuteAction: ExecuteAction = () => + new Promise((resolve) => setTimeout(() => resolve({ ok: true }), 80)); + + const result = await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + slowExecuteAction, + loadModuleReturning({ + example: () => + (globalThis as Record).$.Actions.slack.chat.postMessage({ + inputs: { text: 'hi' }, + }), + }), + mockLogger, + 50, // shorter than slowExecuteAction's own 80ms + ); + + expect(result).toEqual({ data: { ok: true } }); + }); + + test('Should still time out a function that hangs with no $.Actions call in flight, even after an earlier call in the same run completed', async () => { + const executeAction: ExecuteAction = async () => ({ ok: true }); + + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + executeAction, + loadModuleReturning({ + example: async () => { + await (globalThis as Record).$.Actions.slack.chat.postMessage({ + inputs: { text: 'hi' }, + }); + // Hangs with no further $.Actions call — the fresh timeout window from the completed call above must still expire normally. + return new Promise(() => {}); + }, + }), + mockLogger, + 50, + ), + ).rejects.toThrow(/timed out after 50ms/); + }); + // Asserts $'s exact key set, since a token added inside globalThis.$ wouldn't be caught by the weaker top-level check below. + test('Should never expose an auth token to the customer module — only backendFunctionArgs, Actions, and Source are visible on globalThis.$', async () => { const result = await executeScriptLocally( func, diff --git a/packages/plugins/apps/src/vite/local-execution.ts b/packages/plugins/apps/src/vite/local-execution.ts index e175b4109..5808009d8 100644 --- a/packages/plugins/apps/src/vite/local-execution.ts +++ b/packages/plugins/apps/src/vite/local-execution.ts @@ -464,7 +464,30 @@ async function runScriptLocally( // A timed-out execution is abandoned, not canceled — its fn() may keep running and must not act under a newer execution's identity. isCurrent() gates both this execution's own captured `$.Actions` closure and the shared adapters, which resolve the calling execution's dispatch from AsyncLocalStorage rather than whichever registration is currently live. const scope = executionEpoch.start(); - const guardedExecuteAction: ExecuteAction = (fqn, inputs, connectionId) => { + // `executeAction`'s own long-poll (dev-server.ts's pollQueryExecution) + // can legitimately take far longer than `timeoutMs` on its own — that's + // time spent waiting on a real network round trip, not evidence the + // customer function itself has hung. Pausing the hang-detection timer + // while at least one call is in flight, and giving it a fresh + // `timeoutMs` window once every in-flight call has settled, means a + // function that keeps making real progress via `$.Actions` is never + // penalized for it, while a function that genuinely hangs (with no + // `$.Actions` call in flight) still times out at the same `timeoutMs` + // it always did. + let timer: ReturnType | undefined; + let rejectTimeout: ((error: Error) => void) | undefined; + let pendingActionCalls = 0; + + const scheduleTimeout = () => { + timer = setTimeout(() => { + concludeExecution(); + rejectTimeout?.( + new Error(`Local execution of "${func.name}" timed out after ${timeoutMs}ms`), + ); + }, timeoutMs); + }; + + const guardedExecuteAction: ExecuteAction = async (fqn, inputs, connectionId) => { if (!scope.isCurrent()) { // A concluded scope stays concluded forever, not just "not the latest" — the wording stays conclusion-neutral rather than claiming a timeout that may not have happened. return Promise.reject( @@ -474,7 +497,16 @@ async function runScriptLocally( ), ); } - return executeAction(fqn, inputs, connectionId); + pendingActionCalls += 1; + clearTimeout(timer); + try { + return await executeAction(fqn, inputs, connectionId); + } finally { + pendingActionCalls -= 1; + if (pendingActionCalls === 0 && scope.isCurrent()) { + scheduleTimeout(); + } + } }; const concludeExecution = () => { @@ -534,12 +566,9 @@ async function runScriptLocally( ); }; - let timer: ReturnType | undefined; const timeout = new Promise((_resolve, reject) => { - timer = setTimeout(() => { - concludeExecution(); - reject(new Error(`Local execution of "${func.name}" timed out after ${timeoutMs}ms`)); - }, timeoutMs); + rejectTimeout = reject; + scheduleTimeout(); }); // Racing the timeout only stops the caller from waiting — run() keeps executing afterward, so a resumed customer function can still fire real $.Actions side effects; true cancellation would need a Worker thread, not possible in-process. diff --git a/packages/tests/src/_jest/fixtures/apps_backend_project/nestedImport.backend.ts b/packages/tests/src/_jest/fixtures/apps_backend_project/nestedImport.backend.ts new file mode 100644 index 000000000..bdeb98437 --- /dev/null +++ b/packages/tests/src/_jest/fixtures/apps_backend_project/nestedImport.backend.ts @@ -0,0 +1,9 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import { plainEcho } from './getRuntimeUsers.backend'; + +export async function usesNestedImport(value: string) { + return plainEcho(value); +} From 3a56e13d34df036e6d1f61109605b0ee73556e9d Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Thu, 27 Aug 2026 12:43:06 -0400 Subject: [PATCH 20/30] fix(apps): address dev-server wiring review findings --- packages/plugins/apps/package.json | 4 +- .../apps/src/vite/dev-server-module-graph.ts | 110 ++++++++++++++ .../src/vite/dev-server.integration.test.ts | 135 +++++++++++++++++- .../plugins/apps/src/vite/dev-server.test.ts | 61 ++++++-- packages/plugins/apps/src/vite/dev-server.ts | 108 +++++++------- packages/plugins/apps/src/vite/index.test.ts | 4 +- packages/plugins/apps/src/vite/index.ts | 82 ++++++++--- .../apps/src/vite/local-execution.test.ts | 15 +- .../published/esbuild-plugin/package.json | 1 + packages/published/rollup-plugin/package.json | 1 + packages/published/rspack-plugin/package.json | 1 + packages/published/vite-plugin/package.json | 1 + .../published/webpack-plugin/package.json | 1 + .../fixtures/apps_backend_project/helper.ts | 9 ++ .../apps_backend_project/viaHelper.backend.ts | 9 ++ 15 files changed, 453 insertions(+), 89 deletions(-) create mode 100644 packages/plugins/apps/src/vite/dev-server-module-graph.ts create mode 100644 packages/tests/src/_jest/fixtures/apps_backend_project/helper.ts create mode 100644 packages/tests/src/_jest/fixtures/apps_backend_project/viaHelper.backend.ts diff --git a/packages/plugins/apps/package.json b/packages/plugins/apps/package.json index a09626633..0470a0e08 100644 --- a/packages/plugins/apps/package.json +++ b/packages/plugins/apps/package.json @@ -35,12 +35,12 @@ "eslint-scope": "7.2.2", "glob": "11.1.0", "jszip": "3.10.1", - "pretty-bytes": "5.6.0" + "pretty-bytes": "5.6.0", + "rollup": "4.45.1" }, "devDependencies": { "@types/eslint-scope": "3.7.7", "@types/estree": "1.0.8", - "rollup": "4.45.1", "typescript": "5.4.3", "vite": "6.3.5" } diff --git a/packages/plugins/apps/src/vite/dev-server-module-graph.ts b/packages/plugins/apps/src/vite/dev-server-module-graph.ts new file mode 100644 index 000000000..3a8a29f3b --- /dev/null +++ b/packages/plugins/apps/src/vite/dev-server-module-graph.ts @@ -0,0 +1,110 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import { parseAst } from 'rollup/parseAst'; +import type { ModuleNode, ViteDevServer } from 'vite'; + +import { + createParsedModuleRecord, + type ParsedModuleRecord, + shouldTraverseCollectedModule, + unsupportedModuleGraphDependency, +} from '../backend/ast-parsing/module-graph'; +import { LOCAL_EXECUTION_LOAD_SUFFIX } from '../constants'; + +/** + * Builds the same `ReadonlyMap` shape + * `createBackendModuleGraphCollector`'s `moduleParsed` hook produces during a + * real Rollup build — but for the dev server, where `moduleParsed` never + * fires at all (it's a Rollup-build-only hook; Vite's dev-server plugin + * container doesn't implement it). Instead, this walks Vite's own + * `server.moduleGraph`, which the dev server already populates as a side + * effect of `ssrLoadModule`: by the time an `await server.ssrLoadModule(id)` + * call resolves, the entry's `ModuleNode.importedModules` — and every + * imported module's own `importedModules` — already reflect the full + * transitive static-import graph, recursively, with no extra ticks needed. + * + * Call this only after `loadModule` has resolved for `bareEntryId + + * LOCAL_EXECUTION_LOAD_SUFFIX` in the same request — the graph it reads is a + * live side effect of that call, not independently maintained state. + * `bareEntryId` is the same unsuffixed id `extractConnectionIdsFromModuleGraph` + * needs to key into the returned map below; the suffix is appended here, + * internally, rather than left to each caller to remember — Vite keys the + * node it just loaded by the full resolved id (suffix included), since it + * treats each distinct query string as a logically distinct module. + */ +export function collectModuleGraphFromServer( + server: ViteDevServer, + bareEntryId: string, + buildRoot: string, +): ReadonlyMap { + const records = new Map(); + const visited = new Set(); + const pending: ModuleNode[] = []; + + const entryNode = server.moduleGraph.getModuleById(bareEntryId + LOCAL_EXECUTION_LOAD_SUFFIX); + if (entryNode) { + pending.push(entryNode); + } + + while (pending.length > 0) { + const node = pending.shift()!; + const moduleId = node.id ? normalizeViteModuleId(node.id) : undefined; + if (!moduleId || visited.has(moduleId)) { + continue; + } + visited.add(moduleId); + + if (!shouldTraverseCollectedModule(moduleId, buildRoot)) { + continue; + } + + // `transformResult` is the client/browser transform; SSR loads (what + // local execution always is, via server.ssrLoadModule) populate + // `ssrTransformResult` instead. Neither exists yet if Vite hasn't + // transformed this module — a local dependency the caller's own + // ssrLoadModule call never actually reached. + const transformResult = node.ssrTransformResult ?? node.transformResult; + if (typeof transformResult?.code !== 'string') { + continue; + } + + let ast; + try { + ast = parseAst(transformResult.code); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw unsupportedModuleGraphDependency( + moduleId, + `unparseable module source (${reason})`, + ); + } + + // `deps` are this module's own static imports, already resolved to + // real module ids by Vite's import-analysis plugin — the SSR + // equivalent of `moduleParsed`'s `importedIds`/ + // `importedIdResolutions`. `dynamicDeps` (deliberately unused here) + // folds in `import()` calls; `module-graph.ts`'s own AST walk is what + // flags those as unsupported, so only static deps belong here. + const staticDependencyIds = (transformResult.deps ?? []).map(normalizeViteModuleId); + + const record = createParsedModuleRecord(moduleId, buildRoot, ast, staticDependencyIds); + if (record) { + records.set(record.id, record); + } + + for (const dependencyId of staticDependencyIds) { + const dependencyNode = server.moduleGraph.getModuleById(dependencyId); + if (dependencyNode) { + pending.push(dependencyNode); + } + } + } + + return records; +} + +function normalizeViteModuleId(id: string): string { + return id.split('?')[0]; +} 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 d33c1de58..814461a43 100644 --- a/packages/plugins/apps/src/vite/dev-server.integration.test.ts +++ b/packages/plugins/apps/src/vite/dev-server.integration.test.ts @@ -13,8 +13,7 @@ * root rather than build-plugins' own dependency tree. * * The nested-import test below registers the real `getVitePlugin()` hooks - * on this server (previous versions of this file didn't, and left that as a - * documented follow-up) — needed specifically to exercise `resolveId`'s + * on this server, needed specifically to exercise `resolveId`'s * `LOCAL_EXECUTION_LOAD_SUFFIX` propagation against Vite's own real module * resolution, which a mocked `this.resolve()` can't reproduce. * @@ -30,6 +29,7 @@ * meaningful. */ +import { collectModuleGraphFromServer } from '@dd/apps-plugin/vite/dev-server-module-graph'; import { createDevServerMiddleware } from '@dd/apps-plugin/vite/dev-server'; import { getVitePlugin } from '@dd/apps-plugin/vite/index'; import { getContextMock, getMockLogger } from '@dd/tests/_jest/helpers/mocks'; @@ -38,6 +38,7 @@ import type { IncomingMessage, ServerResponse } from 'http'; import path from 'path'; import { build, createServer, type Plugin, type ViteDevServer } from 'vite'; +import { extractConnectionIdsFromModuleGraph } from '../backend/ast-parsing/extract-connection-ids-from-module-graph'; import { encodeQueryName } from '../backend/encodeQueryName'; import type { BackendFunction } from '../backend/types'; @@ -92,6 +93,13 @@ const nestedImportFunc: BackendFunction = { allowedConnectionIds: [], }; +const viaHelperFunc: BackendFunction = { + relativePath: 'viaHelper', + name: 'usesHelper', + absolutePath: path.join(FIXTURE_ROOT, 'viaHelper.backend.ts'), + allowedConnectionIds: [], +}; + describe('Dev Server Middleware — real end-to-end local execution', () => { let server: ViteDevServer; @@ -114,7 +122,6 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { root: FIXTURE_ROOT, logLevel: 'silent', server: { middlewareMode: true, hmr: false }, - ssr: { noExternal: true }, plugins: [appsPlugin], }); }); @@ -128,8 +135,10 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { build, server.ssrLoadModule.bind(server), () => [getRuntimeUsersFunc], + () => [], { site: 'datadoghq.com' }, - undefined, // no auth configured — this function never calls $.Actions + // No auth configured — this function never calls $.Actions. + undefined, FIXTURE_ROOT, getMockLogger(), ); @@ -168,6 +177,7 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { build, server.ssrLoadModule.bind(server), () => [nestedImportFunc], + () => [], { site: 'datadoghq.com' }, undefined, FIXTURE_ROOT, @@ -188,4 +198,121 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { expect(body.success).toBe(true); expect(body.result).toEqual({ data: { value: 'nested-value' } }); }, 30000); + + // Real coverage for the multi-hop case the single-hop propagation above + // still misses: viaHelper.backend.ts imports helper.ts (a plain, + // non-backend module), which itself imports plainEcho from + // getRuntimeUsers.backend.ts. resolveId only appended the suffix when + // the DIRECT importer string ended with it, so helper.ts (reached + // through a suffixed importer, but never suffixed itself, since it + // isn't a *.backend.ts file) became an unsuffixed importer for its own + // import — silently dropping the marker one hop later than the direct + // backend-to-backend case above, and swapping getRuntimeUsers.backend.ts + // for its frontend RPC-proxy stub. + test('Should preserve real code for a *.backend.ts import reached through an intermediate non-backend module', async () => { + const middleware = createDevServerMiddleware( + build, + server.ssrLoadModule.bind(server), + () => [viaHelperFunc], + () => [], + { site: 'datadoghq.com' }, + undefined, + FIXTURE_ROOT, + getMockLogger(), + ); + + const req = createMockRequest('/__dd/executeAction', { + functionName: encodeQueryName(viaHelperFunc), + args: ['via-helper-value'], + }); + 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: { value: 'via-helper-value' } }); + }, 30000); + + // Regression coverage for the real configureServer-installed middleware, + // not a hand-built one: every other test in this file constructs its own + // middleware via createDevServerMiddleware(..., () => [], ...), which + // bypasses getAllowedConnectionIds' real wiring entirely (a hardcoded + // () => [] never exercises collectModuleGraphFromServer at all). Sending + // the request through server.middlewares — the real Connect stack + // getVitePlugin's own configureServer hook installed when this file's + // createServer() call ran — is what actually proves the fix: before it, + // getAllowedConnectionIds threw "missing module record" for the entry + // module itself on every call, since moduleParsed (a Rollup-build-only + // hook) never fires on a real Vite dev server. + test('Should execute successfully through the real configureServer-installed middleware, walking a real multi-hop import graph', async () => { + // Registers viaHelperFunc in the real backend-function registry — + // configureServer's real middleware looks functions up there, and + // registration is itself a side effect of transforming the file as + // a normal (unsuffixed) frontend import, exactly like a real + // frontend entry point importing the generated client SDK would. + await server.ssrLoadModule(viaHelperFunc.absolutePath); + + const req = createMockRequest('/__dd/executeAction', { + functionName: encodeQueryName(viaHelperFunc), + args: ['real-middleware-value'], + }); + const res = createMockResponse(); + + server.middlewares(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: { value: 'real-middleware-value' } }); + }, 30000); + + // Regression coverage for a real getAllowedConnectionIds wired exactly as + // vite/index.ts's configureServer builds it, on the very first request + // for an entry — no priming import beforehand. The test above still + // primes viaHelperFunc via an unsuffixed ssrLoadModule call first, which + // (before the entryId fix) left a stale, unsuffixed moduleGraph node + // behind that getModuleById happened to find, masking the real gap: on a + // cold entry, Vite only ever registers the node under the fully-resolved + // (suffixed) id handleExecuteAction's own loadModule call just produced, + // and collectModuleGraphFromServer was looking it up by the bare path. + test('Should compute allowed connection IDs on the very first request for an entry, with no prior priming import', async () => { + const loadModule = server.ssrLoadModule.bind(server); + // collectModuleGraphFromServer now appends LOCAL_EXECUTION_LOAD_SUFFIX internally, + // so this closure only ever handles the bare id — matching vite/index.ts's real wiring. + const getAllowedConnectionIds = (entryId: string) => + extractConnectionIdsFromModuleGraph( + entryId, + collectModuleGraphFromServer(server, entryId, FIXTURE_ROOT), + FIXTURE_ROOT, + ); + + const middleware = createDevServerMiddleware( + build, + loadModule, + () => [nestedImportFunc], + getAllowedConnectionIds, + { site: 'datadoghq.com' }, + undefined, + FIXTURE_ROOT, + getMockLogger(), + ); + + const req = createMockRequest('/__dd/executeAction', { + functionName: encodeQueryName(nestedImportFunc), + args: ['cold-entry-value'], + }); + 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: { value: 'cold-entry-value' } }); + }, 30000); }); diff --git a/packages/plugins/apps/src/vite/dev-server.test.ts b/packages/plugins/apps/src/vite/dev-server.test.ts index a0a2ca9ca..a00c23350 100644 --- a/packages/plugins/apps/src/vite/dev-server.test.ts +++ b/packages/plugins/apps/src/vite/dev-server.test.ts @@ -28,11 +28,18 @@ jest.mock('@dd/core/helpers/oauth-request', () => ({ }), })); +/** + * Shape of the `$.Actions` dynamic proxy — an arbitrarily-nested property + * path (e.g. `$.Actions.slack.chat.postMessage`) that's callable at any + * depth. Used to type `globalThis.$` in tests without an `any` cast. + */ +type ActionsProxy = { [key: string]: ActionsProxy } & ((...args: unknown[]) => Promise); + 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 + * path doesn't bundle, so tests exercising it configure this directly * instead of `mockBuildWithParsedBackend`. */ const mockLoadModule = jest.fn(); @@ -162,7 +169,8 @@ function mockBuildWithParsedBackend(code = '// code') { * returns for a real backend-function file. */ function mockLoadModuleReturning(func: BackendFunction, fn: (...args: never[]) => unknown) { - mockLoadModule.mockImplementation(moduleResolverFor(func, { [func.name]: fn })); + const resolveModule = moduleResolverFor(func, { [func.name]: fn }); + mockLoadModule.mockImplementation(resolveModule); } describe('Dev Server Middleware', () => { @@ -181,6 +189,7 @@ describe('Dev Server Middleware', () => { mockViteBuild, mockLoadModule, () => mockFunctions, + () => [], mockAuth, getApiKeyRequest(), '/project', @@ -291,6 +300,7 @@ describe('Dev Server Middleware', () => { mockViteBuild, mockLoadModule, () => mockFunctions, + () => [], mockAuth, getApiKeyRequest(), '/project', @@ -368,6 +378,7 @@ describe('Dev Server Middleware', () => { mockViteBuild, mockLoadModule, () => mockFunctions, + () => [], mockAuth, getApiKeyRequest(), '/project', @@ -494,6 +505,7 @@ describe('Dev Server Middleware', () => { mockViteBuild, mockLoadModule, () => mockFunctions, + () => [], mockOauthOnlyAuth, getOAuthRequest(), '/project', @@ -534,6 +546,7 @@ describe('Dev Server Middleware', () => { mockViteBuild, mockLoadModule, () => mockFunctions, + () => [], mockOauthOnlyAuth, undefined, '/project', @@ -622,6 +635,7 @@ describe('Dev Server Middleware', () => { mockViteBuild, mockLoadModule, () => functionsWithAllowlist, + () => [], mockAuth, getApiKeyRequest(), '/project', @@ -783,6 +797,7 @@ describe('Dev Server Middleware', () => { mockViteBuild, mockLoadModule, () => mockFunctions, + () => [], mockAuth, getApiKeyRequest(), '/project', @@ -835,6 +850,7 @@ describe('Dev Server Middleware', () => { mockViteBuild, mockLoadModule, () => mockFunctions, + () => [], mockOauthOnlyAuth, undefined, '/project', @@ -862,13 +878,16 @@ describe('Dev Server Middleware', () => { mockViteBuild, mockLoadModule, () => mockFunctions, + () => [], mockOauthOnlyAuth, undefined, '/project', mockLog, ); mockLoadModuleReturning(mockFunctions[0], () => - (globalThis as Record).$.Actions.slack.chat.postMessage({ + ( + globalThis as typeof globalThis & { $: { Actions: ActionsProxy } } + ).$.Actions.slack.chat.postMessage({ inputs: { text: 'hi' }, }), ); @@ -882,15 +901,32 @@ describe('Dev Server Middleware', () => { noAuthMiddleware(req, res, jest.fn()); await res.done; - expect(res.statusCode).toBe(500); + expect(res.statusCode).toBe(400); 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({ + const funcWithConnection: BackendFunction = { + ...mockFunctions[0], + allowedConnectionIds: ['conn-1'], + }; + const middlewareWithConnection = createDevServerMiddleware( + mockViteBuild, + mockLoadModule, + () => [funcWithConnection, mockFunctions[1]], + (entryId: string) => + entryId === funcWithConnection.absolutePath ? ['conn-1'] : [], + mockAuth, + getApiKeyRequest(), + '/project', + mockLog, + ); + mockLoadModuleReturning(funcWithConnection, () => + ( + globalThis as typeof globalThis & { $: { Actions: ActionsProxy } } + ).$.Actions.slack.chat.postMessage({ inputs: { text: 'hi' }, connectionId: 'conn-1', }), @@ -924,12 +960,12 @@ describe('Dev Server Middleware', () => { }); const req = createMockRequest('/__dd/executeAction', { - functionName: encodeQueryName(mockFunctions[0]), + functionName: encodeQueryName(funcWithConnection), args: [], }); const res = createMockResponse(); - middleware(req, res, jest.fn()); + middlewareWithConnection(req, res, jest.fn()); await res.done; expect(res.statusCode).toBe(200); @@ -951,7 +987,9 @@ describe('Dev Server Middleware', () => { test("Should surface a successful $.Actions call's result to the local console", async () => { mockLoadModuleReturning(mockFunctions[0], () => - (globalThis as Record).$.Actions.slack.chat.postMessage({ + ( + globalThis as typeof globalThis & { $: { Actions: ActionsProxy } } + ).$.Actions.slack.chat.postMessage({ inputs: { text: 'hi' }, }), ); @@ -983,7 +1021,9 @@ describe('Dev Server Middleware', () => { test("Should surface a failed $.Actions call's error detail to the local console", async () => { mockLoadModuleReturning(mockFunctions[0], () => - (globalThis as Record).$.Actions.slack.chat.postMessage({ + ( + globalThis as typeof globalThis & { $: { Actions: ActionsProxy } } + ).$.Actions.slack.chat.postMessage({ inputs: { text: 'hi' }, }), ); @@ -1024,6 +1064,7 @@ describe('Dev Server Middleware', () => { mockViteBuild, mockLoadModule, () => currentFunctions, + () => [], mockAuth, getApiKeyRequest(), '/project', diff --git a/packages/plugins/apps/src/vite/dev-server.ts b/packages/plugins/apps/src/vite/dev-server.ts index a7bcd2670..a3ac6f952 100644 --- a/packages/plugins/apps/src/vite/dev-server.ts +++ b/packages/plugins/apps/src/vite/dev-server.ts @@ -15,6 +15,7 @@ import { encodeQueryName } from '../backend/encodeQueryName'; import type { ExecuteActionRequest, ExecuteActionResponse } from '../backend/protocol'; import type { BackendFunction, BackendOutputs } from '../backend/types'; import { generateDevVirtualEntryContent } from '../backend/virtual-entry'; +import { LOCAL_EXECUTION_LOAD_SUFFIX } from '../constants'; import { createBackendConnectionIdCollector } from './backend-connection-id-collector'; import { getBaseBackendBuildConfig } from './build-config'; @@ -215,22 +216,7 @@ async function executeScriptViaDatadog( return outputs; } -/** - * 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. - * - * Logs the resolved result or error detail at info/error level — the - * Telemetry milestone's primary deliverable. Production's own equivalent - * signal only reaches Datadog's backend; a developer watching `npm run dev` - * would otherwise see no result at all for an action call beyond the - * `log.debug` breadcrumbs `submitQuery`/`pollQueryExecution` already emit. - */ +/** Submits a single-action `preview-async` query per `$.Actions` call (no auth needed until a call is actually made) and logs its result/error, since production's own equivalent signal only reaches Datadog's backend, not the developer's `npm run dev` console. */ function makeExecuteActionRemotely( auth: AuthConfig, doAuthenticatedRequest: DoAuthenticatedRequest | undefined, @@ -242,7 +228,7 @@ function makeExecuteActionRemotely( connectionId: string | undefined, ): Promise => { if (!doAuthenticatedRequest) { - throw new Error(`Auth credentials not configured. ${AUTH_GUIDANCE}`); + throw new HttpError(400, `Auth credentials not configured. ${AUTH_GUIDANCE}`); } try { const receiptId = await submitQuery( @@ -308,7 +294,7 @@ async function pollQueryExecution( log.debug(`Long-poll response, done: ${attrs?.done}`); if (attrs?.done) { - if (attrs.outputs === undefined) { + if (attrs.outputs === undefined || attrs.outputs === null) { throw new Error('Query execution completed without outputs'); } return attrs.outputs; @@ -339,14 +325,13 @@ class HttpError extends Error { } /** - * Shared request pipeline: parse body, validate functionName, look up - * the backend function by encoded query name, and bundle it. + * Parse the request body and look up the backend function by encoded query + * name. */ -async function validateAndBundle( +async function parseAndLookupFunction( req: IncomingMessage, functionsByName: Map, - bundle: BundleFn, -): Promise<{ func: BackendFunction; code: string; args: unknown[] }> { +): Promise<{ func: BackendFunction; args: unknown[] }> { const { functionName, args = [] } = await parseRequestBody(req); if (!functionName || typeof functionName !== 'string') { @@ -358,6 +343,19 @@ async function validateAndBundle( throw new HttpError(404, `Backend function "${functionName}" not found`); } + return { func, args }; +} + +/** + * Shared request pipeline: parse body, validate functionName, look up + * the backend function by encoded query name, and bundle it. + */ +async function validateAndBundle( + req: IncomingMessage, + functionsByName: Map, + bundle: BundleFn, +): Promise<{ func: BackendFunction; code: string; args: unknown[] }> { + const { func, args } = await parseAndLookupFunction(req, functionsByName); const bundled = await bundle(func); return { ...bundled, args }; } @@ -384,29 +382,6 @@ async function handleDebugBundle( } } -/** - * 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 @@ -421,6 +396,8 @@ async function handleExecuteAction( auth: AuthConfig, doAuthenticatedRequest: DoAuthenticatedRequest | undefined, loadModule: LoadModule, + getAllowedConnectionIds: (entryId: string) => string[], + projectRoot: string, log: Logger, ): Promise { try { @@ -429,8 +406,35 @@ async function handleExecuteAction( log.debug(`Executing action locally: ${displayName} with args`); + // The registry's own `func.allowedConnectionIds` is always `[]` here + // — only the bundling collector populates it, and this path + // intentionally skips bundling. Loading the entry once first lets + // the module-graph collector observe Vite's `server.moduleGraph` for + // this entry (see collectModuleGraphFromServer), so the connection-ID + // allowlist reflects the function's actual imports instead of being + // silently empty. + const entrySpecifier = func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX; + const primedModule = await loadModule(entrySpecifier); + const funcWithConnectionIds: BackendFunction = { + ...func, + allowedConnectionIds: getAllowedConnectionIds(func.absolutePath), + }; + + // executeScriptLocally loads this same entry specifier again + // internally; reuse the module already resolved above instead of + // making Vite re-run ssrLoadModule for it a second time. + const loadModuleReusingPrimedEntry: LoadModule = (specifier) => + specifier === entrySpecifier ? Promise.resolve(primedModule) : loadModule(specifier); + const executeAction = makeExecuteActionRemotely(auth, doAuthenticatedRequest, log); - const result = await executeScriptLocally(func, args, executeAction, loadModule, log); + const result = await executeScriptLocally( + funcWithConnectionIds, + projectRoot, + args, + executeAction, + loadModuleReusingPrimedEntry, + log, + ); res.statusCode = 200; res.setHeader('Content-Type', 'application/json'); @@ -446,9 +450,10 @@ async function handleExecuteAction( /** * 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. + * subprocess), the same way `/__dd/executeAction` did before local + * execution existed. 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, @@ -504,6 +509,7 @@ export function createDevServerMiddleware( viteBuild: typeof build, loadModule: LoadModule, getBackendFunctions: () => BackendFunction[], + getAllowedConnectionIds: (entryId: string) => string[], auth: AuthConfig, doAuthenticatedRequest: DoAuthenticatedRequest | undefined, projectRoot: string, @@ -545,6 +551,8 @@ export function createDevServerMiddleware( auth, doAuthenticatedRequest, loadModule, + getAllowedConnectionIds, + projectRoot, log, ).catch(() => { sendError(res, 500, 'Unexpected error'); diff --git a/packages/plugins/apps/src/vite/index.test.ts b/packages/plugins/apps/src/vite/index.test.ts index 2f6e86df0..adced4f6d 100644 --- a/packages/plugins/apps/src/vite/index.test.ts +++ b/packages/plugins/apps/src/vite/index.test.ts @@ -285,8 +285,8 @@ describe('Backend Functions - getVitePlugin', () => { // 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(); + const configHook = plugin!.config as () => { ssr: { noExternal: string[] } }; + const config = configHook(); expect(config).toEqual({ ssr: { diff --git a/packages/plugins/apps/src/vite/index.ts b/packages/plugins/apps/src/vite/index.ts index b94f446ce..e1e3cf7b3 100644 --- a/packages/plugins/apps/src/vite/index.ts +++ b/packages/plugins/apps/src/vite/index.ts @@ -14,6 +14,7 @@ import { type DoAuthenticatedRequest, } from '../auth'; import { extractExportedFunctions } from '../backend/ast-parsing/extract-backend-functions'; +import { extractConnectionIdsFromModuleGraph } from '../backend/ast-parsing/extract-connection-ids-from-module-graph'; import { encodeQueryName } from '../backend/encodeQueryName'; import { generateProxyModule } from '../backend/proxy-codegen'; import type { BackendFunction } from '../backend/types'; @@ -26,6 +27,7 @@ import { import type { AppsOptionsWithDefaults } from '../types'; import { buildBackendFunctions } from './build-backend-functions'; +import { collectModuleGraphFromServer } from './dev-server-module-graph'; import { createDevServerMiddleware } from './dev-server'; import { handleUpload } from './handle-upload'; @@ -122,6 +124,16 @@ export const getVitePlugin = ({ const { setBackendFunctions, getBackendFunctions } = createBackendFunctionRegistry(); + // Non-backend module IDs reached transitively from a suffixed backend + // entry point. A plain helper module's own id can't carry + // LOCAL_EXECUTION_LOAD_SUFFIX (it's never ambiguous between a proxy and + // real code, so it needs no suffix), but it still needs to be recognized + // as an importer belonging to the suffixed subgraph — otherwise a + // *.backend.ts file reached through it (rather than directly from + // another *.backend.ts file) would lose the marker one hop later than a + // direct backend-to-backend import does. + const suffixedSubgraphImporters = new Set(); + return { // The dev server's local-execution path loads backend-function // dependencies (e.g. @datadog/apps-backend, @datadog/action-catalog) @@ -146,11 +158,14 @@ export const getVitePlugin = ({ // would resolve that nested import unsuffixed, hitting transform's // "not suffixed" branch below and getting replaced with the frontend // RPC-proxy stub — breaking local execution for a multi-backend-file - // import graph. Only propagates when the importer itself was - // suffixed (this is local execution's own module graph, not a - // regular frontend import) and only onto another `.backend.ts` file - // (a plain helper module never hits the proxy-vs-real-code branching - // this marker exists to disambiguate, so it needs no suffix). + // import graph. Propagates whenever the importer itself was suffixed + // OR is a previously-seen non-backend module reached from within the + // suffixed subgraph (see `suffixedSubgraphImporters` above) — this is + // local execution's own module graph either way, not a regular + // frontend import — and only appends the suffix onto another + // `.backend.ts` file (a plain helper module never hits the + // proxy-vs-real-code branching this marker exists to disambiguate, + // so it needs no suffix of its own). resolveId: { // Must run before Vite's own built-in resolver: a plain relative // specifier like `./other.backend` is fully resolvable by Vite's @@ -160,7 +175,19 @@ export const getVitePlugin = ({ // `pre` guarantees this hook gets first look at every id. order: 'pre', async handler(source, importer, resolveOptions) { - if (!importer || !importer.endsWith(LOCAL_EXECUTION_LOAD_SUFFIX)) { + // Scoped to `resolveOptions.ssr`: local execution's own + // traversal is always an SSR resolution (it runs through + // `server.ssrLoadModule`), so a helper's id recorded here + // must only count for a later SSR-context resolution too — + // otherwise the same helper subsequently reached from the + // ordinary (non-SSR) client graph would inherit the marker + // and serve real backend code to the browser instead of the + // frontend RPC-proxy stub. + const isPartOfSuffixedSubgraph = + !!importer && + (importer.endsWith(LOCAL_EXECUTION_LOAD_SUFFIX) || + (resolveOptions.ssr === true && suffixedSubgraphImporters.has(importer))); + if (!isPartOfSuffixedSubgraph) { return null; } @@ -172,10 +199,12 @@ export const getVitePlugin = ({ return resolved; } - if ( - BACKEND_FILE_RE.test(resolved.id) && - !resolved.id.endsWith(LOCAL_EXECUTION_LOAD_SUFFIX) - ) { + if (!BACKEND_FILE_RE.test(resolved.id)) { + suffixedSubgraphImporters.add(resolved.id); + return resolved; + } + + if (!resolved.id.endsWith(LOCAL_EXECUTION_LOAD_SUFFIX)) { return { ...resolved, id: resolved.id + LOCAL_EXECUTION_LOAD_SUFFIX }; } @@ -272,17 +301,32 @@ export const getVitePlugin = ({ } } - server.middlewares.use( - createDevServerMiddleware( - bundler.build, - server.ssrLoadModule.bind(server), - getBackendFunctions, - auth, - doAuthenticatedRequest, + const loadModule = server.ssrLoadModule.bind(server); + // Call only after `loadModule` has resolved for this same entryId (plus its + // LOCAL_EXECUTION_LOAD_SUFFIX) — the graph read below is a live side effect of + // that call, not independently maintained state (moduleParsed, the mechanism the + // production bundling path uses instead, never fires during a real Vite dev + // server — it's a Rollup-build-only hook). collectModuleGraphFromServer owns + // appending the suffix internally, so this closure only ever handles the bare + // backend-file path — the same shape extractConnectionIdsFromModuleGraph needs + // to key into the returned records map. + const getAllowedConnectionIds = (entryId: string) => + extractConnectionIdsFromModuleGraph( + entryId, + collectModuleGraphFromServer(server, entryId, context.buildRoot), context.buildRoot, - log, - ), + ); + const middleware = createDevServerMiddleware( + bundler.build, + loadModule, + getBackendFunctions, + getAllowedConnectionIds, + auth, + doAuthenticatedRequest, + context.buildRoot, + log, ); + server.middlewares.use(middleware); }, }; }; diff --git a/packages/plugins/apps/src/vite/local-execution.test.ts b/packages/plugins/apps/src/vite/local-execution.test.ts index b0f7942a0..3c09bb0ce 100644 --- a/packages/plugins/apps/src/vite/local-execution.test.ts +++ b/packages/plugins/apps/src/vite/local-execution.test.ts @@ -42,6 +42,13 @@ beforeEach(() => { jest.spyOn(shared, 'isDatadogAppsBackendInstalled').mockReturnValue(false); }); +/** + * Shape of the `$.Actions` dynamic proxy — an arbitrarily-nested property + * path (e.g. `$.Actions.slack.chat.postMessage`) that's callable at any + * depth. Used to type `globalThis.$` in tests without an `any` cast. + */ +type ActionsProxy = { [key: string]: ActionsProxy } & ((...args: unknown[]) => Promise); + const stubExecuteAction: ExecuteAction = async (fqn) => ({ data: null, stub: true, fqn }); /** A `loadModule` double that resolves the customer's function from a map and rejects anything else with a module-not-found error, matching the common case where neither optional package is installed. */ @@ -453,7 +460,9 @@ describe('local-execution — executeScriptLocally', () => { slowExecuteAction, loadModuleReturning({ example: () => - (globalThis as Record).$.Actions.slack.chat.postMessage({ + ( + globalThis as typeof globalThis & { $: { Actions: ActionsProxy } } + ).$.Actions.slack.chat.postMessage({ inputs: { text: 'hi' }, }), }), @@ -475,7 +484,9 @@ describe('local-execution — executeScriptLocally', () => { executeAction, loadModuleReturning({ example: async () => { - await (globalThis as Record).$.Actions.slack.chat.postMessage({ + await ( + globalThis as typeof globalThis & { $: { Actions: ActionsProxy } } + ).$.Actions.slack.chat.postMessage({ inputs: { text: 'hi' }, }); // Hangs with no further $.Actions call — the fresh timeout window from the completed call above must still expire normally. diff --git a/packages/published/esbuild-plugin/package.json b/packages/published/esbuild-plugin/package.json index 3047f1f7c..b1d19e619 100644 --- a/packages/published/esbuild-plugin/package.json +++ b/packages/published/esbuild-plugin/package.json @@ -63,6 +63,7 @@ "outdent": "0.8.0", "p-queue": "6.6.2", "pretty-bytes": "5.6.0", + "rollup": "4.45.1", "simple-git": "3.36.0", "unplugin": "2.3.11" }, diff --git a/packages/published/rollup-plugin/package.json b/packages/published/rollup-plugin/package.json index d05a63ae0..810ee006d 100644 --- a/packages/published/rollup-plugin/package.json +++ b/packages/published/rollup-plugin/package.json @@ -66,6 +66,7 @@ "outdent": "0.8.0", "p-queue": "6.6.2", "pretty-bytes": "5.6.0", + "rollup": "4.45.1", "simple-git": "3.36.0", "unplugin": "2.3.11" }, diff --git a/packages/published/rspack-plugin/package.json b/packages/published/rspack-plugin/package.json index 85a14619a..9db8727f6 100644 --- a/packages/published/rspack-plugin/package.json +++ b/packages/published/rspack-plugin/package.json @@ -63,6 +63,7 @@ "outdent": "0.8.0", "p-queue": "6.6.2", "pretty-bytes": "5.6.0", + "rollup": "4.45.1", "simple-git": "3.36.0", "unplugin": "2.3.11" }, diff --git a/packages/published/vite-plugin/package.json b/packages/published/vite-plugin/package.json index 05c805e65..5371a5927 100644 --- a/packages/published/vite-plugin/package.json +++ b/packages/published/vite-plugin/package.json @@ -63,6 +63,7 @@ "outdent": "0.8.0", "p-queue": "6.6.2", "pretty-bytes": "5.6.0", + "rollup": "4.45.1", "simple-git": "3.36.0", "unplugin": "2.3.11" }, diff --git a/packages/published/webpack-plugin/package.json b/packages/published/webpack-plugin/package.json index 148e40ffe..35da8ffbf 100644 --- a/packages/published/webpack-plugin/package.json +++ b/packages/published/webpack-plugin/package.json @@ -63,6 +63,7 @@ "outdent": "0.8.0", "p-queue": "6.6.2", "pretty-bytes": "5.6.0", + "rollup": "4.45.1", "simple-git": "3.36.0", "unplugin": "2.3.11" }, diff --git a/packages/tests/src/_jest/fixtures/apps_backend_project/helper.ts b/packages/tests/src/_jest/fixtures/apps_backend_project/helper.ts new file mode 100644 index 000000000..260ef5b6e --- /dev/null +++ b/packages/tests/src/_jest/fixtures/apps_backend_project/helper.ts @@ -0,0 +1,9 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import { plainEcho } from './getRuntimeUsers.backend'; + +export async function helperEcho(value: string) { + return plainEcho(value); +} diff --git a/packages/tests/src/_jest/fixtures/apps_backend_project/viaHelper.backend.ts b/packages/tests/src/_jest/fixtures/apps_backend_project/viaHelper.backend.ts new file mode 100644 index 000000000..68e728635 --- /dev/null +++ b/packages/tests/src/_jest/fixtures/apps_backend_project/viaHelper.backend.ts @@ -0,0 +1,9 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import { helperEcho } from './helper'; + +export async function usesHelper(value: string) { + return helperEcho(value); +} From 3df2db272645bcacfa5e77e01bb94eb9bf15db11 Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Thu, 27 Aug 2026 18:37:07 -0400 Subject: [PATCH 21/30] fix(tools): externalize deep imports of a bundled dependency correctly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rollupConfig.mjs's `external` list only matched a dependency's bare specifier exactly, so `rollup/parseAst` (needed by the new dev-server module-graph collector) got bundled instead of externalized despite `rollup` being declared as a dependency. The bundled copy pulls in Rollup's own native-binary platform loader, which throws through `@rollup/plugin-commonjs`'s dynamic-require interop the moment a consumer's real dev server calls it — breaking every published plugin package for real users, not just this repo's own tests. Switched the `external` option to a function that also matches subpath imports (`id === name || id.startsWith(name + '/')`) for every declared dependency and peer dependency. Also drops a stray blank line introduced between a comment and the test it documents in local-execution.test.ts. --- .../src/vite/dev-server.integration.test.ts | 26 +++++--- .../apps/src/vite/local-execution.test.ts | 51 +++++++++++++- .../plugins/apps/src/vite/local-execution.ts | 19 ++++-- packages/tools/src/rollupConfig.mjs | 66 +++++++++++-------- 4 files changed, 121 insertions(+), 41 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 814461a43..9bc8db310 100644 --- a/packages/plugins/apps/src/vite/dev-server.integration.test.ts +++ b/packages/plugins/apps/src/vite/dev-server.integration.test.ts @@ -100,6 +100,15 @@ const viaHelperFunc: BackendFunction = { allowedConnectionIds: [], }; +// Never referenced by another test in this file — the cold-entry test below +// needs a module its shared beforeAll server has genuinely never loaded. +const noSdkFunc: BackendFunction = { + relativePath: 'noSdk', + name: 'noSdkFunction', + absolutePath: path.join(FIXTURE_ROOT, 'noSdk.backend.ts'), + allowedConnectionIds: [], +}; + describe('Dev Server Middleware — real end-to-end local execution', () => { let server: ViteDevServer; @@ -272,10 +281,11 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { // Regression coverage for a real getAllowedConnectionIds wired exactly as // vite/index.ts's configureServer builds it, on the very first request - // for an entry — no priming import beforehand. The test above still - // primes viaHelperFunc via an unsuffixed ssrLoadModule call first, which - // (before the entryId fix) left a stale, unsuffixed moduleGraph node - // behind that getModuleById happened to find, masking the real gap: on a + // for an entry — no priming import beforehand. Uses noSdkFunc, which no + // earlier test in this file touches: reusing nestedImportFunc or + // viaHelperFunc here would already have a warm moduleGraph node (and + // module-runner cache) left over from an earlier test against this same + // shared beforeAll server, masking the real gap this test guards — on a // cold entry, Vite only ever registers the node under the fully-resolved // (suffixed) id handleExecuteAction's own loadModule call just produced, // and collectModuleGraphFromServer was looking it up by the bare path. @@ -293,7 +303,7 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { const middleware = createDevServerMiddleware( build, loadModule, - () => [nestedImportFunc], + () => [noSdkFunc], getAllowedConnectionIds, { site: 'datadoghq.com' }, undefined, @@ -302,8 +312,8 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { ); const req = createMockRequest('/__dd/executeAction', { - functionName: encodeQueryName(nestedImportFunc), - args: ['cold-entry-value'], + functionName: encodeQueryName(noSdkFunc), + args: [], }); const res = createMockResponse(); @@ -313,6 +323,6 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { expect(res.statusCode).toBe(200); const body = JSON.parse(res.getBody()); expect(body.success).toBe(true); - expect(body.result).toEqual({ data: { value: 'cold-entry-value' } }); + expect(body.result).toEqual({ data: { ok: true } }); }, 30000); }); diff --git a/packages/plugins/apps/src/vite/local-execution.test.ts b/packages/plugins/apps/src/vite/local-execution.test.ts index 3c09bb0ce..88b9601b8 100644 --- a/packages/plugins/apps/src/vite/local-execution.test.ts +++ b/packages/plugins/apps/src/vite/local-execution.test.ts @@ -473,6 +473,56 @@ describe('local-execution — executeScriptLocally', () => { expect(result).toEqual({ data: { ok: true } }); }); + // Guards against the hang-detection timer staying paused forever: without + // a bound on the $.Actions call itself, a stalled network request (no + // abort signal/deadline of its own) would wedge this execution — and, + // since local executions are serialized via `enqueue`, every request + // queued behind it — indefinitely. + test('Should eventually time out an in-flight $.Actions call that never settles, and not wedge subsequently queued executions', async () => { + jest.useFakeTimers(); + try { + const neverSettlingExecuteAction: ExecuteAction = () => new Promise(() => {}); + + const hungExecution = executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + neverSettlingExecuteAction, + loadModuleReturning({ + example: () => + ( + globalThis as typeof globalThis & { $: { Actions: ActionsProxy } } + ).$.Actions.slack.chat.postMessage({ + inputs: { text: 'hi' }, + }), + }), + mockLogger, + 50, + ); + // Enqueued behind hungExecution — if the fix didn't bound the + // stalled $.Actions call, this would never get a turn either. + const queuedNext = executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ example: () => 'next' }), + mockLogger, + ); + + const hungAssertion = expect(hungExecution).rejects.toThrow( + /\$\.Actions call to "com\.datadoghq\.slack\.chat\.postMessage" timed out/, + ); + + await jest.runAllTimersAsync(); + await hungAssertion; + + expect(await queuedNext).toEqual({ data: 'next' }); + } finally { + jest.useRealTimers(); + } + }); + test('Should still time out a function that hangs with no $.Actions call in flight, even after an earlier call in the same run completed', async () => { const executeAction: ExecuteAction = async () => ({ ok: true }); @@ -500,7 +550,6 @@ describe('local-execution — executeScriptLocally', () => { }); // Asserts $'s exact key set, since a token added inside globalThis.$ wouldn't be caught by the weaker top-level check below. - test('Should never expose an auth token to the customer module — only backendFunctionArgs, Actions, and Source are visible on globalThis.$', async () => { const result = await executeScriptLocally( func, diff --git a/packages/plugins/apps/src/vite/local-execution.ts b/packages/plugins/apps/src/vite/local-execution.ts index 5808009d8..f127188ab 100644 --- a/packages/plugins/apps/src/vite/local-execution.ts +++ b/packages/plugins/apps/src/vite/local-execution.ts @@ -112,6 +112,9 @@ function isIndexableRecord(value: unknown): value is Record { const DEFAULT_TIMEOUT_MS = 10_000; +/** Bounds a single `$.Actions` call while it's exempt from the hang-detection timer above (see `guardedExecuteAction`). `doRequest` attaches no abort signal or deadline of its own, so an in-flight call that never settles would otherwise wedge this execution — and, since local executions are serialized, every request queued behind it — forever. Set generously past `pollQueryExecution`'s own worst-case long-poll budget (10 retries at up to ~30s each) so a legitimate slow action is never cut off. */ +const MAX_ACTION_CALL_TIMEOUT_MS = 10 * 60_000; + /** Loads a module by specifier, resolved against the customer's own project rather than build-plugins' dependency tree — the dev server passes its Vite instance's `ssrLoadModule` here. */ export type LoadModule = (specifier: string) => Promise>; @@ -206,11 +209,11 @@ function makeActionsProxy( }); } -/** Bounds a registration's `loadModule` call so a load that never settles (a broken/circular module graph) rejects instead of leaving its cache entry pending forever — eviction-on-rejection below only fires once a promise settles. Can't cancel the underlying promise, so a load that eventually settles still runs its side effects late; see the registration functions for why that's harmless. */ -function withTimeout(promise: Promise, timeoutMs: number, what: string): Promise { +/** Bounds a promise that would otherwise be able to hang forever — a registration's underlying `loadModule` call (a broken/circular module graph, not just a slow one), or a `$.Actions` call whose transport attaches no deadline of its own — so it rejects instead of leaving its caller waiting indefinitely. Doesn't cancel the underlying promise (not possible for a plain `Promise`), so it still runs its side effects late if it eventually does settle; see each call site's own doc comment for why that's harmless there. `label` is the full, already-attributed subject of the timeout message (e.g. `` `Loading ${specifier}` ``), not appended to a fixed prefix, so it reads naturally for both a load and an action call. */ +function withTimeout(promise: Promise, timeoutMs: number, label: string): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => { - reject(new Error(`Loading ${what} timed out after ${timeoutMs}ms`)); + reject(new Error(`${label} timed out after ${timeoutMs}ms`)); }, timeoutMs); promise.then( (value) => { @@ -254,7 +257,7 @@ async function registerActionCatalogOnce(loadModule: LoadModule, timeoutMs: numb const mod = await withTimeout( loadPromise, timeoutMs, - '@datadog/action-catalog/action-execution', + 'Loading @datadog/action-catalog/action-execution', ); const setExecuteActionImplementation = mod.setExecuteActionImplementation; if (typeof setExecuteActionImplementation !== 'function') { @@ -316,7 +319,7 @@ async function registerBackendRuntimeOnce( const [jsFunctionWithActionsModule, runtimeModule] = await withTimeout( loadPromise, timeoutMs, - '@datadog/apps-backend/runtime', + 'Loading @datadog/apps-backend/runtime', ); const buildRuntimeFromJsFunctionWithActions = jsFunctionWithActionsModule.buildRuntimeFromJsFunctionWithActions; @@ -500,7 +503,11 @@ async function runScriptLocally( pendingActionCalls += 1; clearTimeout(timer); try { - return await executeAction(fqn, inputs, connectionId); + return await withTimeout( + executeAction(fqn, inputs, connectionId), + MAX_ACTION_CALL_TIMEOUT_MS, + `$.Actions call to "${fqn}"`, + ); } finally { pendingActionCalls -= 1; if (pendingActionCalls === 0 && scope.isCurrent()) { diff --git a/packages/tools/src/rollupConfig.mjs b/packages/tools/src/rollupConfig.mjs index fadfd2a5e..04db8dca5 100644 --- a/packages/tools/src/rollupConfig.mjs +++ b/packages/tools/src/rollupConfig.mjs @@ -50,10 +50,8 @@ const BUNDLER_NAME_RX = /^@datadog\/(.+)-plugin$/g; * @param {RollupOptions} config * @returns {RollupOptions} */ -export const bundle = (packageJson, config) => ({ - input: 'src/index.ts', - ...config, - external: [ +export const bundle = (packageJson, config) => { + const externalPackageNames = [ // All peer dependencies are external dependencies. ...Object.keys(packageJson.peerDependencies), // All dependencies are external dependencies. @@ -61,28 +59,44 @@ export const bundle = (packageJson, config) => ({ // These should be internal only and never be anywhere published. '@dd/tools', '@dd/tests', - // We never want to include Node.js built-in modules in the bundle. - ...modulePackage.builtinModules, - ...(config.external || []), - ], - onwarn(warning, warn) { - // Ignore warnings about undefined `this`. - if (warning.code === 'THIS_IS_UNDEFINED') { - return; - } - warn(warning); - }, - plugins: [ - babel({ - babelHelpers: 'bundled', - include: ['src/**/*'], - }), - json(), - commonjs(), - nodeResolve({ preferBuiltins: true }), - ...(config.plugins || []), - ], -}); + ]; + + return { + input: 'src/index.ts', + ...config, + // A plain string in Rollup's `external` array only matches an id + // exactly (see Rollup's own `getIdMatcher`) — it does not also match + // a deep import of that package (e.g. declaring `rollup` external + // doesn't cover `rollup/parseAst`). Once `@rollup/plugin-node-resolve` + // resolves a deep import to its real absolute file path, the + // exact-match check no longer sees the original bare specifier at + // all, so a package's own subpath export would otherwise get inlined + // despite being declared as a dependency specifically so it stays + // external. + external: (id) => + // We never want to include Node.js built-in modules in the bundle. + modulePackage.builtinModules.includes(id) || + externalPackageNames.some((name) => id === name || id.startsWith(`${name}/`)) || + (config.external || []).includes(id), + onwarn(warning, warn) { + // Ignore warnings about undefined `this`. + if (warning.code === 'THIS_IS_UNDEFINED') { + return; + } + warn(warning); + }, + plugins: [ + babel({ + babelHelpers: 'bundled', + include: ['src/**/*'], + }), + json(), + commonjs(), + nodeResolve({ preferBuiltins: true }), + ...(config.plugins || []), + ], + }; +}; /** * Returns the base configuration for the build plugin in the context of this project. From 9356229f93325776922747b8a3e2e787dd56cf03 Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Thu, 27 Aug 2026 20:19:05 -0400 Subject: [PATCH 22/30] fix(apps): bound the priming module load in handleExecuteAction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The priming loadModule call (needed to populate the module graph for connection-ID collection before the real execution starts) evaluates the entry's real top-level code and runs before executeScriptLocally installs its own hang-detection timeout — so it had no bound of its own. A customer module with a hanging top-level await would wedge the request forever. Wraps it in the same withTimeout helper the rest of this file already uses, now exported for this cross-module use. --- .../plugins/apps/src/vite/dev-server.test.ts | 37 +++++++++++++++++++ packages/plugins/apps/src/vite/dev-server.ts | 14 +++++-- .../plugins/apps/src/vite/local-execution.ts | 4 +- 3 files changed, 50 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 a00c23350..46deabada 100644 --- a/packages/plugins/apps/src/vite/dev-server.test.ts +++ b/packages/plugins/apps/src/vite/dev-server.test.ts @@ -1055,6 +1055,43 @@ describe('Dev Server Middleware', () => { 'error', ); }); + + // Guards the priming loadModule call (see handleExecuteAction) — it + // evaluates the entry's real top-level code before + // executeScriptLocally's own hang-detection timeout is installed, so + // a customer module with a hanging top-level await would otherwise + // wedge this request forever with no bound at all. + test('Should eventually time out and return a clear error when the priming load never settles', async () => { + jest.useFakeTimers(); + try { + mockLoadModule.mockImplementation( + () => new Promise(() => {}), // never settles + ); + + const req = createMockRequest('/__dd/executeAction', { + functionName: encodeQueryName(mockFunctions[0]), + args: [], + }); + const res = createMockResponse(); + + middleware(req, res, jest.fn()); + const doneAssertion = res.done; + + // createMockRequest emits the body via a real process.nextTick, + // which fake timers don't advance — drain it first so the + // priming load's own setTimeout is actually scheduled before + // runAllTimersAsync tries to advance past it. + await jest.advanceTimersByTimeAsync(0); + await jest.runAllTimersAsync(); + await doneAssertion; + + expect(res.statusCode).toBe(500); + const body = JSON.parse(res.getBody()); + expect(body.error).toMatch(/timed out after 10000ms/); + } finally { + jest.useRealTimers(); + } + }); }); describe('dynamic discovery', () => { diff --git a/packages/plugins/apps/src/vite/dev-server.ts b/packages/plugins/apps/src/vite/dev-server.ts index a3ac6f952..902827f14 100644 --- a/packages/plugins/apps/src/vite/dev-server.ts +++ b/packages/plugins/apps/src/vite/dev-server.ts @@ -20,7 +20,7 @@ import { LOCAL_EXECUTION_LOAD_SUFFIX } from '../constants'; import { createBackendConnectionIdCollector } from './backend-connection-id-collector'; import { getBaseBackendBuildConfig } from './build-config'; import type { ExecuteAction, LoadModule } from './local-execution'; -import { executeScriptLocally } from './local-execution'; +import { DEFAULT_TIMEOUT_MS, executeScriptLocally, withTimeout } from './local-execution'; interface BundleResult { func: BackendFunction; @@ -412,9 +412,17 @@ async function handleExecuteAction( // the module-graph collector observe Vite's `server.moduleGraph` for // this entry (see collectModuleGraphFromServer), so the connection-ID // allowlist reflects the function's actual imports instead of being - // silently empty. + // silently empty. This priming load runs before executeScriptLocally + // installs its own hang-detection timeout below, and it evaluates the + // entry's real top-level code (ssrLoadModule, not a parse-only step) + // — so it needs its own bound, or a customer module with a hanging + // top-level await would wedge this request forever. const entrySpecifier = func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX; - const primedModule = await loadModule(entrySpecifier); + const primedModule = await withTimeout( + loadModule(entrySpecifier), + DEFAULT_TIMEOUT_MS, + `Loading "${displayName}"`, + ); const funcWithConnectionIds: BackendFunction = { ...func, allowedConnectionIds: getAllowedConnectionIds(func.absolutePath), diff --git a/packages/plugins/apps/src/vite/local-execution.ts b/packages/plugins/apps/src/vite/local-execution.ts index f127188ab..ee4960e1a 100644 --- a/packages/plugins/apps/src/vite/local-execution.ts +++ b/packages/plugins/apps/src/vite/local-execution.ts @@ -110,7 +110,7 @@ function isIndexableRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; } -const DEFAULT_TIMEOUT_MS = 10_000; +export const DEFAULT_TIMEOUT_MS = 10_000; /** Bounds a single `$.Actions` call while it's exempt from the hang-detection timer above (see `guardedExecuteAction`). `doRequest` attaches no abort signal or deadline of its own, so an in-flight call that never settles would otherwise wedge this execution — and, since local executions are serialized, every request queued behind it — forever. Set generously past `pollQueryExecution`'s own worst-case long-poll budget (10 retries at up to ~30s each) so a legitimate slow action is never cut off. */ const MAX_ACTION_CALL_TIMEOUT_MS = 10 * 60_000; @@ -210,7 +210,7 @@ function makeActionsProxy( } /** Bounds a promise that would otherwise be able to hang forever — a registration's underlying `loadModule` call (a broken/circular module graph, not just a slow one), or a `$.Actions` call whose transport attaches no deadline of its own — so it rejects instead of leaving its caller waiting indefinitely. Doesn't cancel the underlying promise (not possible for a plain `Promise`), so it still runs its side effects late if it eventually does settle; see each call site's own doc comment for why that's harmless there. `label` is the full, already-attributed subject of the timeout message (e.g. `` `Loading ${specifier}` ``), not appended to a fixed prefix, so it reads naturally for both a load and an action call. */ -function withTimeout(promise: Promise, timeoutMs: number, label: string): Promise { +export function withTimeout(promise: Promise, timeoutMs: number, label: string): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => { reject(new Error(`${label} timed out after ${timeoutMs}ms`)); From ad61a55995e810081ace221f22bbc2db2e9a1a5b Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Thu, 27 Aug 2026 21:18:41 -0400 Subject: [PATCH 23/30] fix(apps): collect connection-ID-scoped module graph from real dev-server output The SSR transform Vite actually runs for a dev-server-only load rewrites every import into a __vite_ssr_import__(...) call and resolves specifiers to absolute paths, which the plain-ImportDeclaration AST search built for a real Rollup build can't parse. Read each module's original source from disk and strip TS/JSX with esbuild in isolation instead, so the parser sees the same untransformed import syntax the production build path already trusts. --- packages/plugins/apps/package.json | 1 + .../src/backend/ast-parsing/module-graph.ts | 3 +- .../apps/src/vite/dev-server-module-graph.ts | 94 +++++++--- .../src/vite/dev-server.integration.test.ts | 174 ++++++++++++++++-- .../plugins/apps/src/vite/dev-server.test.ts | 62 +++++-- packages/plugins/apps/src/vite/dev-server.ts | 13 +- packages/plugins/apps/src/vite/index.ts | 4 +- .../published/esbuild-plugin/package.json | 1 + packages/published/rollup-plugin/package.json | 1 + packages/published/rspack-plugin/package.json | 1 + packages/published/vite-plugin/package.json | 1 + .../published/webpack-plugin/package.json | 1 + .../action-execution.js | 13 ++ .../fixtures/action_catalog_project/index.js | 13 ++ .../action_catalog_project/package.json | 14 ++ .../actionCatalogCall.backend.ts | 9 + .../mixedImports.backend.ts | 19 ++ .../apps_backend_project/package.json | 1 + .../tests/src/_jest/fixtures/package.json | 3 +- packages/tests/src/_jest/fixtures/yarn.lock | 13 ++ yarn.lock | 1 + 21 files changed, 385 insertions(+), 57 deletions(-) create mode 100644 packages/tests/src/_jest/fixtures/action_catalog_project/action-execution.js create mode 100644 packages/tests/src/_jest/fixtures/action_catalog_project/index.js create mode 100644 packages/tests/src/_jest/fixtures/action_catalog_project/package.json create mode 100644 packages/tests/src/_jest/fixtures/apps_backend_project/actionCatalogCall.backend.ts create mode 100644 packages/tests/src/_jest/fixtures/apps_backend_project/mixedImports.backend.ts diff --git a/packages/plugins/apps/package.json b/packages/plugins/apps/package.json index 0470a0e08..f648cef15 100644 --- a/packages/plugins/apps/package.json +++ b/packages/plugins/apps/package.json @@ -32,6 +32,7 @@ "dependencies": { "@dd/core": "workspace:*", "chalk": "2.3.1", + "esbuild": "0.25.8", "eslint-scope": "7.2.2", "glob": "11.1.0", "jszip": "3.10.1", diff --git a/packages/plugins/apps/src/backend/ast-parsing/module-graph.ts b/packages/plugins/apps/src/backend/ast-parsing/module-graph.ts index 171f90066..b3621c8b2 100644 --- a/packages/plugins/apps/src/backend/ast-parsing/module-graph.ts +++ b/packages/plugins/apps/src/backend/ast-parsing/module-graph.ts @@ -190,7 +190,8 @@ function collectStaticModuleDependencies( })); } -function getStaticModuleSources(ast: Program): string[] { +// Exported so callers that must derive dependency ids by resolving each static specifier individually (the dev server has no build-time Rollup ModuleInfo to read them from) extract the exact same specifier list this module zips them against — not a second, independently-written AST walk that could drift from this one. +export function getStaticModuleSources(ast: Program): string[] { return ast.body.flatMap((node) => { if ( (node.type === 'ImportDeclaration' || diff --git a/packages/plugins/apps/src/vite/dev-server-module-graph.ts b/packages/plugins/apps/src/vite/dev-server-module-graph.ts index 3a8a29f3b..c567aeed0 100644 --- a/packages/plugins/apps/src/vite/dev-server-module-graph.ts +++ b/packages/plugins/apps/src/vite/dev-server-module-graph.ts @@ -2,11 +2,16 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. +/* eslint-disable no-await-in-loop */ + +import { transform } from 'esbuild'; +import { readFile } from 'node:fs/promises'; import { parseAst } from 'rollup/parseAst'; import type { ModuleNode, ViteDevServer } from 'vite'; import { createParsedModuleRecord, + getStaticModuleSources, type ParsedModuleRecord, shouldTraverseCollectedModule, unsupportedModuleGraphDependency, @@ -25,6 +30,17 @@ import { LOCAL_EXECUTION_LOAD_SUFFIX } from '../constants'; * imported module's own `importedModules` — already reflect the full * transitive static-import graph, recursively, with no extra ticks needed. * + * Parses each module's own source read fresh from disk (via `ModuleNode.file`), + * stripped of TS/JSX syntax by `esbuild.transform` in isolation, rather than + * either of Vite's own transform results: the client transform + * (`transformResult`) doesn't run for an SSR-only load, and the SSR transform + * (`ssrTransformResult`) rewrites every `import` into a `__vite_ssr_import__(...)` + * call and resolves bare specifiers to absolute paths — neither shape + * `collectActionCatalogImports`'s plain-`ImportDeclaration` search (built for + * the untransformed syntax a real Rollup build sees) can parse. `esbuild.transform` + * in isolation only strips types/JSX; it doesn't touch import specifiers at all, + * so this reads exactly the same syntax the production build path already trusts. + * * Call this only after `loadModule` has resolved for `bareEntryId + * LOCAL_EXECUTION_LOAD_SUFFIX` in the same request — the graph it reads is a * live side effect of that call, not independently maintained state. @@ -34,11 +50,11 @@ import { LOCAL_EXECUTION_LOAD_SUFFIX } from '../constants'; * node it just loaded by the full resolved id (suffix included), since it * treats each distinct query string as a logically distinct module. */ -export function collectModuleGraphFromServer( +export async function collectModuleGraphFromServer( server: ViteDevServer, bareEntryId: string, buildRoot: string, -): ReadonlyMap { +): Promise> { const records = new Map(); const visited = new Set(); const pending: ModuleNode[] = []; @@ -51,7 +67,7 @@ export function collectModuleGraphFromServer( while (pending.length > 0) { const node = pending.shift()!; const moduleId = node.id ? normalizeViteModuleId(node.id) : undefined; - if (!moduleId || visited.has(moduleId)) { + if (!moduleId || visited.has(moduleId) || !node.file) { continue; } visited.add(moduleId); @@ -60,19 +76,24 @@ export function collectModuleGraphFromServer( continue; } - // `transformResult` is the client/browser transform; SSR loads (what - // local execution always is, via server.ssrLoadModule) populate - // `ssrTransformResult` instead. Neither exists yet if Vite hasn't - // transformed this module — a local dependency the caller's own - // ssrLoadModule call never actually reached. - const transformResult = node.ssrTransformResult ?? node.transformResult; - if (typeof transformResult?.code !== 'string') { - continue; + let source: string; + try { + source = await readFile(node.file, 'utf-8'); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw unsupportedModuleGraphDependency( + moduleId, + `unreadable module source (${reason})`, + ); } let ast; try { - ast = parseAst(transformResult.code); + const stripped = await transform(source, { + loader: loaderForModuleId(moduleId), + format: 'esm', + }); + ast = parseAst(stripped.code); } catch (error) { const reason = error instanceof Error ? error.message : String(error); throw unsupportedModuleGraphDependency( @@ -81,30 +102,55 @@ export function collectModuleGraphFromServer( ); } - // `deps` are this module's own static imports, already resolved to - // real module ids by Vite's import-analysis plugin — the SSR - // equivalent of `moduleParsed`'s `importedIds`/ - // `importedIdResolutions`. `dynamicDeps` (deliberately unused here) - // folds in `import()` calls; `module-graph.ts`'s own AST walk is what - // flags those as unsupported, so only static deps belong here. - const staticDependencyIds = (transformResult.deps ?? []).map(normalizeViteModuleId); + // `node.importedModules` mixes static AND dynamic imports with no + // documented ordering guarantee, but `createParsedModuleRecord` + // zips dependency ids positionally against the AST's own static + // import/export declarations — so this list must contain ONLY + // static dependencies, in that same order. The dev server's plugin + // container doesn't populate Rollup-style `ModuleInfo.importedIds` + // outside a real build, so this resolves each of the AST's own + // static specifiers individually instead, via the same resolution + // Vite itself would use — guaranteeing a correct 1:1 correspondence + // by construction, since both sides are derived from this same ast. + const staticModuleSources = getStaticModuleSources(ast); + const importerFile = node.file; + const resolutions = await Promise.all( + staticModuleSources.map((moduleSource) => + server.pluginContainer.resolveId(moduleSource, importerFile ?? undefined, { + ssr: true, + }), + ), + ); + const staticDependencyIds = resolutions.map((resolved, index) => + resolved ? normalizeViteModuleId(resolved.id) : staticModuleSources[index], + ); const record = createParsedModuleRecord(moduleId, buildRoot, ast, staticDependencyIds); if (record) { records.set(record.id, record); } - for (const dependencyId of staticDependencyIds) { - const dependencyNode = server.moduleGraph.getModuleById(dependencyId); - if (dependencyNode) { - pending.push(dependencyNode); - } + for (const dependencyNode of node.importedModules) { + pending.push(dependencyNode); } } return records; } +function loaderForModuleId(moduleId: string): 'ts' | 'tsx' | 'jsx' | 'js' { + if (moduleId.endsWith('.tsx')) { + return 'tsx'; + } + if (moduleId.endsWith('.ts') || moduleId.endsWith('.mts') || moduleId.endsWith('.cts')) { + return 'ts'; + } + if (moduleId.endsWith('.jsx')) { + return 'jsx'; + } + return 'js'; +} + function normalizeViteModuleId(id: string): string { return id.split('?')[0]; } 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 9bc8db310..d526db5ea 100644 --- a/packages/plugins/apps/src/vite/dev-server.integration.test.ts +++ b/packages/plugins/apps/src/vite/dev-server.integration.test.ts @@ -17,24 +17,23 @@ * `LOCAL_EXECUTION_LOAD_SUFFIX` propagation against Vite's own real module * resolution, which a mocked `this.resolve()` can't reproduce. * - * 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. + * `@datadog/apps-backend` and `@datadog/action-catalog` are both real, + * locally-resolvable fixture packages — see `packages/tests/src/_jest/ + * fixtures/node_modules/@datadog/apps-backend` and `.../@datadog/action-catalog` + * — rather than mocked modules, so the connection-ID coverage below exercises + * Vite's actual SSR transform output, not a hand-crafted AST shape a real + * transform would never produce. */ +import { getAuthenticatedRequest } from '@dd/apps-plugin/auth'; import { collectModuleGraphFromServer } from '@dd/apps-plugin/vite/dev-server-module-graph'; import { createDevServerMiddleware } from '@dd/apps-plugin/vite/dev-server'; import { getVitePlugin } from '@dd/apps-plugin/vite/index'; +import type { AuthOptionsWithDefaults } from '@dd/core/types'; import { getContextMock, getMockLogger } from '@dd/tests/_jest/helpers/mocks'; import { EventEmitter } from 'events'; import type { IncomingMessage, ServerResponse } from 'http'; +import nock from 'nock'; import path from 'path'; import { build, createServer, type Plugin, type ViteDevServer } from 'vite'; @@ -109,6 +108,20 @@ const noSdkFunc: BackendFunction = { allowedConnectionIds: [], }; +const actionCatalogCallFunc: BackendFunction = { + relativePath: 'actionCatalogCall', + name: 'postMessage', + absolutePath: path.join(FIXTURE_ROOT, 'actionCatalogCall.backend.ts'), + allowedConnectionIds: [], +}; + +const mixedImportsFunc: BackendFunction = { + relativePath: 'mixedImports', + name: 'usesMixedImports', + absolutePath: path.join(FIXTURE_ROOT, 'mixedImports.backend.ts'), + allowedConnectionIds: [], +}; + describe('Dev Server Middleware — real end-to-end local execution', () => { let server: ViteDevServer; @@ -132,6 +145,13 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { logLevel: 'silent', server: { middlewareMode: true, hmr: false }, plugins: [appsPlugin], + // Local execution only ever goes through ssrLoadModule, never the + // client bundle — Vite's auto-crawl-and-pre-bundle step exists for + // the browser path this feature never uses, and a fixture-only + // dependency (like the fake @datadog/action-catalog package below) + // can trip it up in ways that have nothing to do with what this + // suite is actually testing. + optimizeDeps: { noDiscovery: true }, }); }); @@ -144,7 +164,7 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { build, server.ssrLoadModule.bind(server), () => [getRuntimeUsersFunc], - () => [], + async () => [], { site: 'datadoghq.com' }, // No auth configured — this function never calls $.Actions. undefined, @@ -186,7 +206,7 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { build, server.ssrLoadModule.bind(server), () => [nestedImportFunc], - () => [], + async () => [], { site: 'datadoghq.com' }, undefined, FIXTURE_ROOT, @@ -223,7 +243,7 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { build, server.ssrLoadModule.bind(server), () => [viaHelperFunc], - () => [], + async () => [], { site: 'datadoghq.com' }, undefined, FIXTURE_ROOT, @@ -293,10 +313,10 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { const loadModule = server.ssrLoadModule.bind(server); // collectModuleGraphFromServer now appends LOCAL_EXECUTION_LOAD_SUFFIX internally, // so this closure only ever handles the bare id — matching vite/index.ts's real wiring. - const getAllowedConnectionIds = (entryId: string) => + const getAllowedConnectionIds = async (entryId: string) => extractConnectionIdsFromModuleGraph( entryId, - collectModuleGraphFromServer(server, entryId, FIXTURE_ROOT), + await collectModuleGraphFromServer(server, entryId, FIXTURE_ROOT), FIXTURE_ROOT, ); @@ -325,4 +345,128 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { expect(body.success).toBe(true); expect(body.result).toEqual({ data: { ok: true } }); }, 30000); + + // Regression coverage for the connection-ID collector against a REAL + // Vite SSR transform, not a hand-crafted AST fixture. Vite's SSR + // transform rewrites every `import` into a `__vite_ssr_import__(...)` + // call and resolves bare specifiers to absolute paths — neither shape + // the plain-`ImportDeclaration` search in collectActionCatalogImports + // (built for the untransformed syntax a real Rollup build sees) can + // parse. Before collectModuleGraphFromServer started reading each + // module's original source fresh from disk instead, this collector + // silently returned an empty allowlist for every locally-executed + // function that imports a typed action-catalog function — rejecting any + // real connectionId-scoped call with "not in this function's allowed + // connections: []", not because of a real access violation, but because + // the collector could never see the import that should have allowed it. + test('Should recognize a connectionId-scoped action-catalog call and allow it, not silently reject it', async () => { + const loadModule = server.ssrLoadModule.bind(server); + const getAllowedConnectionIds = async (entryId: string) => + extractConnectionIdsFromModuleGraph( + entryId, + await collectModuleGraphFromServer(server, entryId, FIXTURE_ROOT), + FIXTURE_ROOT, + ); + + const auth: AuthOptionsWithDefaults = { + apiKey: 'test-api-key', + appKey: 'test-app-key', + site: 'datadoghq.com', + }; + const middleware = createDevServerMiddleware( + build, + loadModule, + () => [actionCatalogCallFunc], + getAllowedConnectionIds, + auth, + getAuthenticatedRequest('apiKey', auth, getMockLogger()), + FIXTURE_ROOT, + getMockLogger(), + ); + + // The connection-ID collector is the thing under test here, not the + // real preview-async round trip (already covered by dev-server.test.ts + // and local-execution.test.ts) — this only needs the request to get + // past the allowedConnectionIds check, so a minimal reply is enough. + const apiScope = nock('https://api.datadoghq.com') + .post('/api/v2/app-builder/queries/preview-async') + .reply(200, { data: { id: 'receipt-action-catalog' } }) + .get('/api/v2/app-builder/queries/execution-long-polling/receipt-action-catalog') + .reply(200, { data: { attributes: { done: true, outputs: { ok: true } } } }); + + const req = createMockRequest('/__dd/executeAction', { + functionName: encodeQueryName(actionCatalogCallFunc), + 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); + expect(body.result).toEqual({ data: { ok: true } }); + expect(apiScope.isDone()).toBe(true); + }, 30000); + + // Coverage for a module mixing a static import with a top-level dynamic + // import textually between two static ones. dev-server-module-graph.ts + // now resolves each static specifier individually against the AST + // instead of reading node.importedModules positionally (which mixes + // static and dynamic imports with no documented ordering guarantee) — + // this fixture exercises that resolution path directly. Note: verified + // against a real Vite dev server that node.importedModules for an + // SSR-loaded module doesn't actually include a non-local dynamic + // import's target at all, so the specific silent-misattribution failure + // this was meant to reproduce doesn't manifest on the old code either; + // the new resolution approach is kept regardless since it's correct by + // construction rather than relying on node.importedModules' undocumented + // behavior. + test('Should recognize a connectionId-scoped action-catalog call even when a top-level dynamic import sits between two static imports', async () => { + const loadModule = server.ssrLoadModule.bind(server); + const getAllowedConnectionIds = async (entryId: string) => + extractConnectionIdsFromModuleGraph( + entryId, + await collectModuleGraphFromServer(server, entryId, FIXTURE_ROOT), + FIXTURE_ROOT, + ); + + const auth: AuthOptionsWithDefaults = { + apiKey: 'test-api-key', + appKey: 'test-app-key', + site: 'datadoghq.com', + }; + const middleware = createDevServerMiddleware( + build, + loadModule, + () => [mixedImportsFunc], + getAllowedConnectionIds, + auth, + getAuthenticatedRequest('apiKey', auth, getMockLogger()), + FIXTURE_ROOT, + getMockLogger(), + ); + + const apiScope = nock('https://api.datadoghq.com') + .post('/api/v2/app-builder/queries/preview-async') + .reply(200, { data: { id: 'receipt-mixed-imports' } }) + .get('/api/v2/app-builder/queries/execution-long-polling/receipt-mixed-imports') + .reply(200, { data: { attributes: { done: true, outputs: { ok: true } } } }); + + const req = createMockRequest('/__dd/executeAction', { + functionName: encodeQueryName(mixedImportsFunc), + args: ['hello'], + }); + 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: { ok: true } }); + expect(apiScope.isDone()).toBe(true); + }, 30000); }); diff --git a/packages/plugins/apps/src/vite/dev-server.test.ts b/packages/plugins/apps/src/vite/dev-server.test.ts index 46deabada..b926d4fd1 100644 --- a/packages/plugins/apps/src/vite/dev-server.test.ts +++ b/packages/plugins/apps/src/vite/dev-server.test.ts @@ -189,7 +189,7 @@ describe('Dev Server Middleware', () => { mockViteBuild, mockLoadModule, () => mockFunctions, - () => [], + async () => [], mockAuth, getApiKeyRequest(), '/project', @@ -300,7 +300,7 @@ describe('Dev Server Middleware', () => { mockViteBuild, mockLoadModule, () => mockFunctions, - () => [], + async () => [], mockAuth, getApiKeyRequest(), '/project', @@ -378,7 +378,7 @@ describe('Dev Server Middleware', () => { mockViteBuild, mockLoadModule, () => mockFunctions, - () => [], + async () => [], mockAuth, getApiKeyRequest(), '/project', @@ -505,7 +505,7 @@ describe('Dev Server Middleware', () => { mockViteBuild, mockLoadModule, () => mockFunctions, - () => [], + async () => [], mockOauthOnlyAuth, getOAuthRequest(), '/project', @@ -546,7 +546,7 @@ describe('Dev Server Middleware', () => { mockViteBuild, mockLoadModule, () => mockFunctions, - () => [], + async () => [], mockOauthOnlyAuth, undefined, '/project', @@ -635,7 +635,7 @@ describe('Dev Server Middleware', () => { mockViteBuild, mockLoadModule, () => functionsWithAllowlist, - () => [], + async () => [], mockAuth, getApiKeyRequest(), '/project', @@ -797,7 +797,7 @@ describe('Dev Server Middleware', () => { mockViteBuild, mockLoadModule, () => mockFunctions, - () => [], + async () => [], mockAuth, getApiKeyRequest(), '/project', @@ -850,7 +850,7 @@ describe('Dev Server Middleware', () => { mockViteBuild, mockLoadModule, () => mockFunctions, - () => [], + async () => [], mockOauthOnlyAuth, undefined, '/project', @@ -878,7 +878,7 @@ describe('Dev Server Middleware', () => { mockViteBuild, mockLoadModule, () => mockFunctions, - () => [], + async () => [], mockOauthOnlyAuth, undefined, '/project', @@ -916,7 +916,7 @@ describe('Dev Server Middleware', () => { mockViteBuild, mockLoadModule, () => [funcWithConnection, mockFunctions[1]], - (entryId: string) => + async (entryId: string) => entryId === funcWithConnection.absolutePath ? ['conn-1'] : [], mockAuth, getApiKeyRequest(), @@ -1092,6 +1092,46 @@ describe('Dev Server Middleware', () => { jest.useRealTimers(); } }); + + // Guards getAllowedConnectionIds — it reads every reachable module + // from disk and transforms it (dev-server-module-graph.ts), with no + // bound of its own, unlike the sibling priming load right next to it + // in handleExecuteAction which already has one. + test('Should eventually time out and return a clear error when getAllowedConnectionIds never settles', async () => { + jest.useFakeTimers(); + try { + mockLoadModuleReturning(mockFunctions[0], () => 'done'); + const hangingMiddleware = createDevServerMiddleware( + mockViteBuild, + mockLoadModule, + () => mockFunctions, + () => new Promise(() => {}), // never settles + mockAuth, + getApiKeyRequest(), + '/project', + mockLog, + ); + + const req = createMockRequest('/__dd/executeAction', { + functionName: encodeQueryName(mockFunctions[0]), + args: [], + }); + const res = createMockResponse(); + + hangingMiddleware(req, res, jest.fn()); + const doneAssertion = res.done; + + await jest.advanceTimersByTimeAsync(0); + await jest.runAllTimersAsync(); + await doneAssertion; + + expect(res.statusCode).toBe(500); + const body = JSON.parse(res.getBody()); + expect(body.error).toMatch(/timed out after 10000ms/); + } finally { + jest.useRealTimers(); + } + }); }); describe('dynamic discovery', () => { @@ -1101,7 +1141,7 @@ describe('Dev Server Middleware', () => { mockViteBuild, mockLoadModule, () => currentFunctions, - () => [], + async () => [], mockAuth, getApiKeyRequest(), '/project', diff --git a/packages/plugins/apps/src/vite/dev-server.ts b/packages/plugins/apps/src/vite/dev-server.ts index 902827f14..ac0213de9 100644 --- a/packages/plugins/apps/src/vite/dev-server.ts +++ b/packages/plugins/apps/src/vite/dev-server.ts @@ -396,7 +396,7 @@ async function handleExecuteAction( auth: AuthConfig, doAuthenticatedRequest: DoAuthenticatedRequest | undefined, loadModule: LoadModule, - getAllowedConnectionIds: (entryId: string) => string[], + getAllowedConnectionIds: (entryId: string) => Promise, projectRoot: string, log: Logger, ): Promise { @@ -423,9 +423,16 @@ async function handleExecuteAction( DEFAULT_TIMEOUT_MS, `Loading "${displayName}"`, ); + // Same reasoning as the priming load above: this reads every reachable module from disk and + // transforms it, with no bound of its own — a stalled file read or a hung esbuild.transform + // call would otherwise wedge this request forever. const funcWithConnectionIds: BackendFunction = { ...func, - allowedConnectionIds: getAllowedConnectionIds(func.absolutePath), + allowedConnectionIds: await withTimeout( + getAllowedConnectionIds(func.absolutePath), + DEFAULT_TIMEOUT_MS, + `Resolving allowed connections for "${displayName}"`, + ), }; // executeScriptLocally loads this same entry specifier again @@ -517,7 +524,7 @@ export function createDevServerMiddleware( viteBuild: typeof build, loadModule: LoadModule, getBackendFunctions: () => BackendFunction[], - getAllowedConnectionIds: (entryId: string) => string[], + getAllowedConnectionIds: (entryId: string) => Promise, auth: AuthConfig, doAuthenticatedRequest: DoAuthenticatedRequest | undefined, projectRoot: string, diff --git a/packages/plugins/apps/src/vite/index.ts b/packages/plugins/apps/src/vite/index.ts index e1e3cf7b3..8080e131e 100644 --- a/packages/plugins/apps/src/vite/index.ts +++ b/packages/plugins/apps/src/vite/index.ts @@ -310,10 +310,10 @@ export const getVitePlugin = ({ // appending the suffix internally, so this closure only ever handles the bare // backend-file path — the same shape extractConnectionIdsFromModuleGraph needs // to key into the returned records map. - const getAllowedConnectionIds = (entryId: string) => + const getAllowedConnectionIds = async (entryId: string) => extractConnectionIdsFromModuleGraph( entryId, - collectModuleGraphFromServer(server, entryId, context.buildRoot), + await collectModuleGraphFromServer(server, entryId, context.buildRoot), context.buildRoot, ); const middleware = createDevServerMiddleware( diff --git a/packages/published/esbuild-plugin/package.json b/packages/published/esbuild-plugin/package.json index b1d19e619..ae6a471dc 100644 --- a/packages/published/esbuild-plugin/package.json +++ b/packages/published/esbuild-plugin/package.json @@ -54,6 +54,7 @@ "@jridgewell/remapping": "2.3.5", "async-retry": "1.3.3", "chalk": "2.3.1", + "esbuild": "0.25.8", "eslint-scope": "7.2.2", "glob": "11.1.0", "json-stream-stringify": "3.1.6", diff --git a/packages/published/rollup-plugin/package.json b/packages/published/rollup-plugin/package.json index 810ee006d..14dc73e79 100644 --- a/packages/published/rollup-plugin/package.json +++ b/packages/published/rollup-plugin/package.json @@ -57,6 +57,7 @@ "@jridgewell/remapping": "2.3.5", "async-retry": "1.3.3", "chalk": "2.3.1", + "esbuild": "0.25.8", "eslint-scope": "7.2.2", "glob": "11.1.0", "json-stream-stringify": "3.1.6", diff --git a/packages/published/rspack-plugin/package.json b/packages/published/rspack-plugin/package.json index 9db8727f6..f8888e587 100644 --- a/packages/published/rspack-plugin/package.json +++ b/packages/published/rspack-plugin/package.json @@ -54,6 +54,7 @@ "@jridgewell/remapping": "2.3.5", "async-retry": "1.3.3", "chalk": "2.3.1", + "esbuild": "0.25.8", "eslint-scope": "7.2.2", "glob": "11.1.0", "json-stream-stringify": "3.1.6", diff --git a/packages/published/vite-plugin/package.json b/packages/published/vite-plugin/package.json index 5371a5927..3b11402a6 100644 --- a/packages/published/vite-plugin/package.json +++ b/packages/published/vite-plugin/package.json @@ -54,6 +54,7 @@ "@jridgewell/remapping": "2.3.5", "async-retry": "1.3.3", "chalk": "2.3.1", + "esbuild": "0.25.8", "eslint-scope": "7.2.2", "glob": "11.1.0", "json-stream-stringify": "3.1.6", diff --git a/packages/published/webpack-plugin/package.json b/packages/published/webpack-plugin/package.json index 35da8ffbf..0455d4dc2 100644 --- a/packages/published/webpack-plugin/package.json +++ b/packages/published/webpack-plugin/package.json @@ -54,6 +54,7 @@ "@jridgewell/remapping": "2.3.5", "async-retry": "1.3.3", "chalk": "2.3.1", + "esbuild": "0.25.8", "eslint-scope": "7.2.2", "glob": "11.1.0", "json-stream-stringify": "3.1.6", diff --git a/packages/tests/src/_jest/fixtures/action_catalog_project/action-execution.js b/packages/tests/src/_jest/fixtures/action_catalog_project/action-execution.js new file mode 100644 index 000000000..326bafb85 --- /dev/null +++ b/packages/tests/src/_jest/fixtures/action_catalog_project/action-execution.js @@ -0,0 +1,13 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +let implementation; + +export function setExecuteActionImplementation(fn) { + implementation = fn; +} + +export function getExecuteActionImplementation() { + return implementation; +} diff --git a/packages/tests/src/_jest/fixtures/action_catalog_project/index.js b/packages/tests/src/_jest/fixtures/action_catalog_project/index.js new file mode 100644 index 000000000..6476cbc32 --- /dev/null +++ b/packages/tests/src/_jest/fixtures/action_catalog_project/index.js @@ -0,0 +1,13 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import { getExecuteActionImplementation } from './action-execution.js'; + +export async function sendSlackMessage(request) { + const implementation = getExecuteActionImplementation(); + if (!implementation) { + throw new Error('@datadog/action-catalog fixture: no execute-action implementation registered'); + } + return implementation('com.datadoghq.slack.chat.postMessage', request); +} diff --git a/packages/tests/src/_jest/fixtures/action_catalog_project/package.json b/packages/tests/src/_jest/fixtures/action_catalog_project/package.json new file mode 100644 index 000000000..678e69045 --- /dev/null +++ b/packages/tests/src/_jest/fixtures/action_catalog_project/package.json @@ -0,0 +1,14 @@ +{ + "name": "@datadog/action-catalog", + "version": "0.0.1", + "private": true, + "license": "MIT", + "author": "Datadog", + "type": "module", + "description": "Minimal local fixture standing in for the real @datadog/action-catalog package — only the pieces dev-server.integration.test.ts's connection-ID coverage actually exercises.", + "main": "index.js", + "exports": { + ".": "./index.js", + "./action-execution": "./action-execution.js" + } +} diff --git a/packages/tests/src/_jest/fixtures/apps_backend_project/actionCatalogCall.backend.ts b/packages/tests/src/_jest/fixtures/apps_backend_project/actionCatalogCall.backend.ts new file mode 100644 index 000000000..46a9b5daa --- /dev/null +++ b/packages/tests/src/_jest/fixtures/apps_backend_project/actionCatalogCall.backend.ts @@ -0,0 +1,9 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import { sendSlackMessage } from '@datadog/action-catalog'; + +export async function postMessage() { + return sendSlackMessage({ inputs: { text: 'hi' }, connectionId: 'conn-1' }); +} diff --git a/packages/tests/src/_jest/fixtures/apps_backend_project/mixedImports.backend.ts b/packages/tests/src/_jest/fixtures/apps_backend_project/mixedImports.backend.ts new file mode 100644 index 000000000..450f8810f --- /dev/null +++ b/packages/tests/src/_jest/fixtures/apps_backend_project/mixedImports.backend.ts @@ -0,0 +1,19 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import { helperEcho } from './helper'; + +// A non-local (npm-package) dynamic import specifically, not a local one — a +// local dynamic import is already caught by the fail-closed +// unsupportedDependencies check, which would mask the bug this fixture +// exists to exercise (silent misattribution of the static import textually +// after it, not a thrown error). +await import('chalk'); + +import { sendSlackMessage } from '@datadog/action-catalog'; + +export async function usesMixedImports(value: string) { + await helperEcho(value); + return sendSlackMessage({ inputs: { text: value }, connectionId: 'conn-1' }); +} diff --git a/packages/tests/src/_jest/fixtures/apps_backend_project/package.json b/packages/tests/src/_jest/fixtures/apps_backend_project/package.json index 988be3f0a..fabae5e0f 100644 --- a/packages/tests/src/_jest/fixtures/apps_backend_project/package.json +++ b/packages/tests/src/_jest/fixtures/apps_backend_project/package.json @@ -5,6 +5,7 @@ "author": "Datadog", "packageManager": "yarn@4.2.1", "dependencies": { + "@datadog/action-catalog": "portal:../action_catalog_project", "@datadog/apps-backend": "0.0.1" } } diff --git a/packages/tests/src/_jest/fixtures/package.json b/packages/tests/src/_jest/fixtures/package.json index df591e7ce..72bf7254e 100644 --- a/packages/tests/src/_jest/fixtures/package.json +++ b/packages/tests/src/_jest/fixtures/package.json @@ -7,6 +7,7 @@ "workspaces": [ "hard_project", "easy_project", - "apps_backend_project" + "apps_backend_project", + "action_catalog_project" ] } diff --git a/packages/tests/src/_jest/fixtures/yarn.lock b/packages/tests/src/_jest/fixtures/yarn.lock index 0a81c424b..79a91d93e 100644 --- a/packages/tests/src/_jest/fixtures/yarn.lock +++ b/packages/tests/src/_jest/fixtures/yarn.lock @@ -5,6 +5,18 @@ __metadata: version: 8 cacheKey: 10 +"@datadog/action-catalog@portal:../action_catalog_project::locator=%40tests%2Fapps_backend_project%40workspace%3Aapps_backend_project": + version: 0.0.0-use.local + resolution: "@datadog/action-catalog@portal:../action_catalog_project::locator=%40tests%2Fapps_backend_project%40workspace%3Aapps_backend_project" + languageName: node + linkType: soft + +"@datadog/action-catalog@workspace:action_catalog_project": + version: 0.0.0-use.local + resolution: "@datadog/action-catalog@workspace:action_catalog_project" + languageName: unknown + linkType: soft + "@datadog/apps-backend@npm:0.0.1": version: 0.0.1 resolution: "@datadog/apps-backend@npm:0.0.1" @@ -23,6 +35,7 @@ __metadata: version: 0.0.0-use.local resolution: "@tests/apps_backend_project@workspace:apps_backend_project" dependencies: + "@datadog/action-catalog": "portal:../action_catalog_project" "@datadog/apps-backend": "npm:0.0.1" languageName: unknown linkType: soft diff --git a/yarn.lock b/yarn.lock index 3d9228437..44fed7c02 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1976,6 +1976,7 @@ __metadata: "@types/eslint-scope": "npm:3.7.7" "@types/estree": "npm:1.0.8" chalk: "npm:2.3.1" + esbuild: "npm:0.25.8" eslint-scope: "npm:7.2.2" glob: "npm:11.1.0" jszip: "npm:3.10.1" From 41191053a60cd9043c1df6fc2f5ee125cd90fdae Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Fri, 28 Aug 2026 01:14:11 -0400 Subject: [PATCH 24/30] fix(apps): fail closed on unresolved imports, require auth upfront, bound total execution time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit collectModuleGraphFromServer silently fell back to a static import's raw specifier text when Vite's resolveId failed to resolve it, instead of failing closed like every sibling module-graph error path — a connectionId-scoped action call behind an unresolvable import would silently drop out of the allowlist instead of the request failing loudly. /__dd/executeAction ran a customer's real backend code with no auth check upfront, only lazily inside a $.Actions call — unlike production, which authenticates before any query/execution logic runs (app-builder-api's PreviewAsyncQueryHandler). A function that never calls $.Actions was a loophole around the same requirement production always enforces. Checked upfront as a local credential-presence check, not a network call, so it costs no latency on the local dev loop. guardedExecuteAction's hang-detection pause can't distinguish a customer function genuinely awaiting a slow $.Actions call from one that fired a call without awaiting it and then hung on something unrelated — an unawaited call masked a real hang for up to MAX_ACTION_CALL_TIMEOUT_MS (10 minutes). A second, independent absolute ceiling now bounds one execution's total wall-clock time regardless of pendingActionCalls, set just above pollQueryExecution's own ~300s worst-case long-poll budget so a legitimate slow call still always finishes. Also fixes a comment narrating this PR's own before/after history and an embedded milestone number, both against repo convention. --- .../src/vite/dev-server-module-graph.test.ts | 50 +++++++++++++++++++ .../apps/src/vite/dev-server-module-graph.ts | 13 +++-- .../src/vite/dev-server.integration.test.ts | 37 ++++++++++---- .../plugins/apps/src/vite/dev-server.test.ts | 26 +++++----- packages/plugins/apps/src/vite/dev-server.ts | 17 ++++--- .../apps/src/vite/local-execution.test.ts | 42 +++++++++++++++- .../plugins/apps/src/vite/local-execution.ts | 16 +++++- 7 files changed, 166 insertions(+), 35 deletions(-) create mode 100644 packages/plugins/apps/src/vite/dev-server-module-graph.test.ts diff --git a/packages/plugins/apps/src/vite/dev-server-module-graph.test.ts b/packages/plugins/apps/src/vite/dev-server-module-graph.test.ts new file mode 100644 index 000000000..26526fc1c --- /dev/null +++ b/packages/plugins/apps/src/vite/dev-server-module-graph.test.ts @@ -0,0 +1,50 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import path from 'node:path'; + +import { LOCAL_EXECUTION_LOAD_SUFFIX } from '../constants'; + +import { collectModuleGraphFromServer } from './dev-server-module-graph'; + +const FIXTURE_ROOT = path.resolve( + __dirname, + '../../../../tests/src/_jest/fixtures/apps_backend_project', +); +const ENTRY_ID = path.join(FIXTURE_ROOT, 'helper.ts'); +const SUFFIXED_ENTRY_ID = ENTRY_ID + LOCAL_EXECUTION_LOAD_SUFFIX; + +function makeFakeServer(resolveId: (specifier: string) => Promise<{ id: string } | null>) { + return { + moduleGraph: { + getModuleById: (id: string) => + id === SUFFIXED_ENTRY_ID + ? { id: SUFFIXED_ENTRY_ID, file: ENTRY_ID, importedModules: new Set() } + : undefined, + }, + pluginContainer: { + resolveId: (specifier: string) => resolveId(specifier), + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; +} + +describe('dev-server-module-graph — collectModuleGraphFromServer', () => { + test('Should fail closed, not fall back to the raw specifier, when resolveId fails to resolve a static import', async () => { + const server = makeFakeServer(async () => null); + + await expect(collectModuleGraphFromServer(server, ENTRY_ID, FIXTURE_ROOT)).rejects.toThrow( + /unresolvable import specifier ".\/getRuntimeUsers\.backend"/, + ); + }); + + test('Should use the resolved id when resolveId succeeds', async () => { + const resolvedPath = path.join(FIXTURE_ROOT, 'getRuntimeUsers.backend.ts'); + const server = makeFakeServer(async () => ({ id: resolvedPath })); + + const records = await collectModuleGraphFromServer(server, ENTRY_ID, FIXTURE_ROOT); + + expect(records.has(ENTRY_ID)).toBe(true); + }); +}); diff --git a/packages/plugins/apps/src/vite/dev-server-module-graph.ts b/packages/plugins/apps/src/vite/dev-server-module-graph.ts index c567aeed0..40f649bb2 100644 --- a/packages/plugins/apps/src/vite/dev-server-module-graph.ts +++ b/packages/plugins/apps/src/vite/dev-server-module-graph.ts @@ -121,9 +121,16 @@ export async function collectModuleGraphFromServer( }), ), ); - const staticDependencyIds = resolutions.map((resolved, index) => - resolved ? normalizeViteModuleId(resolved.id) : staticModuleSources[index], - ); + const staticDependencyIds = resolutions.map((resolved, index) => { + if (!resolved) { + // Fail closed rather than trusting an incomplete allowlist — falling back to the raw specifier text would let a connectionId-scoped call behind an unresolvable import silently drop out of extractConnectionIdsFromModuleGraph's allowlist instead of the whole request failing loudly. + throw unsupportedModuleGraphDependency( + moduleId, + `unresolvable import specifier "${staticModuleSources[index]}"`, + ); + } + return normalizeViteModuleId(resolved.id); + }); const record = createParsedModuleRecord(moduleId, buildRoot, ast, staticDependencyIds); if (record) { 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 d526db5ea..ed7649224 100644 --- a/packages/plugins/apps/src/vite/dev-server.integration.test.ts +++ b/packages/plugins/apps/src/vite/dev-server.integration.test.ts @@ -160,14 +160,18 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { }); 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 auth: AuthOptionsWithDefaults = { + apiKey: 'test-api-key', + appKey: 'test-app-key', + site: 'datadoghq.com', + }; const middleware = createDevServerMiddleware( build, server.ssrLoadModule.bind(server), () => [getRuntimeUsersFunc], async () => [], - { site: 'datadoghq.com' }, - // No auth configured — this function never calls $.Actions. - undefined, + auth, + getAuthenticatedRequest('apiKey', auth, getMockLogger()), FIXTURE_ROOT, getMockLogger(), ); @@ -202,13 +206,18 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { // doesn't exist server-side), and this would throw instead of returning // the real value. test('Should preserve real code for a nested *.backend.ts import, not swap it for the frontend RPC-proxy stub', async () => { + const auth: AuthOptionsWithDefaults = { + apiKey: 'test-api-key', + appKey: 'test-app-key', + site: 'datadoghq.com', + }; const middleware = createDevServerMiddleware( build, server.ssrLoadModule.bind(server), () => [nestedImportFunc], async () => [], - { site: 'datadoghq.com' }, - undefined, + auth, + getAuthenticatedRequest('apiKey', auth, getMockLogger()), FIXTURE_ROOT, getMockLogger(), ); @@ -239,13 +248,18 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { // backend-to-backend case above, and swapping getRuntimeUsers.backend.ts // for its frontend RPC-proxy stub. test('Should preserve real code for a *.backend.ts import reached through an intermediate non-backend module', async () => { + const auth: AuthOptionsWithDefaults = { + apiKey: 'test-api-key', + appKey: 'test-app-key', + site: 'datadoghq.com', + }; const middleware = createDevServerMiddleware( build, server.ssrLoadModule.bind(server), () => [viaHelperFunc], async () => [], - { site: 'datadoghq.com' }, - undefined, + auth, + getAuthenticatedRequest('apiKey', auth, getMockLogger()), FIXTURE_ROOT, getMockLogger(), ); @@ -320,13 +334,18 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { FIXTURE_ROOT, ); + const auth: AuthOptionsWithDefaults = { + apiKey: 'test-api-key', + appKey: 'test-app-key', + site: 'datadoghq.com', + }; const middleware = createDevServerMiddleware( build, loadModule, () => [noSdkFunc], getAllowedConnectionIds, - { site: 'datadoghq.com' }, - undefined, + auth, + getAuthenticatedRequest('apiKey', auth, getMockLogger()), FIXTURE_ROOT, getMockLogger(), ); diff --git a/packages/plugins/apps/src/vite/dev-server.test.ts b/packages/plugins/apps/src/vite/dev-server.test.ts index b926d4fd1..31bb2361a 100644 --- a/packages/plugins/apps/src/vite/dev-server.test.ts +++ b/packages/plugins/apps/src/vite/dev-server.test.ts @@ -845,7 +845,7 @@ describe('Dev Server Middleware', () => { expect(mockViteBuild).not.toHaveBeenCalled(); }); - test('Should work with no auth configured at all, for a function that never calls $.Actions', async () => { + test('Should return a clear error when a function calls $.Actions with no auth configured', async () => { const noAuthMiddleware = createDevServerMiddleware( mockViteBuild, mockLoadModule, @@ -856,7 +856,13 @@ describe('Dev Server Middleware', () => { '/project', mockLog, ); - mockLoadModuleReturning(mockFunctions[0], () => 1); + mockLoadModuleReturning(mockFunctions[0], () => + ( + globalThis as typeof globalThis & { $: { Actions: ActionsProxy } } + ).$.Actions.slack.chat.postMessage({ + inputs: { text: 'hi' }, + }), + ); const req = createMockRequest('/__dd/executeAction', { functionName: encodeQueryName(mockFunctions[0]), @@ -867,13 +873,13 @@ describe('Dev Server Middleware', () => { noAuthMiddleware(req, res, jest.fn()); await res.done; - expect(res.statusCode).toBe(200); + expect(res.statusCode).toBe(400); const body = JSON.parse(res.getBody()); - expect(body.success).toBe(true); - expect(body.result).toEqual({ data: 1 }); + expect(body.success).toBe(false); + expect(body.error).toContain('Auth credentials not configured'); }); - test('Should return a clear error when a function calls $.Actions with no auth configured', async () => { + test('Should reject a request with no auth configured upfront, even for a function that never calls $.Actions — matching production, which authenticates before any backend code runs', async () => { const noAuthMiddleware = createDevServerMiddleware( mockViteBuild, mockLoadModule, @@ -884,13 +890,7 @@ describe('Dev Server Middleware', () => { '/project', mockLog, ); - mockLoadModuleReturning(mockFunctions[0], () => - ( - globalThis as typeof globalThis & { $: { Actions: ActionsProxy } } - ).$.Actions.slack.chat.postMessage({ - inputs: { text: 'hi' }, - }), - ); + mockLoadModuleReturning(mockFunctions[0], () => 'pure result, no $.Actions call'); const req = createMockRequest('/__dd/executeAction', { functionName: encodeQueryName(mockFunctions[0]), diff --git a/packages/plugins/apps/src/vite/dev-server.ts b/packages/plugins/apps/src/vite/dev-server.ts index ac0213de9..2bd8601df 100644 --- a/packages/plugins/apps/src/vite/dev-server.ts +++ b/packages/plugins/apps/src/vite/dev-server.ts @@ -385,9 +385,9 @@ async function handleDebugBundle( /** * 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). + * on this path. Auth is required upfront (checked by the caller in + * createDevServerMiddleware before this is reached), matching production's + * own auth-before-execution ordering. */ async function handleExecuteAction( req: IncomingMessage, @@ -465,10 +465,8 @@ async function handleExecuteAction( /** * Handle POST /__dd/executeActionViaCloud — bundles a backend function and * executes it via the existing production round trip (queue + Deno - * subprocess), the same way `/__dd/executeAction` did before local - * execution existed. 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. + * subprocess). Kept as a distinctly-purposed command (`npm run dev:verify`) + * for pre-publish parity checks, not a mode flag on the same endpoint. */ async function handleExecuteActionViaCloud( req: IncomingMessage, @@ -559,6 +557,11 @@ export function createDevServerMiddleware( sendError(res, 500, 'Unexpected error'); }); } else if (req.url === '/__dd/executeAction') { + // Matches production, which authenticates before any backend-function code runs (app-builder-api's PreviewAsyncQueryHandler checks the user first, before the query/execution path) — checked here upfront rather than only lazily inside a $.Actions call, so a function that never calls $.Actions isn't a loophole around the same requirement. A local presence check only (not a real credential-validation network call), so it costs no latency on the fast local dev loop. + if (!doAuthenticatedRequest) { + sendError(res, 400, `Auth credentials not configured. ${AUTH_GUIDANCE}`); + return; + } handleExecuteAction( req, res, diff --git a/packages/plugins/apps/src/vite/local-execution.test.ts b/packages/plugins/apps/src/vite/local-execution.test.ts index 88b9601b8..0515b2959 100644 --- a/packages/plugins/apps/src/vite/local-execution.test.ts +++ b/packages/plugins/apps/src/vite/local-execution.test.ts @@ -477,7 +477,11 @@ describe('local-execution — executeScriptLocally', () => { // a bound on the $.Actions call itself, a stalled network request (no // abort signal/deadline of its own) would wedge this execution — and, // since local executions are serialized via `enqueue`, every request - // queued behind it — indefinitely. + // queued behind it — indefinitely. The absolute execution ceiling + // (MAX_TOTAL_EXECUTION_TIMEOUT_MS, 6 minutes) now always fires before + // the per-call bound (MAX_ACTION_CALL_TIMEOUT_MS, 10 minutes) could, so + // that's the mechanism actually observed here — MAX_ACTION_CALL_TIMEOUT_MS + // remains a backstop for any path the ceiling doesn't cover. test('Should eventually time out an in-flight $.Actions call that never settles, and not wedge subsequently queued executions', async () => { jest.useFakeTimers(); try { @@ -511,7 +515,7 @@ describe('local-execution — executeScriptLocally', () => { ); const hungAssertion = expect(hungExecution).rejects.toThrow( - /\$\.Actions call to "com\.datadoghq\.slack\.chat\.postMessage" timed out/, + /exceeded the absolute 360000ms execution ceiling/, ); await jest.runAllTimersAsync(); @@ -523,6 +527,40 @@ describe('local-execution — executeScriptLocally', () => { } }); + // A fire-and-forget $.Actions call (not awaited by the customer function) increments pendingActionCalls the same as an awaited one, pausing the per-call hang-detection timer for as long as that call stays in flight — up to MAX_ACTION_CALL_TIMEOUT_MS (10 minutes) if the call never settles, even though the customer function itself moved on to something else entirely. The absolute execution ceiling below must still fire well before that. + test('Should eventually time out via an absolute execution ceiling, independent of any $.Actions call still in flight', async () => { + jest.useFakeTimers(); + try { + const neverSettlingExecuteAction: ExecuteAction = () => new Promise(() => {}); + + const execution = executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + neverSettlingExecuteAction, + loadModuleReturning({ + example: () => + ( + globalThis as typeof globalThis & { $: { Actions: ActionsProxy } } + ).$.Actions.slack.chat.postMessage({ + inputs: { text: 'hi' }, + }), + }), + mockLogger, + 50, + ); + + const assertion = expect(execution).rejects.toThrow( + /exceeded the absolute 360000ms execution ceiling/, + ); + + await jest.advanceTimersByTimeAsync(6 * 60_000); + await assertion; + } finally { + jest.useRealTimers(); + } + }); + test('Should still time out a function that hangs with no $.Actions call in flight, even after an earlier call in the same run completed', async () => { const executeAction: ExecuteAction = async () => ({ ok: true }); diff --git a/packages/plugins/apps/src/vite/local-execution.ts b/packages/plugins/apps/src/vite/local-execution.ts index ee4960e1a..42f71587f 100644 --- a/packages/plugins/apps/src/vite/local-execution.ts +++ b/packages/plugins/apps/src/vite/local-execution.ts @@ -115,6 +115,9 @@ export const DEFAULT_TIMEOUT_MS = 10_000; /** Bounds a single `$.Actions` call while it's exempt from the hang-detection timer above (see `guardedExecuteAction`). `doRequest` attaches no abort signal or deadline of its own, so an in-flight call that never settles would otherwise wedge this execution — and, since local executions are serialized, every request queued behind it — forever. Set generously past `pollQueryExecution`'s own worst-case long-poll budget (10 retries at up to ~30s each) so a legitimate slow action is never cut off. */ const MAX_ACTION_CALL_TIMEOUT_MS = 10 * 60_000; +/** Absolute ceiling on one execution's total wall-clock time, independent of `pendingActionCalls`'s pause-and-extend mechanism (see `guardedExecuteAction`) — that mechanism can't distinguish a customer function genuinely awaiting a slow `$.Actions` call from one that fired a call without awaiting it and then hung on something unrelated, so an unawaited call currently masks a real hang for as long as MAX_ACTION_CALL_TIMEOUT_MS. Set just above `pollQueryExecution`'s own worst-case long-poll budget (10 retries at up to ~30s each, ~300s) so a legitimate single slow `$.Actions` call still always finishes — this bounds the masked-hang worst case to roughly 6 minutes instead of the full 10, not lower, since going lower would start killing real in-progress calls instead of just hangs. */ +const MAX_TOTAL_EXECUTION_TIMEOUT_MS = 6 * 60_000; + /** Loads a module by specifier, resolved against the customer's own project rather than build-plugins' dependency tree — the dev server passes its Vite instance's `ssrLoadModule` here. */ export type LoadModule = (specifier: string) => Promise>; @@ -578,7 +581,17 @@ async function runScriptLocally( scheduleTimeout(); }); - // Racing the timeout only stops the caller from waiting — run() keeps executing afterward, so a resumed customer function can still fire real $.Actions side effects; true cancellation would need a Worker thread, not possible in-process. + // Fires regardless of pendingActionCalls, unlike the pause-and-extend timeout above — bounds the worst case of a fire-and-forget $.Actions call masking an unrelated hang to MAX_TOTAL_EXECUTION_TIMEOUT_MS instead of the per-call MAX_ACTION_CALL_TIMEOUT_MS. + const absoluteTimeoutTimer = setTimeout(() => { + concludeExecution(); + rejectTimeout?.( + new Error( + `Local execution of "${func.name}" exceeded the absolute ${MAX_TOTAL_EXECUTION_TIMEOUT_MS}ms execution ceiling, regardless of any $.Actions call in flight.`, + ), + ); + }, MAX_TOTAL_EXECUTION_TIMEOUT_MS); + + // Racing against the timeout only stops the caller from waiting — run() keeps executing in-process afterward, so a customer function that resumes post-timeout can still fire real $.Actions side effects. True cancellation requires terminating a Worker thread, not possible for in-process execution. const runPromise = run(); // Set once the race settles, so the handler below can tell an abandoned rejection (caller already gone) from an ordinary one the caller is about to receive normally. let raceSettled = false; @@ -596,5 +609,6 @@ async function runScriptLocally( } finally { raceSettled = true; clearTimeout(timer); + clearTimeout(absoluteTimeoutTimer); } } From 4ae64bccf2cc2d129bc21a80c9be96b1c1692a81 Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Fri, 28 Aug 2026 01:43:15 -0400 Subject: [PATCH 25/30] fix(apps): remove now-dead lazy auth check and its stale doc comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit makeExecuteActionRemotely's own inner auth check and doc comment ("no auth needed until a call is actually made") went stale once the caller started requiring auth upfront — its only caller is only ever reached after that upfront check already passed, making the inner check unreachable and the comment actively contradictory. Narrows both functions' doAuthenticatedRequest parameter to required, matching how the sibling /__dd/executeActionViaCloud path already types it. Removes the now-redundant "no auth + calls $.Actions" test, fully subsumed by the upfront-check test right after it — no test exercises the removed lazy check anymore since nothing can reach it. --- .../plugins/apps/src/vite/dev-server.test.ts | 34 ------------------- packages/plugins/apps/src/vite/dev-server.ts | 9 ++--- 2 files changed, 3 insertions(+), 40 deletions(-) diff --git a/packages/plugins/apps/src/vite/dev-server.test.ts b/packages/plugins/apps/src/vite/dev-server.test.ts index 31bb2361a..6192e753f 100644 --- a/packages/plugins/apps/src/vite/dev-server.test.ts +++ b/packages/plugins/apps/src/vite/dev-server.test.ts @@ -845,40 +845,6 @@ describe('Dev Server Middleware', () => { expect(mockViteBuild).not.toHaveBeenCalled(); }); - test('Should return a clear error when a function calls $.Actions with no auth configured', async () => { - const noAuthMiddleware = createDevServerMiddleware( - mockViteBuild, - mockLoadModule, - () => mockFunctions, - async () => [], - mockOauthOnlyAuth, - undefined, - '/project', - mockLog, - ); - mockLoadModuleReturning(mockFunctions[0], () => - ( - globalThis as typeof globalThis & { $: { Actions: ActionsProxy } } - ).$.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(400); - const body = JSON.parse(res.getBody()); - expect(body.success).toBe(false); - expect(body.error).toContain('Auth credentials not configured'); - }); - test('Should reject a request with no auth configured upfront, even for a function that never calls $.Actions — matching production, which authenticates before any backend code runs', async () => { const noAuthMiddleware = createDevServerMiddleware( mockViteBuild, diff --git a/packages/plugins/apps/src/vite/dev-server.ts b/packages/plugins/apps/src/vite/dev-server.ts index 2bd8601df..4c257f36a 100644 --- a/packages/plugins/apps/src/vite/dev-server.ts +++ b/packages/plugins/apps/src/vite/dev-server.ts @@ -216,10 +216,10 @@ async function executeScriptViaDatadog( return outputs; } -/** Submits a single-action `preview-async` query per `$.Actions` call (no auth needed until a call is actually made) and logs its result/error, since production's own equivalent signal only reaches Datadog's backend, not the developer's `npm run dev` console. */ +/** Submits a single-action `preview-async` query per `$.Actions` call and logs its result/error, since production's own equivalent signal only reaches Datadog's backend, not the developer's `npm run dev` console. Callers must already have confirmed auth is configured — see `handleExecuteAction`'s own upfront check. */ function makeExecuteActionRemotely( auth: AuthConfig, - doAuthenticatedRequest: DoAuthenticatedRequest | undefined, + doAuthenticatedRequest: DoAuthenticatedRequest, log: Logger, ): ExecuteAction { return async ( @@ -227,9 +227,6 @@ function makeExecuteActionRemotely( inputs: unknown, connectionId: string | undefined, ): Promise => { - if (!doAuthenticatedRequest) { - throw new HttpError(400, `Auth credentials not configured. ${AUTH_GUIDANCE}`); - } try { const receiptId = await submitQuery( connectionId ? { fqn, inputs, connectionId } : { fqn, inputs }, @@ -394,7 +391,7 @@ async function handleExecuteAction( res: ServerResponse, functionsByName: Map, auth: AuthConfig, - doAuthenticatedRequest: DoAuthenticatedRequest | undefined, + doAuthenticatedRequest: DoAuthenticatedRequest, loadModule: LoadModule, getAllowedConnectionIds: (entryId: string) => Promise, projectRoot: string, From 3cda4631e2af45b55cc353c667d2aecb733f9cbb Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Fri, 28 Aug 2026 02:13:40 -0400 Subject: [PATCH 26/30] fix(apps): scope the module-graph priming load's $ access like executeScriptLocally's own load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handleExecuteAction's priming load is the only place a customer module's top-level code actually runs (Vite caches the module, so executeScriptLocally's own load below just reuses the resolved object) — but it called loadModule directly instead of going through customerModuleLoadContext, so a customer module reaching for $ during its own top-level evaluation silently resolved to whatever $ a prior execution left behind instead of throwing the same way it does inside executeScriptLocally. Extracts the scoping into loadCustomerModuleEntry, shared by both call sites. Also corrects the startup warning logged when auth isn't configured: it still described only $.Actions calls as failing, but the earlier auth-upfront hardening rejects the whole /__dd/executeAction endpoint before any backend-function code runs, regardless of whether it calls $.Actions. --- .../plugins/apps/src/vite/dev-server.test.ts | 36 +++++++++++++++++++ packages/plugins/apps/src/vite/dev-server.ts | 23 +++++++++--- .../plugins/apps/src/vite/local-execution.ts | 15 ++++++-- 3 files changed, 67 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 6192e753f..afc12c238 100644 --- a/packages/plugins/apps/src/vite/dev-server.test.ts +++ b/packages/plugins/apps/src/vite/dev-server.test.ts @@ -15,6 +15,7 @@ import { parseAst } from 'rollup/parseAst'; import { encodeQueryName } from '../backend/encodeQueryName'; import type { BackendFunction } from '../backend/types'; +import { LOCAL_EXECUTION_LOAD_SUFFIX } from '../constants'; jest.mock('@dd/core/helpers/oauth-request', () => ({ doOAuthRequest: jest.fn(async (opts) => { @@ -1022,6 +1023,41 @@ describe('Dev Server Middleware', () => { ); }); + // The priming loadModule call (see handleExecuteAction) is the only + // place the entry's top-level code actually runs — Vite caches the + // module, so executeScriptLocally's own load below just reuses this + // same resolved object — so it must carry the same $-scoping + // guarantee executeScriptLocally's load would otherwise provide. + test('Should throw when a customer module reaches for $ during its own top-level evaluation, even though the module-graph priming load runs before executeScriptLocally installs its own scoping', async () => { + let dollarDuringTopLevelLoad: unknown = 'not captured'; + mockLoadModule.mockImplementation(async (specifier: string) => { + if (specifier === mockFunctions[0].absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { + try { + dollarDuringTopLevelLoad = (globalThis as Record).$; + } catch (error) { + dollarDuringTopLevelLoad = error; + } + return { [mockFunctions[0].name]: () => 'done' }; + } + throw new Error(`Cannot find module '${specifier}'`); + }); + + 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); + expect(dollarDuringTopLevelLoad).toBeInstanceOf(Error); + expect((dollarDuringTopLevelLoad as Error).message).toBe( + 'No active local execution to resolve $ under.', + ); + }); + // Guards the priming loadModule call (see handleExecuteAction) — it // evaluates the entry's real top-level code before // executeScriptLocally's own hang-detection timeout is installed, so diff --git a/packages/plugins/apps/src/vite/dev-server.ts b/packages/plugins/apps/src/vite/dev-server.ts index 4c257f36a..263cee24c 100644 --- a/packages/plugins/apps/src/vite/dev-server.ts +++ b/packages/plugins/apps/src/vite/dev-server.ts @@ -20,7 +20,12 @@ import { LOCAL_EXECUTION_LOAD_SUFFIX } from '../constants'; import { createBackendConnectionIdCollector } from './backend-connection-id-collector'; import { getBaseBackendBuildConfig } from './build-config'; import type { ExecuteAction, LoadModule } from './local-execution'; -import { DEFAULT_TIMEOUT_MS, executeScriptLocally, withTimeout } from './local-execution'; +import { + DEFAULT_TIMEOUT_MS, + executeScriptLocally, + loadCustomerModuleEntry, + withTimeout, +} from './local-execution'; interface BundleResult { func: BackendFunction; @@ -216,7 +221,7 @@ async function executeScriptViaDatadog( return outputs; } -/** Submits a single-action `preview-async` query per `$.Actions` call and logs its result/error, since production's own equivalent signal only reaches Datadog's backend, not the developer's `npm run dev` console. Callers must already have confirmed auth is configured — see `handleExecuteAction`'s own upfront check. */ +/** Submits a single-action `preview-async` query per `$.Actions` call and logs its result/error, since production's own equivalent signal only reaches Datadog's backend, not the developer's `npm run dev` console. Callers must already have confirmed auth is configured — see `createDevServerMiddleware`'s upfront check on this route. */ function makeExecuteActionRemotely( auth: AuthConfig, doAuthenticatedRequest: DoAuthenticatedRequest, @@ -413,10 +418,18 @@ async function handleExecuteAction( // installs its own hang-detection timeout below, and it evaluates the // entry's real top-level code (ssrLoadModule, not a parse-only step) // — so it needs its own bound, or a customer module with a hanging - // top-level await would wedge this request forever. + // top-level await would wedge this request forever. It's also the + // only place that top-level code actually runs (Vite caches the + // module, and executeScriptLocally below reuses this same resolved + // object rather than re-evaluating it), so it must go through + // loadCustomerModuleEntry's scoping rather than a raw loadModule call + // — otherwise a customer module reaching for $ during its own + // top-level evaluation would silently resolve to whatever `$` a + // prior execution happened to leave behind, instead of throwing the + // same way it does inside executeScriptLocally. const entrySpecifier = func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX; const primedModule = await withTimeout( - loadModule(entrySpecifier), + loadCustomerModuleEntry(loadModule, entrySpecifier), DEFAULT_TIMEOUT_MS, `Loading "${displayName}"`, ); @@ -537,7 +550,7 @@ export function createDevServerMiddleware( if (!doAuthenticatedRequest) { log.warn( - `Auth credentials not configured. Backend functions that call $.Actions will fail; the /__dd/executeActionViaCloud endpoint will be unavailable. ${AUTH_GUIDANCE}`, + `Auth credentials not configured. Both the /__dd/executeAction and /__dd/executeActionViaCloud endpoints will be unavailable. ${AUTH_GUIDANCE}`, ); } diff --git a/packages/plugins/apps/src/vite/local-execution.ts b/packages/plugins/apps/src/vite/local-execution.ts index 42f71587f..33abd65e6 100644 --- a/packages/plugins/apps/src/vite/local-execution.ts +++ b/packages/plugins/apps/src/vite/local-execution.ts @@ -121,6 +121,16 @@ const MAX_TOTAL_EXECUTION_TIMEOUT_MS = 6 * 60_000; /** Loads a module by specifier, resolved against the customer's own project rather than build-plugins' dependency tree — the dev server passes its Vite instance's `ssrLoadModule` here. */ export type LoadModule = (specifier: string) => Promise>; +/** Loads a customer module's entry under the same top-level-evaluation scoping `runScriptLocally`'s own load uses (see `customerModuleLoadContext`'s doc comment above) — for callers that need to trigger the entry's real top-level evaluation ahead of `executeScriptLocally` (e.g. dev-server.ts's module-graph priming load), so a customer module reaching for `$` during that load fails the same way it would inside `executeScriptLocally`, instead of silently resolving to whatever `globalDollarOutsideExecution` happens to hold. */ +export function loadCustomerModuleEntry( + loadModule: LoadModule, + entrySpecifier: string, +): Promise> { + return customerModuleLoadContext.run({ assigned: false, value: undefined }, () => + loadModule(entrySpecifier), + ); +} + /** Executes a real `$.Actions.foo.bar(...)` call; the dev server supplies the implementation using its own auth, so this module never holds or sees a credential itself. */ export type ExecuteAction = ( fqn: string, @@ -539,8 +549,9 @@ async function runScriptLocally( const run = async (): Promise => { // Loads and evaluates the customer's module BEFORE installing $ and the SDK bridges below, matching production's own ordering (backend/virtual-entry.ts statically imports the customer module before its wrapper installs $ and the SDK bridges) — code that reaches for $ or a typed action during its own top-level evaluation fails the same way locally as it would in Datadog, instead of silently succeeding against bindings production wouldn't have installed yet. - const mod = await customerModuleLoadContext.run({ assigned: false, value: undefined }, () => - loadModule(func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX), + const mod = await loadCustomerModuleEntry( + loadModule, + func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX, ); const fn = mod[func.name]; if (typeof fn !== 'function') { From e57215bd00f46a24c89709a26fcd2049350d1f98 Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Fri, 28 Aug 2026 03:16:53 -0400 Subject: [PATCH 27/30] fix(apps): add missing startup-warning test coverage, fix convention violations found in round-6 review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add test coverage for the startup auth-warning log message (dev-server.ts) — no test asserted its exact wording, which is how a stale claim about which endpoints fail escaped an earlier review round. - guardedExecuteAction is async, so its not-current branch's `return Promise.reject(...)` was needless wrapping — use `throw` instead. - Extract three inlined function-call arguments (withTimeout's first argument at two dev-server.ts call sites, executeAction's result in local-execution.ts) into named locals, matching this file's own existing convention at every other withTimeout call site. - Replace a bare `as any` cast (with an eslint-disable to suppress the rule that would flag it) in dev-server-module-graph.test.ts with a narrower `as unknown as ViteDevServer`. - Move two same-line comments onto their own line. - Reword four regression-test comments in dev-server.integration.test.ts that narrated "before the fix, X happened" — restated as the present-tense invariant each test guards. --- .../src/vite/dev-server-module-graph.test.ts | 4 +- .../src/vite/dev-server.integration.test.ts | 34 +++++++-------- .../plugins/apps/src/vite/dev-server.test.ts | 43 ++++++++++++++++++- packages/plugins/apps/src/vite/dev-server.ts | 6 ++- .../apps/src/vite/local-execution.test.ts | 3 +- .../plugins/apps/src/vite/local-execution.ts | 11 +++-- 6 files changed, 71 insertions(+), 30 deletions(-) diff --git a/packages/plugins/apps/src/vite/dev-server-module-graph.test.ts b/packages/plugins/apps/src/vite/dev-server-module-graph.test.ts index 26526fc1c..00d2fe0d4 100644 --- a/packages/plugins/apps/src/vite/dev-server-module-graph.test.ts +++ b/packages/plugins/apps/src/vite/dev-server-module-graph.test.ts @@ -3,6 +3,7 @@ // Copyright 2019-Present Datadog, Inc. import path from 'node:path'; +import type { ViteDevServer } from 'vite'; import { LOCAL_EXECUTION_LOAD_SUFFIX } from '../constants'; @@ -26,8 +27,7 @@ function makeFakeServer(resolveId: (specifier: string) => Promise<{ id: string } pluginContainer: { resolveId: (specifier: string) => resolveId(specifier), }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any; + } as unknown as ViteDevServer; } describe('dev-server-module-graph — collectModuleGraphFromServer', () => { 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 ed7649224..ea6dc95de 100644 --- a/packages/plugins/apps/src/vite/dev-server.integration.test.ts +++ b/packages/plugins/apps/src/vite/dev-server.integration.test.ts @@ -238,15 +238,15 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { }, 30000); // Real coverage for the multi-hop case the single-hop propagation above - // still misses: viaHelper.backend.ts imports helper.ts (a plain, + // doesn't exercise: viaHelper.backend.ts imports helper.ts (a plain, // non-backend module), which itself imports plainEcho from - // getRuntimeUsers.backend.ts. resolveId only appended the suffix when - // the DIRECT importer string ended with it, so helper.ts (reached - // through a suffixed importer, but never suffixed itself, since it - // isn't a *.backend.ts file) became an unsuffixed importer for its own - // import — silently dropping the marker one hop later than the direct - // backend-to-backend case above, and swapping getRuntimeUsers.backend.ts - // for its frontend RPC-proxy stub. + // getRuntimeUsers.backend.ts. resolveId's suffix propagation must follow + // the importer chain through helper.ts (reached through a suffixed + // importer, but never itself suffixed, since it isn't a *.backend.ts + // file) — otherwise helper.ts becomes an unsuffixed importer for its own + // import one hop past the direct backend-to-backend case above, and + // getRuntimeUsers.backend.ts resolves to its frontend RPC-proxy stub + // instead of its real code. test('Should preserve real code for a *.backend.ts import reached through an intermediate non-backend module', async () => { const auth: AuthOptionsWithDefaults = { apiKey: 'test-api-key', @@ -285,11 +285,10 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { // bypasses getAllowedConnectionIds' real wiring entirely (a hardcoded // () => [] never exercises collectModuleGraphFromServer at all). Sending // the request through server.middlewares — the real Connect stack - // getVitePlugin's own configureServer hook installed when this file's - // createServer() call ran — is what actually proves the fix: before it, - // getAllowedConnectionIds threw "missing module record" for the entry - // module itself on every call, since moduleParsed (a Rollup-build-only - // hook) never fires on a real Vite dev server. + // getVitePlugin's own configureServer hook installs — is what actually + // exercises getAllowedConnectionIds resolving the entry module's own + // record without moduleParsed, a Rollup-build-only hook that never fires + // on a real Vite dev server. test('Should execute successfully through the real configureServer-installed middleware, walking a real multi-hop import graph', async () => { // Registers viaHelperFunc in the real backend-function registry — // configureServer's real middleware looks functions up there, and @@ -319,10 +318,11 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { // earlier test in this file touches: reusing nestedImportFunc or // viaHelperFunc here would already have a warm moduleGraph node (and // module-runner cache) left over from an earlier test against this same - // shared beforeAll server, masking the real gap this test guards — on a - // cold entry, Vite only ever registers the node under the fully-resolved - // (suffixed) id handleExecuteAction's own loadModule call just produced, - // and collectModuleGraphFromServer was looking it up by the bare path. + // shared beforeAll server, masking the real invariant this test guards — + // on a cold entry, Vite only ever registers the node under the + // fully-resolved (suffixed) id handleExecuteAction's own loadModule call + // produces, so collectModuleGraphFromServer must look it up by that same + // suffixed id, not the bare path. test('Should compute allowed connection IDs on the very first request for an entry, with no prior priming import', async () => { const loadModule = server.ssrLoadModule.bind(server); // collectModuleGraphFromServer now appends LOCAL_EXECUTION_LOAD_SUFFIX internally, diff --git a/packages/plugins/apps/src/vite/dev-server.test.ts b/packages/plugins/apps/src/vite/dev-server.test.ts index afc12c238..6b5cce818 100644 --- a/packages/plugins/apps/src/vite/dev-server.test.ts +++ b/packages/plugins/apps/src/vite/dev-server.test.ts @@ -185,6 +185,43 @@ describe('Dev Server Middleware', () => { nock.cleanAll(); }); + describe('startup auth warning', () => { + test('Should warn that both executeAction endpoints will be unavailable when auth is not configured', () => { + createDevServerMiddleware( + mockViteBuild, + mockLoadModule, + () => mockFunctions, + async () => [], + mockAuth, + undefined, + '/project', + mockLog, + ); + + expect(mockLogFn).toHaveBeenCalledWith( + expect.stringContaining( + 'Both the /__dd/executeAction and /__dd/executeActionViaCloud endpoints will be unavailable', + ), + 'warn', + ); + }); + + test('Should not warn when auth is configured', () => { + createDevServerMiddleware( + mockViteBuild, + mockLoadModule, + () => mockFunctions, + async () => [], + mockAuth, + getApiKeyRequest(), + '/project', + mockLog, + ); + + expect(mockLogFn).not.toHaveBeenCalledWith(expect.anything(), 'warn'); + }); + }); + describe('createDevServerMiddleware routing', () => { const middleware = createDevServerMiddleware( mockViteBuild, @@ -1067,7 +1104,8 @@ describe('Dev Server Middleware', () => { jest.useFakeTimers(); try { mockLoadModule.mockImplementation( - () => new Promise(() => {}), // never settles + // Never settles. + () => new Promise(() => {}), ); const req = createMockRequest('/__dd/executeAction', { @@ -1107,7 +1145,8 @@ describe('Dev Server Middleware', () => { mockViteBuild, mockLoadModule, () => mockFunctions, - () => new Promise(() => {}), // never settles + // Never settles. + () => new Promise(() => {}), mockAuth, getApiKeyRequest(), '/project', diff --git a/packages/plugins/apps/src/vite/dev-server.ts b/packages/plugins/apps/src/vite/dev-server.ts index 263cee24c..c4a2f8024 100644 --- a/packages/plugins/apps/src/vite/dev-server.ts +++ b/packages/plugins/apps/src/vite/dev-server.ts @@ -428,18 +428,20 @@ async function handleExecuteAction( // prior execution happened to leave behind, instead of throwing the // same way it does inside executeScriptLocally. const entrySpecifier = func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX; + const primingLoadPromise = loadCustomerModuleEntry(loadModule, entrySpecifier); const primedModule = await withTimeout( - loadCustomerModuleEntry(loadModule, entrySpecifier), + primingLoadPromise, DEFAULT_TIMEOUT_MS, `Loading "${displayName}"`, ); // Same reasoning as the priming load above: this reads every reachable module from disk and // transforms it, with no bound of its own — a stalled file read or a hung esbuild.transform // call would otherwise wedge this request forever. + const allowedConnectionIdsPromise = getAllowedConnectionIds(func.absolutePath); const funcWithConnectionIds: BackendFunction = { ...func, allowedConnectionIds: await withTimeout( - getAllowedConnectionIds(func.absolutePath), + allowedConnectionIdsPromise, DEFAULT_TIMEOUT_MS, `Resolving allowed connections for "${displayName}"`, ), diff --git a/packages/plugins/apps/src/vite/local-execution.test.ts b/packages/plugins/apps/src/vite/local-execution.test.ts index 0515b2959..14342ebd3 100644 --- a/packages/plugins/apps/src/vite/local-execution.test.ts +++ b/packages/plugins/apps/src/vite/local-execution.test.ts @@ -467,7 +467,8 @@ describe('local-execution — executeScriptLocally', () => { }), }), mockLogger, - 50, // shorter than slowExecuteAction's own 80ms + // Shorter than slowExecuteAction's own 80ms. + 50, ); expect(result).toEqual({ data: { ok: true } }); diff --git a/packages/plugins/apps/src/vite/local-execution.ts b/packages/plugins/apps/src/vite/local-execution.ts index 33abd65e6..793cd8705 100644 --- a/packages/plugins/apps/src/vite/local-execution.ts +++ b/packages/plugins/apps/src/vite/local-execution.ts @@ -506,18 +506,17 @@ async function runScriptLocally( const guardedExecuteAction: ExecuteAction = async (fqn, inputs, connectionId) => { if (!scope.isCurrent()) { // A concluded scope stays concluded forever, not just "not the latest" — the wording stays conclusion-neutral rather than claiming a timeout that may not have happened. - return Promise.reject( - new Error( - `Execution of "${func.name}" already concluded; refusing to run ` + - `"${fqn}" as this stale execution to avoid using a newer execution's identity.`, - ), + throw new Error( + `Execution of "${func.name}" already concluded; refusing to run ` + + `"${fqn}" as this stale execution to avoid using a newer execution's identity.`, ); } pendingActionCalls += 1; clearTimeout(timer); try { + const actionCallPromise = executeAction(fqn, inputs, connectionId); return await withTimeout( - executeAction(fqn, inputs, connectionId), + actionCallPromise, MAX_ACTION_CALL_TIMEOUT_MS, `$.Actions call to "${fqn}"`, ); From a6975dbc94bfb77287cbfd5124e3e3fe372913f8 Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Fri, 28 Aug 2026 04:01:11 -0400 Subject: [PATCH 28/30] style(apps): tighten comment prose in dev-server.ts and local-execution.ts Multi-paragraph and multi-clause comments compressed to one dense sentence, or two-to-three for a genuinely complex invariant, keeping every distinct WHY-reason without restating what the code already shows. --- .../src/backend/ast-parsing/module-graph.ts | 2 +- .../apps/src/vite/dev-server-module-graph.ts | 59 +++---- .../src/vite/dev-server.integration.test.ts | 160 +++++++----------- .../plugins/apps/src/vite/dev-server.test.ts | 63 +++---- packages/plugins/apps/src/vite/dev-server.ts | 79 +++------ packages/plugins/apps/src/vite/index.test.ts | 9 +- packages/plugins/apps/src/vite/index.ts | 84 ++++----- .../apps/src/vite/local-execution.test.ts | 26 ++- .../plugins/apps/src/vite/local-execution.ts | 23 +-- 9 files changed, 186 insertions(+), 319 deletions(-) diff --git a/packages/plugins/apps/src/backend/ast-parsing/module-graph.ts b/packages/plugins/apps/src/backend/ast-parsing/module-graph.ts index b3621c8b2..36c989f14 100644 --- a/packages/plugins/apps/src/backend/ast-parsing/module-graph.ts +++ b/packages/plugins/apps/src/backend/ast-parsing/module-graph.ts @@ -190,7 +190,7 @@ function collectStaticModuleDependencies( })); } -// Exported so callers that must derive dependency ids by resolving each static specifier individually (the dev server has no build-time Rollup ModuleInfo to read them from) extract the exact same specifier list this module zips them against — not a second, independently-written AST walk that could drift from this one. +// Exported so callers without build-time Rollup ModuleInfo (the dev server) can resolve each static specifier individually against the exact same list this module zips against, rather than a second AST walk that could drift from it. export function getStaticModuleSources(ast: Program): string[] { return ast.body.flatMap((node) => { if ( diff --git a/packages/plugins/apps/src/vite/dev-server-module-graph.ts b/packages/plugins/apps/src/vite/dev-server-module-graph.ts index 40f649bb2..56bb4f72a 100644 --- a/packages/plugins/apps/src/vite/dev-server-module-graph.ts +++ b/packages/plugins/apps/src/vite/dev-server-module-graph.ts @@ -20,35 +20,24 @@ import { LOCAL_EXECUTION_LOAD_SUFFIX } from '../constants'; /** * Builds the same `ReadonlyMap` shape - * `createBackendModuleGraphCollector`'s `moduleParsed` hook produces during a - * real Rollup build — but for the dev server, where `moduleParsed` never - * fires at all (it's a Rollup-build-only hook; Vite's dev-server plugin - * container doesn't implement it). Instead, this walks Vite's own - * `server.moduleGraph`, which the dev server already populates as a side - * effect of `ssrLoadModule`: by the time an `await server.ssrLoadModule(id)` - * call resolves, the entry's `ModuleNode.importedModules` — and every - * imported module's own `importedModules` — already reflect the full - * transitive static-import graph, recursively, with no extra ticks needed. + * `createBackendModuleGraphCollector`'s `moduleParsed` hook produces during a real Rollup + * build, but for the dev server, where that hook never fires (Vite's plugin container + * doesn't implement it) — instead walking `server.moduleGraph`, which `ssrLoadModule` + * already populates with the entry's full transitive static-import graph by the time it + * resolves. * - * Parses each module's own source read fresh from disk (via `ModuleNode.file`), - * stripped of TS/JSX syntax by `esbuild.transform` in isolation, rather than - * either of Vite's own transform results: the client transform - * (`transformResult`) doesn't run for an SSR-only load, and the SSR transform - * (`ssrTransformResult`) rewrites every `import` into a `__vite_ssr_import__(...)` - * call and resolves bare specifiers to absolute paths — neither shape - * `collectActionCatalogImports`'s plain-`ImportDeclaration` search (built for - * the untransformed syntax a real Rollup build sees) can parse. `esbuild.transform` - * in isolation only strips types/JSX; it doesn't touch import specifiers at all, - * so this reads exactly the same syntax the production build path already trusts. + * Parses each module's source fresh from disk (via `ModuleNode.file`), stripped of TS/JSX + * by `esbuild.transform` in isolation, rather than Vite's own transform results — the + * client transform doesn't run for an SSR-only load, and the SSR transform rewrites + * imports into `__vite_ssr_import__(...)` calls that `collectActionCatalogImports`'s + * plain-`ImportDeclaration` parser can't read. `esbuild.transform` alone only strips + * types/JSX, leaving import specifiers untouched, so this sees the same syntax the + * production build path already trusts. * - * Call this only after `loadModule` has resolved for `bareEntryId + - * LOCAL_EXECUTION_LOAD_SUFFIX` in the same request — the graph it reads is a - * live side effect of that call, not independently maintained state. - * `bareEntryId` is the same unsuffixed id `extractConnectionIdsFromModuleGraph` - * needs to key into the returned map below; the suffix is appended here, - * internally, rather than left to each caller to remember — Vite keys the - * node it just loaded by the full resolved id (suffix included), since it - * treats each distinct query string as a logically distinct module. + * Call only after `loadModule` has resolved for `bareEntryId + LOCAL_EXECUTION_LOAD_SUFFIX` + * in the same request — the graph is a live side effect of that call. The suffix is + * appended internally, since Vite keys the loaded node by its full resolved id, so callers + * only need to pass the bare id `extractConnectionIdsFromModuleGraph` also uses. */ export async function collectModuleGraphFromServer( server: ViteDevServer, @@ -102,16 +91,12 @@ export async function collectModuleGraphFromServer( ); } - // `node.importedModules` mixes static AND dynamic imports with no - // documented ordering guarantee, but `createParsedModuleRecord` - // zips dependency ids positionally against the AST's own static - // import/export declarations — so this list must contain ONLY - // static dependencies, in that same order. The dev server's plugin - // container doesn't populate Rollup-style `ModuleInfo.importedIds` - // outside a real build, so this resolves each of the AST's own - // static specifiers individually instead, via the same resolution - // Vite itself would use — guaranteeing a correct 1:1 correspondence - // by construction, since both sides are derived from this same ast. + // `node.importedModules` mixes static and dynamic imports with no ordering guarantee, + // but `createParsedModuleRecord` zips dependency ids positionally against the AST's + // static import/export declarations, so this list must be static-only, same order. The + // dev server has no Rollup-style `ModuleInfo.importedIds` outside a real build, so each + // static specifier is resolved individually via Vite's own resolution instead — a + // correct 1:1 correspondence by construction, since both sides derive from the same AST. const staticModuleSources = getStaticModuleSources(ast); const importerFile = node.file; const resolutions = await Promise.all( 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 ea6dc95de..0d8b8291a 100644 --- a/packages/plugins/apps/src/vite/dev-server.integration.test.ts +++ b/packages/plugins/apps/src/vite/dev-server.integration.test.ts @@ -3,26 +3,22 @@ // Copyright 2019-Present Datadog, Inc. /** - * 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, - * including resolving `@datadog/apps-backend` from the fixture's own project - * root rather than build-plugins' own dependency tree. + * Real coverage for the local-execution path's module resolution — no mocked + * `viteBuild`/`loadModule`, no hand-written stand-in module. 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 and execute a real `.backend.ts` file via the real + * `/__dd/executeAction` handler, including resolving `@datadog/apps-backend` from the + * fixture's own project root. * - * The nested-import test below registers the real `getVitePlugin()` hooks - * on this server, needed specifically to exercise `resolveId`'s - * `LOCAL_EXECUTION_LOAD_SUFFIX` propagation against Vite's own real module - * resolution, which a mocked `this.resolve()` can't reproduce. + * The nested-import test below registers the real `getVitePlugin()` hooks on this server + * to exercise `resolveId`'s `LOCAL_EXECUTION_LOAD_SUFFIX` propagation against Vite's actual + * module resolution, which a mocked `this.resolve()` can't reproduce. * - * `@datadog/apps-backend` and `@datadog/action-catalog` are both real, - * locally-resolvable fixture packages — see `packages/tests/src/_jest/ - * fixtures/node_modules/@datadog/apps-backend` and `.../@datadog/action-catalog` - * — rather than mocked modules, so the connection-ID coverage below exercises - * Vite's actual SSR transform output, not a hand-crafted AST shape a real - * transform would never produce. + * `@datadog/apps-backend` and `@datadog/action-catalog` are real, locally-resolvable + * fixture packages (not mocked), so the connection-ID coverage below exercises Vite's + * actual SSR transform output rather than a hand-crafted AST shape a real transform would + * never produce. */ import { getAuthenticatedRequest } from '@dd/apps-plugin/auth'; @@ -145,12 +141,10 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { logLevel: 'silent', server: { middlewareMode: true, hmr: false }, plugins: [appsPlugin], - // Local execution only ever goes through ssrLoadModule, never the - // client bundle — Vite's auto-crawl-and-pre-bundle step exists for - // the browser path this feature never uses, and a fixture-only - // dependency (like the fake @datadog/action-catalog package below) - // can trip it up in ways that have nothing to do with what this - // suite is actually testing. + // Local execution only ever goes through ssrLoadModule, never the client bundle, so + // Vite's auto-crawl-and-pre-bundle step (for the browser path) is disabled — a + // fixture-only dependency like the fake @datadog/action-catalog package below can + // otherwise trip it up in ways unrelated to what this suite tests. optimizeDeps: { noDiscovery: true }, }); }); @@ -197,14 +191,11 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { }); }, 30000); - // Real coverage for the resolveId propagation fix in vite/index.ts: - // nestedImport.backend.ts statically imports plainEcho from - // getRuntimeUsers.backend.ts. Without propagating - // LOCAL_EXECUTION_LOAD_SUFFIX onto that nested import, Vite would - // resolve it unsuffixed, the transform hook would replace it with the - // frontend RPC-proxy stub (calling globalThis.DD_APPS_RUNTIME, which - // doesn't exist server-side), and this would throw instead of returning - // the real value. + // Real coverage for resolveId's LOCAL_EXECUTION_LOAD_SUFFIX propagation: nestedImport.backend.ts + // statically imports plainEcho from getRuntimeUsers.backend.ts. Without propagating the suffix + // onto that nested import, Vite would resolve it unsuffixed, the transform hook would swap in + // the frontend RPC-proxy stub (calling the server-nonexistent globalThis.DD_APPS_RUNTIME), and + // this would throw instead of returning the real value. test('Should preserve real code for a nested *.backend.ts import, not swap it for the frontend RPC-proxy stub', async () => { const auth: AuthOptionsWithDefaults = { apiKey: 'test-api-key', @@ -237,16 +228,12 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { expect(body.result).toEqual({ data: { value: 'nested-value' } }); }, 30000); - // Real coverage for the multi-hop case the single-hop propagation above - // doesn't exercise: viaHelper.backend.ts imports helper.ts (a plain, - // non-backend module), which itself imports plainEcho from - // getRuntimeUsers.backend.ts. resolveId's suffix propagation must follow - // the importer chain through helper.ts (reached through a suffixed - // importer, but never itself suffixed, since it isn't a *.backend.ts - // file) — otherwise helper.ts becomes an unsuffixed importer for its own - // import one hop past the direct backend-to-backend case above, and - // getRuntimeUsers.backend.ts resolves to its frontend RPC-proxy stub - // instead of its real code. + // Real coverage for the multi-hop case the single-hop test above doesn't exercise: + // viaHelper.backend.ts imports helper.ts (plain, non-backend), which imports plainEcho from + // getRuntimeUsers.backend.ts. Suffix propagation must follow the chain through helper.ts + // (reached via a suffixed importer, but never itself suffixed) — otherwise helper.ts becomes + // an unsuffixed importer one hop past the direct case, and getRuntimeUsers.backend.ts + // resolves to its frontend RPC-proxy stub instead of its real code. test('Should preserve real code for a *.backend.ts import reached through an intermediate non-backend module', async () => { const auth: AuthOptionsWithDefaults = { apiKey: 'test-api-key', @@ -279,21 +266,15 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { expect(body.result).toEqual({ data: { value: 'via-helper-value' } }); }, 30000); - // Regression coverage for the real configureServer-installed middleware, - // not a hand-built one: every other test in this file constructs its own - // middleware via createDevServerMiddleware(..., () => [], ...), which - // bypasses getAllowedConnectionIds' real wiring entirely (a hardcoded - // () => [] never exercises collectModuleGraphFromServer at all). Sending - // the request through server.middlewares — the real Connect stack - // getVitePlugin's own configureServer hook installs — is what actually - // exercises getAllowedConnectionIds resolving the entry module's own - // record without moduleParsed, a Rollup-build-only hook that never fires - // on a real Vite dev server. + // Regression coverage for the real configureServer-installed middleware, not the hand-built + // one every other test in this file uses via createDevServerMiddleware(..., () => [], ...), + // which bypasses getAllowedConnectionIds' real wiring entirely. Sending the request through + // server.middlewares — the real Connect stack getVitePlugin's configureServer hook installs — + // is what actually exercises getAllowedConnectionIds resolving the entry's record without + // moduleParsed, a Rollup-build-only hook that never fires on a real dev server. test('Should execute successfully through the real configureServer-installed middleware, walking a real multi-hop import graph', async () => { - // Registers viaHelperFunc in the real backend-function registry — - // configureServer's real middleware looks functions up there, and - // registration is itself a side effect of transforming the file as - // a normal (unsuffixed) frontend import, exactly like a real + // Registers viaHelperFunc in the real backend-function registry — a side effect of + // transforming the file as a normal (unsuffixed) frontend import, exactly like a real // frontend entry point importing the generated client SDK would. await server.ssrLoadModule(viaHelperFunc.absolutePath); @@ -312,21 +293,16 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { expect(body.result).toEqual({ data: { value: 'real-middleware-value' } }); }, 30000); - // Regression coverage for a real getAllowedConnectionIds wired exactly as - // vite/index.ts's configureServer builds it, on the very first request - // for an entry — no priming import beforehand. Uses noSdkFunc, which no - // earlier test in this file touches: reusing nestedImportFunc or - // viaHelperFunc here would already have a warm moduleGraph node (and - // module-runner cache) left over from an earlier test against this same - // shared beforeAll server, masking the real invariant this test guards — - // on a cold entry, Vite only ever registers the node under the - // fully-resolved (suffixed) id handleExecuteAction's own loadModule call - // produces, so collectModuleGraphFromServer must look it up by that same - // suffixed id, not the bare path. + // Regression coverage for a real getAllowedConnectionIds, wired exactly as vite/index.ts's + // configureServer builds it, on the very first request for an entry — no priming import + // beforehand. Uses noSdkFunc since any other function here would already have a warm + // moduleGraph node from an earlier test against this shared beforeAll server, masking the + // invariant under test: on a cold entry, Vite only registers the node under the fully-resolved + // (suffixed) id, so collectModuleGraphFromServer must look it up by that id, not the bare path. test('Should compute allowed connection IDs on the very first request for an entry, with no prior priming import', async () => { const loadModule = server.ssrLoadModule.bind(server); - // collectModuleGraphFromServer now appends LOCAL_EXECUTION_LOAD_SUFFIX internally, - // so this closure only ever handles the bare id — matching vite/index.ts's real wiring. + // collectModuleGraphFromServer appends LOCAL_EXECUTION_LOAD_SUFFIX internally, so this + // closure only handles the bare id — matching vite/index.ts's real wiring. const getAllowedConnectionIds = async (entryId: string) => extractConnectionIdsFromModuleGraph( entryId, @@ -365,19 +341,14 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { expect(body.result).toEqual({ data: { ok: true } }); }, 30000); - // Regression coverage for the connection-ID collector against a REAL - // Vite SSR transform, not a hand-crafted AST fixture. Vite's SSR - // transform rewrites every `import` into a `__vite_ssr_import__(...)` - // call and resolves bare specifiers to absolute paths — neither shape - // the plain-`ImportDeclaration` search in collectActionCatalogImports - // (built for the untransformed syntax a real Rollup build sees) can - // parse. Before collectModuleGraphFromServer started reading each - // module's original source fresh from disk instead, this collector - // silently returned an empty allowlist for every locally-executed - // function that imports a typed action-catalog function — rejecting any - // real connectionId-scoped call with "not in this function's allowed - // connections: []", not because of a real access violation, but because - // the collector could never see the import that should have allowed it. + // Regression coverage for the connection-ID collector against a REAL Vite SSR transform, not a + // hand-crafted AST fixture. Vite's SSR transform rewrites imports into + // `__vite_ssr_import__(...)` calls the plain-`ImportDeclaration` search in + // collectActionCatalogImports can't parse — before collectModuleGraphFromServer started + // reading each module's original source from disk instead, this silently returned an empty + // allowlist for any function importing a typed action-catalog function, rejecting real calls + // with "not in this function's allowed connections: []" for lack of visibility, not a real + // access violation. test('Should recognize a connectionId-scoped action-catalog call and allow it, not silently reject it', async () => { const loadModule = server.ssrLoadModule.bind(server); const getAllowedConnectionIds = async (entryId: string) => @@ -403,10 +374,9 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { getMockLogger(), ); - // The connection-ID collector is the thing under test here, not the - // real preview-async round trip (already covered by dev-server.test.ts - // and local-execution.test.ts) — this only needs the request to get - // past the allowedConnectionIds check, so a minimal reply is enough. + // The connection-ID collector is under test here, not the preview-async round trip + // (already covered elsewhere) — the request just needs to pass the allowedConnectionIds + // check, so a minimal reply is enough. const apiScope = nock('https://api.datadoghq.com') .post('/api/v2/app-builder/queries/preview-async') .reply(200, { data: { id: 'receipt-action-catalog' } }) @@ -429,19 +399,11 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { expect(apiScope.isDone()).toBe(true); }, 30000); - // Coverage for a module mixing a static import with a top-level dynamic - // import textually between two static ones. dev-server-module-graph.ts - // now resolves each static specifier individually against the AST - // instead of reading node.importedModules positionally (which mixes - // static and dynamic imports with no documented ordering guarantee) — - // this fixture exercises that resolution path directly. Note: verified - // against a real Vite dev server that node.importedModules for an - // SSR-loaded module doesn't actually include a non-local dynamic - // import's target at all, so the specific silent-misattribution failure - // this was meant to reproduce doesn't manifest on the old code either; - // the new resolution approach is kept regardless since it's correct by - // construction rather than relying on node.importedModules' undocumented - // behavior. + // Coverage for a module mixing a static import with a top-level dynamic import textually + // between two static ones — exercises dev-server-module-graph.ts resolving each static + // specifier individually against the AST, independent of node.importedModules's undocumented + // ordering for mixed static/dynamic imports, so the resolution stays correct by construction + // regardless of what that ordering happens to be for a given module. test('Should recognize a connectionId-scoped action-catalog call even when a top-level dynamic import sits between two static imports', async () => { const loadModule = server.ssrLoadModule.bind(server); const getAllowedConnectionIds = async (entryId: string) => diff --git a/packages/plugins/apps/src/vite/dev-server.test.ts b/packages/plugins/apps/src/vite/dev-server.test.ts index 6b5cce818..88b634c35 100644 --- a/packages/plugins/apps/src/vite/dev-server.test.ts +++ b/packages/plugins/apps/src/vite/dev-server.test.ts @@ -29,20 +29,12 @@ jest.mock('@dd/core/helpers/oauth-request', () => ({ }), })); -/** - * Shape of the `$.Actions` dynamic proxy — an arbitrarily-nested property - * path (e.g. `$.Actions.slack.chat.postMessage`) that's callable at any - * depth. Used to type `globalThis.$` in tests without an `any` cast. - */ +/** Shape of the `$.Actions` dynamic proxy — a nested property path (e.g. `$.Actions.slack.chat.postMessage`) callable at any depth; types `globalThis.$` in tests without an `any` cast. */ type ActionsProxy = { [key: string]: ActionsProxy } & ((...args: unknown[]) => Promise); const mockViteBuild = jest.fn(); -/** - * Stands in for the real `server.ssrLoadModule` — the local executeAction - * path doesn't bundle, so tests exercising it configure this directly - * instead of `mockBuildWithParsedBackend`. - */ +/** Stands in for the real `server.ssrLoadModule` — the local executeAction path doesn't bundle, so tests exercising it configure this directly instead of `mockBuildWithParsedBackend`. */ const mockLoadModule = jest.fn(); const DD_API_ORIGIN = 'https://api.datadoghq.com'; @@ -975,11 +967,9 @@ describe('Dev Server Middleware', () => { 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. + // The action's raw output ({ok, ts}) is what $.Actions.foo.bar() resolves to; the + // outer {data: ...} comes from executeScriptLocally's usual return-value wrapping, + // not 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({ @@ -1060,20 +1050,14 @@ describe('Dev Server Middleware', () => { ); }); - // The priming loadModule call (see handleExecuteAction) is the only - // place the entry's top-level code actually runs — Vite caches the - // module, so executeScriptLocally's own load below just reuses this - // same resolved object — so it must carry the same $-scoping - // guarantee executeScriptLocally's load would otherwise provide. - test('Should throw when a customer module reaches for $ during its own top-level evaluation, even though the module-graph priming load runs before executeScriptLocally installs its own scoping', async () => { + // The priming loadModule call (see handleExecuteAction) is the only place the entry's + // top-level code runs (Vite caches the module for executeScriptLocally's reuse), so it + // must carry the same $-scoping guarantee executeScriptLocally's own load would provide. + test('Should read $ as undefined when a customer module reaches for it during its own top-level evaluation, even though the module-graph priming load runs before executeScriptLocally installs its own scoping', async () => { let dollarDuringTopLevelLoad: unknown = 'not captured'; mockLoadModule.mockImplementation(async (specifier: string) => { if (specifier === mockFunctions[0].absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { - try { - dollarDuringTopLevelLoad = (globalThis as Record).$; - } catch (error) { - dollarDuringTopLevelLoad = error; - } + dollarDuringTopLevelLoad = (globalThis as Record).$; return { [mockFunctions[0].name]: () => 'done' }; } throw new Error(`Cannot find module '${specifier}'`); @@ -1089,17 +1073,12 @@ describe('Dev Server Middleware', () => { await res.done; expect(res.statusCode).toBe(200); - expect(dollarDuringTopLevelLoad).toBeInstanceOf(Error); - expect((dollarDuringTopLevelLoad as Error).message).toBe( - 'No active local execution to resolve $ under.', - ); + expect(dollarDuringTopLevelLoad).toBeUndefined(); }); - // Guards the priming loadModule call (see handleExecuteAction) — it - // evaluates the entry's real top-level code before - // executeScriptLocally's own hang-detection timeout is installed, so - // a customer module with a hanging top-level await would otherwise - // wedge this request forever with no bound at all. + // Guards the priming loadModule call (see handleExecuteAction) — it evaluates the entry's + // real top-level code before executeScriptLocally's hang-detection timeout is installed, + // so a hanging top-level await would otherwise wedge this request forever. test('Should eventually time out and return a clear error when the priming load never settles', async () => { jest.useFakeTimers(); try { @@ -1117,10 +1096,9 @@ describe('Dev Server Middleware', () => { middleware(req, res, jest.fn()); const doneAssertion = res.done; - // createMockRequest emits the body via a real process.nextTick, - // which fake timers don't advance — drain it first so the - // priming load's own setTimeout is actually scheduled before - // runAllTimersAsync tries to advance past it. + // createMockRequest emits the body via a real process.nextTick, which fake timers + // don't advance — drain it first so the priming load's setTimeout is scheduled + // before runAllTimersAsync tries to advance past it. await jest.advanceTimersByTimeAsync(0); await jest.runAllTimersAsync(); await doneAssertion; @@ -1133,10 +1111,9 @@ describe('Dev Server Middleware', () => { } }); - // Guards getAllowedConnectionIds — it reads every reachable module - // from disk and transforms it (dev-server-module-graph.ts), with no - // bound of its own, unlike the sibling priming load right next to it - // in handleExecuteAction which already has one. + // Guards getAllowedConnectionIds — it reads and transforms every reachable module from + // disk (dev-server-module-graph.ts) with no bound of its own, unlike the sibling priming + // load next to it in handleExecuteAction. test('Should eventually time out and return a clear error when getAllowedConnectionIds never settles', async () => { jest.useFakeTimers(); try { diff --git a/packages/plugins/apps/src/vite/dev-server.ts b/packages/plugins/apps/src/vite/dev-server.ts index c4a2f8024..97c971d3c 100644 --- a/packages/plugins/apps/src/vite/dev-server.ts +++ b/packages/plugins/apps/src/vite/dev-server.ts @@ -128,11 +128,10 @@ 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 `makeExecuteActionRemotely` - * below) — `submitQuery` itself doesn't care which. + * Submits a query to Datadog's app-builder `preview-async` endpoint and returns its + * receipt ID. `querySpec` is the query's own `spec` — either the `jsFunctionWithActions` + * wrapper or a single action's `{fqn, inputs}` (see `makeExecuteActionRemotely`) — + * `submitQuery` doesn't care which. */ async function submitQuery( querySpec: Record, @@ -184,11 +183,7 @@ async function submitQuery( 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. - */ +/** Executes a script via Datadog's app-builder queries API — the production round trip, wrapping the whole script as a `jsFunctionWithActions` query. */ async function executeScriptViaDatadog( scriptBody: string, func: BackendFunction, @@ -221,7 +216,7 @@ async function executeScriptViaDatadog( return outputs; } -/** Submits a single-action `preview-async` query per `$.Actions` call and logs its result/error, since production's own equivalent signal only reaches Datadog's backend, not the developer's `npm run dev` console. Callers must already have confirmed auth is configured — see `createDevServerMiddleware`'s upfront check on this route. */ +/** Submits a single-action `preview-async` query per `$.Actions` call and logs its result/error, since production's equivalent signal never reaches the `npm run dev` console. Callers must have already confirmed auth is configured (see `createDevServerMiddleware`). */ function makeExecuteActionRemotely( auth: AuthConfig, doAuthenticatedRequest: DoAuthenticatedRequest, @@ -257,17 +252,12 @@ interface PollResult { } /** - * 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. + * Long-polls Datadog API until a submitted query's execution completes or times out, + * returning the raw `outputs` value — shape varies by query type (`jsFunctionWithActions` + * wraps as `{data: }`; a single-action query's `outputs` is that action's own schema), + * so callers interpret it accordingly. The server holds each poll open ~30s and responds + * `done: false` on timeout; this loop only handles that application-level re-polling, since + * `doRequest` already retries transient HTTP failures internally. */ async function pollQueryExecution( receiptId: string, @@ -385,11 +375,9 @@ async function handleDebugBundle( } /** - * 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. Auth is required upfront (checked by the caller in - * createDevServerMiddleware before this is reached), matching production's - * own auth-before-execution ordering. + * Handles POST /__dd/executeAction — imports a backend function's real file and executes + * it in-process (see local-execution.ts), with no bundling. Auth is checked upfront by the + * caller in `createDevServerMiddleware`, matching production's auth-before-execution ordering. */ async function handleExecuteAction( req: IncomingMessage, @@ -408,25 +396,15 @@ async function handleExecuteAction( log.debug(`Executing action locally: ${displayName} with args`); - // The registry's own `func.allowedConnectionIds` is always `[]` here - // — only the bundling collector populates it, and this path - // intentionally skips bundling. Loading the entry once first lets - // the module-graph collector observe Vite's `server.moduleGraph` for - // this entry (see collectModuleGraphFromServer), so the connection-ID - // allowlist reflects the function's actual imports instead of being - // silently empty. This priming load runs before executeScriptLocally - // installs its own hang-detection timeout below, and it evaluates the - // entry's real top-level code (ssrLoadModule, not a parse-only step) - // — so it needs its own bound, or a customer module with a hanging - // top-level await would wedge this request forever. It's also the - // only place that top-level code actually runs (Vite caches the - // module, and executeScriptLocally below reuses this same resolved - // object rather than re-evaluating it), so it must go through - // loadCustomerModuleEntry's scoping rather than a raw loadModule call - // — otherwise a customer module reaching for $ during its own - // top-level evaluation would silently resolve to whatever `$` a - // prior execution happened to leave behind, instead of throwing the - // same way it does inside executeScriptLocally. + // The bundling collector is what normally populates func.allowedConnectionIds, but this + // path skips bundling — priming the entry through Vite's moduleGraph first (see + // collectModuleGraphFromServer) is what makes the allowlist reflect the function's real + // imports instead of staying empty. Runs before executeScriptLocally's hang-detection + // timeout, and evaluates the entry's real top-level code (not a parse-only step) — needs + // its own bound, or a hanging top-level await wedges the request forever. Also the only + // place top-level code runs (Vite caches the module), so it must use + // loadCustomerModuleEntry's $-scoping, not a raw loadModule call, or a customer module's + // top-level $ access would silently resolve stale instead of throwing. const entrySpecifier = func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX; const primingLoadPromise = loadCustomerModuleEntry(loadModule, entrySpecifier); const primedModule = await withTimeout( @@ -475,10 +453,9 @@ async function handleExecuteAction( } /** - * Handle POST /__dd/executeActionViaCloud — bundles a backend function and - * executes it via the existing production round trip (queue + Deno - * subprocess). Kept as a distinctly-purposed command (`npm run dev:verify`) - * for pre-publish parity checks, not a mode flag on the same endpoint. + * Handles POST /__dd/executeActionViaCloud — bundles a backend function and executes it via + * the production round trip (queue + Deno subprocess), kept as its own endpoint + * (`npm run dev:verify`) for pre-publish parity checks rather than a mode flag. */ async function handleExecuteActionViaCloud( req: IncomingMessage, @@ -569,7 +546,7 @@ export function createDevServerMiddleware( sendError(res, 500, 'Unexpected error'); }); } else if (req.url === '/__dd/executeAction') { - // Matches production, which authenticates before any backend-function code runs (app-builder-api's PreviewAsyncQueryHandler checks the user first, before the query/execution path) — checked here upfront rather than only lazily inside a $.Actions call, so a function that never calls $.Actions isn't a loophole around the same requirement. A local presence check only (not a real credential-validation network call), so it costs no latency on the fast local dev loop. + // Matches production's auth-before-execution ordering (PreviewAsyncQueryHandler checks the user first) — checked upfront here, not lazily inside a $.Actions call, so a function that never calls $.Actions isn't a loophole. Just a local presence check, not a credential-validation network call, so it adds no latency to the dev loop. if (!doAuthenticatedRequest) { sendError(res, 400, `Auth credentials not configured. ${AUTH_GUIDANCE}`); return; diff --git a/packages/plugins/apps/src/vite/index.test.ts b/packages/plugins/apps/src/vite/index.test.ts index adced4f6d..96bc0c922 100644 --- a/packages/plugins/apps/src/vite/index.test.ts +++ b/packages/plugins/apps/src/vite/index.test.ts @@ -279,11 +279,10 @@ describe('Backend Functions - getVitePlugin', () => { }); 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. + // These SDKs ship ESM-only, but Vite's dev-server SSR mode externalizes node_modules by + // default (a plain require()), which throws "Cannot use import statement outside a + // module" for them — ssr.noExternal is what server.ssrLoadModule depends on to load them + // correctly. const plugin = getVitePlugin(defaultOptions); const configHook = plugin!.config as () => { ssr: { noExternal: string[] } }; const config = configHook(); diff --git a/packages/plugins/apps/src/vite/index.ts b/packages/plugins/apps/src/vite/index.ts index 8080e131e..54a92dd5f 100644 --- a/packages/plugins/apps/src/vite/index.ts +++ b/packages/plugins/apps/src/vite/index.ts @@ -124,26 +124,19 @@ export const getVitePlugin = ({ const { setBackendFunctions, getBackendFunctions } = createBackendFunctionRegistry(); - // Non-backend module IDs reached transitively from a suffixed backend - // entry point. A plain helper module's own id can't carry - // LOCAL_EXECUTION_LOAD_SUFFIX (it's never ambiguous between a proxy and - // real code, so it needs no suffix), but it still needs to be recognized - // as an importer belonging to the suffixed subgraph — otherwise a - // *.backend.ts file reached through it (rather than directly from - // another *.backend.ts file) would lose the marker one hop later than a - // direct backend-to-backend import does. + // Non-backend module ids reached transitively from a suffixed backend entry point. A plain + // helper module's id never carries LOCAL_EXECUTION_LOAD_SUFFIX itself (it's never ambiguous + // between a proxy and real code), but must still be recognized as part of the suffixed + // subgraph — otherwise a *.backend.ts file reached through it would lose the marker one hop + // later than a direct backend-to-backend import does. const suffixedSubgraphImporters = new Set(); 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. + // @datadog/apps-backend and @datadog/action-catalog ship ESM-only, but server.ssrLoadModule + // externalizes node_modules by default (a plain require(), for speed), which throws + // "Cannot use import statement outside a module" for them. ssr.noExternal forces Vite's + // SSR transform pipeline to handle them instead, matching how production bundling already + // inlines every dependency. config() { return { ssr: { @@ -151,38 +144,26 @@ export const getVitePlugin = ({ }, }; }, - // Propagates the LOCAL_EXECUTION_LOAD_SUFFIX marker through the - // backend-file dependency graph: `ssrLoadModule` only tags the one - // entry module it's called with, so without this hook, a - // `.backend.ts` file statically importing another `.backend.ts` file - // would resolve that nested import unsuffixed, hitting transform's - // "not suffixed" branch below and getting replaced with the frontend - // RPC-proxy stub — breaking local execution for a multi-backend-file - // import graph. Propagates whenever the importer itself was suffixed - // OR is a previously-seen non-backend module reached from within the - // suffixed subgraph (see `suffixedSubgraphImporters` above) — this is - // local execution's own module graph either way, not a regular - // frontend import — and only appends the suffix onto another - // `.backend.ts` file (a plain helper module never hits the - // proxy-vs-real-code branching this marker exists to disambiguate, - // so it needs no suffix of its own). + // Propagates the LOCAL_EXECUTION_LOAD_SUFFIX marker through the backend-file dependency + // graph: ssrLoadModule only tags the one entry module it's called with, so without this + // hook a `.backend.ts` file importing another `.backend.ts` file would resolve that nested + // import unsuffixed and get replaced with the frontend RPC-proxy stub, breaking local + // execution for a multi-backend-file graph. Propagates whenever the importer was itself + // suffixed, or is a previously-seen non-backend module reached from within the suffixed + // subgraph (see `suffixedSubgraphImporters`) — and only appends the suffix onto another + // `.backend.ts` file, since a plain helper module never hits the proxy-vs-real-code branch + // this marker disambiguates. resolveId: { - // Must run before Vite's own built-in resolver: a plain relative - // specifier like `./other.backend` is fully resolvable by Vite's - // internal filesystem-based resolution alone, which — running at - // its default, unenforced order — would resolve and short-circuit - // the hook chain before this plugin's own resolveId ever saw it. + // Must run before Vite's built-in resolver: a plain relative specifier like + // `./other.backend` is fully resolvable by Vite's own filesystem resolution, which at + // its default order would short-circuit the hook chain before this plugin ever saw it. // `pre` guarantees this hook gets first look at every id. order: 'pre', async handler(source, importer, resolveOptions) { - // Scoped to `resolveOptions.ssr`: local execution's own - // traversal is always an SSR resolution (it runs through - // `server.ssrLoadModule`), so a helper's id recorded here - // must only count for a later SSR-context resolution too — - // otherwise the same helper subsequently reached from the - // ordinary (non-SSR) client graph would inherit the marker - // and serve real backend code to the browser instead of the - // frontend RPC-proxy stub. + // Scoped to `resolveOptions.ssr` since local execution's traversal is always an + // SSR resolution — otherwise the same helper reached later from the ordinary + // client graph would inherit the marker and serve real backend code to the + // browser instead of the frontend RPC-proxy stub. const isPartOfSuffixedSubgraph = !!importer && (importer.endsWith(LOCAL_EXECUTION_LOAD_SUFFIX) || @@ -302,14 +283,11 @@ export const getVitePlugin = ({ } const loadModule = server.ssrLoadModule.bind(server); - // Call only after `loadModule` has resolved for this same entryId (plus its - // LOCAL_EXECUTION_LOAD_SUFFIX) — the graph read below is a live side effect of - // that call, not independently maintained state (moduleParsed, the mechanism the - // production bundling path uses instead, never fires during a real Vite dev - // server — it's a Rollup-build-only hook). collectModuleGraphFromServer owns - // appending the suffix internally, so this closure only ever handles the bare - // backend-file path — the same shape extractConnectionIdsFromModuleGraph needs - // to key into the returned records map. + // Call only after `loadModule` has resolved for this same entryId — the graph read + // below is a live side effect of that call, not independently maintained state + // (moduleParsed, production's mechanism, is a Rollup-build-only hook that never + // fires on a real dev server). collectModuleGraphFromServer appends + // LOCAL_EXECUTION_LOAD_SUFFIX internally, so this closure only handles the bare path. const getAllowedConnectionIds = async (entryId: string) => extractConnectionIdsFromModuleGraph( entryId, diff --git a/packages/plugins/apps/src/vite/local-execution.test.ts b/packages/plugins/apps/src/vite/local-execution.test.ts index 14342ebd3..3e1a37690 100644 --- a/packages/plugins/apps/src/vite/local-execution.test.ts +++ b/packages/plugins/apps/src/vite/local-execution.test.ts @@ -42,11 +42,7 @@ beforeEach(() => { jest.spyOn(shared, 'isDatadogAppsBackendInstalled').mockReturnValue(false); }); -/** - * Shape of the `$.Actions` dynamic proxy — an arbitrarily-nested property - * path (e.g. `$.Actions.slack.chat.postMessage`) that's callable at any - * depth. Used to type `globalThis.$` in tests without an `any` cast. - */ +/** Shape of the `$.Actions` dynamic proxy — a nested property path (e.g. `$.Actions.slack.chat.postMessage`) callable at any depth; types `globalThis.$` in tests without an `any` cast. */ type ActionsProxy = { [key: string]: ActionsProxy } & ((...args: unknown[]) => Promise); const stubExecuteAction: ExecuteAction = async (fqn) => ({ data: null, stub: true, fqn }); @@ -448,7 +444,7 @@ describe('local-execution — executeScriptLocally', () => { ); }); - // executeAction stands in for dev-server.ts's real makeExecuteActionRemotely, whose long-poll can legitimately outlast a short hang-detection timeout — that's network wait time, not a hung customer function. + // executeAction stands in for dev-server.ts's makeExecuteActionRemotely, whose long-poll can legitimately outlast a short hang-detection timeout — that's network wait, not a hung function. test('Should not time out while a real $.Actions call is still legitimately in flight, even past the configured timeout', async () => { const slowExecuteAction: ExecuteAction = () => new Promise((resolve) => setTimeout(() => resolve({ ok: true }), 80)); @@ -474,15 +470,11 @@ describe('local-execution — executeScriptLocally', () => { expect(result).toEqual({ data: { ok: true } }); }); - // Guards against the hang-detection timer staying paused forever: without - // a bound on the $.Actions call itself, a stalled network request (no - // abort signal/deadline of its own) would wedge this execution — and, - // since local executions are serialized via `enqueue`, every request - // queued behind it — indefinitely. The absolute execution ceiling - // (MAX_TOTAL_EXECUTION_TIMEOUT_MS, 6 minutes) now always fires before - // the per-call bound (MAX_ACTION_CALL_TIMEOUT_MS, 10 minutes) could, so - // that's the mechanism actually observed here — MAX_ACTION_CALL_TIMEOUT_MS - // remains a backstop for any path the ceiling doesn't cover. + // Guards against the hang-detection timer staying paused forever: without a bound on the + // $.Actions call itself, a stalled request would wedge this execution, and every request + // serialized behind it via `enqueue`, indefinitely. The absolute execution ceiling (6 minutes) + // always fires before the per-call bound (10 minutes) could, so that's the mechanism this test + // observes — the per-call bound remains a backstop for any path the ceiling doesn't cover. test('Should eventually time out an in-flight $.Actions call that never settles, and not wedge subsequently queued executions', async () => { jest.useFakeTimers(); try { @@ -528,7 +520,9 @@ describe('local-execution — executeScriptLocally', () => { } }); - // A fire-and-forget $.Actions call (not awaited by the customer function) increments pendingActionCalls the same as an awaited one, pausing the per-call hang-detection timer for as long as that call stays in flight — up to MAX_ACTION_CALL_TIMEOUT_MS (10 minutes) if the call never settles, even though the customer function itself moved on to something else entirely. The absolute execution ceiling below must still fire well before that. + // A fire-and-forget $.Actions call pauses the per-call hang-detection timer for as long as it + // stays in flight (up to 10 minutes), even though the customer function has moved on — the + // absolute execution ceiling below must still fire well before that. test('Should eventually time out via an absolute execution ceiling, independent of any $.Actions call still in flight', async () => { jest.useFakeTimers(); try { diff --git a/packages/plugins/apps/src/vite/local-execution.ts b/packages/plugins/apps/src/vite/local-execution.ts index 793cd8705..db083ffda 100644 --- a/packages/plugins/apps/src/vite/local-execution.ts +++ b/packages/plugins/apps/src/vite/local-execution.ts @@ -112,16 +112,16 @@ function isIndexableRecord(value: unknown): value is Record { export const DEFAULT_TIMEOUT_MS = 10_000; -/** Bounds a single `$.Actions` call while it's exempt from the hang-detection timer above (see `guardedExecuteAction`). `doRequest` attaches no abort signal or deadline of its own, so an in-flight call that never settles would otherwise wedge this execution — and, since local executions are serialized, every request queued behind it — forever. Set generously past `pollQueryExecution`'s own worst-case long-poll budget (10 retries at up to ~30s each) so a legitimate slow action is never cut off. */ +/** Bounds a single `$.Actions` call while it's exempt from the hang-detection timer (see `guardedExecuteAction`) — `doRequest` has no deadline of its own, so an unsettled call would wedge this execution, and every serialized request queued behind it, forever. Set generously past `pollQueryExecution`'s worst-case long-poll budget (10 retries × ~30s) so a legitimately slow action is never cut off. */ const MAX_ACTION_CALL_TIMEOUT_MS = 10 * 60_000; -/** Absolute ceiling on one execution's total wall-clock time, independent of `pendingActionCalls`'s pause-and-extend mechanism (see `guardedExecuteAction`) — that mechanism can't distinguish a customer function genuinely awaiting a slow `$.Actions` call from one that fired a call without awaiting it and then hung on something unrelated, so an unawaited call currently masks a real hang for as long as MAX_ACTION_CALL_TIMEOUT_MS. Set just above `pollQueryExecution`'s own worst-case long-poll budget (10 retries at up to ~30s each, ~300s) so a legitimate single slow `$.Actions` call still always finishes — this bounds the masked-hang worst case to roughly 6 minutes instead of the full 10, not lower, since going lower would start killing real in-progress calls instead of just hangs. */ +/** Absolute ceiling on one execution's wall-clock time, independent of `pendingActionCalls`'s pause-and-extend mechanism (see `guardedExecuteAction`) — that mechanism can't tell a function genuinely awaiting a slow `$.Actions` call from one that fired-and-forgot a call and then hung on something else, so an unawaited call can mask a real hang for up to `MAX_ACTION_CALL_TIMEOUT_MS`. Set just above `pollQueryExecution`'s worst case (~300s) so one legitimate slow call still finishes, bounding the masked-hang case to ~6 minutes rather than the full 10 — going lower would start killing real in-progress calls instead of just hangs. */ const MAX_TOTAL_EXECUTION_TIMEOUT_MS = 6 * 60_000; /** Loads a module by specifier, resolved against the customer's own project rather than build-plugins' dependency tree — the dev server passes its Vite instance's `ssrLoadModule` here. */ export type LoadModule = (specifier: string) => Promise>; -/** Loads a customer module's entry under the same top-level-evaluation scoping `runScriptLocally`'s own load uses (see `customerModuleLoadContext`'s doc comment above) — for callers that need to trigger the entry's real top-level evaluation ahead of `executeScriptLocally` (e.g. dev-server.ts's module-graph priming load), so a customer module reaching for `$` during that load fails the same way it would inside `executeScriptLocally`, instead of silently resolving to whatever `globalDollarOutsideExecution` happens to hold. */ +/** Loads a customer module under the same top-level-evaluation `$`-scoping `runScriptLocally` uses (see `customerModuleLoadContext`) — for callers like dev-server.ts's priming load that trigger real top-level evaluation ahead of `executeScriptLocally`. */ export function loadCustomerModuleEntry( loadModule: LoadModule, entrySpecifier: string, @@ -222,7 +222,7 @@ function makeActionsProxy( }); } -/** Bounds a promise that would otherwise be able to hang forever — a registration's underlying `loadModule` call (a broken/circular module graph, not just a slow one), or a `$.Actions` call whose transport attaches no deadline of its own — so it rejects instead of leaving its caller waiting indefinitely. Doesn't cancel the underlying promise (not possible for a plain `Promise`), so it still runs its side effects late if it eventually does settle; see each call site's own doc comment for why that's harmless there. `label` is the full, already-attributed subject of the timeout message (e.g. `` `Loading ${specifier}` ``), not appended to a fixed prefix, so it reads naturally for both a load and an action call. */ +/** Bounds a promise that could otherwise hang forever — a `loadModule` call against a broken/circular graph, or a `$.Actions` call with no deadline of its own — rejecting instead of leaving the caller waiting indefinitely. Doesn't cancel the underlying promise (not possible for a plain `Promise`), so late side effects can still fire if it eventually settles; see each call site for why that's harmless there. `label` is the full, already-attributed subject of the timeout message (e.g. `` `Loading ${specifier}` ``), not a suffix on a fixed prefix, so it reads naturally for both loads and action calls. */ export function withTimeout(promise: Promise, timeoutMs: number, label: string): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => { @@ -480,16 +480,11 @@ async function runScriptLocally( // A timed-out execution is abandoned, not canceled — its fn() may keep running and must not act under a newer execution's identity. isCurrent() gates both this execution's own captured `$.Actions` closure and the shared adapters, which resolve the calling execution's dispatch from AsyncLocalStorage rather than whichever registration is currently live. const scope = executionEpoch.start(); - // `executeAction`'s own long-poll (dev-server.ts's pollQueryExecution) - // can legitimately take far longer than `timeoutMs` on its own — that's - // time spent waiting on a real network round trip, not evidence the - // customer function itself has hung. Pausing the hang-detection timer - // while at least one call is in flight, and giving it a fresh - // `timeoutMs` window once every in-flight call has settled, means a - // function that keeps making real progress via `$.Actions` is never - // penalized for it, while a function that genuinely hangs (with no - // `$.Actions` call in flight) still times out at the same `timeoutMs` - // it always did. + // `executeAction`'s long-poll (dev-server.ts's pollQueryExecution) can legitimately outlast + // `timeoutMs` — that's network wait, not a hung customer function. Pausing the hang-detection + // timer while a call is in flight, and giving a fresh `timeoutMs` window once all in-flight + // calls settle, means real `$.Actions` progress is never penalized, while a genuine hang (no + // call in flight) still times out at the usual `timeoutMs`. let timer: ReturnType | undefined; let rejectTimeout: ((error: Error) => void) | undefined; let pendingActionCalls = 0; From 52fa99395ad6b3d93aa7256d02712fbb72a581ed Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Thu, 27 Aug 2026 13:18:36 -0400 Subject: [PATCH 29/30] test(apps): confirm sync CPU-bound hang and process.exit() failure modes (Milestone 7) --- .../vite/local-execution.resilience.test.ts | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 packages/plugins/apps/src/vite/local-execution.resilience.test.ts diff --git a/packages/plugins/apps/src/vite/local-execution.resilience.test.ts b/packages/plugins/apps/src/vite/local-execution.resilience.test.ts new file mode 100644 index 000000000..314fcbaca --- /dev/null +++ b/packages/plugins/apps/src/vite/local-execution.resilience.test.ts @@ -0,0 +1,96 @@ +// 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. + +/** + * Milestone 7 (Kickoff doc): two targeted resilience checks for the + * documented, accepted v1 limitations of running backend functions + * in-process rather than in an isolated child process/thread — see the + * RFC's "Decisions and Trade-Offs" section. These don't fix anything; they + * empirically confirm the actual failure modes, which is what the milestone + * asks for before deciding whether closing them (real process/thread + * isolation) is worth pursuing. + */ + +import { mockLogger, moduleResolverFor } from '@dd/tests/_jest/helpers/mocks'; +import { spawnSync } from 'child_process'; + +import type { BackendFunction } from '../backend/types'; + +import type { ExecuteAction } from './local-execution'; +import { executeScriptLocally } from './local-execution'; + +const func: BackendFunction = { + relativePath: 'src/example', + name: 'example', + absolutePath: '/src/example.backend.ts', + allowedConnectionIds: [], +}; + +const stubExecuteAction: ExecuteAction = async (fqn) => ({ data: null, stub: true, fqn }); + +describe('local-execution resilience (Milestone 7)', () => { + // A real `while (true) {}` would hang this test (and the whole Jest + // worker) forever, since nothing — including the timeout's own + // setTimeout callback — can run while the event loop is synchronously + // blocked. A bounded, time-boxed busy-wait demonstrates the exact same + // mechanism without actually hanging: if the 20ms timeout could + // interrupt a synchronous loop, this would settle around 20ms with a + // timeout rejection; instead it can only settle once the loop itself + // finishes on its own, ~200ms later, with the loop's real result. + test('Should NOT interrupt a synchronous CPU-bound loop with the current timeout — known, accepted v1 limitation', async () => { + const start = Date.now(); + + const result = await executeScriptLocally( + func, + '/project', + [], + stubExecuteAction, + moduleResolverFor(func, { + example: () => { + const deadline = Date.now() + 200; + // eslint-disable-next-line no-empty + while (Date.now() < deadline) {} + return 'loop finished on its own'; + }, + }), + mockLogger, + 20, + ); + + const elapsedMs = Date.now() - start; + + expect(result).toEqual({ data: 'loop finished on its own' }); + expect(elapsedMs).toBeGreaterThanOrEqual(150); + }); + + // process.exit() can't be run inside this same Jest process — it would + // actually terminate the test runner. Spawning a real child process is + // the only safe way to observe what it does, and it directly tests the + // relevant claim: does try/finally around the customer's function call + // (the same shape runScriptLocally uses to run cleanup unconditionally) + // offer any protection against it? It doesn't — process.exit() is + // immediate and unconditional at the OS level, so no JS-level exception + // handling in this in-process design can intercept it. A customer + // function calling process.exit() takes the whole dev server down with + // it, not just its own execution. + test('Should confirm process.exit() inside the customer function crashes the whole process, bypassing try/finally cleanup — known, real risk, not a safely-contained failure', () => { + const script = ` + async function customerFunction() { + process.exit(7); + } + (async () => { + try { + await customerFunction(); + } finally { + console.log('CLEANUP_RAN'); + } + })(); + `; + + const result = spawnSync(process.execPath, ['-e', script]); + + expect(result.status).toBe(7); + expect(result.stdout.toString()).not.toContain('CLEANUP_RAN'); + }); +}); From 2afe0dbbc1b91b72ec30d55a75e78d520cabbcac Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Thu, 27 Aug 2026 13:18:45 -0400 Subject: [PATCH 30/30] style(apps): collapse module doc comment, drop external doc pointers --- .../apps/src/vite/local-execution.resilience.test.ts | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/packages/plugins/apps/src/vite/local-execution.resilience.test.ts b/packages/plugins/apps/src/vite/local-execution.resilience.test.ts index 314fcbaca..288267adf 100644 --- a/packages/plugins/apps/src/vite/local-execution.resilience.test.ts +++ b/packages/plugins/apps/src/vite/local-execution.resilience.test.ts @@ -2,15 +2,7 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -/** - * Milestone 7 (Kickoff doc): two targeted resilience checks for the - * documented, accepted v1 limitations of running backend functions - * in-process rather than in an isolated child process/thread — see the - * RFC's "Decisions and Trade-Offs" section. These don't fix anything; they - * empirically confirm the actual failure modes, which is what the milestone - * asks for before deciding whether closing them (real process/thread - * isolation) is worth pursuing. - */ +/** Two targeted checks that empirically confirm real failure modes of running backend functions in-process rather than in an isolated child process/thread — accepted v1 limitations, not bugs this file fixes. */ import { mockLogger, moduleResolverFor } from '@dd/tests/_jest/helpers/mocks'; import { spawnSync } from 'child_process'; @@ -88,7 +80,7 @@ describe('local-execution resilience (Milestone 7)', () => { })(); `; - const result = spawnSync(process.execPath, ['-e', script]); + const result = spawnSync(process.execPath, ['-e', script], { timeout: 5000 }); expect(result.status).toBe(7); expect(result.stdout.toString()).not.toContain('CLEANUP_RAN');