diff --git a/packages/plugins/apps/src/backend/index.test.ts b/packages/plugins/apps/src/backend/index.test.ts index 0f5ceb8c..e3eb235c 100644 --- a/packages/plugins/apps/src/backend/index.test.ts +++ b/packages/plugins/apps/src/backend/index.test.ts @@ -2,8 +2,8 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import { BACKEND_VIRTUAL_PREFIX, getBackendPlugin } from '@dd/apps-plugin/backend/index'; -import { getMockLogger, mockLogFn } from '@dd/tests/_jest/helpers/mocks'; +import { getBackendPlugin } from '@dd/apps-plugin/backend/index'; +import { getMockLogger } from '@dd/tests/_jest/helpers/mocks'; const log = getMockLogger(); @@ -24,63 +24,9 @@ describe('Backend Functions - getBackendPlugin', () => { expect(plugin.enforce).toBe('pre'); }); - test('Should have rollup and vite properties', () => { + test('Should have a vite property', () => { const plugin = getBackendPlugin(functions, new Map(), log); - expect(plugin.rollup).toBeDefined(); expect(plugin.vite).toBeDefined(); }); }); - - describe('resolveId', () => { - const cases = [ - { - description: 'resolve virtual backend module ID', - input: `${BACKEND_VIRTUAL_PREFIX}myHandler`, - expected: `${BACKEND_VIRTUAL_PREFIX}myHandler`, - }, - { - description: 'return null for non-backend module', - input: 'some-other-module', - expected: null, - }, - { - description: 'return null for empty string', - input: '', - expected: null, - }, - ]; - - test.each(cases)('Should $description', ({ input, expected }) => { - const plugin = getBackendPlugin(functions, new Map(), log); - const resolveId = plugin.resolveId as Function; - expect(resolveId(input, undefined, {})).toBe(expected); - }); - }); - - describe('load', () => { - test('Should return virtual entry content for known function', () => { - const plugin = getBackendPlugin(functions, new Map(), log); - const load = plugin.load as Function; - const content = load(`${BACKEND_VIRTUAL_PREFIX}myHandler`); - expect(content).toContain('import { myHandler }'); - expect(content).toContain('export async function main($)'); - }); - - test('Should return null and log error for unknown function', () => { - const plugin = getBackendPlugin(functions, new Map(), log); - const load = plugin.load as Function; - const content = load(`${BACKEND_VIRTUAL_PREFIX}unknownFunc`); - expect(content).toBeNull(); - expect(mockLogFn).toHaveBeenCalledWith( - expect.stringContaining('Backend function "unknownFunc" not found'), - 'error', - ); - }); - - test('Should return null for non-prefixed ID', () => { - const plugin = getBackendPlugin(functions, new Map(), log); - const load = plugin.load as Function; - expect(load('regular-module')).toBeNull(); - }); - }); }); diff --git a/packages/plugins/apps/src/backend/index.ts b/packages/plugins/apps/src/backend/index.ts index eaa4c0a5..8b237de4 100644 --- a/packages/plugins/apps/src/backend/index.ts +++ b/packages/plugins/apps/src/backend/index.ts @@ -5,46 +5,26 @@ import type { Logger, PluginOptions } from '@dd/core/types'; import type { BackendFunction } from './discovery'; -import { getRollupPlugin } from './rollup'; -import { generateVirtualEntryContent } from './virtual-entry'; import { getVitePlugin } from './vite'; -export const BACKEND_VIRTUAL_PREFIX = '\0dd-backend:'; +export interface BackendPluginContext { + buildRoot: string; + bundler: any; +} /** - * Returns a plugin that injects backend functions as additional entry points - * into the host build. The backendOutputs map is populated during the build - * and read by the upload plugin in asyncTrueEnd. + * Returns a plugin that builds backend functions via a separate vite.build() + * and populates backendOutputs for the upload plugin. */ export function getBackendPlugin( functions: BackendFunction[], backendOutputs: Map, log: Logger, + pluginContext?: BackendPluginContext, ): PluginOptions { - const functionsByName = new Map(functions.map((f) => [f.name, f])); - return { name: 'datadog-apps-backend-plugin', enforce: 'pre', - resolveId(source) { - if (source.startsWith(BACKEND_VIRTUAL_PREFIX)) { - return source; - } - return null; - }, - load(id) { - if (!id.startsWith(BACKEND_VIRTUAL_PREFIX)) { - return null; - } - const funcName = id.slice(BACKEND_VIRTUAL_PREFIX.length); - const func = functionsByName.get(funcName); - if (!func) { - log.error(`Backend function "${funcName}" not found.`); - return null; - } - return generateVirtualEntryContent(func.name, func.entryPath); - }, - rollup: getRollupPlugin(functions, backendOutputs, log), - vite: getVitePlugin(functions, backendOutputs, log), + vite: getVitePlugin(functions, backendOutputs, log, pluginContext), }; } diff --git a/packages/plugins/apps/src/backend/rollup.ts b/packages/plugins/apps/src/backend/rollup.ts deleted file mode 100644 index 47ccf859..00000000 --- a/packages/plugins/apps/src/backend/rollup.ts +++ /dev/null @@ -1,87 +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, PluginOptions } from '@dd/core/types'; -import path from 'path'; -import type { GetManualChunk, ManualChunksOption, OutputOptions } from 'rollup'; - -import type { BackendFunction } from './discovery'; -import { BACKEND_VIRTUAL_PREFIX } from './index'; - -type BackendBundlerHooks = Pick< - NonNullable, - 'outputOptions' | 'buildStart' | 'writeBundle' ->; - -export const getRollupPlugin = ( - functions: BackendFunction[], - backendOutputs: Map, - log: Logger, -): BackendBundlerHooks => { - /** - * Wrap a user-provided manualChunks function to exclude backend modules - * from being pulled into shared frontend chunks. - */ - const wrapManualChunks = (original: GetManualChunk): ManualChunksOption => { - return (id, api) => { - const moduleInfo = api.getModuleInfo(id); - if (moduleInfo) { - const importers = moduleInfo.importers || []; - if (importers.some((imp: string) => imp.startsWith(BACKEND_VIRTUAL_PREFIX))) { - return undefined; - } - } - return original(id, api); - }; - }; - - const guardManualChunks = (output: OutputOptions) => { - const original = output.manualChunks; - if (typeof original === 'function') { - output.manualChunks = wrapManualChunks(original); - } - }; - - return { - outputOptions(outputOptions) { - // Guard user-configured manualChunks to prevent backend modules - // from being pulled into shared frontend chunks. - if (Array.isArray(outputOptions)) { - for (const out of outputOptions) { - guardManualChunks(out); - } - } else if (outputOptions) { - guardManualChunks(outputOptions); - } - return outputOptions; - }, - buildStart() { - for (const func of functions) { - this.emitFile({ - type: 'chunk', - id: `${BACKEND_VIRTUAL_PREFIX}${func.name}`, - name: `backend/${func.name}`, - preserveSignature: 'exports-only', - }); - } - }, - writeBundle(options, bundle) { - const outDir = options.dir || path.dirname(options.file || ''); - for (const [fileName, chunk] of Object.entries(bundle)) { - if (chunk.type !== 'chunk') { - continue; - } - if ( - chunk.facadeModuleId && - chunk.facadeModuleId.startsWith(BACKEND_VIRTUAL_PREFIX) - ) { - const funcName = chunk.facadeModuleId.slice(BACKEND_VIRTUAL_PREFIX.length); - const absolutePath = path.resolve(outDir, fileName); - backendOutputs.set(funcName, absolutePath); - log.debug(`Backend function "${funcName}" output: ${absolutePath}`); - } - } - }, - }; -}; diff --git a/packages/plugins/apps/src/backend/shared.ts b/packages/plugins/apps/src/backend/shared.ts index 30986016..325d5ff8 100644 --- a/packages/plugins/apps/src/backend/shared.ts +++ b/packages/plugins/apps/src/backend/shared.ts @@ -4,11 +4,13 @@ /** * Check if @datadog/action-catalog is installed using Node's module resolution. - * Works across all package managers (npm, yarn, yarn PnP, pnpm). + * Resolves from the given directory (defaults to cwd) so the check works + * even when the plugin itself is loaded from a different location (e.g. linked). */ -export function isActionCatalogInstalled(): boolean { +export function isActionCatalogInstalled(fromDir?: string): boolean { try { - require.resolve('@datadog/action-catalog/action-execution'); + const paths = fromDir ? [fromDir] : undefined; + require.resolve('@datadog/action-catalog/action-execution', { paths }); return true; } catch { return false; @@ -23,7 +25,7 @@ export const ACTION_CATALOG_IMPORT = export const SET_EXECUTE_ACTION_SNIPPET = `\ if (typeof setExecuteActionImplementation === 'function') { setExecuteActionImplementation(async (actionId, request) => { - const actionPath = actionId.replace(/^com\\\\.datadoghq\\\\./, ''); + const actionPath = actionId.replace(/^com\\.datadoghq\\./, ''); const pathParts = actionPath.split('.'); let actionFn = $.Actions; for (const part of pathParts) { diff --git a/packages/plugins/apps/src/backend/virtual-entry.ts b/packages/plugins/apps/src/backend/virtual-entry.ts index 4563e9aa..90f7b07f 100644 --- a/packages/plugins/apps/src/backend/virtual-entry.ts +++ b/packages/plugins/apps/src/backend/virtual-entry.ts @@ -9,33 +9,44 @@ import { } from './shared'; /** - * Generate the virtual entry source for a backend function. - * The host bundler resolves imports, so no export-stripping is needed. + * Generate the shared main($) function body lines. */ -export function generateVirtualEntryContent(functionName: string, entryPath: string): string { +function generateMainBody(functionName: string, argsExpression: string): string[] { + return [ + '/** @param {import("./context.types").Context} $ */', + 'export async function main($) {', + ' globalThis.$ = $;', + '', + ' // Register the $.Actions-based implementation for executeAction', + SET_EXECUTE_ACTION_SNIPPET, + '', + ` const args = ${argsExpression};`, + ` const result = await ${functionName}(...args);`, + ' return result;', + '}', + ]; +} + +/** + * Generate the virtual entry source for a backend function (production). + * Uses a template expression resolved at runtime by App Builder. + */ +export function generateVirtualEntryContent( + functionName: string, + entryPath: string, + projectRoot?: string, +): string { const lines: string[] = []; lines.push(`import { ${functionName} } from ${JSON.stringify(entryPath)};`); - if (isActionCatalogInstalled()) { + if (isActionCatalogInstalled(projectRoot)) { lines.push(ACTION_CATALOG_IMPORT); } lines.push(''); - lines.push('/** @param {import("./context.types").Context} $ */'); - lines.push('export async function main($) {'); - lines.push(' globalThis.$ = $;'); - lines.push(''); - lines.push(` // Register the $.Actions-based implementation for executeAction`); - lines.push(SET_EXECUTE_ACTION_SNIPPET); - lines.push(''); - lines.push(' // backendFunctionArgs is a template expression resolved at runtime by'); - lines.push(" // App Builder's executeBackendFunction client via template_params."); // eslint-disable-next-line no-template-curly-in-string - lines.push(" const args = JSON.parse('${backendFunctionArgs}' || '[]');"); - lines.push(` const result = await ${functionName}(...args);`); - lines.push(' return result;'); - lines.push('}'); + lines.push(...generateMainBody(functionName, "JSON.parse('${backendFunctionArgs}' || '[]')")); return lines.join('\n'); } diff --git a/packages/plugins/apps/src/backend/vite/index.ts b/packages/plugins/apps/src/backend/vite/index.ts index e1ebcf6d..4ef2bada 100644 --- a/packages/plugins/apps/src/backend/vite/index.ts +++ b/packages/plugins/apps/src/backend/vite/index.ts @@ -3,22 +3,133 @@ // Copyright 2019-Present Datadog, Inc. import type { Logger, PluginOptions } from '@dd/core/types'; +import { mkdtemp } from 'fs/promises'; +import { tmpdir } from 'os'; +import path from 'path'; +import type { RollupOutput } from 'rollup'; import type { BackendFunction } from '../discovery'; -import { getRollupPlugin } from '../rollup'; +import type { BackendPluginContext } from '../index'; +import { generateVirtualEntryContent } from '../virtual-entry'; + +const VIRTUAL_PREFIX = '\0dd-backend:'; + +/** + * Build all backend functions using a separate vite.build() call. + * Produces one standalone JS file per function in a temp directory. + */ +async function buildBackendFunctions( + vite: any, + functions: BackendFunction[], + backendOutputs: Map, + buildRoot: string, + log: Logger, +): Promise { + const outDir = await mkdtemp(path.join(tmpdir(), 'dd-apps-backend-')); + + const virtualEntries: Record = {}; + const input: Record = {}; + + for (const func of functions) { + const virtualId = `${VIRTUAL_PREFIX}${func.name}`; + virtualEntries[virtualId] = generateVirtualEntryContent( + func.name, + func.entryPath, + buildRoot, + ); + input[func.name] = virtualId; + } + + log.debug(`Building ${functions.length} backend function(s) via vite.build()`); + + const result = await vite.build({ + configFile: false, + root: buildRoot, + logLevel: 'silent', + build: { + write: true, + outDir, + emptyOutDir: false, + minify: false, + target: 'esnext', + rollupOptions: { + input, + output: { format: 'es', exports: 'named', entryFileNames: '[name].js' }, + preserveEntrySignatures: 'exports-only', + treeshake: false, + // Silence "use client" directive warnings from third-party deps. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onwarn(warning: any, defaultHandler: any) { + if (warning.code === 'MODULE_LEVEL_DIRECTIVE') { + return; + } + defaultHandler(warning); + }, + }, + }, + resolve: { + extensions: ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.json'], + }, + plugins: [ + { + name: 'dd-backend-resolve', + enforce: 'pre', + resolveId(id: string) { + if (virtualEntries[id]) { + return { id, moduleSideEffects: true }; + } + return null; + }, + load(id: string) { + if (virtualEntries[id]) { + return virtualEntries[id]; + } + return null; + }, + }, + ], + }); + + const output = (Array.isArray(result) ? result[0] : result) as RollupOutput; + + for (const chunk of output.output) { + if (chunk.type !== 'chunk' || !chunk.isEntry) { + continue; + } + const funcName = chunk.name; + const absolutePath = path.resolve(outDir, chunk.fileName); + backendOutputs.set(funcName, absolutePath); + log.debug(`Backend function "${funcName}" output: ${absolutePath}`); + } +} /** * Returns the Vite-specific plugin hooks for backend functions. - * Extends the Rollup plugin with Vite-compatible types. + * Uses a separate vite.build() for production instead of emitting chunks + * into the host build, giving full control over backend build config. */ export const getVitePlugin = ( functions: BackendFunction[], backendOutputs: Map, log: Logger, + pluginContext?: BackendPluginContext, ): PluginOptions['vite'] => { - const rollupPlugin = getRollupPlugin(functions, backendOutputs, log); + const vite = pluginContext?.bundler; + + const vitePlugin: PluginOptions['vite'] = {}; + + // Production: run a separate vite.build() after the host build completes. + if (vite) { + vitePlugin.closeBundle = async () => { + await buildBackendFunctions( + vite, + functions, + backendOutputs, + pluginContext!.buildRoot, + log, + ); + }; + } - return { - ...rollupPlugin, - }; + return vitePlugin; }; diff --git a/packages/plugins/apps/src/index.ts b/packages/plugins/apps/src/index.ts index c65cefa0..66127a2a 100644 --- a/packages/plugins/apps/src/index.ts +++ b/packages/plugins/apps/src/index.ts @@ -28,7 +28,7 @@ export type types = { AppsOptions: AppsOptions; }; -export const getPlugins: GetPlugins = ({ options, context }) => { +export const getPlugins: GetPlugins = ({ options, context, bundler }) => { const log = context.getLogger(PLUGIN_NAME); let toThrow: Error | undefined; const validatedOptions = validateOptions(options); @@ -150,11 +150,16 @@ Either: const plugins: PluginOptions[] = []; - // Backend build plugin — injects backend functions as additional entry points. - // Only supported for rollup and vite (Phase 1). - const backendSupportedBundlers = ['rollup', 'vite']; + // Backend build plugin — builds backend functions via a separate vite.build(). + // Only supported for vite. + const backendSupportedBundlers = ['vite']; if (hasBackend && backendSupportedBundlers.includes(context.bundler.name)) { - plugins.push(getBackendPlugin(backendFunctions, backendOutputs, log)); + plugins.push( + getBackendPlugin(backendFunctions, backendOutputs, log, { + buildRoot: context.buildRoot, + bundler, + }), + ); } else if (hasBackend) { log.warn( `Backend functions are not yet supported for ${context.bundler.name}. Skipping backend build.`,