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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/plugins/apps/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ describe('Apps Plugin - getPlugins', () => {
const args = getArgs();
args.bundler = { build: viteBuild };
const plugins = getPlugins(args);
const transform = plugins[0].transform as {
const transform = plugins[0].vite?.transform as {
handler: (code: string, id: string) => unknown;
};
transform.handler.call(
Expand Down
282 changes: 5 additions & 277 deletions packages/plugins/apps/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,124 +2,22 @@
// This product includes software developed at Datadog (https://www.datadoghq.com/).
// Copyright 2019-Present Datadog, Inc.

import { rm } from '@dd/core/helpers/fs';
import type { GetPlugins } from '@dd/core/types';
import { InjectPosition } from '@dd/core/types';
import chalk from 'chalk';
import fsp from 'fs/promises';
import os from 'os';
import path from 'path';

import { createArchive } from './archive';
import type { Asset } from './assets';
import { collectAssets } from './assets';
import { extractExportedFunctions } from './backend/ast-parsing/extract-backend-functions';
import { extractConnectionIds } from './backend/ast-parsing/extract-connection-ids';
import { encodeQueryName } from './backend/encodeQueryName';
import { generateProxyModule } from './backend/proxy-codegen';
import type { BackendFunction } from './backend/types';
import { BACKEND_FILE_RE, CONFIG_KEY, PLUGIN_NAME } from './constants';
import { resolveIdentifier } from './identifier';
import type { AppsManifest, AppsOptions } from './types';
import { uploadArchive } from './upload';
import { CONFIG_KEY, PLUGIN_NAME } from './constants';
import type { AppsOptions } from './types';
import { validateOptions } from './validate';
import { getVitePlugin } from './vite/index';

export { CONFIG_KEY, PLUGIN_NAME };

/**
* Build BackendFunction entries from discovered export names and generate
* the frontend proxy module that replaces the original backend code.
*/
function buildProxyModule(
exportNames: string[],
id: string,
buildRoot: string,
allowedConnectionIds: string[],
): { functions: BackendFunction[]; proxyCode: string } {
const relativePath = path.relative(buildRoot, id);
const refPath = relativePath.replace(BACKEND_FILE_RE, '');

const functions: BackendFunction[] = [];
const proxyExports: Array<{ exportName: string; queryName: string }> = [];

for (const exportName of exportNames) {
const func = {
relativePath: refPath,
name: exportName,
absolutePath: id,
allowedConnectionIds,
};
functions.push(func);
proxyExports.push({ exportName, queryName: encodeQueryName(func) });
}

return { functions, proxyCode: generateProxyModule(proxyExports) };
}

const yellow = chalk.yellow.bold;
const red = chalk.red.bold;
const MANIFEST_FILE_NAME = 'manifest.json';

/**
* Create a registry for tracking discovered backend functions.
* Uses a Map keyed by entryPath so that re-transforms (e.g. during HMR)
* replace stale entries for a file instead of appending duplicates.
*/
function createBackendFunctionRegistry() {
const functionsByEntryPath = new Map<string, BackendFunction[]>();

return {
/** Replace all entries for a given file. Handles HMR re-transforms. */
setBackendFunctions(entryPath: string, functions: BackendFunction[]) {
functionsByEntryPath.set(entryPath, functions);
},
/** Get a flat array of all currently registered backend functions. */
getBackendFunctions(): BackendFunction[] {
return Array.from(functionsByEntryPath.values()).flat();
},
};
}

function buildManifest(backendFunctions: BackendFunction[]): AppsManifest {
const functions: AppsManifest['backend']['functions'] = {};
for (const func of backendFunctions) {
functions[encodeQueryName(func)] = {
allowedConnectionIds: [...func.allowedConnectionIds],
};
}
return { backend: { functions } };
}

async function writeManifestFile(backendFunctions: BackendFunction[]): Promise<{
manifestAsset: Asset;
cleanup: () => Promise<void>;
}> {
const manifestDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'dd-apps-manifest-'));
const manifestPath = path.join(manifestDir, MANIFEST_FILE_NAME);
try {
await fsp.writeFile(manifestPath, JSON.stringify(buildManifest(backendFunctions), null, 2));
} catch (error) {
await rm(manifestDir);
throw error;
}
return {
manifestAsset: {
absolutePath: manifestPath,
relativePath: MANIFEST_FILE_NAME,
},
cleanup: () => rm(manifestDir),
};
}

export type types = {
// Add the types you'd like to expose here.
AppsOptions: AppsOptions;
};

export const getPlugins: GetPlugins = ({ options, context, bundler }) => {
const log = context.getLogger(PLUGIN_NAME);
let toThrow: Error | undefined;
const validatedOptions = validateOptions(options);
if (!validatedOptions.enable) {
return [];
Expand All @@ -130,186 +28,16 @@ export const getPlugins: GetPlugins = ({ options, context, bundler }) => {
return [];
}

// Inject the runtime that `globalThis.DD_APPS_RUNTIME.executeBackendFunction`
// is read from. The generated proxy modules (emitted by the transform hook
// below) reference that global. NOTE: This file is built alongside the
// bundler plugin via the `toBuild` entry in @dd/apps-plugin's package.json.
//
// Position MIDDLE is used instead of BEFORE so Vite's dev server injects
// the runtime as a <script type="module"> via `transformIndexHtml` — BEFORE
// is served via Rollup's `banner()` output hook which only fires at build
// time, leaving the runtime undefined during `vite` (dev).
context.inject({
type: 'file',
position: InjectPosition.MIDDLE,
value: path.join(__dirname, './apps-runtime.mjs'),
});

const { setBackendFunctions, getBackendFunctions } = createBackendFunctionRegistry();

const handleUpload = async (backendOutputs: Map<string, string>) => {
const handleTimer = log.time('handle assets');
let archiveDir: string | undefined;
let cleanupManifest: (() => Promise<void>) | undefined;
try {
const identifierTimer = log.time('resolve identifier');

const { name, identifier } = resolveIdentifier(context.buildRoot, log, {
url: context.git?.remote,
name: validatedOptions.name,
identifier: validatedOptions.identifier,
});

if (!identifier || !name) {
throw new Error(`Missing apps identification.
Either:
- pass an 'options.apps.identifier' and 'options.apps.name' to your plugin's configuration.
- have a 'name' and a 'repository' in your 'package.json'.
- have a valid remote url on your git project.
`);
}
identifierTimer.end();

const relativeOutdir = path.relative(context.buildRoot, context.bundler.outDir);
const assetGlobs = [...validatedOptions.include, `${relativeOutdir}/**/*`];

const assets = await collectAssets(assetGlobs, context.buildRoot);

if (!assets.length) {
log.debug(`No assets to upload.`);
return;
}

// Exclude backend output files from frontend assets.
const backendPaths = new Set(backendOutputs.values());
const frontendOnly = assets.filter((a) => !backendPaths.has(a.absolutePath));

// Prefix all frontend assets with frontend/.
// Use POSIX joins — archive entries must use forward slashes.
const allAssets: Asset[] = frontendOnly.map((asset) => ({
...asset,
relativePath: `frontend/${asset.relativePath}`,
}));

// Append backend assets from the outputs map populated during the build.
// Keys are encoded query names ({hash(path)}.{name}).
for (const [bundleName, absolutePath] of backendOutputs) {
allAssets.push({
absolutePath,
relativePath: `backend/${bundleName}.js`,
});
}

const backendFunctions = getBackendFunctions();
const { manifestAsset, cleanup } = await writeManifestFile(backendFunctions);
cleanupManifest = cleanup;
allAssets.push(manifestAsset);

const archiveTimer = log.time('archive assets');
const archive = await createArchive(allAssets);
archiveTimer.end();
// Store variable for later disposal of directory.
archiveDir = path.dirname(archive.archivePath);

const uploadTimer = log.time('upload assets');
const { errors: uploadErrors, warnings: uploadWarnings } = await uploadArchive(
archive,
{
apiKey: context.auth.apiKey,
appKey: context.auth.appKey,
bundlerName: context.bundler.name,
dryRun: validatedOptions.dryRun,
identifier,
name,
site: context.auth.site,
version: context.version,
},
log,
);
uploadTimer.end();

if (uploadWarnings.length > 0) {
log.warn(
`${yellow('Warnings while uploading assets:')}\n - ${uploadWarnings.join('\n - ')}`,
);
}

if (uploadErrors.length > 0) {
const listOfErrors = uploadErrors
.map((error) => error.cause || error.stack || error.message || error)
.join('\n - ');
throw new Error(` - ${listOfErrors}`);
}
} catch (error: any) {
toThrow = error;
log.error(`${red('Failed to upload assets:')}\n${error?.message || error}`);
}

// Clean temporary directory
if (archiveDir) {
await rm(archiveDir);
}
if (cleanupManifest) {
await cleanupManifest();
}
handleTimer.end();

if (toThrow) {
// Break the build.
throw toThrow;
}
};

// All build + upload logic is handled inside the Vite sub-plugin's closeBundle.
// When backend functions exist, it builds them first, then uploads everything.
return [
{
name: PLUGIN_NAME,
enforce: 'post',
transform: {
filter: {
id: {
include: [BACKEND_FILE_RE],
exclude: [/node_modules/, /[/\\]dist[/\\]/],
},
},
// For each .backend.* file, parse its named exports, register
// them as backend functions, and replace the module with a
// frontend proxy that calls executeBackendFunction at runtime.
handler(code, id) {
const ast = this.parse(code);
const exportNames = extractExportedFunctions(ast, id);
if (exportNames.length === 0) {
log.warn(
`Backend file ${id} has no exported functions. ` +
`Did you forget to add a named export?`,
);
// Clear any previously registered functions for this file
// so stale entries don't persist across HMR re-transforms.
setBackendFunctions(id, []);
return { code: '', map: null };
}

const allowedConnectionIds = extractConnectionIds(ast, id);
const { functions, proxyCode } = buildProxyModule(
exportNames,
id,
context.buildRoot,
allowedConnectionIds,
);
setBackendFunctions(id, functions);
log.debug(`Generated proxy for ${id} with ${functions.length} export(s)`);

return { code: proxyCode, map: null };
},
},
vite: getVitePlugin({
viteBuild: bundler.build,
buildRoot: context.buildRoot,
getBackendFunctions,
handleUpload,
log,
auth: context.auth,
bundler,
context,
options: validatedOptions,
}),
},
];
Expand Down
Loading
Loading