Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/plugins/apps/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@
"format": [
"esm"
]
},
"local-exec-child": {
"entry": "./src/vite/local-exec-child.js",
"format": [
"cjs"
]
}
}
},
Expand Down
150 changes: 138 additions & 12 deletions packages/plugins/apps/src/vite/dev-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,34 @@ describe('Dev Server Middleware', () => {
expect(res.end).toHaveBeenCalled();
});

test('Should handle /__dd/executeAction POST', async () => {
test('Should handle /__dd/executeAction POST by running the bundle locally, not via the Datadog API', async () => {
mockBuildWithParsedBackend(
'export async function main($) { return { echo: $.backendFunctionArgs }; }',
);

// No nock scope registered -- if the middleware still called the
// Datadog API for this endpoint, the request would fail outright
// (nock throws on unmocked requests by default), so an unmocked
// 200 here already proves local execution, not the cloud path.
const req = createMockRequest('/__dd/executeAction', {
functionName: encodeQueryName(mockFunctions[0]),
args: ['world'],
});
const res = createMockResponse();
const next = jest.fn();

middleware(req, res, next);
expect(next).not.toHaveBeenCalled();

await res.done;

expect(res.statusCode).toBe(200);
const body = JSON.parse(res.getBody());
expect(body.success).toBe(true);
expect(body.result).toEqual({ data: { echo: ['world'] } });
}, 20_000);

test('Should handle /__dd/executeActionViaCloud POST', async () => {
mockBuildWithParsedBackend();

// Mock the Datadog API via nock.
Expand All @@ -225,7 +252,7 @@ describe('Dev Server Middleware', () => {
},
});

const req = createMockRequest('/__dd/executeAction', {
const req = createMockRequest('/__dd/executeActionViaCloud', {
functionName: encodeQueryName(mockFunctions[0]),
args: ['world'],
});
Expand Down Expand Up @@ -321,7 +348,7 @@ describe('Dev Server Middleware', () => {
});
});

describe('executeAction handler', () => {
describe('executeAction handler (local execution)', () => {
const middleware = createDevServerMiddleware(
mockViteBuild,
() => mockFunctions,
Expand Down Expand Up @@ -353,6 +380,105 @@ describe('Dev Server Middleware', () => {
expect(res.statusCode).toBe(404);
});

test('Should run the bundle in a real forked child process and return its result', async () => {
mockBuildWithParsedBackend(
'export async function main($) { return { doubled: $.backendFunctionArgs[0] * 2 }; }',
);

const req = createMockRequest('/__dd/executeAction', {
functionName: encodeQueryName(mockFunctions[0]),
args: [21],
});
const res = createMockResponse();

middleware(req, res, jest.fn());
await res.done;

expect(res.statusCode).toBe(200);
const body = JSON.parse(res.getBody());
expect(body.success).toBe(true);
expect(body.result).toEqual({ data: { doubled: 42 } });
}, 20_000);

test('Should propagate a real crash in the bundled function as a 500, not a hang', async () => {
mockBuildWithParsedBackend(
"export async function main() { throw new Error('deliberate crash'); }",
);

const req = createMockRequest('/__dd/executeAction', {
functionName: encodeQueryName(mockFunctions[0]),
args: [],
});
const res = createMockResponse();

middleware(req, res, jest.fn());
await res.done;

expect(res.statusCode).toBe(500);
const body = JSON.parse(res.getBody());
expect(body.success).toBe(false);
expect(body.error).toContain('deliberate crash');
}, 20_000);

test('Should work without any Datadog credentials configured, unlike executeActionViaCloud', async () => {
const noAuthMiddleware = createDevServerMiddleware(
mockViteBuild,
() => mockFunctions,
mockOauthOnlyAuth,
undefined,
'/project',
mockLog,
);
mockBuildWithParsedBackend('export async function main() { return { ok: true }; }');

const req = createMockRequest('/__dd/executeAction', {
functionName: encodeQueryName(mockFunctions[0]),
args: [],
});
const res = createMockResponse();

noAuthMiddleware(req, res, jest.fn());
await res.done;

expect(res.statusCode).toBe(200);
const body = JSON.parse(res.getBody());
expect(body.success).toBe(true);
expect(body.result).toEqual({ data: { ok: true } });
}, 20_000);
});

describe('executeActionViaCloud handler', () => {
const middleware = createDevServerMiddleware(
mockViteBuild,
() => mockFunctions,
mockAuth,
getApiKeyRequest(),
'/project',
mockLog,
);

test('Should return 400 for missing functionRef', async () => {
const req = createMockRequest('/__dd/executeActionViaCloud', {});
const res = createMockResponse();

middleware(req, res, jest.fn());
await res.done;

expect(res.statusCode).toBe(400);
});

test('Should return 404 for unknown function', async () => {
const req = createMockRequest('/__dd/executeActionViaCloud', {
functionName: 'nonexistent.nonexistent',
});
const res = createMockResponse();

middleware(req, res, jest.fn());
await res.done;

expect(res.statusCode).toBe(404);
});

/*
* The nock mock replies with 403 to simulate the upstream Datadog API
* rejecting the request (e.g. bad credentials). The middleware still
Expand All @@ -369,7 +495,7 @@ describe('Dev Server Middleware', () => {
.post('/api/v2/app-builder/queries/preview-async')
.reply(403, 'Forbidden');

const req = createMockRequest('/__dd/executeAction', {
const req = createMockRequest('/__dd/executeActionViaCloud', {
functionName: encodeQueryName(mockFunctions[0]),
args: [],
});
Expand Down Expand Up @@ -421,7 +547,7 @@ describe('Dev Server Middleware', () => {
data: { attributes: { done: true, outputs: { data: { value: 42 } } } },
});

const req = createMockRequest('/__dd/executeAction', {
const req = createMockRequest('/__dd/executeActionViaCloud', {
functionName: encodeQueryName(mockFunctions[0]),
args: ['hello', 42],
});
Expand Down Expand Up @@ -469,7 +595,7 @@ describe('Dev Server Middleware', () => {
data: { attributes: { done: true, outputs: { data: { ok: true } } } },
});

const req = createMockRequest('/__dd/executeAction', {
const req = createMockRequest('/__dd/executeActionViaCloud', {
functionName: encodeQueryName(mockFunctions[0]),
args: [],
});
Expand All @@ -495,7 +621,7 @@ describe('Dev Server Middleware', () => {
mockLog,
);

const req = createMockRequest('/__dd/executeAction', {
const req = createMockRequest('/__dd/executeActionViaCloud', {
functionName: encodeQueryName(mockFunctions[0]),
args: [],
});
Expand Down Expand Up @@ -546,7 +672,7 @@ describe('Dev Server Middleware', () => {
});

const trickyArgs = ["don't break", "'); alert(1); //", '😀'];
const req = createMockRequest('/__dd/executeAction', {
const req = createMockRequest('/__dd/executeActionViaCloud', {
functionName: encodeQueryName(mockFunctions[0]),
args: trickyArgs,
});
Expand Down Expand Up @@ -605,7 +731,7 @@ describe('Dev Server Middleware', () => {
data: { attributes: { done: true, outputs: { data: { ok: true } } } },
});

const req = createMockRequest('/__dd/executeAction', {
const req = createMockRequest('/__dd/executeActionViaCloud', {
functionName: encodeQueryName(functionsWithAllowlist[1]),
args: [],
});
Expand Down Expand Up @@ -660,7 +786,7 @@ describe('Dev Server Middleware', () => {
data: { attributes: { done: true, outputs: { data: { ok: true } } } },
});

const req = createMockRequest('/__dd/executeAction', {
const req = createMockRequest('/__dd/executeActionViaCloud', {
functionName: encodeQueryName(mockFunctions[0]),
args: [],
});
Expand All @@ -687,7 +813,7 @@ describe('Dev Server Middleware', () => {
errors: [{ title: 'ExecutionFailed', detail: 'Script threw an error' }],
});

const req = createMockRequest('/__dd/executeAction', {
const req = createMockRequest('/__dd/executeActionViaCloud', {
functionName: encodeQueryName(mockFunctions[0]),
args: [],
});
Expand Down Expand Up @@ -715,7 +841,7 @@ describe('Dev Server Middleware', () => {
data: { attributes: { done: true, outputs: { data: { ok: true } } } },
});

const req = createMockRequest('/__dd/executeAction', {
const req = createMockRequest('/__dd/executeActionViaCloud', {
functionName: encodeQueryName(mockFunctions[0]),
args: [],
});
Expand Down
62 changes: 57 additions & 5 deletions packages/plugins/apps/src/vite/dev-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { generateDevVirtualEntryContent } from '../backend/virtual-entry';

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

interface BundleResult {
func: BackendFunction;
Expand Down Expand Up @@ -308,9 +309,52 @@ async function handleDebugBundle(
}

/**
* Handle POST /__dd/executeAction — bundles a backend function and executes it via Datadog API.
* Handle POST /__dd/executeAction — bundles a backend function and runs it
* locally in a forked Node child process (see local-execution.ts). This is
* the new default per the local Node execution design doc: `npm run dev`
* unconditionally uses local execution, no customer-facing toggle.
*
* NOTE: `$.Actions` calls are currently stubbed (see local-execution.ts's
* executeActionRemotely) -- the single-action execution endpoint this needs
* doesn't exist publicly yet (design doc's "Open Dependency" section). Real
* action results are only available via /__dd/executeActionViaCloud below
* until that resolves.
*/
async function handleExecuteAction(
req: IncomingMessage,
res: ServerResponse,
functionsByName: Map<string, BackendFunction>,
bundle: BundleFn,
log: Logger,
): Promise<void> {
try {
const { func, code, args } = await validateAndBundle(req, functionsByName, bundle);
const displayName = formatRef(func);

log.debug(`Executing action locally: ${displayName} with args`);

const result = await executeScriptLocally(code, func, args, log);

res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ success: true, result } satisfies ExecuteActionResponse));
} catch (error: unknown) {
const statusCode = error instanceof HttpError ? error.statusCode : 500;
const message = error instanceof Error ? error.message : 'Internal server error';
log.debug(`Error handling executeAction: ${message}`);
sendError(res, statusCode, message);
}
}

/**
* Handle POST /__dd/executeActionViaCloud — bundles a backend function and
* executes it via Datadog's cloud API (today's `preview-async` + long-poll
* round trip), unchanged from before local execution existed. Preserves this
* path for pre-publish parity verification (the design doc's "Mode B") --
* not yet wired to an `npm run dev:verify` script (that requires changes to
* the create-apps scaffolding templates, outside this package's scope).
*/
async function handleExecuteActionViaCloud(
req: IncomingMessage,
res: ServerResponse,
functionsByName: Map<string, BackendFunction>,
Expand All @@ -323,7 +367,7 @@ async function handleExecuteAction(
const { func, code, args } = await validateAndBundle(req, functionsByName, bundle);
const displayName = formatRef(func);

log.debug(`Executing action: ${displayName} with args`);
log.debug(`Executing action via cloud: ${displayName} with args`);

const result = await executeScriptViaDatadog(
code,
Expand All @@ -340,7 +384,7 @@ async function handleExecuteAction(
} catch (error: unknown) {
const statusCode = error instanceof HttpError ? error.statusCode : 500;
const message = error instanceof Error ? error.message : 'Internal server error';
log.debug(`Error handling executeAction: ${message}`);
log.debug(`Error handling executeActionViaCloud: ${message}`);
sendError(res, statusCode, message);
}
}
Expand Down Expand Up @@ -380,7 +424,7 @@ export function createDevServerMiddleware(

if (!doAuthenticatedRequest) {
log.warn(
`Auth credentials not configured. The /__dd/executeAction endpoint will be unavailable. ${AUTH_GUIDANCE}`,
`Auth credentials not configured. The /__dd/executeActionViaCloud endpoint will be unavailable. ${AUTH_GUIDANCE}`,
);
}

Expand All @@ -397,11 +441,19 @@ export function createDevServerMiddleware(
sendError(res, 500, 'Unexpected error');
});
} else if (req.url === '/__dd/executeAction') {
// Local execution doesn't need Datadog credentials today --
// `$.Actions` calls are stubbed until the single-action execution
// endpoint exists (see local-execution.ts). Unlike the cloud path
// below, this endpoint works without any auth configured.
handleExecuteAction(req, res, functionsByName, bundle, log).catch(() => {
sendError(res, 500, 'Unexpected error');
});
} else if (req.url === '/__dd/executeActionViaCloud') {
if (!doAuthenticatedRequest) {
sendError(res, 400, `Auth credentials not configured. ${AUTH_GUIDANCE}`);
return;
}
handleExecuteAction(
handleExecuteActionViaCloud(
req,
res,
functionsByName,
Expand Down
8 changes: 8 additions & 0 deletions packages/plugins/apps/src/vite/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type { AppsOptionsWithDefaults } from '../types';
import { buildBackendFunctions } from './build-backend-functions';
import { createDevServerMiddleware } from './dev-server';
import { handleUpload } from './handle-upload';
import { killAllLocalExecutionChildren } from './local-execution';

export type ViteBundler = {
build: typeof build;
Expand Down Expand Up @@ -210,6 +211,13 @@ export const getVitePlugin = ({
log,
),
);

// Local execution forks a real child process per backend-function
// call (see local-execution.ts). Without this, killing the dev
// server mid-execution would leave that child orphaned.
server.httpServer?.once('close', () => {
killAllLocalExecutionChildren();
});
},
};
};
Loading
Loading