Skip to content
Merged
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
7 changes: 6 additions & 1 deletion packages/plugins/apps/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ It needs Vite 6.4 or later: on an older Vite, it stops every Vite command with a
- [Development server requests](#development-server-requests)
- [Cloud execution](#cloud-execution)
- [Local execution](#local-execution)
- [Backend function runtime](#backend-function-runtime)
- [Package output](#package-output)
- [apps.enable](#appsenable)
- [apps.include](#appsinclude)
Expand Down Expand Up @@ -44,7 +45,7 @@ apps?: {

## Backend functions

Each named export of a `.backend.ts` (or `.tsx`, `.js`, `.jsx`) module in your app is a backend function. The frontend gets a proxy that executes it through Datadog. `vite build` bundles each function on its own into the package. `vite dev` executes it locally through `/__dd/executeAction`. Static checks reject Node built-in imports and network globals such as `fetch` in backend code and the modules it imports. Static `connectionId`s passed to `@datadog/action-catalog` calls become the function's allowed connections.
Each named export of a `.backend.ts` (or `.tsx`, `.js`, `.jsx`) module in your app is a backend function. The frontend gets a proxy that executes it through Datadog. `vite build` bundles each function on its own into the package. `vite dev` executes it locally through `/__dd/executeAction`. Static checks reject globals the [Node backend runtime](#backend-function-runtime) lacks in backend code and the modules it imports. Static `connectionId`s passed to `@datadog/action-catalog` calls become the function's allowed connections.

### Backend functions from packages

Expand Down Expand Up @@ -110,6 +111,10 @@ When Datadog rejects a bundle as longer than the `jsFunctionWithActions` `script

Local execution doesn't support Yarn Plug'n'Play, whose packages load through hooks a call's process doesn't get: each call fails with a hint to set `nodeLinker: node-modules` or use `dev:verify`.

## Backend function runtime

Backend functions run on the Node backend runtime, and the plugin's build-time and dev-server checks follow its rules: backend functions may import Node built-ins and use Node's network globals such as `fetch` and `WebSocket`, but not `XMLHttpRequest`, `EventSource` or `Deno`, which Node doesn't expose by default. The checks fail only where the missing global would throw: any bare use but `typeof Deno`, or using `globalThis.Deno` as an object, function or class, even after a feature check or a polyfill install, so use an imported polyfill by its name. Feature checks such as `if (globalThis.EventSource)` and destructuring defaults pass. The checks follow a binding the global is declared or destructured into, but not a plain assignment such as `d = globalThis.Deno`, a `let` or parameter alias of `globalThis`, a bulk copy such as `{ ...globalThis }`, or a value that is stored in an object, array or class field, exported, returned or passed on. Local execution runs on your own Node, while the Node backend runtime runs Node 24, so the dev server warns once at start on another major version, and on one before Node 22, which lacks the global `WebSocket`.

## Package output

A production `vite build` writes `datadog-app-assets.zip` beside the Vite output. The ZIP contains `frontend/`, `backend/`, and `manifest.json`. The app's identity is resolved by `@datadog/apps-cli` at deploy time.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@

import type * as eslintScope from 'eslint-scope';
import type {
BaseNode,
Node as EstreeNode,
Expression,
Identifier,
MemberExpression,
ObjectExpression,
ObjectPattern,
PrivateIdentifier,
Program,
Expand All @@ -18,7 +20,7 @@ import type {
import { resolveIdentifier } from './module-scope';
import type { ModuleScopeAnalysis } from './module-scope';
import { isVariableDeclaratorNode, staticStringValue } from './type-guards';
import { walkAst } from './walk-ast';
import { parentsOf, walkAst } from './walk-ast';

const GLOBAL_THIS_NAME = 'globalThis';
const NODE_GLOBAL_ALIAS_NAME = 'global';
Expand Down Expand Up @@ -183,71 +185,132 @@ function findDeclaredVariable(
return scopeAnalysis.scopeManager.getDeclaredVariables(declarator)[0];
}

// Standard-library statics that reify every property VALUE of their argument at once (see the CallExpression visitor below). `Object.keys`/`Reflect.ownKeys` are excluded since they return only name strings, not values. Matched by identifier name only — a locally-shadowed `Object`/`Reflect` is an accepted gap.
const BULK_COPY_CALLS: ReadonlyArray<{ readonly object: string; readonly property: string }> = [
{ object: 'Object', property: 'assign' },
{ object: 'Object', property: 'values' },
{ object: 'Object', property: 'entries' },
{ object: 'Object', property: 'getOwnPropertyDescriptors' },
];
export interface AmbientGlobalAccessHandlers {
/** A watched name was reached where its absence throws. */
onNamedAccess(name: string): void;
}

function matchBulkCopyCall(
callee: Expression | Super,
scopeAnalysis: ModuleScopeAnalysis,
): { readonly object: string; readonly property: string } | undefined {
if (callee.type !== 'MemberExpression' || callee.object.type !== 'Identifier') {
return undefined;
}
// staticMemberName already handles both a plain identifier property (`.assign`) and a
// computed access resolving to a static string (`["assign"]`) — reusing it here (instead of
// requiring callee.property.type === 'Identifier') closes a gap where `Object["assign"](...)`
// evaded detection entirely.
const propertyName = staticMemberName(callee, scopeAnalysis);
if (!propertyName) {
return undefined;
}
const objectName = callee.object.name;
return BULK_COPY_CALLS.find(
(candidate) => candidate.object === objectName && candidate.property === propertyName,
);
function isPattern(node: BaseNode): boolean {
return node.type === 'ObjectPattern' || node.type === 'ArrayPattern';
}

export interface AmbientGlobalAccessHandlers {
/** A specific restricted/divergent name was reached by name. */
onNamedAccess(name: string): void;
/** Every ambient global was copied at once (a rest destructure, an object spread, or a bulk-copy call like `Object.assign`/`Object.values`), with no specific name to report. */
onBulkCopy(): void;
// A missing global reads as undefined, which throws once it's used as an object, function or class.
// A `?.` earlier in the same chain short-circuits the rest of it.
function throwsWhenUndefined(
node: BaseNode,
parentOf: (node: BaseNode) => EstreeNode | undefined,
): boolean {
let current = node;
let shortCircuited = false;
for (let parent = parentOf(current); parent; parent = parentOf(current)) {
if (
(parent.type === 'MemberExpression' && parent.object === current) ||
(parent.type === 'CallExpression' && parent.callee === current)
) {
shortCircuited ||= parent.optional;
if (!shortCircuited) {
return true;
}
} else if (parent.type === 'ChainExpression') {
shortCircuited = false;
} else {
return (
(parent.type === 'NewExpression' && parent.callee === current) ||
(parent.type === 'TaggedTemplateExpression' && parent.tag === current) ||
((parent.type === 'ClassDeclaration' || parent.type === 'ClassExpression') &&
parent.superClass === current) ||
(parent.type === 'BinaryExpression' &&
parent.right === current &&
(parent.operator === 'instanceof' || parent.operator === 'in')) ||
(parent.type === 'ForOfStatement' && parent.right === current) ||
(parent.type === 'SpreadElement' &&
parentOf(parent)?.type !== 'ObjectExpression') ||
(parent.type === 'VariableDeclarator' &&
parent.init === current &&
isPattern(parent.id)) ||
((parent.type === 'AssignmentExpression' || parent.type === 'AssignmentPattern') &&
parent.right === current &&
isPattern(parent.left))
);
}
current = parent;
}
return false;
}

// Walks every syntactic form that can reach the ambient global object and reports matching names; shared by `rejectRestrictedGlobals` and `warnAboutDivergentGlobals`.
// Reports watched names reached on the ambient global object, by name or through an alias, where their
// absence throws; bulk copies such as `{ ...globalThis }` aren't followed.
export function forEachAmbientGlobalAccess(
program: Program,
scopeAnalysis: ModuleScopeAnalysis,
names: ReadonlySet<string>,
handlers: AmbientGlobalAccessHandlers,
): void {
forOfAmbientAliases = new Set();
let parentMap: Map<BaseNode, EstreeNode> | undefined;
const parentOf = (node: BaseNode) => {
parentMap ??= parentsOf(program);
return parentMap.get(node);
};
const followedBindings = new Set<eslintScope.Variable>();

// A binding written only here holds the missing global, so it throws wherever the global would have.
const bindingThrows = (binding: Identifier): boolean => {
const variable = scopeAnalysis.referencesByIdentifier.get(binding)?.resolved;
if (!variable || followedBindings.has(variable)) {
return false;
}
followedBindings.add(variable);
const { references } = variable;
return (
references.every(
(reference) => !reference.isWrite() || reference.identifier === binding,
) &&
references.some((reference) => reference.isRead() && reachThrows(reference.identifier))
);
};

const reachThrows = (node: BaseNode): boolean => {
const parent = parentOf(node);
if (
parent?.type === 'VariableDeclarator' &&
parent.init === node &&
parent.id.type === 'Identifier'
) {
return bindingThrows(parent.id);
}
return throwsWhenUndefined(node, parentOf);
};

// An undeclared bare name throws a ReferenceError on any use but `typeof`.
for (const [identifier, reference] of scopeAnalysis.referencesByIdentifier) {
if (!names.has(identifier.name) || reference.resolved) {
continue;
}
handlers.onNamedAccess(identifier.name);
const parent = parentOf(identifier);
if (parent?.type !== 'UnaryExpression' || parent.operator !== 'typeof') {
handlers.onNamedAccess(identifier.name);
}
}

// Shared by both `const { x } = globalThis`-style destructures and a for-of loop's inline destructure below — the per-property checking is identical once the source is known to be the ambient global.
const checkDestructuredPattern = (pattern: ObjectPattern) => {
for (const property of pattern.properties) {
if (property.type === 'RestElement') {
handlers.onBulkCopy();
continue;
}
const name = staticPropertyName(property, scopeAnalysis);
if (name && names.has(name)) {
const { value } = property;
// A default (`{ EventSource = P }`) fills the gap; a nested pattern destructures undefined.
const isWatched = !!name && names.has(name);
if (
isWatched &&
(value.type === 'Identifier' ? bindingThrows(value) : isPattern(value))
) {
handlers.onNamedAccess(name);
}
// `const { globalThis: { fetch } } = globalThis` is a real self-reference, so recurse the same way resolvesToAmbientGlobal's MemberExpression case does — otherwise a name nested inside a `globalThis`/`global` key escapes detection.
const nestedPattern = unwrapDefaultValue(property.value);
const nestedPattern = unwrapDefaultValue(value);
if (
name &&
isAmbientGlobalReceiverName(name) &&
Expand All @@ -271,7 +334,7 @@ export function forEachAmbientGlobalAccess(
return;
}
const name = staticMemberName(node, scopeAnalysis);
if (name && names.has(name)) {
if (name && names.has(name) && reachThrows(node)) {
handlers.onNamedAccess(name);
}
},
Expand Down Expand Up @@ -339,34 +402,5 @@ export function forEachAmbientGlobalAccess(
forOfAmbientAliases.add(bindingIdentifierVariable);
}
},
// Mirrors a rest-destructure — `{ ...globalThis }` copies every ambient global just as `const { ...x } = globalThis` does.
ObjectExpression(node: ObjectExpression) {
for (const property of node.properties) {
if (
property.type === 'SpreadElement' &&
resolvesToAmbientGlobal(property.argument, scopeAnalysis)
) {
handlers.onBulkCopy();
}
}
},
// Built-in statics like `Object.assign({}, globalThis)` reify every property at once — the same threat as a rest-destructure or spread, but with no named-property access for the visitors above to see. Scoped to this known list, not a user-defined equivalent or an aliased `Object`/`Reflect`.
CallExpression(node) {
const match = matchBulkCopyCall(node.callee, scopeAnalysis);
if (!match) {
return;
}
// Object.assign's first argument is the write target, not a source — checking it would
// reject harmless calls like Object.assign(globalThis, { marker: true }).
const sourceArguments =
match.property === 'assign' ? node.arguments.slice(1) : node.arguments;
const hasAmbientArgument = sourceArguments.some(
(arg) =>
arg.type !== 'SpreadElement' && resolvesToAmbientGlobal(arg, scopeAnalysis),
);
if (hasAmbientArgument) {
handlers.onBulkCopy();
}
},
});
}
Loading
Loading