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
3 changes: 3 additions & 0 deletions packages/plugins/apps/src/backend/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,6 @@ export interface BackendFunction {
/** Connection IDs this backend function is allowed to use. */
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: <value> }` wrapper. */
export type BackendOutputs = { data: unknown };
7 changes: 7 additions & 0 deletions packages/plugins/apps/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ export const PLUGIN_NAME: PluginName = 'datadog-apps-plugin' as const;
export const APPS_API_PATH = 'api/unstable/app-builder-code/apps';
export const ARCHIVE_FILENAME = 'datadog-apps-assets.zip';
export const BACKEND_FILE_RE = /\.backend\.(ts|tsx|js|jsx)$/;

/** Query suffix marking a local-execution load, so the transform hook can target it directly instead of matching on the broader `options.ssr` flag. */
export const LOCAL_EXECUTION_LOAD_SUFFIX = '?dd-local-exec';
// Matches a backend file with any (or no) trailing query string — scoping only to the exact local-execution suffix would let an unrecognized query slip past this filter and leak the real backend source instead of the safe proxy stub; the handler decides safety per case.
export const BACKEND_FILE_WITH_QUERY_RE = new RegExp(
`${BACKEND_FILE_RE.source.slice(0, -1)}(\\?.*)?$`,
);
export const BACKEND_CODE_EXTENSIONS = [
'.ts',
'.tsx',
Expand Down
7 changes: 1 addition & 6 deletions packages/plugins/apps/src/vite/dev-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { AUTH_GUIDANCE } from '../auth';
import type { DoAuthenticatedRequest } from '../auth';
import { encodeQueryName } from '../backend/encodeQueryName';
import type { ExecuteActionRequest, ExecuteActionResponse } from '../backend/protocol';
import type { BackendFunction } from '../backend/types';
import type { BackendFunction, BackendOutputs } from '../backend/types';
import { generateDevVirtualEntryContent } from '../backend/virtual-entry';

import { createBackendConnectionIdCollector } from './backend-connection-id-collector';
Expand All @@ -30,11 +30,6 @@ const DEV_VIRTUAL_PREFIX = 'virtual:dd-backend-dev:';

type AuthConfig = AuthOptionsWithDefaults;

/** Shape of the `outputs` field in a Datadog app-builder query response —
* the API wraps a JS action's return value as `{ data: <value> }`.
*/
type BackendOutputs = { data: unknown };

/**
* Format a BackendFunction for display in log/error messages.
*/
Expand Down
116 changes: 112 additions & 4 deletions packages/plugins/apps/src/vite/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { parseAst } from 'rollup/parseAst';

import { encodeQueryName } from '../backend/encodeQueryName';
import type { BackendFunction } from '../backend/types';
import { LOCAL_EXECUTION_LOAD_SUFFIX } from '../constants';

const functions: BackendFunction[] = [
{
Expand All @@ -31,6 +32,20 @@ const functions: BackendFunction[] = [
const bundleName1 = encodeQueryName(functions[0]);
const bundleName2 = encodeQueryName(functions[1]);

/** Narrows a Vite plugin's `transform` hook to its full-object form (`{ handler, ... }`) so tests can call it directly — throws with a clear message if it's the short-form function or missing, since these tests always configure the object form. */
function getTransformHandler(plugin: ReturnType<typeof getVitePlugin>): Function {
const transform = plugin?.transform;
if (
typeof transform !== 'object' ||
transform === null ||
!('handler' in transform) ||
typeof transform.handler !== 'function'
) {
throw new Error('Expected plugin.transform to be an object with a handler function.');
}
return transform.handler;
}

const mockViteBuild = jest.fn();
const mockVite = {
build: mockViteBuild,
Expand Down Expand Up @@ -135,11 +150,9 @@ describe('Backend Functions - getVitePlugin', () => {

test('Should build backend functions and then upload in closeBundle', async () => {
const plugin = getVitePlugin(defaultOptions);
const transform = plugin!.transform as {
handler: (code: string, id: string) => unknown;
};
const transformHandler = getTransformHandler(plugin);

await transform.handler.call(
await transformHandler.call(
{
parse: parseAst,
resolve: jest.fn(async () => null),
Expand All @@ -160,6 +173,101 @@ describe('Backend Functions - getVitePlugin', () => {
expect(assets.collectAssets).toHaveBeenCalledWith(['dist/**/*'], '/build');
});

// Regression test: without the suffix check, ssrLoadModule() would get the proxy stub instead of the real function body.
test('Should skip proxy generation for a suffixed local-execution load made from SSR context, returning the real source untouched', async () => {
const plugin = getVitePlugin(defaultOptions);
const transformHandler = getTransformHandler(plugin);

const realSource = 'export function myHandler() { return 42; }';
const result = await transformHandler.call(
{
parse: parseAst,
resolve: jest.fn(async () => null),
load: jest.fn(async () => null),
addWatchFile: jest.fn(),
},
realSource,
`/build/src/backend/myHandler.backend.ts${LOCAL_EXECUTION_LOAD_SUFFIX}`,
{ ssr: true },
);

expect(result).toBeNull();
});

// Regression test: the suffix alone must not bypass proxy generation — a spoofed client-side import reusing it still gets the safe proxy stub, never the real backend module body.
test('Should still generate the frontend RPC-proxy for a suffixed import made outside SSR context', async () => {
const plugin = getVitePlugin(defaultOptions);
const transformHandler = getTransformHandler(plugin);

const result = (await transformHandler.call(
{
parse: parseAst,
resolve: jest.fn(async () => null),
load: jest.fn(async () => null),
addWatchFile: jest.fn(),
},
'export function myHandler() { return 42; }',
`/build/src/backend/myHandler.backend.ts${LOCAL_EXECUTION_LOAD_SUFFIX}`,
)) as { code: string } | null;

expect(result?.code).toEqual(expect.stringContaining('executeBackendFunction'));
});

test('Should still generate the frontend RPC-proxy for a normal (unsuffixed) import of the same file', async () => {
const plugin = getVitePlugin(defaultOptions);
const transformHandler = getTransformHandler(plugin);

const result = (await transformHandler.call(
{
parse: parseAst,
resolve: jest.fn(async () => null),
load: jest.fn(async () => null),
addWatchFile: jest.fn(),
},
'export function myHandler() { return 42; }',
'/build/src/backend/myHandler.backend.ts',
)) as { code: string } | null;

expect(result?.code).toEqual(expect.stringContaining('executeBackendFunction'));
});

// Regression test: an unrecognized query string must still be caught by the transform filter, or Vite falls back to its default loader and leaks the real backend source.
test('Transform filter should match a backend file carrying an unrecognized query string', () => {
const plugin = getVitePlugin(defaultOptions);
const filter = (plugin!.transform as { filter?: { id?: { include?: RegExp[] } } }).filter;
const includePatterns = filter?.id?.include ?? [];

const idsThatMustMatch = [
'/build/src/backend/myHandler.backend.ts',
`/build/src/backend/myHandler.backend.ts${LOCAL_EXECUTION_LOAD_SUFFIX}`,
'/build/src/backend/myHandler.backend.ts?x',
`/build/src/backend/myHandler.backend.ts${LOCAL_EXECUTION_LOAD_SUFFIX}&x`,
];

for (const id of idsThatMustMatch) {
expect(includePatterns.some((pattern) => pattern.test(id))).toBe(true);
}
});

// Regression test: an unrecognized query must still default to the safe proxy stub, not the real backend source.
test('Should still generate the frontend RPC-proxy for an import with an unrecognized query string', async () => {
const plugin = getVitePlugin(defaultOptions);
const transformHandler = getTransformHandler(plugin);

const result = (await transformHandler.call(
{
parse: parseAst,
resolve: jest.fn(async () => null),
load: jest.fn(async () => null),
addWatchFile: jest.fn(),
},
'export function myHandler() { return 42; }',
'/build/src/backend/myHandler.backend.ts?x',
)) as { code: string } | null;

expect(result?.code).toEqual(expect.stringContaining('executeBackendFunction'));
});

test('Should inject the apps runtime', () => {
getVitePlugin(defaultOptions);

Expand Down
31 changes: 22 additions & 9 deletions packages/plugins/apps/src/vite/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,12 @@ import { extractExportedFunctions } from '../backend/ast-parsing/extract-backend
import { encodeQueryName } from '../backend/encodeQueryName';
import { generateProxyModule } from '../backend/proxy-codegen';
import type { BackendFunction } from '../backend/types';
import { BACKEND_FILE_RE, PLUGIN_NAME } from '../constants';
import {
BACKEND_FILE_RE,
BACKEND_FILE_WITH_QUERY_RE,
LOCAL_EXECUTION_LOAD_SUFFIX,
PLUGIN_NAME,
} from '../constants';
import type { AppsOptionsWithDefaults } from '../types';

import { buildBackendFunctions } from './build-backend-functions';
Expand Down Expand Up @@ -121,34 +126,42 @@ export const getVitePlugin = ({
transform: {
filter: {
id: {
include: [BACKEND_FILE_RE],
include: [BACKEND_FILE_WITH_QUERY_RE],
exclude: [/node_modules/, /[/\\]dist[/\\]/],
},
},
// For each .backend.* file, parse its named exports, register
// them as backend functions, and replace the module with a
// frontend proxy that calls executeBackendFunction at runtime.
handler(code, id) {
handler(code, id, transformOptions) {
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.
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.
const queryIndex = id.indexOf('?');
const normalizedId = queryIndex === -1 ? id : id.slice(0, queryIndex);

const ast = this.parse(code);
const exportNames = extractExportedFunctions(ast, id);
const exportNames = extractExportedFunctions(ast, normalizedId);
if (exportNames.length === 0) {
log.warn(
`Backend file ${id} has no exported functions. ` +
`Backend file ${normalizedId} has no exported functions. ` +
`Did you forget to add a named export?`,
);
// Clear any previously registered functions for this file
// so stale entries don't persist across HMR re-transforms.
setBackendFunctions(id, []);
setBackendFunctions(normalizedId, []);
return { code: '', map: null };
}

const { functions, proxyCode } = buildProxyModule(
exportNames,
id,
normalizedId,
context.buildRoot,
);
setBackendFunctions(id, functions);
log.debug(`Generated proxy for ${id} with ${functions.length} export(s)`);
setBackendFunctions(normalizedId, functions);
log.debug(`Generated proxy for ${normalizedId} with ${functions.length} export(s)`);

return { code: proxyCode, map: null };
},
Expand Down
Loading
Loading