From b71e121f2b14b0526ce82a86771a89b7383d9527 Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Fri, 16 Oct 2026 19:49:17 -0400 Subject: [PATCH] feat(apps): switch the apps plugin's static checks to the Node backend runtime Backend functions run on the Node backend runtime, but the plugin's static checks still enforced the Deno runtime's rules, so they rejected code the Node backend runtime runs. The checks now apply the Node backend runtime's rules: Node built-in imports and Node's network globals, such as fetch and WebSocket, are allowed, and XMLHttpRequest, EventSource and Deno, which Node lacks, are rejected with a hint. They fail only where the missing global would throw: a bare use other than typeof, or using it as an object, function or class, including through a binding it was declared or destructured into. Feature checks, polyfill installs, delete and destructuring defaults pass. The Deno-only checks are removed: the Node built-in import check, the network-global check and the crypto/Intl warning. Local execution runs on the developer's own Node, so the dev server now warns once at start when its major version isn't the Node backend runtime's (24), and when it's before 22, which lacks the global WebSocket. Co-Authored-By: Claude Opus 5.5 --- packages/plugins/apps/README.md | 5 + .../ast-parsing/ambient-global-access.ts | 170 +++--- .../reject-globals-missing-from-node.test.ts | 543 ++++++++++++++++++ .../reject-globals-missing-from-node.ts | 39 ++ .../reject-node-builtin-imports.test.ts | 274 --------- .../reject-node-builtin-imports.ts | 73 --- .../reject-restricted-globals.test.ts | 358 ------------ .../ast-parsing/reject-restricted-globals.ts | 46 -- .../run-backend-static-checks.test.ts | 85 +++ .../ast-parsing/run-backend-static-checks.ts | 10 +- .../src/backend/ast-parsing/walk-ast.test.ts | 11 +- .../apps/src/backend/ast-parsing/walk-ast.ts | 18 +- .../warn-divergent-globals.test.ts | 239 -------- .../ast-parsing/warn-divergent-globals.ts | 68 --- packages/plugins/apps/src/constants.ts | 3 + packages/plugins/apps/src/index.test.ts | 41 +- .../apps/src/parameters/extract-parameters.ts | 18 +- .../vite/backend-static-checks-plugin.test.ts | 42 +- .../src/vite/backend-static-checks-plugin.ts | 3 +- .../src/vite/dev-server-module-graph.test.ts | 22 +- .../apps/src/vite/dev-server-module-graph.ts | 2 +- .../plugins/apps/src/vite/dev-server.test.ts | 12 +- packages/plugins/apps/src/vite/dev-server.ts | 2 +- packages/plugins/apps/src/vite/index.test.ts | 192 +++++-- packages/plugins/apps/src/vite/index.ts | 23 +- .../plugins/apps/src/vite/local-execution.ts | 2 +- ...WithBannedImport.ts => helperUsingDeno.ts} | 4 +- ...er.backend.ts => viaDenoHelper.backend.ts} | 4 +- 28 files changed, 1027 insertions(+), 1282 deletions(-) create mode 100644 packages/plugins/apps/src/backend/ast-parsing/reject-globals-missing-from-node.test.ts create mode 100644 packages/plugins/apps/src/backend/ast-parsing/reject-globals-missing-from-node.ts delete mode 100644 packages/plugins/apps/src/backend/ast-parsing/reject-node-builtin-imports.test.ts delete mode 100644 packages/plugins/apps/src/backend/ast-parsing/reject-node-builtin-imports.ts delete mode 100644 packages/plugins/apps/src/backend/ast-parsing/reject-restricted-globals.test.ts delete mode 100644 packages/plugins/apps/src/backend/ast-parsing/reject-restricted-globals.ts create mode 100644 packages/plugins/apps/src/backend/ast-parsing/run-backend-static-checks.test.ts delete mode 100644 packages/plugins/apps/src/backend/ast-parsing/warn-divergent-globals.test.ts delete mode 100644 packages/plugins/apps/src/backend/ast-parsing/warn-divergent-globals.ts rename packages/tests/src/_jest/fixtures/apps_backend_project/{helperWithBannedImport.ts => helperUsingDeno.ts} (78%) rename packages/tests/src/_jest/fixtures/apps_backend_project/{viaBannedHelper.backend.ts => viaDenoHelper.backend.ts} (72%) diff --git a/packages/plugins/apps/README.md b/packages/plugins/apps/README.md index 2e442ebe8..e80de40f0 100644 --- a/packages/plugins/apps/README.md +++ b/packages/plugins/apps/README.md @@ -12,6 +12,7 @@ A Vite plugin that builds a deployable Datadog Apps package. Publishing is owned - [Development server authentication](#development-server-authentication) - [Development server requests](#development-server-requests) - [Local execution](#local-execution) +- [Backend function runtime](#backend-function-runtime) - [Package output](#package-output) - [apps.enable](#appsenable) - [apps.include](#appsinclude) @@ -74,6 +75,10 @@ On Vite 6.0.4 or later, `/__dd/executeAction` runs each backend function call th 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. +## 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 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. diff --git a/packages/plugins/apps/src/backend/ast-parsing/ambient-global-access.ts b/packages/plugins/apps/src/backend/ast-parsing/ambient-global-access.ts index eada6f133..85f1d8ab7 100644 --- a/packages/plugins/apps/src/backend/ast-parsing/ambient-global-access.ts +++ b/packages/plugins/apps/src/backend/ast-parsing/ambient-global-access.ts @@ -4,9 +4,11 @@ import type * as eslintScope from 'eslint-scope'; import type { + BaseNode, + Node as EstreeNode, Expression, + Identifier, MemberExpression, - ObjectExpression, ObjectPattern, PrivateIdentifier, Program, @@ -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'; @@ -183,43 +185,61 @@ 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.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, @@ -227,27 +247,70 @@ export function forEachAmbientGlobalAccess( handlers: AmbientGlobalAccessHandlers, ): void { forOfAmbientAliases = new Set(); + let parentMap: Map | undefined; + const parentOf = (node: BaseNode) => { + parentMap ??= parentsOf(program); + return parentMap.get(node); + }; + const followedBindings = new Set(); + // 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) && @@ -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); } }, @@ -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(); - } - }, }); } diff --git a/packages/plugins/apps/src/backend/ast-parsing/reject-globals-missing-from-node.test.ts b/packages/plugins/apps/src/backend/ast-parsing/reject-globals-missing-from-node.test.ts new file mode 100644 index 000000000..6398354d7 --- /dev/null +++ b/packages/plugins/apps/src/backend/ast-parsing/reject-globals-missing-from-node.test.ts @@ -0,0 +1,543 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import { analyzeModuleScope } from '@dd/apps-plugin/backend/ast-parsing/module-scope'; +import { + NODE_REPLACEMENT_HINTS, + rejectGlobalsMissingFromNode, +} from '@dd/apps-plugin/backend/ast-parsing/reject-globals-missing-from-node'; +import { ensureProgram } from '@dd/apps-plugin/backend/ast-parsing/type-guards'; +import { NODE_BACKEND_RUNTIME } from '@dd/apps-plugin/constants'; +import { parseAst } from 'rollup/parseAst'; + +describe('Backend Functions - rejectGlobalsMissingFromNode', () => { + const filePath = '/project/src/math.backend.ts'; + + const checkMissingGlobals = (code: string) => { + const ast = parseAst(code); + const program = ensureProgram(ast, filePath); + const scopeAnalysis = analyzeModuleScope(program); + rejectGlobalsMissingFromNode(ast, filePath, scopeAnalysis); + }; + + const rejectedCases = [ + { + description: 'reject a bare XMLHttpRequest reference', + code: 'export async function run() { return new XMLHttpRequest(); }', + }, + { + description: 'reject XMLHttpRequest referenced without calling it', + code: 'export function run() { const f = XMLHttpRequest; return f; }', + }, + { + description: 'reject globalThis.XMLHttpRequest(...)', + code: 'export async function run() { return globalThis.XMLHttpRequest(); }', + }, + { + description: 'reject globalThis["XMLHttpRequest"](...)', + code: 'export async function run() { return globalThis["XMLHttpRequest"](); }', + }, + { + description: 'reject destructuring XMLHttpRequest off of globalThis', + code: 'export function run() { const { XMLHttpRequest } = globalThis; return new XMLHttpRequest(); }', + }, + { + description: + 'reject destructuring XMLHttpRequest off of globalThis under a local alias', + code: 'export function run() { const { XMLHttpRequest: Xhr } = globalThis; return new Xhr(); }', + }, + { + description: + 'reject destructuring XMLHttpRequest off of globalThis via a computed string-literal key', + code: "export function run() { const { ['XMLHttpRequest']: request } = globalThis; return new request(); }", + }, + { + description: 'reject global.XMLHttpRequest(...) via the Node global alias', + code: 'export async function run() { return global.XMLHttpRequest(); }', + }, + { + description: 'reject global["XMLHttpRequest"](...) via the Node global alias', + code: 'export async function run() { return global["XMLHttpRequest"](); }', + }, + { + description: 'reject destructuring XMLHttpRequest off of global, the Node global alias', + code: 'export function run() { const { XMLHttpRequest } = global; return new XMLHttpRequest(); }', + }, + { + description: 'reject a destructuring *assignment* (not declaration) off of globalThis', + code: 'export function run() { let request; ({ XMLHttpRequest: request } = globalThis); return new request(); }', + }, + { + description: 'reject a destructuring *assignment* off of global, the Node global alias', + code: 'export function run() { let request; ({ XMLHttpRequest: request } = global); return new request(); }', + }, + { + description: + 'reject globalThis[`XMLHttpRequest`](...) via a no-substitution template-literal key', + code: 'export async function run() { return globalThis[`XMLHttpRequest`](); }', + }, + { + description: + 'reject destructuring XMLHttpRequest off of globalThis via a template-literal key', + code: 'export function run() { const { [`XMLHttpRequest`]: request } = globalThis; return new request(); }', + }, + { + description: + 'reject destructuring XMLHttpRequest off of globalThis via a quoted (non-computed) string-literal key', + code: "export function run() { const { 'XMLHttpRequest': request } = globalThis; return new request(); }", + }, + { + description: + 'reject destructuring XMLHttpRequest off of globalThis via a function parameter default value', + code: 'export function run({ XMLHttpRequest } = globalThis) { return new XMLHttpRequest(); }', + }, + { + description: 'reject XMLHttpRequest reached through a single const alias of globalThis', + code: 'export function run() { const g = globalThis; return g.XMLHttpRequest(); }', + }, + { + description: 'reject a destructure off of a single const alias of globalThis', + code: 'export function run() { const g = globalThis; const { XMLHttpRequest } = g; return new XMLHttpRequest(); }', + }, + { + description: + 'reject XMLHttpRequest reached through a chain of const aliases of globalThis', + code: 'export function run() { const g1 = globalThis; const g2 = g1; return g2.XMLHttpRequest(); }', + }, + { + description: + 'reject globalThis.globalThis.XMLHttpRequest(...), a self-referential member-expression chain', + code: 'export function run() { return globalThis.globalThis.XMLHttpRequest(); }', + }, + { + description: + "reject global.global.XMLHttpRequest(...), the Node alias's own self-referential chain", + code: 'export function run() { return global.global.XMLHttpRequest(); }', + }, + { + description: + 'reject XMLHttpRequest reached through a nested destructure of the self-referential globalThis.globalThis', + code: 'export function run() { const { globalThis: { XMLHttpRequest } } = globalThis; return new XMLHttpRequest(); }', + }, + { + description: + 'reject XMLHttpRequest reached through a const alias bound by a self-referential globalThis destructure key', + code: 'export function run() { const { globalThis: g } = globalThis; return g.XMLHttpRequest(); }', + }, + { + description: + "reject XMLHttpRequest reached through a const alias bound by a self-referential global destructure key (Node's own alias)", + code: 'export function run() { const { global: g } = global; return g.XMLHttpRequest(); }', + }, + { + description: + 'reject XMLHttpRequest reached through a const alias bound by a self-referential globalThis destructure key that also carries a default value', + code: 'export function run() { const { globalThis: g = {} } = globalThis; return g.XMLHttpRequest(); }', + }, + { + description: + 'reject XMLHttpRequest reached through a const alias bound by two levels of self-referential globalThis destructure keys', + code: 'export function run() { const { globalThis: { globalThis: g } } = globalThis; return g.XMLHttpRequest(); }', + }, + { + description: + 'reject XMLHttpRequest reached through a nested destructure of the self-referential globalThis.globalThis when the outer key carries a default value', + code: 'export function run() { const { globalThis: { XMLHttpRequest } = {} } = globalThis; return new XMLHttpRequest(); }', + }, + { + description: + 'reject XMLHttpRequest reached through a for-of destructure whose inline iterable array literal contains globalThis', + code: 'export function run() { for (const { XMLHttpRequest } of [globalThis]) { return new XMLHttpRequest(); } }', + }, + { + description: + 'reject XMLHttpRequest reached through a for-of destructure whose inline iterable array literal contains a const alias of globalThis', + code: 'export function run() { const g = globalThis; for (const { XMLHttpRequest } of [g]) { return new XMLHttpRequest(); } }', + }, + { + description: + 'reject XMLHttpRequest reached through a plain (non-destructured) for-of loop variable bound to globalThis via an inline array literal', + code: 'export function run() { for (const g of [globalThis]) { return g.XMLHttpRequest(); } }', + }, + { + description: + 'reject XMLHttpRequest reached through a for-of loop using an assignment target (no const/let) destructuring globalThis via an inline array literal', + code: 'export function run() { let XMLHttpRequest; for ({ XMLHttpRequest } of [globalThis]) { return new XMLHttpRequest(); } }', + }, + { + description: + 'reject globalThis[f](...) where f is a const alias of a string-literal property name', + code: "export function run() { const f = 'XMLHttpRequest'; return globalThis[f](); }", + }, + { + description: + 'reject destructuring XMLHttpRequest off of globalThis via a computed key that is a const alias of a string-literal property name', + code: "export function run() { const key = 'XMLHttpRequest'; const { [key]: r } = globalThis; return new r(); }", + }, + { + description: + 'reject XMLHttpRequest reached through a chain of const aliases resolving a computed property name', + code: "export function run() { const f1 = 'XMLHttpRequest'; const f2 = f1; return globalThis[f2](); }", + }, + { + description: 'reject new EventSource(...)', + code: 'export function run() { return new EventSource("/events"); }', + }, + { + description: 'reject globalThis.EventSource', + code: 'export function run() { return new globalThis.EventSource("/events"); }', + }, + { + description: 'reject Deno.env.get(...)', + code: 'export function run() { return Deno.env.get("TOKEN"); }', + }, + { + description: 'reject globalThis.Deno', + code: 'export function run() { return globalThis.Deno.version; }', + }, + { + description: 'reject destructuring off of Deno', + code: 'export function run() { const { env } = Deno; return env.get("TOKEN"); }', + }, + { + description: 'reject a bare Deno as an if test, which throws a ReferenceError on Node', + code: 'export function run() { if (Deno) { return 1; } return 2; }', + }, + { + description: 'reject an assignment of a bare Deno inside a test', + code: 'let d; export function run() { return (d = Deno) == null; }', + }, + { + description: + 'reject an optional call on a bare Deno, which still throws a ReferenceError', + code: 'export function run() { return Deno?.cwd(); }', + }, + { + description: 'reject typeof of a Deno member, which reads Deno first', + code: 'export function run() { return typeof Deno.env; }', + }, + { + description: 'reject using globalThis.Deno after checking for it', + code: 'export function run() { if (globalThis.Deno) { return globalThis.Deno.cwd(); } return 2; }', + }, + { + description: 'reject constructing globalThis.EventSource', + code: 'export function run(url) { return new globalThis.EventSource(url); }', + }, + { + description: 'reject assigning a member of globalThis.Deno, which reads Deno first', + code: 'export function run(f) { globalThis.Deno.cwd = f; }', + }, + { + description: 'reject reading a global after installing a polyfill on it', + code: 'export function run(P, url) { globalThis.EventSource = P; return new globalThis.EventSource(url); }', + }, + { + description: 'reject dereferencing a parenthesized optional chain on a missing global', + code: 'export function run() { return (globalThis.Deno?.env).get("X"); }', + }, + { + description: 'reject dereferencing an optional member of globalThis itself', + code: 'export function run() { return globalThis?.Deno.cwd(); }', + }, + { + description: 'reject dereferencing a const alias of a missing global', + code: 'export function run() { const d = globalThis.Deno; return d.env.get("X"); }', + }, + { + description: 'reject destructuring a missing global', + code: 'export function run() { const { env } = globalThis.Deno; return env; }', + }, + { + description: 'reject a nested destructure of a missing global', + code: 'export function run() { const { Deno: { env } } = globalThis; return env; }', + }, + { + description: 'reject extending a missing global', + code: 'export class SSE extends globalThis.EventSource {}', + }, + { + description: 'reject iterating or spreading a missing global', + code: 'export function run() { for (const x of globalThis.Deno) {} return [...globalThis.Deno]; }', + }, + { + description: 'reject spreading a missing global into a call', + code: 'export function run(f) { return f(...globalThis.Deno); }', + }, + { + description: 'reject a missing global on the right of instanceof', + code: 'export function run(e) { return e instanceof globalThis.XMLHttpRequest; }', + }, + { + description: 'reject a missing global on the right of in', + code: 'export function run() { return "k" in globalThis.Deno; }', + }, + { + description: 'reject a missing global used as a template tag', + code: 'export function run() { return globalThis.Deno`x`; }', + }, + ]; + + test.each(rejectedCases)('Should $description', ({ code }) => { + expect(() => checkMissingGlobals(code)).toThrow( + `is not available in the ${NODE_BACKEND_RUNTIME}`, + ); + expect(() => checkMissingGlobals(code)).toThrow(filePath); + }); + + test('Should suggest fetch or importing a polyfill by name', () => { + expect(() => + checkMissingGlobals('export function run() { return new EventSource("/events"); }'), + ).toThrow(NODE_REPLACEMENT_HINTS.EventSource); + }); + + test('Should suggest a Node API for Deno, not fetch or a polyfill', () => { + const run = () => checkMissingGlobals('export function run() { return Deno.cwd(); }'); + + expect(run).toThrow(NODE_REPLACEMENT_HINTS.Deno); + expect(run).toThrow( + expect.objectContaining({ message: expect.not.stringContaining('fetch') }), + ); + expect(run).toThrow( + expect.objectContaining({ message: expect.not.stringContaining('polyfill') }), + ); + }); + + const allowedCases = [ + { + description: 'allow calling an imported action-catalog function', + code: "import { request } from '@datadog/action-catalog/http/http';\nexport async function run() { return request({ inputs: {} }); }", + }, + { + description: + 'allow a locally-declared function that happens to be named XMLHttpRequest', + code: 'function XMLHttpRequest() { return "local"; }\nexport function run() { return new XMLHttpRequest(); }', + }, + { + description: 'allow a parameter named XMLHttpRequest shadowing the global', + code: 'export function run(XMLHttpRequest) { return new XMLHttpRequest(); }', + }, + { + description: 'allow unrelated code with no restricted-global references', + code: 'export function run(a, b) { return a + b; }', + }, + { + description: 'allow accessing an unrestricted globalThis property', + code: 'export function run() { return globalThis.console; }', + }, + { + description: 'allow destructuring an unrestricted property off of globalThis', + code: 'export function run() { const { console } = globalThis; return console; }', + }, + { + description: + 'allow a nested destructure named XMLHttpRequest whose outer key is not itself a globalThis/global self-reference, since the outer key is not provably an alias back to the ambient global', + code: 'export function run() { const { myApi: { XMLHttpRequest } } = globalThis; return XMLHttpRequest; }', + }, + { + description: + 'allow accessing .XMLHttpRequest on a parameter named globalThis shadowing the ambient global', + code: 'export function run(globalThis) { return globalThis.XMLHttpRequest(); }', + }, + { + description: + 'allow destructuring XMLHttpRequest off of a parameter named globalThis shadowing the ambient global', + code: 'export function run(globalThis) { const { XMLHttpRequest } = globalThis; return new XMLHttpRequest(); }', + }, + { + description: + 'allow accessing .XMLHttpRequest on a parameter named global shadowing the Node alias', + code: 'export function run(global) { return global.XMLHttpRequest(); }', + }, + { + description: + 'allow a destructuring assignment off of a parameter named globalThis shadowing the ambient global', + code: 'export function run(globalThis) { let request; ({ XMLHttpRequest: request } = globalThis); return request(); }', + }, + { + description: + 'allow a "let" alias of globalThis, since it could be reassigned to something else', + code: 'export function run() { let g = globalThis; return g.XMLHttpRequest(); }', + }, + { + description: + 'allow a function-parameter default value aliasing globalThis, same opacity as a "let" alias', + code: 'export function run(g = globalThis) { return g.XMLHttpRequest(); }', + }, + { + description: + 'allow a "let"-declared for-of loop variable bound to globalThis via an inline array literal, since it could be reassigned to something else before use, same opacity as a "let" alias', + code: 'export function run() { for (let g of [globalThis]) { g = safeObj; return g.XMLHttpRequest(); } }', + }, + { + description: 'allow a computed member access with a template literal that interpolates', + // eslint-disable-next-line no-template-curly-in-string -- source text for the AST under test, not a real interpolation + code: 'export function run(name) { return globalThis[`${name}`](); }', + }, + { + description: + 'allow accessing a property on a destructured binding of globalThis, since the binding is a specific property value, not the ambient global object itself', + code: 'export function run() { const { console: g } = globalThis; return g.XMLHttpRequest; }', + }, + { + description: + 'allow a computed property access whose key is a "let" alias of a string literal, same opacity as a "let" alias of globalThis itself', + code: "export function run() { let f = 'XMLHttpRequest'; return globalThis[f](); }", + }, + { + description: + 'allow a computed property access whose key is a mutually self-referential const cycle, since the key cannot be statically resolved', + code: 'export function run() { const a = b; const b = a; return globalThis[a](); }', + }, + { + description: 'allow fetch, which Node provides', + code: 'export async function run() { return fetch("https://example.com"); }', + }, + { + description: 'allow WebSocket, which Node provides', + code: 'export function run() { return new WebSocket("wss://example.com"); }', + }, + { + description: 'allow an imported EventSource polyfill, which binds the name locally', + code: "import { EventSource } from 'eventsource';\nexport function run() { return new EventSource('/events'); }", + }, + { + description: 'allow a local binding named Deno', + code: 'const Deno = { cwd: () => "/" };\nexport function run() { return Deno.cwd(); }', + }, + { + description: + 'allow copying every property of globalThis, which the check does not follow', + code: 'export function run() { const { ...all } = globalThis; return all; }', + }, + { + description: 'allow spreading globalThis into a new object', + code: 'export function run() { return { ...globalThis }; }', + }, + { + description: 'allow Object.assign and Object.values over globalThis', + code: 'export function run() { return [Object.assign({}, globalThis), Object.values(globalThis)]; }', + }, + { + description: 'allow typeof Deno feature detection', + code: "export function run() { return typeof Deno !== 'undefined' ? 'deno' : 'node'; }", + }, + { + description: 'allow typeof XMLHttpRequest and typeof EventSource feature detection', + code: 'export function run() { return [typeof XMLHttpRequest, typeof EventSource]; }', + }, + { + description: 'allow typeof globalThis.Deno feature detection', + code: "export function run() { return typeof globalThis.Deno === 'undefined'; }", + }, + { + description: 'allow globalThis.Deno as an if test', + code: 'export function run() { if (globalThis.Deno) { return 1; } return 2; }', + }, + { + description: 'allow globalThis.EventSource as a conditional test', + code: 'export function run() { return globalThis.EventSource ? 1 : 2; }', + }, + { + description: 'allow negating globalThis.XMLHttpRequest', + code: 'export function run() { return !globalThis.XMLHttpRequest; }', + }, + { + description: 'allow globalThis.Deno as the left side of a logical expression', + code: 'export function run(fallback) { return globalThis.Deno ?? fallback; }', + }, + { + description: 'allow comparing globalThis.Deno with undefined', + code: 'export function run() { return globalThis.Deno === undefined; }', + }, + { + description: 'allow optional-chained feature checks on globalThis', + code: 'export function run() { return [typeof globalThis?.Deno, !globalThis?.XMLHttpRequest, globalThis?.EventSource ? 1 : 2]; }', + }, + { + description: 'allow an optional-chained globalThis member as an if test', + code: 'export function run() { if (globalThis?.EventSource) { return 1; } return 2; }', + }, + { + description: 'allow globalThis members on either side of && or || in a test', + code: 'export function run(a) { if (a && globalThis.EventSource) { return 1; } return globalThis.WebSocket || globalThis.EventSource ? 2 : 3; }', + }, + { + description: + 'allow an optional member access on a missing global, which yields undefined', + code: 'export function run() { return [globalThis.Deno?.version?.deno, typeof globalThis.Deno?.version, globalThis.Deno?.env.get("X")]; }', + }, + { + description: 'allow a switch on, or a Boolean() of, a globalThis member', + code: 'export function run() { switch (globalThis.EventSource) { case undefined: return Boolean(globalThis.XMLHttpRequest); default: return 1; } }', + }, + { + description: "allow an 'in' check on globalThis", + code: "export function run() { return 'Deno' in globalThis; }", + }, + { + description: + 'allow ?. and ?? feature checks after a lower esbuild target rewrites them', + code: 'var _a, _b, _c; export function run() { return [(_a = globalThis.Deno) == null ? void 0 : _a.env, (_b = globalThis.EventSource) != null ? _b : Object, void 0 === (_c = globalThis.Deno)]; }', + }, + { + description: 'allow installing a polyfill on globalThis after checking for it', + code: "import { EventSource as P } from 'eventsource'; export function run() { if (!globalThis.EventSource) { globalThis.EventSource = P; } }", + }, + { + description: 'allow assigning XMLHttpRequest on globalThis', + code: 'export function run(P) { globalThis.XMLHttpRequest = P; }', + }, + { + description: 'allow installing a polyfill with ??= or ||=', + code: 'export function run(P) { globalThis.EventSource ??= P; global.XMLHttpRequest ||= P; }', + }, + { + description: 'allow reading a missing global into a binding without using it', + code: 'export function run() { const f = globalThis.XMLHttpRequest; return f; }', + }, + { + description: 'allow testing a const alias or destructured binding of a missing global', + code: 'export function run() { const d = globalThis.Deno; const { EventSource } = globalThis; return [d ? 1 : 2, !EventSource]; }', + }, + { + description: 'allow a destructuring default that fills a missing global', + code: 'export function run(P, url) { const { EventSource = P } = globalThis; return new EventSource(url); }', + }, + { + description: + 'allow dereferencing a reassigned binding, which may no longer hold the global', + code: 'export function run(shim) { let d = globalThis.Deno; d = shim; return d.cwd(); }', + }, + { + description: 'allow spreading a missing global into an object literal', + code: 'export function run() { const { Deno } = globalThis; return [{ ...globalThis.Deno?.env }, { ...Deno }]; }', + }, + { + description: 'allow deleting a missing global', + code: 'export function run() { delete globalThis.Deno; }', + }, + { + description: 'allow a missing global as a switch case', + code: 'export function run(x) { switch (x) { case globalThis.EventSource: return 1; default: return 2; } }', + }, + { + description: 'allow installing a polyfill with &&=', + code: 'export function run(P) { globalThis.EventSource &&= P; }', + }, + { + description: 'allow globalThis?.X after a lower esbuild target rewrites it', + code: 'export function run() { return globalThis == null ? void 0 : globalThis.Deno; }', + }, + { + description: + 'allow, as an accepted gap, a missing global assigned after its declaration', + code: 'let d; export function run() { d = globalThis.Deno; return d.cwd(); }', + }, + ]; + + test.each(allowedCases)('Should $description', ({ code }) => { + expect(() => checkMissingGlobals(code)).not.toThrow(); + }); +}); diff --git a/packages/plugins/apps/src/backend/ast-parsing/reject-globals-missing-from-node.ts b/packages/plugins/apps/src/backend/ast-parsing/reject-globals-missing-from-node.ts new file mode 100644 index 000000000..1e98e0243 --- /dev/null +++ b/packages/plugins/apps/src/backend/ast-parsing/reject-globals-missing-from-node.ts @@ -0,0 +1,39 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import type { BaseNode } from 'estree'; + +import { NODE_BACKEND_RUNTIME } from '../../constants'; + +import { forEachAmbientGlobalAccess } from './ambient-global-access'; +import type { ModuleScopeAnalysis } from './module-scope'; +import { ensureProgram } from './type-guards'; + +const POLYFILL_HINT = "Use fetch, or import a polyfill's export by name instead of the global"; + +// Node has no XMLHttpRequest or Deno, and the Node backend runtime doesn't pass --experimental-eventsource. +export const NODE_REPLACEMENT_HINTS: Readonly> = { + XMLHttpRequest: POLYFILL_HINT, + EventSource: POLYFILL_HINT, + Deno: 'Use a Node API or a node: built-in import instead', +}; +const NAMES_MISSING_FROM_NODE = Object.keys(NODE_REPLACEMENT_HINTS); +const GLOBALS_MISSING_FROM_NODE: ReadonlySet = new Set(NAMES_MISSING_FROM_NODE); + +export function rejectGlobalsMissingFromNode( + ast: BaseNode, + filePath: string, + scopeAnalysis: ModuleScopeAnalysis, +): void { + const program = ensureProgram(ast, filePath); + + forEachAmbientGlobalAccess(program, scopeAnalysis, GLOBALS_MISSING_FROM_NODE, { + onNamedAccess(name) { + throw new Error( + `"${name}" is not available in the ${NODE_BACKEND_RUNTIME}. ` + + `${NODE_REPLACEMENT_HINTS[name]}: ${filePath}`, + ); + }, + }); +} diff --git a/packages/plugins/apps/src/backend/ast-parsing/reject-node-builtin-imports.test.ts b/packages/plugins/apps/src/backend/ast-parsing/reject-node-builtin-imports.test.ts deleted file mode 100644 index abe1fa012..000000000 --- a/packages/plugins/apps/src/backend/ast-parsing/reject-node-builtin-imports.test.ts +++ /dev/null @@ -1,274 +0,0 @@ -// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. -// This product includes software developed at Datadog (https://www.datadoghq.com/). -// Copyright 2019-Present Datadog, Inc. - -import { rejectNodeBuiltinImports } from '@dd/apps-plugin/backend/ast-parsing/reject-node-builtin-imports'; -import type { TypeScriptImportExportMetadata } from '@dd/apps-plugin/backend/ast-parsing/type-guards'; -import type { - ExportNamedDeclaration, - ExportSpecifier, - ImportDeclaration, - ImportSpecifier, - Program, -} from 'estree'; -import { parseAst } from 'rollup/parseAst'; - -function program(body: Program['body']): Program { - return { type: 'Program', sourceType: 'module', body }; -} - -// `overrides` also accepts TypeScript's `importKind`/`exportKind` (not declared by `estree`), so type-only imports can be built without a cast. -function importDecl( - source: string, - overrides: Partial & TypeScriptImportExportMetadata = {}, -): ImportDeclaration { - return { - type: 'ImportDeclaration', - specifiers: [ - { - type: 'ImportDefaultSpecifier', - local: { type: 'Identifier', name: 'x' }, - }, - ], - source: { type: 'Literal', value: source }, - attributes: [], - ...overrides, - }; -} - -// Built by hand rather than parsed: `rollup/parseAst` can't parse TypeScript's `export type { ... }` syntax used by the type-only re-export test below. -function exportNamedDecl( - source: string, - overrides: Partial & TypeScriptImportExportMetadata = {}, -): ExportNamedDeclaration { - return { - type: 'ExportNamedDeclaration', - declaration: null, - specifiers: [ - { - type: 'ExportSpecifier', - local: { type: 'Identifier', name: 'readFile' }, - exported: { type: 'Identifier', name: 'readFile' }, - }, - ], - source: { type: 'Literal', value: source }, - attributes: [], - ...overrides, - }; -} - -// Same technique as `importDecl` above, for TypeScript's inline `importKind`. -function importSpecifier( - name: string, - overrides: Partial & TypeScriptImportExportMetadata = {}, -): ImportSpecifier & TypeScriptImportExportMetadata { - return { - type: 'ImportSpecifier', - imported: { type: 'Identifier', name }, - local: { type: 'Identifier', name }, - ...overrides, - }; -} - -// Same technique as `exportNamedDecl` above, for TypeScript's inline `exportKind`. -function exportSpecifier( - name: string, - overrides: Partial & TypeScriptImportExportMetadata = {}, -): ExportSpecifier & TypeScriptImportExportMetadata { - return { - type: 'ExportSpecifier', - local: { type: 'Identifier', name }, - exported: { type: 'Identifier', name }, - ...overrides, - }; -} - -describe('Backend Functions - rejectNodeBuiltinImports', () => { - const filePath = '/project/src/math.backend.ts'; - - const allowedCases = [ - { - description: 'allow importing a relative module', - source: './helpers', - }, - { - description: 'allow importing a scoped npm package', - source: '@datadog/action-catalog', - }, - { - description: 'allow importing an ordinary npm package', - source: 'lodash', - }, - ]; - - test.each(allowedCases)('Should $description', ({ source }) => { - const importDeclaration = importDecl(source); - const ast = program([importDeclaration]); - expect(() => rejectNodeBuiltinImports(ast, filePath)).not.toThrow(); - }); - - const rejectedCases = [ - { - description: 'reject importing "node:fs" via the node: prefix', - source: 'node:fs', - }, - { - description: 'reject importing the bare built-in "fs"', - source: 'fs', - }, - { - description: 'reject importing "child_process"', - source: 'child_process', - }, - { - description: 'reject importing "node:child_process"', - source: 'node:child_process', - }, - { - description: 'reject importing "net"', - source: 'net', - }, - { - description: 'reject importing a built-in subpath "fs/promises"', - source: 'fs/promises', - }, - ]; - - test.each(rejectedCases)('Should $description', ({ source }) => { - const importDeclaration = importDecl(source); - const ast = program([importDeclaration]); - expect(() => rejectNodeBuiltinImports(ast, filePath)).toThrow( - `Importing Node built-in module "${source}" is not supported in backend function code`, - ); - expect(() => rejectNodeBuiltinImports(ast, filePath)).toThrow(filePath); - }); - - test('Should allow a type-only import of a Node built-in', () => { - // import type { Stats } from 'fs'; - const importDeclaration = importDecl('fs', { importKind: 'type' }); - const ast = program([importDeclaration]); - expect(() => rejectNodeBuiltinImports(ast, filePath)).not.toThrow(); - }); - - test('Should allow an inline type-only named specifier of a Node built-in', () => { - // import { type Stats } from 'fs'; - const importDeclaration = importDecl('fs', { - specifiers: [importSpecifier('Stats', { importKind: 'type' })], - }); - const ast = program([importDeclaration]); - expect(() => rejectNodeBuiltinImports(ast, filePath)).not.toThrow(); - }); - - test('Should reject when only one of several named specifiers is inline type-only', () => { - // import { type Stats, readFileSync } from 'fs'; - const importDeclaration = importDecl('fs', { - specifiers: [ - importSpecifier('Stats', { importKind: 'type' }), - importSpecifier('readFileSync'), - ], - }); - const ast = program([importDeclaration]); - expect(() => rejectNodeBuiltinImports(ast, filePath)).toThrow( - `Importing Node built-in module "fs" is not supported in backend function code`, - ); - }); - - test('Should ignore non-import statements', () => { - const ast = program([ - { - type: 'ExpressionStatement', - expression: { type: 'Literal', value: 1 }, - }, - ]); - expect(() => rejectNodeBuiltinImports(ast, filePath)).not.toThrow(); - }); - - const rejectedReExportCases = [ - { - description: 'reject re-exporting "readFile" from "node:fs" under a new name', - code: "export { readFile as handler } from 'node:fs';", - source: 'node:fs', - }, - { - description: 'reject re-exporting the bare built-in "fs" without renaming', - code: "export { readFile } from 'fs';", - source: 'fs', - }, - ]; - - test.each(rejectedReExportCases)('Should $description', ({ code, source }) => { - const ast = parseAst(code); - expect(() => rejectNodeBuiltinImports(ast, filePath)).toThrow( - `Importing Node built-in module "${source}" is not supported in backend function code`, - ); - expect(() => rejectNodeBuiltinImports(ast, filePath)).toThrow(filePath); - }); - - test('Should allow re-exporting from a relative module', () => { - const ast = parseAst("export { helper } from './helpers';"); - expect(() => rejectNodeBuiltinImports(ast, filePath)).not.toThrow(); - }); - - test('Should allow a type-only re-export of a Node built-in', () => { - // export type { Stats } from 'fs'; - const exportDeclaration = exportNamedDecl('fs', { exportKind: 'type' }); - const ast = program([exportDeclaration]); - expect(() => rejectNodeBuiltinImports(ast, filePath)).not.toThrow(); - }); - - test('Should allow an inline type-only named re-export specifier of a Node built-in', () => { - // export { type Stats } from 'fs'; - const exportDeclaration = exportNamedDecl('fs', { - specifiers: [exportSpecifier('Stats', { exportKind: 'type' })], - }); - const ast = program([exportDeclaration]); - expect(() => rejectNodeBuiltinImports(ast, filePath)).not.toThrow(); - }); - - test('Should reject "export * from" a Node built-in', () => { - const ast = parseAst("export * from 'node:fs';"); - expect(() => rejectNodeBuiltinImports(ast, filePath)).toThrow( - 'Importing Node built-in module "node:fs" is not supported in backend function code', - ); - }); - - test('Should reject "export * as ns from" a Node built-in', () => { - const ast = parseAst("export * as fs from 'node:fs';"); - expect(() => rejectNodeBuiltinImports(ast, filePath)).toThrow( - 'Importing Node built-in module "node:fs" is not supported in backend function code', - ); - }); - - test('Should allow "export * from" a relative module', () => { - const ast = parseAst("export * from './helpers';"); - expect(() => rejectNodeBuiltinImports(ast, filePath)).not.toThrow(); - }); - - test('Should allow a named export with no source', () => { - const ast = parseAst('const value = 1;\nexport { value };'); - expect(() => rejectNodeBuiltinImports(ast, filePath)).not.toThrow(); - }); - - test('Should reject a literal dynamic import() of a Node built-in', () => { - const ast = parseAst( - "export async function run() { const fs = await import('node:fs'); return fs.readFileSync('/etc/passwd'); }", - ); - expect(() => rejectNodeBuiltinImports(ast, filePath)).toThrow( - 'Importing Node built-in module "node:fs" is not supported in backend function code', - ); - }); - - test('Should allow a literal dynamic import() of a relative module', () => { - const ast = parseAst( - "export async function run() { return (await import('./helpers')).helper(); }", - ); - expect(() => rejectNodeBuiltinImports(ast, filePath)).not.toThrow(); - }); - - test('Should allow a non-literal dynamic import(), which cannot be statically checked', () => { - const ast = parseAst( - 'export async function run(name) { return (await import(name)).default; }', - ); - expect(() => rejectNodeBuiltinImports(ast, filePath)).not.toThrow(); - }); -}); diff --git a/packages/plugins/apps/src/backend/ast-parsing/reject-node-builtin-imports.ts b/packages/plugins/apps/src/backend/ast-parsing/reject-node-builtin-imports.ts deleted file mode 100644 index 8ad845cb9..000000000 --- a/packages/plugins/apps/src/backend/ast-parsing/reject-node-builtin-imports.ts +++ /dev/null @@ -1,73 +0,0 @@ -// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. -// This product includes software developed at Datadog (https://www.datadoghq.com/). -// Copyright 2019-Present Datadog, Inc. - -import type { BaseNode, Expression } from 'estree'; -import { builtinModules } from 'node:module'; - -import { ensureProgram, isTypeOnly, staticStringValue } from './type-guards'; -import { walkAst } from './walk-ast'; - -const RESTRICTED_MODULES = new Set(builtinModules); - -function isRestrictedSource(source: string): boolean { - return source.startsWith('node:') || RESTRICTED_MODULES.has(source); -} - -// A declaration with no specifiers still evaluates the module for its side effects, so only a specifier list that's entirely type-only is safe to skip. -function hasRuntimeSpecifier(specifiers: readonly BaseNode[]): boolean { - return specifiers.length === 0 || specifiers.some((specifier) => !isTypeOnly(specifier)); -} - -// Rejects static/dynamic imports of Node built-ins in `.backend.ts` files, which run in a restricted environment. Best-effort — doesn't catch `require()` or a runtime-computed specifier (see `rejectRestrictedGlobals` for bare network globals like `fetch`). -export function rejectNodeBuiltinImports(ast: BaseNode, filePath: string): void { - const program = ensureProgram(ast, filePath); - for (const node of program.body) { - if ( - node.type === 'ImportDeclaration' && - !isTypeOnly(node) && - hasRuntimeSpecifier(node.specifiers) - ) { - rejectIfRestrictedSource(node.source, filePath); - continue; - } - - // A named re-export from a source loads the built-in module just like a regular import does. - if ( - node.type === 'ExportNamedDeclaration' && - node.source && - !isTypeOnly(node) && - hasRuntimeSpecifier(node.specifiers) - ) { - rejectIfRestrictedSource(node.source, filePath); - continue; - } - - // `export * from` is its own Rollup node type, not an ExportNamedDeclaration, so it needs its own check. - if (node.type === 'ExportAllDeclaration' && !isTypeOnly(node)) { - rejectIfRestrictedSource(node.source, filePath); - } - } - - // A dynamic `import()` is an ImportExpression that can appear anywhere in the tree, not just program.body, so it needs its own walk. - walkAst(program, null, { - ImportExpression(node) { - rejectIfRestrictedSource(node.source, filePath); - }, - }); -} - -function rejectIfRestrictedSource(source: Expression, filePath: string): void { - const value = staticStringValue(source); - if (value === undefined || !isRestrictedSource(value)) { - return; - } - - throw new Error( - `Importing Node built-in module "${value}" is not supported in backend function code. ` + - `Backend functions run in a restricted environment: for networking, filesystem, or ` + - `process access, use an Action Platform action ($.Actions or an @datadog/action-catalog ` + - `typed wrapper) instead; for pure utility modules (e.g. path, url, util), use a ` + - `runtime-independent package instead: ${filePath}`, - ); -} diff --git a/packages/plugins/apps/src/backend/ast-parsing/reject-restricted-globals.test.ts b/packages/plugins/apps/src/backend/ast-parsing/reject-restricted-globals.test.ts deleted file mode 100644 index 1cd198d8b..000000000 --- a/packages/plugins/apps/src/backend/ast-parsing/reject-restricted-globals.test.ts +++ /dev/null @@ -1,358 +0,0 @@ -// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. -// This product includes software developed at Datadog (https://www.datadoghq.com/). -// Copyright 2019-Present Datadog, Inc. - -import { rejectRestrictedGlobals } from '@dd/apps-plugin/backend/ast-parsing/reject-restricted-globals'; -import { parseAst } from 'rollup/parseAst'; - -describe('Backend Functions - rejectRestrictedGlobals', () => { - const filePath = '/project/src/math.backend.ts'; - - const rejectedCases = [ - { - description: 'reject a bare fetch() call', - code: 'export async function run() { return fetch("https://example.com"); }', - }, - { - description: 'reject fetch referenced without calling it', - code: 'export function run() { const f = fetch; return f; }', - }, - { - description: 'reject new XMLHttpRequest()', - code: 'export function run() { return new XMLHttpRequest(); }', - }, - { - description: 'reject new WebSocket(...)', - code: 'export function run() { return new WebSocket("wss://example.com"); }', - }, - { - description: 'reject new EventSource(...)', - code: 'export function run() { return new EventSource("/events"); }', - }, - { - description: 'reject globalThis.fetch(...)', - code: 'export async function run() { return globalThis.fetch("https://example.com"); }', - }, - { - description: 'reject globalThis.fetch referenced without calling it', - code: 'export function run() { const f = globalThis.fetch; return f; }', - }, - { - description: 'reject globalThis["fetch"](...)', - code: 'export async function run() { return globalThis["fetch"]("https://example.com"); }', - }, - { - description: 'reject destructuring fetch off of globalThis', - code: 'export function run() { const { fetch } = globalThis; return fetch("https://example.com"); }', - }, - { - description: 'reject destructuring fetch off of globalThis under a local alias', - code: 'export function run() { const { fetch: doFetch } = globalThis; return doFetch("https://example.com"); }', - }, - { - description: - 'reject destructuring fetch off of globalThis via a computed string-literal key', - code: "export function run() { const { ['fetch']: request } = globalThis; return request('https://example.com'); }", - }, - { - description: 'reject global.fetch(...) via the Node global alias', - code: 'export async function run() { return global.fetch("https://example.com"); }', - }, - { - description: 'reject global["fetch"](...) via the Node global alias', - code: 'export async function run() { return global["fetch"]("https://example.com"); }', - }, - { - description: 'reject destructuring fetch off of global, the Node global alias', - code: 'export function run() { const { fetch } = global; return fetch("https://example.com"); }', - }, - { - description: 'reject a destructuring *assignment* (not declaration) off of globalThis', - code: 'export function run() { let request; ({ fetch: request } = globalThis); return request("https://example.com"); }', - }, - { - description: 'reject a destructuring *assignment* off of global, the Node global alias', - code: 'export function run() { let request; ({ fetch: request } = global); return request("https://example.com"); }', - }, - { - description: - 'reject a rest-destructure of globalThis, which copies every ambient global', - code: 'export function run() { const { ...globals } = globalThis; return globals.fetch("https://example.com"); }', - }, - { - description: 'reject a rest-destructure of global, the Node global alias', - code: 'export function run() { const { ...globals } = global; return globals.fetch("https://example.com"); }', - }, - { - description: - 'reject spreading globalThis into a new object, which copies every ambient global', - code: 'export function run() { const stolen = { ...globalThis }; return stolen.fetch("https://example.com"); }', - }, - { - description: 'reject spreading global into a new object, the Node global alias', - code: 'export function run() { const stolen = { ...global }; return stolen.fetch("https://example.com"); }', - }, - { - description: 'reject Object.assign copying every ambient global into a new object', - code: 'export function run() { const stolen = Object.assign({}, globalThis); return stolen.fetch("https://example.com"); }', - }, - { - description: 'reject Object.assign targeting global, the Node global alias', - code: 'export function run() { const stolen = Object.assign({}, global); return stolen.fetch("https://example.com"); }', - }, - { - description: - 'reject Object["assign"] copying every ambient global, a computed-property-access form of the same call', - code: 'export function run() { const stolen = Object["assign"]({}, globalThis); return stolen.fetch("https://example.com"); }', - }, - { - description: 'reject Object.values reading every ambient global value at once', - code: 'export function run() { const values = Object.values(globalThis); return values.find((v) => typeof v === "function")(); }', - }, - { - description: 'reject Object.entries reading every ambient global at once', - code: 'export function run() { const entries = Object.entries(globalThis); return entries[0][1](); }', - }, - { - description: - 'reject Object.getOwnPropertyDescriptors reading every ambient global descriptor at once', - code: 'export function run() { const descriptors = Object.getOwnPropertyDescriptors(globalThis); return descriptors.fetch.value(); }', - }, - { - description: - 'reject globalThis[`fetch`](...) via a no-substitution template-literal key', - code: 'export async function run() { return globalThis[`fetch`]("https://example.com"); }', - }, - { - description: 'reject destructuring fetch off of globalThis via a template-literal key', - code: "export function run() { const { [`fetch`]: request } = globalThis; return request('https://example.com'); }", - }, - { - description: - 'reject destructuring fetch off of globalThis via a quoted (non-computed) string-literal key', - code: "export function run() { const { 'fetch': request } = globalThis; return request('https://example.com'); }", - }, - { - description: - 'reject destructuring fetch off of globalThis via a function parameter default value', - code: 'export function run({ fetch } = globalThis) { return fetch("https://example.com"); }', - }, - { - description: 'reject fetch reached through a single const alias of globalThis', - code: 'export function run() { const g = globalThis; return g.fetch("https://example.com"); }', - }, - { - description: 'reject a destructure off of a single const alias of globalThis', - code: 'export function run() { const g = globalThis; const { fetch } = g; return fetch("https://example.com"); }', - }, - { - description: 'reject fetch reached through a chain of const aliases of globalThis', - code: 'export function run() { const g1 = globalThis; const g2 = g1; return g2.fetch("https://example.com"); }', - }, - { - description: - 'reject globalThis.globalThis.fetch(...), a self-referential member-expression chain', - code: 'export function run() { return globalThis.globalThis.fetch("https://example.com"); }', - }, - { - description: - "reject global.global.fetch(...), the Node alias's own self-referential chain", - code: 'export function run() { return global.global.fetch("https://example.com"); }', - }, - { - description: - 'reject fetch reached through a nested destructure of the self-referential globalThis.globalThis', - code: 'export function run() { const { globalThis: { fetch } } = globalThis; return fetch("https://example.com"); }', - }, - { - description: - 'reject fetch reached through a const alias bound by a self-referential globalThis destructure key', - code: 'export function run() { const { globalThis: g } = globalThis; return g.fetch("https://example.com"); }', - }, - { - description: - "reject fetch reached through a const alias bound by a self-referential global destructure key (Node's own alias)", - code: 'export function run() { const { global: g } = global; return g.fetch("https://example.com"); }', - }, - { - description: - 'reject fetch reached through a const alias bound by a self-referential globalThis destructure key that also carries a default value', - code: 'export function run() { const { globalThis: g = {} } = globalThis; return g.fetch("https://example.com"); }', - }, - { - description: - 'reject fetch reached through a const alias bound by two levels of self-referential globalThis destructure keys', - code: 'export function run() { const { globalThis: { globalThis: g } } = globalThis; return g.fetch("https://example.com"); }', - }, - { - description: - 'reject fetch reached through a nested destructure of the self-referential globalThis.globalThis when the outer key carries a default value', - code: 'export function run() { const { globalThis: { fetch } = {} } = globalThis; return fetch("https://example.com"); }', - }, - { - description: - 'reject fetch reached through a for-of destructure whose inline iterable array literal contains globalThis', - code: 'export function run() { for (const { fetch } of [globalThis]) { return fetch("https://example.com"); } }', - }, - { - description: - 'reject fetch reached through a for-of destructure whose inline iterable array literal contains a const alias of globalThis', - code: 'export function run() { const g = globalThis; for (const { fetch } of [g]) { return fetch("https://example.com"); } }', - }, - { - description: - 'reject fetch reached through a plain (non-destructured) for-of loop variable bound to globalThis via an inline array literal', - code: 'export function run() { for (const g of [globalThis]) { return g.fetch("https://example.com"); } }', - }, - { - description: - 'reject fetch reached through a for-of loop using an assignment target (no const/let) destructuring globalThis via an inline array literal', - code: 'export function run() { let fetch; for ({ fetch } of [globalThis]) { return fetch("https://example.com"); } }', - }, - { - description: - 'reject globalThis[f](...) where f is a const alias of a string-literal property name', - code: "export function run() { const f = 'fetch'; return globalThis[f]('https://example.com'); }", - }, - { - description: - 'reject destructuring fetch off of globalThis via a computed key that is a const alias of a string-literal property name', - code: "export function run() { const key = 'fetch'; const { [key]: r } = globalThis; return r('https://example.com'); }", - }, - { - description: - 'reject fetch reached through a chain of const aliases resolving a computed property name', - code: "export function run() { const f1 = 'fetch'; const f2 = f1; return globalThis[f2]('https://example.com'); }", - }, - ]; - - test.each(rejectedCases)('Should $description', ({ code }) => { - const ast = parseAst(code); - expect(() => rejectRestrictedGlobals(ast, filePath)).toThrow( - /is not supported in backend function code/, - ); - expect(() => rejectRestrictedGlobals(ast, filePath)).toThrow(filePath); - }); - - const allowedCases = [ - { - description: 'allow calling an imported action-catalog function', - code: "import { request } from '@datadog/action-catalog/http/http';\nexport async function run() { return request({ inputs: {} }); }", - }, - { - description: 'allow a locally-declared function that happens to be named fetch', - code: 'function fetch() { return "local"; }\nexport function run() { return fetch(); }', - }, - { - description: 'allow a parameter named fetch shadowing the global', - code: 'export function run(fetch) { return fetch(); }', - }, - { - description: 'allow unrelated code with no restricted-global references', - code: 'export function run(a, b) { return a + b; }', - }, - { - description: 'allow accessing an unrestricted globalThis property', - code: 'export function run() { return globalThis.console; }', - }, - { - description: 'allow destructuring an unrestricted property off of globalThis', - code: 'export function run() { const { console } = globalThis; return console; }', - }, - { - description: - 'allow a nested destructure named fetch whose outer key is not itself a globalThis/global self-reference, since the outer key is not provably an alias back to the ambient global', - code: 'export function run() { const { myApi: { fetch } } = globalThis; return fetch; }', - }, - { - description: - 'allow accessing .fetch on a parameter named globalThis shadowing the ambient global', - code: 'export function run(globalThis) { return globalThis.fetch(); }', - }, - { - description: - 'allow destructuring fetch off of a parameter named globalThis shadowing the ambient global', - code: 'export function run(globalThis) { const { fetch } = globalThis; return fetch(); }', - }, - { - description: - 'allow accessing .fetch on a parameter named global shadowing the Node alias', - code: 'export function run(global) { return global.fetch(); }', - }, - { - description: - 'allow a destructuring assignment off of a parameter named globalThis shadowing the ambient global', - code: 'export function run(globalThis) { let request; ({ fetch: request } = globalThis); return request(); }', - }, - { - description: - 'allow a rest-destructure of a parameter named globalThis shadowing the ambient global', - code: 'export function run(globalThis) { const { ...rest } = globalThis; return rest; }', - }, - { - description: - 'allow a "let" alias of globalThis, since it could be reassigned to something else', - code: 'export function run() { let g = globalThis; return g.fetch(); }', - }, - { - description: - 'allow a function-parameter default value aliasing globalThis, same opacity as a "let" alias', - code: 'export function run(g = globalThis) { return g.fetch(); }', - }, - { - description: - 'allow a "let"-declared for-of loop variable bound to globalThis via an inline array literal, since it could be reassigned to something else before use, same opacity as a "let" alias', - code: 'export function run() { for (let g of [globalThis]) { g = safeObj; return g.fetch(); } }', - }, - { - description: 'allow a computed member access with a template literal that interpolates', - // eslint-disable-next-line no-template-curly-in-string -- source text for the AST under test, not a real interpolation - code: 'export function run(name) { return globalThis[`${name}`](); }', - }, - { - description: - 'allow accessing a property on a destructured binding of globalThis, since the binding is a specific property value, not the ambient global object itself', - code: 'export function run() { const { console: g } = globalThis; return g.fetch; }', - }, - { - description: - 'allow Object.assign copying an unrelated object, since the source is not the ambient global', - code: 'export function run() { const merged = Object.assign({}, { a: 1 }); return merged; }', - }, - { - description: - 'allow Object.assign(globalThis, ...), since globalThis there is the write target, not a source being copied from', - code: 'export function run() { Object.assign(globalThis, { marker: true }); }', - }, - { - description: - 'allow Object.keys on a parameter named globalThis shadowing the ambient global', - code: 'export function run(globalThis) { return Object.keys(globalThis); }', - }, - { - description: - 'allow Object.keys(globalThis), since it returns only property-name strings, never a callable reference', - code: 'export function run() { const names = Object.keys(globalThis); return names.length; }', - }, - { - description: - 'allow Reflect.ownKeys(globalThis), since it returns only property-name strings, never a callable reference', - code: 'export function run() { const keys = Reflect.ownKeys(globalThis); return keys.length; }', - }, - { - description: - 'allow a computed property access whose key is a "let" alias of a string literal, same opacity as a "let" alias of globalThis itself', - code: "export function run() { let f = 'fetch'; return globalThis[f](); }", - }, - { - description: - 'allow a computed property access whose key is a mutually self-referential const cycle, since the key cannot be statically resolved', - code: 'export function run() { const a = b; const b = a; return globalThis[a](); }', - }, - ]; - - test.each(allowedCases)('Should $description', ({ code }) => { - const ast = parseAst(code); - expect(() => rejectRestrictedGlobals(ast, filePath)).not.toThrow(); - }); -}); diff --git a/packages/plugins/apps/src/backend/ast-parsing/reject-restricted-globals.ts b/packages/plugins/apps/src/backend/ast-parsing/reject-restricted-globals.ts deleted file mode 100644 index 78c3c6a87..000000000 --- a/packages/plugins/apps/src/backend/ast-parsing/reject-restricted-globals.ts +++ /dev/null @@ -1,46 +0,0 @@ -// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. -// This product includes software developed at Datadog (https://www.datadoghq.com/). -// Copyright 2019-Present Datadog, Inc. - -import type { BaseNode } from 'estree'; - -import { forEachAmbientGlobalAccess } from './ambient-global-access'; -import { analyzeModuleScope } from './module-scope'; -import type { ModuleScopeAnalysis } from './module-scope'; -import { ensureProgram } from './type-guards'; - -const RESTRICTED_GLOBALS = new Set(['fetch', 'XMLHttpRequest', 'WebSocket', 'EventSource']); - -// Rejects network-capable globals (fetch, etc.) in `.backend.ts` files — backend functions have no raw network access in production and must go through an Action Platform action instead. Only pre-v2 (legacy) apps need this; the planned Terrapin-based v2 sandbox lifts the restriction. -export function rejectRestrictedGlobals( - ast: BaseNode, - filePath: string, - precomputedScopeAnalysis?: ModuleScopeAnalysis, -): void { - const program = ensureProgram(ast, filePath); - const scopeAnalysis = precomputedScopeAnalysis ?? analyzeModuleScope(program); - - forEachAmbientGlobalAccess(program, scopeAnalysis, RESTRICTED_GLOBALS, { - onNamedAccess(name) { - throwRestrictedGlobalError(name, filePath); - }, - onBulkCopy() { - throw new Error( - `Copying every property of "globalThis"/"global" at once (a rest destructure, ` + - `an object spread, or a call like Object.assign/Object.values) is not ` + - `supported in backend function code — it copies every ambient global, ` + - `including network-capable ones (${Array.from(RESTRICTED_GLOBALS).join(', ')}), ` + - `into a plain object: ${filePath}`, - ); - }, - }); -} - -function throwRestrictedGlobalError(name: string, filePath: string): never { - throw new Error( - `Using "${name}" is not supported in backend function code. ` + - `Backend functions cannot make raw network requests in production — ` + - `use an Action Platform action ($.Actions or an @datadog/action-catalog ` + - `typed wrapper) instead: ${filePath}`, - ); -} diff --git a/packages/plugins/apps/src/backend/ast-parsing/run-backend-static-checks.test.ts b/packages/plugins/apps/src/backend/ast-parsing/run-backend-static-checks.test.ts new file mode 100644 index 000000000..1eef12c64 --- /dev/null +++ b/packages/plugins/apps/src/backend/ast-parsing/run-backend-static-checks.test.ts @@ -0,0 +1,85 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import { analyzeModuleScope } from '@dd/apps-plugin/backend/ast-parsing/module-scope'; +import { NODE_REPLACEMENT_HINTS } from '@dd/apps-plugin/backend/ast-parsing/reject-globals-missing-from-node'; +import { runBackendStaticChecks } from '@dd/apps-plugin/backend/ast-parsing/run-backend-static-checks'; +import { ensureProgram } from '@dd/apps-plugin/backend/ast-parsing/type-guards'; +import { NODE_BACKEND_RUNTIME } from '@dd/apps-plugin/constants'; +import { getMockLogger, mockLogFn } from '@dd/tests/_jest/helpers/mocks'; +import { parseAst } from 'rollup/parseAst'; + +const FILE_PATH = '/project/src/handler.backend.ts'; + +function check(code: string): void { + const ast = parseAst(code); + const program = ensureProgram(ast, FILE_PATH); + const scopeAnalysis = analyzeModuleScope(program); + const log = getMockLogger(); + runBackendStaticChecks(ast, FILE_PATH, log, scopeAnalysis); +} + +describe('Backend Functions - runBackendStaticChecks', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test.each([ + { + description: 'a Node built-in import', + code: "import os from 'os';\nexport function run() { return os.hostname(); }", + }, + { + description: 'a node: built-in import', + code: "import { readFile } from 'node:fs/promises';\nexport function run() { return readFile('/etc/hosts'); }", + }, + { + description: 'a dynamic Node built-in import', + code: "export async function run() { const os = await import('os'); return os.hostname(); }", + }, + { + description: 'fetch', + code: 'export function run() { return fetch("https://example.com"); }', + }, + { + description: 'globalThis.fetch', + code: 'export function run() { return globalThis.fetch("https://example.com"); }', + }, + { + description: 'WebSocket', + code: 'export function run() { return new WebSocket("wss://example.com"); }', + }, + { + description: 'copying every property of globalThis', + code: 'export function run() { return { ...globalThis }; }', + }, + ])(`should allow $description, which the ${NODE_BACKEND_RUNTIME} supports`, ({ code }) => { + expect(() => check(code)).not.toThrow(); + }); + + test.each([ + { + name: 'XMLHttpRequest', + code: 'export function run() { return new XMLHttpRequest(); }', + }, + { + name: 'EventSource', + code: 'export function run() { return new EventSource("/events"); }', + }, + { + name: 'Deno', + code: 'export function run() { return Deno.env.get("TOKEN"); }', + }, + ])('should reject $name, which Node lacks, with a hint', ({ name, code }) => { + expect(() => check(code)).toThrow( + `"${name}" is not available in the ${NODE_BACKEND_RUNTIME}. ${NODE_REPLACEMENT_HINTS[name]}: ${FILE_PATH}`, + ); + }); + + test('should not warn about crypto or Intl', () => { + check('export function run() { return [crypto.randomUUID(), new Intl.Locale("en")]; }'); + + expect(mockLogFn).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/plugins/apps/src/backend/ast-parsing/run-backend-static-checks.ts b/packages/plugins/apps/src/backend/ast-parsing/run-backend-static-checks.ts index 78e922a9c..cc87f2d26 100644 --- a/packages/plugins/apps/src/backend/ast-parsing/run-backend-static-checks.ts +++ b/packages/plugins/apps/src/backend/ast-parsing/run-backend-static-checks.ts @@ -6,18 +6,14 @@ import type { Logger } from '@dd/core/types'; import type { BaseNode } from 'estree'; import type { ModuleScopeAnalysis } from './module-scope'; -import { rejectNodeBuiltinImports } from './reject-node-builtin-imports'; -import { rejectRestrictedGlobals } from './reject-restricted-globals'; -import { warnAboutDivergentGlobals } from './warn-divergent-globals'; +import { rejectGlobalsMissingFromNode } from './reject-globals-missing-from-node'; -/** Runs every static check (banned Node built-ins, restricted globals, divergent-global warnings) against a single module; shared by index.ts and backend-static-checks-plugin.ts so the two call sites can't drift. */ +/** Runs every static check against a single module; shared by every call site so the checks can't drift. */ export function runBackendStaticChecks( ast: BaseNode, filePath: string, log: Logger, scopeAnalysis: ModuleScopeAnalysis, ): void { - rejectNodeBuiltinImports(ast, filePath); - rejectRestrictedGlobals(ast, filePath, scopeAnalysis); - warnAboutDivergentGlobals(ast, filePath, log, scopeAnalysis); + rejectGlobalsMissingFromNode(ast, filePath, scopeAnalysis); } diff --git a/packages/plugins/apps/src/backend/ast-parsing/walk-ast.test.ts b/packages/plugins/apps/src/backend/ast-parsing/walk-ast.test.ts index a4d0e8ba9..7b9baa38c 100644 --- a/packages/plugins/apps/src/backend/ast-parsing/walk-ast.test.ts +++ b/packages/plugins/apps/src/backend/ast-parsing/walk-ast.test.ts @@ -4,9 +4,18 @@ import type { BaseNode, Node, Program } from 'estree'; -import { walkAst } from './walk-ast'; +import { parentsOf, walkAst } from './walk-ast'; describe('Backend AST Parsing - walkAst', () => { + test('Should map every child node to its parent', () => { + const ast = buildEstreeFixture(); + const parents = parentsOf(ast); + const [statement] = ast.body; + + expect(parents.get(ast)).toBeUndefined(); + expect(parents.get(statement)).toBe(ast); + }); + test('Should visit universal and specialized visitors for ESTree-shaped nodes', () => { const ast = buildEstreeFixture(); const state = { diff --git a/packages/plugins/apps/src/backend/ast-parsing/walk-ast.ts b/packages/plugins/apps/src/backend/ast-parsing/walk-ast.ts index 1d8c439af..c788245ee 100644 --- a/packages/plugins/apps/src/backend/ast-parsing/walk-ast.ts +++ b/packages/plugins/apps/src/backend/ast-parsing/walk-ast.ts @@ -2,7 +2,7 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import type { BaseNode, Node as EstreeNode } from 'estree'; +import type { BaseNode, Node as EstreeNode, Program } from 'estree'; /** * Object passed to every visitor. @@ -110,3 +110,19 @@ function getSpecializedVisitor( ): WalkAstVisitor | undefined { return (visitors as Record | undefined>)[node.type]; } + +export function parentsOf(program: Program): Map { + const parents = new Map(); + walkAst(program, null, { + _(node) { + for (const value of Object.values(node)) { + for (const child of Array.isArray(value) ? value : [value]) { + if (isAstNode(child)) { + parents.set(child, node); + } + } + } + }, + }); + return parents; +} diff --git a/packages/plugins/apps/src/backend/ast-parsing/warn-divergent-globals.test.ts b/packages/plugins/apps/src/backend/ast-parsing/warn-divergent-globals.test.ts deleted file mode 100644 index 98b509231..000000000 --- a/packages/plugins/apps/src/backend/ast-parsing/warn-divergent-globals.test.ts +++ /dev/null @@ -1,239 +0,0 @@ -// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. -// This product includes software developed at Datadog (https://www.datadoghq.com/). -// Copyright 2019-Present Datadog, Inc. - -import { - resetDivergentGlobalWarnings, - warnAboutDivergentGlobals, -} from '@dd/apps-plugin/backend/ast-parsing/warn-divergent-globals'; -import { getMockLogger, mockLogFn } from '@dd/tests/_jest/helpers/mocks'; -import { parseAst } from 'rollup/parseAst'; - -describe('Backend Functions - warnAboutDivergentGlobals', () => { - const filePath = '/project/src/math.backend.ts'; - - // Reset so an earlier test's warning doesn't suppress this test's expected warning via the cross-call dedup cache. - beforeEach(() => { - resetDivergentGlobalWarnings(); - }); - - const warnedCases = [ - { - description: 'warn on a bare crypto.randomUUID() call', - code: 'export function run() { return crypto.randomUUID(); }', - global: 'crypto', - }, - { - description: 'warn on crypto referenced without calling it', - code: 'export function run() { const c = crypto; return c; }', - global: 'crypto', - }, - { - description: 'warn on a bare new Intl.NumberFormat(...)', - code: 'export function run() { return new Intl.NumberFormat("en-US"); }', - global: 'Intl', - }, - { - description: 'warn on globalThis.crypto', - code: 'export function run() { return globalThis.crypto.randomUUID(); }', - global: 'crypto', - }, - { - description: 'warn on globalThis["Intl"]', - code: 'export function run() { return globalThis["Intl"].NumberFormat; }', - global: 'Intl', - }, - { - description: 'warn on destructuring crypto off of globalThis', - code: 'export function run() { const { crypto } = globalThis; return crypto.randomUUID(); }', - global: 'crypto', - }, - { - description: 'warn on destructuring Intl off of globalThis under a local alias', - code: 'export function run() { const { Intl: I } = globalThis; return I.NumberFormat; }', - global: 'Intl', - }, - { - description: 'warn on global.crypto via the Node global alias', - code: 'export function run() { return global.crypto.randomUUID(); }', - global: 'crypto', - }, - { - description: 'warn on global["Intl"] via the Node global alias', - code: 'export function run() { return global["Intl"].NumberFormat; }', - global: 'Intl', - }, - { - description: 'warn on destructuring crypto off of global, the Node global alias', - code: 'export function run() { const { crypto } = global; return crypto.randomUUID(); }', - global: 'crypto', - }, - { - description: 'warn on a destructuring *assignment* (not declaration) off of globalThis', - code: 'export function run() { let c; ({ crypto: c } = globalThis); return c.randomUUID(); }', - global: 'crypto', - }, - { - description: 'warn on globalThis[`crypto`] via a no-substitution template-literal key', - code: 'export function run() { return globalThis[`crypto`].randomUUID(); }', - global: 'crypto', - }, - { - description: 'warn on crypto reached through a single const alias of globalThis', - code: 'export function run() { const g = globalThis; return g.crypto.randomUUID(); }', - global: 'crypto', - }, - { - description: - 'warn on destructuring crypto off of globalThis via a function parameter default value', - code: 'export function run({ crypto } = globalThis) { return crypto.randomUUID(); }', - global: 'crypto', - }, - { - description: - 'warn on crypto reached through a nested destructure of the self-referential globalThis.globalThis', - code: 'export function run() { const { globalThis: { crypto } } = globalThis; return crypto.randomUUID(); }', - global: 'crypto', - }, - { - description: - 'warn on crypto reached through a const alias bound by a self-referential globalThis destructure key', - code: 'export function run() { const { globalThis: g } = globalThis; return g.crypto.randomUUID(); }', - global: 'crypto', - }, - { - description: - 'warn on globalThis[key] where key is a const alias of a string-literal property name', - code: "export function run() { const key = 'crypto'; return globalThis[key].randomUUID(); }", - global: 'crypto', - }, - ]; - - test.each(warnedCases)('Should $description', ({ code, global }) => { - const ast = parseAst(code); - const logger = getMockLogger(); - warnAboutDivergentGlobals(ast, filePath, logger); - expect(mockLogFn).toHaveBeenCalledWith(expect.stringContaining(global), 'warn'); - expect(mockLogFn).toHaveBeenCalledWith(expect.stringContaining(filePath), 'warn'); - }); - - test('Should warn only once per distinct global, even if referenced multiple times', () => { - const code = - 'export function run() { crypto.randomUUID(); crypto.randomUUID(); return crypto.randomUUID(); }'; - const ast = parseAst(code); - const logger = getMockLogger(); - warnAboutDivergentGlobals(ast, filePath, logger); - expect(mockLogFn).toHaveBeenCalledTimes(1); - }); - - test('Should warn for every distinct global in a multi-property destructure', () => { - const code = - 'export function run() { const { crypto, Intl } = globalThis; return [crypto, Intl]; }'; - const ast = parseAst(code); - const logger = getMockLogger(); - warnAboutDivergentGlobals(ast, filePath, logger); - expect(mockLogFn).toHaveBeenCalledWith(expect.stringContaining('crypto'), 'warn'); - expect(mockLogFn).toHaveBeenCalledWith(expect.stringContaining('Intl'), 'warn'); - expect(mockLogFn).toHaveBeenCalledTimes(2); - }); - - // Regression test: a real backend entry file is transformed multiple times with the same filePath, and must still warn only once per distinct global. - test('Should warn only once per distinct global across separate calls for the same file path', () => { - const code = 'export function run() { return crypto.randomUUID(); }'; - const ast = parseAst(code); - const logger = getMockLogger(); - - warnAboutDivergentGlobals(ast, filePath, logger); - warnAboutDivergentGlobals(ast, filePath, logger); - warnAboutDivergentGlobals(ast, filePath, logger); - - expect(mockLogFn).toHaveBeenCalledTimes(1); - }); - - test('Should still warn for a different file path even after another file already warned for the same global', () => { - const code = 'export function run() { return crypto.randomUUID(); }'; - const ast = parseAst(code); - const logger = getMockLogger(); - const otherFilePath = '/project/src/other.backend.ts'; - - warnAboutDivergentGlobals(ast, filePath, logger); - warnAboutDivergentGlobals(ast, otherFilePath, logger); - - expect(mockLogFn).toHaveBeenCalledTimes(2); - expect(mockLogFn).toHaveBeenCalledWith(expect.stringContaining(otherFilePath), 'warn'); - }); - - test('Should warn for every divergent global on a rest-destructure of globalThis, which copies every ambient global', () => { - const code = 'export function run() { const { ...globals } = globalThis; return globals; }'; - const ast = parseAst(code); - const logger = getMockLogger(); - warnAboutDivergentGlobals(ast, filePath, logger); - expect(mockLogFn).toHaveBeenCalledWith(expect.stringContaining('crypto'), 'warn'); - expect(mockLogFn).toHaveBeenCalledWith(expect.stringContaining('Intl'), 'warn'); - expect(mockLogFn).toHaveBeenCalledTimes(2); - }); - - test('Should warn for every divergent global on Object.assign copying every ambient global at once', () => { - const code = - 'export function run() { const stolen = Object.assign({}, globalThis); return stolen; }'; - const ast = parseAst(code); - const logger = getMockLogger(); - warnAboutDivergentGlobals(ast, filePath, logger); - expect(mockLogFn).toHaveBeenCalledWith(expect.stringContaining('crypto'), 'warn'); - expect(mockLogFn).toHaveBeenCalledWith(expect.stringContaining('Intl'), 'warn'); - expect(mockLogFn).toHaveBeenCalledTimes(2); - }); - - test('Should warn on destructuring crypto off of globalThis via a computed string-literal key', () => { - const code = "export function run() { const { ['crypto']: c } = globalThis; return c; }"; - const ast = parseAst(code); - const logger = getMockLogger(); - warnAboutDivergentGlobals(ast, filePath, logger); - expect(mockLogFn).toHaveBeenCalledWith(expect.stringContaining('crypto'), 'warn'); - }); - - const allowedCases = [ - { - description: 'not warn on an imported action-catalog function', - code: "import { request } from '@datadog/action-catalog/http/http';\nexport async function run() { return request({ inputs: {} }); }", - }, - { - description: 'not warn on a locally-declared function that happens to be named crypto', - code: 'function crypto() { return "local"; }\nexport function run() { return crypto(); }', - }, - { - description: 'not warn on a parameter named Intl shadowing the global', - code: 'export function run(Intl) { return Intl.NumberFormat; }', - }, - { - description: 'not warn on unrelated code with no divergent-global references', - code: 'export function run(a, b) { return a + b; }', - }, - { - description: 'not warn on accessing an unrelated globalThis property', - code: 'export function run() { return globalThis.console; }', - }, - { - description: - 'not warn on accessing .crypto on a parameter named globalThis shadowing the ambient global', - code: 'export function run(globalThis) { return globalThis.crypto; }', - }, - { - description: - 'not warn on destructuring crypto off of a parameter named globalThis shadowing the ambient global', - code: 'export function run(globalThis) { const { crypto } = globalThis; return crypto; }', - }, - { - description: - 'not warn on a nested destructure named crypto whose outer key is not itself a globalThis/global self-reference', - code: 'export function run() { const { myApi: { crypto } } = globalThis; return crypto; }', - }, - ]; - - test.each(allowedCases)('Should $description', ({ code }) => { - const ast = parseAst(code); - const logger = getMockLogger(); - warnAboutDivergentGlobals(ast, filePath, logger); - expect(mockLogFn).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/plugins/apps/src/backend/ast-parsing/warn-divergent-globals.ts b/packages/plugins/apps/src/backend/ast-parsing/warn-divergent-globals.ts deleted file mode 100644 index 614807eb6..000000000 --- a/packages/plugins/apps/src/backend/ast-parsing/warn-divergent-globals.ts +++ /dev/null @@ -1,68 +0,0 @@ -// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. -// This product includes software developed at Datadog (https://www.datadoghq.com/). -// Copyright 2019-Present Datadog, Inc. - -import type { Logger } from '@dd/core/types'; -import type { BaseNode } from 'estree'; - -import { forEachAmbientGlobalAccess } from './ambient-global-access'; -import { analyzeModuleScope } from './module-scope'; -import type { ModuleScopeAnalysis } from './module-scope'; -import { ensureProgram } from './type-guards'; - -const DIVERGENT_GLOBALS = new Set(['crypto', 'Intl']); - -// Cross-call cache since the same backend entry file is transformed multiple times per build; bounded and cleared wholesale once too large, trading an occasional re-warn for capped memory in long dev-server sessions. -const MAX_TRACKED_FILES = 500; -const warnedGlobalsByFile = new Map>(); - -// Test-only escape hatch: clears the cross-call dedup cache between test cases that reuse the same file path. -export function resetDivergentGlobalWarnings(): void { - warnedGlobalsByFile.clear(); -} - -// Warns (never rejects) on `crypto`/`Intl` references in `.backend.ts` files, since their behavior can differ between local Node and production Deno. Editor-time guidance only — `npm run dev:verify`'s real cloud round trip is the actual parity gate. -export function warnAboutDivergentGlobals( - ast: BaseNode, - filePath: string, - log: Logger, - precomputedScopeAnalysis?: ModuleScopeAnalysis, -): void { - const program = ensureProgram(ast, filePath); - const scopeAnalysis = precomputedScopeAnalysis ?? analyzeModuleScope(program); - - let warned = warnedGlobalsByFile.get(filePath); - if (!warned) { - if (warnedGlobalsByFile.size >= MAX_TRACKED_FILES) { - warnedGlobalsByFile.clear(); - } - warned = new Set(); - warnedGlobalsByFile.set(filePath, warned); - } - - const warnOnce = (name: string) => { - if (warned.has(name)) { - return; - } - warned.add(name); - warnDivergentGlobal(name, filePath, log); - }; - - forEachAmbientGlobalAccess(program, scopeAnalysis, DIVERGENT_GLOBALS, { - onNamedAccess: warnOnce, - onBulkCopy() { - // No specific property to point at, so warn about every divergent global rather than staying silent. - for (const name of DIVERGENT_GLOBALS) { - warnOnce(name); - } - }, - }); -} - -function warnDivergentGlobal(name: string, filePath: string, log: Logger): void { - log.warn( - `"${name}" is used in ${filePath}. Its exact behavior can differ between local ` + - `execution (Node) and production (Deno) — verify locale-/implementation-sensitive ` + - `results with "npm run dev:verify" before publishing.`, - ); -} diff --git a/packages/plugins/apps/src/constants.ts b/packages/plugins/apps/src/constants.ts index 6f86d4de6..1c6122fe0 100644 --- a/packages/plugins/apps/src/constants.ts +++ b/packages/plugins/apps/src/constants.ts @@ -20,6 +20,9 @@ export const BACKEND_FILE_WITH_QUERY_RE = new RegExp( /** Vite's `--mode` for `npm run dev:verify`; read via `server.config.mode` since `import.meta.env.MODE` breaks Jest's CommonJS transform. */ export const DEV_VERIFY_MODE = 'dev-verify'; +export const NODE_BACKEND_RUNTIME = 'Node backend runtime'; +export const NODE_BACKEND_RUNTIME_NODE_MAJOR = 24; + export const BACKEND_CODE_EXTENSIONS = [ '.ts', '.tsx', diff --git a/packages/plugins/apps/src/index.test.ts b/packages/plugins/apps/src/index.test.ts index 58882ef0e..91b31890f 100644 --- a/packages/plugins/apps/src/index.test.ts +++ b/packages/plugins/apps/src/index.test.ts @@ -23,7 +23,7 @@ import path from 'path'; import { parseAst } from 'rollup/parseAst'; import type { BackendFunction } from './backend/types'; -import { ARCHIVE_FILENAME } from './constants'; +import { ARCHIVE_FILENAME, NODE_BACKEND_RUNTIME } from './constants'; import type { AppsOptionsWithDefaults } from './types'; import { BACKEND_OUT_DIR_PREFIX } from './vite/build-backend-functions'; import { buildAppPackage, MANIFEST_DIR_PREFIX } from './vite/build-package'; @@ -390,28 +390,26 @@ describe('Apps Plugin - getPlugins closeBundle', () => { ); }); - test('Should reject a Node builtin import inside a helper module reachable from a backend function', async () => { + test('Should reject Deno inside a helper module reachable from a backend function', async () => { jest.spyOn(assets, 'collectAssets').mockResolvedValue([ { absolutePath: '/project/dist/index.js', relativePath: 'dist/index.js' }, ]); // The entry file alone is clean; only importing a local helper is visible from here. const entryCode = ` - import { readSecret } from './helpers/fs-helper.js'; + import { readSecret } from './helpers/deno-helper.js'; export function greet() { return readSecret(); } `; - // Only the nested backend build's module graph walk sees this Node builtin import; the outer transform never reaches the helper. + // Only the nested backend build's module graph walk sees this helper; the outer transform never reaches it. const helperCode = ` - import fs from 'fs'; - export function readSecret() { - return fs.readFileSync('/etc/passwd', 'utf8'); + return Deno.readTextFileSync('/etc/passwd'); } `; - const helperId = '/project/src/backend/helpers/fs-helper.js'; + const helperId = '/project/src/backend/helpers/deno-helper.js'; const viteBuild = jest.fn().mockImplementation(async (config) => { await emitModuleParsed(config, '/project/src/backend/greet.backend.js', entryCode); await emitModuleParsed(config, helperId, helperCode); @@ -431,7 +429,7 @@ describe('Apps Plugin - getPlugins closeBundle', () => { const plugins = getPlugins(args); const transform = extractViteTransform(plugins); const resolveMock = jest.fn(async (specifier: string) => - specifier === './helpers/fs-helper.js' ? { id: helperId } : null, + specifier === './helpers/deno-helper.js' ? { id: helperId } : null, ); const loadMock = jest.fn(async () => null); const addWatchFileMock = jest.fn(); @@ -448,15 +446,23 @@ describe('Apps Plugin - getPlugins closeBundle', () => { const closeBundleResult = extractCloseBundle(plugins)(); await expect(closeBundleResult).rejects.toThrow( - 'Importing Node built-in module "fs" is not supported in backend function code', + `"Deno" is not available in the ${NODE_BACKEND_RUNTIME}`, ); await expectNoLeakedTempDirs(mkdtempMock, [BACKEND_OUT_DIR_PREFIX]); }); - test('Should reject a bare fetch() call inside a helper module reachable from a backend function', async () => { + test('Should accept fetch and a Node built-in import inside a helper module reachable from a backend function', async () => { jest.spyOn(assets, 'collectAssets').mockResolvedValue([ { absolutePath: '/project/dist/index.js', relativePath: 'dist/index.js' }, ]); + const createArchive = jest + .spyOn(archive, 'createArchive') + .mockImplementation(async (archiveAssets) => ({ + archivePath: '/tmp/dd-apps-790/datadog-app-assets.zip', + assets: archiveAssets, + size: 30, + decompressedSize: 100, + })); const entryCode = ` import { getEcho } from './helpers/http-helper.js'; @@ -466,8 +472,10 @@ describe('Apps Plugin - getPlugins closeBundle', () => { } `; const helperCode = ` + import os from 'node:os'; + export function getEcho() { - return fetch('https://example.com'); + return fetch('https://example.com/' + os.hostname()); } `; const helperId = '/project/src/backend/helpers/http-helper.js'; @@ -505,11 +513,10 @@ describe('Apps Plugin - getPlugins closeBundle', () => { '/project/src/backend/greet.backend.js', ); - const closeBundleResult = extractCloseBundle(plugins)(); - await expect(closeBundleResult).rejects.toThrow( - 'Using "fetch" is not supported in backend function code', - ); - await expectNoLeakedTempDirs(mkdtempMock, [BACKEND_OUT_DIR_PREFIX]); + await extractCloseBundle(plugins)(); + + expect(createArchive).toHaveBeenCalledTimes(1); + await expectNoLeakedTempDirs(mkdtempMock, [BACKEND_OUT_DIR_PREFIX, MANIFEST_DIR_PREFIX]); }); test('Should remove the backend output directory when packaging the app fails', async () => { diff --git a/packages/plugins/apps/src/parameters/extract-parameters.ts b/packages/plugins/apps/src/parameters/extract-parameters.ts index 884de58ce..5aab0bebc 100644 --- a/packages/plugins/apps/src/parameters/extract-parameters.ts +++ b/packages/plugins/apps/src/parameters/extract-parameters.ts @@ -15,7 +15,7 @@ import type { } from 'estree'; import { analyzeModuleScope, getModuleVariable } from '../backend/ast-parsing/module-scope'; -import { walkAst } from '../backend/ast-parsing/walk-ast'; +import { parentsOf, walkAst } from '../backend/ast-parsing/walk-ast'; import type { AppsParameterSchema } from '../types'; import { MAX_PARAMETERS, validateParameterSchema } from '../validate'; @@ -131,22 +131,6 @@ function importedName(specifier: ImportSpecifier | ExportSpecifier): string { return name.type === 'Identifier' ? name.name : String(name.value); } -function parentsOf(program: Program): Map { - const parents = new Map(); - walkAst(program as Node, parents, { - _(node) { - for (const value of Object.values(node)) { - for (const child of Array.isArray(value) ? value : [value]) { - if (child && typeof child === 'object' && typeof child.type === 'string') { - parents.set(child as Node, node as Node); - } - } - } - }, - }); - return parents; -} - /** A specifier written as a string or as a template string with no expressions. */ const staticSpecifier = (source: Node | null | undefined): string | undefined => { if (source?.type === 'Literal' && typeof source.value === 'string') { diff --git a/packages/plugins/apps/src/vite/backend-static-checks-plugin.test.ts b/packages/plugins/apps/src/vite/backend-static-checks-plugin.test.ts index 6bda3d325..f63ca503f 100644 --- a/packages/plugins/apps/src/vite/backend-static-checks-plugin.test.ts +++ b/packages/plugins/apps/src/vite/backend-static-checks-plugin.test.ts @@ -3,7 +3,8 @@ // Copyright 2019-Present Datadog, Inc. import { createParsedModuleRecord } from '@dd/apps-plugin/backend/ast-parsing/module-graph'; -import { getMockLogger, mockLogFn } from '@dd/tests/_jest/helpers/mocks'; +import { NODE_BACKEND_RUNTIME } from '@dd/apps-plugin/constants'; +import { getMockLogger } from '@dd/tests/_jest/helpers/mocks'; import { parseAst } from 'rollup/parseAst'; import { createBackendStaticChecksPlugin } from './backend-static-checks-plugin'; @@ -29,7 +30,7 @@ describe('Backend Functions - backend static checks plugin', () => { jest.clearAllMocks(); }); - test('Should reject a module importing a Node built-in', () => { + test('Should accept a module importing a Node built-in and calling fetch', () => { const plugin = createBackendStaticChecksPlugin( '/project', getMockLogger(), @@ -39,12 +40,12 @@ describe('Backend Functions - backend static checks plugin', () => { expect(() => callModuleParsed(plugin, { id: '/project/src/backend/helpers/http.js', - code: "import fs from 'fs';\nexport function readIt() { return fs.readFileSync('/etc/passwd'); }", + code: "import fs from 'fs';\nexport function readIt() { fs.readFileSync('/etc/hosts'); return fetch('https://example.com'); }", }), - ).toThrow('Importing Node built-in module "fs" is not supported in backend function code'); + ).not.toThrow(); }); - test('Should reject a module calling a restricted global', () => { + test('Should reject a module using Deno', () => { const plugin = createBackendStaticChecksPlugin( '/project', getMockLogger(), @@ -54,27 +55,12 @@ describe('Backend Functions - backend static checks plugin', () => { expect(() => callModuleParsed(plugin, { id: '/project/src/backend/helpers/http.js', - code: 'export function callIt() { return fetch("https://example.com"); }', + code: 'export function cwd() { return Deno.cwd(); }', }), - ).toThrow('Using "fetch" is not supported in backend function code'); - }); - - test('Should warn on a module referencing crypto or Intl', () => { - const plugin = createBackendStaticChecksPlugin( - '/project', - getMockLogger(), - () => new Map(), - ); - - callModuleParsed(plugin, { - id: '/project/src/backend/helpers/id.js', - code: 'export function makeId() { return crypto.randomUUID(); }', - }); - - expect(mockLogFn).toHaveBeenCalledWith(expect.stringContaining('crypto'), 'warn'); + ).toThrow(`"Deno" is not available in the ${NODE_BACKEND_RUNTIME}`); }); - test('Should allow a module with no restricted imports or globals', () => { + test('Should allow a module with no restricted globals', () => { const plugin = createBackendStaticChecksPlugin( '/project', getMockLogger(), @@ -142,7 +128,7 @@ describe('Backend Functions - backend static checks plugin', () => { test('Should reuse an already-parsed module record instead of re-parsing', () => { const moduleId = '/project/src/backend/helpers/http.js'; - const ast = parseAst('export function callIt() { return fetch("https://example.com"); }'); + const ast = parseAst('export function callIt() { return Deno.cwd(); }'); const record = createParsedModuleRecord(moduleId, '/project', ast, []); if (!record) { throw new Error('Expected a module record to be created for this test.'); @@ -162,7 +148,7 @@ describe('Backend Functions - backend static checks plugin', () => { { id: moduleId, code: 'this code is never read when a record already exists' }, throwIfParsed, ), - ).toThrow('Using "fetch" is not supported in backend function code'); + ).toThrow(`"Deno" is not available in the ${NODE_BACKEND_RUNTIME}`); }); test('Should fall back to parsing when no record exists for the module', () => { @@ -175,9 +161,9 @@ describe('Backend Functions - backend static checks plugin', () => { expect(() => callModuleParsed(plugin, { id: '/project/src/backend/helpers/http.js', - code: 'export function callIt() { return fetch("https://example.com"); }', + code: 'export function callIt() { return Deno.cwd(); }', }), - ).toThrow('Using "fetch" is not supported in backend function code'); + ).toThrow(`"Deno" is not available in the ${NODE_BACKEND_RUNTIME}`); }); test('Should throw a checks-specific error, not a connection-ID-collector-specific one, when the fallback parse fails', () => { @@ -193,7 +179,7 @@ describe('Backend Functions - backend static checks plugin', () => { code: 'export function callIt( {', }), ).toThrow( - /Unsupported module source .*: unparseable module source .* could hide a Node-builtin import or restricted-global access/, + /Unsupported module source .*: unparseable module source .* could hide a restricted-global access/, ); }); }); diff --git a/packages/plugins/apps/src/vite/backend-static-checks-plugin.ts b/packages/plugins/apps/src/vite/backend-static-checks-plugin.ts index 57df3e7a5..edb8f7f73 100644 --- a/packages/plugins/apps/src/vite/backend-static-checks-plugin.ts +++ b/packages/plugins/apps/src/vite/backend-static-checks-plugin.ts @@ -19,7 +19,7 @@ import { isViteVirtualModuleId, normalizeViteModuleId } from './backend-module-g // Distinct from module-graph.ts's `unsupportedModuleGraphDependency` — that message is specific to connection-ID collection, unrelated to this plugin's fallback parse. function unsupportedStaticChecksSource(filePath: string, unsupported: string): Error { return new Error( - `Unsupported module source for ${filePath}: ${unsupported} could hide a Node-builtin import or restricted-global access.`, + `Unsupported module source for ${filePath}: ${unsupported} could hide a restricted-global access.`, ); } @@ -60,7 +60,6 @@ export function createBackendStaticChecksPlugin( `unparseable module source (${reason})`, ); } - // Shared so the checks below don't each independently re-walk this fallback-parsed AST to build the same scope graph. const program = ensureProgram(ast, moduleId); scopeAnalysis = analyzeModuleScope(program); } diff --git a/packages/plugins/apps/src/vite/dev-server-module-graph.test.ts b/packages/plugins/apps/src/vite/dev-server-module-graph.test.ts index 6005d6a00..0c426945c 100644 --- a/packages/plugins/apps/src/vite/dev-server-module-graph.test.ts +++ b/packages/plugins/apps/src/vite/dev-server-module-graph.test.ts @@ -9,7 +9,7 @@ import path from 'node:path'; import { parseAst } from 'rollup/parseAst'; import type { ViteDevServer } from 'vite'; -import { LOCAL_EXECUTION_LOAD_SUFFIX } from '../constants'; +import { LOCAL_EXECUTION_LOAD_SUFFIX, NODE_BACKEND_RUNTIME } from '../constants'; import { collectModuleGraphFromServer } from './dev-server-module-graph'; @@ -588,21 +588,21 @@ describe('dev-server-module-graph — collectModuleGraphFromServer', () => { }); // The production build path checks every app-local module transitively, not just the - // .backend.ts entry — local execution must reject the same banned helper the same way. - test('Should reject a helper module transitively imported by a backend entry when it imports a banned Node builtin', async () => { - const entryPath = path.join(FIXTURE_ROOT, 'viaBannedHelper.backend.ts'); - const bannedHelperPath = path.join(FIXTURE_ROOT, 'helperWithBannedImport.ts'); - const bannedHelperNode: FakeModuleNode = { - id: bannedHelperPath, - file: bannedHelperPath, + // .backend.ts entry — local execution must reject the same helper the same way. + test('Should reject a helper module transitively imported by a backend entry when it uses Deno, which Node lacks', async () => { + const entryPath = path.join(FIXTURE_ROOT, 'viaDenoHelper.backend.ts'); + const denoHelperPath = path.join(FIXTURE_ROOT, 'helperUsingDeno.ts'); + const denoHelperNode: FakeModuleNode = { + id: denoHelperPath, + file: denoHelperPath, importedModules: new Set(), }; const entryNode: FakeModuleNode = { id: SUFFIXED_ENTRY_ID, file: entryPath, - importedModules: new Set([bannedHelperNode]), + importedModules: new Set([denoHelperNode]), }; - const server = makeFakeServer(async () => ({ id: bannedHelperPath }), entryNode); + const server = makeFakeServer(async () => ({ id: denoHelperPath }), entryNode); const log = getMockLogger(); @@ -615,7 +615,7 @@ describe('dev-server-module-graph — collectModuleGraphFromServer', () => { ); await expect(collecting).rejects.toThrow( - /Importing Node built-in module "fs" is not supported/, + `"Deno" is not available in the ${NODE_BACKEND_RUNTIME}`, ); }); }); diff --git a/packages/plugins/apps/src/vite/dev-server-module-graph.ts b/packages/plugins/apps/src/vite/dev-server-module-graph.ts index ac0265044..4679ae33b 100644 --- a/packages/plugins/apps/src/vite/dev-server-module-graph.ts +++ b/packages/plugins/apps/src/vite/dev-server-module-graph.ts @@ -209,7 +209,7 @@ async function walkModuleGraph( const record = createParsedModuleRecord(moduleId, buildRoot, ast, staticDependencies); if (record) { // No build-time moduleParsed hook here (Rollup-only), so this is what catches a - // banned import or restricted global locally instead of only at publish time. + // restricted global locally instead of only at publish time. runBackendStaticChecks(record.ast, record.id, log, record.scopeAnalysis); records.set(record.id, record); } diff --git a/packages/plugins/apps/src/vite/dev-server.test.ts b/packages/plugins/apps/src/vite/dev-server.test.ts index a6dd886f9..83994005d 100644 --- a/packages/plugins/apps/src/vite/dev-server.test.ts +++ b/packages/plugins/apps/src/vite/dev-server.test.ts @@ -29,7 +29,7 @@ import nock from 'nock'; import { encodeQueryName } from '../backend/encodeQueryName'; import type { BackendFunction } from '../backend/types'; -import { DEV_VERIFY_MODE, LOCAL_EXECUTION_LOAD_SUFFIX } from '../constants'; +import { DEV_VERIFY_MODE, LOCAL_EXECUTION_LOAD_SUFFIX, NODE_BACKEND_RUNTIME } from '../constants'; import type { AppsOptionsWithDefaults } from '../types'; /** Shape of the `$.Actions` dynamic proxy — a nested property path (e.g. `$.Actions.slack.chat.postMessage`) callable at any depth; types `globalThis.$` in tests without an `any` cast. */ @@ -608,7 +608,7 @@ describe('Dev Server Middleware', () => { await emitModuleParsed( config, '/project/backend/helpers/secrets.ts', - "import fs from 'fs';\nexport function readSecret() { return fs.readFileSync('/etc/passwd'); }", + "export function readSecret() { return Deno.readTextFileSync('/etc/passwd'); }", ); return mockBuildResult('// code'); }); @@ -626,9 +626,7 @@ describe('Dev Server Middleware', () => { expect(res.statusCode).toBe(500); const responseText = res.getBody(); const body = JSON.parse(responseText); - expect(body.error).toContain( - 'Importing Node built-in module "fs" is not supported in backend function code', - ); + expect(body.error).toContain(`"Deno" is not available in the ${NODE_BACKEND_RUNTIME}`); }); test('Should call vite.build with configFile: false and write: false', async () => { @@ -2037,7 +2035,7 @@ describe('Dev Server Middleware', () => { // Regression test: getAllowedConnectionIds (which only resolves/transforms modules, never // executes them) must run and reject before the priming load ever evaluates the entry's - // real top-level code — otherwise a banned import there could run before being rejected. + // real top-level code — otherwise a module the static checks reject could run first. test('Should never prime (evaluate) the entry when getAllowedConnectionIds rejects', async () => { const primingCalls: string[] = []; mockLoadModule.mockImplementation(async (specifier: string) => { @@ -2046,7 +2044,7 @@ describe('Dev Server Middleware', () => { }); const rejectingMiddleware = createTestMiddleware({ getAllowedConnectionIds: async () => { - throw new Error('Importing Node built-in module "fs" is not supported'); + throw new Error(`"Deno" is not available in the ${NODE_BACKEND_RUNTIME}`); }, }); diff --git a/packages/plugins/apps/src/vite/dev-server.ts b/packages/plugins/apps/src/vite/dev-server.ts index 3d05dd6ad..b8c77de63 100644 --- a/packages/plugins/apps/src/vite/dev-server.ts +++ b/packages/plugins/apps/src/vite/dev-server.ts @@ -686,7 +686,7 @@ async function handleExecuteAction( } } -/** Handle POST /__dd/executeActionViaCloud: bundle and execute via the production round trip (queue + Deno subprocess). */ +/** Handle POST /__dd/executeActionViaCloud: bundle and execute via the production round trip. */ async function handleExecuteActionViaCloud( req: IncomingMessage, res: ServerResponse, diff --git a/packages/plugins/apps/src/vite/index.test.ts b/packages/plugins/apps/src/vite/index.test.ts index 0d07bfd49..5ee05f59b 100644 --- a/packages/plugins/apps/src/vite/index.test.ts +++ b/packages/plugins/apps/src/vite/index.test.ts @@ -3,7 +3,12 @@ // Copyright 2019-Present Datadog, Inc. import type { HostCheckConfig } from '@dd/apps-plugin/vite/dev-server-request-guard'; -import { getVitePlugin, SSR_WARMUP_SETTING } from '@dd/apps-plugin/vite/index'; +import { + getVitePlugin, + localNodeWarning, + MIN_LOCAL_NODE_MAJOR, + SSR_WARMUP_SETTING, +} from '@dd/apps-plugin/vite/index'; import type { ViteBundler } from '@dd/apps-plugin/vite/index'; import { localExecutionResolutionContext } from '@dd/apps-plugin/vite/local-execution'; import { rmSync } from '@dd/core/helpers/fs'; @@ -40,6 +45,8 @@ import { BACKEND_FILE_WITH_QUERY_RE, DEV_VERIFY_MODE, LOCAL_EXECUTION_LOAD_SUFFIX, + NODE_BACKEND_RUNTIME, + NODE_BACKEND_RUNTIME_NODE_MAJOR, } from '../constants'; import * as buildPackage from './build-package'; @@ -50,6 +57,8 @@ import * as networkGuard from './network-guard'; type TransformHandler = (code: string, id: string, transformOptions?: { ssr?: boolean }) => unknown; +const NODE_WITHOUT_WEBSOCKET = `${MIN_LOCAL_NODE_MAJOR - 1}.7.3`; + // 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) { const { transform } = plugin ?? {}; @@ -277,6 +286,36 @@ describe('Backend Functions - getVitePlugin', () => { nock.cleanAll(); }); + const runtimeMajor = `"${NODE_BACKEND_RUNTIME_NODE_MAJOR}"`; + const nodeVersionCases = [ + { version: `${MIN_LOCAL_NODE_MAJOR - 2}.19.4`, warns: true, mentions: ['WebSocket'] }, + { version: NODE_WITHOUT_WEBSOCKET, warns: true, mentions: ['WebSocket'] }, + { version: `${MIN_LOCAL_NODE_MAJOR}.0.0`, warns: true, mentions: [runtimeMajor] }, + { + version: `${NODE_BACKEND_RUNTIME_NODE_MAJOR - 1}.0.0`, + warns: true, + mentions: [runtimeMajor], + }, + { version: `${NODE_BACKEND_RUNTIME_NODE_MAJOR}.0.0`, warns: false, mentions: [] }, + { version: `${NODE_BACKEND_RUNTIME_NODE_MAJOR}.13.1`, warns: false, mentions: [] }, + { + version: `${NODE_BACKEND_RUNTIME_NODE_MAJOR + 1}.0.0`, + warns: true, + mentions: [runtimeMajor], + }, + ]; + test.each(nodeVersionCases)( + 'Should warn about local Node $version (warns: $warns)', + ({ version, warns, mentions }) => { + const warning = localNodeWarning(version); + + expect(warning !== undefined).toBe(warns); + for (const expected of [...mentions, ...(warns ? [version] : [])]) { + expect(warning).toContain(expected); + } + }, + ); + test('Should return a vite plugin object with closeBundle', () => { const plugin = getVitePlugin(defaultOptions); expect(plugin).toBeDefined(); @@ -409,60 +448,41 @@ describe('Backend Functions - getVitePlugin', () => { expect(authSpy).not.toHaveBeenCalled(); }); - test('Should reject a backend file importing a Node built-in module', () => { + const transformBackendFile = (code: string) => { const plugin = getVitePlugin(defaultOptions); const handler = getTransformHandler(plugin); const resolveMock = jest.fn(async () => null); const loadMock = jest.fn(async () => null); const addWatchFileMock = jest.fn(); - - expect(() => - handler.call( - { - parse: parseAst, - resolve: resolveMock, - load: loadMock, - addWatchFile: addWatchFileMock, - }, - ` - import fs from 'node:fs'; - export function myHandler() { - return fs.readFileSync('/etc/passwd', 'utf8'); - } - `, - '/build/src/backend/myHandler.backend.ts', - ), - ).toThrow( - 'Importing Node built-in module "node:fs" is not supported in backend function code', + return handler.call( + { + parse: parseAst, + resolve: resolveMock, + load: loadMock, + addWatchFile: addWatchFileMock, + }, + code, + '/build/src/backend/myHandler.backend.ts', ); - }); + }; - test('Should warn, but not reject, a backend file referencing crypto or Intl', () => { - const plugin = getVitePlugin(defaultOptions); - const handler = getTransformHandler(plugin); - const resolveMock = jest.fn(async () => null); - const loadMock = jest.fn(async () => null); - const addWatchFileMock = jest.fn(); + test('Should accept a backend file using fetch and a Node built-in module', async () => { + const result = await transformBackendFile(` + import os from 'node:os'; + export async function myHandler() { + const response = await fetch('https://example.com'); + return { host: os.hostname(), status: response.status }; + } + `); + const transformedCode = extractTransformedCode(result); - expect(() => - handler.call( - { - parse: parseAst, - resolve: resolveMock, - load: loadMock, - addWatchFile: addWatchFileMock, - }, - ` - export function myHandler() { - return crypto.randomUUID() + new Intl.NumberFormat('en-US').format(1); - } - `, - '/build/src/backend/myHandler.backend.ts', - ), - ).not.toThrow(); + expect(transformedCode).toEqual(expect.stringContaining('executeBackendFunction')); + }); - expect(mockLogFn).toHaveBeenCalledWith(expect.stringContaining('crypto'), 'warn'); - expect(mockLogFn).toHaveBeenCalledWith(expect.stringContaining('Intl'), 'warn'); + test('Should reject a backend file using Deno', () => { + expect(() => + transformBackendFile('export function myHandler() { return Deno.cwd(); }'), + ).toThrow(`"Deno" is not available in the ${NODE_BACKEND_RUNTIME}`); }); // Regression test: without the suffix check, ssrLoadModule() would get the proxy stub instead of the real function body. @@ -1198,34 +1218,47 @@ describe('Backend Functions - getVitePlugin', () => { // Without auth both endpoints are off, and dev-verify sends every execution to the cloud. test.each([ - { scenario: 'no authentication', mode: 'development', withAuth: false, installs: 0 }, - { scenario: 'dev-verify mode', mode: DEV_VERIFY_MODE, withAuth: true, installs: 0 }, - { scenario: 'local execution', mode: 'development', withAuth: true, installs: 1 }, + { scenario: 'no authentication', mode: 'development', withAuth: false, runsLocally: false }, + { scenario: 'dev-verify mode', mode: DEV_VERIFY_MODE, withAuth: true, runsLocally: false }, + { scenario: 'local execution', mode: 'development', withAuth: true, runsLocally: true }, ])( - 'Should install the sandbox guards only when local execution can run ($scenario)', - ({ mode, withAuth, installs }) => { + 'Should install the sandbox guards and check local Node only when local execution can run ($scenario)', + ({ mode, withAuth, runsLocally }) => { const plugin = getVitePlugin(defaultOptions); const installGuards = jest .spyOn(networkGuard, 'installGuards') .mockImplementation(() => undefined); const configureServer = getConfigureServer(plugin); + const realVersion = Object.getOwnPropertyDescriptor(process.versions, 'node'); const restoreEnv = clearDatadogEnv(); - if (withAuth) { - process.env.DD_API_KEY = 'test-api-key'; - process.env.DD_APP_KEY = 'test-app-key'; - } try { + Object.defineProperty(process.versions, 'node', { + value: NODE_WITHOUT_WEBSOCKET, + configurable: true, + }); + if (withAuth) { + process.env.DD_API_KEY = 'test-api-key'; + process.env.DD_APP_KEY = 'test-app-key'; + } configureServer({ middlewares: { use: jest.fn() }, ssrLoadModule: jest.fn(), config: fakeServerConfig(mode), }); + const warning = localNodeWarning(NODE_WITHOUT_WEBSOCKET); + const warnings = mockLogFn.mock.calls.filter(([message]) => message === warning); + const installs = runsLocally ? 1 : 0; + const expectedWarnings = runsLocally ? [[warning, 'warn']] : []; expect(installGuards).toHaveBeenCalledTimes(installs); + expect(warnings).toEqual(expectedWarnings); } finally { restoreEnv(); installGuards.mockRestore(); + if (realVersion) { + Object.defineProperty(process.versions, 'node', realVersion); + } } }, ); @@ -1325,6 +1358,55 @@ describe('Backend Functions - getVitePlugin', () => { }, ); + test('Should check the local Node version when backend functions run in child processes', () => { + const plugin = getVitePlugin(defaultOptions); + const resolveRunner = jest + .spyOn(localExecutionProcess, 'resolveViteModuleRunner') + .mockReturnValue('/project/node_modules/vite/dist/node/module-runner.js'); + const isYarnPnp = jest + .spyOn(localExecutionProcess, 'isYarnPnpRuntime') + .mockReturnValue(false); + const createMiddleware = jest.spyOn(devServer, 'createDevServerMiddleware'); + const configureServer = getConfigureServer(plugin); + const realVersion = Object.getOwnPropertyDescriptor(process.versions, 'node'); + const restoreEnv = clearDatadogEnv(); + process.env.DD_API_KEY = 'test-api-key'; + process.env.DD_APP_KEY = 'test-app-key'; + + try { + Object.defineProperty(process.versions, 'node', { + value: NODE_WITHOUT_WEBSOCKET, + configurable: true, + }); + configureServer({ + middlewares: { use: jest.fn() }, + ssrLoadModule: jest.fn(), + close: jest.fn(), + config: fakeServerConfig('development'), + environments: { + ssr: { + hot: { handleInvoke: jest.fn() }, + config: { dev: {} }, + fetchModule: jest.fn(), + }, + }, + }); + + expect(typeof createMiddleware.mock.calls[0]?.[11]).toBe('function'); + const warning = localNodeWarning(NODE_WITHOUT_WEBSOCKET); + const warnings = mockLogFn.mock.calls.filter(([message]) => message === warning); + expect(warnings).toEqual([[warning, 'warn']]); + } finally { + restoreEnv(); + resolveRunner.mockRestore(); + isYarnPnp.mockRestore(); + createMiddleware.mockRestore(); + if (realVersion) { + Object.defineProperty(process.versions, 'node', realVersion); + } + } + }); + test.each([ { predates: true, warns: true }, { predates: false, warns: false }, diff --git a/packages/plugins/apps/src/vite/index.ts b/packages/plugins/apps/src/vite/index.ts index fa7b43985..7f2b18cfa 100644 --- a/packages/plugins/apps/src/vite/index.ts +++ b/packages/plugins/apps/src/vite/index.ts @@ -31,6 +31,8 @@ import { BACKEND_FILE_WITH_QUERY_RE, DEV_VERIFY_MODE, LOCAL_EXECUTION_LOAD_SUFFIX, + NODE_BACKEND_RUNTIME, + NODE_BACKEND_RUNTIME_NODE_MAJOR, PLUGIN_NAME, } from '../constants'; import { PARAMETERS_MODULE } from '../parameters/extract-parameters'; @@ -125,6 +127,20 @@ function createBackendFunctionRegistry() { const APPS_RUNTIME_PATH = path.join(__dirname, './apps-runtime.mjs'); export const SSR_WARMUP_SETTING = 'environments.ssr.dev.preTransformRequests'; +// Local execution runs on the developer's own Node; WebSocket is a default global from Node 22. +export const MIN_LOCAL_NODE_MAJOR = 22; + +export function localNodeWarning(nodeVersion: string): string | undefined { + const major = Number.parseInt(nodeVersion, 10); + if (major < MIN_LOCAL_NODE_MAJOR) { + return `Node ${nodeVersion} has no global WebSocket, which backend functions get on the ${NODE_BACKEND_RUNTIME}; use Node ${MIN_LOCAL_NODE_MAJOR} or later to run them locally.`; + } + if (major !== NODE_BACKEND_RUNTIME_NODE_MAJOR) { + return `Your local Node version "${nodeVersion}" doesn't match the ${NODE_BACKEND_RUNTIME}'s Node version "${NODE_BACKEND_RUNTIME_NODE_MAJOR}", so backend functions may behave differently locally.`; + } + return undefined; +} + const toPosixPath = (filePath: string) => filePath.replace(/\\/g, '/'); const isWithinDirectory = (directory: string, filePath: string): boolean => { @@ -329,9 +345,8 @@ export const getVitePlugin = ({ const ast = this.parse(code); 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. + // Runs even for a file with zero exports, to catch a restricted global as soon as it's written. runBackendStaticChecks(ast, normalizedId, log, scopeAnalysis); const exportNames = extractExportedFunctions(ast, normalizedId); if (exportNames.length === 0) { @@ -530,6 +545,10 @@ export const getVitePlugin = ({ log.warn(usesYarnPnp ? YARN_PNP_WARNING : MODULE_RUNNER_UNRESOLVED_WARNING); } if (runsLocally) { + const nodeWarning = localNodeWarning(process.versions.node); + if (nodeWarning) { + log.warn(nodeWarning); + } // Started here so an unusable Vite is reported at startup; each execution reuses // this load, or retries it after a failure. loadViteParseAst().catch((error: unknown) => { diff --git a/packages/plugins/apps/src/vite/local-execution.ts b/packages/plugins/apps/src/vite/local-execution.ts index e19924849..112b27e64 100644 --- a/packages/plugins/apps/src/vite/local-execution.ts +++ b/packages/plugins/apps/src/vite/local-execution.ts @@ -486,7 +486,7 @@ export async function executeScriptLocally( * Cold-function entry point: collects `allowedConnectionIds`, then primes and runs the entry in * one `enqueue()` call — priming runs real top-level code, so doing it outside the queue would let * two cold functions run in parallel. Connection IDs are collected first, executing no code, so a - * banned import is rejected before the entry runs. + * module the static checks reject never runs. */ export async function executeColdActionLocally( func: BackendFunction, diff --git a/packages/tests/src/_jest/fixtures/apps_backend_project/helperWithBannedImport.ts b/packages/tests/src/_jest/fixtures/apps_backend_project/helperUsingDeno.ts similarity index 78% rename from packages/tests/src/_jest/fixtures/apps_backend_project/helperWithBannedImport.ts rename to packages/tests/src/_jest/fixtures/apps_backend_project/helperUsingDeno.ts index 578a219a4..95b018684 100644 --- a/packages/tests/src/_jest/fixtures/apps_backend_project/helperWithBannedImport.ts +++ b/packages/tests/src/_jest/fixtures/apps_backend_project/helperUsingDeno.ts @@ -2,8 +2,6 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import fs from 'fs'; - export function readSomething() { - return fs.readFileSync('/etc/hosts', 'utf8'); + return Deno.readTextFileSync('/etc/hosts'); } diff --git a/packages/tests/src/_jest/fixtures/apps_backend_project/viaBannedHelper.backend.ts b/packages/tests/src/_jest/fixtures/apps_backend_project/viaDenoHelper.backend.ts similarity index 72% rename from packages/tests/src/_jest/fixtures/apps_backend_project/viaBannedHelper.backend.ts rename to packages/tests/src/_jest/fixtures/apps_backend_project/viaDenoHelper.backend.ts index 01c38f99a..37987312f 100644 --- a/packages/tests/src/_jest/fixtures/apps_backend_project/viaBannedHelper.backend.ts +++ b/packages/tests/src/_jest/fixtures/apps_backend_project/viaDenoHelper.backend.ts @@ -2,8 +2,8 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import { readSomething } from './helperWithBannedImport'; +import { readSomething } from './helperUsingDeno'; -export async function usesBannedHelper() { +export async function usesDenoHelper() { return readSomething(); }