Skip to content

Commit ecd1ea2

Browse files
tyfficalclaude
andcommitted
prototype: local Node execution for backend functions
Adds a Mode-A local execution path for *.backend.ts functions, parallel to executeScriptViaDatadog (dev-server.ts): a child_process.fork()'d child runs the real bundled script, with $.Actions calls proxied back over the fork's built-in IPC channel to the parent. Not wired into createDevServerMiddleware. executeActionRemotely is a stub for the single-action execution capability that doesn't exist yet (see the design doc's Open Dependency section) -- this is an early prototype for design-doc discussion, not ready to ship. The $.Actions Proxy get/apply logic in local-exec-child.js is a direct Node port of the actionplatform Deno script template's makeActionsProxy (dd-source), which has zero Deno dependencies and copies over verbatim. local-execution.integration.test.ts is a real, unmocked integration test: real vite.build(), real getBaseBackendBuildConfig, real generateDevVirtualEntryContent, against a real *.backend.ts fixture on disk -- not hand-written stand-ins. Full design doc: .plans/high-code-apps-local-node-execution-design.md (dd-source, not yet published to Confluence). Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 66b0c70 commit ecd1ea2

3 files changed

Lines changed: 382 additions & 0 deletions

File tree

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License.
2+
// This product includes software developed at Datadog (https://www.datadoghq.com/).
3+
// Copyright 2019-Present Datadog, Inc.
4+
5+
/* eslint-env node, es2020 */
6+
7+
// child_process.fork() entry point for local backend-function execution.
8+
// Plain JS (not TS) deliberately: this is the actual runtime artifact Node
9+
// forks and executes directly, not source that goes through a build step.
10+
//
11+
// The $.Actions Proxy get/apply logic below is a direct Node port of
12+
// domains/actionplatform/shared/libs/ts/highcode-script-template/render.ts's
13+
// makeActionsProxy (dd-source) -- that logic has zero Deno dependencies and
14+
// copies over verbatim. Only the transport changed: Deno.connect + hand-
15+
// rolled HTTP/1.1 framing over a unix socket is replaced by fork()'s built-in
16+
// structured-clone IPC channel (process.send()/process.on('message')).
17+
18+
let nextRequestId = 0;
19+
const pending = new Map();
20+
21+
process.on('message', (msg) => {
22+
if (msg && msg.type === 'action-response') {
23+
const resolver = pending.get(msg.id);
24+
if (resolver) {
25+
pending.delete(msg.id);
26+
resolver(msg.payload);
27+
}
28+
}
29+
});
30+
31+
function callAction(fqn, inputs, connectionId) {
32+
return new Promise((resolve, reject) => {
33+
const id = ++nextRequestId;
34+
pending.set(id, (payload) => {
35+
if (payload.type === 'success') {
36+
resolve(payload.result);
37+
} else {
38+
reject(payload.result);
39+
}
40+
});
41+
process.send({ type: 'action-request', id, fqn, inputs, connectionId });
42+
});
43+
}
44+
45+
// Satisfies the exact contract packages/plugins/apps/src/backend/shared.ts's
46+
// SET_EXECUTE_ACTION_SNIPPET expects: $.Actions must resolve any nested
47+
// property path (e.g. $.Actions.slack.chat.postMessage) to a callable.
48+
function makeActionsProxy(pathParts = []) {
49+
return new Proxy(function () {}, {
50+
get(_target, prop) {
51+
return makeActionsProxy(pathParts.concat(String(prop)));
52+
},
53+
apply(_target, _thisArg, args) {
54+
if (args.length === 0) {
55+
return Promise.reject(
56+
`No arguments provided to action $.Actions.${pathParts.join('.')}`,
57+
);
58+
}
59+
const { inputs, connectionId } = args[0];
60+
if (typeof inputs !== 'object' || !inputs) {
61+
return Promise.reject(
62+
`First argument to action $.Actions.${pathParts.join('.')} must have an inputs field`,
63+
);
64+
}
65+
const fqn = `com.datadoghq.${pathParts.join('.')}`;
66+
return callAction(fqn, inputs, connectionId);
67+
},
68+
});
69+
}
70+
71+
process.on('message', async function onExecute(msg) {
72+
if (!msg || msg.type !== 'execute') {
73+
return;
74+
}
75+
76+
try {
77+
const $ = { backendFunctionArgs: msg.backendFunctionArgs, Actions: makeActionsProxy() };
78+
globalThis.$ = $;
79+
80+
// The real bundled code (from vite.build(), format:'es', no externals
81+
// for real npm deps -- see build-config.ts) is a plain ES module string
82+
// exporting `main`. Importing it as a data: URL avoids writing a temp
83+
// file. Verified empirically: this only works because Rollup inlines
84+
// every resolvable npm dependency (no `external` array configured) --
85+
// genuine Node built-ins (crypto, fs, etc.) are the only bare imports
86+
// left in real output, and those resolve fine from a data: URL since
87+
// they're resolved by scheme, not by filesystem context. A bare
88+
// specifier for an actual unresolved npm package would fail here with
89+
// "Failed to resolve module specifier" -- confirmed by direct test.
90+
const dataUrl = `data:text/javascript;base64,${Buffer.from(msg.scriptBody).toString('base64')}`;
91+
const mod = await import(dataUrl);
92+
const result = await mod.main($);
93+
94+
process.send({ type: 'result', result });
95+
process.exit(0);
96+
} catch (err) {
97+
process.send({ type: 'error', error: String(err && err.message ? err.message : err) });
98+
process.exit(1);
99+
}
100+
});
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License.
2+
// This product includes software developed at Datadog (https://www.datadoghq.com/).
3+
// Copyright 2019-Present Datadog, Inc.
4+
5+
/**
6+
* Real, unmocked integration test for the local-execution implementation
7+
* POC (.plans/high-code-apps-local-node-execution-design.md in dd-source).
8+
*
9+
* Unlike dev-server.test.ts (which mocks vite.build entirely), this test
10+
* uses the REAL vite.build(), REAL getBaseBackendBuildConfig, and REAL
11+
* generateDevVirtualEntryContent -- the exact same bundling path
12+
* bundleBackendFunction() in dev-server.ts uses -- then feeds that real
13+
* bundled output through executeScriptLocally(), proving the new local
14+
* execution path works against a genuine bundle, not a hand-written stand-in.
15+
*/
16+
17+
import { outputFileSync } from '@dd/core/helpers/fs';
18+
import { getTempWorkingDir } from '@dd/tests/_jest/helpers/env';
19+
import { getMockLogger } from '@dd/tests/_jest/helpers/mocks';
20+
import { build } from 'vite';
21+
22+
import type { BackendFunction } from '../backend/types';
23+
import { generateDevVirtualEntryContent } from '../backend/virtual-entry';
24+
25+
import { getBaseBackendBuildConfig } from './build-config';
26+
import { executeScriptLocally } from './local-execution';
27+
28+
const log = getMockLogger();
29+
30+
async function bundleRealBackendFunction(
31+
workingDir: string,
32+
functionName: string,
33+
sourceCode: string,
34+
): Promise<string> {
35+
const absolutePath = `${workingDir}/src/${functionName}.backend.ts`;
36+
outputFileSync(absolutePath, sourceCode);
37+
38+
const virtualId = `virtual:dd-backend-dev:${functionName}`;
39+
const virtualContent = generateDevVirtualEntryContent(functionName, absolutePath, workingDir);
40+
const baseConfig = getBaseBackendBuildConfig(workingDir, { [virtualId]: virtualContent }, []);
41+
42+
const result = await build({
43+
...baseConfig,
44+
build: {
45+
...baseConfig.build,
46+
write: false,
47+
rollupOptions: {
48+
...baseConfig.build.rollupOptions,
49+
input: virtualId,
50+
output: baseConfig.build.rollupOptions.output,
51+
},
52+
},
53+
});
54+
55+
const output = Array.isArray(result) ? result[0] : result;
56+
if (!('output' in output)) {
57+
throw new Error('Unexpected vite.build result');
58+
}
59+
const chunk = output.output[0];
60+
return chunk.type === 'chunk' ? chunk.code : '';
61+
}
62+
63+
describe('executeScriptLocally (real bundle, no mocks)', () => {
64+
test('runs a real Rollup-bundled *.backend.ts function locally and calls $.Actions through the local child', async () => {
65+
const workingDir = getTempWorkingDir(`local-exec-poc-${Date.now()}`);
66+
67+
// A real sample backend function -- calls $.Actions like a customer's
68+
// real *.backend.ts file would, plus a genuine Node built-in import
69+
// (crypto) to exercise the data:-URL-import concern documented in
70+
// local-exec-child.js.
71+
const sourceCode = `
72+
import { randomBytes } from 'node:crypto';
73+
export async function greet(name) {
74+
const nonce = randomBytes(4).toString('hex');
75+
const response = await $.Actions.slack.chat.postMessage({
76+
inputs: { channel: '#test', text: 'hello ' + name },
77+
connectionId: 'connection:slack:poc',
78+
});
79+
return { greeting: 'hello ' + name, nonce, actionResult: response };
80+
}
81+
`;
82+
83+
const code = await bundleRealBackendFunction(workingDir, 'greet', sourceCode);
84+
85+
// Sanity check on the real bundle output itself, before executing it:
86+
// confirms Rollup actually inlined everything real-npm and left only
87+
// the genuine Node built-in as a bare import (the assumption
88+
// local-exec-child.js's data:-URL-import approach depends on).
89+
expect(code).toContain("from 'node:crypto'");
90+
expect(code).not.toContain('@datadog/action-catalog'); // not installed in this fixture -> no snippet at all
91+
// Rollup's preserveEntrySignatures:'exports-only' rewrites the inline
92+
// `export async function main($)` into a plain declaration plus a
93+
// separate `export { main };` -- assert on real Rollup output shape,
94+
// not the pre-bundling source template's shape.
95+
expect(code).toMatch(/async function main\(\$\)/);
96+
expect(code).toContain('export { main }');
97+
98+
const func: BackendFunction = {
99+
relativePath: 'src/greet',
100+
name: 'greet',
101+
absolutePath: `${workingDir}/src/greet.backend.ts`,
102+
allowedConnectionIds: ['connection:slack:poc'],
103+
};
104+
105+
const outputs = await executeScriptLocally(code, func, ['world'], log);
106+
107+
// $.Actions.foo.bar(...) resolves to the raw result value directly
108+
// (already unwrapped from the internal {type, result} envelope) --
109+
// matches the real contract in shared.ts's SET_EXECUTE_ACTION_SNIPPET
110+
// (`return actionFn(request)`, not `return {type, result}`).
111+
expect(outputs.data).toMatchObject({
112+
greeting: 'hello world',
113+
actionResult: { data: null, stub: true, fqn: 'com.datadoghq.slack.chat.postMessage' },
114+
});
115+
expect((outputs.data as { nonce: string }).nonce).toMatch(/^[0-9a-f]{8}$/);
116+
}, 20_000);
117+
118+
test('propagates a real crash (uncaught throw in the bundled function) as a rejected promise, not a hang', async () => {
119+
const workingDir = getTempWorkingDir(`local-exec-poc-crash-${Date.now()}`);
120+
const sourceCode = `
121+
export async function crashes() {
122+
throw new Error('deliberate bug in a real bundled backend function');
123+
}
124+
`;
125+
const code = await bundleRealBackendFunction(workingDir, 'crashes', sourceCode);
126+
127+
const func: BackendFunction = {
128+
relativePath: 'src/crashes',
129+
name: 'crashes',
130+
absolutePath: `${workingDir}/src/crashes.backend.ts`,
131+
allowedConnectionIds: [],
132+
};
133+
134+
await expect(executeScriptLocally(code, func, [], log)).rejects.toThrow(
135+
'deliberate bug in a real bundled backend function',
136+
);
137+
}, 20_000);
138+
});
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License.
2+
// This product includes software developed at Datadog (https://www.datadoghq.com/).
3+
// Copyright 2019-Present Datadog, Inc.
4+
5+
/**
6+
* Local Node execution for backend functions -- the "Mode A" path described
7+
* in the design doc (.plans/high-code-apps-local-node-execution-design.md
8+
* in dd-source). This is an implementation POC, not production code: it is
9+
* NOT wired into createDevServerMiddleware, and executeActionRemotely below
10+
* is a stub for the single-action execution endpoint that doesn't exist yet
11+
* (see the design doc's "Open Dependency" section).
12+
*
13+
* Parallel structure to executeScriptViaDatadog in dev-server.ts: same
14+
* BackendOutputs return shape, so it's a drop-in alternate implementation
15+
* behind the same contract, not a protocol change.
16+
*/
17+
18+
import type { Logger } from '@dd/core/types';
19+
import { fork } from 'child_process';
20+
import * as path from 'path';
21+
22+
import type { BackendFunction } from '../backend/types';
23+
24+
type BackendOutputs = { data: unknown };
25+
26+
interface ActionRequestMessage {
27+
type: 'action-request';
28+
id: number;
29+
fqn: string;
30+
inputs: Record<string, unknown>;
31+
connectionId?: string;
32+
}
33+
34+
type ChildMessage =
35+
| ActionRequestMessage
36+
| { type: 'result'; result: unknown }
37+
| { type: 'error'; error: string };
38+
39+
const LOCAL_EXEC_CHILD_SCRIPT = path.join(__dirname, 'local-exec-child.js');
40+
const DEFAULT_TIMEOUT_MS = 10_000;
41+
42+
/**
43+
* TODO(open-dependency): stub for the single-action execution capability the
44+
* design doc asks the Action Platform team to build (a new REST endpoint or
45+
* an MCP tool -- see "Open Dependency"). This is the ONLY function that
46+
* reaches outward for a real $.Actions call; once the real endpoint exists,
47+
* only this function's body needs to change.
48+
*/
49+
async function executeActionRemotely(
50+
request: ActionRequestMessage,
51+
log: Logger,
52+
): Promise<{ type: 'success' | 'failure'; result: unknown }> {
53+
log.debug(
54+
`[local-execution] (stub -- no real endpoint exists yet) would call ${request.fqn} with inputs=${JSON.stringify(request.inputs)}`,
55+
);
56+
return { type: 'success', result: { data: null, stub: true, fqn: request.fqn } };
57+
}
58+
59+
/**
60+
* Execute a bundled backend function locally via a forked Node child process,
61+
* with $.Actions calls proxied back through this function (which currently
62+
* stubs the outward call -- see executeActionRemotely above).
63+
*/
64+
export function executeScriptLocally(
65+
scriptBody: string,
66+
func: BackendFunction,
67+
args: unknown[],
68+
log: Logger,
69+
timeoutMs: number = DEFAULT_TIMEOUT_MS,
70+
): Promise<BackendOutputs> {
71+
return new Promise((resolve, reject) => {
72+
// NOTE: production implementation must pass an explicit restricted
73+
// `env`, never inherit process.env wholesale -- see the design doc's
74+
// Secrets Handling section (never hand secrets to the child).
75+
const child = fork(LOCAL_EXEC_CHILD_SCRIPT, [], { stdio: 'inherit', env: {} });
76+
let settled = false;
77+
78+
const timer = setTimeout(() => {
79+
if (!settled) {
80+
settled = true;
81+
child.kill();
82+
reject(
83+
new Error(`Local execution of "${func.name}" timed out after ${timeoutMs}ms`),
84+
);
85+
}
86+
}, timeoutMs);
87+
88+
child.on('message', (msg: ChildMessage) => {
89+
if (!msg) {
90+
return;
91+
}
92+
93+
if (msg.type === 'action-request') {
94+
executeActionRemotely(msg, log)
95+
.then((response) => {
96+
child.send({ type: 'action-response', id: msg.id, payload: response });
97+
})
98+
.catch((err: unknown) => {
99+
child.send({
100+
type: 'action-response',
101+
id: msg.id,
102+
payload: { type: 'failure', result: String(err) },
103+
});
104+
});
105+
return;
106+
}
107+
108+
if (msg.type === 'result' && !settled) {
109+
settled = true;
110+
clearTimeout(timer);
111+
resolve({ data: msg.result });
112+
return;
113+
}
114+
115+
if (msg.type === 'error' && !settled) {
116+
settled = true;
117+
clearTimeout(timer);
118+
reject(new Error(msg.error));
119+
}
120+
});
121+
122+
child.on('exit', (code) => {
123+
if (!settled) {
124+
settled = true;
125+
clearTimeout(timer);
126+
reject(
127+
new Error(
128+
`Local execution of "${func.name}" exited with code ${code} before reporting a result`,
129+
),
130+
);
131+
}
132+
});
133+
134+
child.on('error', (err) => {
135+
if (!settled) {
136+
settled = true;
137+
clearTimeout(timer);
138+
reject(err);
139+
}
140+
});
141+
142+
child.send({ type: 'execute', scriptBody, backendFunctionArgs: args });
143+
});
144+
}

0 commit comments

Comments
 (0)