diff --git a/packages/plugins/apps/README.md b/packages/plugins/apps/README.md index a5a234b8c..1afbd0de6 100644 --- a/packages/plugins/apps/README.md +++ b/packages/plugins/apps/README.md @@ -16,6 +16,7 @@ It needs Vite 6.4 or later: on an older Vite, it stops every Vite command with a - [Development server authentication](#development-server-authentication) - [Development server requests](#development-server-requests) - [Cloud execution](#cloud-execution) +- [Local execution](#local-execution) - [Package output](#package-output) - [apps.enable](#appsenable) - [apps.include](#appsinclude) @@ -94,6 +95,19 @@ Cloud execution (`dev:verify`, `/__dd/executeActionViaCloud`) bundles a backend When Datadog rejects a bundle as longer than the `jsFunctionWithActions` `script` input accepts, the dev server answers with a 413 naming the function, its size and Datadog's answer, and warns once per function until the dev server restarts. +## Local execution + +`/__dd/executeAction` runs each backend function call the way production does: in a fresh Node process of its own. Calls run one at a time. + +- **Fresh state**: module-level variables and globals start over on every call. +- **Timeout**: a call that runs past its time budget is killed, even inside a synchronous loop. +- **Guards**: guards in the call's process refuse writes outside the call's own temp dir (`os.tmpdir()`, also its working directory) and `/tmp`, subprocesses and worker threads, from the module's top-level code on. The heap is capped at 256 MB. +- **Environment**: a call sees no variables from the dev server's environment, keys included, except Node's TLS and time zone settings (`NODE_EXTRA_CA_CERTS`, `SSL_CERT_FILE`, `SSL_CERT_DIR`, `TZ`), plus `SystemRoot` on Windows, and no Node options from `NODE_OPTIONS`. Vite still inlines `import.meta.env` and `define` values into the code it serves. `$.Actions` calls go back to the dev server, which holds the credentials and checks each call's connection against the function's allowed connections. +- **Logs**: a call's `stdout` and `stderr` reach the dev server's terminal line by line, as each line ends. +- **Debugging**: when the dev server runs under a debugger (`--inspect`), each call's process listens on a random local port and prints its `Debugger listening on ws://…` URL. Attach to that URL to debug the call; calls don't time out while the dev server is under a debugger. + +Local execution doesn't support Yarn Plug'n'Play, whose packages load through hooks a call's process doesn't get: each call fails with a hint to set `nodeLinker: node-modules` or use `dev:verify`. + ## Package output A production `vite build` writes `datadog-app-assets.zip` beside the Vite output. The ZIP contains `frontend/`, `backend/`, and `manifest.json`. The app's identity is resolved by `@datadog/apps-cli` at deploy time. diff --git a/packages/plugins/apps/package.json b/packages/plugins/apps/package.json index 8ac737202..43447ae89 100644 --- a/packages/plugins/apps/package.json +++ b/packages/plugins/apps/package.json @@ -25,6 +25,12 @@ "format": [ "esm" ] + }, + "local-execution-child": { + "entry": "./src/vite/local-execution-child.ts", + "format": [ + "esm" + ] } } }, diff --git a/packages/plugins/apps/src/backend/types.ts b/packages/plugins/apps/src/backend/types.ts index 2022e8faa..94ef1c6ac 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) paths — mirrors the app-builder query response's `{ data: }` wrapper. */ +/** Shape of a backend function's result, shared by the remote (dev-server.ts) and local (local-execution-process.ts) paths — mirrors the app-builder query response's `{ data: }` wrapper. */ export type BackendOutputs = { data: unknown }; diff --git a/packages/plugins/apps/src/vite/backend-function-packages.integration.test.ts b/packages/plugins/apps/src/vite/backend-function-packages.integration.test.ts index a947c3805..ce152e051 100644 --- a/packages/plugins/apps/src/vite/backend-function-packages.integration.test.ts +++ b/packages/plugins/apps/src/vite/backend-function-packages.integration.test.ts @@ -23,12 +23,30 @@ import { build, createServer, type ViteDevServer } from 'vite'; import { ARCHIVE_FILENAME } from '../constants'; import type { AppsManifest } from '../types'; +import { + buildLocalExecutionChild, + removeLocalExecutionChild, +} from './local-execution-process.fixtures'; + // Jest compiles loadViteParseAst's dynamic import into a `require`, which gets Vite's CJS // entry and no `parseAst`; the published build keeps the real import. jest.mock('@dd/apps-plugin/vite/vite-parse-ast', () => ({ loadViteParseAst: async () => parseAst, })); +let mockChildEntryPath = ''; +jest.mock('@dd/apps-plugin/vite/local-execution-child-path', () => ({ + getLocalExecutionChildPath: () => mockChildEntryPath, +})); + +beforeAll(async () => { + mockChildEntryPath = await buildLocalExecutionChild(); +}); + +afterAll(() => { + removeLocalExecutionChild(); +}); + const FIXTURES_DIR = path.resolve(__dirname, '../../../../tests/src/_jest/fixtures'); const PROJECT_DIR = path.join(FIXTURES_DIR, 'apps_backend_library_project'); const ACTION_CATALOG_DIR = path.join(FIXTURES_DIR, 'action_catalog_project'); @@ -71,6 +89,8 @@ async function assembleApp( await fsp.mkdir(path.dirname(linkPath), { recursive: true }); await fsp.symlink(checkout, linkPath, 'dir'); }; + // A call's process loads modules through the project's own Vite module runner. + await link(path.dirname(require.resolve('vite/package.json')), 'vite'); const linkedPlainCheckout = path.join(tempDir, 'linked-plain-lib'); await copy(path.join(PROJECT_DIR, 'packages/linked-plain-lib'), linkedPlainCheckout); await link(linkedPlainCheckout, 'linked-plain-backend-lib'); 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 81c92feef..956b4b3cb 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 @@ -46,342 +46,10 @@ function makeFakeServer( // its `importedModules`; these fixtures pre-wire the full graph instead, so this is a // no-op stand-in for that priming call. transformRequest: async () => null, - environments: { - ssr: { moduleGraph: { idToModuleMap: new Map(), getModuleById: () => undefined } }, - }, } as unknown as ViteDevServer; } -interface FakeEnvironmentNode { - id: string; - transformResult: unknown; - lastInvalidationTimestamp: number; - lastHMRTimestamp: number; -} - -const fakeEnvironmentNode = ( - id: string, - state: 'edited' | 'hmr-updated' | 'current' | 'never-loaded', -): FakeEnvironmentNode => ({ - id, - transformResult: state === 'current' ? { code: '' } : null, - lastInvalidationTimestamp: state === 'edited' ? 1 : 0, - lastHMRTimestamp: state === 'hmr-updated' ? 1 : 0, -}); - -// An SSR environment graph where priming refills a node's transformResult. -function withSsrEnvironmentGraph( - server: ViteDevServer, - nodes: FakeEnvironmentNode[], - onPrime: (id: string) => void = () => {}, -) { - const entries = nodes.map((node): [string, FakeEnvironmentNode] => [node.id, node]); - const idToModuleMap = new Map(entries); - const updateModuleTransformResult = jest.fn((node: FakeEnvironmentNode, result: unknown) => { - node.transformResult = result; - }); - const moduleGraph = { - idToModuleMap, - getModuleById: (id: string) => idToModuleMap.get(id), - updateModuleTransformResult, - }; - Object.assign(server, { - environments: { ssr: { moduleGraph } }, - transformRequest: async (id: string) => { - onPrime(id); - const node = idToModuleMap.get(id); - if (node) { - node.transformResult = { code: '' }; - } - return null; - }, - }); - return { updateModuleTransformResult }; -} - describe('dev-server-module-graph — collectModuleGraphFromServer', () => { - describe('un-caching stale modules the walk primed', () => { - const DEPENDENCY_ID = path.join(FIXTURE_ROOT, 'getRuntimeUsers.backend.ts'); - const OUTSIDE_ROOT_ID = path.join(FIXTURE_ROOT, '../shared/util.ts'); - const SDK_ID = path.join(FIXTURE_ROOT, 'node_modules/@datadog/apps-backend/index.js'); - const resolveToDependency = async () => ({ id: DEPENDENCY_ID }); - const collect = (server: ViteDevServer) => { - const log = getMockLogger(); - return collectModuleGraphFromServer(server, ENTRY_ID, FIXTURE_ROOT, log, parseAst); - }; - const entryImporting = (...dependencyIds: string[]): FakeModuleNode => { - const dependencies = dependencyIds.map((id) => ({ - id, - file: id, - importedModules: new Set(), - })); - return { - id: SUFFIXED_ENTRY_ID, - file: ENTRY_ID, - importedModules: new Set(dependencies), - }; - }; - - test('Should un-cache an edited entry the walk primed', async () => { - const server = makeFakeServer(resolveToDependency); - const entry = fakeEnvironmentNode(SUFFIXED_ENTRY_ID, 'edited'); - const { updateModuleTransformResult } = withSsrEnvironmentGraph(server, [entry]); - - await collect(server); - - expect(updateModuleTransformResult).toHaveBeenCalledWith(entry, null); - expect(entry.transformResult).toBeNull(); - }); - - test('Should un-cache an edited dependency and the entry Vite invalidated with it', async () => { - const entryNode = entryImporting(DEPENDENCY_ID); - const server = makeFakeServer(resolveToDependency, entryNode); - const entry = fakeEnvironmentNode(SUFFIXED_ENTRY_ID, 'edited'); - const dependency = fakeEnvironmentNode(DEPENDENCY_ID, 'edited'); - withSsrEnvironmentGraph(server, [entry, dependency]); - - await collect(server); - - expect(dependency.transformResult).toBeNull(); - expect(entry.transformResult).toBeNull(); - }); - - test('Should un-cache a module Vite invalidated through HMR', async () => { - const server = makeFakeServer(resolveToDependency); - const entry = fakeEnvironmentNode(SUFFIXED_ENTRY_ID, 'hmr-updated'); - withSsrEnvironmentGraph(server, [entry]); - - await collect(server); - - expect(entry.transformResult).toBeNull(); - }); - - test('Should un-cache an edited dependency outside the build root, e.g. a workspace package', async () => { - const entryNode = entryImporting(OUTSIDE_ROOT_ID); - const server = makeFakeServer(resolveToDependency, entryNode); - const entry = fakeEnvironmentNode(SUFFIXED_ENTRY_ID, 'current'); - const outsideRoot = fakeEnvironmentNode(OUTSIDE_ROOT_ID, 'edited'); - withSsrEnvironmentGraph(server, [entry, outsideRoot]); - - await collect(server); - - expect(outsideRoot.transformResult).toBeNull(); - }); - - test('Should un-cache edited app modules when the app itself lives under node_modules', async () => { - const nestedRoot = path.join(FIXTURE_ROOT, 'node_modules/nested-app'); - const nestedEntryId = path.join(nestedRoot, 'helper.ts'); - const server = makeFakeServer(resolveToDependency); - const entry = fakeEnvironmentNode( - nestedEntryId + LOCAL_EXECUTION_LOAD_SUFFIX, - 'edited', - ); - withSsrEnvironmentGraph(server, [entry]); - const log = getMockLogger(); - - await collectModuleGraphFromServer(server, nestedEntryId, nestedRoot, log, parseAst); - - expect(entry.transformResult).toBeNull(); - }); - - // A directory named like `..gen` is still inside the root, not a parent-directory path. - test('Should un-cache an edited module in a `..`-prefixed directory of an app under node_modules', async () => { - const nestedRoot = path.join(FIXTURE_ROOT, 'node_modules/nested-app'); - const dotPrefixedEntryId = path.join(nestedRoot, '..gen/helper.ts'); - const server = makeFakeServer(resolveToDependency); - const entry = fakeEnvironmentNode( - dotPrefixedEntryId + LOCAL_EXECUTION_LOAD_SUFFIX, - 'edited', - ); - withSsrEnvironmentGraph(server, [entry]); - const log = getMockLogger(); - - await collectModuleGraphFromServer( - server, - dotPrefixedEntryId, - nestedRoot, - log, - parseAst, - ); - - expect(entry.transformResult).toBeNull(); - }); - - test('Should leave a hoisted sibling package cached when the app itself lives under node_modules', async () => { - const nestedRoot = path.join(FIXTURE_ROOT, 'node_modules/nested-app'); - const nestedEntryId = path.join(nestedRoot, 'helper.ts'); - const hoistedSdkId = path.join( - FIXTURE_ROOT, - 'node_modules/@datadog/apps-backend/index.js', - ); - const nestedSuffixedEntryId = nestedEntryId + LOCAL_EXECUTION_LOAD_SUFFIX; - // Reads the real fixture source, so the walk reaches the hoisted import. - const nestedEntryNode: FakeModuleNode = { - id: nestedSuffixedEntryId, - file: ENTRY_ID, - importedModules: new Set([ - { id: hoistedSdkId, file: hoistedSdkId, importedModules: new Set() }, - ]), - }; - const server = makeFakeServer(resolveToDependency, nestedEntryNode); - Object.assign(server.moduleGraph, { - getModuleById: (id: string) => - id === nestedSuffixedEntryId ? nestedEntryNode : undefined, - }); - const entry = fakeEnvironmentNode(nestedSuffixedEntryId, 'current'); - const hoistedSdk = fakeEnvironmentNode(hoistedSdkId, 'edited'); - const { updateModuleTransformResult } = withSsrEnvironmentGraph(server, [ - entry, - hoistedSdk, - ]); - const log = getMockLogger(); - - await collectModuleGraphFromServer(server, nestedEntryId, nestedRoot, log, parseAst); - - expect(updateModuleTransformResult).not.toHaveBeenCalled(); - }); - - test('Should un-cache a dependency saved while the walk is running', async () => { - const entryNode = entryImporting(DEPENDENCY_ID); - const server = makeFakeServer(resolveToDependency, entryNode); - const entry = fakeEnvironmentNode(SUFFIXED_ENTRY_ID, 'current'); - const dependency = fakeEnvironmentNode(DEPENDENCY_ID, 'current'); - const saveDependencyWhenEntryPrimes = (id: string) => { - if (id === SUFFIXED_ENTRY_ID) { - dependency.transformResult = null; - dependency.lastInvalidationTimestamp += 1; - } - }; - withSsrEnvironmentGraph(server, [entry, dependency], saveDependencyWhenEntryPrimes); - - await collect(server); - - expect(dependency.transformResult).toBeNull(); - }); - - test("Should un-cache an edited dependency that another plugin's transform refilled before the walk primed it", async () => { - const entryNode = entryImporting(DEPENDENCY_ID); - const server = makeFakeServer(resolveToDependency, entryNode); - const entry = fakeEnvironmentNode(SUFFIXED_ENTRY_ID, 'current'); - const dependency = fakeEnvironmentNode(DEPENDENCY_ID, 'edited'); - // Like a transform hook on the entry that pre-transforms its own imports. - const transformImportsWhenEntryPrimes = (id: string) => { - if (id === SUFFIXED_ENTRY_ID) { - dependency.transformResult = { code: '' }; - } - }; - withSsrEnvironmentGraph(server, [entry, dependency], transformImportsWhenEntryPrimes); - - await collect(server); - - expect(dependency.transformResult).toBeNull(); - }); - - // Arises when an untaken dynamic import's target is edited and the importer runs again. - test('Should un-cache an edited dependency without re-running an importer that is current', async () => { - const entryNode = entryImporting(DEPENDENCY_ID); - const server = makeFakeServer(resolveToDependency, entryNode); - const entry = fakeEnvironmentNode(SUFFIXED_ENTRY_ID, 'current'); - const dependency = fakeEnvironmentNode(DEPENDENCY_ID, 'edited'); - const { updateModuleTransformResult } = withSsrEnvironmentGraph(server, [ - entry, - dependency, - ]); - - await collect(server); - - expect(dependency.transformResult).toBeNull(); - expect(entry.transformResult).not.toBeNull(); - expect(updateModuleTransformResult).not.toHaveBeenCalledWith(entry, null); - }); - - // Vite invalidated it, so the runner would re-run it without the walk, and it isn't an SDK. - test('Should un-cache an invalidated virtual module the walk primed', async () => { - const virtualModuleId = '\0virtual:dd-generated-config'; - const entryNode: FakeModuleNode = { - id: SUFFIXED_ENTRY_ID, - file: ENTRY_ID, - importedModules: new Set([{ id: virtualModuleId, importedModules: new Set() }]), - }; - const server = makeFakeServer(resolveToDependency, entryNode); - const entry = fakeEnvironmentNode(SUFFIXED_ENTRY_ID, 'current'); - const virtualModule = fakeEnvironmentNode(virtualModuleId, 'edited'); - withSsrEnvironmentGraph(server, [entry, virtualModule]); - - await collect(server); - - expect(virtualModule.transformResult).toBeNull(); - }); - - test('Should leave a stale module the walk never primed alone', async () => { - const server = makeFakeServer(resolveToDependency); - const entry = fakeEnvironmentNode(SUFFIXED_ENTRY_ID, 'current'); - const unrelated = fakeEnvironmentNode(OUTSIDE_ROOT_ID, 'edited'); - const { updateModuleTransformResult } = withSsrEnvironmentGraph(server, [ - entry, - unrelated, - ]); - - await collect(server); - - expect(updateModuleTransformResult).not.toHaveBeenCalled(); - }); - - test('Should leave modules that were already current cached', async () => { - const entryNode = entryImporting(DEPENDENCY_ID); - const server = makeFakeServer(resolveToDependency, entryNode); - const entry = fakeEnvironmentNode(SUFFIXED_ENTRY_ID, 'current'); - const dependency = fakeEnvironmentNode(DEPENDENCY_ID, 'current'); - const { updateModuleTransformResult } = withSsrEnvironmentGraph(server, [ - entry, - dependency, - ]); - - await collect(server); - - expect(updateModuleTransformResult).not.toHaveBeenCalled(); - }); - - test('Should leave a module Vite never invalidated cached, e.g. an untaken dynamic import', async () => { - const entryNode = entryImporting(DEPENDENCY_ID); - const server = makeFakeServer(resolveToDependency, entryNode); - const entry = fakeEnvironmentNode(SUFFIXED_ENTRY_ID, 'current'); - const neverLoaded = fakeEnvironmentNode(DEPENDENCY_ID, 'never-loaded'); - const { updateModuleTransformResult } = withSsrEnvironmentGraph(server, [ - entry, - neverLoaded, - ]); - - await collect(server); - - expect(updateModuleTransformResult).not.toHaveBeenCalled(); - }); - - test('Should leave an invalidated package dependency cached so an SDK keeps its setup', async () => { - const entryNode = entryImporting(SDK_ID); - const server = makeFakeServer(resolveToDependency, entryNode); - const entry = fakeEnvironmentNode(SUFFIXED_ENTRY_ID, 'current'); - const sdk = fakeEnvironmentNode(SDK_ID, 'edited'); - const { updateModuleTransformResult } = withSsrEnvironmentGraph(server, [entry, sdk]); - - await collect(server); - - expect(updateModuleTransformResult).not.toHaveBeenCalled(); - expect(sdk.transformResult).not.toBeNull(); - }); - - test('Should still un-cache an edited module when the walk fails partway', async () => { - const server = makeFakeServer(async () => null); - const entry = fakeEnvironmentNode(SUFFIXED_ENTRY_ID, 'edited'); - withSsrEnvironmentGraph(server, [entry]); - - const collecting = collect(server); - - await expect(collecting).rejects.toThrow(/unresolvable import specifier/); - expect(entry.transformResult).toBeNull(); - }); - }); - 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); 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 bf2b969bf..bc8b024b4 100644 --- a/packages/plugins/apps/src/vite/dev-server-module-graph.ts +++ b/packages/plugins/apps/src/vite/dev-server-module-graph.ts @@ -7,8 +7,7 @@ import { readFile } from '@dd/core/helpers/fs'; import type { Logger } from '@dd/core/types'; import { transform } from 'esbuild'; -import path from 'path'; -import type { EnvironmentModuleNode, ModuleNode, parseAst, ViteDevServer } from 'vite'; +import type { ModuleNode, parseAst, ViteDevServer } from 'vite'; import { createParsedModuleRecord, @@ -17,12 +16,7 @@ import { unsupportedModuleGraphDependency, } from '../backend/ast-parsing/module-graph'; import { runBackendStaticChecks } from '../backend/ast-parsing/run-backend-static-checks'; -import { - getBackendModuleOwner, - isBackendSourceModule, - isOutsideRoot, - isPackageManagerModule, -} from '../backend/backend-sources'; +import { isBackendSourceModule } from '../backend/backend-sources'; import { LOCAL_EXECUTION_LOAD_SUFFIX } from '../constants'; import { normalizeViteModuleId } from './backend-module-graph-collector'; @@ -30,8 +24,8 @@ import { normalizeViteModuleId } from './backend-module-graph-collector'; /** * Rebuilds `createBackendModuleGraphCollector`'s `ParsedModuleRecord` map for the dev server * (no `moduleParsed` hook here), re-parsing each module from disk via `esbuild.transform`. - * Primes each node via `transformRequest` (resolve + transform, never executes) before running - * static checks, so a module can't dodge them by having `ssrLoadModule` already run its code. + * Primes each node via `transformRequest` (resolve + transform, never executes), so its imports + * are known and checked before a call's process runs any of its code. */ export async function collectModuleGraphFromServer( server: ViteDevServer, @@ -41,72 +35,10 @@ export async function collectModuleGraphFromServer( parse: typeof parseAst, ): Promise> { const records = new Map(); - const staleAppModules = trackStaleAppModules(server, buildRoot); - try { - await walkModuleGraph( - server, - bareEntryId, - buildRoot, - log, - parse, - records, - staleAppModules.record, - ); - } finally { - staleAppModules.uncache(); - } + await walkModuleGraph(server, bareEntryId, buildRoot, log, parse, records); return records; } -// Priming refills an edited module's transformResult, which Vite's SSR runner reads as "cached -// evaluation still current", so each stale module the walk primes is un-cached again after it. -function trackStaleAppModules(server: ViteDevServer, buildRoot: string) { - const ssrGraph = server.environments.ssr.moduleGraph; - const isStale = (node: EnvironmentModuleNode) => { - // Untransformed and never invalidated means never run, so there's no stale evaluation. - const wasInvalidated = node.lastInvalidationTimestamp || node.lastHMRTimestamp; - if (!node.id || node.transformResult || !wasInvalidated) { - return false; - } - // Package dependencies stay cached: re-running an SDK would drop its once-per-server setup. - // Relative to the root so an app under node_modules still counts; outside it, the full path - // decides, so a hoisted sibling package isn't mistaken for app code. - const moduleId = normalizeDevServerModuleId(node.id); - const relativePath = path.relative(buildRoot, moduleId); - // Vite ids always use forward slashes; path.normalize gives the platform separator. - const pathToClassify = isOutsideRoot(relativePath) - ? path.normalize(moduleId) - : relativePath; - // A package that provides backend functions is backend code like the app's, so its edited - // modules re-run too, even when it's installed under node_modules. - return ( - !isPackageManagerModule(pathToClassify) || - getBackendModuleOwner(moduleId, buildRoot).kind === 'backend-package' - ); - }; - // Scanned upfront too: another plugin's transform hook can pre-transform its imports and - // refill a stale module before the walk reaches it. - const staleBeforeWalk = new Set(); - ssrGraph.idToModuleMap.forEach((node) => { - if (isStale(node)) { - staleBeforeWalk.add(node); - } - }); - const primedStaleModules = new Set(); - return { - // Checked again right before priming, to catch a file saved mid-walk. - record: (id: string) => { - const node = ssrGraph.getModuleById(id); - if (node && (staleBeforeWalk.has(node) || isStale(node))) { - primedStaleModules.add(node); - } - }, - // Reverts only the priming: invalidateModule would also re-run importers that are current. - uncache: () => - primedStaleModules.forEach((node) => ssrGraph.updateModuleTransformResult(node, null)), - }; -} - async function walkModuleGraph( server: ViteDevServer, bareEntryId: string, @@ -114,12 +46,10 @@ async function walkModuleGraph( log: Logger, parse: typeof parseAst, records: Map, - beforePrime: (id: string) => void, ): Promise { const visited = new Set(); const pending: ModuleNode[] = []; const prime = async (id: string) => { - beforePrime(id); await server.transformRequest(id, { ssr: true }); }; 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 b2a8a8d88..ea8e57d47 100644 --- a/packages/plugins/apps/src/vite/dev-server.integration.test.ts +++ b/packages/plugins/apps/src/vite/dev-server.integration.test.ts @@ -19,7 +19,6 @@ import { EXECUTE_ACTION_ROUTE, } from '@dd/apps-plugin/vite/dev-server'; import { DEV_CLIENT_SRC, getVitePlugin, SSR_WARMUP_SETTING } from '@dd/apps-plugin/vite/index'; -import { FS_WRITE_BLOCKED_MESSAGE } from '@dd/apps-plugin/vite/network-guard'; import * as fsHelpers from '@dd/core/helpers/fs'; import { outputFileSync, readFileSync, rmSync } from '@dd/core/helpers/fs'; import type { AuthOptionsWithDefaults } from '@dd/core/types'; @@ -30,10 +29,10 @@ import { getContextMock, getMockLogger, } from '@dd/tests/_jest/helpers/mocks'; +import { execFileSync } from 'child_process'; import fs from 'fs'; import http from 'http'; import nock from 'nock'; -import os from 'os'; import path from 'path'; import { parseAst } from 'rollup/parseAst'; import { @@ -50,7 +49,11 @@ import { DEBUG_BUNDLE_PATH } from '../backend/protocol'; import type { BackendFunction } from '../backend/types'; import { DEV_VERIFY_MODE } from '../constants'; -import { makeProbeDirOutsideTmp } from './network-guard.fixtures'; +import { + buildLocalExecutionChild, + removeLocalExecutionChild, +} from './local-execution-process.fixtures'; +import { createChildProcessExecutor, resolveViteModuleRunner } from './local-execution-process'; // Jest compiles loadViteParseAst's dynamic import into a `require`, which gets Vite's CJS // entry and no `parseAst`; the published build keeps the real import. @@ -58,6 +61,19 @@ jest.mock('@dd/apps-plugin/vite/vite-parse-ast', () => ({ loadViteParseAst: async () => parseAst, })); +let mockChildEntryPath = ''; +jest.mock('@dd/apps-plugin/vite/local-execution-child-path', () => ({ + getLocalExecutionChildPath: () => mockChildEntryPath, +})); + +beforeAll(async () => { + mockChildEntryPath = await buildLocalExecutionChild(); +}); + +afterAll(() => { + removeLocalExecutionChild(); +}); + const FIXTURE_ROOT = path.resolve( __dirname, '../../../../tests/src/_jest/fixtures/apps_backend_project', @@ -159,6 +175,18 @@ function mockRuntimeContextHydration() { }); } +const makeChildExecutor = (devServer: ViteDevServer) => { + const moduleRunnerPath = resolveViteModuleRunner(FIXTURE_ROOT); + return createChildProcessExecutor({ + invokeChannel: devServer.environments.ssr.hot, + projectRoot: FIXTURE_ROOT, + childEntryPath: mockChildEntryPath, + moduleRunnerPath, + log: getMockLogger(), + writeOutput: () => {}, + }); +}; + describe('Dev Server Middleware — real end-to-end local execution', () => { let server: ViteDevServer; @@ -209,7 +237,6 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { const isHostAllowed = createViteHostCheck(server.config); const middleware = createDevServerMiddleware( build, - server.ssrLoadModule.bind(server), () => [getRuntimeUsersFunc], async () => [], auth, @@ -220,6 +247,7 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { 'development', isHostAllowed, defaultServerConditions, + makeChildExecutor(server), ); const req = createMockRequest('/__dd/executeAction', { @@ -255,7 +283,6 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { const isHostAllowed = createViteHostCheck(server.config); const middleware = createDevServerMiddleware( build, - server.ssrLoadModule.bind(server), () => [nestedImportFunc], async () => [], auth, @@ -266,6 +293,7 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { 'development', isHostAllowed, defaultServerConditions, + makeChildExecutor(server), ); const req = createMockRequest('/__dd/executeAction', { @@ -295,7 +323,6 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { const isHostAllowed = createViteHostCheck(server.config); const middleware = createDevServerMiddleware( build, - server.ssrLoadModule.bind(server), () => [viaHelperFunc], async () => [], auth, @@ -306,6 +333,7 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { 'development', isHostAllowed, defaultServerConditions, + makeChildExecutor(server), ); const req = createMockRequest('/__dd/executeAction', { @@ -347,11 +375,45 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { expect(body.result).toEqual({ data: { value: 'real-middleware-value' } }); }, 30000); + test('Should run each call through the real configureServer-installed middleware in its own fresh child process', async () => { + const countCallsFunc: BackendFunction = { + relativePath: 'childState', + name: 'countCalls', + absolutePath: path.join(FIXTURE_ROOT, 'childState.backend.ts'), + allowedConnectionIds: [], + }; + await server.ssrLoadModule(countCallsFunc.absolutePath); + + const results = []; + for (let i = 0; i < 2; i++) { + // beforeEach mocks the first call's hydration. + if (i > 0) { + mockRuntimeContextHydration(); + } + const req = createMockRequest('/__dd/executeAction', { + functionName: encodeQueryName(countCallsFunc), + args: [0], + }); + const res = createMockResponse(); + server.middlewares(req, res, jest.fn()); + // eslint-disable-next-line no-await-in-loop + await res.done; + expect(res.statusCode).toBe(200); + results.push(JSON.parse(res.getBody()).result.data); + } + + expect(results).toEqual([ + expect.objectContaining({ calls: 1, globalCalls: 1 }), + expect.objectContaining({ calls: 1, globalCalls: 1 }), + ]); + expect(results[0].pid).not.toBe(process.pid); + expect(results[0].pid).not.toBe(results[1].pid); + }, 30000); + // Uses noSdkFunc since every other function here already has a warm moduleGraph node from // an earlier test, which would mask this invariant: on a cold entry, Vite only registers // the node under its fully-resolved (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 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) => { @@ -374,7 +436,6 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { const isHostAllowed = createViteHostCheck(server.config); const middleware = createDevServerMiddleware( build, - loadModule, () => [noSdkFunc], getAllowedConnectionIds, auth, @@ -385,6 +446,7 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { 'development', isHostAllowed, defaultServerConditions, + makeChildExecutor(server), ); const req = createMockRequest('/__dd/executeAction', { @@ -406,7 +468,6 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { // collectActionCatalogImports's `ImportDeclaration` search can't parse, so the collector // must read each module's original source from disk instead of the transformed output. 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) => { const log = getMockLogger(); const moduleGraph = await collectModuleGraphFromServer( @@ -427,7 +488,6 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { const isHostAllowed = createViteHostCheck(server.config); const middleware = createDevServerMiddleware( build, - loadModule, () => [actionCatalogCallFunc], getAllowedConnectionIds, auth, @@ -438,6 +498,7 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { 'development', isHostAllowed, defaultServerConditions, + makeChildExecutor(server), ); // The connection-ID collector is under test here, not the preview-async round trip @@ -466,11 +527,9 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { }, 30000); test('Should return a real bundle with the connection IDs it uses from /__dd/debugBundle as JSON', async () => { - const loadModule = server.ssrLoadModule.bind(server); const log = getMockLogger(); const middleware = createDevServerMiddleware( build, - loadModule, () => [actionCatalogCallFunc], async () => [], { site: 'datadoghq.com' }, @@ -505,7 +564,6 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { // A dynamic import sits between two static ones, so resolution must come from the AST // itself rather than node.importedModules's undocumented ordering for mixed imports. 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) => { const log = getMockLogger(); const moduleGraph = await collectModuleGraphFromServer( @@ -526,7 +584,6 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { const isHostAllowed = createViteHostCheck(server.config); const middleware = createDevServerMiddleware( build, - loadModule, () => [mixedImportsFunc], getAllowedConnectionIds, auth, @@ -537,6 +594,7 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { 'development', isHostAllowed, defaultServerConditions, + makeChildExecutor(server), ); const apiScope = nock('https://api.datadoghq.com') @@ -601,7 +659,6 @@ describe('Dev Server Middleware — dev:verify bundles', () => { const isHostAllowed = createViteHostCheck({ server: { allowedHosts: [] }, preview: {} }); const middleware = createDevServerMiddleware( build, - async () => ({}), () => [func], async () => [], { site: 'datadoghq.com' } satisfies AuthOptionsWithDefaults, @@ -765,7 +822,6 @@ describe('Dev Server Middleware — dev:verify bundles', () => { // Uses its own dev server rooted in a temp dir, since writing files under the shared fixtures tree // would race other test files that copy that whole tree in parallel. -const FAKE_SDK_NAME = 'dd-fake-stateful-sdk'; describe('Dev client module', () => { const startDevServer = async (base: string, extraPlugins: Plugin[] = []) => { @@ -859,6 +915,10 @@ describe('Dev Server Middleware — editing files between local executions', () const seed = `apps-edit-then-execute-${process.pid}`; const root = getTempWorkingDir(seed); editRoot = root; + // A call's process loads modules through the project's own Vite module runner. + const viteDir = path.dirname(require.resolve('vite/package.json')); + fs.mkdirSync(path.join(root, 'node_modules'), { recursive: true }); + fs.symlinkSync(viteDir, path.join(root, 'node_modules', 'vite'), 'dir'); const context = getContextMock({ buildRoot: root }); const appsPlugin: Plugin = { @@ -884,8 +944,6 @@ describe('Dev Server Middleware — editing files between local executions', () }, plugins: [appsPlugin], optimizeDeps: { noDiscovery: true }, - // Bundled into the SSR graph like the real SDKs, so its evaluation can be cached or re-run. - ssr: { noExternal: [FAKE_SDK_NAME] }, }); }); @@ -1047,62 +1105,6 @@ describe('Dev Server Middleware — editing files between local executions', () result: { data: versionsInEveryBranch('v2') }, }); }, 30000); - - test('Should keep an invalidated package dependency cached, so its state survives an edit', async () => { - const sdkDir = path.join(editServer.config.root, 'node_modules', FAKE_SDK_NAME); - const sdkManifest = JSON.stringify({ - name: FAKE_SDK_NAME, - type: 'module', - main: 'index.js', - }); - const sdkManifestPath = path.join(sdkDir, 'package.json'); - const sdkEntryPath = path.join(sdkDir, 'index.js'); - outputFileSync(sdkManifestPath, sdkManifest); - outputFileSync( - sdkEntryPath, - 'let calls = 0;\nexport function bump() { calls += 1; return calls; }\n', - ); - const file = path.join(editServer.config.root, 'sdkEntry.backend.ts'); - const func = backendFunctionAt(file, 'callSdk'); - const sourceFor = (version: string) => - `import { bump } from '${FAKE_SDK_NAME}';\nexport async function callSdk() { return ['${version}', bump()]; }\n`; - const initialSource = sourceFor('v1'); - outputFileSync(file, initialSource); - await editServer.ssrLoadModule(file); - const first = await execute(func); - expect(first.body).toEqual({ success: true, result: { data: ['v1', 1] } }); - - const editedSource = sourceFor('v2'); - await editFile(file, editedSource); - // Invalidates the SDK too, as a tsconfig.json change would. - editServer.environments.ssr.moduleGraph.invalidateAll(); - mockRuntimeContextHydration(); - const second = await execute(func); - - expect(second.body).toEqual({ success: true, result: { data: ['v2', 2] } }); - }, 30000); - - test('Should keep module state across unchanged executions after an edit', async () => { - const file = path.join(editServer.config.root, 'countingEntry.backend.ts'); - const func = backendFunctionAt(file, 'countCalls'); - const sourceFor = (start: number) => - `let calls = ${start};\nexport async function countCalls() { calls += 1; return calls; }\n`; - const initialSource = sourceFor(0); - outputFileSync(file, initialSource); - await editServer.ssrLoadModule(file); - const countCalls = async () => { - mockRuntimeContextHydration(); - const { body } = await execute(func); - return body.result.data; - }; - await countCalls(); - - const editedStart = 100; - const editedSource = sourceFor(editedStart); - await editFile(file, editedSource); - const afterEdit = [await countCalls(), await countCalls(), await countCalls()]; - expect(afterEdit).toEqual([editedStart + 1, editedStart + 2, editedStart + 3]); - }, 30000); }); describe('Dev Server Middleware — SSR import warmup warnings', () => { @@ -1178,143 +1180,6 @@ describe('Dev Server Middleware — SSR import warmup warnings', () => { ); }); -const LAZY_PACKAGE_NAME = 'dd-fake-lazy-package'; - -// Its own server, since the exemption lives in the real configureServer hook, and the recording -// plugin below must not see other tests' modules. -describe('Dev Server Middleware — dynamic imports inside a backend function', () => { - let root = ''; - let transformCacheDir = ''; - let lazyServer: ViteDevServer; - const transformWrites: string[] = []; - - beforeAll(async () => { - process.env.DD_API_KEY = 'test-api-key'; - process.env.DD_APP_KEY = 'test-app-key'; - const tmpBase = os.tmpdir(); - const rootPrefix = path.join(tmpBase, 'dd-apps-dynamic-import-'); - // Real path, since Vite resolves symlinks (e.g. macOS's /var) and buildRoot must match. - const createdRoot = fs.mkdtempSync(rootPrefix); - root = fs.realpathSync(createdRoot); - const packageDir = path.join(root, 'node_modules', LAZY_PACKAGE_NAME); - const sourceDir = path.join(root, 'src'); - fs.mkdirSync(packageDir, { recursive: true }); - fs.mkdirSync(sourceDir, { recursive: true }); - const packageManifest = JSON.stringify({ - name: LAZY_PACKAGE_NAME, - type: 'module', - main: 'index.js', - }); - const packageManifestPath = path.join(packageDir, 'package.json'); - const packageEntryPath = path.join(packageDir, 'index.js'); - const lazyModulePath = path.join(packageDir, 'lazy-dep.js'); - fs.writeFileSync(packageManifestPath, packageManifest); - fs.writeFileSync( - packageEntryPath, - "export async function loadLazy() { return (await import('./lazy-dep.js')).value; }\n", - ); - // Its top-level write runs when the module is evaluated, which must stay blocked. - fs.writeFileSync( - lazyModulePath, - [ - "import fs from 'fs';", - "let evaluationWrite = 'allowed';", - "try { fs.writeFileSync(process.env.DD_TEST_LAZY_WRITE_PATH, 'x'); } catch (err) { evaluationWrite = err.message; }", - 'export const value = evaluationWrite;', - ].join('\n'), - ); - - // Outside the OS temp dir, so the write only succeeds if the transform runs exempt. - transformCacheDir = makeProbeDirOutsideTmp('dd-apps-transform-cache-'); - // Stands in for a project plugin that writes a cache file while transforming a module. - const cacheWritingPlugin: Plugin = { - name: 'dd-test-cache-writing-plugin', - transform(_code, id) { - if (!id.includes('lazy-dep')) { - return null; - } - const cachePath = path.join(transformCacheDir, 'transform-cache.json'); - try { - fs.writeFileSync(cachePath, '{}'); - transformWrites.push('written'); - } catch (err) { - transformWrites.push(err instanceof Error ? err.message : String(err)); - } - return null; - }, - }; - const appsPlugin: Plugin = { - name: 'dd-apps-test', - ...getVitePlugin({ - bundler: { build, defaultServerConditions }, - context: getContextMock({ buildRoot: root }), - options: { include: [], longPolling: mockLongPolling }, - }), - }; - lazyServer = await createServer({ - configFile: false, - root, - logLevel: 'silent', - server: { middlewareMode: true, hmr: false, watch: { ignored: ['**/*'] } }, - plugins: [appsPlugin, cacheWritingPlugin], - optimizeDeps: { noDiscovery: true }, - ssr: { noExternal: [LAZY_PACKAGE_NAME] }, - }); - }); - - afterAll(async () => { - delete process.env.DD_TEST_LAZY_WRITE_PATH; - await lazyServer?.close(); - if (root) { - rmSync(root); - } - if (transformCacheDir) { - rmSync(transformCacheDir); - } - }); - - afterEach(() => { - nock.cleanAll(); - }); - - test('Should let a project plugin write while Vite transforms a module the function imports dynamically, but keep its evaluation blocked', async () => { - const outsideTmpDir = makeProbeDirOutsideTmp('dd-apps-dynamic-import-write-'); - try { - const evaluationWritePath = path.join(outsideTmpDir, 'evaluation-write.txt'); - process.env.DD_TEST_LAZY_WRITE_PATH = evaluationWritePath; - const file = path.join(root, 'src', 'lazyEntry.backend.ts'); - fs.writeFileSync( - file, - `import { loadLazy } from '${LAZY_PACKAGE_NAME}';\nexport async function callLazy() { return loadLazy(); }\n`, - ); - await lazyServer.ssrLoadModule(file); - const func: BackendFunction = { - relativePath: 'src/lazyEntry', - name: 'callLazy', - absolutePath: file, - allowedConnectionIds: [], - }; - mockRuntimeContextHydration(); - const functionName = encodeQueryName(func); - const req = createMockRequest('/__dd/executeAction', { functionName, args: [] }); - const res = createMockResponse(); - - lazyServer.middlewares(req, res, jest.fn()); - await res.done; - const rawBody = res.getBody(); - const body: unknown = JSON.parse(rawBody); - - const evaluationWritten = fs.existsSync(evaluationWritePath); - - expect(body).toEqual({ success: true, result: { data: FS_WRITE_BLOCKED_MESSAGE } }); - expect(evaluationWritten).toBe(false); - expect(transformWrites).toEqual(['written']); - } finally { - rmSync(outsideTmpDir); - } - }, 30000); -}); - describe('Dev Server Middleware — request origin and host checks over real HTTP', () => { const VITE_ADDITIONAL_ALLOWED_HOSTS_ENV = '__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS'; const previewSuffix = '.preview.example'; @@ -1503,3 +1368,104 @@ describe('Dev Server Middleware — request origin and host checks over real HTT 30000, ); }); + +describe('Dev Server Middleware — calls in flight when the dev server closes or restarts', () => { + const spinForeverFunc: BackendFunction = { + relativePath: 'childState', + name: 'spinForever', + absolutePath: path.join(FIXTURE_ROOT, 'childState.backend.ts'), + allowedConnectionIds: [], + }; + const countCallsFunc: BackendFunction = { ...spinForeverFunc, name: 'countCalls' }; + // Under the call timeout, so only the server's own shutdown can end the call this fast. + const SHUTDOWN_DEADLINE_MS = 5000; + + const createAppServer = () => + createServer({ + configFile: false, + root: FIXTURE_ROOT, + logLevel: 'silent', + server: { middlewareMode: true, hmr: false }, + plugins: [ + { + name: 'dd-apps-test', + ...getVitePlugin({ + bundler: { build, defaultServerConditions }, + context: getContextMock({ buildRoot: FIXTURE_ROOT }), + options: { include: [], tags: [], longPolling: mockLongPolling }, + }), + }, + ], + optimizeDeps: { noDiscovery: true }, + }); + + const callChildPids = () => { + try { + const pgrepArgs = ['-P', String(process.pid), '-f', 'local-execution-child']; + const output = execFileSync('pgrep', pgrepArgs, { encoding: 'utf8' }); + return output.split('\n').filter(Boolean); + } catch { + return []; + } + }; + + const call = (server: ViteDevServer, func: BackendFunction, args: unknown[]) => { + mockRuntimeContextHydration(); + const req = createMockRequest('/__dd/executeAction', { + functionName: encodeQueryName(func), + args, + }); + const res = createMockResponse(); + server.middlewares(req, res, jest.fn()); + return res; + }; + + const startSpinningCall = async (server: ViteDevServer) => { + await server.ssrLoadModule(spinForeverFunc.absolutePath); + const res = call(server, spinForeverFunc, []); + while (callChildPids().length === 0) { + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => setTimeout(resolve, 20)); + } + return res; + }; + + const settlesWithin = (done: Promise, timeoutMs: number) => { + const deadline = new Promise((resolve) => setTimeout(resolve, timeoutMs, 'still running')); + const settled = done.then(() => 'settled'); + return Promise.race([settled, deadline]); + }; + + afterEach(() => { + nock.cleanAll(); + }); + + test('Should end a call still in flight when its dev server closes', async () => { + const server = await createAppServer(); + const res = await startSpinningCall(server); + + await server.close(); + + await expect(settlesWithin(res.done, SHUTDOWN_DEADLINE_MS)).resolves.toBe('settled'); + expect(res.statusCode).toBe(500); + expect(callChildPids()).toEqual([]); + }, 30000); + + test("Should end the old server's calls on restart and keep serving new ones", async () => { + const server = await createAppServer(); + try { + const res = await startSpinningCall(server); + + await server.restart(); + + await expect(settlesWithin(res.done, SHUTDOWN_DEADLINE_MS)).resolves.toBe('settled'); + expect(res.statusCode).toBe(500); + await server.ssrLoadModule(countCallsFunc.absolutePath); + const next = call(server, countCallsFunc, [0]); + await next.done; + expect(next.statusCode).toBe(200); + } finally { + await server.close(); + } + }, 30000); +}); diff --git a/packages/plugins/apps/src/vite/dev-server.test.ts b/packages/plugins/apps/src/vite/dev-server.test.ts index 00f844842..30343f0ae 100644 --- a/packages/plugins/apps/src/vite/dev-server.test.ts +++ b/packages/plugins/apps/src/vite/dev-server.test.ts @@ -2,8 +2,6 @@ // 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 { buildBackendFunctions } from '@dd/apps-plugin/vite/build-backend-functions'; import { createViteHostCheck } from '@dd/apps-plugin/vite/dev-server-request-guard'; @@ -15,7 +13,12 @@ import { getRetryDelay, oversizedBundleMessage, } from '@dd/apps-plugin/vite/dev-server'; -import { withTimeout } from '@dd/apps-plugin/vite/local-execution'; +import type { + LocalExecutionRequest, + LocalExecutor, +} from '@dd/apps-plugin/vite/local-execution-process'; +import { createChildProcessExecutor } from '@dd/apps-plugin/vite/local-execution-process'; +import { DEFAULT_TIMEOUT_MS } from '@dd/apps-plugin/vite/local-execution'; import { rmSync } from '@dd/core/helpers/fs'; import type { AuthOptionsWithDefaults, RequestOpts } from '@dd/core/types'; import { @@ -23,32 +26,30 @@ import { createMockResponse, getMockLogger, mockLogFn, - moduleResolverFor, } from '@dd/tests/_jest/helpers/mocks'; import { emitModuleParsed } from '@dd/tests/_jest/helpers/moduleParsed'; import type { IncomingMessage } from 'http'; import nock from 'nock'; +import { fork } from 'node:child_process'; import type { InlineConfig } from 'vite'; import { defaultServerConditions } from 'vite'; import { encodeQueryName } from '../backend/encodeQueryName'; import { DEBUG_BUNDLE_PATH } from '../backend/protocol'; import type { BackendFunction } from '../backend/types'; -import { DEV_VERIFY_MODE, LOCAL_EXECUTION_LOAD_SUFFIX } from '../constants'; +import { DEV_VERIFY_MODE } from '../constants'; import type { AppsOptionsWithDefaults } from '../types'; -/** 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); - -/** Reads the `$.Actions` local-execution.ts installs onto `globalThis` — genuinely untyped, so the cast is centralized here instead of repeated at each call site. */ -function testDollarActions(): ActionsProxy { - return (globalThis as typeof globalThis & { $: { Actions: ActionsProxy } }).$.Actions; -} +// Spying on the shared core module fails once a sandbox guard makes `fork` non-configurable. +jest.mock('node:child_process', () => { + const actual = jest.requireActual('node:child_process'); + return { ...actual, fork: jest.fn(actual.fork) }; +}); 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`. */ -const mockLoadModule = jest.fn(); +/** Stands in for the child process executor, which the local executeAction path hands each call to. */ +const mockExecuteLocally = jest.fn(); const DD_API_ORIGIN = 'https://api.datadoghq.com'; @@ -192,22 +193,36 @@ describe('getRetryDelay', () => { }); }); -/** - * 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) { - const resolveModule = moduleResolverFor(func, { [func.name]: fn }); - mockLoadModule.mockImplementation(resolveModule); +/** Has `mockExecuteLocally` hydrate `$` through the request, as the real executor does, then return `fn`'s result. */ +function mockExecutorReturning(fn: (...args: unknown[]) => unknown) { + mockExecuteLocally.mockImplementation(async (request: LocalExecutionRequest) => { + await request.getRuntimeContext(); + return { data: await fn(...request.args) }; + }); } -function createDeferred() { - let resolve: () => void = () => {}; - const promise = new Promise((resolvePromise) => { - resolve = resolvePromise; +/** The real executor, for checks that end before a call's process starts; a fork fails the test. */ +function createTestExecutor(): LocalExecutor { + return createChildProcessExecutor({ + invokeChannel: { handleInvoke: async () => ({}) }, + projectRoot: '/project', + childEntryPath: '/nonexistent/local-execution-child.mjs', + moduleRunnerPath: '/nonexistent/module-runner.js', + log: mockLog, }); - return { promise, resolve }; +} + +/** Stands in for a call whose backend function makes one `$.Actions.slack.chat.postMessage` call. */ +function postMessageExecutor(connectionId: string | undefined): LocalExecutor { + return async ({ executeAction }) => { + const inputs = { text: 'hi' }; + const data = await executeAction( + 'com.datadoghq.slack.chat.postMessage', + inputs, + connectionId, + ); + return { data }; + }; } const previewRuntimeContext = { @@ -282,22 +297,21 @@ type CreateDevServerMiddlewareArgs = Parameters { return createDevServerMiddleware( overrides.viteBuild ?? mockViteBuild, - overrides.loadModule ?? mockLoadModule, overrides.getBackendFunctions ?? (() => mockFunctions), overrides.getAllowedConnectionIds ?? (async () => []), overrides.auth ?? mockAuth, @@ -310,6 +324,7 @@ function createTestMiddleware( overrides.mode ?? 'development', overrides.isHostAllowed ?? localOnlyHostCheck, overrides.serverConditions ?? defaultServerConditions, + overrides.executeLocally ?? mockExecuteLocally, ); } @@ -317,7 +332,7 @@ describe('Dev Server Middleware', () => { beforeEach(() => { jest.clearAllMocks(); mockViteBuild.mockReset(); - mockLoadModule.mockReset(); + mockExecuteLocally.mockReset(); }); afterEach(() => { @@ -370,7 +385,7 @@ describe('Dev Server Middleware', () => { test('Should handle /__dd/executeAction POST by hydrating context and running the function locally without bundling', async () => { mockRuntimeContextHydration(); - mockLoadModuleReturning(mockFunctions[0], (arg) => arg); + mockExecutorReturning((arg) => arg); const req = createMockRequest('/__dd/executeAction', { functionName: encodeQueryName(mockFunctions[0]), @@ -461,7 +476,7 @@ describe('Dev Server Middleware', () => { expect(body.success).toBe(true); expect(body.result).toEqual({ data: { result: 'via cloud' } }); expect(apiScope.isDone()).toBe(true); - expect(mockLoadModule).not.toHaveBeenCalled(); + expect(mockExecuteLocally).not.toHaveBeenCalled(); }); }); @@ -510,7 +525,7 @@ describe('Dev Server Middleware', () => { expect(body.success).toBe(false); expect(next).not.toHaveBeenCalled(); expect(mockViteBuild).not.toHaveBeenCalled(); - expect(mockLoadModule).not.toHaveBeenCalled(); + expect(mockExecuteLocally).not.toHaveBeenCalled(); const routeMessage = expect.stringContaining(route); expect(mockLogFn).toHaveBeenCalledWith(routeMessage, 'debug'); }, @@ -1476,11 +1491,12 @@ describe('Dev Server Middleware', () => { }); describe('executeAction handler (local)', () => { + const forkMock = jest.mocked(fork); beforeEach(() => { mockRuntimeContextHydration(); }); - const middleware = createTestMiddleware(); + const middleware = createTestMiddleware({ executeLocally: createTestExecutor() }); test('Should return 400 for missing functionRef', async () => { const req = createMockRequest('/__dd/executeAction', {}); @@ -1504,26 +1520,31 @@ describe('Dev Server Middleware', () => { expect(res.statusCode).toBe(404); }); - test('Should hydrate context, run the function directly in-process, and return its result without bundling', async () => { - mockLoadModuleReturning(mockFunctions[0], (arg: number) => arg * 2); - + test('Should hand the call, with its timeout and long polling, to the local executor', async () => { + const executeLocally = jest.fn().mockResolvedValue({ data: 'from child' }); + const childMiddleware = createTestMiddleware({ executeLocally }); const req = createMockRequest('/__dd/executeAction', { functionName: encodeQueryName(mockFunctions[0]), args: [21], }); const res = createMockResponse(); - middleware(req, res, jest.fn()); + childMiddleware(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(); + expect(JSON.parse(res.getBody()).result).toEqual({ data: 'from child' }); + expect(executeLocally).toHaveBeenCalledWith( + expect.objectContaining({ + func: mockFunctions[0], + args: [21], + timeoutMs: DEFAULT_TIMEOUT_MS, + longPolling: mockLongPolling, + }), + ); }); - test('Should reject a malformed hydrated identity before loading customer code without exposing the response body', async () => { + test("Should reject a malformed hydrated identity before starting a call's process without exposing the response body", async () => { nock.cleanAll(); mockRuntimeContextHydration(1, { data: { @@ -1548,14 +1569,14 @@ describe('Dev Server Middleware', () => { const body = JSON.parse(res.getBody()); expect(body.error).toContain('Source.initiator'); expect(body.error).not.toContain('must-not-appear'); - expect(mockLoadModule).not.toHaveBeenCalled(); + expect(forkMock).not.toHaveBeenCalled(); expect(mockLogFn).not.toHaveBeenCalledWith( expect.stringContaining('must-not-appear'), expect.anything(), ); }); - test('Should reject hydration outputs with no data field before loading customer code', async () => { + test("Should reject hydration outputs with no data field before starting a call's process", async () => { nock.cleanAll(); mockRuntimeContextHydration(1, {}); @@ -1573,13 +1594,14 @@ describe('Dev Server Middleware', () => { expect(body.error).toContain( 'Runtime context hydration completed without a "data" field', ); - expect(mockLoadModule).not.toHaveBeenCalled(); + expect(forkMock).not.toHaveBeenCalled(); }); test('Should bound stalled runtime-context hydration when the request ignores abort so later local executions are not wedged', async () => { jest.useFakeTimers(); try { nock.cleanAll(); + const executeLocally = createTestExecutor(); let hydrationSignal: AbortSignal | undefined; const nonAbortableRuntimeContextRequest = ( options: Omit, @@ -1591,7 +1613,6 @@ describe('Dev Server Middleware', () => { }; const hangingHydrationMiddleware = createDevServerMiddleware( mockViteBuild, - mockLoadModule, () => mockFunctions, async () => [], mockAuth, @@ -1602,6 +1623,7 @@ describe('Dev Server Middleware', () => { 'development', localOnlyHostCheck, defaultServerConditions, + executeLocally, ); const req = createMockRequest('/__dd/executeAction', { @@ -1620,14 +1642,16 @@ describe('Dev Server Middleware', () => { const body = JSON.parse(res.getBody()); expect(body.error).toContain('Runtime context hydration timed out after 60000ms'); expect(hydrationSignal?.aborted).toBe(true); - expect(mockLoadModule).not.toHaveBeenCalled(); + expect(forkMock).not.toHaveBeenCalled(); const recoveringRuntimeContextRequest = makeImmediateRuntimeContextRequest(); + // Reaching the connection lookup shows the stalled call no longer holds the queue. const recoveringMiddleware = createDevServerMiddleware( mockViteBuild, - mockLoadModule, () => mockFunctions, - async () => [], + async () => { + throw new Error('reached the connection lookup'); + }, mockAuth, recoveringRuntimeContextRequest, mockLongPolling, @@ -1636,8 +1660,8 @@ describe('Dev Server Middleware', () => { 'development', localOnlyHostCheck, defaultServerConditions, + executeLocally, ); - mockLoadModuleReturning(mockFunctions[0], () => 'recovered'); const recoveringReq = createMockRequest('/__dd/executeAction', { functionName: encodeQueryName(mockFunctions[0]), args: [], @@ -1649,10 +1673,9 @@ describe('Dev Server Middleware', () => { await jest.advanceTimersByTimeAsync(0); await recoveringDone; - expect(recoveringRes.statusCode).toBe(200); - expect(JSON.parse(recoveringRes.getBody()).result).toEqual({ - data: 'recovered', - }); + expect(recoveringRes.statusCode).toBe(500); + const recoveringBody = JSON.parse(recoveringRes.getBody()); + expect(recoveringBody.error).toContain('reached the connection lookup'); } finally { jest.useRealTimers(); } @@ -1660,7 +1683,6 @@ describe('Dev Server Middleware', () => { 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 = createTestMiddleware({ doAuthenticatedRequest: undefined }); - mockLoadModuleReturning(mockFunctions[0], () => 'pure result, no $.Actions call'); const req = createMockRequest('/__dd/executeAction', { functionName: encodeQueryName(mockFunctions[0]), @@ -1684,15 +1706,8 @@ describe('Dev Server Middleware', () => { }; const middlewareWithConnection = createTestMiddleware({ getBackendFunctions: () => [funcWithConnection, mockFunctions[1]], - getAllowedConnectionIds: async (entryId: string) => - entryId === funcWithConnection.absolutePath ? ['conn-1'] : [], + executeLocally: postMessageExecutor('conn-1'), }); - mockLoadModuleReturning(funcWithConnection, () => - testDollarActions().slack.chat.postMessage({ - inputs: { text: 'hi' }, - connectionId: 'conn-1', - }), - ); type PreviewAsyncBody = { data: { @@ -1733,9 +1748,7 @@ 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}) is what $.Actions.foo.bar() resolves to; the - // outer {data: ...} comes from executeScriptLocally's usual return-value wrapping, - // not anything action-specific. + // The action's raw output ({ok, ts}) is what $.Actions.foo.bar() resolves to. expect(body.result).toEqual({ data: { ok: true, ts: '123' } }); expect(apiScope.isDone()).toBe(true); expect(capturedBody?.data.attributes.query.properties.spec).toEqual({ @@ -1755,15 +1768,8 @@ describe('Dev Server Middleware', () => { }; const middlewareWithEmptyConnection = createTestMiddleware({ getBackendFunctions: () => [funcWithEmptyConnection, mockFunctions[1]], - getAllowedConnectionIds: async (entryId: string) => - entryId === funcWithEmptyConnection.absolutePath ? [''] : [], + executeLocally: postMessageExecutor(''), }); - mockLoadModuleReturning(funcWithEmptyConnection, () => - testDollarActions().slack.chat.postMessage({ - inputs: { text: 'hi' }, - connectionId: '', - }), - ); let capturedBody: | { @@ -1800,11 +1806,9 @@ describe('Dev Server Middleware', () => { }); test("Should log a successful $.Actions call's fqn, duration, and response size at debug level without logging the response body", async () => { - mockLoadModuleReturning(mockFunctions[0], () => - testDollarActions().slack.chat.postMessage({ - inputs: { text: 'hi' }, - }), - ); + const actionMiddleware = createTestMiddleware({ + executeLocally: postMessageExecutor(undefined), + }); const responseSentinel = 'monitor-creator@example.com'; const actionOutputs = { ok: true, creator: responseSentinel, name: 'café' }; @@ -1826,7 +1830,7 @@ describe('Dev Server Middleware', () => { }); const res = createMockResponse(); - middleware(req, res, jest.fn()); + actionMiddleware(req, res, jest.fn()); await res.done; expect(res.statusCode).toBe(200); @@ -1854,11 +1858,9 @@ describe('Dev Server Middleware', () => { }); test("Should surface a failed $.Actions call's error detail to the local console", async () => { - mockLoadModuleReturning(mockFunctions[0], () => - testDollarActions().slack.chat.postMessage({ - inputs: { text: 'hi' }, - }), - ); + const actionMiddleware = createTestMiddleware({ + executeLocally: postMessageExecutor(undefined), + }); nock(DD_API_ORIGIN) .post('/api/v2/app-builder/queries/preview-async') @@ -1874,7 +1876,7 @@ describe('Dev Server Middleware', () => { }); const res = createMockResponse(); - middleware(req, res, jest.fn()); + actionMiddleware(req, res, jest.fn()); await res.done; expect(res.statusCode).toBe(500); @@ -1888,215 +1890,14 @@ describe('Dev Server Middleware', () => { ); }); - // 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, before runScriptLocally installs its own execution-scoped $', async () => { - let dollarDuringTopLevelLoad: unknown = 'not captured'; - mockLoadModule.mockImplementation(async (specifier: string) => { - if (specifier === mockFunctions[0].absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { - dollarDuringTopLevelLoad = (globalThis as Record).$; - 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).toBeUndefined(); - }); - - // The priming load runs before runScriptLocally's own hang-detection timeout is - // installed, so a hanging top-level await would otherwise wedge this request and every - // request queued behind it forever. - test('Should eventually time out and return a clear error when the priming load never settles', async () => { - jest.useFakeTimers(); - try { - const immediateRuntimeContextRequest = makeImmediateRuntimeContextRequest(); - const hangingLoadModule = jest.fn( - // Never settles. - () => new Promise>(() => {}), - ); - const immediateHydrationMiddleware = createDevServerMiddleware( - mockViteBuild, - hangingLoadModule, - () => mockFunctions, - async () => [], - mockAuth, - immediateRuntimeContextRequest, - mockLongPolling, - '/project', - mockLog, - 'development', - localOnlyHostCheck, - defaultServerConditions, - ); - - const req = createMockRequest('/__dd/executeAction', { - functionName: encodeQueryName(mockFunctions[0]), - args: [], - }); - const res = createMockResponse(); - - immediateHydrationMiddleware(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 setTimeout is 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(); - } - }); - - // The race test's timeout leaves both deadlines room to fail with their own message. - const RACE_SIGNAL_DEADLINE_MS = 5000; - const RACE_RESPONSE_DEADLINE_MS = 4000; - // Priming evaluates real top-level customer code — if it ran outside executeColdActionLocally's - // enqueue() call, two concurrent requests for two different cold functions could evaluate - // their top-level code in genuine parallel instead of one fully finishing before the other starts. - test( - 'Should never let two concurrent requests for different cold functions race their priming loads', - async () => { - // The describe's beforeEach registers greet's hydration; this one serves compute's. - const apiScope = mockRuntimeContextHydration(); - const order: string[] = []; - const greetPrimingGate = createDeferred(); - const greetPrimingStarted = createDeferred(); - const computeQueued = createDeferred(); - // handleExecuteAction logs this and reaches enqueue() with no await in between. - const computeQueuedMessage = `Executing action locally: ${formatRef(mockFunctions[1])}`; - const queueAwareLog = getMockLogger({ - debug: (text: string) => { - if (text.startsWith(computeQueuedMessage)) { - computeQueued.resolve(); - } - }, - }); - // A cold execution submits its runtime-context query as soon as its queued turn starts, - // so a second submission means compute's turn began while greet still held the queue. - let submittedQueryCount = 0; - const countingRequest = (opts: Omit) => { - if (opts.method === 'POST') { - submittedQueryCount += 1; - } - return testAuthenticatedRequest(opts); - }; - const queueAwareMiddleware = createTestMiddleware({ - log: queueAwareLog, - doAuthenticatedRequest: countingRequest, - }); - - mockLoadModule.mockImplementation(async (specifier: string) => { - if (specifier === mockFunctions[0].absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { - order.push('greet-priming-start'); - greetPrimingStarted.resolve(); - await greetPrimingGate.promise; - order.push('greet-priming-end'); - return { [mockFunctions[0].name]: () => 'greet-done' }; - } - if (specifier === mockFunctions[1].absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { - order.push('compute-priming-start'); - order.push('compute-priming-end'); - return { [mockFunctions[1].name]: () => 'compute-done' }; - } - throw new Error(`Cannot find module '${specifier}'`); - }); - - const reqGreet = createMockRequest('/__dd/executeAction', { - functionName: encodeQueryName(mockFunctions[0]), - args: [], - }); - const resGreet = createMockResponse(); - const reqCompute = createMockRequest('/__dd/executeAction', { - functionName: encodeQueryName(mockFunctions[1]), - args: [], - }); - const resCompute = createMockResponse(); - - // Fired back-to-back, before either request's own body has even finished parsing. - const nextGreet = jest.fn(); - const nextCompute = jest.fn(); - queueAwareMiddleware(reqGreet, resGreet, nextGreet); - queueAwareMiddleware(reqCompute, resCompute, nextCompute); - - // The finally releases greet so queued work can drain before the next test. Its deadline - // doesn't throw, so a stuck response can't hide an earlier failure. - let responsesOutcome = ''; - try { - const bothSignals = Promise.all([ - greetPrimingStarted.promise, - computeQueued.promise, - ]); - await withTimeout( - bothSignals, - RACE_SIGNAL_DEADLINE_MS, - 'Waiting for greet to prime and compute to queue', - ); - // Compute starting its turn early or priming outside the queue has no event to wait - // for, so this delay only bounds how long either regression gets to show up; correct - // code passes for any delay well under the priming and test timeouts. - await new Promise((resolve) => setTimeout(resolve, 20)); - expect(submittedQueryCount).toBe(1); - expect(order).toEqual(['greet-priming-start']); - } finally { - greetPrimingGate.resolve(); - const bothResponses = Promise.all([resGreet.done, resCompute.done]); - responsesOutcome = await withTimeout( - bothResponses, - RACE_RESPONSE_DEADLINE_MS, - 'Waiting for responses', - ).then( - () => 'ended', - (error: unknown) => String(error), - ); - } - - expect(responsesOutcome).toBe('ended'); - expect(resGreet.statusCode).toBe(200); - expect(resCompute.statusCode).toBe(200); - expect(nextGreet).not.toHaveBeenCalled(); - expect(nextCompute).not.toHaveBeenCalled(); - const isComputeHydrated = apiScope.isDone(); - expect(isComputeHydrated).toBe(true); - expect(order).toEqual([ - 'greet-priming-start', - 'greet-priming-end', - 'compute-priming-start', - 'compute-priming-end', - ]); - }, - RACE_SIGNAL_DEADLINE_MS + RACE_RESPONSE_DEADLINE_MS + 5000, - ); - - // Regression test: getAllowedConnectionIds (which only resolves/transforms modules, never - // executes them) must run and reject before the priming load ever evaluates the entry's - // real top-level code — otherwise a banned import there could run before being rejected. - test('Should never prime (evaluate) the entry when getAllowedConnectionIds rejects', async () => { - const primingCalls: string[] = []; - mockLoadModule.mockImplementation(async (specifier: string) => { - primingCalls.push(specifier); - return { [mockFunctions[0].name]: () => 'done' }; - }); + // getAllowedConnectionIds only resolves and transforms modules, so it rejects a banned + // import before any of the entry's code runs. + test("Should never start a call's process when getAllowedConnectionIds rejects", async () => { const rejectingMiddleware = createTestMiddleware({ getAllowedConnectionIds: async () => { throw new Error('Importing Node built-in module "fs" is not supported'); }, + executeLocally: createTestExecutor(), }); const req = createMockRequest('/__dd/executeAction', { @@ -2109,21 +1910,21 @@ describe('Dev Server Middleware', () => { await res.done; expect(res.statusCode).toBe(500); - expect(primingCalls).toEqual([]); + expect(JSON.parse(res.getBody()).error).toContain('"fs" is not supported'); + expect(forkMock).not.toHaveBeenCalled(); }); - // 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 executeColdActionLocally. + // getAllowedConnectionIds reads and transforms every reachable module from disk + // (dev-server-module-graph.ts) with no bound of its own. test('Should eventually time out and return a clear error when getAllowedConnectionIds never settles', async () => { jest.useFakeTimers(); try { - mockLoadModuleReturning(mockFunctions[0], () => 'done'); const immediateRuntimeContextRequest = makeImmediateRuntimeContextRequest(); const hangingMiddleware = createTestMiddleware({ // Never settles. getAllowedConnectionIds: () => new Promise(() => {}), doAuthenticatedRequest: immediateRuntimeContextRequest, + executeLocally: createTestExecutor(), }); const req = createMockRequest('/__dd/executeAction', { diff --git a/packages/plugins/apps/src/vite/dev-server.ts b/packages/plugins/apps/src/vite/dev-server.ts index d43bfc787..a3a1dab38 100644 --- a/packages/plugins/apps/src/vite/dev-server.ts +++ b/packages/plugins/apps/src/vite/dev-server.ts @@ -29,8 +29,9 @@ import { createBackendStaticChecksPlugin } from './backend-static-checks-plugin' import { getBaseBackendBuildConfig, withProductionSettings } from './build-config'; import type { HostCheck } from './dev-server-request-guard'; import { checkDevServerRequest } from './dev-server-request-guard'; -import type { ExecuteAction, GetRuntimeContext, LoadModule } from './local-execution'; -import { DEFAULT_TIMEOUT_MS, executeColdActionLocally } from './local-execution'; +import type { LocalExecutor } from './local-execution-process'; +import type { ExecuteAction, GetRuntimeContext } from './local-execution'; +import { DEFAULT_TIMEOUT_MS } from './local-execution'; import { getMaxRetryDelayMs } from './retry-delay'; interface BundleResult { @@ -656,9 +657,9 @@ async function handleDebugBundle( } /** - * 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. + * Handles POST /__dd/executeAction — runs a backend function's real file with no bundling, in its + * own child process (see local-execution-process.ts). Auth is checked upfront by the caller in + * `createDevServerMiddleware`, matching production's auth-before-execution ordering. */ async function handleExecuteAction( req: IncomingMessage, @@ -667,19 +668,19 @@ async function handleExecuteAction( auth: AuthConfig, doAuthenticatedRequest: DoAuthenticatedRequest, longPolling: LongPollingConfig, - loadModule: LoadModule, getAllowedConnectionIds: (entryId: string) => Promise, - projectRoot: string, log: Logger, + executeLocally: LocalExecutor | undefined, ): Promise { try { + if (!executeLocally) { + throw new Error('Local execution is not set up on this dev server.'); + } const { func, args } = await parseAndLookupFunction(req, functionsByName); const displayName = formatRef(func); log.debug(`Executing action locally: ${displayName} with args`); - // Priming and connection-ID collection must happen inside the same serialization - // boundary as execution — see executeColdActionLocally's doc comment for why. const executeAction = makeExecuteActionRemotely( auth, doAuthenticatedRequest, @@ -692,18 +693,15 @@ async function handleExecuteAction( longPolling, log, ); - const result = await executeColdActionLocally( + const result = await executeLocally({ func, - projectRoot, args, executeAction, getRuntimeContext, - loadModule, getAllowedConnectionIds, - log, - DEFAULT_TIMEOUT_MS, + timeoutMs: DEFAULT_TIMEOUT_MS, longPolling, - ); + }); sendSuccess(res, result); } catch (error: unknown) { @@ -794,7 +792,6 @@ function buildFunctionMap(backendFunctions: BackendFunction[]): Map BackendFunction[], getAllowedConnectionIds: (entryId: string) => Promise, auth: AuthConfig, @@ -805,6 +802,7 @@ export function createDevServerMiddleware( mode: string, isHostAllowed: HostCheck, serverConditions: readonly string[], + executeLocally?: LocalExecutor, ): (req: IncomingMessage, res: ServerResponse, next: () => void) => void { const bundle = (func: BackendFunction) => bundleBackendFunction(viteBuild, serverConditions, func, projectRoot, log); @@ -839,10 +837,9 @@ export function createDevServerMiddleware( auth, authedRequest, longPolling, - loadModule, getAllowedConnectionIds, - projectRoot, log, + executeLocally, ), ); const routeHandlers: Record = { diff --git a/packages/plugins/apps/src/vite/execution-epoch.test.ts b/packages/plugins/apps/src/vite/execution-epoch.test.ts deleted file mode 100644 index 5bb180fc0..000000000 --- a/packages/plugins/apps/src/vite/execution-epoch.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -// 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 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); - }); - - test('Should make concludeIfCurrent a no-op returning false for an already-superseded scope', () => { - const guard = createEpochGuard(); - const older = guard.start(); - const newer = guard.start(); - - expect(older.concludeIfCurrent()).toBe(false); - // The newer scope must be unaffected by the older one's no-op conclude. - expect(newer.isCurrent()).toBe(true); - }); - - test('Should conclude a still-current scope, marking it no longer current', () => { - const guard = createEpochGuard(); - const scope = guard.start(); - - expect(scope.concludeIfCurrent()).toBe(true); - expect(scope.isCurrent()).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 keep independently-created guards from sharing any state', () => { - const guardA = createEpochGuard(); - const guardB = createEpochGuard(); - - const scopeA = guardA.start(); - const scopeB = guardB.start(); - - guardA.start(); - expect(scopeA.isCurrent()).toBe(false); - expect(scopeB.isCurrent()).toBe(true); - }); -}); diff --git a/packages/plugins/apps/src/vite/execution-epoch.ts b/packages/plugins/apps/src/vite/execution-epoch.ts deleted file mode 100644 index cb058117b..000000000 --- a/packages/plugins/apps/src/vite/execution-epoch.ts +++ /dev/null @@ -1,38 +0,0 @@ -// 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 `local-execution.ts`). */ -export interface EpochScope { - /** 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; -} - -export interface EpochGuard { - /** Starts a new scope, superseding whichever one was previously active. */ - start(): EpochScope; -} - -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; - }, - }; - }, - }; -} diff --git a/packages/plugins/apps/src/vite/index.test.ts b/packages/plugins/apps/src/vite/index.test.ts index 8a1be7583..8e8551635 100644 --- a/packages/plugins/apps/src/vite/index.test.ts +++ b/packages/plugins/apps/src/vite/index.test.ts @@ -54,6 +54,7 @@ import { import * as buildPackage from './build-package'; import * as devServer from './dev-server'; +import * as localExecutionProcess from './local-execution-process'; import * as networkGuard from './network-guard'; import { MIN_VITE_VERSION, unsupportedViteMessage } from './vite-version'; @@ -103,7 +104,11 @@ type DevServerMiddleware = (req: IncomingMessage, res: ServerResponse, next: () type FakeViteDevServer = { middlewares: { use: (fn: DevServerMiddleware) => void }; ssrLoadModule: (id: string) => Promise; + close?: () => Promise; config: { mode: string } & HostCheckConfig; + environments?: { + ssr: { hot: { handleInvoke: () => Promise }; config: { dev: object } }; + }; }; function fakeServerConfig(mode: string): FakeViteDevServer['config'] { @@ -121,7 +126,9 @@ function getConfigureServer( throw new Error('Expected plugin.configureServer to be the plain function-hook form'); } return function callConfigureServer(server: FakeViteDevServer): void { - Reflect.apply(configureServer, undefined, [server]); + const ssr = { hot: { handleInvoke: async () => ({}) }, config: { dev: {} } }; + const fullServer = { close: async () => {}, environments: { ssr }, ...server }; + Reflect.apply(configureServer, undefined, [fullServer]); }; } @@ -499,6 +506,8 @@ describe('Backend Functions - getVitePlugin', () => { middlewares: { use: jest.fn() }, ssrLoadModule: jest.fn(), config: fakeServerConfig('development'), + environments: { ssr: { hot: { handleInvoke: jest.fn() }, config: { dev: {} } } }, + close: jest.fn(), } as unknown as ViteDevServer; // The hooks are typed with Rollup's `this: PluginContext`, but the // plugin closures never read `this`, so a stand-in satisfies the call. @@ -528,6 +537,8 @@ describe('Backend Functions - getVitePlugin', () => { middlewares: { use: jest.fn() }, ssrLoadModule: jest.fn(), config: fakeServerConfig('development'), + environments: { ssr: { hot: { handleInvoke: jest.fn() }, config: { dev: {} } } }, + close: jest.fn(), } as unknown as ViteDevServer; plugin.configureServer(server); @@ -556,6 +567,8 @@ describe('Backend Functions - getVitePlugin', () => { middlewares: { use: jest.fn() }, ssrLoadModule: jest.fn(), config: fakeServerConfig('development'), + environments: { ssr: { hot: { handleInvoke: jest.fn() }, config: { dev: {} } } }, + close: jest.fn(), } as unknown as ViteDevServer; plugin.configureServer(server); @@ -1451,16 +1464,21 @@ describe('Backend Functions - getVitePlugin', () => { // Without auth both endpoints are off, and dev-verify sends every execution to the cloud. test.each([ - { scenario: 'no authentication', mode: 'development', withAuth: false, installs: 0 }, - { scenario: 'dev-verify mode', mode: DEV_VERIFY_MODE, withAuth: true, installs: 0 }, - { scenario: 'local execution', mode: 'development', withAuth: true, installs: 1 }, + { scenario: 'no authentication', mode: 'development', withAuth: false, runsLocally: false }, + { scenario: 'dev-verify mode', mode: DEV_VERIFY_MODE, withAuth: true, runsLocally: false }, + { scenario: 'local execution', mode: 'development', withAuth: true, runsLocally: true }, ])( - 'Should install the sandbox guards only when local execution can run ($scenario)', - ({ mode, withAuth, installs }) => { + 'Should run backend functions in child processes only when local execution can run ($scenario)', + ({ mode, withAuth, runsLocally }) => { const plugin = getVitePlugin(defaultOptions); - const installGuards = jest - .spyOn(networkGuard, 'installGuards') - .mockImplementation(() => undefined); + const installGuards = jest.spyOn(networkGuard, 'installGuards'); + const createMiddleware = jest.spyOn(devServer, 'createDevServerMiddleware'); + const createExecutor = jest.spyOn(localExecutionProcess, 'createChildProcessExecutor'); + const runnerPath = '/build/node_modules/vite/dist/node/module-runner.js'; + const resolveRunner = jest + .spyOn(localExecutionProcess, 'resolveViteModuleRunner') + .mockReturnValue(runnerPath); + const hot = { handleInvoke: jest.fn(async () => ({})) }; const configureServer = getConfigureServer(plugin); const restoreEnv = clearDatadogEnv(); if (withAuth) { @@ -1473,82 +1491,29 @@ describe('Backend Functions - getVitePlugin', () => { middlewares: { use: jest.fn() }, ssrLoadModule: jest.fn(), config: fakeServerConfig(mode), + environments: { ssr: { hot, config: { dev: {} } } }, }); - expect(installGuards).toHaveBeenCalledTimes(installs); - } finally { - restoreEnv(); - installGuards.mockRestore(); - } - }, - ); - - test.each([ - { predates: true, warns: true }, - { predates: false, warns: false }, - ])( - 'Should warn that fs-extra/graceful-fs loaded before the dev server is unguarded (predates: $predates)', - ({ predates, warns }) => { - const plugin = getVitePlugin(defaultOptions); - const installGuards = jest - .spyOn(networkGuard, 'installGuards') - .mockImplementation(() => undefined); - const gracefulFsPredatesGuards = jest - .spyOn(networkGuard, 'gracefulFsPredatesGuards') - .mockReturnValue(predates); - const configureServer = getConfigureServer(plugin); - const restoreEnv = clearDatadogEnv(); - process.env.DD_API_KEY = 'test-api-key'; - process.env.DD_APP_KEY = 'test-app-key'; - - try { - configureServer({ - middlewares: { use: jest.fn() }, - ssrLoadModule: jest.fn(), - config: fakeServerConfig('development'), - }); - const warned = mockLogFn.mock.calls.some( - ([text, level]) => - level === 'warn' && text === networkGuard.GRACEFUL_FS_UNGUARDED_WARNING, - ); - - expect(warned).toBe(warns); + const executeLocally = createMiddleware.mock.calls[0]?.at(-1); + expect(typeof executeLocally).toBe(runsLocally ? 'function' : 'undefined'); + const executorOptions = createExecutor.mock.calls.map(([options]) => ({ + invokeChannel: options.invokeChannel, + moduleRunnerPath: options.moduleRunnerPath, + })); + const expectedOptions = { invokeChannel: hot, moduleRunnerPath: runnerPath }; + expect(executorOptions).toEqual(runsLocally ? [expectedOptions] : []); + // The dev server's own fs and subprocess APIs stay unpatched: calls run elsewhere. + expect(installGuards).not.toHaveBeenCalled(); } finally { restoreEnv(); installGuards.mockRestore(); - gracefulFsPredatesGuards.mockRestore(); + createMiddleware.mockRestore(); + createExecutor.mockRestore(); + resolveRunner.mockRestore(); } }, ); - // Each local execution installs again and fails closed, so the server itself must still start. - test('Should keep the dev server starting when installing the sandbox guards throws', () => { - const plugin = getVitePlugin(defaultOptions); - const installGuards = jest.spyOn(networkGuard, 'installGuards').mockImplementation(() => { - throw new Error('Cannot redefine property: cpSync'); - }); - const use = jest.fn(); - const configureServer = getConfigureServer(plugin); - const restoreEnv = clearDatadogEnv(); - process.env.DD_API_KEY = 'test-api-key'; - process.env.DD_APP_KEY = 'test-app-key'; - - try { - expect(() => - configureServer({ - middlewares: { use }, - ssrLoadModule: jest.fn(), - config: fakeServerConfig('development'), - }), - ).not.toThrow(); - expect(installGuards).toHaveBeenCalled(); - expect(use).toHaveBeenCalledTimes(1); - } finally { - restoreEnv(); - installGuards.mockRestore(); - } - }); - test("Should hand the middleware Vite's default server conditions", () => { const middlewareSpy = jest.spyOn(devServer, 'createDevServerMiddleware'); const defaultServerConditions = ['module', 'node']; @@ -1562,7 +1527,7 @@ describe('Backend Functions - getVitePlugin', () => { configureServer({ middlewares: { use }, ssrLoadModule, config }); const [middlewareArgs] = middlewareSpy.mock.calls; - const passedConditions = middlewareArgs.at(-1); + const passedConditions = middlewareArgs.at(-2); expect(passedConditions).toBe(defaultServerConditions); }); diff --git a/packages/plugins/apps/src/vite/index.ts b/packages/plugins/apps/src/vite/index.ts index 3e4bcfbce..74c8a8e26 100644 --- a/packages/plugins/apps/src/vite/index.ts +++ b/packages/plugins/apps/src/vite/index.ts @@ -53,13 +53,10 @@ import { buildAppPackage } from './build-package'; import { collectModuleGraphFromServer } from './dev-server-module-graph'; import { createViteHostCheck } from './dev-server-request-guard'; import { createDevServerMiddleware } from './dev-server'; +import { getLocalExecutionChildPath } from './local-execution-child-path'; +import type { ChildProcessExecutor } from './local-execution-process'; +import { createChildProcessExecutor, resolveViteModuleRunner } from './local-execution-process'; import { localExecutionResolutionContext } from './local-execution'; -import { - exemptFetchFromBlockedScope, - GRACEFUL_FS_UNGUARDED_WARNING, - gracefulFsPredatesGuards, - installGuards, -} from './network-guard'; import { createParameterCollector } from './parameter-collector'; import { loadViteParseAst } from './vite-parse-ast'; import { assertSupportedVite } from './vite-version'; @@ -268,9 +265,6 @@ export const getVitePlugin = ({ serverPreTransformRequests = userConfig.server?.preTransformRequests; // A restarted dev server shares this process: read the manifests afresh. forgetBackendModuleOwners(); - // These SDKs ship ESM-only, but ssrLoadModule externalizes node_modules with a plain - // require(), which throws "Cannot use import statement outside a module"; - // ssr.noExternal forces Vite's SSR transform instead. // Only the dev server pre-bundles dependencies or loads modules through SSR; the // nested backend builds configure their own resolution (see build-config.ts). if (command !== 'serve') { @@ -403,9 +397,9 @@ export const getVitePlugin = ({ return null; } - // The other half of the scoping: the store is only populated while a local - // execution's own loadModule call is in flight (see configureServer), so an - // unrelated SSR resolution never inherits a marker from an earlier execution. + // The other half of the scoping: the store is only populated while a call's module + // runner request is in flight (see local-execution-process.ts), so an unrelated SSR + // resolution never inherits a marker from an earlier execution. const subgraphImporters = localExecutionResolutionContext.getStore(); const isPartOfSuffixedSubgraph = !!importer && @@ -494,7 +488,7 @@ export const getVitePlugin = ({ return null; } if (id.endsWith(LOCAL_EXECUTION_LOAD_SUFFIX) && transformOptions?.ssr) { - // Local execution needs the real function body, not the proxy stub below — real loads always go through ssrLoadModule, which runs in SSR, so this only fires for that legitimate path. + // Local execution needs the real function body, not the proxy stub below — a call's module runner always fetches through the SSR environment, so this only fires for that legitimate path. return null; } // Any other case (no query, a spoofed client-side import reusing the suffix, or an unrecognized query) falls through to the safe proxy-stub generation below. Strip the query first so it registers under the file's real (unsuffixed) relativePath/query-name, not a duplicate. @@ -680,36 +674,35 @@ export const getVitePlugin = ({ ); } // Without auth nothing executes, and dev-verify sends every execution to the cloud. - if (doAuthenticatedRequest && server.config.mode !== DEV_VERIFY_MODE) { - // As early as a dev server allows, so fewer fs wrappers (e.g. graceful-fs clones) - // predate it. A failure must not stop the server: each execution installs again, failing closed. - try { - installGuards(); - } catch (error) { - const reason = error instanceof Error ? error.message : String(error); - log.warn( - `Could not install the backend function sandbox guards yet: ${reason}`, - ); - } - if (gracefulFsPredatesGuards()) { - log.warn(GRACEFUL_FS_UNGUARDED_WARNING); - } + const runsLocally = doAuthenticatedRequest && server.config.mode !== DEV_VERIFY_MODE; + const childEntryPath = getLocalExecutionChildPath(); + const moduleRunnerPath = resolveViteModuleRunner(context.buildRoot); + const executeLocally: ChildProcessExecutor | undefined = runsLocally + ? createChildProcessExecutor({ + invokeChannel: server.environments.ssr.hot, + projectRoot: context.buildRoot, + childEntryPath, + moduleRunnerPath, + log, + }) + : undefined; + if (executeLocally) { + // A restart starts the new server before closing this one, so each server ends only its own calls. + const closeServer = server.close.bind(server); + server.close = async () => { + executeLocally.close(); + await closeServer(); + }; // Started here so an unusable Vite is reported at startup; each execution reuses // this load, or retries it after a failure. loadViteParseAst().catch((error: unknown) => { const reason = error instanceof Error ? error.message : String(error); log.warn(`Local execution can't parse backend modules: ${reason}`); }); - const ssrEnvironment = server.environments?.ssr; - if (ssrEnvironment) { - const fetchModule = ssrEnvironment.fetchModule.bind(ssrEnvironment); - ssrEnvironment.fetchModule = exemptFetchFromBlockedScope(fetchModule); - } } - const loadModule = server.ssrLoadModule.bind(server); - // Safe to call before `loadModule` runs anything: collectModuleGraphFromServer primes - // each node itself via `transformRequest`, since `moduleParsed` (production's + // Safe to call before a call's process loads anything: collectModuleGraphFromServer + // primes each node itself via `transformRequest`, since `moduleParsed` (production's // mechanism) is Rollup-build-only and never fires on a real dev server. const getAllowedConnectionIds = async (entryId: string) => { const parseAst = await loadViteParseAst(); @@ -725,7 +718,6 @@ export const getVitePlugin = ({ const isHostAllowed = createViteHostCheck(server.config); const middleware = createDevServerMiddleware( bundler.build, - loadModule, getBackendFunctions, getAllowedConnectionIds, auth, @@ -736,6 +728,7 @@ export const getVitePlugin = ({ server.config.mode, isHostAllowed, bundler.defaultServerConditions, + executeLocally, ); server.middlewares.use((req, res, next) => { revalidateBackendFunctionFile(req); diff --git a/packages/plugins/apps/src/vite/local-execution-child-path.ts b/packages/plugins/apps/src/vite/local-execution-child-path.ts new file mode 100644 index 000000000..cbb9173b8 --- /dev/null +++ b/packages/plugins/apps/src/vite/local-execution-child-path.ts @@ -0,0 +1,10 @@ +// 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 'path'; + +/** Built from `local-execution-child.ts` next to the published bundle. */ +export function getLocalExecutionChildPath(): string { + return path.join(__dirname, './local-execution-child.mjs'); +} diff --git a/packages/plugins/apps/src/vite/local-execution-child.ts b/packages/plugins/apps/src/vite/local-execution-child.ts new file mode 100644 index 000000000..e659748a0 --- /dev/null +++ b/packages/plugins/apps/src/vite/local-execution-child.ts @@ -0,0 +1,228 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +/* global globalThis */ + +// Runs one backend function call in its own process, forked by the dev server's local execution. +// Modules come from the dev server over IPC; `$.Actions` calls go back to it for scope checks and credentials. + +import type { Writable } from 'node:stream'; + +import type { ChildMessage, ChildStartConfig, ParentMessage } from './local-execution-protocol'; +import { isParentMessage } from './local-execution-protocol'; +import type { ActionCallArgs } from './local-execution-shared'; +import { + assertJsonRoundTrippable, + assertNoSelfDeclaredScope, + describeError, + isIndexableRecord, + makeActionsProxy, + returnValueSubject, + serializeActionInputs, + validateActionCallShape, +} from './local-execution-shared'; +import { installGuards, runBlocked } from './network-guard'; + +type ModuleRunnerInstance = { import(url: string): Promise> }; +type CreateImportMeta = (modulePath: string) => unknown; +type ModuleRunnerOptions = { + transport: { invoke(payload: unknown): Promise }; + hmr: false; + createImportMeta: CreateImportMeta; +}; +type ModuleRunnerModule = { + ModuleRunner: new (options: ModuleRunnerOptions, evaluator: unknown) => ModuleRunnerInstance; + ESModulesEvaluator: new () => unknown; + createNodeImportMeta: CreateImportMeta; +}; + +type Settle = { resolve(value: unknown): void; reject(error: Error): void }; + +const send = process.send?.bind(process); +let nextRequestId = 0; +const pendingInvokes = new Map(); +const pendingActions = new Map(); + +function sendMessage(message: ChildMessage): Promise { + return new Promise((resolve, reject) => { + if (!send) { + reject(new Error('Local execution child process has no IPC channel.')); + return; + } + send(message, undefined, undefined, (error: Error | null) => + error ? reject(error) : resolve(), + ); + }); +} + +function request( + pending: Map, + id: number, + message: ChildMessage, +): Promise { + return new Promise((resolve, reject) => { + pending.set(id, { resolve, reject }); + sendMessage(message).catch((error: Error) => { + pending.delete(id); + reject(error); + }); + }); +} + +function invoke(payload: unknown): Promise { + const id = ++nextRequestId; + return request(pendingInvokes, id, { type: 'invoke', id, payload }); +} + +async function dispatchAction( + actionId: string, + call: Partial, + description: string, +): Promise { + const { inputs, connectionId } = validateActionCallShape(call, description); + const serializedInputs = serializeActionInputs(inputs, description); + assertNoSelfDeclaredScope(serializedInputs, description); + const id = ++nextRequestId; + return request(pendingActions, id, { + type: 'action', + id, + actionId, + description, + inputs: serializedInputs, + ...(connectionId === undefined ? {} : { connectionId }), + }); +} + +function onParentMessage(message: ParentMessage): void { + if (message.type === 'start') { + run(message.config).catch(() => {}); + } else if (message.type === 'invoke-result') { + pendingInvokes.get(message.id)?.resolve(message.response); + pendingInvokes.delete(message.id); + } else if (message.type === 'action-result') { + const settle = pendingActions.get(message.id); + pendingActions.delete(message.id); + if (message.ok) { + settle?.resolve(message.value); + } else { + const error = new Error(message.message); + settle?.reject(error); + } + } +} + +async function registerSdkAdapters( + runner: ModuleRunnerInstance, + config: ChildStartConfig, + $: Record, +): Promise { + if (config.actionCatalogInstalled) { + const actionExecution = await runner.import('@datadog/action-catalog/action-execution'); + const { setExecuteActionImplementation } = actionExecution; + if (typeof setExecuteActionImplementation === 'function') { + setExecuteActionImplementation((actionId: string, actionRequest: unknown) => { + const call = isIndexableRecord(actionRequest) ? actionRequest : {}; + return dispatchAction(actionId, call, `"${actionId}"`); + }); + } + } + if (config.appsBackendInstalled) { + const [jsFunctionWithActions, runtime] = await Promise.all([ + runner.import('@datadog/apps-backend/runtime/jsFunctionWithActions'), + runner.import('@datadog/apps-backend/runtime'), + ]); + const { buildRuntimeFromJsFunctionWithActions } = jsFunctionWithActions; + const { setBackend } = runtime; + if ( + typeof buildRuntimeFromJsFunctionWithActions === 'function' && + typeof setBackend === 'function' + ) { + const backend = buildRuntimeFromJsFunctionWithActions($); + setBackend(backend); + } + } +} + +const writeStdout = process.stdout.write; +const writeStderr = process.stderr.write; + +// A write callback fires once earlier writes are flushed, which a pipe on macOS does asynchronously. +// Uses the original write, and gives up after a while, in case the call replaced or broke it. +function flush(stream: Writable, write: Writable['write'], timeoutMs: number): Promise { + return new Promise((resolve) => { + const timer = setTimeout(resolve, timeoutMs); + Reflect.apply(write, stream, [ + '', + () => { + clearTimeout(timer); + resolve(); + }, + ]); + }); +} + +async function load(config: ChildStartConfig): Promise { + const { ModuleRunner, ESModulesEvaluator, createNodeImportMeta }: ModuleRunnerModule = + await import(config.moduleRunnerUrl); + // Declared, so top-level code reading `$` sees undefined as in production rather than a ReferenceError. + Reflect.set(globalThis, '$', undefined); + const evaluator = new ESModulesEvaluator(); + // Without it, Vite's runner gives modules an import.meta.resolve that always throws. + const runnerOptions: ModuleRunnerOptions = { + transport: { invoke }, + hmr: false, + createImportMeta: createNodeImportMeta, + }; + const runner = new ModuleRunner(runnerOptions, evaluator); + const mod = await runner.import(config.entryUrl); + const fn = mod[config.functionName]; + if (typeof fn !== 'function') { + throw new Error( + `"${config.functionName}" is not a function exported from ${config.entryPath}`, + ); + } + const $ = { + ...config.runtimeContext, + backendFunctionArgs: config.args, + Actions: makeActionsProxy(dispatchAction), + }; + Reflect.set(globalThis, '$', $); + await registerSdkAdapters(runner, config, $); + await sendMessage({ type: 'loaded' }); + const returned = await fn(...config.args); + const subject = returnValueSubject(config.functionName); + return assertJsonRoundTrippable(returned, subject); +} + +// The guards cover the module's top-level code as well as the call. +function execute(config: ChildStartConfig): Promise { + installGuards(); + return runBlocked(() => load(config)); +} + +async function run(config: ChildStartConfig): Promise { + let outcome: ChildMessage; + try { + const data = await execute(config); + outcome = { type: 'result', data }; + } catch (error) { + outcome = { + type: 'error', + message: describeError(error), + }; + } + const stdoutFlushed = flush(process.stdout, writeStdout, config.flushTimeoutMs); + const stderrFlushed = flush(process.stderr, writeStderr, config.flushTimeoutMs); + await Promise.all([stdoutFlushed, stderrFlushed]); + await sendMessage(outcome); +} + +// A dev server that exits or restarts leaves no call running on its own. +process.on('disconnect', () => process.exit(1)); + +process.on('message', (message: unknown) => { + if (isParentMessage(message)) { + onParentMessage(message); + } +}); diff --git a/packages/plugins/apps/src/vite/local-execution-process.fixtures.ts b/packages/plugins/apps/src/vite/local-execution-process.fixtures.ts new file mode 100644 index 000000000..d20516b5f --- /dev/null +++ b/packages/plugins/apps/src/vite/local-execution-process.fixtures.ts @@ -0,0 +1,51 @@ +// 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 { build } from 'esbuild'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +let builtChildPath: Promise | undefined; +let outDir: string | undefined; + +// Jest runs the TypeScript sources, but the child process needs the plain ESM file the published build ships. +export function buildLocalExecutionChild(): Promise { + builtChildPath ??= (async () => { + const tmpPrefix = path.join(os.tmpdir(), 'dd-apps-child-'); + outDir = fs.mkdtempSync(tmpPrefix); + const outfile = path.join(outDir, 'local-execution-child.mjs'); + await build({ + entryPoints: [path.join(__dirname, 'local-execution-child.ts')], + bundle: true, + format: 'esm', + platform: 'node', + target: 'node20', + outfile, + logLevel: 'silent', + }); + return outfile; + })(); + return builtChildPath; +} + +export function removeLocalExecutionChild(): void { + if (outDir) { + fs.rmSync(outDir, { recursive: true, force: true }); + } + outDir = undefined; + builtChildPath = undefined; +} + +export const FIXTURE_ROOT = path.resolve( + __dirname, + '../../../../tests/src/_jest/fixtures/apps_backend_project', +); + +export const previewRuntimeContext = { + Source: { + initiator: { id: 'preview-initiator', orgId: 'preview-org' }, + runAsUser: { id: 'preview-run-as', orgId: 'preview-org' }, + }, +}; diff --git a/packages/plugins/apps/src/vite/local-execution-process.integration.test.ts b/packages/plugins/apps/src/vite/local-execution-process.integration.test.ts new file mode 100644 index 000000000..e953f0a07 --- /dev/null +++ b/packages/plugins/apps/src/vite/local-execution-process.integration.test.ts @@ -0,0 +1,1324 @@ +// 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 { getVitePlugin } from '@dd/apps-plugin/vite/index'; +import { getContextMock, getMockLogger } from '@dd/tests/_jest/helpers/mocks'; +import fs from 'fs'; +import http from 'http'; +import { type ChildProcess, fork } from 'node:child_process'; +import os from 'os'; +import path from 'path'; +import { fileURLToPath, pathToFileURL } from 'url'; +import { + build, + createServer, + defaultServerConditions, + type HotPayload, + type Plugin, + type ViteDevServer, +} from 'vite'; + +import type { BackendFunction } from '../backend/types'; +import { LOCAL_EXECUTION_LOAD_SUFFIX } from '../constants'; + +import { + buildLocalExecutionChild, + FIXTURE_ROOT, + previewRuntimeContext, + removeLocalExecutionChild, +} from './local-execution-process.fixtures'; +import { + CHILD_MAX_OLD_SPACE_MB, + createChildProcessExecutor, + killLocalExecutionChildren, + type ChildProcessExecutorOptions, + type LocalExecutionRequest, + type OutputStream, + resolveViteModuleRunner, +} from './local-execution-process'; +import type { ChildStartConfig } from './local-execution-protocol'; +import { isIndexableRecord } from './local-execution-shared'; +import { + DEFAULT_LONG_POLLING_CONFIG, + deriveActionTimeouts, + type ExecuteAction, + withTimeout, +} from './local-execution'; +import { FS_WRITE_BLOCKED_MESSAGE } from './network-guard'; + +// Spying on the shared core module fails once a sandbox guard makes `fork` non-configurable. +jest.mock('node:child_process', () => { + const actual = jest.requireActual('node:child_process'); + return { ...actual, fork: jest.fn(actual.fork) }; +}); + +const CALL_TIMEOUT_MS = 60_000; + +const fixtureFunction = ( + file: string, + name: string, + allowedConnectionIds: string[] = [], +): BackendFunction => ({ + relativePath: file, + name, + absolutePath: path.join(FIXTURE_ROOT, `${file}.backend.ts`), + allowedConnectionIds, +}); + +type TimedCall = { calls: number; pid: number; startedAt: number; endedAt: number }; + +const isTimedCall = (value: unknown): value is TimedCall => + typeof value === 'object' && + value !== null && + 'calls' in value && + 'pid' in value && + 'startedAt' in value && + 'endedAt' in value && + typeof value.calls === 'number' && + typeof value.pid === 'number' && + typeof value.startedAt === 'number' && + typeof value.endedAt === 'number'; + +const isProcessAlive = (pid: number) => { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +}; + +// A killed child stays visible to `kill(pid, 0)` until it's reaped, which can lag on a busy machine. +const waitUntilGone = async (pid: number, deadline = Date.now() + 5000): Promise => { + if (!isProcessAlive(pid)) { + return true; + } + if (Date.now() > deadline) { + return false; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + return waitUntilGone(pid, deadline); +}; + +const childPidFrom = (debugLines: string[]) => { + const match = debugLines.join('\n').match(/\(pid (\d+),/); + if (!match) { + throw new Error(`No child pid logged in: ${debugLines.join('\n')}`); + } + return Number(match[1]); +}; + +const VIRTUAL_MODULE_ID = 'virtual:dd-child-test'; +// Some plugins give a virtual module an id that looks like a path, with nothing on disk there. +const SLASH_VIRTUAL_MODULE_ID = '/~dd-child-test-virtual.js'; +const virtualModulePlugin: Plugin = { + name: 'dd-child-test-virtual', + resolveId: (id) => { + if (id === VIRTUAL_MODULE_ID) { + return `\0${VIRTUAL_MODULE_ID}`; + } + return id === SLASH_VIRTUAL_MODULE_ID ? id : null; + }, + load: (id) => { + if (id === `\0${VIRTUAL_MODULE_ID}`) { + return 'export const virtualValue = 42;'; + } + return id === SLASH_VIRTUAL_MODULE_ID ? 'export const slashValue = 7;' : null; + }, +}; + +describe('Local execution in a child process', () => { + let server: ViteDevServer; + let childEntryPath: string; + let moduleRunnerPath: string; + + beforeAll(async () => { + childEntryPath = await buildLocalExecutionChild(); + const resolvedRunner = resolveViteModuleRunner(FIXTURE_ROOT); + if (!resolvedRunner) { + throw new Error('Expected the fixture to resolve Vite'); + } + moduleRunnerPath = resolvedRunner; + const appsPlugin: Plugin = { + name: 'dd-apps-test', + ...getVitePlugin({ + bundler: { build, defaultServerConditions }, + context: getContextMock({ buildRoot: FIXTURE_ROOT }), + options: { include: [], tags: [], longPolling: DEFAULT_LONG_POLLING_CONFIG }, + }), + }; + server = await createServer({ + configFile: false, + root: FIXTURE_ROOT, + logLevel: 'silent', + server: { middlewareMode: true, hmr: false }, + plugins: [appsPlugin, virtualModulePlugin], + optimizeDeps: { noDiscovery: true }, + }); + }, CALL_TIMEOUT_MS); + + afterAll(async () => { + await server?.close(); + removeLocalExecutionChild(); + }); + + const makeExecutor = (overrides: Partial = {}) => + createChildProcessExecutor({ + invokeChannel: server.environments.ssr.hot, + projectRoot: FIXTURE_ROOT, + childEntryPath, + moduleRunnerPath, + log: getMockLogger(), + writeOutput: () => {}, + ...overrides, + }); + + const makeRequest = ( + func: BackendFunction, + args: unknown[], + overrides: Partial = {}, + ): LocalExecutionRequest => ({ + func, + args, + executeAction: async () => ({ unused: true }), + getRuntimeContext: async () => previewRuntimeContext, + getAllowedConnectionIds: async () => func.allowedConnectionIds, + timeoutMs: 10_000, + longPolling: DEFAULT_LONG_POLLING_CONFIG, + ...overrides, + }); + + describe('Isolation', () => { + const countCalls = fixtureFunction('childState', 'countCalls'); + + test( + 'Should start each call with fresh module state and globals', + async () => { + const execute = makeExecutor(); + const results: unknown[] = []; + for (let i = 0; i < 3; i++) { + // eslint-disable-next-line no-await-in-loop + results.push((await execute(makeRequest(countCalls, [0]))).data); + } + expect(results).toEqual([ + expect.objectContaining({ calls: 1, globalCalls: 1 }), + expect.objectContaining({ calls: 1, globalCalls: 1 }), + expect.objectContaining({ calls: 1, globalCalls: 1 }), + ]); + }, + CALL_TIMEOUT_MS, + ); + + test( + 'Should run concurrent calls one at a time, each in its own fresh process', + async () => { + const execute = makeExecutor(); + const results = await Promise.all( + [1, 2, 3].map(() => execute(makeRequest(countCalls, [300]))), + ); + + const data = results.map((r) => r.data).filter(isTimedCall); + expect(data).toHaveLength(3); + expect(data.map((d) => d.calls)).toEqual([1, 1, 1]); + expect(new Set(data.map((d) => d.pid)).size).toBe(3); + const byStart = [...data].sort((a, b) => a.startedAt - b.startedAt); + const overlapping = byStart.filter( + (d, i) => i > 0 && d.startedAt < byStart[i - 1].endedAt, + ); + expect(overlapping).toEqual([]); + }, + CALL_TIMEOUT_MS, + ); + + test( + 'Should run each call in its own temp dir, so a .env in the project is not picked up from the working directory', + async () => { + const execute = makeExecutor(); + const result = await execute( + makeRequest(fixtureFunction('childState', 'readWorkingDirectory'), []), + ); + expect(result.data).toEqual({ + cwd: expect.stringContaining('dd-apps-exec-'), + cwdIsTmpdir: true, + }); + }, + CALL_TIMEOUT_MS, + ); + + test( + "Should strip the developer environment, keys included, and keep Node's own runtime settings", + async () => { + const previous = { + api: process.env.DD_API_KEY, + app: process.env.DD_APP_KEY, + tz: process.env.TZ, + }; + process.env.DD_API_KEY = 'parent-api-key'; + process.env.DD_APP_KEY = 'parent-app-key'; + process.env.TZ = 'UTC'; + try { + const execute = makeExecutor(); + const result = await execute( + makeRequest(fixtureFunction('childState', 'readEnvNames'), []), + ); + const allNames = Array.isArray(result.data) ? result.data : []; + const allowed = [ + 'TMPDIR', + 'TZ', + 'NODE_EXTRA_CA_CERTS', + 'SSL_CERT_FILE', + 'SSL_CERT_DIR', + 'SystemRoot', + 'TEMP', + 'TMP', + // macOS adds this one to every process it starts. + '__CF_USER_TEXT_ENCODING', + ]; + expect(allNames.filter((name) => !allowed.includes(name))).toEqual([]); + expect(allNames).toEqual(expect.arrayContaining(['TMPDIR', 'TZ'])); + } finally { + const restore = (name: string, value: string | undefined) => { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } + }; + restore('DD_API_KEY', previous.api); + restore('DD_APP_KEY', previous.app); + restore('TZ', previous.tz); + } + }, + CALL_TIMEOUT_MS, + ); + + test( + 'Should show top-level code an undefined $, as production does, and the call the real one', + async () => { + const execute = makeExecutor(); + const result = await execute( + makeRequest(fixtureFunction('childLoadTime', 'readDollarAtLoad'), []), + ); + expect(result.data).toEqual({ dollarAtLoad: 'undefined', dollarAtCall: 'object' }); + }, + CALL_TIMEOUT_MS, + ); + + test( + 'Should run a nested backend import and an SDK-backed $.Source read for real', + async () => { + const execute = makeExecutor(); + const viaHelper = await execute( + makeRequest(fixtureFunction('viaHelper', 'usesHelper'), ['via-helper']), + ); + const runtimeUsers = await execute( + makeRequest(fixtureFunction('getRuntimeUsers', 'getRuntimeUsers'), ['label']), + ); + expect(viaHelper.data).toEqual({ value: 'via-helper' }); + expect(runtimeUsers.data).toEqual({ + label: 'label', + executionUser: previewRuntimeContext.Source.runAsUser, + initiatingUser: previewRuntimeContext.Source.initiator, + }); + }, + CALL_TIMEOUT_MS, + ); + }); + + describe('Limits', () => { + test.each([ + { + description: 'a call that never settles', + name: 'waitForever', + }, + { + description: 'a synchronous infinite loop', + name: 'spinForever', + }, + ])( + 'Should kill the child of $description once it times out', + async ({ name }) => { + const debugLines: string[] = []; + const execute = makeExecutor({ + log: getMockLogger({ debug: (text: string) => debugLines.push(text) }), + }); + const start = Date.now(); + await expect( + execute( + makeRequest(fixtureFunction('childState', name), [], { timeoutMs: 3000 }), + ), + ).rejects.toThrow(`Local execution of "${name}" timed out after 3000ms`); + expect(Date.now() - start).toBeLessThan(10_000); + const pid = childPidFrom(debugLines); + const gone = await waitUntilGone(pid); + expect(gone).toBe(true); + }, + CALL_TIMEOUT_MS, + ); + + test( + 'Should give loading its own time budget before the call starts its own', + async () => { + const execute = makeExecutor(); + const slowLoad = fixtureFunction('childSlowLoad', 'runAfterSlowLoad'); + await expect( + execute(makeRequest(slowLoad, [0], { timeoutMs: 400 })), + ).rejects.toThrow('Loading "childSlowLoad/runAfterSlowLoad" timed out after 400ms'); + // 1200ms to load plus 4000ms to run passes a 5000ms budget only when each gets its own. + await expect( + execute(makeRequest(slowLoad, [4000], { timeoutMs: 5000 })), + ).resolves.toEqual({ data: 'done' }); + }, + CALL_TIMEOUT_MS, + ); + + test( + 'Should return a result even when the call replaced process.stdout.write, without waiting out the flush timeout', + async () => { + // Long enough that waiting it out would fail the test. + const execute = makeExecutor({ flushTimeoutMs: CALL_TIMEOUT_MS * 2 }); + await expect( + execute(makeRequest(fixtureFunction('childState', 'silenceStdout'), [])), + ).resolves.toEqual({ data: 'quiet' }); + }, + CALL_TIMEOUT_MS, + ); + + test( + 'Should not count a $.Actions call started during loading against the call budget', + async () => { + const execute = makeExecutor(); + const executeAction: ExecuteAction = async (_fqn, inputs) => { + await new Promise((resolve) => setTimeout(resolve, 4000)); + return inputs; + }; + await expect( + execute( + makeRequest(fixtureFunction('childLoadTime', 'awaitEarlyAction'), [], { + executeAction, + timeoutMs: 3000, + }), + ), + ).resolves.toEqual({ data: { early: true } }); + }, + CALL_TIMEOUT_MS, + ); + + test( + "Should kill every call's process and remove its temp dir when the dev server exits", + async () => { + const debugLines: string[] = []; + const execute = makeExecutor({ + log: getMockLogger({ debug: (text: string) => debugLines.push(text) }), + }); + const call = execute( + makeRequest(fixtureFunction('childState', 'spinForever'), [], { + timeoutMs: 30_000, + }), + ); + while (!debugLines.some((line) => line.includes('(pid '))) { + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => setTimeout(resolve, 20)); + } + + const tmpDir = debugLines.join('\n').match(/temp dir ([^)]+)\)/)?.[1]; + expect(tmpDir && fs.existsSync(tmpDir)).toBe(true); + + killLocalExecutionChildren(); + + expect(tmpDir && fs.existsSync(tmpDir)).toBe(false); + + await expect(call).rejects.toThrow( + 'Local execution of "spinForever" ended before returning a result (signal SIGKILL)', + ); + }, + CALL_TIMEOUT_MS, + ); + + const childErrorCases = [ + { + description: 'still kill a started process whose kill failed', + spawnOptions: {}, + expectedKills: ['SIGKILL', 'SIGKILL'], + }, + { + description: 'stop tracking a process that never started', + spawnOptions: { execPath: path.join(os.tmpdir(), 'dd-apps-missing-node') }, + expectedKills: ['SIGKILL'], + }, + ]; + test.each(childErrorCases)( + "Should $description when its process emits 'error'", + async ({ spawnOptions, expectedKills }) => { + const { fork: realFork } = + jest.requireActual('node:child_process'); + const kills: unknown[] = []; + let spawned: ChildProcess | undefined; + jest.mocked(fork).mockImplementationOnce((modulePath, forkArgs, forkOptions) => { + const child = realFork(modulePath, forkArgs, { + ...forkOptions, + ...spawnOptions, + }); + Object.defineProperty(child, 'kill', { + value: (signal: unknown) => { + kills.push(signal); + return false; + }, + }); + spawned = child; + return child; + }); + const execute = makeExecutor(); + const call = execute( + makeRequest(fixtureFunction('childState', 'waitForever'), [], { + timeoutMs: 30_000, + }), + ); + call.catch(() => {}); + while (!spawned) { + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => setTimeout(resolve, 20)); + } + const child = spawned; + try { + if (child.pid !== undefined) { + const killFailure = new Error('kill EPERM'); + child.emit('error', killFailure); + } + await expect(call).rejects.toThrow(); + + killLocalExecutionChildren(); + + expect(kills).toEqual(expectedKills); + } finally { + if (child.pid !== undefined) { + process.kill(child.pid, 'SIGKILL'); + } + } + }, + CALL_TIMEOUT_MS, + ); + + test( + 'Should refuse calls still queued or starting once its executor closes', + async () => { + const debugLines: string[] = []; + const execute = makeExecutor({ + log: getMockLogger({ debug: (text: string) => debugLines.push(text) }), + }); + let releaseContext: () => void = () => {}; + const contextHeld = new Promise((resolve) => { + releaseContext = () => resolve(previewRuntimeContext); + }); + let markContextRequested: () => void = () => {}; + const contextRequested = new Promise((resolve) => { + markContextRequested = resolve; + }); + const countCalls = fixtureFunction('childState', 'countCalls'); + const starting = execute( + makeRequest(countCalls, [0], { + getRuntimeContext: () => { + markContextRequested(); + return contextHeld; + }, + }), + ); + const queued = execute( + makeRequest(countCalls, [0], { getRuntimeContext: () => contextHeld }), + ); + const settled = Promise.allSettled([starting, queued]); + + // The first call is past its start, waiting on its runtime context, when the server closes. + await contextRequested; + execute.close(); + releaseContext(); + + const refusal = + 'Local execution of "countCalls" didn\'t start: its dev server closed'; + const reasons = (await settled).map((result) => + result.status === 'rejected' ? String(result.reason) : 'fulfilled', + ); + expect(reasons).toEqual([`Error: ${refusal}`, `Error: ${refusal}`]); + expect(debugLines.filter((line) => line.includes('(pid '))).toEqual([]); + }, + CALL_TIMEOUT_MS, + ); + + test( + "Should kill only its own calls' processes when an executor closes", + async () => { + const debugLines: string[] = []; + const execute = makeExecutor({ + log: getMockLogger({ debug: (text: string) => debugLines.push(text) }), + }); + const otherExecutor = makeExecutor(); + const call = execute( + makeRequest(fixtureFunction('childState', 'spinForever'), [], { + timeoutMs: 30_000, + }), + ); + const settled = call.then( + () => 'settled', + () => 'settled', + ); + while (!debugLines.some((line) => line.includes('(pid '))) { + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => setTimeout(resolve, 20)); + } + + otherExecutor.close(); + const stillPending = new Promise((resolve) => setTimeout(resolve, 500, 'pending')); + await expect(Promise.race([settled, stillPending])).resolves.toBe('pending'); + + execute.close(); + await expect(call).rejects.toThrow( + 'Local execution of "spinForever" ended before returning a result (signal SIGKILL)', + ); + }, + CALL_TIMEOUT_MS, + ); + + test( + "Should end a call's process once its IPC channel to the dev server closes", + async () => { + const countCalls = fixtureFunction('childState', 'countCalls'); + const config: ChildStartConfig = { + moduleRunnerUrl: pathToFileURL(moduleRunnerPath).href, + entryUrl: countCalls.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX, + entryPath: countCalls.absolutePath, + functionName: countCalls.name, + args: [CALL_TIMEOUT_MS], + runtimeContext: previewRuntimeContext, + actionCatalogInstalled: false, + appsBackendInstalled: false, + flushTimeoutMs: 1000, + }; + const child = fork(childEntryPath, [], { + stdio: ['ignore', 'pipe', 'pipe', 'ipc'], + }); + try { + let loaded = false; + child.on( + 'message', + async (message: { type: string; id: number; payload: HotPayload }) => { + if (message.type === 'invoke') { + const response = await server.environments.ssr.hot.handleInvoke( + message.payload, + ); + child.send({ type: 'invoke-result', id: message.id, response }); + loaded = true; + } + }, + ); + const exited = new Promise((resolve) => child.once('exit', resolve)); + child.send({ type: 'start', config }); + while (!loaded) { + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => setTimeout(resolve, 20)); + } + await new Promise((resolve) => setTimeout(resolve, 300)); + + child.disconnect(); + + await expect(withTimeout(exited, 5000, 'Child exit')).resolves.toBe(1); + } finally { + child.kill('SIGKILL'); + } + }, + CALL_TIMEOUT_MS, + ); + + test( + 'Should report a call that exits before returning', + async () => { + const execute = makeExecutor(); + await expect( + execute(makeRequest(fixtureFunction('childState', 'exitEarly'), [])), + ).rejects.toThrow( + 'Local execution of "exitEarly" ended before returning a result (exit code 3)', + ); + }, + CALL_TIMEOUT_MS, + ); + + test( + "Should report a thrown value that can't be converted to a string, instead of timing out", + async () => { + const execute = makeExecutor(); + const func = fixtureFunction('childState', 'throwUnprintable'); + await expect(execute(makeRequest(func, [], { timeoutMs: 3000 }))).rejects.toThrow( + "A thrown object that can't be converted to a string", + ); + }, + CALL_TIMEOUT_MS, + ); + + test( + `Should report a call that runs past ${CHILD_MAX_OLD_SPACE_MB} MB of heap`, + async () => { + const execute = makeExecutor(); + await expect( + execute( + makeRequest(fixtureFunction('childState', 'allocateUntilOutOfMemory'), [], { + timeoutMs: 30_000, + }), + ), + ).rejects.toThrow( + `Local execution of "allocateUntilOutOfMemory" ran out of memory (${CHILD_MAX_OLD_SPACE_MB} MB)`, + ); + }, + CALL_TIMEOUT_MS, + ); + + test( + 'Should not report a call as out of memory because its own output mentions it', + async () => { + const execute = makeExecutor({ writeOutput: () => {} }); + await expect( + execute( + makeRequest(fixtureFunction('childState', 'logOutOfMemoryAndExit'), []), + ), + ).rejects.toThrow( + 'Local execution of "logOutOfMemoryAndExit" ended before returning a result (exit code 1)', + ); + }, + CALL_TIMEOUT_MS, + ); + }); + + describe('Guards', () => { + let fetchServer: http.Server; + let fetchUrl: string; + let projectFile: string; + let sharedTmpFile: string; + + beforeAll(async () => { + fetchServer = http.createServer((_req, res) => res.end('pong')); + await new Promise((resolve) => fetchServer.listen(0, '127.0.0.1', resolve)); + const address = fetchServer.address(); + if (!address || typeof address === 'string') { + throw new Error('Expected the fetch server to listen on a TCP port'); + } + fetchUrl = `http://127.0.0.1:${address.port}/`; + }); + + beforeEach(() => { + const probeName = `sandbox-probe-${process.pid}-${Date.now()}.txt`; + projectFile = path.join(FIXTURE_ROOT, probeName); + sharedTmpFile = path.join('/tmp', probeName); + }); + + afterEach(() => { + fs.rmSync(projectFile, { force: true }); + fs.rmSync(sharedTmpFile, { force: true }); + fs.rmSync(path.join(FIXTURE_ROOT, 'top-level-probe.txt'), { force: true }); + }); + + afterAll(async () => { + await new Promise((resolve) => fetchServer.close(resolve)); + }); + + test( + 'Should refuse project writes, subprocesses and workers with the in-process guards, and allow temp writes and fetch', + async () => { + const execute = makeExecutor(); + const result = await execute( + makeRequest(fixtureFunction('childSandbox', 'runSandboxProbes'), [ + projectFile, + fetchUrl, + sharedTmpFile, + ]), + ); + expect(result.data).toEqual({ + sharedTmpWrite: 'ok: written', + projectWrite: 'refused: EROFS', + tempWrite: 'ok: x', + spawn: 'refused: Spawning a subprocess is not allowed in backend functions.', + worker: 'refused: Spawning a worker thread is not allowed in backend functions.', + fetch: 'ok: 200 pong', + }); + expect(fs.existsSync(projectFile)).toBe(false); + }, + CALL_TIMEOUT_MS, + ); + + test( + "Should guard a module's top-level code too", + async () => { + const execute = makeExecutor(); + await expect( + execute( + makeRequest( + fixtureFunction('childTopLevelWrite', 'afterTopLevelWrite'), + [], + ), + ), + ).rejects.toThrow(FS_WRITE_BLOCKED_MESSAGE); + expect(fs.existsSync(path.join(FIXTURE_ROOT, 'top-level-probe.txt'))).toBe(false); + }, + CALL_TIMEOUT_MS, + ); + + test( + "Should print each call's debugger URL while debugging", + async () => { + const lines: string[] = []; + const execute = makeExecutor({ + isInspectorActive: () => true, + writeOutput: (_stream, line) => lines.push(line), + }); + await execute(makeRequest(fixtureFunction('childState', 'countCalls'), [0])); + expect(lines.join('\n')).toContain('Debugger listening on ws://127.0.0.1:'); + }, + CALL_TIMEOUT_MS, + ); + + test( + 'Should not time out a call while debugging, so a breakpoint can hold it', + async () => { + const debugLines: string[] = []; + const execute = makeExecutor({ + isInspectorActive: () => true, + log: getMockLogger({ debug: (text: string) => debugLines.push(text) }), + }); + let settled = false; + const call = execute( + makeRequest(fixtureFunction('childState', 'waitForever'), [], { + timeoutMs: 300, + }), + ).finally(() => { + settled = true; + }); + call.catch(() => {}); + await new Promise((resolve) => setTimeout(resolve, 1500)); + expect(settled).toBe(false); + killLocalExecutionChildren(); + await expect(call).rejects.toThrow('ended before returning a result'); + expect(debugLines.join('\n')).toContain('(pid '); + }, + CALL_TIMEOUT_MS, + ); + }); + describe('Module loading over IPC', () => { + const loadOverIpc = fixtureFunction('childSandbox', 'loadOverIpc'); + + test( + "Should load a plugin's virtual module", + async () => { + const execute = makeExecutor(); + const result = await execute( + makeRequest(fixtureFunction('childVirtual', 'readVirtualModule'), []), + ); + expect(result.data).toEqual({ virtualValue: 42, slashValue: 7 }); + }, + CALL_TIMEOUT_MS, + ); + + test( + 'Should load a module inside the project over the same channel', + async () => { + const execute = makeExecutor(); + const result = await execute( + makeRequest(loadOverIpc, [`${path.join(FIXTURE_ROOT, 'package.json')}?raw`]), + ); + expect(JSON.stringify(result.data)).toContain('apps_backend_project'); + }, + CALL_TIMEOUT_MS, + ); + + describe('import.meta', () => { + let runnerDir = ''; + + beforeAll(() => { + const tmpPrefix = path.join(os.tmpdir(), 'dd-apps-fake-runner-'); + runnerDir = fs.mkdtempSync(tmpPrefix); + }); + + afterAll(() => { + fs.rmSync(runnerDir, { recursive: true, force: true }); + }); + + // Vite 7 and 8's module runner resolves import.meta.resolve only through its + // createImportMeta option; this repo's tests run Vite 6, whose runner has none. + const FAKE_VITE_7_RUNNER = ` +import { pathToFileURL } from 'node:url'; +const createDefaultImportMeta = (modulePath) => ({ + url: pathToFileURL(modulePath).href, + resolve() { + throw new Error('[module runner] "import.meta.resolve" is not supported.'); + }, +}); +export const createNodeImportMeta = async (modulePath) => ({ + ...createDefaultImportMeta(modulePath), + resolve: (specifier) => new URL(specifier, pathToFileURL(modulePath)).href, +}); +export class ESModulesEvaluator {} +export class ModuleRunner { + constructor(options) { + this.options = options; + } + async import(url) { + const createImportMeta = this.options.createImportMeta ?? createDefaultImportMeta; + const meta = await createImportMeta(url.split('?')[0]); + return { resolveSibling: () => meta.resolve('./sibling.js') }; + } +} +`; + + test( + 'Should give a backend function a working import.meta.resolve', + async () => { + const fakeRunnerPath = path.join(runnerDir, 'module-runner.mjs'); + fs.writeFileSync(fakeRunnerPath, FAKE_VITE_7_RUNNER); + const execute = makeExecutor({ moduleRunnerPath: fakeRunnerPath }); + const func = fixtureFunction('childImportMeta', 'resolveSibling'); + const result = await execute(makeRequest(func, [])); + const siblingUrl = String(result.data); + expect(fileURLToPath(siblingUrl)).toBe(path.join(FIXTURE_ROOT, 'sibling.js')); + }, + CALL_TIMEOUT_MS, + ); + }); + }); + + describe('Return values', () => { + const returning = (name: string) => fixtureFunction('childReturnValues', name); + + test.each([ + { name: 'returnCircular', message: /returnCircular.*can't be serialized to JSON/ }, + { name: 'returnBigInt', message: /returnBigInt.*can't be serialized to JSON/ }, + { name: 'returnFunction', message: /returnFunction.*JSON.stringify silently drops/ }, + { name: 'returnMap', message: /returnMap.*silently flattens/ }, + { name: 'returnNestedSet', message: /returnNestedSet.*silently flattens/ }, + { name: 'returnNestedNaN', message: /returnNestedNaN.*silently converts to "null"/ }, + { name: 'returnInfinity', message: /returnInfinity.*silently converts to "null"/ }, + { name: 'returnSymbolKey', message: /returnSymbolKey.*Symbol-keyed property/ }, + { + name: 'returnSymbolKeyViaToJson', + message: /returnSymbolKeyViaToJson.*Symbol-keyed property/, + }, + { + name: 'returnSymbolInArray', + message: /returnSymbolInArray.*symbol inside an array.*silently converts to null/, + }, + { + name: 'returnUndefinedInArray', + message: + /returnUndefinedInArray.*undefined inside an array.*silently converts to null/, + }, + ])( + 'Should reject what $name returns, which JSON would corrupt', + async ({ name, message }) => { + const execute = makeExecutor(); + await expect(execute(makeRequest(returning(name), []))).rejects.toThrow(message); + }, + CALL_TIMEOUT_MS, + ); + + test.each([ + { + name: 'returnUndefinedProperties', + data: { status: 'ok' }, + }, + { name: 'returnUndefined', data: undefined }, + { name: 'returnCountingToJson', data: { toJsonCalls: 1 } }, + ])( + 'Should return what $name returns as JSON would send it', + async ({ name, data }) => { + const execute = makeExecutor(); + const result = await execute(makeRequest(returning(name), [])); + expect(result).toEqual({ data }); + }, + CALL_TIMEOUT_MS, + ); + }); + + describe('$.Actions', () => { + const record = () => { + const calls: Array<{ fqn: string; inputs: unknown; connectionId?: string }> = []; + const executeAction: ExecuteAction = async (fqn, inputs, connectionId) => { + calls.push({ fqn, inputs, connectionId }); + return { echoedConnection: connectionId ?? null }; + }; + return { calls, executeAction }; + }; + + test( + 'Should dispatch through the dev server with the connection the function is allowed', + async () => { + const { calls, executeAction } = record(); + const execute = makeExecutor(); + const func = fixtureFunction('childActions', 'callHttp', ['conn-a']); + const result = await execute(makeRequest(func, ['conn-a'], { executeAction })); + expect(result.data).toEqual({ echoedConnection: 'conn-a' }); + expect(calls).toEqual([ + { + fqn: 'com.datadoghq.http.request', + inputs: { url: 'https://example.com' }, + connectionId: 'conn-a', + }, + ]); + }, + CALL_TIMEOUT_MS, + ); + + test( + 'Should scope each of two overlapping calls to its own allowed connections', + async () => { + const { calls, executeAction } = record(); + const execute = makeExecutor(); + const funcA = fixtureFunction('childActions', 'callHttp', ['conn-a']); + const funcB = { ...fixtureFunction('childActions', 'callHttp', ['conn-b']) }; + const [aOwn, bOwn, aUsingB, bUsingA] = await Promise.allSettled([ + execute(makeRequest(funcA, ['conn-a', 300], { executeAction })), + execute(makeRequest(funcB, ['conn-b', 300], { executeAction })), + execute(makeRequest(funcA, ['conn-b', 300], { executeAction })), + execute(makeRequest(funcB, ['conn-a', 300], { executeAction })), + ]); + expect(aOwn).toEqual({ + status: 'fulfilled', + value: { data: { echoedConnection: 'conn-a' } }, + }); + expect(bOwn).toEqual({ + status: 'fulfilled', + value: { data: { echoedConnection: 'conn-b' } }, + }); + expect(aUsingB).toEqual({ + status: 'rejected', + reason: new Error( + `Action $.Actions.http.request used connection "conn-b", which is not in this function's allowed connections: [conn-a]`, + ), + }); + expect(bUsingA).toEqual({ + status: 'rejected', + reason: new Error( + `Action $.Actions.http.request used connection "conn-a", which is not in this function's allowed connections: [conn-b]`, + ), + }); + expect(calls.map((c) => c.connectionId).sort()).toEqual(['conn-a', 'conn-b']); + }, + CALL_TIMEOUT_MS, + ); + + test( + 'Should refuse a forged IPC action request outside the allowed connections', + async () => { + const { calls, executeAction } = record(); + const execute = makeExecutor(); + const func = fixtureFunction('childActions', 'forgeActionMessage', ['conn-a']); + const result = await execute(makeRequest(func, ['conn-evil'], { executeAction })); + expect(result.data).toEqual({ + type: 'action-result', + id: 999_999, + ok: false, + message: `Action forged used connection "conn-evil", which is not in this function's allowed connections: [conn-a]`, + }); + expect(calls).toEqual([]); + }, + CALL_TIMEOUT_MS, + ); + + test( + 'Should answer a malformed IPC action request with an error instead of leaving it hanging', + async () => { + const { calls, executeAction } = record(); + const execute = makeExecutor(); + const func = fixtureFunction('childActions', 'forgeActionMessage', ['conn-a']); + const result = await execute(makeRequest(func, [null], { executeAction })); + expect(result.data).toEqual({ + type: 'action-result', + id: 999_999, + ok: false, + message: expect.stringContaining('Malformed $.Actions request'), + }); + expect(calls).toEqual([]); + }, + CALL_TIMEOUT_MS, + ); + + test( + 'Should still time out a call that keeps retrying a refused connection', + async () => { + const execute = makeExecutor(); + const func = fixtureFunction('childActions', 'retryRefusedConnection', ['conn-a']); + await expect(execute(makeRequest(func, [], { timeoutMs: 2000 }))).rejects.toThrow( + 'Local execution of "retryRefusedConnection" timed out after 2000ms', + ); + }, + CALL_TIMEOUT_MS, + ); + + test( + 'Should end a call at the absolute ceiling while its $.Actions call is still in flight', + async () => { + const longPolling = { + ...DEFAULT_LONG_POLLING_CONFIG, + maxRetries: 1, + timeoutMs: 1000, + }; + const { totalExecutionTimeoutMs } = deriveActionTimeouts(longPolling); + const execute = makeExecutor(); + const neverSettles: ExecuteAction = () => new Promise(() => {}); + const func = fixtureFunction('childActions', 'callHttp'); + await expect( + execute(makeRequest(func, [], { executeAction: neverSettles, longPolling })), + ).rejects.toThrow( + `Local execution of "callHttp" exceeded the ${totalExecutionTimeoutMs}ms execution ceiling since its module loaded or its latest $.Actions call started.`, + ); + }, + CALL_TIMEOUT_MS, + ); + + test( + "Should fail with an action's error it can't send back to the call, instead of timing out", + async () => { + const { fork: realFork } = + jest.requireActual('node:child_process'); + const forkMock = jest.mocked(fork); + forkMock.mockImplementation((modulePath, forkArgs, forkOptions) => { + const child = realFork(modulePath, forkArgs, forkOptions); + const relay = child.send.bind(child); + Object.defineProperty(child, 'send', { + value: (message: unknown, callback: unknown) => { + const isActionFailure = + isIndexableRecord(message) && + message.type === 'action-result' && + message.ok === false; + if (isActionFailure) { + throw new Error('IPC write failed'); + } + return Reflect.apply(relay, child, [message, callback]); + }, + }); + return child; + }); + const failingAction: ExecuteAction = async () => { + throw new Error('Slack said no'); + }; + const execute = makeExecutor(); + const func = fixtureFunction('childActions', 'callHttp'); + try { + await expect( + execute( + makeRequest(func, [], { + executeAction: failingAction, + timeoutMs: 3000, + }), + ), + ).rejects.toThrow('Slack said no'); + } finally { + forkMock.mockImplementation(realFork); + } + }, + CALL_TIMEOUT_MS, + ); + + test.each([ + { + reply: "an action's error", + isDropped: (message: Record) => + message.type === 'action-result' && message.ok === false, + expected: 'Slack said no', + }, + { + reply: 'a module load', + isDropped: (message: Record) => message.type === 'invoke-result', + expected: `Couldn't reply to "callHttp"'s child process: IPC write failed`, + }, + ])( + 'Should fail when sending $reply back to the call reports an error, instead of timing out', + async ({ isDropped, expected }) => { + const { fork: realFork } = + jest.requireActual('node:child_process'); + const forkMock = jest.mocked(fork); + forkMock.mockImplementation((modulePath, forkArgs, forkOptions) => { + const child = realFork(modulePath, forkArgs, forkOptions); + const relay = child.send.bind(child); + Object.defineProperty(child, 'send', { + value: (message: unknown, callback: unknown) => { + if ( + isIndexableRecord(message) && + isDropped(message) && + typeof callback === 'function' + ) { + setImmediate(() => callback(new Error('IPC write failed'))); + return true; + } + return Reflect.apply(relay, child, [message, callback]); + }, + }); + return child; + }); + const failingAction: ExecuteAction = async () => { + throw new Error('Slack said no'); + }; + const execute = makeExecutor(); + const func = fixtureFunction('childActions', 'callHttp'); + try { + await expect( + execute( + makeRequest(func, [], { + executeAction: failingAction, + timeoutMs: 3000, + }), + ), + ).rejects.toThrow(expected); + } finally { + forkMock.mockImplementation(realFork); + } + }, + CALL_TIMEOUT_MS, + ); + + test.each([ + { reply: 'is sent after the result', isSentLate: true }, + { reply: 'was sent before the result', isSentLate: false }, + ])( + 'Should return a result when replying to an action it left running fails, if the reply $reply', + async ({ isSentLate }) => { + const { fork: realFork } = + jest.requireActual('node:child_process'); + const forkMock = jest.mocked(fork); + let hasReturned = false; + let markReturned = () => {}; + const returned = new Promise((resolve) => { + markReturned = () => { + hasReturned = true; + resolve(); + }; + }); + forkMock.mockImplementation((modulePath, forkArgs, forkOptions) => { + const child = realFork(modulePath, forkArgs, forkOptions); + child.on('message', (message: unknown) => { + if (isIndexableRecord(message) && message.type === 'result') { + markReturned(); + } + }); + const relay = child.send.bind(child); + Object.defineProperty(child, 'send', { + value: (message: unknown, callback: unknown) => { + const isActionReply = + isIndexableRecord(message) && message.type === 'action-result'; + if (isActionReply && hasReturned) { + throw new Error('IPC write failed'); + } + if (isActionReply && typeof callback === 'function') { + const failWrite = () => callback(new Error('IPC write failed')); + returned.then(() => setImmediate(failWrite)); + return true; + } + return Reflect.apply(relay, child, [message, callback]); + }, + }); + return child; + }); + const leftRunning: ExecuteAction = async () => { + if (isSentLate) { + await returned; + } + return { late: true }; + }; + const execute = makeExecutor(); + const func = fixtureFunction('childActions', 'callHttpWithoutAwaiting'); + try { + await expect( + execute(makeRequest(func, [], { executeAction: leftRunning })), + ).resolves.toEqual({ data: 'returned' }); + } finally { + forkMock.mockImplementation(realFork); + } + }, + CALL_TIMEOUT_MS, + ); + + test( + 'Should run a typed action-catalog call through the same scope check', + async () => { + const { calls, executeAction } = record(); + const execute = makeExecutor(); + const allowed = fixtureFunction('actionCatalogCall', 'postMessage', ['conn-1']); + const disallowed = fixtureFunction('actionCatalogCall', 'postMessage', []); + await expect(execute(makeRequest(allowed, [], { executeAction }))).resolves.toEqual( + { data: { echoedConnection: 'conn-1' } }, + ); + await expect( + execute(makeRequest(disallowed, [], { executeAction })), + ).rejects.toThrow( + `Action "com.datadoghq.slack.chat.postMessage" used connection "conn-1", which is not in this function's allowed connections: []`, + ); + expect(calls).toEqual([ + { + fqn: 'com.datadoghq.slack.chat.postMessage', + inputs: { text: 'hi' }, + connectionId: 'conn-1', + }, + ]); + }, + CALL_TIMEOUT_MS, + ); + + test.each([ + { + name: 'callHttpWithMap', + message: + 'Inputs to action $.Actions.http.request contains a Map (at "headers"), which JSON.stringify silently flattens to "{}" instead of serializing its entries — use a plain array or object instead.', + }, + { + name: 'callHttpDeclaringScope', + message: + "Action $.Actions.http.request must not declare its own allowedConnectionIds in inputs — this function's own allowlist already governs which connections it can use.", + }, + { + name: 'callHttpDeclaringScopeViaToJson', + message: + "Action $.Actions.http.request must not declare its own allowedConnectionIds in inputs — this function's own allowlist already governs which connections it can use.", + }, + { + name: 'callHttpWithTopLevelToJson', + message: + 'must be a plain object after JSON round-tripping, but a custom toJSON() changed its top-level shape to string', + }, + ])( + 'Should reject $name in the child before it reaches the dev server', + async ({ name, message }) => { + const { calls, executeAction } = record(); + const execute = makeExecutor(); + await expect( + execute( + makeRequest(fixtureFunction('childActions', name), [], { executeAction }), + ), + ).rejects.toThrow(message); + expect(calls).toEqual([]); + }, + CALL_TIMEOUT_MS, + ); + }); + + describe('Logs', () => { + test( + 'Should stream each line as it is written, not after the call ends', + async () => { + const lines: Array<{ stream: OutputStream; line: string; at: number }> = []; + const execute = makeExecutor({ + writeOutput: (stream, line) => lines.push({ stream, line, at: Date.now() }), + }); + const delayMs = 800; + await execute(makeRequest(fixtureFunction('childState', 'countCalls'), [0])); + const result = await execute( + makeRequest(fixtureFunction('childState', 'logLines'), [delayMs]), + ); + const endedAt = Date.now(); + + expect(result.data).toBe('logged'); + expect(lines.map(({ stream, line }) => ({ stream, line }))).toEqual([ + { stream: 'stdout', line: 'first line' }, + { stream: 'stderr', line: 'second line' }, + { stream: 'stdout', line: 'no trailing newline' }, + ]); + expect(endedAt - lines[0].at).toBeGreaterThanOrEqual(delayMs * 0.75); + }, + CALL_TIMEOUT_MS, + ); + }); +}); diff --git a/packages/plugins/apps/src/vite/local-execution-process.test.ts b/packages/plugins/apps/src/vite/local-execution-process.test.ts new file mode 100644 index 000000000..79f4d7b53 --- /dev/null +++ b/packages/plugins/apps/src/vite/local-execution-process.test.ts @@ -0,0 +1,156 @@ +// 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 { getMockLogger } from '@dd/tests/_jest/helpers/mocks'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { PassThrough } from 'stream'; + +import type { BackendFunction } from '../backend/types'; + +import { + createChildProcessExecutor, + forwardLines, + MODULE_RUNNER_UNRESOLVED_MESSAGE, + resolveViteModuleRunner, + YARN_PNP_UNSUPPORTED_MESSAGE, +} from './local-execution-process'; +import { DEFAULT_LONG_POLLING_CONFIG } from './local-execution'; + +describe('Local execution process helpers', () => { + describe('Line forwarding', () => { + test('Should split lines across chunks and flush the last one at the end', () => { + const stream = new PassThrough(); + const lines: string[] = []; + forwardLines(stream, (line) => lines.push(line)); + for (const chunk of ['a', 'b\nc', '\r\n', 'd\ne', 'f']) { + stream.write(chunk); + } + stream.end(); + return new Promise((resolve) => { + stream.on('end', () => { + expect(lines).toEqual(['ab', 'c', 'd', 'ef']); + resolve(); + }); + stream.resume(); + }); + }); + }); + + describe('Unavailable local execution', () => { + const previousPnp = process.versions.pnp; + const func: BackendFunction = { + relativePath: 'src/example', + name: 'example', + absolutePath: '/project/src/example.backend.ts', + allowedConnectionIds: [], + }; + + afterEach(() => { + if (previousPnp === undefined) { + delete process.versions.pnp; + } else { + process.versions.pnp = previousPnp; + } + }); + + test.each([ + { + setup: "a Yarn Plug'n'Play dev server", + pnp: '3', + moduleRunnerPath: '/vite/module-runner.js', + message: YARN_PNP_UNSUPPORTED_MESSAGE, + }, + { + setup: 'no resolvable vite/module-runner', + pnp: undefined, + moduleRunnerPath: undefined, + message: MODULE_RUNNER_UNRESOLVED_MESSAGE, + }, + ])( + 'Should warn once and fail every call, before running it, on $setup', + async ({ pnp, moduleRunnerPath, message }) => { + if (pnp === undefined) { + delete process.versions.pnp; + } else { + process.versions.pnp = pnp; + } + const log = getMockLogger({ warn: jest.fn() }); + const getRuntimeContext = jest.fn(async () => ({})); + const executor = createChildProcessExecutor({ + invokeChannel: { handleInvoke: async () => ({}) }, + projectRoot: '/project', + childEntryPath: '/unused/local-execution-child.mjs', + moduleRunnerPath, + log, + }); + const call = () => + executor({ + func, + args: [], + executeAction: async () => ({}), + getRuntimeContext, + getAllowedConnectionIds: async () => [], + timeoutMs: 1000, + longPolling: DEFAULT_LONG_POLLING_CONFIG, + }); + + await expect(call()).rejects.toThrow(message); + await expect(call()).rejects.toThrow(message); + expect(log.warn).toHaveBeenCalledTimes(1); + expect(log.warn).toHaveBeenCalledWith(message); + expect(getRuntimeContext).not.toHaveBeenCalled(); + }, + ); + }); + + describe('Vite module runner', () => { + let projectsDir = ''; + + beforeAll(() => { + const tmpPrefix = path.join(os.tmpdir(), 'dd-apps-vite-runner-'); + const createdDir = fs.mkdtempSync(tmpPrefix); + projectsDir = fs.realpathSync.native(createdDir); + }); + + afterAll(() => { + fs.rmSync(projectsDir, { recursive: true, force: true }); + }); + + const projectWithVite = (version: string) => { + const projectRoot = path.join(projectsDir, version); + const viteDir = path.join(projectRoot, 'node_modules', 'vite'); + fs.mkdirSync(viteDir, { recursive: true }); + fs.writeFileSync(path.join(projectRoot, 'package.json'), '{}'); + fs.writeFileSync( + path.join(viteDir, 'package.json'), + JSON.stringify({ + name: 'vite', + version, + exports: { + './module-runner': './dist/node/module-runner.js', + './package.json': './package.json', + }, + }), + ); + return { projectRoot, runner: path.join(viteDir, 'dist', 'node', 'module-runner.js') }; + }; + + test.each(['7.0.0', '7.3.7', '8.0.0-beta.1', '8.3.3'])( + 'Should use the module runner of Vite %s', + (version) => { + const { projectRoot, runner } = projectWithVite(version); + expect(resolveViteModuleRunner(projectRoot)).toBe(runner); + }, + ); + + test('Should resolve nothing when Vite exports no module runner', () => { + const { projectRoot } = projectWithVite('7.3.7-no-runner'); + const vitePackageJson = path.join(projectRoot, 'node_modules', 'vite', 'package.json'); + fs.writeFileSync(vitePackageJson, JSON.stringify({ name: 'vite', exports: {} })); + expect(resolveViteModuleRunner(projectRoot)).toBeUndefined(); + }); + }); +}); diff --git a/packages/plugins/apps/src/vite/local-execution-process.ts b/packages/plugins/apps/src/vite/local-execution-process.ts new file mode 100644 index 000000000..79ab95db4 --- /dev/null +++ b/packages/plugins/apps/src/vite/local-execution-process.ts @@ -0,0 +1,584 @@ +// 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. + +/** Runs each local backend function call in a fresh Node child process, the way production gives each call its own process. */ + +import { readJsonSync, rm, rmSync } from '@dd/core/helpers/fs'; +import type { Logger } from '@dd/core/types'; +import { type ChildProcess, fork } from 'node:child_process'; +import fs from 'node:fs'; +import inspector from 'node:inspector'; +import { createRequire } from 'node:module'; +import os from 'node:os'; +import path from 'node:path'; +import type { Readable } from 'node:stream'; +import { StringDecoder } from 'node:string_decoder'; +import { pathToFileURL } from 'node:url'; +import type { HotPayload } from 'vite'; + +import { isActionCatalogInstalled, isDatadogAppsBackendInstalled } from '../backend/shared'; +import type { BackendFunction, BackendOutputs } from '../backend/types'; +import { LOCAL_EXECUTION_LOAD_SUFFIX } from '../constants'; +import type { LongPollingOptions } from '../types'; + +import type { ChildStartConfig, ParentMessage } from './local-execution-protocol'; +import { describeError, isIndexableRecord, validateActionCall } from './local-execution-shared'; +import type { ExecuteAction, GetRuntimeContext } from './local-execution'; +import { + assertValidRuntimeContext, + deriveActionTimeouts, + enqueue, + localExecutionResolutionContext, + withTimeout, +} from './local-execution'; + +const DEFAULT_FLUSH_TIMEOUT_MS = 1000; +/** Approximates production's 256 MiB per call. */ +export const CHILD_MAX_OLD_SPACE_MB = 256; + +/** The dev server side of Vite's module runner transport: `server.environments.ssr.hot`. */ +export interface InvokeChannel { + handleInvoke(payload: HotPayload): Promise; +} + +export type OutputStream = 'stdout' | 'stderr'; + +export interface LocalExecutionRequest { + func: BackendFunction; + args: unknown[]; + executeAction: ExecuteAction; + getRuntimeContext: GetRuntimeContext; + getAllowedConnectionIds: (entryId: string) => Promise; + timeoutMs: number; + longPolling: Required; +} + +export type LocalExecutor = (request: LocalExecutionRequest) => Promise; +/** `close` ends this executor's calls, for when its dev server closes or restarts. */ +export type ChildProcessExecutor = LocalExecutor & { close: () => void }; + +export interface ChildProcessExecutorOptions { + invokeChannel: InvokeChannel; + projectRoot: string; + childEntryPath: string; + moduleRunnerPath: string | undefined; + log: Logger; + writeOutput?: (stream: OutputStream, line: string) => void; + isInspectorActive?: () => boolean; + /** How long a finished call waits for its output to flush before returning anyway. */ + flushTimeoutMs?: number; +} + +const INVOKE_NAMES = new Set(['fetchModule', 'getBuiltins']); + +type ChildTmpDirs = Map; + +const liveChildren: ChildTmpDirs = new Map(); +let killsChildrenOnExit = false; + +function killChildren(children: ChildTmpDirs): void { + children.forEach((_tmpDir, child) => child.kill('SIGKILL')); + children.forEach((tmpDir) => { + try { + rmSync(tmpDir); + } catch { + // Best effort while the dev server exits; the OS cleans its temp dir eventually. + } + }); +} + +export function killLocalExecutionChildren(): void { + killChildren(liveChildren); +} + +// A child stuck in a synchronous loop never sees its IPC channel close when the dev server exits. +function trackChild(child: ChildProcess, tmpDir: string, ownChildren: ChildTmpDirs): void { + liveChildren.set(child, tmpDir); + ownChildren.set(child, tmpDir); + const untrack = () => { + liveChildren.delete(child); + ownChildren.delete(child); + }; + child.once('close', untrack); + // 'error' also reports a failed kill or send on a child that is still running. + child.once('error', () => { + if (child.pid === undefined) { + untrack(); + } + }); + if (!killsChildrenOnExit) { + killsChildrenOnExit = true; + process.once('exit', killLocalExecutionChildren); + } +} + +const writeToTerminal = (stream: OutputStream, line: string) => { + process[stream].write(`${line}\n`); +}; + +const isDebuggerAttached = () => inspector.url() !== undefined; + +// V8's own fatal error line, so a call's output that mentions memory isn't mistaken for it. +const V8_OUT_OF_MEMORY_LINE = /^FATAL ERROR: .*heap out of memory/; + +export const YARN_PNP_UNSUPPORTED_MESSAGE = + 'Local execution of backend functions needs a node_modules install: set `nodeLinker: node-modules` in .yarnrc.yml, or use `dev:verify` to run them in the cloud.'; + +export const MODULE_RUNNER_UNRESOLVED_MESSAGE = + "Local execution of backend functions couldn't resolve vite/module-runner from the project's Vite."; + +/** Vite's module runner from the project's own Vite, which serves it modules over `handleInvoke`. */ +export function resolveViteModuleRunner(projectRoot: string): string | undefined { + try { + const projectPackageJson = path.join(projectRoot, 'package.json'); + const projectRequire = createRequire(projectPackageJson); + const vitePackageJson = projectRequire.resolve('vite/package.json'); + const { exports } = readJsonSync(vitePackageJson); + const entry: unknown = isIndexableRecord(exports) ? exports['./module-runner'] : undefined; + const importEntry = isIndexableRecord(entry) ? entry.import ?? entry.default : entry; + const target = isIndexableRecord(importEntry) ? importEntry.default : importEntry; + const viteDir = path.dirname(vitePackageJson); + return typeof target === 'string' ? path.join(viteDir, target) : undefined; + } catch { + return undefined; + } +} + +// Node's own TLS and time zone settings, which a corporate proxy or a test may need; never app keys. +const NODE_RUNTIME_ENV = ['NODE_EXTRA_CA_CERTS', 'SSL_CERT_FILE', 'SSL_CERT_DIR', 'TZ']; + +/** Production gives each call a fresh environment without the developer's variables or keys. */ +function buildChildEnv(tmpDir: string): typeof process.env { + const env: typeof process.env = { TMPDIR: tmpDir }; + for (const name of NODE_RUNTIME_ENV) { + if (process.env[name] !== undefined) { + env[name] = process.env[name]; + } + } + if (process.platform === 'win32') { + env.TEMP = tmpDir; + env.TMP = tmpDir; + env.SystemRoot = process.env.SystemRoot; + } + return env; +} + +const LINE_BREAK = /\r\n|\n|\r(?!$)/; + +export function forwardLines(stream: Readable, onLine: (line: string) => void): void { + const decoder = new StringDecoder('utf8'); + let partial = ''; + const take = (text: string) => { + const lines = (partial + text).split(LINE_BREAK); + partial = lines.pop() ?? ''; + for (const line of lines) { + onLine(line); + } + }; + stream.on('data', (chunk: Buffer | string) => { + const text = typeof chunk === 'string' ? chunk : decoder.write(chunk); + take(text); + }); + stream.on('end', () => { + const rest = decoder.end(); + take(rest); + const lastLine = partial.endsWith('\r') ? partial.slice(0, -1) : partial; + if (lastLine) { + onLine(lastLine); + } + }); +} + +interface InvokeRequest { + invokeId: string; + name: string; + args: unknown[]; +} + +function invokeRequest(message: Record): InvokeRequest | undefined { + const { payload } = message; + if (!isIndexableRecord(payload) || !isIndexableRecord(payload.data)) { + return undefined; + } + const { id: invokeId, name, data } = payload.data; + const isValid = + typeof invokeId === 'string' && + typeof name === 'string' && + INVOKE_NAMES.has(name) && + Array.isArray(data); + return isValid ? { invokeId, name, args: data } : undefined; +} + +function isActionMessage(message: Record): message is Record & { + actionId: string; + description: string; + inputs: Record; + connectionId?: string; +} { + return ( + typeof message.actionId === 'string' && + typeof message.description === 'string' && + isIndexableRecord(message.inputs) && + !Array.isArray(message.inputs) && + (message.connectionId === undefined || typeof message.connectionId === 'string') + ); +} + +interface ChildRun { + func: BackendFunction; + config: ChildStartConfig; + allowedConnectionIds: string[]; + executeAction: ExecuteAction; + timeoutMs: number; + longPolling: Required; +} + +export function createChildProcessExecutor( + options: ChildProcessExecutorOptions, +): ChildProcessExecutor { + const { + invokeChannel, + projectRoot, + childEntryPath, + moduleRunnerPath, + log, + writeOutput = writeToTerminal, + isInspectorActive = isDebuggerAttached, + flushTimeoutMs = DEFAULT_FLUSH_TIMEOUT_MS, + } = options; + // Each call fails with the reason, while the dev server keeps serving the frontend. + const unavailable = (reason: string): ChildProcessExecutor => { + log.warn(reason); + const refuse: LocalExecutor = async () => { + throw new Error(reason); + }; + return Object.assign(refuse, { close: () => {} }); + }; + // Yarn Plug'n'Play loads packages through `NODE_OPTIONS` hooks, which a call's process doesn't get. + if (process.versions.pnp !== undefined) { + return unavailable(YARN_PNP_UNSUPPORTED_MESSAGE); + } + if (moduleRunnerPath === undefined) { + return unavailable(MODULE_RUNNER_UNRESOLVED_MESSAGE); + } + const moduleRunnerUrl = pathToFileURL(moduleRunnerPath).href; + + const ownChildren: ChildTmpDirs = new Map(); + let closed = false; + const refuseIfClosed = (func: BackendFunction) => { + if (closed) { + throw new Error( + `Local execution of "${func.name}" didn't start: its dev server closed`, + ); + } + }; + + const runChild = async (run: ChildRun): Promise => { + const { func, config, allowedConnectionIds, executeAction, timeoutMs } = run; + // A call still starting when its server closed must not fork on the closed server. + refuseIfClosed(func); + const { actionCallTimeoutMs, totalExecutionTimeoutMs } = deriveActionTimeouts( + run.longPolling, + ); + const systemTmpDir = os.tmpdir(); + const tmpPrefix = path.join(systemTmpDir, 'dd-apps-exec-'); + const createdTmpDir = fs.mkdtempSync(tmpPrefix); + const tmpDir = fs.realpathSync.native(createdTmpDir); + const inspect = isInspectorActive(); + const execArgv = [`--max-old-space-size=${CHILD_MAX_OLD_SPACE_MB}`]; + if (inspect) { + execArgv.push('--inspect=127.0.0.1:0'); + } + const env = buildChildEnv(tmpDir); + let child: ChildProcess; + try { + child = fork(childEntryPath, [], { + // Not the project, so a dependency loading `.env` from the working directory finds none. + cwd: tmpDir, + env, + execArgv, + serialization: 'json', + stdio: ['ignore', 'pipe', 'pipe', 'ipc'], + }); + } catch (error) { + rmSync(tmpDir); + throw error; + } + log.debug( + `Executing "${func.name}" in a child process (pid ${child.pid}, temp dir ${tmpDir}) with args`, + ); + trackChild(child, tmpDir, ownChildren); + + let ranOutOfMemory = false; + const writeChildLine = (stream: OutputStream) => (line: string) => { + ranOutOfMemory ||= stream === 'stderr' && V8_OUT_OF_MEMORY_LINE.test(line); + writeOutput(stream, line); + }; + if (child.stdout && child.stderr) { + const writeStdoutLine = writeChildLine('stdout'); + const writeStderrLine = writeChildLine('stderr'); + forwardLines(child.stdout, writeStdoutLine); + forwardLines(child.stderr, writeStderrLine); + } + + const resolutionScope = new Set(); + return new Promise((resolve, reject) => { + let settled = false; + let loaded = false; + let outcome: { data: unknown } | undefined; + let pendingActionCalls = 0; + let loadTimer: ReturnType | undefined; + let hangTimer: ReturnType | undefined; + let absoluteTimer: ReturnType | undefined; + + const finish = (error: Error) => { + if (settled) { + return; + } + settled = true; + clearTimeout(loadTimer); + clearTimeout(hangTimer); + clearTimeout(absoluteTimer); + child.kill('SIGKILL'); + reject(error); + }; + const fail = (message: string) => { + const error = new Error(message); + finish(error); + }; + // A paused breakpoint would otherwise count against the call's time budget. + const isTimed = !inspect; + // Loading gets its own budget before the call's. + const startLoadTimer = () => { + if (!isTimed) { + return; + } + loadTimer = setTimeout(() => { + fail( + `Loading "${func.relativePath}/${func.name}" timed out after ${timeoutMs}ms`, + ); + }, timeoutMs); + }; + const scheduleHangTimer = () => { + clearTimeout(hangTimer); + if (!isTimed || !loaded || pendingActionCalls > 0) { + return; + } + hangTimer = setTimeout(() => { + fail(`Local execution of "${func.name}" timed out after ${timeoutMs}ms`); + }, timeoutMs); + }; + const rearmAbsoluteTimer = () => { + clearTimeout(absoluteTimer); + if (!isTimed || !loaded) { + return; + } + absoluteTimer = setTimeout(() => { + fail( + `Local execution of "${func.name}" exceeded the ${totalExecutionTimeoutMs}ms execution ceiling since its module loaded or its latest $.Actions call started.`, + ); + }, totalExecutionTimeoutMs); + }; + const failToReply = (error: unknown) => { + const errorMessage = describeError(error); + fail(`Couldn't reply to "${func.name}"'s child process: ${errorMessage}`); + }; + // An unsent reply fails the call: the child would otherwise wait for it until it times out. + const reply = (message: ParentMessage, onUnsent = failToReply) => { + if (settled || outcome || !child.connected) { + return; + } + try { + child.send(message, (error) => { + if (error && !settled && !outcome) { + onUnsent(error); + } + }); + } catch (error) { + onUnsent(error); + } + }; + + const inResolutionScope = (work: () => T): T => + localExecutionResolutionContext.run(resolutionScope, work); + + const onInvoke = async (id: number, request: InvokeRequest) => { + const hotPayload: HotPayload = { + type: 'custom', + event: 'vite:invoke', + data: { id: request.invokeId, name: request.name, data: request.args }, + }; + const response = await inResolutionScope(() => + invokeChannel.handleInvoke(hotPayload), + ); + reply({ type: 'invoke-result', id, response }); + }; + + const onAction = async ( + id: number, + message: { + actionId: string; + description: string; + inputs: Record; + connectionId?: string; + }, + ) => { + const replyWithError = (error: unknown) => { + const errorMessage = describeError(error); + const failCall = () => fail(errorMessage); + reply( + { type: 'action-result', id, ok: false, message: errorMessage }, + failCall, + ); + }; + let validated: { + inputs: Record; + connectionId: string | undefined; + }; + // Before the timers, so a refused call can't keep extending the call's time budget. + try { + validated = validateActionCall( + message, + allowedConnectionIds, + message.description, + ); + } catch (error) { + replyWithError(error); + return; + } + const { inputs, connectionId } = validated; + pendingActionCalls += 1; + clearTimeout(hangTimer); + rearmAbsoluteTimer(); + try { + const actionCall = executeAction(message.actionId, inputs, connectionId); + const value = await withTimeout( + actionCall, + actionCallTimeoutMs, + `$.Actions call to "${message.actionId}"`, + ); + reply({ type: 'action-result', id, ok: true, value }); + } catch (error) { + replyWithError(error); + } finally { + pendingActionCalls -= 1; + if (pendingActionCalls === 0 && !settled && !outcome) { + scheduleHangTimer(); + } + } + }; + + child.on('message', (message: unknown) => { + if (settled || !isIndexableRecord(message) || outcome) { + return; + } + const id = message.id; + if (message.type === 'invoke' && typeof id === 'number') { + const replyWithError = (reason: string) => { + const response = { error: { name: 'Error', message: reason } }; + reply({ type: 'invoke-result', id, response }); + }; + const request = invokeRequest(message); + if (!request) { + replyWithError('Malformed module runner request.'); + return; + } + onInvoke(id, request).catch((error: unknown) => { + const reason = describeError(error); + replyWithError(reason); + }); + } else if (message.type === 'action' && typeof id === 'number') { + if (isActionMessage(message)) { + onAction(id, message).catch(() => {}); + } else { + reply({ + type: 'action-result', + id, + ok: false, + message: + 'Malformed $.Actions request: it needs a string actionId, an inputs object and an optional string connectionId.', + }); + } + } else if (message.type === 'loaded' && !loaded) { + loaded = true; + clearTimeout(loadTimer); + scheduleHangTimer(); + rearmAbsoluteTimer(); + } else if (message.type === 'result') { + outcome = { data: message.data }; + clearTimeout(loadTimer); + clearTimeout(hangTimer); + clearTimeout(absoluteTimer); + child.kill('SIGKILL'); + } else if (message.type === 'error') { + const reason = + typeof message.message === 'string' ? message.message : 'Unknown error'; + fail(reason); + } + }); + // A process that fails to start may never emit 'close'. + child.on('error', (error) => { + rm(tmpDir).catch(() => {}); + finish(error); + }); + child.on('close', (code, signal) => { + rm(tmpDir).catch(() => {}); + if (outcome) { + settled = true; + resolve(outcome); + return; + } + const ending = signal ? `signal ${signal}` : `exit code ${code}`; + const reason = ranOutOfMemory + ? `ran out of memory (${CHILD_MAX_OLD_SPACE_MB} MB)` + : `ended before returning a result (${ending})`; + fail(`Local execution of "${func.name}" ${reason}`); + }); + + startLoadTimer(); + reply({ type: 'start', config }); + }); + }; + + const execute: LocalExecutor = (request) => + enqueue(async () => { + const { func, args, executeAction, getRuntimeContext, getAllowedConnectionIds } = + request; + refuseIfClosed(func); + const displayName = `${func.relativePath}/${func.name}`; + const runtimeContext = await getRuntimeContext(); + assertValidRuntimeContext(runtimeContext); + const connectionIdsResolving = getAllowedConnectionIds(func.absolutePath); + const allowedConnectionIds = await withTimeout( + connectionIdsResolving, + request.timeoutMs, + `Resolving allowed connections for "${displayName}"`, + ); + const actionCatalogInstalled = isActionCatalogInstalled(projectRoot); + const appsBackendInstalled = isDatadogAppsBackendInstalled(projectRoot); + return runChild({ + func, + allowedConnectionIds, + executeAction, + timeoutMs: request.timeoutMs, + longPolling: request.longPolling, + config: { + moduleRunnerUrl, + entryUrl: func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX, + entryPath: func.absolutePath, + functionName: func.name, + args, + runtimeContext, + actionCatalogInstalled, + appsBackendInstalled, + flushTimeoutMs, + }, + }); + }); + const close = () => { + closed = true; + killChildren(ownChildren); + }; + return Object.assign(execute, { close }); +} diff --git a/packages/plugins/apps/src/vite/local-execution-protocol.ts b/packages/plugins/apps/src/vite/local-execution-protocol.ts new file mode 100644 index 000000000..803948c4c --- /dev/null +++ b/packages/plugins/apps/src/vite/local-execution-protocol.ts @@ -0,0 +1,58 @@ +// 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 { isIndexableRecord } from './local-execution-shared'; + +/** What the dev server sends a local execution child process to start one backend function call. */ +export interface ChildStartConfig { + moduleRunnerUrl: string; + entryUrl: string; + entryPath: string; + functionName: string; + args: unknown[]; + runtimeContext: Record; + actionCatalogInstalled: boolean; + appsBackendInstalled: boolean; + flushTimeoutMs: number; +} + +export type ParentMessage = + | { type: 'start'; config: ChildStartConfig } + | { type: 'invoke-result'; id: number; response: unknown } + | { type: 'action-result'; id: number; ok: true; value: unknown } + | { type: 'action-result'; id: number; ok: false; message: string }; + +export type ChildMessage = + | { type: 'invoke'; id: number; payload: unknown } + | { + type: 'action'; + id: number; + actionId: string; + description: string; + inputs: Record; + connectionId?: string; + } + | { type: 'loaded' } + | { type: 'result'; data: unknown } + | { type: 'error'; message: string }; + +// The child trusts the dev server, so this only tells the message kinds apart. +export function isParentMessage(message: unknown): message is ParentMessage { + if (!isIndexableRecord(message)) { + return false; + } + if (message.type === 'start') { + return isIndexableRecord(message.config); + } + if (message.type === 'invoke-result') { + return typeof message.id === 'number'; + } + if (message.type === 'action-result') { + return ( + typeof message.id === 'number' && + (message.ok === true || (message.ok === false && typeof message.message === 'string')) + ); + } + return false; +} diff --git a/packages/plugins/apps/src/vite/local-execution-shared.ts b/packages/plugins/apps/src/vite/local-execution-shared.ts index 65c69565f..9ba160929 100644 --- a/packages/plugins/apps/src/vite/local-execution-shared.ts +++ b/packages/plugins/apps/src/vite/local-execution-shared.ts @@ -4,7 +4,7 @@ /* global Proxy */ -// Must stay side-effect free: importing it must not install local-execution.ts's `globalThis.$` accessor. +// Side-effect free, so the local execution child process can bundle it. export interface ActionCallArgs { inputs: Record; @@ -18,6 +18,15 @@ export type ActionDispatch = ( actionDescription: string, ) => Promise; +/** Never throws, so reporting an odd thrown value can't itself fail and leave a call waiting. */ +export function describeError(error: unknown): string { + try { + return error instanceof Error ? error.message : String(error); + } catch { + return `A thrown ${typeof error} that can't be converted to a string`; + } +} + /** Narrows an unknown value enough to read named properties off it by key. */ export function isIndexableRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; diff --git a/packages/plugins/apps/src/vite/local-execution.fixtures.ts b/packages/plugins/apps/src/vite/local-execution.fixtures.ts deleted file mode 100644 index ab60111bf..000000000 --- a/packages/plugins/apps/src/vite/local-execution.fixtures.ts +++ /dev/null @@ -1,35 +0,0 @@ -// 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. - -// Not named `*.test.*` so `yarn test:unit` doesn't run it as its own suite. Shared by all three -// local-execution test files, including the spawned-process fixture — spawning isolates the OS -// process, not module resolution, so this import works there too. - -import type { BackendFunction } from '../backend/types'; - -import type { ExecuteAction, GetRuntimeContext, RuntimeContext } from './local-execution'; - -export const func: BackendFunction = { - relativePath: 'src/example', - name: 'example', - absolutePath: '/src/example.backend.ts', - allowedConnectionIds: [], -}; - -export const PROCESS_EXIT_FIXTURE_CODE = 7; - -export const stubExecuteAction: ExecuteAction = async (fqn) => ({ data: null, stub: true, fqn }); - -// A factory, not a shared constant: local-execution.ts spreads this shallowly onto `$`, so a -// reused instance would let one execution's mutation of `$.Source.initiator` leak into the next. -export function makePreviewRuntimeContext(): RuntimeContext { - return { - Source: { - initiator: { id: 'preview-initiator', orgId: 'preview-org' }, - runAsUser: { id: 'preview-run-as', orgId: 'preview-org' }, - }, - }; -} - -export const stubGetRuntimeContext: GetRuntimeContext = async () => makePreviewRuntimeContext(); diff --git a/packages/plugins/apps/src/vite/local-execution.process-exit.fixture.ts b/packages/plugins/apps/src/vite/local-execution.process-exit.fixture.ts deleted file mode 100644 index 8b9a2d8f8..000000000 --- a/packages/plugins/apps/src/vite/local-execution.process-exit.fixture.ts +++ /dev/null @@ -1,46 +0,0 @@ -// 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. - -// Not named `*.test.*` so `yarn test:unit` skips it — resilience.test.ts spawns it as its own Jest -// process instead, since process.exit() here would otherwise kill the parent worker. Jest still -// treats it as a test at runtime, so the eslint-disables below cover rules whose overrides key off -// the filename and don't apply here. - -// eslint-disable-next-line import/no-extraneous-dependencies -- test-only helper -import { mockLogger, moduleResolverFor } from '@dd/tests/_jest/helpers/mocks'; -import fs from 'fs'; - -import { - func, - PROCESS_EXIT_FIXTURE_CODE, - stubExecuteAction, - stubGetRuntimeContext, -} from './local-execution.fixtures'; -import { executeScriptLocally } from './local-execution'; - -// eslint-disable-next-line no-undef -- Jest injects `test` at runtime -test('process.exit fixture', async () => { - // Jest's console.log is buffered in-process and only flushed once a test result is reported — - // process.exit() never lets that happen, so the marker must go straight to the real stdout fd. - // `process.stdout.write` itself is asynchronous when stdout is a pipe (spawnSync's default) on - // POSIX, so process.exit() could still race ahead of it — fs.writeSync bypasses that entirely. - fs.writeSync(1, 'FIXTURE_STARTED\n'); - const resolveModule = moduleResolverFor(func, { - example: () => { - process.exit(PROCESS_EXIT_FIXTURE_CODE); - }, - }); - await executeScriptLocally( - func, - '/project', - [], - stubExecuteAction, - stubGetRuntimeContext, - resolveModule, - mockLogger, - 5000, - ); - // Unreachable if process.exit() truly bypasses cleanup; see note above for why this bypasses console.log. - fs.writeSync(1, 'FIXTURE_CLEANUP_RAN\n'); -}); diff --git a/packages/plugins/apps/src/vite/local-execution.resilience.test.ts b/packages/plugins/apps/src/vite/local-execution.resilience.test.ts deleted file mode 100644 index b796281ec..000000000 --- a/packages/plugins/apps/src/vite/local-execution.resilience.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -// 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. - -/** Confirms known v1 limitations of in-process execution (vs. an isolated child process/thread) — not bugs to fix. */ - -import { mockLogger, moduleResolverFor } from '@dd/tests/_jest/helpers/mocks'; -import { - FIXTURE_TEST_TIMEOUT_MS, - spawnJestFixture, -} from '@dd/tests/_jest/helpers/spawnJestFixture'; - -import { - func, - PROCESS_EXIT_FIXTURE_CODE, - stubExecuteAction, - stubGetRuntimeContext, -} from './local-execution.fixtures'; -import { executeScriptLocally } from './local-execution'; - -describe('local-execution resilience (in-process execution known limitations)', () => { - // A real `while (true) {}` would hang this test forever, since nothing — not even the timeout's - // own callback — can run while the event loop is blocked synchronously. This bounded busy-wait - // proves the same point safely: the timeout can't interrupt it, so it settles once the loop ends. - test('Should NOT interrupt a synchronous CPU-bound loop with the current timeout — known, accepted v1 limitation', async () => { - const resolver = moduleResolverFor(func, { - example: () => { - const deadline = Date.now() + 800; - // eslint-disable-next-line no-empty - while (Date.now() < deadline) {} - return 'loop finished on its own'; - }, - }); - - const start = Date.now(); - - const result = await executeScriptLocally( - func, - '/project', - [], - stubExecuteAction, - stubGetRuntimeContext, - resolver, - mockLogger, - 300, - ); - - const elapsedMs = Date.now() - start; - - expect(result).toEqual({ data: 'loop finished on its own' }); - expect(elapsedMs).toBeGreaterThanOrEqual(700); - }); - - // process.exit() would kill this Jest process, so the fixture runs as its own real Jest process — - // proving runScriptLocally's try/finally offers no protection, since exit() acts at the OS level. - // The helper's default --runInBand keeps the fixture out of a worker, so its exit code surfaces. - test( - "Should confirm process.exit() inside the customer function crashes the whole process, bypassing runScriptLocally's own try/finally cleanup — known, real risk, not a safely-contained failure", - () => { - const run = spawnJestFixture('**/local-execution.process-exit.fixture.ts', [], {}); - - // A signal kill leaves status null, so this also rules one out. - if (run.status !== PROCESS_EXIT_FIXTURE_CODE) { - throw new Error(`Unexpected child exit\n${run.output}`); - } - expect(run.stdout).toContain('FIXTURE_STARTED'); - expect(run.stdout).not.toContain('FIXTURE_CLEANUP_RAN'); - }, - FIXTURE_TEST_TIMEOUT_MS, - ); -}); diff --git a/packages/plugins/apps/src/vite/local-execution.test.ts b/packages/plugins/apps/src/vite/local-execution.test.ts deleted file mode 100644 index b838eb8a9..000000000 --- a/packages/plugins/apps/src/vite/local-execution.test.ts +++ /dev/null @@ -1,3251 +0,0 @@ -// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. -// This product includes software developed at Datadog (https://www.datadoghq.com/). -// Copyright 2019-Present Datadog, Inc. - -/* global globalThis, NodeJS */ - -import { rmSync } from '@dd/core/helpers/fs'; -import type { Logger } from '@dd/core/types'; -import { mockLogFn, mockLogger, moduleResolverFor } from '@dd/tests/_jest/helpers/mocks'; -import fs from 'fs'; -import os from 'os'; -import path from 'path'; - -import * as shared from '../backend/shared'; -import type { BackendFunction } from '../backend/types'; -import { LOCAL_EXECUTION_LOAD_SUFFIX } from '../constants'; - -import { - func, - makePreviewRuntimeContext, - stubExecuteAction, - stubGetRuntimeContext, -} from './local-execution.fixtures'; -import type { ExecuteAction, LoadModule } from './local-execution'; -import { - DEFAULT_LONG_POLLING_CONFIG, - DEFAULT_TIMEOUT_MS, - deriveActionTimeouts, - executeScriptLocally as executeScriptLocallyWithRuntimeContext, -} from './local-execution'; -import { makeProbeDirOutsideTmp } from './network-guard.fixtures'; -import { ALREADY_GUARDED, FOREIGN_GUARD_MESSAGE, FS_WRITE_BLOCKED_MESSAGE } from './network-guard'; - -const funcWithConnection: BackendFunction = { ...func, allowedConnectionIds: ['conn-1'] }; - -const TEST_PROJECT_ROOT = '/project'; - -interface TestGlobalDollar { - backendFunctionArgs: unknown[]; - // Left untyped: $.Actions is a Proxy of unbounded, dynamic depth ($.Actions.....(...)), the same shape a real customer's untyped code sees. - Actions: any; - Source: { initiator: { id: string; orgId: string }; runAsUser: { id: string; orgId: string } }; - previewMetadata?: unknown; -} - -/** 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 }).$; -} - -beforeEach(() => { - // Neither optional SDK is installed by default; tests exercising the "installed" path override this. - jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(false); - jest.spyOn(shared, 'isDatadogAppsBackendInstalled').mockReturnValue(false); -}); - -/** Keeps the existing test call sites concise while every invocation receives a fresh preview context. */ -function executeScriptLocally( - backendFunction: BackendFunction, - projectRoot: string, - args: unknown[], - executeAction: ExecuteAction, - loadModule: LoadModule, - log: Logger, - timeoutMs: number = DEFAULT_TIMEOUT_MS, - primedEntry?: Record, - longPolling = DEFAULT_LONG_POLLING_CONFIG, -) { - return executeScriptLocallyWithRuntimeContext( - backendFunction, - projectRoot, - args, - executeAction, - stubGetRuntimeContext, - loadModule, - log, - timeoutMs, - primedEntry, - longPolling, - ); -} - -// Per-run directory, so leftover probe files from a regressed guard can't collide across runs or workers. -let probeDir: string; -beforeAll(() => { - probeDir = makeProbeDirOutsideTmp('dd-local-execution-probes-'); -}); -afterAll(() => { - rmSync(probeDir); -}); - -/** 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 moduleResolverFor(func, exports); -} - -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( - func, - TEST_PROJECT_ROOT, - [21], - stubExecuteAction, - loadModuleReturning({ example: (n: number) => n * 2 }), - mockLogger, - ); - expect(result).toEqual({ data: 42 }); - }); - - // Local execution reads the developer's real environment, like any other Node tool, both in - // the function body and while its module loads. - test("Should let the function and its module loading read the dev server's ambient process.env", async () => { - const envKey = 'DD_LOCAL_EXECUTION_AMBIENT_ENV_PROBE'; - process.env[envKey] = 'visible'; - const resolveModule = loadModuleReturning({ example: () => process.env[envKey] }); - const readsDuringLoad: Array = []; - const loadModule: LoadModule = async (...args) => { - readsDuringLoad.push(process.env[envKey]); - return resolveModule(...args); - }; - try { - const result = await executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModule, - mockLogger, - ); - expect(result).toEqual({ data: 'visible' }); - expect(readsDuringLoad.length).toBeGreaterThan(0); - expect(readsDuringLoad).not.toContain(undefined); - } finally { - delete process.env[envKey]; - } - }); - - test('Should pick up a changed loadModule result on a subsequent call, not a stale cached result', async () => { - const first = await executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ example: () => 1 }), - mockLogger, - ); - const second = await executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ example: () => 2 }), - mockLogger, - ); - expect(first).toEqual({ data: 1 }); - expect(second).toEqual({ data: 2 }); - }); - - test('Should reject with a clear error when the named export is missing from the loaded module', async () => { - await expect( - executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ somethingElse: () => 1 }), - mockLogger, - ), - ).rejects.toThrow(`"example" is not a function exported from ${func.absolutePath}`); - }); - - test('Should 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 $ 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( - `Cannot find module '${specifier}'`, - ); - notFoundError.code = 'MODULE_NOT_FOUND'; - throw notFoundError; - }; - - const result = await executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModule, - mockLogger, - ); - - expect(result).toEqual({ data: 'done' }); - expect(dollarDuringModuleLoad).toBeUndefined(); - }); - - test("Should 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 executeScriptLocallyWithRuntimeContext; - try { - jest.isolateModules(() => { - // 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; - }); - - 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, - stubGetRuntimeContext, - 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 () => { - 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, - firstLoadModule, - mockLogger, - ); - - let dollarDuringSecondLoad: unknown = 'not captured'; - const secondLoadModule: LoadModule = async (specifier) => { - if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { - dollarDuringSecondLoad = (globalThis as Record).$; - return { example: () => 'second' }; - } - throw new Error(`Cannot find module '${specifier}'`); - }; - await executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - secondLoadModule, - mockLogger, - ); - - expect(dollarDuringSecondLoad).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 import time — 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( - funcWithConnection, - TEST_PROJECT_ROOT, - [], - executeAction, - loadModuleReturning({ - example: () => - testDollar().Actions.slack.chat.postMessage({ - inputs: { text: 'hi' }, - connectionId: 'conn-1', - }), - }), - mockLogger, - ); - expect(result).toEqual({ data: { ok: true } }); - expect(executeAction).toHaveBeenCalledWith( - 'com.datadoghq.slack.chat.postMessage', - { text: 'hi' }, - 'conn-1', - ); - }); - - test('Should reject with a clear error, not hang, when a customer function returns an un-invoked $.Actions reference instead of calling it', async () => { - // 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, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ - example: () => testDollar().Actions.slack.chat, - }), - mockLogger, - 20, - ), - ).rejects.toThrow(/JSON\.stringify silently drops/); - }); - - test('Should resolve a single-segment $.Actions.foo(...) call to a single-segment fqn', async () => { - const executeAction = jest.fn().mockResolvedValue({ ok: true }); - const result = await executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - executeAction, - loadModuleReturning({ - example: () => testDollar().Actions.foo({ inputs: { text: 'hi' } }), - }), - mockLogger, - ); - expect(result).toEqual({ data: { ok: true } }); - expect(executeAction).toHaveBeenCalledWith('com.datadoghq.foo', { text: 'hi' }, undefined); - }); - - test('Should resolve a $.Actions(...) call with no property access to a trailing-dot fqn with no action name segment', async () => { - // Documents current behavior: pathParts is empty at this call site, so `com.datadoghq.${pathParts.join('.')}` yields a malformed fqn rather than being rejected. - const executeAction = jest.fn().mockResolvedValue({ ok: true }); - const result = await executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - executeAction, - loadModuleReturning({ - example: () => testDollar().Actions({ inputs: { text: 'hi' } }), - }), - mockLogger, - ); - expect(result).toEqual({ data: { ok: true } }); - expect(executeAction).toHaveBeenCalledWith('com.datadoghq.', { text: 'hi' }, undefined); - }); - - test('Should resolve a deeply nested $.Actions.a.b.c.d(...) call to its full dotted fqn', async () => { - const executeAction = jest.fn().mockResolvedValue({ ok: true }); - const result = await executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - executeAction, - loadModuleReturning({ - example: () => testDollar().Actions.a.b.c.d({ inputs: { text: 'hi' } }), - }), - mockLogger, - ); - expect(result).toEqual({ data: { ok: true } }); - expect(executeAction).toHaveBeenCalledWith( - 'com.datadoghq.a.b.c.d', - { text: 'hi' }, - undefined, - ); - }); - - test("Should reject a $.Actions call whose connectionId isn't in the function's allowedConnectionIds", async () => { - const executeAction = jest.fn().mockResolvedValue({ ok: true }); - await expect( - executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - executeAction, - loadModuleReturning({ - example: () => - testDollar().Actions.slack.chat.postMessage({ - inputs: { text: 'hi' }, - connectionId: 'conn-not-allowed', - }), - }), - mockLogger, - ), - ).rejects.toThrow(/not in this function's allowed connections/); - expect(executeAction).not.toHaveBeenCalled(); - }); - - test('Should allow a $.Actions call with no connectionId regardless of allowedConnectionIds', async () => { - const executeAction = jest.fn().mockResolvedValue({ ok: true }); - const result = await executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - executeAction, - loadModuleReturning({ - example: () => - testDollar().Actions.slack.chat.postMessage({ - inputs: { text: 'hi' }, - }), - }), - mockLogger, - ); - expect(result).toEqual({ data: { ok: true } }); - }); - - test('Should reject when the action call is missing an inputs field', async () => { - await expect( - executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ - example: () => testDollar().Actions.slack.chat.postMessage({}), - }), - mockLogger, - ), - ).rejects.toThrow(/must have an inputs field/); - }); - - // A call with no connectionId skips assertConnectionIdAllowed, so a scope nested in its inputs - // would otherwise reach the destination unvalidated. - test('Should reject an action call that declares its own allowedConnectionIds inside inputs', async () => { - const executeAction = jest.fn().mockResolvedValue({ ok: true }); - await expect( - executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - executeAction, - loadModuleReturning({ - example: () => - testDollar().Actions.datatransformation.jsFunctionWithActions({ - inputs: { script: 'malicious', allowedConnectionIds: ['not-allowed'] }, - }), - }), - mockLogger, - ), - ).rejects.toThrow(/must not declare its own allowedConnectionIds/); - expect(executeAction).not.toHaveBeenCalled(); - }); - - // The pre-serialization check above only sees the inputs object as it exists before - // JSON.stringify calls toJSON() — a toJSON() that only introduces allowedConnectionIds in its - // return value would otherwise sail through unnoticed and reach executeAction. - test('Should reject an action call whose inputs introduce allowedConnectionIds only via a custom toJSON()', async () => { - const executeAction = jest.fn().mockResolvedValue({ ok: true }); - await expect( - executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - executeAction, - loadModuleReturning({ - example: () => - testDollar().Actions.datatransformation.jsFunctionWithActions({ - inputs: { - toJSON: () => ({ - script: 'malicious', - allowedConnectionIds: ['not-allowed'], - }), - }, - }), - }), - mockLogger, - ), - ).rejects.toThrow(/must not declare its own allowedConnectionIds/); - expect(executeAction).not.toHaveBeenCalled(); - }); - - // `in` matches inherited properties, not just inputs' own key — a polluted Object.prototype - // would otherwise make every subsequent call from every function in the process falsely appear - // to self-declare a scope, rejecting legitimate calls process-wide until restart. - test('Should not reject a legitimate call whose inputs have no own allowedConnectionIds, even when Object.prototype is polluted with that key', async () => { - Reflect.set(Object.prototype, 'allowedConnectionIds', ['hostile']); - try { - const executeAction = jest.fn().mockResolvedValue({ ok: true }); - await executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - executeAction, - loadModuleReturning({ - example: () => - testDollar().Actions.datatransformation.jsFunctionWithActions({ - inputs: { script: 'legitimate' }, - }), - }), - mockLogger, - ); - expect(executeAction).toHaveBeenCalled(); - } finally { - Reflect.deleteProperty(Object.prototype, 'allowedConnectionIds'); - } - }); - - // validateActionCall's own `typeof inputs !== 'object'` check passes an array through - // unchanged (typeof [] === 'object'), but serializeActionInputs's shape check downstream - // rejects it — inputs is semantically a plain object of named parameters, and a caller relying - // on `Record`-shaped inputs must never actually receive an array. - test('Should reject an array as inputs, since inputs is semantically a plain object of named parameters', async () => { - await expect( - executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ - example: () => - testDollar().Actions.slack.chat.postMessage({ inputs: ['a', 'b'] }), - }), - mockLogger, - ), - ).rejects.toThrow(/Inputs to action.*must be a plain object.*top-level shape to an array/); - }); - - test('Should reject a $.Actions call whose inputs contain a Map, instead of silently sending {} to the destination action', async () => { - await expect( - executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ - example: () => - testDollar().Actions.slack.chat.postMessage({ - inputs: { text: new Map([['a', 1]]) }, - }), - }), - mockLogger, - ), - ).rejects.toThrow(/Inputs to action.*silently flattens/); - }); - - test('Should reject a $.Actions call whose inputs contain NaN, instead of silently sending null to the destination action', async () => { - await expect( - executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ - example: () => - testDollar().Actions.slack.chat.postMessage({ - inputs: { score: NaN }, - }), - }), - mockLogger, - ), - ).rejects.toThrow(/Inputs to action.*silently converts to "null"/); - }); - - // Regression test: production's own JSON serialization of $.Actions inputs already silently - // omits an undefined-valued object property (e.g. `threadId: options?.threadId`) rather than - // erroring — a common optional-field pattern that must behave the same way locally. - test('Should silently omit an undefined-valued object property from $.Actions inputs, matching real JSON.stringify/production behavior', async () => { - const executeAction = jest.fn().mockResolvedValue({ ok: true }); - const result = await executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - executeAction, - loadModuleReturning({ - example: () => - testDollar().Actions.slack.chat.postMessage({ - inputs: { text: 'hi', threadId: undefined }, - }), - }), - mockLogger, - ); - expect(result).toEqual({ data: { ok: true } }); - expect(executeAction).toHaveBeenCalledWith( - 'com.datadoghq.slack.chat.postMessage', - { text: 'hi' }, - undefined, - ); - }); - - // Unlike an object property, JSON.stringify silently converts an array element's undefined to - // null instead of dropping it — real corruption, so this case still needs to be caught. - test('Should reject a $.Actions call whose inputs contain undefined inside an array, instead of silently sending null to the destination action', async () => { - await expect( - executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ - example: () => - testDollar().Actions.slack.chat.postMessage({ - inputs: { items: ['a', undefined, 'b'] }, - }), - }), - mockLogger, - ), - ).rejects.toThrow(/Inputs to action.*undefined inside an array.*silently converts to null/); - }); - - // A top-level toJSON() can change the round-tripped value's shape entirely (object -> string), - // which the JSON-corruption checks above don't catch — they validate what's inside the value, - // not what type the whole thing ends up being. Callers depend on getting a plain object back. - test('Should reject a $.Actions call whose inputs round-trip to something other than a plain object via a top-level toJSON()', async () => { - await expect( - executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ - example: () => - testDollar().Actions.slack.chat.postMessage({ - inputs: { toJSON: () => 'not an object' }, - }), - }), - mockLogger, - ), - ).rejects.toThrow(/Inputs to action.*must be a plain object.*top-level shape to string/); - }); - - test('Should reject with the thrown message when the customer function throws synchronously', async () => { - await expect( - executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ - example: () => { - throw new Error('boom'); - }, - }), - mockLogger, - ), - ).rejects.toThrow('boom'); - }); - - // Regression test: the "late failure" log fires only for an execution abandoned after the caller stopped waiting (see the test below) — here the caller is still waiting and gets the error via `rejects.toThrow` above. - test('Should not log a "caller had already stopped waiting" message for an ordinary, timely rejection', async () => { - await expect( - executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ - example: () => { - throw new Error('boom'); - }, - }), - mockLogger, - ), - ).rejects.toThrow('boom'); - - expect(mockLogFn).not.toHaveBeenCalledWith( - expect.stringContaining('already stopped waiting'), - 'debug', - ); - }); - - test('Should reject with the rejection reason when the customer function rejects asynchronously', async () => { - await expect( - executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ example: () => Promise.reject(new Error('async boom')) }), - mockLogger, - ), - ).rejects.toThrow('async boom'); - }); - - test('Should time out a hung async function with an explicit, attributed error', async () => { - await expect( - executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ example: () => new Promise(() => {}) }), - mockLogger, - 50, - ), - ).rejects.toThrow(/timed out after 50ms/); - }); - - // Proves the hang-detection timer only fires for a genuinely stuck execution, not for a legitimate in-flight $.Actions call that's still comfortably within its budget. - test('Should resolve normally when a legitimate in-flight $.Actions call finishes well within the timeout, without the hang-detection timer misfiring', async () => { - const executeAction: ExecuteAction = jest.fn( - () => new Promise((resolve) => setTimeout(() => resolve({ ok: true }), 50)), - ); - const result = await executeScriptLocally( - funcWithConnection, - TEST_PROJECT_ROOT, - [], - executeAction, - loadModuleReturning({ - example: () => - testDollar().Actions.slack.chat.postMessage({ - inputs: { text: 'hi' }, - connectionId: 'conn-1', - }), - }), - mockLogger, - 500, - ); - expect(result).toEqual({ data: { ok: true } }); - }); - - // The caller already moved on after the timeout rejection above; this covers the abandoned execution's own eventual failure, which has no caller left to report it to. - test('Should log a late failure from an abandoned execution instead of swallowing it silently', async () => { - let rejectHung: ((error: Error) => void) | undefined; - await expect( - executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ - example: () => - new Promise((_resolve, reject) => { - rejectHung = reject; - }), - }), - mockLogger, - 50, - ), - ).rejects.toThrow(/timed out after 50ms/); - - rejectHung?.(new Error('late failure after caller stopped waiting')); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(mockLogFn).toHaveBeenCalledWith( - expect.stringContaining('late failure after caller stopped waiting'), - 'debug', - ); - }); - - // Stands in for makeExecuteActionRemotely's long-poll, which 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)); - - const result = await executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - slowExecuteAction, - loadModuleReturning({ - example: () => - testDollar().Actions.slack.chat.postMessage({ - inputs: { text: 'hi' }, - }), - }), - mockLogger, - // Shorter than slowExecuteAction's own 80ms. - 50, - ); - - expect(result).toEqual({ data: { ok: true } }); - }); - - // Without a bound on the $.Actions call itself, a stalled request would wedge this - // execution and every request queued behind it via `enqueue` 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: () => - testDollar().Actions.slack.chat.postMessage({ - inputs: { text: 'hi' }, - }), - }), - mockLogger, - 50, - ); - // Enqueued behind hungExecution — proves the stalled $.Actions call doesn't block the - // queue for later executions. - const queuedNext = executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ example: () => 'next' }), - mockLogger, - ); - - const { totalExecutionTimeoutMs } = deriveActionTimeouts(DEFAULT_LONG_POLLING_CONFIG); - const hungAssertion = (async () => { - await expect(hungExecution).rejects.toThrow( - new RegExp( - `exceeded the absolute ${totalExecutionTimeoutMs}ms execution ceiling`, - ), - ); - })(); - - await jest.runAllTimersAsync(); - await hungAssertion; - - expect(await queuedNext).toEqual({ data: 'next' }); - } finally { - jest.useRealTimers(); - } - }); - - // A fire-and-forget $.Actions call pauses the per-call hang-detection timer for as long as it - // stays in flight (up to the derived per-call ceiling), 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 { - const neverSettlingExecuteAction: ExecuteAction = () => new Promise(() => {}); - - const execution = executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - neverSettlingExecuteAction, - loadModuleReturning({ - example: () => - testDollar().Actions.slack.chat.postMessage({ - inputs: { text: 'hi' }, - }), - }), - mockLogger, - 50, - ); - - const { totalExecutionTimeoutMs } = deriveActionTimeouts(DEFAULT_LONG_POLLING_CONFIG); - const assertion = (async () => { - await expect(execution).rejects.toThrow( - new RegExp( - `exceeded the absolute ${totalExecutionTimeoutMs}ms execution ceiling`, - ), - ); - })(); - - await jest.advanceTimersByTimeAsync(totalExecutionTimeoutMs); - await assertion; - } finally { - jest.useRealTimers(); - } - }); - - // The absolute ceiling re-arms on each new $.Actions call — without that, two genuinely - // healthy sequential calls (each individually within bounds) could still sum past a single - // fixed window from execution start. This doesn't weaken the hang protection above, which - // relies on the call never re-arming it at all. - test('Should not reject a function whose sequential $.Actions calls each individually stay within the absolute ceiling but sum past it', async () => { - jest.useFakeTimers(); - try { - const { totalExecutionTimeoutMs } = deriveActionTimeouts(DEFAULT_LONG_POLLING_CONFIG); - const delayedExecuteAction: ExecuteAction = () => - new Promise((resolve) => { - setTimeout(() => resolve('ok'), totalExecutionTimeoutMs - 10); - }); - - const execution = executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - delayedExecuteAction, - loadModuleReturning({ - example: async () => { - await testDollar().Actions.slack.chat.postMessage({ - inputs: { text: 'first' }, - }); - await testDollar().Actions.slack.chat.postMessage({ - inputs: { text: 'second' }, - }); - return 'done'; - }, - }), - mockLogger, - ); - - await jest.advanceTimersByTimeAsync(totalExecutionTimeoutMs * 2); - await expect(execution).resolves.toEqual({ data: 'done' }); - } 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 }); - - await expect( - executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - executeAction, - loadModuleReturning({ - example: async () => { - await testDollar().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/); - }); - - test("Should keep a hung function's own late continuation blocked after timeout, while a fresh execution afterward still works normally", async () => { - const probePath = path.join(probeDir, 'late.txt'); - let lateWriteAttempt: Promise | undefined; - - await expect( - executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ - example: () => - new Promise(() => { - // Scheduled, not awaited, so `example` never settles and the race - // below times out normally — fires after that 50ms timeout, not before. - setTimeout(() => { - lateWriteAttempt = fs.promises.writeFile(probePath, 'data'); - lateWriteAttempt.catch(() => undefined); - }, 100); - }), - }), - mockLogger, - 50, - ), - ).rejects.toThrow(/timed out after 50ms/); - - // Lets the hung function's own delayed continuation fire, well after the timeout above. - await new Promise((resolve) => setTimeout(resolve, 100)); - - // The abandoned continuation's own async chain stays permanently blocked (by design), so - // its late write attempt must still be rejected — an identity check on the guarded - // property can't verify this, since the wrapper never changes identity either way. - expect(lateWriteAttempt).toBeDefined(); - await expect(lateWriteAttempt).rejects.toThrow(FS_WRITE_BLOCKED_MESSAGE); - - // A fresh execution afterward must still work normally — the abandoned scope above must - // not permanently wedge fs/action access for everything that runs after it. - const result = await executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ - example: () => - testDollar().Actions.slack.chat.postMessage({ inputs: { text: 'hi' } }), - }), - mockLogger, - ); - expect(result).toEqual({ data: { data: null, stub: true, fqn: expect.any(String) } }); - }); - - test('Should preserve preview context fields while overriding invocation-owned args and Actions', async () => { - const getRuntimeContext = async () => ({ - ...makePreviewRuntimeContext(), - previewMetadata: { requestId: 'preview-request' }, - backendFunctionArgs: ['remote-placeholder'], - Actions: 'remote-placeholder', - }); - const result = await executeScriptLocallyWithRuntimeContext( - func, - TEST_PROJECT_ROOT, - ['local-argument'], - stubExecuteAction, - getRuntimeContext, - loadModuleReturning({ - example: () => { - const dollar = testDollar(); - return { - args: dollar.backendFunctionArgs, - previewMetadata: dollar.previewMetadata, - actionsIsRemotePlaceholder: dollar.Actions === 'remote-placeholder', - }; - }, - }), - mockLogger, - ); - expect(result).toEqual({ - data: { - args: ['local-argument'], - previewMetadata: { requestId: 'preview-request' }, - actionsIsRemotePlaceholder: false, - }, - }); - }); - - test('Should never expose a credential-shaped field anywhere on $, not just inside $.Source', async () => { - const result = await executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ - example: () => { - const CREDENTIAL_SUBSTRINGS = [ - 'token', - 'secret', - 'key', - 'password', - 'credential', - ]; - const hasCredentialName = (key: string) => - CREDENTIAL_SUBSTRINGS.some((substring) => - key.toLowerCase().includes(substring), - ); - // Recurses into every value, but never enumerates Actions itself (a Proxy dispatch - // mechanism, not a data container) — the preview response backing the rest of $ is - // validated only for Source's shape, so nothing else stops an unexpected field - // (present now or added later) from reaching it undetected. - const containsCredentialKey = (value: unknown): boolean => - typeof value === 'object' && - value !== null && - Object.entries(value).some( - ([key, nested]) => - hasCredentialName(key) || containsCredentialKey(nested), - ); - const dollar = testDollar(); - const { Actions: _actions, ...dollarWithoutActions } = dollar; - return ( - Object.keys(globalThis).some(hasCredentialName) || - containsCredentialKey(dollarWithoutActions) - ); - }, - }), - mockLogger, - ); - expect(result).toEqual({ data: false }); - }); - - test('Should populate $.Source with the preview identity, reachable via globalThis.$', async () => { - const result = await executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ example: () => testDollar().Source }), - mockLogger, - ); - expect(result).toEqual({ - data: { - initiator: { id: 'preview-initiator', orgId: 'preview-org' }, - runAsUser: { id: 'preview-run-as', orgId: 'preview-org' }, - }, - }); - }); - - test('Should reject malformed preview identity before loading the customer module', async () => { - const loadModule = jest.fn(); - const getRuntimeContext = async () => ({ - Source: { - initiator: { id: 'missing-org' }, - runAsUser: { id: 'run-as', orgId: 'preview-org' }, - }, - }); - - await expect( - executeScriptLocallyWithRuntimeContext( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - getRuntimeContext, - loadModule, - mockLogger, - ), - ).rejects.toThrow( - 'The preview runtime context Source.initiator must have a non-empty string "orgId" field.', - ); - expect(loadModule).not.toHaveBeenCalled(); - }); - - test("Should give each execution its own $.Source object, so one execution mutating it can't corrupt a later execution's identity", async () => { - const first = await executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ - example: () => { - testDollar().Source.initiator.id = 'hacked'; - return 'first done'; - }, - }), - mockLogger, - ); - expect(first).toEqual({ data: 'first done' }); - - const second = await executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ example: () => testDollar().Source }), - mockLogger, - ); - expect(second).toEqual({ - data: { - initiator: { id: 'preview-initiator', orgId: 'preview-org' }, - runAsUser: { id: 'preview-run-as', orgId: 'preview-org' }, - }, - }); - }); - - 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 { - const result = await executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ - example: () => { - (globalThis as Record).$ = { reassigned: true }; - return 'done'; - }, - }), - mockLogger, - ); - expect(result).toEqual({ data: 'done' }); - expect(Object.is((globalThis as Record).$, preExisting)).toBe(true); - } finally { - (globalThis as Record).$ = undefined; - } - }); - - 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 and must read the current $ (still `preExisting`) rather than 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( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ example: () => 'done' }), - mockLogger, - ); - expect((globalThis as Record).$).toBeUndefined(); - }); - - 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: () => { - (globalThis as Record).$ = { fromFirstExecution: true }; - return 'first'; - }, - }), - mockLogger, - ); - - const second = await executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ - example: () => Object.keys(testDollar()).sort(), - }), - mockLogger, - ); - expect(second).toEqual({ data: ['Actions', 'Source', 'backendFunctionArgs'] }); - }); - - describe('action-catalog / apps-backend registration', () => { - test('Should silently skip registration when neither package is installed', async () => { - // Confirms loadModuleReturning's rejection of other specifiers doesn't surface as an execution failure. - const result = await executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ example: () => 'fine' }), - mockLogger, - ); - expect(result).toEqual({ data: 'fine' }); - }); - - test('Should 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 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, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModule, - mockLogger, - ); - expect(registeredImpl).toBeDefined(); - }); - - test('Should reuse the cached action-catalog registration across executions that share the same loadModule reference, even when each passes a different primedEntry — matching dev-server.ts, which threads one stable loadModule but a fresh per-request primed module', async () => { - jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(true); - let actionCatalogLoadCount = 0; - const loadModule: LoadModule = async (specifier: string) => { - if (specifier === '@datadog/action-catalog/action-execution') { - actionCatalogLoadCount += 1; - return { setExecuteActionImplementation: () => {} }; - } - const error: NodeJS.ErrnoException = new Error(`Cannot find module '${specifier}'`); - error.code = 'MODULE_NOT_FOUND'; - throw error; - }; - - const first = await executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModule, - mockLogger, - undefined, - { example: () => 'first' }, - ); - const second = await executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModule, - mockLogger, - undefined, - { example: () => 'second' }, - ); - - expect(first).toEqual({ data: 'first' }); - expect(second).toEqual({ data: 'second' }); - expect(actionCatalogLoadCount).toBe(1); - }); - - test('Should propagate a real load failure from an installed action-catalog package, not treat it as absent', async () => { - jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(true); - const loadModule: LoadModule = async (specifier: string) => { - if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { - return { example: () => 'unreachable' }; - } - if (specifier === '@datadog/action-catalog/action-execution') { - // A real transform/evaluation failure, not a module-not-found error — must not be swallowed as "not installed". - throw new Error('Unexpected token in action-catalog/action-execution'); - } - const error: NodeJS.ErrnoException = new Error(`Cannot find module '${specifier}'`); - error.code = 'MODULE_NOT_FOUND'; - throw error; - }; - - await expect( - executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModule, - mockLogger, - ), - ).rejects.toThrow('Unexpected token in action-catalog/action-execution'); - }); - - // 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); - 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 }); - let registeredImpl: - | ((actionId: string, request: unknown) => Promise) - | undefined; - - const loadModule: LoadModule = async (specifier: string) => { - if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { - return { - example: async () => - registeredImpl?.('com.datadoghq.slack.chat.postMessage', { - inputs: { text: 'hi' }, - connectionId: 'conn-1', - }), - }; - } - if (specifier === '@datadog/action-catalog/action-execution') { - return { - setExecuteActionImplementation: ( - impl: (actionId: string, request: unknown) => Promise, - ) => { - registeredImpl = impl; - }, - }; - } - const error: NodeJS.ErrnoException = new Error(`Cannot find module '${specifier}'`); - error.code = 'MODULE_NOT_FOUND'; - throw error; - }; - - const result = await executeScriptLocally( - funcWithConnection, - TEST_PROJECT_ROOT, - [], - executeAction, - loadModule, - mockLogger, - ); - expect(result).toEqual({ data: { ok: true } }); - expect(executeAction).toHaveBeenCalledWith( - 'com.datadoghq.slack.chat.postMessage', - { text: 'hi' }, - 'conn-1', - ); - }); - - test("Should reject an action-catalog typed-wrapper call whose connectionId isn't in the function's allowedConnectionIds", async () => { - jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(true); - const executeAction = jest.fn().mockResolvedValue({ ok: true }); - let registeredImpl: - | ((actionId: string, request: unknown) => Promise) - | undefined; - - const loadModule: LoadModule = async (specifier: string) => { - if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { - return { - example: async () => - registeredImpl?.('com.datadoghq.slack.chat.postMessage', { - inputs: { text: 'hi' }, - connectionId: 'conn-not-allowed', - }), - }; - } - if (specifier === '@datadog/action-catalog/action-execution') { - return { - setExecuteActionImplementation: ( - impl: (actionId: string, request: unknown) => Promise, - ) => { - registeredImpl = impl; - }, - }; - } - const error: NodeJS.ErrnoException = new Error(`Cannot find module '${specifier}'`); - error.code = 'MODULE_NOT_FOUND'; - throw error; - }; - - await expect( - executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - executeAction, - loadModule, - mockLogger, - ), - ).rejects.toThrow(/not in this function's allowed connections/); - expect(executeAction).not.toHaveBeenCalled(); - }); - - test('Should reject an action-catalog typed-wrapper call missing an inputs field, same as a raw $.Actions call', async () => { - jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(true); - const executeAction = jest.fn().mockResolvedValue({ ok: true }); - let registeredImpl: - | ((actionId: string, request: unknown) => Promise) - | undefined; - - const loadModule: LoadModule = async (specifier: string) => { - if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { - return { - example: async () => - registeredImpl?.('com.datadoghq.slack.chat.postMessage', { - connectionId: 'conn-1', - }), - }; - } - if (specifier === '@datadog/action-catalog/action-execution') { - return { - setExecuteActionImplementation: ( - impl: (actionId: string, request: unknown) => Promise, - ) => { - registeredImpl = impl; - }, - }; - } - const error: NodeJS.ErrnoException = new Error(`Cannot find module '${specifier}'`); - error.code = 'MODULE_NOT_FOUND'; - throw error; - }; - - await expect( - executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - executeAction, - loadModule, - mockLogger, - ), - ).rejects.toThrow(/must have an inputs field/); - expect(executeAction).not.toHaveBeenCalled(); - }); - - // Mirrors the raw $.Actions path's nested-allowedConnectionIds test: validateActionCall's - // check is shared, but the typed-wrapper path reaches it through a different call site. - test('Should reject a typed-wrapper action call that declares its own allowedConnectionIds inside inputs', async () => { - jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(true); - const executeAction = jest.fn().mockResolvedValue({ ok: true }); - let registeredImpl: - | ((actionId: string, request: unknown) => Promise) - | undefined; - - const loadModule: LoadModule = async (specifier: string) => { - if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { - return { - example: async () => - registeredImpl?.( - 'com.datadoghq.datatransformation.jsFunctionWithActions', - { - inputs: { - script: 'malicious', - allowedConnectionIds: ['not-allowed'], - }, - }, - ), - }; - } - if (specifier === '@datadog/action-catalog/action-execution') { - return { - setExecuteActionImplementation: ( - impl: (actionId: string, request: unknown) => Promise, - ) => { - registeredImpl = impl; - }, - }; - } - const error: NodeJS.ErrnoException = new Error(`Cannot find module '${specifier}'`); - error.code = 'MODULE_NOT_FOUND'; - throw error; - }; - - await expect( - executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - executeAction, - loadModule, - mockLogger, - ), - ).rejects.toThrow(/must not declare its own allowedConnectionIds/); - expect(executeAction).not.toHaveBeenCalled(); - }); - - // Mirrors the raw $.Actions path's toJSON()-introduced-allowedConnectionIds test: the - // typed-wrapper path shares validateActionCall, but reaches it through a different call site. - test('Should reject a typed-wrapper action call whose inputs introduce allowedConnectionIds only via a custom toJSON()', async () => { - jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(true); - const executeAction = jest.fn().mockResolvedValue({ ok: true }); - let registeredImpl: - | ((actionId: string, request: unknown) => Promise) - | undefined; - - const loadModule: LoadModule = async (specifier: string) => { - if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { - return { - example: async () => - registeredImpl?.( - 'com.datadoghq.datatransformation.jsFunctionWithActions', - { - inputs: { - toJSON: () => ({ - script: 'malicious', - allowedConnectionIds: ['not-allowed'], - }), - }, - }, - ), - }; - } - if (specifier === '@datadog/action-catalog/action-execution') { - return { - setExecuteActionImplementation: ( - impl: (actionId: string, request: unknown) => Promise, - ) => { - registeredImpl = impl; - }, - }; - } - const error: NodeJS.ErrnoException = new Error(`Cannot find module '${specifier}'`); - error.code = 'MODULE_NOT_FOUND'; - throw error; - }; - - await expect( - executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - executeAction, - loadModule, - mockLogger, - ), - ).rejects.toThrow(/must not declare its own allowedConnectionIds/); - expect(executeAction).not.toHaveBeenCalled(); - }); - - // Mirrors the raw $.Actions path's malicious-toJSON() test: the action-catalog typed-wrapper - // path doesn't share code with makeActionsProxy, so it needs the same coverage separately. - test("Should block a malicious toJSON() on an action-catalog typed-wrapper call's request from making a real fs write", async () => { - jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(true); - let registeredImpl: - | ((actionId: string, request: unknown) => Promise) - | undefined; - const probePath = path.join(probeDir, 'toJSON-catalog.txt'); - let writeAttempt: Promise | undefined; - const maliciousRequest = { - inputs: { - text: 'hi', - toJSON() { - writeAttempt = fs.promises.writeFile(probePath, 'exfiltrated'); - return { text: 'hi' }; - }, - }, - connectionId: 'conn-1', - }; - - const loadModule: LoadModule = async (specifier: string) => { - if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { - return { - example: async () => - registeredImpl?.( - 'com.datadoghq.slack.chat.postMessage', - maliciousRequest, - ), - }; - } - if (specifier === '@datadog/action-catalog/action-execution') { - return { - setExecuteActionImplementation: ( - impl: (actionId: string, request: unknown) => Promise, - ) => { - registeredImpl = impl; - }, - }; - } - const error: NodeJS.ErrnoException = new Error(`Cannot find module '${specifier}'`); - error.code = 'MODULE_NOT_FOUND'; - throw error; - }; - - const result = await executeScriptLocally( - funcWithConnection, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModule, - mockLogger, - ); - - expect(result).toEqual({ data: { data: null, stub: true, fqn: expect.any(String) } }); - expect(writeAttempt).toBeDefined(); - await expect(writeAttempt).rejects.toThrow(FS_WRITE_BLOCKED_MESSAGE); - }); - - // 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'), - }); - }); - - // 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', () => { - 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 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 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 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 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 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/); - }); - - // A pre-stringify scan of the original value would miss this: toJSON() only runs during - // JSON.stringify itself, so the symbol-keyed object it returns must be checked where the - // replacer actually sees it, not on the value returned from the customer function. - test('Should reject a Symbol-keyed property introduced only by a custom toJSON(), not present on the original value', async () => { - const secretSymbol = Symbol('secret'); - await expect( - executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ - example: () => ({ - status: 'ok', - toJSON: () => ({ replaced: true, [secretSymbol]: 'leaked' }), - }), - }), - mockLogger, - ), - ).rejects.toThrow(/example.*Symbol-keyed property/); - }); - - // Production's own HTTP serialization of a function's return value already silently omits - // an undefined-valued object property rather than erroring, so local execution must match. - test('Should silently omit an undefined nested inside a plain object from the return value, matching production, not just at the top level', async () => { - const result = await executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ example: () => ({ status: 'ok', extra: undefined }) }), - mockLogger, - ); - expect(result).toEqual({ data: { status: 'ok' } }); - }); - - test('Should silently omit an undefined at a property literally named the empty string, not mistake it for the JSON root', async () => { - const result = await executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ example: () => ({ '': undefined, other: 'ok' }) }), - mockLogger, - ); - expect(result).toEqual({ data: { other: 'ok' } }); - }); - - 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( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ example: () => [1, Symbol('unsupported')] }), - mockLogger, - ), - ).rejects.toThrow(/example.*symbol inside an array.*silently converts to null/); - }); - - // Unlike an object property, JSON.stringify silently converts an array element's undefined - // to null instead of dropping it — real corruption, so this must still be caught. - test('Should reject an undefined nested inside an array of the return value, instead of silently sending null', async () => { - await expect( - executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ example: () => [1, undefined, 2] }), - mockLogger, - ), - ).rejects.toThrow(/example.*undefined inside an array.*silently converts to null/); - }); - - 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('fs write/subprocess guard', () => { - test('Should reject when the customer function tries a raw fs write', async () => { - const probePath = path.join(probeDir, 'raw-write.txt'); - await expect( - executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ - example: () => { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const rawFs = require('fs'); - return rawFs.promises.writeFile(probePath, 'data'); - }, - }), - mockLogger, - ), - ).rejects.toThrow(FS_WRITE_BLOCKED_MESSAGE); - }); - - test('Should reject when the customer function tries to spawn a subprocess', async () => { - await expect( - executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ - example: () => { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const child_process = require('child_process'); - return child_process.execSync('curl https://example.com'); - }, - }), - mockLogger, - ), - ).rejects.toThrow(/Spawning a subprocess is not allowed/); - }); - - test('Should still let a real $.Actions call through while the rest of the function is blocked', async () => { - const probePath = path.join(probeDir, 'after-action.txt'); - const executeAction = jest.fn().mockResolvedValue({ ok: true }); - const result = await executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - executeAction, - loadModuleReturning({ - example: async () => { - const actionResult = await testDollar().Actions.slack.chat.postMessage({ - inputs: { text: 'hi' }, - }); - // A write right after a $.Actions call must still be blocked. - const blockedWrite = fs.promises.writeFile(probePath, 'data'); - await expect(blockedWrite).rejects.toThrow(FS_WRITE_BLOCKED_MESSAGE); - return actionResult; - }, - }), - mockLogger, - ); - expect(result).toEqual({ data: { ok: true } }); - expect(executeAction).toHaveBeenCalledWith( - 'com.datadoghq.slack.chat.postMessage', - { text: 'hi' }, - undefined, - ); - }); - - test('Should refuse path writes from an execution abandoned by its timeout', async () => { - const workPrefix = path.join(os.tmpdir(), 'dd-local-execution-abandoned-'); - const workDir = fs.mkdtempSync(workPrefix); - const lateFile = path.join(workDir, 'late.txt'); - let lateWriteError: unknown; - let lateWriteDone: (() => void) | undefined; - const lateWriteFinished = new Promise((resolve) => { - lateWriteDone = resolve; - }); - const loadModule = loadModuleReturning({ - example: async () => { - setTimeout(() => { - try { - fs.writeFileSync(lateFile, 'data'); - } catch (err) { - lateWriteError = err; - } - lateWriteDone?.(); - }, 60); - await new Promise(() => {}); - }, - }); - try { - const abandoned = executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModule, - mockLogger, - 20, - ); - - await expect(abandoned).rejects.toThrow(/timed out after 20ms/); - await lateWriteFinished; - const written = fs.existsSync(lateFile); - - expect(lateWriteError).toMatchObject({ message: FS_WRITE_BLOCKED_MESSAGE }); - expect(written).toBe(false); - } finally { - rmSync(workDir); - } - }); - - test('Should let the function write and read a file under os.tmpdir()', async () => { - const tmpFile = path.join( - os.tmpdir(), - `dd-local-execution-${process.pid}-${Date.now()}.json`, - ); - const loadModule = loadModuleReturning({ - example: async () => { - await fs.promises.writeFile(tmpFile, '{"cached":true}'); - return fs.promises.readFile(tmpFile, 'utf8'); - }, - }); - try { - const result = await executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModule, - mockLogger, - ); - - expect(result).toEqual({ data: '{"cached":true}' }); - } finally { - rmSync(tmpFile); - } - }); - - // The dispatch reaches code the customer can replace, like globalThis.fetch. - test("Should keep the $.Actions dispatch inside the function's blocked scope", async () => { - const dispatchWritePath = path.join(probeDir, 'dispatch-write.txt'); - let dispatchWriteError: unknown; - const executeAction: ExecuteAction = async () => { - try { - fs.writeFileSync(dispatchWritePath, 'cached'); - } catch (err) { - dispatchWriteError = err; - } - return { ok: true }; - }; - const loadModule = loadModuleReturning({ - example: () => - testDollar().Actions.slack.chat.postMessage({ inputs: { text: 'hi' } }), - }); - const result = await executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - executeAction, - loadModule, - mockLogger, - ); - const dispatchWritten = fs.existsSync(dispatchWritePath); - - expect(result).toEqual({ data: { ok: true } }); - expect(dispatchWriteError).toMatchObject({ message: FS_WRITE_BLOCKED_MESSAGE }); - expect(dispatchWritten).toBe(false); - }); - - test('Should block a malicious toJSON() on $.Actions inputs from making a real fs write', async () => { - // toJSON() must be synchronous, so its write attempt can't be awaited there — capture the outcome and assert once the whole execution settles. - const probePath = path.join(probeDir, 'toJSON.txt'); - let writeAttempt: Promise | undefined; - const maliciousInputs = { - text: 'hi', - toJSON() { - writeAttempt = fs.promises.writeFile(probePath, 'exfiltrated'); - return { text: 'hi' }; - }, - }; - - const result = await executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ - example: () => - testDollar().Actions.slack.chat.postMessage({ - inputs: maliciousInputs, - }), - }), - mockLogger, - ); - - expect(result).toEqual({ data: { data: null, stub: true, fqn: expect.any(String) } }); - expect(writeAttempt).toBeDefined(); - await expect(writeAttempt).rejects.toThrow(FS_WRITE_BLOCKED_MESSAGE); - }); - - test('Should restore fs write access after execution, for whatever the dev server itself does next', async () => { - await executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ example: () => 'fine' }), - mockLogger, - ); - const afterPath = path.join(probeDir, 'after.txt'); - fs.writeFileSync(afterPath, 'written'); - const content = fs.readFileSync(afterPath, 'utf8'); - expect(content).toBe('written'); - }); - - // Adapter resolution goes through Vite's own module loader, which can write caches. - test("Should not block fs writes while loading the action-catalog adapter, since that's Vite's own module resolution, not customer code", async () => { - jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(true); - const cachePath = path.join(probeDir, 'vite-dep-cache.json'); - const loadModule: LoadModule = async (specifier: string) => { - if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { - return { example: () => 'fine' }; - } - if (specifier === '@datadog/action-catalog/action-execution') { - await fs.promises.writeFile(cachePath, '{}'); - return { setExecuteActionImplementation: () => {} }; - } - 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, - ); - const cacheWritten = fs.existsSync(cachePath); - - expect(result).toEqual({ data: 'fine' }); - expect(cacheWritten).toBe(true); - }); - - test('Should not load the adapter packages for an execution already abandoned while its own module was still loading', async () => { - jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(true); - const requestedSpecifiers: string[] = []; - const loadModule: LoadModule = async (specifier: string) => { - requestedSpecifiers.push(specifier); - if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { - await new Promise((resolve) => setTimeout(resolve, 60)); - return { example: () => 'fine' }; - } - return { setExecuteActionImplementation: () => {} }; - }; - - const abandoned = executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModule, - mockLogger, - 20, - ); - await expect(abandoned).rejects.toThrow(/timed out after 20ms/); - // The zombie logs this once it gives up, so waiting for it means it has passed the registration step. - const zombieGaveUp = () => - mockLogFn.mock.calls.some(([text]) => - String(text).includes('was abandoned after timing out before it could start'), - ); - for (let attempt = 0; attempt < 100 && !zombieGaveUp(); attempt++) { - await new Promise((resolve) => setTimeout(resolve, 10)); - } - const gaveUp = zombieGaveUp(); - - expect(gaveUp).toBe(true); - expect(requestedSpecifiers).toEqual([func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX]); - }); - }); - - describe('loadCustomerModuleEntry', () => { - // The guards must be installed before customer top-level code runs, or it could keep raw - // fs/child_process references. Isolated modules keep an earlier test's install from satisfying it. - test("Should install the guards before evaluating the customer module's top-level code", async () => { - const calls: string[] = []; - - await jest - .isolateModulesAsync(async () => { - jest.doMock('./network-guard', () => ({ - assertNoForeignGuard: () => undefined, - installGuards: () => { - calls.push('guards-installed'); - }, - runBlocked: async (fn: () => Promise) => fn(), - })); - - const { - loadCustomerModuleEntry: isolatedLoadCustomerModuleEntry, - } = require('./local-execution'); - - const loadModule: LoadModule = async () => { - calls.push('customer-module-loaded'); - return { example: () => 'done' }; - }; - - const mod = await isolatedLoadCustomerModuleEntry( - loadModule, - func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX, - ); - expect(mod).toEqual({ example: expect.any(Function) }); - }) - .finally(() => { - jest.dontMock('./network-guard'); - }); - - expect(calls).toEqual(['guards-installed', 'customer-module-loaded']); - }); - - test("Should refuse before evaluating the customer module when another release's guard is installed", async () => { - const loadModule = jest.fn, Parameters>(); - - await jest.isolateModulesAsync(async () => { - const isolatedGuard: typeof import('./network-guard') = require('./network-guard'); - const foreignGetter = () => () => 'real'; - Object.defineProperty(foreignGetter, ALREADY_GUARDED, { value: true }); - const target = {}; - Object.defineProperty(target, 'value', { get: foreignGetter, configurable: true }); - isolatedGuard.installGuardedProperty( - target, - 'value', - (getReal: () => unknown) => getReal, - ); - const { - loadCustomerModuleEntry: isolatedLoadCustomerModuleEntry, - } = require('./local-execution'); - - const loading = isolatedLoadCustomerModuleEntry( - loadModule, - func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX, - ); - - await expect(loading).rejects.toThrow(FOREIGN_GUARD_MESSAGE); - }); - - expect(loadModule).not.toHaveBeenCalled(); - }); - }); - - describe('serialization of concurrent executions', () => { - 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 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: recordingOrder('A', 20) }), - mockLogger, - ), - executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ example: recordingOrder('B', 0) }), - mockLogger, - ), - ]); - - expect([resultA, resultB]).toEqual([{ data: 'A' }, { data: 'B' }]); - 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-/), - expect.stringMatching(/^end-/), - expect.stringMatching(/^start-/), - expect.stringMatching(/^end-/), - ]); - 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 { - return () => - new Promise((resolve) => - setTimeout(() => resolve(testDollar().backendFunctionArgs), delayMs), - ); - } - - // 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, - TEST_PROJECT_ROOT, - ['A-arg'], - stubExecuteAction, - loadModuleReturning({ example: readOwnArgsAfterDelay(20) }), - mockLogger, - ), - executeScriptLocally( - func, - TEST_PROJECT_ROOT, - ['B-arg'], - stubExecuteAction, - loadModuleReturning({ example: readOwnArgsAfterDelay(0) }), - mockLogger, - ), - ]); - expect(resultA).toEqual({ data: ['A-arg'] }); - expect(resultB).toEqual({ data: ['B-arg'] }); - }); - - 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 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'; - - 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 } = 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 { - 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, 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 { - // 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/); - - // 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, - [], - 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), 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(); - }); - - // 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'; - 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/); - - // 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({ - rejected: expect.stringContaining('already concluded'), - }); - }); - - // 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'] }; - 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 — inside funcB's in-flight window — using conn-B, a connection funcA 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 conclude 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 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); - 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, - ); - }); - - // 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); - - 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); - 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'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. - await new Promise((resolve) => setTimeout(resolve, 100)); - - expect(registeredImpl).toBe(registeredAfterB); - }); - - // 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: - | ((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, - ); - }); - - // Neither the module load nor the registration individually exceeds its own timeout budget, but their SUM crosses the outer timeout — proves the specific "abandoned ... before it could start" rejection fires (and is logged) for this cumulative-delay case, not just an individual step timing out. - test("Should log 'abandoned ... before it could start' when cumulative module-load + registration delay crosses the timeout, without either step individually exceeding it", async () => { - jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(true); - const loadModule: LoadModule = async (specifier: string) => { - if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { - // Well under the 20ms timeout on its own. - await new Promise((resolve) => setTimeout(resolve, 12)); - return { example: () => 'should never run' }; - } - if (specifier === '@datadog/action-catalog/action-execution') { - // Also well under 20ms on its own, but by now ~24ms have elapsed since scope.start(). - await new Promise((resolve) => setTimeout(resolve, 12)); - return { setExecuteActionImplementation: () => {} }; - } - const notFoundError: NodeJS.ErrnoException = new Error( - `Cannot find module '${specifier}'`, - ); - notFoundError.code = 'MODULE_NOT_FOUND'; - throw notFoundError; - }; - - await expect( - executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModule, - mockLogger, - 20, - ), - ).rejects.toThrow(/timed out after 20ms/); - - // Give registration room to resolve and run()'s own rejection to be logged. - await new Promise((resolve) => setTimeout(resolve, 40)); - - expect(mockLogFn).toHaveBeenCalledWith( - expect.stringContaining('was abandoned after timing out before it could start'), - 'debug', - ); - }); - - // 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); - }); - - // An abandoned execution's loadModule can resolve late, after a newer one is already inside the guards — it must not corrupt the newer state. - test("Should never let an abandoned execution's late-resolving loadModule enter the guards while a newer execution is still inside them", async () => { - const makeLoadModule = (mainDelayMs: number): LoadModule => { - return async (specifier: string) => { - if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { - if (mainDelayMs > 0) { - await new Promise((resolve) => setTimeout(resolve, mainDelayMs)); - } - return { - example: async () => { - // B's own body: still running when A's slow loadModule resolves, so any state A corrupts on its way in would be visible here. - await new Promise((resolve) => setTimeout(resolve, 200)); - return 'b-result'; - }, - }; - } - const notFoundError: NodeJS.ErrnoException = new Error( - `Cannot find module '${specifier}'`, - ); - notFoundError.code = 'MODULE_NOT_FOUND'; - throw notFoundError; - }; - }; - - // A times out at 20ms, well before its own 150ms-delayed loadModule resolves. - const abandoned = executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - makeLoadModule(150), - mockLogger, - 20, - ); - await expect(abandoned).rejects.toThrow(/timed out after 20ms/); - - // B starts as soon as the queue frees, and is still running its own 200ms body when A's loadModule resolves at the ~150ms mark. - const second = executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - makeLoadModule(0), - mockLogger, - ); - - await expect(second).resolves.toEqual({ data: 'b-result' }); - }); - }); -}); diff --git a/packages/plugins/apps/src/vite/local-execution.ts b/packages/plugins/apps/src/vite/local-execution.ts index 441890abf..467b12199 100644 --- a/packages/plugins/apps/src/vite/local-execution.ts +++ b/packages/plugins/apps/src/vite/local-execution.ts @@ -2,31 +2,14 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -/* global Proxy, globalThis */ +/** Contracts and limits local execution shares between the dev server and each call's process. */ -/** Executes a backend function's file directly in-process inside the Vite dev server — a drop-in alternate to dev-server.ts's executeScriptViaDatadog, mirroring its `BackendOutputs` contract. */ - -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 type { LongPollingOptions } from '../types'; import { resolveLongPolling } from '../validate'; -import { createEpochGuard } from './execution-epoch'; -import type { ActionCallArgs } from './local-execution-shared'; -import { - assertJsonRoundTrippable, - assertNoSelfDeclaredScope, - isIndexableRecord, - makeActionsProxy, - returnValueSubject, - serializeActionInputs, - validateActionCall, -} from './local-execution-shared'; -import { assertNoForeignGuard, installGuards, runBlocked } from './network-guard'; +import { isIndexableRecord } from './local-execution-shared'; import { getTotalRetryDelayBudgetMs } from './retry-delay'; type RuntimeUser = { @@ -43,91 +26,9 @@ export type RuntimeContext = Record & { }; }; -type BackendGlobals = RuntimeContext & { - backendFunctionArgs: unknown[]; - Actions: unknown; -}; - -/** 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 so a zombie execution's late "fresh" read resolves to its own `$`, never a newer execution's identity. */ -const backendGlobalsContext = new AsyncLocalStorage(); - -/** Whether `$` was installed (e.g. by `zx/globals`) before this module's own accessor — 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 the window where a customer module's own top-level code is loading (narrower than "between executions," where the undefined-returning fallback below applies instead). Carries its own mutable box so a top-level `$` write (e.g. `zx/globals`) is scoped to this load, not the shared `globalDollarOutsideExecution` slot a later, unrelated load would also read. */ -const customerModuleLoadContext = new AsyncLocalStorage<{ assigned: boolean; value: unknown }>(); - -/** Scopes vite/index.ts's suffixed-subgraph tracking to one entry's own module-graph traversal — every caller funnels through `loadCustomerModuleEntry`, so scoping here reaches every path uniformly. Without this, a process-wide Set would let a helper module stay marked for the dev server's whole lifetime, so a later unrelated SSR resolution of that helper would inherit the marker and serve real backend code instead of the frontend RPC-proxy stub. */ +/** Scopes vite/index.ts's suffixed-subgraph tracking to one call's module loads, so a later unrelated SSR resolution of a helper never inherits the marker and serves real backend code instead of the frontend proxy stub. */ export const localExecutionResolutionContext = new AsyncLocalStorage>(); -/** Backs `globalThis.$` outside any execution box; 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 { - 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: $ isn't global until main() assigns it, so it reads as undefined - // rather than throwing, even though it's a real accessor property here, not absent. - return undefined; - } - 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 load, not globalDollarOutsideExecution — otherwise a top-level write (e.g. zx/globals) would leak into every later, unrelated load. - loadBox.assigned = true; - loadBox.value = value; - return; - } - globalDollarOutsideExecution = value; -} - -ensureDollarAccessorInstalled(); - -/** What the stable, once-ever-registered adapters below need to dispatch a call to whichever execution is live on the AsyncLocalStorage stack — kept out of `BackendGlobals` since that's also `globalThis.$`, 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(); - function assertRuntimeUser(value: unknown, label: string): asserts value is RuntimeUser { if (!isIndexableRecord(value) || Array.isArray(value)) { throw new Error(`The preview runtime context ${label} must be an object.`); @@ -197,24 +98,6 @@ export function deriveActionTimeouts(longPolling: LongPollingConfig): { }; } -/** 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 under the same top-level `$`-scoping `runScriptLocally` uses, for callers (like `executeColdActionLocally`'s priming) that evaluate it before `runScriptLocally`. Its top-level code runs before `runBlocked`, so it isn't guarded; installing first still hands it the guarded fs/child_process functions. */ -export async function loadCustomerModuleEntry( - loadModule: LoadModule, - entrySpecifier: string, -): Promise> { - installGuards(); - // Checked before the customer's top-level code runs, since runBlocked would refuse anyway. - assertNoForeignGuard(); - return localExecutionResolutionContext.run(new Set(), () => - 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, @@ -225,28 +108,10 @@ export type ExecuteAction = ( /** Resolves a fresh serializable `$` snapshot from an authenticated preview execution. */ export type GetRuntimeContext = () => Promise; -/** Shared validate → serialize → dispatch sequence for both $.Actions entry points, so a contract change can't reach one and miss the other. */ -async function invokeAction( - executeAction: ExecuteAction, - actionId: string, - call: Partial, - allowedConnectionIds: string[], - actionDescription: string, -): Promise { - const { inputs, connectionId } = validateActionCall( - call, - allowedConnectionIds, - actionDescription, - ); - const serializedInputs = serializeActionInputs(inputs, actionDescription); - assertNoSelfDeclaredScope(serializedInputs, actionDescription); - return executeAction(actionId, serializedInputs, connectionId); -} - -/** Serializes local executions — a customer function deleting `globalThis.$` mid-flight would otherwise break `$` access for any other execution concurrently in progress (see `ensureDollarAccessorInstalled`). */ +/** Runs local executions one at a time, in arrival order. */ let queueTail: Promise = Promise.resolve(); -function enqueue(run: () => Promise): Promise { +export function enqueue(run: () => Promise): Promise { const result = queueTail.then(run); queueTail = result.then( () => undefined, @@ -255,23 +120,7 @@ function enqueue(run: () => Promise): Promise { return result; } -// Shared wording for the "no longer current" rejection at every abandonment call site. -function abandonedExecutionError(functionName: string, refusedAction: string): Error { - return new Error( - `Execution of "${functionName}" already concluded; refusing to ${refusedAction} ` + - `as this stale execution to avoid using a newer execution's identity.`, - ); -} - -/** - * One shared guard across all executions — `enqueue` only serializes each execution's start, so a - * timed-out `fn()` keeps running (abandoned, not canceled). `isCurrent()` rejects that zombie's - * later dispatch once a newer scope takes over; `concludeIfCurrent()` only clears the generation if - * its own scope is still active, so delayed cleanup can't clobber a newer scope. - */ -const executionEpoch = createEpochGuard(); - -/** 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. Doesn't cancel the underlying 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 subject of the timeout message (e.g. `` `Loading ${specifier}` ``), not a suffix on a fixed prefix. */ +/** Bounds a promise that could otherwise hang forever, such as a `$.Actions` call with no deadline of its own, without cancelling it. `label` is the full subject of the timeout message. */ export function withTimeout(promise: Promise, timeoutMs: number, label: string): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => { @@ -289,455 +138,3 @@ export function withTimeout(promise: Promise, timeoutMs: number, label: st ); }); } - -/** Shared once-ever-registration wrapper for both adapters below: no-ops if uninstalled (re-checked uncached, so a mid-session install is picked up on the next execution), caches by `loadModule` identity (true once-ever for a real dev server's reused `ssrLoadModule`, isolated per test), and evicts a rejection so the next execution retries instead of staying poisoned. */ -function registerOnceIfInstalled( - isInstalled: (projectRoot: string) => boolean, - registrations: WeakMap>, - registerOnce: (loadModule: LoadModule, timeoutMs: number) => Promise, - loadModule: LoadModule, - projectRoot: string, - timeoutMs: number, -): Promise { - if (!isInstalled(projectRoot)) { - return Promise.resolve(); - } - const existing = registrations.get(loadModule); - if (existing) { - return existing; - } - const registration = registerOnce(loadModule, timeoutMs).catch((err) => { - registrations.delete(loadModule); - throw err; - }); - registrations.set(loadModule, registration); - return registration; -} - -/** Keyed by `loadModule` identity — see `registerOnceIfInstalled`'s doc comment. */ -const actionCatalogRegistrations = new WeakMap>(); - -/** Registers one stable dispatcher for the process lifetime that reads `executionDispatchContext.getStore()` at call time, so a zombie's typed-wrapper call can never dispatch under a newer execution's identity. */ -function registerActionCatalogIfInstalled( - loadModule: LoadModule, - projectRoot: string, - timeoutMs: number, -): Promise { - return registerOnceIfInstalled( - isActionCatalogInstalled, - actionCatalogRegistrations, - registerActionCatalogOnce, - loadModule, - projectRoot, - timeoutMs, - ); -} - -async function registerActionCatalogOnce(loadModule: LoadModule, timeoutMs: number): Promise { - const loadPromise = loadModule('@datadog/action-catalog/action-execution'); - const mod = await withTimeout( - loadPromise, - timeoutMs, - 'Loading @datadog/action-catalog/action-execution', - ); - const setExecuteActionImplementation = mod.setExecuteActionImplementation; - if (typeof setExecuteActionImplementation !== 'function') { - 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 abandonedExecutionError(dispatch.functionName, `run "${actionId}"`); - } - const call: Partial = isIndexableRecord(request) ? request : {}; - return invokeAction( - dispatch.executeAction, - actionId, - call, - dispatch.allowedConnectionIds, - `"${actionId}"`, - ); - }); -} - -/** Mirrors `actionCatalogRegistrations` — see `registerOnceIfInstalled`'s doc comment. */ -const backendRuntimeRegistrations = new WeakMap>(); - -/** Registers one stable runtime Proxy for the process lifetime that resolves whichever execution's `$` is live on the AsyncLocalStorage stack, rather than binding to one execution's `$` at registration time. */ -function registerBackendRuntimeIfInstalled( - loadModule: LoadModule, - projectRoot: string, - timeoutMs: number, -): Promise { - return registerOnceIfInstalled( - isDatadogAppsBackendInstalled, - backendRuntimeRegistrations, - registerBackendRuntimeOnce, - loadModule, - projectRoot, - timeoutMs, - ); -} - -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( - loadPromise, - timeoutMs, - 'Loading @datadog/apps-backend/runtime', - ); - const buildRuntimeFromJsFunctionWithActions = - jsFunctionWithActionsModule.buildRuntimeFromJsFunctionWithActions; - const setBackend = runtimeModule.setBackend; - if ( - typeof buildRuntimeFromJsFunctionWithActions !== 'function' || - typeof setBackend !== 'function' - ) { - return; - } - // Cached by dispatch identity, not rebuilt per accessor call — dispatch.$ is fixed for the whole execution. - const runtimeByDispatch = new WeakMap(); - // 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( - {}, - { - get(_target, prop) { - const dispatch = executionDispatchContext.getStore(); - if (!dispatch) { - throw new Error( - `No active local execution to resolve an apps-backend accessor under.`, - ); - } - if (dispatch.isAbandoned()) { - throw abandonedExecutionError( - dispatch.functionName, - 'resolve a further apps-backend accessor', - ); - } - let runtime = runtimeByDispatch.get(dispatch); - if (runtime === undefined) { - runtime = buildRuntimeFromJsFunctionWithActions(dispatch.$); - runtimeByDispatch.set(dispatch, runtime); - } - if (!isIndexableRecord(runtime)) { - return undefined; - } - const value = runtime[String(prop)]; - // 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; - }, - }, - ); - setBackend(backendRuntimeProxy); -} - -function assertJsonSerializable(result: unknown, func: BackendFunction): unknown { - const subject = returnValueSubject(func.name); - return assertJsonRoundTrippable(result, subject); -} - -/** - * Test-only entry point: exercises `runScriptLocally`'s queue/execution behavior with priming - * already done via `primedEntry`. Production goes through `executeColdActionLocally` instead, which - * primes inside the same `enqueue()` call — kept separate so `primedEntry` keeps its current - * meaning for the tests calling this directly. - */ -export async function executeScriptLocally( - func: BackendFunction, - projectRoot: string, - args: unknown[], - executeAction: ExecuteAction, - getRuntimeContext: GetRuntimeContext, - loadModule: LoadModule, - log: Logger, - timeoutMs: number = DEFAULT_TIMEOUT_MS, - primedEntry?: Record, - longPolling: LongPollingConfig = DEFAULT_LONG_POLLING_CONFIG, -): Promise { - return enqueue(async () => { - const runtimeContext = await getRuntimeContext(); - assertValidRuntimeContext(runtimeContext); - return runScriptLocally( - func, - projectRoot, - args, - executeAction, - runtimeContext, - loadModule, - log, - timeoutMs, - primedEntry, - longPolling, - ); - }); -} - -/** - * Cold-function entry point: collects `allowedConnectionIds`, then primes and runs the entry in - * one `enqueue()` call — priming runs real top-level code, so doing it outside the queue would let - * two cold functions run in parallel. Connection IDs are collected first, executing no code, so a - * banned import is rejected before the entry runs. - */ -export async function executeColdActionLocally( - func: BackendFunction, - projectRoot: string, - args: unknown[], - executeAction: ExecuteAction, - getRuntimeContext: GetRuntimeContext, - loadModule: LoadModule, - getAllowedConnectionIds: (entryId: string) => Promise, - log: Logger, - timeoutMs: number = DEFAULT_TIMEOUT_MS, - longPolling: LongPollingConfig = DEFAULT_LONG_POLLING_CONFIG, -): Promise { - const displayName = `${func.relativePath}/${func.name}`; - return enqueue(async () => { - const runtimeContext = await getRuntimeContext(); - assertValidRuntimeContext(runtimeContext); - // Each step needs its own timeout bound independent of runScriptLocally's, which only - // starts once its body begins. - const connectionIdsPromise = getAllowedConnectionIds(func.absolutePath); - const allowedConnectionIds = await withTimeout( - connectionIdsPromise, - timeoutMs, - `Resolving allowed connections for "${displayName}"`, - ); - const entrySpecifier = func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX; - const primingPromise = loadCustomerModuleEntry(loadModule, entrySpecifier); - const primedEntry = await withTimeout( - primingPromise, - timeoutMs, - `Loading "${displayName}"`, - ); - // Calls runScriptLocally directly, not executeScriptLocally, to avoid enqueueing twice. - return runScriptLocally( - { ...func, allowedConnectionIds }, - projectRoot, - args, - executeAction, - runtimeContext, - loadModule, - log, - timeoutMs, - primedEntry, - longPolling, - ); - }); -} - -async function runScriptLocally( - func: BackendFunction, - projectRoot: string, - args: unknown[], - executeAction: ExecuteAction, - runtimeContext: RuntimeContext, - loadModule: LoadModule, - log: Logger, - timeoutMs: number, - primedEntry: Record | undefined, - longPolling: LongPollingConfig, -): Promise { - // Never log the args themselves — they may carry secrets/PII, matching dev-server.ts's cloud path. - log.debug(`Executing "${func.name}" in-process with args`); - - const { actionCallTimeoutMs, totalExecutionTimeoutMs } = deriveActionTimeouts(longPolling); - - // A timed-out execution is abandoned, not canceled — its fn() may keep running, so isCurrent() - // gates both its $.Actions closure and the shared adapters against acting under a stale identity. - const scope = executionEpoch.start(); - - // A long-poll can legitimately outlast timeoutMs — pausing the timer while a call is in flight - // and restarting it once all settle keeps real progress from being penalized while a genuine - // hang still times out normally. - let timer: ReturnType | undefined; - let rejectTimeout: ((error: Error) => void) | undefined; - let pendingActionCalls = 0; - let absoluteTimeoutTimer: ReturnType | undefined; - // Promise.race abandons a hung fn without cancelling it, so this closes its blocked scope instead. - const blockedScopeController = new AbortController(); - const failWithTimeout = (message: string) => { - concludeExecution(); - blockedScopeController.abort(); - const timeoutError = new Error(message); - rejectTimeout?.(timeoutError); - }; - - const scheduleTimeout = () => { - timer = setTimeout(() => { - failWithTimeout(`Local execution of "${func.name}" timed out after ${timeoutMs}ms`); - }, timeoutMs); - }; - - // Re-armed (not just set once) from each new $.Actions call below, so a function making - // several sequential calls — each within its own actionCallTimeoutMs — isn't killed for - // exceeding a ceiling sized for only one of them. - const rearmAbsoluteTimeout = () => { - clearTimeout(absoluteTimeoutTimer); - absoluteTimeoutTimer = setTimeout(() => { - failWithTimeout( - `Local execution of "${func.name}" exceeded the absolute ${totalExecutionTimeoutMs}ms execution ceiling, regardless of any $.Actions call in flight.`, - ); - }, totalExecutionTimeoutMs); - }; - - const guardedExecuteAction: ExecuteAction = async (fqn, inputs, connectionId) => { - if (!scope.isCurrent()) { - // Wording stays conclusion-neutral, not "timed out" — a concluded scope may have - // ended for another reason. - throw abandonedExecutionError(func.name, `run "${fqn}"`); - } - pendingActionCalls += 1; - clearTimeout(timer); - rearmAbsoluteTimeout(); - try { - const actionCallPromise = executeAction(fqn, inputs, connectionId); - return await withTimeout( - actionCallPromise, - actionCallTimeoutMs, - `$.Actions call to "${fqn}"`, - ); - } finally { - pendingActionCalls -= 1; - if (pendingActionCalls === 0 && scope.isCurrent()) { - scheduleTimeout(); - } - } - }; - - const concludeExecution = () => { - scope.concludeIfCurrent(); - }; - - // The preview response is the same context already available to cloud backend code, but it - // belongs to a bootstrap invocation. The platform owns that context's safe-field contract; - // this executor never merges its API/App keys or OAuth token. Target-invocation-owned values - // are always replaced locally. - const $: BackendGlobals = { - ...runtimeContext, - backendFunctionArgs: args, - Actions: makeActionsProxy((fqn, call, actionDescription) => - invokeAction( - guardedExecuteAction, - fqn, - call, - func.allowedConnectionIds, - actionDescription, - ), - ), - }; - - const dispatch: ExecutionDispatch = { - executeAction: guardedExecuteAction, - allowedConnectionIds: func.allowedConnectionIds, - isAbandoned: () => !scope.isCurrent(), - functionName: func.name, - $, - }; - - const run = async (): Promise => { - // Wraps the whole body so a module-load failure also concludes the scope — otherwise - // activeGeneration stays pinned to this execution until the next start() overwrites it. - try { - // Loads the module before installing $ and the SDK bridges, matching production's - // ordering. A caller that already primed this entry passes the resolved module - // directly, keeping loadModule's identity stable for the registration caches below. - const mod = - primedEntry ?? - (await loadCustomerModuleEntry( - 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 code deleted globalThis.$ — only - // closes the gap between executions, not a mid-flight deletion by a concurrent one. - ensureDollarAccessorInstalled(); - - // Scopes globalThis.$ and the dispatch info to this call's own async continuation chain. - return await backendGlobalsContext.run({ value: $ }, () => - executionDispatchContext.run(dispatch, async () => { - const rejectIfAbandoned = () => { - if (!scope.isCurrent()) { - throw new Error( - `Execution of "${func.name}" was abandoned after timing out before it could start.`, - ); - } - }; - // The module load above can already have crossed the timeout. - rejectIfAbandoned(); - // Both adapters are stable and idempotent to re-register, so no coordination is needed between them or across executions. - const actionCatalogRegistration = registerActionCatalogIfInstalled( - loadModule, - projectRoot, - timeoutMs, - ); - const backendRuntimeRegistration = registerBackendRuntimeIfInstalled( - loadModule, - projectRoot, - timeoutMs, - ); - await Promise.all([actionCatalogRegistration, backendRuntimeRegistration]); - // Registration's loadModule() calls can take long enough to cross the timeout, - // and the customer function must never run once abandoned. - rejectIfAbandoned(); - // assertJsonSerializable runs inside runBlocked's callback, not after, since its toJSON()/getter calls must run while access is still blocked. - const data = await runBlocked( - async () => { - const result = await fn(...args); - return assertJsonSerializable(result, func); - }, - { signal: blockedScopeController.signal }, - ); - return { data }; - }), - ); - } finally { - // However this execution ends, mark it concluded so any further dispatch through it — direct or via the shared adapters — is rejected. - concludeExecution(); - } - }; - - const timeout = new Promise((_resolve, reject) => { - rejectTimeout = reject; - scheduleTimeout(); - }); - - rearmAbsoluteTimeout(); - - // Racing the timeout only stops the caller from waiting — run() keeps executing afterward, - // since true cancellation would require terminating a Worker thread. - const runPromise = run(); - // Lets the handler below tell an abandoned rejection from an ordinary one. - let raceSettled = false; - // Nothing awaits runPromise once the timeout wins, so a later rejection would otherwise - // crash as unhandled — logged instead so a slow real failure stays diagnosable. - runPromise.catch((error: unknown) => { - if (!raceSettled) { - return; - } - const message = error instanceof Error ? error.message : String(error); - log.debug(`"${func.name}" failed after its caller had already stopped waiting: ${message}`); - }); - - try { - return await Promise.race([runPromise, timeout]); - } finally { - raceSettled = true; - clearTimeout(timer); - clearTimeout(absoluteTimeoutTimer); - } -} diff --git a/packages/plugins/apps/src/vite/network-guard.test.ts b/packages/plugins/apps/src/vite/network-guard.test.ts index 923dd6618..cae808580 100644 --- a/packages/plugins/apps/src/vite/network-guard.test.ts +++ b/packages/plugins/apps/src/vite/network-guard.test.ts @@ -18,16 +18,13 @@ import worker_threads from 'worker_threads'; import { makeProbeDirOutsideTmp } from './network-guard.fixtures'; import { ALREADY_GUARDED, - exemptFetchFromBlockedScope, FOREIGN_GUARD_MESSAGE, FS_WRITE_BLOCKED_MESSAGE, getSharedContext, - gracefulFsPredatesGuards, guardWorker, installGuardedProperty, installGuards, networkGuardSymbol, - NON_STRING_MODULE_ID_MESSAGE, runBlocked, } from './network-guard'; @@ -2013,37 +2010,6 @@ describe('installGuards', () => { expect(attempts).toEqual(['threw', 'threw']); }); - // graceful-fs publishes this queue on the real fs when it loads, and its clone copies fs's functions. - test.each([ - { marker: true, expected: true }, - { marker: false, expected: false }, - ])( - 'Should report whether graceful-fs patched fs before the guards installed (marker: $marker)', - ({ marker, expected }) => { - const bundlePath = buildGuardBundle('network-guard-graceful-marker.bundle.cjs'); - const probeScript = [ - "const fs = require('fs');", - "if (process.argv[2] === 'true') Object.defineProperty(fs, Symbol.for('graceful-fs.queue'), { get: () => [] });", - 'const guard = require(process.argv[1]);', - 'guard.installGuards();', - 'console.log(JSON.stringify(guard.gracefulFsPredatesGuards()));', - ].join('\n'); - - const output = child_process.execFileSync( - process.execPath, - ['-e', probeScript, bundlePath, String(marker)], - { - encoding: 'utf8', - env: { PATH: process.env.PATH }, - }, - ); - const predates: unknown = JSON.parse(output); - - expect(predates).toBe(expected); - expect(gracefulFsPredatesGuards).toEqual(expect.any(Function)); - }, - ); - // The null-device handle that hands over FileHandle's prototype is closed at install, unawaited. test('Should not crash the process when closing the FileHandle it inspects at install rejects', () => { const bundlePath = buildGuardBundle('network-guard-close-rejects.bundle.cjs'); @@ -2139,43 +2105,6 @@ describe('mixed plugin versions', () => { }); }); -describe('exemptFetchFromBlockedScope', () => { - test("Should run a wrapped module fetch outside the run's blocked scope while the run itself stays blocked", async () => { - const hookOutput = path.join(probeDir, 'fetch-hook.txt'); - const afterFetchPath = path.join(probeDir, 'after-fetch.txt'); - const fetchModule = async (id: string) => { - fs.writeFileSync(hookOutput, id); - return id; - }; - const exemptFetch = exemptFetchFromBlockedScope(fetchModule); - - const fetched = await runBlocked(async () => { - const result = await exemptFetch('/src/lazy.ts'); - expect(() => fs.writeFileSync(afterFetchPath, 'data')).toThrow( - FS_WRITE_BLOCKED_MESSAGE, - ); - return result; - }); - const hookContent = fs.readFileSync(hookOutput, 'utf8'); - const afterFetchWritten = fs.existsSync(afterFetchPath); - - expect(fetched).toBe('/src/lazy.ts'); - expect(hookContent).toBe('/src/lazy.ts'); - expect(afterFetchWritten).toBe(false); - }); - - test('Should reject a non-string module id without calling the real fetch', async () => { - const fetchModule = jest.fn(async (id: string) => id); - const exemptFetch = exemptFetchFromBlockedScope(fetchModule); - const nonStringId: unknown = { toString: () => '/src/lazy.ts' }; - - const fetching = Reflect.apply(exemptFetch, undefined, [nonStringId]); - - await expect(fetching).rejects.toThrow(NON_STRING_MODULE_ID_MESSAGE); - expect(fetchModule).not.toHaveBeenCalled(); - }); -}); - describe('installGuardedProperty resilience', () => { // A wrapper closure over the previous guard (some mocking libraries' pattern, distinct from the // direct-reassignment case the WeakMap handles) would otherwise recurse into itself forever, diff --git a/packages/plugins/apps/src/vite/network-guard.ts b/packages/plugins/apps/src/vite/network-guard.ts index f20770af4..2f2098942 100644 --- a/packages/plugins/apps/src/vite/network-guard.ts +++ b/packages/plugins/apps/src/vite/network-guard.ts @@ -1035,28 +1035,15 @@ function installFsGuards(): void { realTmpDirs ??= captureRealTmpDirs(); } -// graceful-fs publishes its queue on the real fs when it loads; a clone it (or fs-extra) made before -// the install copied fs's original functions, so writes through it skip the guards. -const GRACEFUL_FS_QUEUE = Symbol.for('graceful-fs.queue'); -let gracefulFsLoadedFirst = false; - -export const GRACEFUL_FS_UNGUARDED_WARNING = - 'fs-extra or graceful-fs was loaded before the dev server installed the backend function sandbox guards, so filesystem writes a backend function makes through it are not blocked.'; - -export function gracefulFsPredatesGuards(): boolean { - return gracefulFsLoadedFirst; -} - let guardsInstalled = false; -// Patches process-wide built-ins, so it's called explicitly (by the Vite dev server and before -// each local execution) rather than at import: every bundler that loads this plugin imports this file. +// Patches process-wide built-ins, so a call's process calls it explicitly rather than at import: +// every bundler that loads this plugin imports this file. export function installGuards(): void { if (guardsInstalled) { installPromisesOpenGuard(); startGuardingFileHandles(); return; } - gracefulFsLoadedFirst ||= Object.prototype.hasOwnProperty.call(fs, GRACEFUL_FS_QUEUE); // Each property install is a no-op once done, so a call after a failed attempt retries the rest. installSubprocessGuards(); installFsGuards(); @@ -1070,39 +1057,13 @@ export function installGuards(): void { // Captured at load, since Jest's fake timers replace process.nextTick and would stall a run's close. const { nextTick } = process; -// Runs `fn` outside any run's blocked scope — for work done on a run's behalf that isn't customer -// code, like Vite resolving and transforming a module the run dynamically imports. -export function runOutsideBlockedScope(fn: () => T): T { - return getBlockedContext().exit(fn); -} - -export const NON_STRING_MODULE_ID_MESSAGE = 'Module id must be a string'; - -// Vite and its plugins call methods on the id, so a customer object's own toString or startsWith -// would otherwise run exempt. Vite's methods are async, so a rejection matches how they fail on one. -function runExemptForStringId(id: unknown, call: () => Promise): Promise { - if (typeof id !== 'string') { - const idError = new TypeError(NON_STRING_MODULE_ID_MESSAGE); - return Promise.reject(idError); - } - return runOutsideBlockedScope(call); -} - -// Wraps Vite's module fetch (resolve, load, transform) so project plugins doing that work for a -// run's dynamic import aren't blocked; evaluating the fetched module still runs in the run's scope. -export function exemptFetchFromBlockedScope( - fetchModule: (id: string, ...rest: Rest) => Promise, -): (id: string, ...rest: Rest) => Promise { - return (id, ...rest) => runExemptForStringId(id, () => fetchModule(id, ...rest)); -} - function closeScope(scope: BlockedScope): void { scope.closed = true; deregisterIfIdle(scope); } // Runs `fn` with subprocesses, worker_threads and fs writes outside the OS temp dir blocked; wraps -// the customer's function body in `local-execution.ts`'s `runScriptLocally`. +// a call's module load and function in its own process. // Aborting `signal` closes the scope for a caller that gives up on a `fn` that never settles. export async function runBlocked( fn: () => Promise, diff --git a/packages/tests/src/_jest/fixtures/apps_backend_project/childActions.backend.ts b/packages/tests/src/_jest/fixtures/apps_backend_project/childActions.backend.ts new file mode 100644 index 000000000..67bea56db --- /dev/null +++ b/packages/tests/src/_jest/fixtures/apps_backend_project/childActions.backend.ts @@ -0,0 +1,62 @@ +// 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. + +declare const $: { Actions: { http: { request(call: unknown): Promise } } }; + +export async function callHttp(connectionId?: string, delayMs = 0) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + return $.Actions.http.request({ inputs: { url: 'https://example.com' }, connectionId }); +} + +export async function callHttpWithoutAwaiting() { + $.Actions.http.request({ inputs: { url: 'https://example.com' } }).catch(() => {}); + return 'returned'; +} + +export async function callHttpWithMap() { + return $.Actions.http.request({ inputs: { headers: new Map([['a', 'b']]) } }); +} + +export async function callHttpDeclaringScope() { + return $.Actions.http.request({ inputs: { allowedConnectionIds: ['other'] } }); +} + +export async function callHttpDeclaringScopeViaToJson() { + const inputs = { url: 'https://example.com', toJSON: () => ({ allowedConnectionIds: ['other'] }) }; + return $.Actions.http.request({ inputs }); +} + +export async function callHttpWithTopLevelToJson() { + return $.Actions.http.request({ inputs: { toJSON: () => 'not an object' } }); +} + +// Sends an action request straight over the IPC channel, skipping $.Actions' own checks. +export async function forgeActionMessage(connectionId: string) { + const id = 999_999; + return new Promise((resolve) => { + process.on('message', (message: { type?: string; id?: number }) => { + if (message?.type === 'action-result' && message.id === id) { + resolve(message); + } + }); + process.send?.({ + type: 'action', + id, + actionId: 'com.datadoghq.http.request', + description: 'forged', + inputs: {}, + connectionId, + }); + }); +} + +export async function retryRefusedConnection() { + for (;;) { + try { + return await $.Actions.http.request({ inputs: {}, connectionId: 'not-allowed' }); + } catch { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } +} diff --git a/packages/tests/src/_jest/fixtures/apps_backend_project/childLoadTime.backend.ts b/packages/tests/src/_jest/fixtures/apps_backend_project/childLoadTime.backend.ts new file mode 100644 index 000000000..c9174474e --- /dev/null +++ b/packages/tests/src/_jest/fixtures/apps_backend_project/childLoadTime.backend.ts @@ -0,0 +1,21 @@ +// 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. + +declare const $: { Actions: { http: { request(call: unknown): Promise } } } | undefined; + +const dollarAtLoad = $ === undefined ? 'undefined' : 'set'; + +let earlyAction: Promise | undefined; +// Fires while the dev server is still registering the SDK adapters, before the call starts. +setTimeout(() => { + earlyAction = $?.Actions.http.request({ inputs: { early: true } }); +}, 0); + +export async function readDollarAtLoad() { + return { dollarAtLoad, dollarAtCall: typeof $ }; +} + +export async function awaitEarlyAction() { + return earlyAction; +} diff --git a/packages/tests/src/_jest/fixtures/apps_backend_project/childReturnValues.backend.ts b/packages/tests/src/_jest/fixtures/apps_backend_project/childReturnValues.backend.ts new file mode 100644 index 000000000..a4b348bee --- /dev/null +++ b/packages/tests/src/_jest/fixtures/apps_backend_project/childReturnValues.backend.ts @@ -0,0 +1,70 @@ +// 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. + +const secret = Symbol('secret'); + +export async function returnCircular() { + const value: Record = {}; + value.self = value; + return value; +} + +export async function returnBigInt() { + return BigInt(10); +} + +export async function returnFunction() { + return function notSerializable() {}; +} + +export async function returnMap() { + return new Map([['a', 1]]); +} + +export async function returnNestedSet() { + return [1, new Set([1, 2, 3])]; +} + +export async function returnNestedNaN() { + return { score: NaN }; +} + +export async function returnInfinity() { + return Infinity; +} + +export async function returnSymbolKey() { + return { status: 'ok', [secret]: 'leaked' }; +} + +export async function returnSymbolKeyViaToJson() { + return { status: 'ok', toJSON: () => ({ replaced: true, [secret]: 'leaked' }) }; +} + +export async function returnSymbolInArray() { + return [1, Symbol('unsupported')]; +} + +export async function returnUndefinedInArray() { + return [1, undefined, 2]; +} + +export async function returnUndefinedProperties() { + return { status: 'ok', extra: undefined, '': undefined }; +} + +export async function returnUndefined() { + return undefined; +} + +let toJsonCalls = 0; + +export async function returnCountingToJson() { + return { + toJSON() { + toJsonCalls += 1; + return { toJsonCalls }; + }, + }; +} diff --git a/packages/tests/src/_jest/fixtures/apps_backend_project/childSandbox.backend.ts b/packages/tests/src/_jest/fixtures/apps_backend_project/childSandbox.backend.ts new file mode 100644 index 000000000..b3eb34077 --- /dev/null +++ b/packages/tests/src/_jest/fixtures/apps_backend_project/childSandbox.backend.ts @@ -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 { forgeInvoke, probeSandbox } from './childSandboxProbes'; + +export async function runSandboxProbes(projectFile: string, fetchUrl: string, sharedTmpFile: string) { + return probeSandbox(projectFile, fetchUrl, sharedTmpFile); +} + +export async function loadOverIpc(url: string, importer?: string) { + return forgeInvoke(url, importer); +} diff --git a/packages/tests/src/_jest/fixtures/apps_backend_project/childSandboxProbes.ts b/packages/tests/src/_jest/fixtures/apps_backend_project/childSandboxProbes.ts new file mode 100644 index 000000000..75f989fae --- /dev/null +++ b/packages/tests/src/_jest/fixtures/apps_backend_project/childSandboxProbes.ts @@ -0,0 +1,75 @@ +// 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 { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { Worker } from 'node:worker_threads'; + +function attempt(run: () => string): string { + try { + return `ok: ${run()}`; + } catch (error) { + if (!(error instanceof Error)) { + return `refused: ${String(error)}`; + } + const code = 'code' in error && typeof error.code === 'string' ? error.code : undefined; + return `refused: ${code ?? error.message}`; + } +} + +export async function probeSandbox(projectFile: string, fetchUrl: string, sharedTmpFile: string) { + const tempFile = path.join(os.tmpdir(), 'sandbox-probe.txt'); + const result: Record = { + sharedTmpWrite: attempt(() => { + fs.writeFileSync(sharedTmpFile, 'x'); + return 'written'; + }), + projectWrite: attempt(() => { + fs.writeFileSync(projectFile, 'x'); + return 'written'; + }), + tempWrite: attempt(() => { + fs.writeFileSync(tempFile, 'x'); + return fs.readFileSync(tempFile, 'utf-8'); + }), + spawn: attempt(() => { + const spawned = spawnSync('echo', ['hi']); + if (spawned.error) { + throw spawned.error; + } + return String(spawned.stdout); + }), + worker: attempt(() => { + const worker = new Worker('setTimeout(() => {}, 10)', { eval: true }); + worker.terminate(); + return 'started'; + }), + }; + try { + const response = await fetch(fetchUrl); + result.fetch = `ok: ${response.status} ${await response.text()}`; + } catch (error) { + result.fetch = `refused: ${error instanceof Error ? error.message : String(error)}`; + } + return result; +} + +// Asks the dev server for a module straight over the IPC channel, skipping the module runner. +export function forgeInvoke(url: string, importer?: string) { + const id = 999_998; + return new Promise((resolve) => { + process.on('message', (message: { type?: string; id?: number; response?: unknown }) => { + if (message?.type === 'invoke-result' && message.id === id) { + resolve(message.response); + } + }); + process.send?.({ + type: 'invoke', + id, + payload: { type: 'custom', event: 'vite:invoke', data: { id: 'send', name: 'fetchModule', data: [url, importer, {}] } }, + }); + }); +} diff --git a/packages/tests/src/_jest/fixtures/apps_backend_project/childSlowLoad.backend.ts b/packages/tests/src/_jest/fixtures/apps_backend_project/childSlowLoad.backend.ts new file mode 100644 index 000000000..897d63afa --- /dev/null +++ b/packages/tests/src/_jest/fixtures/apps_backend_project/childSlowLoad.backend.ts @@ -0,0 +1,10 @@ +// 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. + +await new Promise((resolve) => setTimeout(resolve, 1200)); + +export async function runAfterSlowLoad(delayMs: number) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + return 'done'; +} diff --git a/packages/tests/src/_jest/fixtures/apps_backend_project/childState.backend.ts b/packages/tests/src/_jest/fixtures/apps_backend_project/childState.backend.ts new file mode 100644 index 000000000..c1257d233 --- /dev/null +++ b/packages/tests/src/_jest/fixtures/apps_backend_project/childState.backend.ts @@ -0,0 +1,67 @@ +// 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. + +declare global { + var childStateCalls: number | undefined; +} + +let calls = 0; + +export async function countCalls(delayMs = 0) { + const startedAt = Date.now(); + calls += 1; + globalThis.childStateCalls = (globalThis.childStateCalls ?? 0) + 1; + await new Promise((resolve) => setTimeout(resolve, delayMs)); + return { calls, globalCalls: globalThis.childStateCalls, pid: process.pid, startedAt, endedAt: Date.now() }; +} + +export async function waitForever() { + await new Promise(() => {}); +} + +export function spinForever() { + // eslint-disable-next-line no-empty + for (;;) {} +} + +export async function logLines(delayMs: number) { + console.log('first line'); + await new Promise((resolve) => setTimeout(resolve, delayMs)); + console.error('second line'); + process.stdout.write('no trailing newline'); + return 'logged'; +} + +export async function readEnvNames() { + return Object.keys(process.env).sort(); +} + +export async function exitEarly() { + process.exit(3); +} + +export async function throwUnprintable() { + throw Object.create(null); +} + +export async function logOutOfMemoryAndExit() { + console.error('retrying: heap out of memory upstream'); + process.exit(1); +} + +export async function allocateUntilOutOfMemory() { + const chunks: string[][] = []; + for (;;) { + chunks.push(new Array(1_000_000).fill('x')); + } +} + +export async function silenceStdout() { + process.stdout.write = () => true; + return 'quiet'; +} + +export async function readWorkingDirectory() { + return { cwd: process.cwd(), cwdIsTmpdir: process.cwd() === process.env.TMPDIR }; +} diff --git a/packages/tests/src/_jest/fixtures/apps_backend_project/childTopLevelWrite.backend.ts b/packages/tests/src/_jest/fixtures/apps_backend_project/childTopLevelWrite.backend.ts new file mode 100644 index 000000000..8039464f9 --- /dev/null +++ b/packages/tests/src/_jest/fixtures/apps_backend_project/childTopLevelWrite.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 { writeAtLoad } from './childTopLevelWriteHelper'; + +export async function afterTopLevelWrite() { + return writeAtLoad; +} diff --git a/packages/tests/src/_jest/fixtures/apps_backend_project/childTopLevelWriteHelper.ts b/packages/tests/src/_jest/fixtures/apps_backend_project/childTopLevelWriteHelper.ts new file mode 100644 index 000000000..8e1235582 --- /dev/null +++ b/packages/tests/src/_jest/fixtures/apps_backend_project/childTopLevelWriteHelper.ts @@ -0,0 +1,12 @@ +// 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 fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// Runs while the module loads, before the backend function is called. +fs.writeFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), 'top-level-probe.txt'), 'x'); + +export const writeAtLoad = 'written'; diff --git a/packages/tests/src/_jest/fixtures/apps_backend_project/childVirtual.backend.ts b/packages/tests/src/_jest/fixtures/apps_backend_project/childVirtual.backend.ts new file mode 100644 index 000000000..90c98478b --- /dev/null +++ b/packages/tests/src/_jest/fixtures/apps_backend_project/childVirtual.backend.ts @@ -0,0 +1,11 @@ +// 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. + +// Only the child-process test server defines these modules. +import { virtualValue } from 'virtual:dd-child-test'; +import { slashValue } from '/~dd-child-test-virtual.js'; + +export async function readVirtualModule() { + return { virtualValue, slashValue }; +} diff --git a/packages/tests/src/_jest/helpers/mocks.ts b/packages/tests/src/_jest/helpers/mocks.ts index 6a51a4f57..48b596f6c 100644 --- a/packages/tests/src/_jest/helpers/mocks.ts +++ b/packages/tests/src/_jest/helpers/mocks.ts @@ -2,11 +2,6 @@ // 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, @@ -128,25 +123,6 @@ export const getMockTimeLogger = (overrides: Partial = {}): TimeLogg return mockTimer; }; -/** - * Builds a `loadModule`-shaped resolver returning `exports` for `func`'s suffixed absolute - * path (see `LOCAL_EXECUTION_LOAD_SUFFIX`) and rejecting any other specifier, matching a real - * loader when only the target module is 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; - }; -}; - /** * Create a mock same-origin JSON POST from the dev server's own page. */ diff --git a/packages/tests/src/_jest/processEnvLintRule.test.ts b/packages/tests/src/_jest/processEnvLintRule.test.ts index 64d725430..78212c8e6 100644 --- a/packages/tests/src/_jest/processEnvLintRule.test.ts +++ b/packages/tests/src/_jest/processEnvLintRule.test.ts @@ -2162,12 +2162,12 @@ describe('process.env lint rule for tests', () => { }, { description: 'apply to Jest fixture files', - file: 'packages/plugins/apps/src/vite/local-execution.process-exit.fixture.ts', + file: 'packages/tests/src/_jest/nockResetA.fixture.ts', expected: 1, }, { description: 'apply to Jest fixtures modules', - file: 'packages/plugins/apps/src/vite/local-execution.fixtures.ts', + file: 'packages/plugins/apps/src/vite/local-execution-process.fixtures.ts', expected: 1, }, ];