Skip to content

Commit 2a864e8

Browse files
committed
wire local execution into the real dev server
- npm run dev's /__dd/executeAction now runs backend functions via executeScriptLocally (a real forked child process) instead of the cloud preview-async round trip, per the design doc's Rollout section: no customer-facing toggle, local execution is the new unconditional default. - Preserves today's cloud round trip as a new, additive /__dd/executeActionViaCloud endpoint rather than deleting it -- no `npm run dev:verify` script exists yet to replace it (that requires create-apps scaffolding template changes, outside this package), and the existing dev-server.test.ts suite has extensive coverage of the cloud path that would otherwise be silently orphaned. - /__dd/executeAction no longer requires Datadog credentials to be configured, unlike /__dd/executeActionViaCloud -- local execution's $.Actions calls are stubbed today (see local-execution.ts), so there's nothing for it to authenticate yet. - Wires killAllLocalExecutionChildren() (from #471) into the dev server's own shutdown via server.httpServer's 'close' event, so killing the dev server mid-execution doesn't leave an orphaned child process. - Registers local-exec-child.js in the package's buildPlugin.toBuild config (same mechanism apps-runtime.ts already uses) so it's actually copied into the published dist/src output next to the compiled bundle. local-execution.ts resolves it at runtime via a __dirname-relative path, which only works if the file exists on disk next to the bundle -- found by running a real npm-linked build against a scaffolded app, since the existing jest tests resolve local-execution.ts's source directly and never exercise the published package layout. $.Actions calls remain stubbed until the single-action execution endpoint resolves (design doc's Open Dependency section) -- this is explicitly not customer-ready yet, same as the rest of this PR stack. Milestone 2 of APPS-2792's local Node execution kickoff plan.
1 parent 4ac0179 commit 2a864e8

4 files changed

Lines changed: 209 additions & 17 deletions

File tree

packages/plugins/apps/package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@
1919
"format": [
2020
"esm"
2121
]
22+
},
23+
"local-exec-child": {
24+
"entry": "./src/vite/local-exec-child.js",
25+
"format": [
26+
"cjs"
27+
]
2228
}
2329
}
2430
},

packages/plugins/apps/src/vite/dev-server.test.ts

Lines changed: 138 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,34 @@ describe('Dev Server Middleware', () => {
201201
expect(res.end).toHaveBeenCalled();
202202
});
203203

204-
test('Should handle /__dd/executeAction POST', async () => {
204+
test('Should handle /__dd/executeAction POST by running the bundle locally, not via the Datadog API', async () => {
205+
mockBuildWithParsedBackend(
206+
'export async function main($) { return { echo: $.backendFunctionArgs }; }',
207+
);
208+
209+
// No nock scope registered -- if the middleware still called the
210+
// Datadog API for this endpoint, the request would fail outright
211+
// (nock throws on unmocked requests by default), so an unmocked
212+
// 200 here already proves local execution, not the cloud path.
213+
const req = createMockRequest('/__dd/executeAction', {
214+
functionName: encodeQueryName(mockFunctions[0]),
215+
args: ['world'],
216+
});
217+
const res = createMockResponse();
218+
const next = jest.fn();
219+
220+
middleware(req, res, next);
221+
expect(next).not.toHaveBeenCalled();
222+
223+
await res.done;
224+
225+
expect(res.statusCode).toBe(200);
226+
const body = JSON.parse(res.getBody());
227+
expect(body.success).toBe(true);
228+
expect(body.result).toEqual({ data: { echo: ['world'] } });
229+
}, 20_000);
230+
231+
test('Should handle /__dd/executeActionViaCloud POST', async () => {
205232
mockBuildWithParsedBackend();
206233

207234
// Mock the Datadog API via nock.
@@ -218,7 +245,7 @@ describe('Dev Server Middleware', () => {
218245
},
219246
});
220247

221-
const req = createMockRequest('/__dd/executeAction', {
248+
const req = createMockRequest('/__dd/executeActionViaCloud', {
222249
functionName: encodeQueryName(mockFunctions[0]),
223250
args: ['world'],
224251
});
@@ -314,7 +341,7 @@ describe('Dev Server Middleware', () => {
314341
});
315342
});
316343

317-
describe('executeAction handler', () => {
344+
describe('executeAction handler (local execution)', () => {
318345
const middleware = createDevServerMiddleware(
319346
mockViteBuild,
320347
() => mockFunctions,
@@ -346,6 +373,105 @@ describe('Dev Server Middleware', () => {
346373
expect(res.statusCode).toBe(404);
347374
});
348375

376+
test('Should run the bundle in a real forked child process and return its result', async () => {
377+
mockBuildWithParsedBackend(
378+
'export async function main($) { return { doubled: $.backendFunctionArgs[0] * 2 }; }',
379+
);
380+
381+
const req = createMockRequest('/__dd/executeAction', {
382+
functionName: encodeQueryName(mockFunctions[0]),
383+
args: [21],
384+
});
385+
const res = createMockResponse();
386+
387+
middleware(req, res, jest.fn());
388+
await res.done;
389+
390+
expect(res.statusCode).toBe(200);
391+
const body = JSON.parse(res.getBody());
392+
expect(body.success).toBe(true);
393+
expect(body.result).toEqual({ data: { doubled: 42 } });
394+
}, 20_000);
395+
396+
test('Should propagate a real crash in the bundled function as a 500, not a hang', async () => {
397+
mockBuildWithParsedBackend(
398+
"export async function main() { throw new Error('deliberate crash'); }",
399+
);
400+
401+
const req = createMockRequest('/__dd/executeAction', {
402+
functionName: encodeQueryName(mockFunctions[0]),
403+
args: [],
404+
});
405+
const res = createMockResponse();
406+
407+
middleware(req, res, jest.fn());
408+
await res.done;
409+
410+
expect(res.statusCode).toBe(500);
411+
const body = JSON.parse(res.getBody());
412+
expect(body.success).toBe(false);
413+
expect(body.error).toContain('deliberate crash');
414+
}, 20_000);
415+
416+
test('Should work without any Datadog credentials configured, unlike executeActionViaCloud', async () => {
417+
const noAuthMiddleware = createDevServerMiddleware(
418+
mockViteBuild,
419+
() => mockFunctions,
420+
mockOauthOnlyAuth,
421+
undefined,
422+
'/project',
423+
mockLog,
424+
);
425+
mockBuildWithParsedBackend('export async function main() { return { ok: true }; }');
426+
427+
const req = createMockRequest('/__dd/executeAction', {
428+
functionName: encodeQueryName(mockFunctions[0]),
429+
args: [],
430+
});
431+
const res = createMockResponse();
432+
433+
noAuthMiddleware(req, res, jest.fn());
434+
await res.done;
435+
436+
expect(res.statusCode).toBe(200);
437+
const body = JSON.parse(res.getBody());
438+
expect(body.success).toBe(true);
439+
expect(body.result).toEqual({ data: { ok: true } });
440+
}, 20_000);
441+
});
442+
443+
describe('executeActionViaCloud handler', () => {
444+
const middleware = createDevServerMiddleware(
445+
mockViteBuild,
446+
() => mockFunctions,
447+
mockAuth,
448+
getApiKeyRequest(),
449+
'/project',
450+
mockLog,
451+
);
452+
453+
test('Should return 400 for missing functionRef', async () => {
454+
const req = createMockRequest('/__dd/executeActionViaCloud', {});
455+
const res = createMockResponse();
456+
457+
middleware(req, res, jest.fn());
458+
await res.done;
459+
460+
expect(res.statusCode).toBe(400);
461+
});
462+
463+
test('Should return 404 for unknown function', async () => {
464+
const req = createMockRequest('/__dd/executeActionViaCloud', {
465+
functionName: 'nonexistent.nonexistent',
466+
});
467+
const res = createMockResponse();
468+
469+
middleware(req, res, jest.fn());
470+
await res.done;
471+
472+
expect(res.statusCode).toBe(404);
473+
});
474+
349475
/*
350476
* The nock mock replies with 403 to simulate the upstream Datadog API
351477
* rejecting the request (e.g. bad credentials). The middleware still
@@ -362,7 +488,7 @@ describe('Dev Server Middleware', () => {
362488
.post('/api/v2/app-builder/queries/preview-async')
363489
.reply(403, 'Forbidden');
364490

365-
const req = createMockRequest('/__dd/executeAction', {
491+
const req = createMockRequest('/__dd/executeActionViaCloud', {
366492
functionName: encodeQueryName(mockFunctions[0]),
367493
args: [],
368494
});
@@ -414,7 +540,7 @@ describe('Dev Server Middleware', () => {
414540
data: { attributes: { done: true, outputs: { data: { value: 42 } } } },
415541
});
416542

417-
const req = createMockRequest('/__dd/executeAction', {
543+
const req = createMockRequest('/__dd/executeActionViaCloud', {
418544
functionName: encodeQueryName(mockFunctions[0]),
419545
args: ['hello', 42],
420546
});
@@ -462,7 +588,7 @@ describe('Dev Server Middleware', () => {
462588
data: { attributes: { done: true, outputs: { data: { ok: true } } } },
463589
});
464590

465-
const req = createMockRequest('/__dd/executeAction', {
591+
const req = createMockRequest('/__dd/executeActionViaCloud', {
466592
functionName: encodeQueryName(mockFunctions[0]),
467593
args: [],
468594
});
@@ -488,7 +614,7 @@ describe('Dev Server Middleware', () => {
488614
mockLog,
489615
);
490616

491-
const req = createMockRequest('/__dd/executeAction', {
617+
const req = createMockRequest('/__dd/executeActionViaCloud', {
492618
functionName: encodeQueryName(mockFunctions[0]),
493619
args: [],
494620
});
@@ -539,7 +665,7 @@ describe('Dev Server Middleware', () => {
539665
});
540666

541667
const trickyArgs = ["don't break", "'); alert(1); //", '😀'];
542-
const req = createMockRequest('/__dd/executeAction', {
668+
const req = createMockRequest('/__dd/executeActionViaCloud', {
543669
functionName: encodeQueryName(mockFunctions[0]),
544670
args: trickyArgs,
545671
});
@@ -598,7 +724,7 @@ describe('Dev Server Middleware', () => {
598724
data: { attributes: { done: true, outputs: { data: { ok: true } } } },
599725
});
600726

601-
const req = createMockRequest('/__dd/executeAction', {
727+
const req = createMockRequest('/__dd/executeActionViaCloud', {
602728
functionName: encodeQueryName(functionsWithAllowlist[1]),
603729
args: [],
604730
});
@@ -653,7 +779,7 @@ describe('Dev Server Middleware', () => {
653779
data: { attributes: { done: true, outputs: { data: { ok: true } } } },
654780
});
655781

656-
const req = createMockRequest('/__dd/executeAction', {
782+
const req = createMockRequest('/__dd/executeActionViaCloud', {
657783
functionName: encodeQueryName(mockFunctions[0]),
658784
args: [],
659785
});
@@ -680,7 +806,7 @@ describe('Dev Server Middleware', () => {
680806
errors: [{ title: 'ExecutionFailed', detail: 'Script threw an error' }],
681807
});
682808

683-
const req = createMockRequest('/__dd/executeAction', {
809+
const req = createMockRequest('/__dd/executeActionViaCloud', {
684810
functionName: encodeQueryName(mockFunctions[0]),
685811
args: [],
686812
});
@@ -708,7 +834,7 @@ describe('Dev Server Middleware', () => {
708834
data: { attributes: { done: true, outputs: { data: { ok: true } } } },
709835
});
710836

711-
const req = createMockRequest('/__dd/executeAction', {
837+
const req = createMockRequest('/__dd/executeActionViaCloud', {
712838
functionName: encodeQueryName(mockFunctions[0]),
713839
args: [],
714840
});

packages/plugins/apps/src/vite/dev-server.ts

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { generateDevVirtualEntryContent } from '../backend/virtual-entry';
1818

1919
import { createBackendConnectionIdCollector } from './backend-connection-id-collector';
2020
import { getBaseBackendBuildConfig } from './build-config';
21+
import { executeScriptLocally } from './local-execution';
2122

2223
interface BundleResult {
2324
func: BackendFunction;
@@ -308,9 +309,52 @@ async function handleDebugBundle(
308309
}
309310

310311
/**
311-
* Handle POST /__dd/executeAction — bundles a backend function and executes it via Datadog API.
312+
* Handle POST /__dd/executeAction — bundles a backend function and runs it
313+
* locally in a forked Node child process (see local-execution.ts). This is
314+
* the new default per the local Node execution design doc: `npm run dev`
315+
* unconditionally uses local execution, no customer-facing toggle.
316+
*
317+
* NOTE: `$.Actions` calls are currently stubbed (see local-execution.ts's
318+
* executeActionRemotely) -- the single-action execution endpoint this needs
319+
* doesn't exist publicly yet (design doc's "Open Dependency" section). Real
320+
* action results are only available via /__dd/executeActionViaCloud below
321+
* until that resolves.
312322
*/
313323
async function handleExecuteAction(
324+
req: IncomingMessage,
325+
res: ServerResponse,
326+
functionsByName: Map<string, BackendFunction>,
327+
bundle: BundleFn,
328+
log: Logger,
329+
): Promise<void> {
330+
try {
331+
const { func, code, args } = await validateAndBundle(req, functionsByName, bundle);
332+
const displayName = formatRef(func);
333+
334+
log.debug(`Executing action locally: ${displayName} with args`);
335+
336+
const result = await executeScriptLocally(code, func, args, log);
337+
338+
res.statusCode = 200;
339+
res.setHeader('Content-Type', 'application/json');
340+
res.end(JSON.stringify({ success: true, result } satisfies ExecuteActionResponse));
341+
} catch (error: unknown) {
342+
const statusCode = error instanceof HttpError ? error.statusCode : 500;
343+
const message = error instanceof Error ? error.message : 'Internal server error';
344+
log.debug(`Error handling executeAction: ${message}`);
345+
sendError(res, statusCode, message);
346+
}
347+
}
348+
349+
/**
350+
* Handle POST /__dd/executeActionViaCloud — bundles a backend function and
351+
* executes it via Datadog's cloud API (today's `preview-async` + long-poll
352+
* round trip), unchanged from before local execution existed. Preserves this
353+
* path for pre-publish parity verification (the design doc's "Mode B") --
354+
* not yet wired to an `npm run dev:verify` script (that requires changes to
355+
* the create-apps scaffolding templates, outside this package's scope).
356+
*/
357+
async function handleExecuteActionViaCloud(
314358
req: IncomingMessage,
315359
res: ServerResponse,
316360
functionsByName: Map<string, BackendFunction>,
@@ -323,7 +367,7 @@ async function handleExecuteAction(
323367
const { func, code, args } = await validateAndBundle(req, functionsByName, bundle);
324368
const displayName = formatRef(func);
325369

326-
log.debug(`Executing action: ${displayName} with args`);
370+
log.debug(`Executing action via cloud: ${displayName} with args`);
327371

328372
const result = await executeScriptViaDatadog(
329373
code,
@@ -340,7 +384,7 @@ async function handleExecuteAction(
340384
} catch (error: unknown) {
341385
const statusCode = error instanceof HttpError ? error.statusCode : 500;
342386
const message = error instanceof Error ? error.message : 'Internal server error';
343-
log.debug(`Error handling executeAction: ${message}`);
387+
log.debug(`Error handling executeActionViaCloud: ${message}`);
344388
sendError(res, statusCode, message);
345389
}
346390
}
@@ -380,7 +424,7 @@ export function createDevServerMiddleware(
380424

381425
if (!doAuthenticatedRequest) {
382426
log.warn(
383-
`Auth credentials not configured. The /__dd/executeAction endpoint will be unavailable. ${AUTH_GUIDANCE}`,
427+
`Auth credentials not configured. The /__dd/executeActionViaCloud endpoint will be unavailable. ${AUTH_GUIDANCE}`,
384428
);
385429
}
386430

@@ -397,11 +441,19 @@ export function createDevServerMiddleware(
397441
sendError(res, 500, 'Unexpected error');
398442
});
399443
} else if (req.url === '/__dd/executeAction') {
444+
// Local execution doesn't need Datadog credentials today --
445+
// `$.Actions` calls are stubbed until the single-action execution
446+
// endpoint exists (see local-execution.ts). Unlike the cloud path
447+
// below, this endpoint works without any auth configured.
448+
handleExecuteAction(req, res, functionsByName, bundle, log).catch(() => {
449+
sendError(res, 500, 'Unexpected error');
450+
});
451+
} else if (req.url === '/__dd/executeActionViaCloud') {
400452
if (!doAuthenticatedRequest) {
401453
sendError(res, 400, `Auth credentials not configured. ${AUTH_GUIDANCE}`);
402454
return;
403455
}
404-
handleExecuteAction(
456+
handleExecuteActionViaCloud(
405457
req,
406458
res,
407459
functionsByName,

packages/plugins/apps/src/vite/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import type { AppsOptionsWithDefaults } from '../types';
2323
import { buildBackendFunctions } from './build-backend-functions';
2424
import { createDevServerMiddleware } from './dev-server';
2525
import { handleUpload } from './handle-upload';
26+
import { killAllLocalExecutionChildren } from './local-execution';
2627

2728
export type ViteBundler = {
2829
build: typeof build;
@@ -210,6 +211,13 @@ export const getVitePlugin = ({
210211
log,
211212
),
212213
);
214+
215+
// Local execution forks a real child process per backend-function
216+
// call (see local-execution.ts). Without this, killing the dev
217+
// server mid-execution would leave that child orphaned.
218+
server.httpServer?.once('close', () => {
219+
killAllLocalExecutionChildren();
220+
});
213221
},
214222
};
215223
};

0 commit comments

Comments
 (0)