Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/plugins/apps/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ A Vite plugin that builds a deployable Datadog Apps package. Publishing is owned
- [Configuration](#configuration)
- [Development server authentication](#development-server-authentication)
- [Development server requests](#development-server-requests)
- [Cloud execution](#cloud-execution)
- [Package output](#package-output)
- [apps.enable](#appsenable)
- [apps.include](#appsinclude)
Expand Down Expand Up @@ -58,6 +59,12 @@ The dev server's backend function endpoints (`/__dd/executeAction`, `/__dd/execu

To call one by hand, send that content type, for example `curl -X POST -H 'Content-Type: application/json' -d '{"functionName":"..."}' http://localhost:5173/__dd/debugBundle`.

## Cloud execution

Cloud execution (`dev:verify`, `/__dd/executeActionViaCloud`) bundles a backend function with the production settings a `vite build` upload gets, though the dev server runs under `NODE_ENV=development`, so it sends the bytes an upload would; `/__dd/debugBundle` returns that bundle, minified with names kept. Two cases still follow the dev server's `NODE_ENV`: package export conditions on Vite 5, which doesn't export its default server conditions, and Vite's stub for an optional peer dependency the app hasn't installed.

When Datadog rejects a bundle as longer than the `jsFunctionWithActions` `script` input accepts, the dev server answers with a 413 naming the function, its size and Datadog's answer, and warns once per function until the dev server restarts.

## Package output

A production `vite build` writes `datadog-app-assets.zip` beside the Vite output. The ZIP contains `frontend/`, `backend/`, and `manifest.json`. The app's identity is resolved by `@datadog/apps-cli` at deploy time.
Expand Down
14 changes: 0 additions & 14 deletions packages/plugins/apps/src/vite/build-backend-functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,6 @@ import { getBaseBackendBuildConfig } from './build-config';
const VIRTUAL_PREFIX = '\0dd-backend:';
export const BACKEND_OUT_DIR_PREFIX = 'dd-apps-backend-';

// Rolldown (Vite 8) output option that keeps function and class names when minifying. Rollup's
// types don't declare it, and Rollup-based Vite ignores it, so it is spread in rather than typed.
const ROLLDOWN_KEEP_NAMES = { keepNames: true } satisfies Record<string, unknown>;

/**
* Build all backend functions using a separate vite.build() call.
* Produces one standalone JS file per function in a temp directory.
Expand Down Expand Up @@ -74,17 +70,8 @@ export async function buildBackendFunctions(
// eslint-disable-next-line no-await-in-loop
const result = await viteBuild({
...baseConfig,
// Production only: dev-server bundles stay unminified so local traces keep line
// numbers. Function and class names are kept for function-log stack frames and
// code reading `.name`; frames still point at `1:<column>`.
//
// `minify: true` selects each Vite major's own minifier: esbuild before Vite 8,
// Oxc in Rolldown-based Vite 8, which only loads esbuild if the app installs it.
// Each reads its own keepNames option and ignores the other's.
esbuild: { keepNames: true },
build: {
...baseConfig.build,
minify: true,
write: true,
outDir,
emptyOutDir: false,
Expand All @@ -93,7 +80,6 @@ export async function buildBackendFunctions(
input: { [bundleName]: virtualId },
output: {
...baseConfig.build.rollupOptions.output,
...ROLLDOWN_KEEP_NAMES,
entryFileNames: '[name].js',
},
},
Expand Down
42 changes: 39 additions & 3 deletions packages/plugins/apps/src/vite/build-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,13 @@

import { outputFileSync, rmSync } from '@dd/core/helpers/fs';
import { getTempWorkingDir } from '@dd/tests/_jest/helpers/env';
import { build } from 'vite';
import { build, defaultServerConditions } from 'vite';

import { getBaseBackendBuildConfig } from './build-config';
import {
DEV_PROD_CONDITION,
getBaseBackendBuildConfig,
withProductionSettings,
} from './build-config';

describe('getBaseBackendBuildConfig', () => {
test('bundles a backend function that imports a real Node builtin module with a working import, not a browser-external stub', async () => {
Expand Down Expand Up @@ -58,7 +62,7 @@ describe('getBaseBackendBuildConfig', () => {
const code = chunk.type === 'chunk' ? chunk.code : '';

// The browser-external stub has no real exports and rewrites away the import specifier.
expect(code).toContain("from 'node:crypto'");
expect(code).toMatch(/from\s*["']node:crypto["']/);
expect(code).not.toContain('__vite-browser-external');
} finally {
rmSync(workingDir);
Expand Down Expand Up @@ -108,6 +112,8 @@ describe('getBaseBackendBuildConfig', () => {
...baseConfig,
build: {
...baseConfig.build,
// The minifier's own dead-code removal would otherwise hide a tree-shaking regression.
minify: false,
write: false,
rollupOptions: { ...baseConfig.build.rollupOptions, input: virtualId },
},
Expand Down Expand Up @@ -243,3 +249,33 @@ describe('getBaseBackendBuildConfig', () => {
}
});
});

describe('withProductionSettings', () => {
const baseConfig = getBaseBackendBuildConfig('/project', {});

test("Should resolve Vite's default server conditions to production", () => {
const config = withProductionSettings(baseConfig, defaultServerConditions);

const { resolve, ...ssr } = config.ssr ?? {};
const conditions = resolve?.conditions;
const otherConditions = defaultServerConditions.filter((c) => c !== DEV_PROD_CONDITION);
expect(defaultServerConditions).toContain(DEV_PROD_CONDITION);
expect(ssr).toEqual(baseConfig.ssr);
const expectedConditions = expect.arrayContaining(['production', ...otherConditions]);
expect(conditions).toEqual(expectedConditions);
expect(conditions).toHaveLength(defaultServerConditions.length);
expect(config.define).toEqual({
...baseConfig.define,
'import.meta.env.DEV': 'false',
'import.meta.env.PROD': 'true',
});
expect(config.esbuild).toEqual({ ...baseConfig.esbuild, jsxDev: false });
});

test("Should keep Vite's own conditions when it exports no defaults", () => {
const config = withProductionSettings(baseConfig, undefined);

expect(config.ssr).toEqual(baseConfig.ssr);
expect(config.esbuild).toEqual({ ...baseConfig.esbuild, jsxDev: false });
});
});
54 changes: 49 additions & 5 deletions packages/plugins/apps/src/vite/build-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,41 @@ import type { BuildOptions, InlineConfig, Plugin } from 'vite';

import { BACKEND_CODE_EXTENSIONS } from '../constants';

// Rolldown (Vite 8) output option that keeps function and class names when minifying. Rollup's
// types don't declare it, and Rollup-based Vite ignores it, so it is spread in rather than typed.
const ROLLDOWN_KEEP_NAMES = { keepNames: true } satisfies Record<string, unknown>;

export const DEV_PROD_CONDITION = 'development|production';

type BaseBackendBuildConfig = ReturnType<typeof getBaseBackendBuildConfig>;

/**
* Vite takes export conditions, `import.meta.env.DEV`/`PROD` and the JSX dev transform from
* `process.env.NODE_ENV`, which a dev server sets to development. Pinning them makes a bundle built
* there match a `vite build` upload, which runs under production. `serverConditions` is Vite's
* `defaultServerConditions`, which Vite 5 doesn't export; no option pins its missing-peer stub.
*/
export function withProductionSettings(
config: BaseBackendBuildConfig,
serverConditions: readonly string[] | undefined,
): BaseBackendBuildConfig {
const conditions = serverConditions?.map((condition) =>
condition === DEV_PROD_CONDITION ? 'production' : condition,
);
const resolve = conditions && { resolve: { ...config.ssr?.resolve, conditions } };
return {
...config,
define: {
...config.define,
'import.meta.env.DEV': 'false',
'import.meta.env.PROD': 'true',
},
// Vite 8 converts esbuild options to Oxc's, jsxDev included.
esbuild: { ...config.esbuild, jsxDev: false },
ssr: { ...config.ssr, ...resolve },
};
}

/**
* Create the virtual module resolver plugin used by both production and dev builds.
* Maps virtual IDs to their generated source content.
Expand All @@ -30,9 +65,9 @@ export function createVirtualPlugin(name: string, virtualEntries: Record<string,
}

/**
* Shared Vite/Rollup config for building backend functions.
* Both the production build (write to disk) and dev build (in-memory)
* use this as a base, overriding only what differs.
* Shared Vite/Rollup config for the backend bundles Datadog runs: uploads (written to disk) and
* dev-server cloud execution (in memory, through `withProductionSettings`), so both build alike.
* Local execution imports the source directly and never builds with it.
*/
export function getBaseBackendBuildConfig(
root: string,
Expand All @@ -52,15 +87,24 @@ export function getBaseBackendBuildConfig(
envPrefix: [],
root,
logLevel: 'silent',
// Names stay readable in function-log stack frames and to code reading `.name`. esbuild
// (before Vite 8) and Oxc (Vite 8) each read only their own keepNames option.
esbuild: { keepNames: true },
build: {
minify: false,
// Not 'esbuild', which Vite 8 (Rolldown) loads only if the app installs it.
minify: true,
target: 'esnext',
// Backend functions run server-side. Without this, Vite's default
// browser-target build externalizes Node builtins (node:crypto, fs)
// to a stub with no real exports.
ssr: true,
rollupOptions: {
output: { format: 'es', exports: 'named', inlineDynamicImports: true },
output: {
format: 'es',
exports: 'named',
inlineDynamicImports: true,
...ROLLDOWN_KEEP_NAMES,
},
preserveEntrySignatures: 'exports-only',
// Each exported function is bundled separately, so without tree-shaking every
// bundle carries the whole import graph of its `.backend.ts` file, including code
Expand Down
Loading
Loading