Skip to content
Open
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
9 changes: 5 additions & 4 deletions packages/plugins/apps/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,17 +61,18 @@ To call one by hand, send that content type, for example `curl -X POST -H 'Conte

## Local execution

On Vite 6.0.4 or later, `/__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.
On Vite 6.0.4 or later, `/__dd/executeAction` runs each backend function call the way production does: in a fresh Node process of its own, in parallel with other calls.

- **Fresh state**: module-level variables and globals start over on every call.
- **Concurrency**: calls run in parallel, with no local limit, so a large burst starts that many processes. Production's concurrency limits vary by org, and `dev:verify` runs against them. Calls that start together share one Datadog query for their runtime context (`$.Source`).
- **Timeout**: a call that runs past its time budget is killed, even inside a synchronous loop.
- **Sandbox**: Node's [permission model](https://nodejs.org/api/permissions.html) lets a call read the project, its `node_modules` folders and the packages linked from them, Vite and Vite's [`server.fs.allow`](https://vite.dev/config/server-options#server-fs-allow) folders, and write only to its own temp dir (`os.tmpdir()`), which is also its working directory, and `/tmp`. Modules loaded through Vite, though not `fs` reads or a native `require`, refuse files matching Vite's [`server.fs.deny`](https://vite.dev/config/server-options#server-fs-deny), such as `.env`, and, unless `server.fs.strict` is `false`, files outside those folders. It refuses subprocesses, worker threads and native addons, allows the network, and caps the heap at 256 MB. A known gap, from Node's [documented limitations](https://nodejs.org/api/permissions.html#limitations-and-known-issues): "Symbolic links will be followed even to locations outside of the set of paths that access has been granted to." Here, a symlink in a writable folder (the call's temp dir or `/tmp`) lets a call read or write wherever it points, project files included. Local execution is a parity aid, not a security boundary.
- **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`), and no Node options from `NODE_OPTIONS`. `$.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.
- **Logs**: a call's `stdout` and `stderr` reach the dev server's terminal line by line, each prefixed with its call, for example `[getGreeting#3]`.
- **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 with its prefix. Attach to that URL to debug the call; calls don't time out while the dev server is under a debugger.
- **Older Node**: on a Node without the permission model, or without `--allow-inspector` while debugging, a call's process runs under in-process guards instead. They refuse writes outside the temp dirs, subprocesses and worker threads, from the module's top-level code on.

Vite 5 has no module runner transport, so there each call runs inside the dev server's process. Vite 6.0.0 to 6.0.3, whose dev server answers the module runner in an older shape, do the same with a warning at startup, as do a Yarn Plug'n'Play project, whose packages load through hooks a call's process doesn't get, and any Vite 6+ project where `vite/module-runner` can't be resolved.
Vite 5 has no module runner transport, so there each call runs inside the dev server's process, one at a time. Vite 6.0.0 to 6.0.3, whose dev server answers the module runner in an older shape, do the same with a warning at startup, as do a Yarn Plug'n'Play project, whose packages load through hooks a call's process doesn't get, and any Vite 6+ project where `vite/module-runner` can't be resolved.

## Package output

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,41 @@ describe('executeBackendFunction', () => {
);
});

it("should throw with the dev server's error, as a deployed app does", async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: false,
status: 500,
text: async () =>
JSON.stringify({ success: false, error: 'Execution failed: plain error x' }),
});

const error = await executeBackendFunction('testFunction', []).catch((e: unknown) => e);

expect(error).toBeInstanceOf(BackendFunctionError);
expect(error).toMatchObject({
message: 'Execution failed: plain error x',
functionName: 'testFunction',
statusCode: 500,
});
});

it.each([
['a non-string error', JSON.stringify({ success: false, error: 42 })],
['an empty error', JSON.stringify({ success: false, error: '' })],
['a success body', JSON.stringify({ success: true, error: 'unused' })],
['JSON that is not an object', 'null'],
])('should keep the status and raw body for %s', async (_label, body) => {
global.fetch = jest.fn().mockResolvedValue({
ok: false,
status: 500,
text: async () => body,
});

await expect(executeBackendFunction('testFunction', [])).rejects.toThrow(
`Backend function "testFunction" failed with status 500: ${body}`,
);
});

it('should throw BackendFunctionError when response contains error field', async () => {
const mockResponse = {
error: 'Function execution failed',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,26 @@ import { BackendFunctionError } from '../types';

const ENDPOINT = '/__dd/executeAction';

function parseErrorMessage(body: string): string | undefined {
try {
const parsed: unknown = JSON.parse(body);
if (
typeof parsed === 'object' &&
parsed !== null &&
'success' in parsed &&
parsed.success === false &&
'error' in parsed &&
typeof parsed.error === 'string' &&
parsed.error !== ''
) {
return parsed.error;
}
} catch {
return undefined;
}
return undefined;
}

export const devServerTransport: BackendFunctionTransport = async <TData>(
functionName: string,
args: unknown[],
Expand Down Expand Up @@ -39,7 +59,10 @@ export const devServerTransport: BackendFunctionTransport = async <TData>(
let errorMessage = `Backend function "${functionName}" failed with status ${response.status}`;
try {
const errorBody = await response.text();
if (errorBody) {
const parsedMessage = parseErrorMessage(errorBody);
if (parsedMessage !== undefined) {
errorMessage = parsedMessage;
Comment thread
tyffical marked this conversation as resolved.
} else if (errorBody) {
errorMessage += `: ${errorBody}`;
}
} catch {
Expand Down
252 changes: 252 additions & 0 deletions packages/plugins/apps/src/vite/dev-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
formatRef,
getRetryDelay,
} from '@dd/apps-plugin/vite/dev-server';
import type { LocalExecutionRequest } from '@dd/apps-plugin/vite/local-execution-process';
import { DEFAULT_TIMEOUT_MS, withTimeout } from '@dd/apps-plugin/vite/local-execution';
import type { AuthOptionsWithDefaults, RequestOpts } from '@dd/core/types';
import {
Expand Down Expand Up @@ -1204,6 +1205,67 @@ describe('Dev Server Middleware', () => {
);
expect(mockLoadModule).not.toHaveBeenCalled();
});

test('Should send one runtime context query for calls that need it at the same time', async () => {
nock.cleanAll();
const hydration = mockRuntimeContextHydration(1);
const callCount = 3;
const allStarted = createDeferred();
let started = 0;
const executeLocally = jest.fn(async (request: LocalExecutionRequest) => {
started += 1;
if (started === callCount) {
allStarted.resolve();
}
await withTimeout(allStarted.promise, 5000, 'Waiting for every call to start');
const data = await request.getRuntimeContext();
return { data };
});
const childMiddleware = createTestMiddleware({ executeLocally });
const responses = Array.from({ length: callCount }, () => {
const req = createMockRequest('/__dd/executeAction', {
functionName: encodeQueryName(mockFunctions[0]),
args: [],
});
const res = createMockResponse();
childMiddleware(req, res, jest.fn());
return res;
});
const allDone = responses.map((res) => res.done);
await Promise.all(allDone);

for (const res of responses) {
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.getBody()).result).toEqual({
data: previewRuntimeContext,
});
}
expect(hydration.isDone()).toBe(true);
});

test('Should query the runtime context again for a call that starts after the last query ended', async () => {
nock.cleanAll();
const hydration = mockRuntimeContextHydration(2);
const executeLocally = jest.fn(async (request: LocalExecutionRequest) => {
const data = await request.getRuntimeContext();
return { data };
});
const childMiddleware = createTestMiddleware({ executeLocally });
const callAndWait = async () => {
const req = createMockRequest('/__dd/executeAction', {
functionName: encodeQueryName(mockFunctions[0]),
args: [],
});
const res = createMockResponse();
childMiddleware(req, res, jest.fn());
await res.done;
return res.statusCode;
};

expect(await callAndWait()).toBe(200);
expect(await callAndWait()).toBe(200);
expect(hydration.isDone()).toBe(true);
});
});

test('Should hydrate context, run the function directly in-process, and return its result without bundling', async () => {
Expand Down Expand Up @@ -1588,6 +1650,196 @@ describe('Dev Server Middleware', () => {
);
});

describe('What a failed $.Actions call rejects with, as in a deployed app', () => {
const catchingPostMessage = (call: Record<string, unknown>) => async () => {
try {
await testDollarActions().slack.chat.postMessage(call);
return 'no error';
} catch (error) {
return { type: typeof error, value: error };
}
};
const runAndReadData = async () => {
const req = createMockRequest('/__dd/executeAction', {
functionName: encodeQueryName(mockFunctions[0]),
args: [],
});
const res = createMockResponse();
middleware(req, res, jest.fn());
await res.done;
return JSON.parse(res.getBody()).result.data;
};
const mockActionErrors = (errors: Array<{ detail?: string; title?: string }>) =>
nock(DD_API_ORIGIN)
.post('/api/v2/app-builder/queries/preview-async')
.reply(200, { data: { id: 'receipt-action-failure' } })
.get(
'/api/v2/app-builder/queries/execution-long-polling/receipt-action-failure',
)
.reply(200, { errors });

test("Should reject with the platform's bare error detail as a string", async () => {
mockLoadModuleReturning(
mockFunctions[0],
catchingPostMessage({ inputs: { text: 'hi' } }),
);
mockActionErrors([
{ detail: 'rpc error: code = NotFound desc = connection not found' },
]);

await expect(runAndReadData()).resolves.toEqual({
type: 'string',
value: 'rpc error: code = NotFound desc = connection not found',
});
});

test("Should join several of the platform's errors, using a title when there's no detail", async () => {
mockLoadModuleReturning(
mockFunctions[0],
catchingPostMessage({ inputs: { text: 'hi' } }),
);
mockActionErrors([{ detail: 'first' }, { title: 'second' }]);

await expect(runAndReadData()).resolves.toEqual({
type: 'string',
value: 'first; second',
});
});

test('Should reject a call with no inputs with the deployed wording, as a string', async () => {
mockLoadModuleReturning(mockFunctions[0], catchingPostMessage({}));

await expect(runAndReadData()).resolves.toEqual({
type: 'string',
value: 'First argument to action $.Actions.slack.chat.postMessage must have an inputs field that is an object',
});
});

test('Should report an uncaught failure as "Execution failed: " and the bare detail', async () => {
mockLoadModuleReturning(mockFunctions[0], () =>
testDollarActions().slack.chat.postMessage({ inputs: { text: 'hi' } }),
);
mockActionErrors([
{ detail: 'a connection is required for com.datadoghq.slack.chat' },
]);
const req = createMockRequest('/__dd/executeAction', {
functionName: encodeQueryName(mockFunctions[0]),
args: [],
});
const res = createMockResponse();
middleware(req, res, jest.fn());
await res.done;

expect(res.statusCode).toBe(500);
expect(JSON.parse(res.getBody())).toEqual({
success: false,
error: 'Execution failed: a connection is required for com.datadoghq.slack.chat',
});
});

test('Should reject a call with no arguments with the deployed wording, as a string', async () => {
mockLoadModuleReturning(mockFunctions[0], async () => {
try {
await testDollarActions().slack.chat.postMessage();
return 'no error';
} catch (error) {
return { type: typeof error, value: error };
}
});

await expect(runAndReadData()).resolves.toEqual({
type: 'string',
value: 'No arguments provided to action $.Actions.slack.chat.postMessage',
});
});

test('Should reject with a fallback message when no platform error has a detail or a title', async () => {
mockLoadModuleReturning(
mockFunctions[0],
catchingPostMessage({ inputs: { text: 'hi' } }),
);
mockActionErrors([{}, { detail: '' }]);

await expect(runAndReadData()).resolves.toEqual({
type: 'string',
value: 'Query execution failed',
});
});

test("Should reject a connection outside the function's allowlist as a string", async () => {
mockLoadModuleReturning(
mockFunctions[0],
catchingPostMessage({ inputs: { text: 'hi' }, connectionId: 'not-listed' }),
);

await expect(runAndReadData()).resolves.toEqual({
type: 'string',
value: `Action $.Actions.slack.chat.postMessage used connection "not-listed", which is not in this function's allowed connections: []`,
});
});
});

describe('What an uncaught failure reports, as in a deployed app', () => {
const runAndReadError = async (fn: () => unknown) => {
mockLoadModuleReturning(mockFunctions[0], fn);
const req = createMockRequest('/__dd/executeAction', {
functionName: encodeQueryName(mockFunctions[0]),
args: [],
});
const res = createMockResponse();
middleware(req, res, jest.fn());
await res.done;
return { statusCode: res.statusCode, error: JSON.parse(res.getBody()).error };
};

test('Should prefix a thrown string with "Execution failed: "', async () => {
await expect(
runAndReadError(() => {
const failure = 'plain string failure';
throw failure;
}),
).resolves.toEqual({
statusCode: 500,
error: 'Execution failed: plain string failure',
});
});

test("Should report a thrown Error's stack after the prefix", async () => {
const { statusCode, error } = await runAndReadError(() => {
throw new TypeError('plain error x');
});
expect(statusCode).toBe(500);
expect(error).toMatch(/^Execution failed: TypeError: plain error x\n {4}at /);
});

test('Should report any other thrown value as a string', async () => {
await expect(
runAndReadError(() => {
const failure = { a: 1 };
throw failure;
}),
).resolves.toEqual({ statusCode: 500, error: 'Execution failed: [object Object]' });
});

test("Should leave the dev server's own failures unprefixed", async () => {
mockLoadModule.mockImplementation(
moduleResolverFor(mockFunctions[0], { notTheFunction: () => 'unused' }),
);
const req = createMockRequest('/__dd/executeAction', {
functionName: encodeQueryName(mockFunctions[0]),
args: [],
});
const res = createMockResponse();
middleware(req, res, jest.fn());
await res.done;

expect(res.statusCode).toBe(500);
expect(JSON.parse(res.getBody()).error).toBe(
`"${mockFunctions[0].name}" is not a function exported from ${mockFunctions[0].absolutePath}`,
);
});
});

// 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.
Expand Down
Loading
Loading