Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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 @@ -31,11 +31,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
194 changes: 188 additions & 6 deletions packages/plugins/apps/src/vite/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,12 @@ import { parseAst } from 'rollup/parseAst';

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

type TransformHandler = (code: string, id: string) => unknown;
type TransformHandler = (code: string, id: string, transformOptions?: { ssr?: boolean }) => unknown;

// Narrows `plugin.transform` to the object-hook form via a runtime check, then wraps `handler` in `Reflect.apply` to match `TransformHandler` without casting its wider real signature.
function getTransformHandler(plugin: ReturnType<typeof getVitePlugin>): TransformHandler {
// Narrows `plugin.transform` to the object-hook form via a runtime check, since tests need to access both `handler` and `filter` without an `as` cast.
function getTransformObject(plugin: ReturnType<typeof getVitePlugin>) {
const { transform } = plugin ?? {};
if (
typeof transform !== 'object' ||
Expand All @@ -27,13 +28,32 @@ function getTransformHandler(plugin: ReturnType<typeof getVitePlugin>): Transfor
'Expected plugin.transform to be the object-hook form with a handler function',
);
}
return transform;
}

const handler = transform.handler;
return function callTransformHandler(this: unknown, code: string, id: string): unknown {
return Reflect.apply(handler, this, [code, id]);
// Wraps `handler` in `Reflect.apply` to match `TransformHandler` without casting its wider real signature.
function getTransformHandler(plugin: ReturnType<typeof getVitePlugin>): TransformHandler {
const { handler } = getTransformObject(plugin);
return function callTransformHandler(
this: unknown,
code: string,
id: string,
transformOptions?: { ssr?: boolean },
): unknown {
return Reflect.apply(handler, this, [code, id, transformOptions]);
};
}

/** Extracts `.code` from a transform hook's result if it's the object form — avoids an `as` cast on the otherwise-broad Rollup `TransformResult` union, since these tests only ever care about the code string. */
function extractTransformedCode(result: unknown): string | undefined {
return typeof result === 'object' &&
result !== null &&
'code' in result &&
typeof result.code === 'string'
? result.code
: undefined;
}

const functions: BackendFunction[] = [
{
relativePath: 'src/backend/myHandler',
Expand Down Expand Up @@ -235,6 +255,168 @@ describe('Backend Functions - getVitePlugin', () => {
expect(mockLogFn).toHaveBeenCalledWith(expect.stringContaining('Intl'), 'warn');
});

// 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 handler = getTransformHandler(plugin);

const realSource = 'export function myHandler() { return 42; }';
const result = await handler.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}`,
);

expect(extractTransformedCode(result)).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 handler = getTransformHandler(plugin);

const result = await handler.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',
);

expect(extractTransformedCode(result)).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 } = getTransformObject(plugin);
const filterId = filter?.id;
// This plugin always configures `filter.id` as `{ include: RegExp[] }` (see vite/index.ts) —
// narrowed here rather than asserted, since Rollup's own StringFilter type also allows a bare
// string/RegExp/array for other plugins' use.
const includePatterns =
typeof filterId === 'object' &&
filterId !== null &&
!Array.isArray(filterId) &&
!(filterId instanceof RegExp)
? (Array.isArray(filterId.include)
? filterId.include
: filterId.include
? [filterId.include]
: []
).filter((pattern): pattern is RegExp => pattern instanceof RegExp)
: [];

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',
);

expect(extractTransformedCode(result)).toEqual(
expect.stringContaining('executeBackendFunction'),
);
});

// Regression test: a query-bearing id with zero exports must not clear a DIFFERENT,
// already-registered import of the same file's real (unsuffixed) id — otherwise one
// unrelated query-bearing import anywhere in the app permanently breaks the file's real
// registration until an edit or server restart. Vite's own `?raw`/`?url`/`?worker` load hooks
// all produce a default export, which is already rejected with a loud throw before this
// branch is reached — this covers whatever else might legitimately produce zero exports
// without throwing.
test('Should not clear an already-registered function when a query-bearing import of the same file has zero exports', async () => {
const plugin = getVitePlugin(defaultOptions);
const handler = getTransformHandler(plugin);

// Real, unsuffixed import — registers myHandler normally.
await handler.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',
);

// An unrelated query-bearing import of the SAME file with zero exports (not `export
// default` — Vite's own `?raw`/`?url`/`?worker` load hooks all produce a default export,
// which this file's static checks already reject with a loud throw before this branch is
// ever reached; this covers whatever else might legitimately produce no named exports
// without throwing).
await handler.call(
{
parse: parseAst,
resolve: jest.fn(async () => null),
load: jest.fn(async () => null),
addWatchFile: jest.fn(),
},
'',
'/build/src/backend/myHandler.backend.ts?some-other-query',
);

// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (plugin as any).closeBundle();

// Still built once for myHandler — the ?raw import didn't clear its real registration.
expect(mockViteBuild).toHaveBeenCalledTimes(1);
});

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

Expand Down
55 changes: 39 additions & 16 deletions packages/plugins/apps/src/vite/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,12 @@ import { ensureProgram } from '../backend/ast-parsing/type-guards';
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 @@ -124,39 +129,57 @@ 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 program = ensureProgram(ast, id);
const program = ensureProgram(ast, normalizedId);
// Shared so the checks below don't each independently re-walk the same AST to build the same scope graph.
const scopeAnalysis = analyzeModuleScope(program);
// Runs even for a file with zero exports, to catch a banned import/global as soon as it's written.
runBackendStaticChecks(ast, id, log, scopeAnalysis);
const exportNames = extractExportedFunctions(ast, id);
runBackendStaticChecks(ast, normalizedId, log, scopeAnalysis);
const exportNames = extractExportedFunctions(ast, normalizedId);
if (exportNames.length === 0) {
log.warn(
`Backend file ${id} 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, []);
// Only a genuinely no-query id can be trusted as a real re-transform of this
// exact file's own source. Vite's own `?raw`/`?url`/`?worker` load hooks all
// produce a default export, which enumerateBackendExports already rejects
// with a loud throw before this branch is reached — but some other
// query-bearing load producing zero-export content isn't ruled out, and
// clearing the registry for that case would silently and permanently break
// the file's real (unsuffixed) registration until a file edit or server
// restart, over an import that never touched its real source.
if (queryIndex === -1) {
log.warn(
`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(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