diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index bdae1550f..f01d53b81 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -29,3 +29,6 @@ packages/plugins/analytics @yoannmoin
# Custom Hooks
packages/plugins/custom-hooks @yoannmoinet
+
+# Synthetics
+packages/plugins/synthetics @yoannmoinet @DataDog/synthetics-ct
diff --git a/README.md b/README.md
index de30e57ab..725f6ae4b 100644
--- a/README.md
+++ b/README.md
@@ -29,6 +29,7 @@ To interact with Datadog directly from your builds.
- [`customPlugins`](#customplugins)
- [Features](#features)
- [Error Tracking](#error-tracking-----)
+ - [Synthetics](#synthetics-----)
- [Telemetry](#telemetry-----)
- [Contributing](#contributing)
- [License](#license)
@@ -103,6 +104,9 @@ Follow the specific documentation for each bundler:
service: string;
};
};
+ synthetics?: {
+ disabled?: boolean;
+ };
telemetry?: {
disabled?: boolean;
enableTracing?: boolean;
@@ -246,6 +250,26 @@ datadogWebpackPlugin({
+### Synthetics
+
+> Interact with Synthetics at build time.
+
+#### [📝 Full documentation ➡️](/packages/plugins/synthetics#readme)
+
+
+
+Configuration
+
+```typescript
+datadogWebpackPlugin({
+ synthetics?: {
+ disabled?: boolean,
+ }
+});
+```
+
+
+
### Telemetry
> Display and send telemetry data as metrics to Datadog.
diff --git a/global.d.ts b/global.d.ts
index 1be630767..4a289433c 100644
--- a/global.d.ts
+++ b/global.d.ts
@@ -19,6 +19,16 @@ declare global {
* For instance, we only submit logs to Datadog when the environment is `production`.
*/
BUILD_PLUGINS_ENV?: Env;
+ /**
+ * The port of the dev server of our synthetics plugin.
+ *
+ * This is only used by datadog-ci, in its build-and-test command,
+ * using the customer's build command, if it includes our plugin,
+ * will launch a dev server to serve the outdir of the build so datadog-ci
+ * can trigger a CI batch over the branch's code using a tunnel.
+ *
+ */
+ BUILD_PLUGINS_S8S_PORT?: string;
/**
* Defined in github actions when running in CI.
*/
diff --git a/packages/core/src/helpers/server.ts b/packages/core/src/helpers/server.ts
index 8a897aa99..aae3275d8 100644
--- a/packages/core/src/helpers/server.ts
+++ b/packages/core/src/helpers/server.ts
@@ -24,7 +24,7 @@ type File = {
content: string;
};
type RouteVerb = 'get' | 'post' | 'put' | 'patch' | 'delete';
-type Routes = Record<
+export type Routes = Record<
string,
Partial<
Record<
@@ -33,13 +33,13 @@ type Routes = Record<
>
>
>;
-type Response = {
+export type Response = {
statusCode: number;
headers: Record;
body: string;
error?: Error;
};
-type RunServerOptions = {
+export type RunServerOptions = {
port: number;
root: string;
routes?: Routes;
@@ -76,8 +76,8 @@ export const prepareFile = async (root: string, requestUrl: string): Promise {
- const server = http.createServer(async (req, res) => {
+export const getServer = ({ root, middleware, routes }: Omit) => {
+ return http.createServer(async (req, res) => {
const response: Response = {
statusCode: 200,
headers: {},
@@ -125,7 +125,10 @@ export const runServer = ({ port, root, middleware, routes }: RunServerOptions)
res.writeHead(response.statusCode, response.headers);
res.end(response.body);
});
+};
+export const runServer = ({ port, root, middleware, routes }: RunServerOptions) => {
+ const server = getServer({ root, middleware, routes });
server.listen(port);
return server;
};
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index 1cd1d9d80..4121672c1 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -13,6 +13,8 @@ import type { ErrorTrackingOptions } from '@dd/error-tracking-plugin/types';
import type * as errorTracking from '@dd/error-tracking-plugin';
import type { RumOptions } from '@dd/rum-plugin/types';
import type * as rum from '@dd/rum-plugin';
+import type { SyntheticsOptions } from '@dd/synthetics-plugin/types';
+import type * as synthetics from '@dd/synthetics-plugin';
import type { TelemetryOptions } from '@dd/telemetry-plugin/types';
import type * as telemetry from '@dd/telemetry-plugin';
// #imports-injection-marker
@@ -22,8 +24,11 @@ import type { UnpluginOptions } from 'unplugin';
import type { ALL_ENVS, FULL_NAME_BUNDLERS, SUPPORTED_BUNDLERS } from './constants';
+// Re-assign B into A.
export type Assign = Omit & B;
-export type WithRequired = T & { [P in K]-?: T[P] };
+// Type object with specified keys required.
+export type Ensure = Assign>>;
+// Target one item from an iterable.
export type IterableElement> =
IterableType extends Iterable ? ElementType : never;
@@ -197,6 +202,7 @@ export interface Options extends BaseOptions {
// #types-injection-marker
[errorTracking.CONFIG_KEY]?: ErrorTrackingOptions;
[rum.CONFIG_KEY]?: RumOptions;
+ [synthetics.CONFIG_KEY]?: SyntheticsOptions;
[telemetry.CONFIG_KEY]?: TelemetryOptions;
// #types-injection-marker
customPlugins?: GetCustomPlugins;
diff --git a/packages/factory/package.json b/packages/factory/package.json
index a6c97eea5..a3270ce94 100644
--- a/packages/factory/package.json
+++ b/packages/factory/package.json
@@ -25,6 +25,7 @@
"@dd/internal-git-plugin": "workspace:*",
"@dd/internal-injection-plugin": "workspace:*",
"@dd/rum-plugin": "workspace:*",
+ "@dd/synthetics-plugin": "workspace:*",
"@dd/telemetry-plugin": "workspace:*",
"chalk": "2.3.1",
"unplugin": "1.16.0"
diff --git a/packages/factory/src/index.ts b/packages/factory/src/index.ts
index 9c203c823..2b3d348ec 100644
--- a/packages/factory/src/index.ts
+++ b/packages/factory/src/index.ts
@@ -28,6 +28,7 @@ import { getContext, validateOptions } from './helpers';
// #imports-injection-marker
import * as errorTracking from '@dd/error-tracking-plugin';
import * as rum from '@dd/rum-plugin';
+import * as synthetics from '@dd/synthetics-plugin';
import * as telemetry from '@dd/telemetry-plugin';
import { getAnalyticsPlugins } from '@dd/internal-analytics-plugin';
import { getBuildReportPlugins } from '@dd/internal-build-report-plugin';
@@ -39,6 +40,7 @@ import { getInjectionPlugins } from '@dd/internal-injection-plugin';
// #types-export-injection-marker
export type { types as ErrorTrackingTypes } from '@dd/error-tracking-plugin';
export type { types as RumTypes } from '@dd/rum-plugin';
+export type { types as SyntheticsTypes } from '@dd/synthetics-plugin';
export type { types as TelemetryTypes } from '@dd/telemetry-plugin';
// #types-export-injection-marker
@@ -102,6 +104,7 @@ export const buildPluginFactory = ({
// #configs-injection-marker
errorTracking,
rum,
+ synthetics,
telemetry,
// #configs-injection-marker
];
diff --git a/packages/plugins/synthetics/README.md b/packages/plugins/synthetics/README.md
new file mode 100644
index 000000000..82b7d2858
--- /dev/null
+++ b/packages/plugins/synthetics/README.md
@@ -0,0 +1,27 @@
+# Synthetics Plugin
+
+Interact with Synthetics at build time.
+
+
+
+## Table of content
+
+
+
+
+- [Configuration](#configuration)
+- [Build and test](#build-and-test)
+
+
+## Configuration
+
+```ts
+synthetics?: {
+ disabled?: boolean;
+}
+```
+
+## Build and test
+
+Using [`datadog-ci`'s `synthetics build-and-test` command](https://github.com/DataDog/datadog-ci/tree/master/src/commands/synthetics#run-tests-command),
+you can have the build spin a dev server to serve the outdir of the build in order [to trigger a CI batch](https://docs.datadoghq.com/continuous_testing/) over the branch's code.
diff --git a/packages/plugins/synthetics/package.json b/packages/plugins/synthetics/package.json
new file mode 100644
index 000000000..52997ed3b
--- /dev/null
+++ b/packages/plugins/synthetics/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "@dd/synthetics-plugin",
+ "packageManager": "yarn@4.0.2",
+ "license": "MIT",
+ "private": true,
+ "author": "Datadog",
+ "description": "Interact with Synthetics at build time.",
+ "homepage": "https://github.com/DataDog/build-plugins/tree/main/packages/plugins/synthetics#readme",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/DataDog/build-plugins",
+ "directory": "packages/plugins/synthetics"
+ },
+ "exports": {
+ ".": "./src/index.ts",
+ "./*": "./src/*.ts"
+ },
+ "scripts": {
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@dd/core": "workspace:*",
+ "@dd/error-tracking-plugin": "workspace:*",
+ "chalk": "2.3.1"
+ }
+}
diff --git a/packages/plugins/synthetics/src/constants.ts b/packages/plugins/synthetics/src/constants.ts
new file mode 100644
index 000000000..541a2d603
--- /dev/null
+++ b/packages/plugins/synthetics/src/constants.ts
@@ -0,0 +1,10 @@
+// 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 { PluginName } from '@dd/core/types';
+
+export const CONFIG_KEY = 'synthetics' as const;
+export const PLUGIN_NAME: PluginName = 'datadog-synthetics-plugin' as const;
+
+export const API_PREFIX = '_datadog-ci_' as const;
diff --git a/packages/plugins/synthetics/src/helpers/server.ts b/packages/plugins/synthetics/src/helpers/server.ts
new file mode 100644
index 000000000..abffdf2b6
--- /dev/null
+++ b/packages/plugins/synthetics/src/helpers/server.ts
@@ -0,0 +1,209 @@
+// 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 { doRequest } from '@dd/core/helpers/request';
+import type { Routes } from '@dd/core/helpers/server';
+import { getServer } from '@dd/core/helpers/server';
+import type { Options, PluginOptions, Logger } from '@dd/core/types';
+import { CONFIG_KEY as ERROR_TRACKING } from '@dd/error-tracking-plugin';
+import type { Server } from 'http';
+import url from 'url';
+
+import { API_PREFIX, PLUGIN_NAME } from '../constants';
+import type { ServerResponse, SyntheticsOptionsWithDefaults } from '../types';
+
+const identifier = 'Server Running from the Datadog Synthetics Plugin';
+const triedPorts: number[] = [];
+export const getPort = (): number => {
+ const MIN_PORT = 49152;
+ const MAX_PORT = 65535;
+ const port = Math.floor(Math.random() * (MAX_PORT - MIN_PORT + 1)) + MIN_PORT;
+ if (triedPorts.includes(port)) {
+ return getPort();
+ }
+ triedPorts.push(port);
+ return port;
+};
+
+const killServer = (server?: Server) => {
+ if (!server) {
+ return;
+ }
+ server.close();
+ server.closeAllConnections();
+ server.closeIdleConnections();
+};
+
+// Keep it global to avoid creating a new server on each run.
+let SERVER: Server | undefined;
+const RUNNING_SERVERS: number[] = [];
+
+const verifyServer = async (
+ server: Server,
+ port: number,
+ cb: (portUsed: number) => Promise | void,
+): Promise => {
+ // Listen for errors.
+ const errorListener = async (e: any) => {
+ server.removeListener('listening', successListener);
+ // If the port is already in use.
+ if (e.code === 'EADDRINUSE') {
+ // Verify if another instance of the plugin is running on this port.
+ const resp = await doRequest({
+ url: `http://127.0.0.1:${port}/${API_PREFIX}/build-status`,
+ retries: 0,
+ type: 'json',
+ });
+
+ if (resp.identifier === identifier) {
+ // Another instance of the plugin is running on this port.
+ // Instrument the other server so we can piggyback on it.
+ return verifyServer(server, getPort(), cb);
+ } else {
+ // We have something else running here.
+ // Throw an error as the feature can't work.
+ throw new Error(`Something else is running on port ${port}.`);
+ }
+ } else {
+ // Something else happened.
+ // Forward the error.
+ throw e;
+ }
+ };
+
+ const successListener = async () => {
+ // Remove the error listener.
+ server.removeListener('error', errorListener);
+ // Callback to communicate the port used.
+ await cb(port);
+ };
+
+ server.once('error', errorListener);
+ server.once('listening', successListener);
+ server.listen(port);
+};
+
+const setupMasterServer = (routes: Routes, log: Logger, runningServers: number[]) => {
+ // The master server should forward file request to its sub servers.
+ // Could use middleware to do this.
+ // It should also have a register route.
+ routes[`/${API_PREFIX}/register`] = {
+ get: (req, res) => {
+ const sendError = (message: string) => {
+ res.writeHead(400, { 'Content-Type': 'text/html' });
+ res.end(message);
+ };
+
+ if (!req.url) {
+ return sendError('Missing URL.');
+ }
+
+ const query = url.parse(req.url, true).query;
+ if (!query) {
+ return sendError('Missing query.');
+ }
+
+ if (!query.port) {
+ return sendError('Missing port.');
+ }
+
+ log.debug(`Registering port ${query.port} to the master server.`);
+ const portsToRegister = Array.isArray(query.port) ? query.port : [query.port];
+ runningServers.push(...portsToRegister.map(Number));
+
+ res.writeHead(200, { 'Content-Type': 'text/html' });
+ res.end('ok');
+ },
+ };
+};
+
+const setupSubServer = async (masterPort: number, port: number) => {
+ // The sub server should register to the master server.
+ await doRequest({
+ url: `http://127.0.0.1:${masterPort}/${API_PREFIX}/register?port=${port}`,
+ });
+};
+
+export const getServerPlugin = (
+ opts: Options,
+ options: SyntheticsOptionsWithDefaults,
+ log: Logger,
+): PluginOptions => {
+ // This is the mutable response the server will use to report the build's status.
+ const response: ServerResponse = {
+ publicPath: opts[ERROR_TRACKING]?.sourcemaps?.minifiedPathPrefix,
+ status: 'running',
+ identifier,
+ };
+
+ const routes: Routes = {
+ // TODO: The master server should forward file request to its sub servers.
+ // Could use a special __catch_all__, "/*" or "/" route.
+ // Route to get the build status.
+ [`/${API_PREFIX}/build-status`]: {
+ get: (req, res) => {
+ // TODO: Status should be based on all the running servers.
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify(response));
+ },
+ },
+ // Route to kill the server.
+ [`/${API_PREFIX}/kill`]: {
+ get: (req, res) => {
+ // TODO: Kill all the sub-servers if we're the master server.
+ res.writeHead(200, { 'Content-Type': 'text/html' });
+ res.end('ok');
+ // kill kill kill.
+ killServer(SERVER);
+ },
+ },
+ };
+
+ return {
+ name: PLUGIN_NAME,
+ // Wait for us to have the bundler report to start the server over the outDir.
+ bundlerReport(bundlerReport) {
+ response.outDir = bundlerReport.outDir;
+ if (options.server?.run && !SERVER) {
+ const port = options.server.port;
+
+ // Only create the server first.
+ SERVER = getServer({
+ root: response.outDir,
+ routes,
+ });
+
+ try {
+ verifyServer(SERVER, port, async (portUsed) => {
+ if (portUsed === port) {
+ log.debug(`Setting up master server on port ${portUsed}.`);
+ setupMasterServer(routes, log, RUNNING_SERVERS);
+ } else {
+ log.debug(`Setting up sub server on port ${portUsed}.`);
+ await setupSubServer(port, portUsed);
+ }
+ });
+ } catch (e) {
+ log.error(`Error starting Synthetics local server: ${e}`);
+ }
+ }
+ },
+ buildReport(buildReport) {
+ if (buildReport.errors.length) {
+ response.status = 'fail';
+ }
+ },
+ writeBundle() {
+ response.status = 'success';
+ },
+ esbuild: {
+ setup(build) {
+ build.onDispose(() => {
+ // We kill the plugin when the build is disposed.
+ killServer(SERVER);
+ });
+ },
+ },
+ };
+};
diff --git a/packages/plugins/synthetics/src/index.test.ts b/packages/plugins/synthetics/src/index.test.ts
new file mode 100644
index 000000000..5f3acd51a
--- /dev/null
+++ b/packages/plugins/synthetics/src/index.test.ts
@@ -0,0 +1,314 @@
+// 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 { runServer } from '@dd/core/helpers/server';
+import { getContext } from '@dd/factory/helpers';
+import { API_PREFIX } from '@dd/synthetics-plugin/constants';
+import type { ServerResponse } from '@dd/synthetics-plugin/types';
+import { getPlugins } from '@dd/synthetics-plugin';
+import { getContextMock, mockLogFn, mockLogger } from '@dd/tests/_jest/helpers/mocks';
+import { BUNDLERS, runBundlers } from '@dd/tests/_jest/helpers/runBundlers';
+import fs from 'fs';
+import nock from 'nock';
+import path from 'path';
+
+jest.mock('@dd/core/helpers/server', () => {
+ const original = jest.requireActual('@dd/core/helpers/server');
+ return {
+ ...original,
+ runServer: jest.fn(original.runServer),
+ };
+});
+
+jest.mock('@dd/factory/helpers', () => {
+ const original = jest.requireActual('@dd/factory/helpers');
+ return {
+ ...original,
+ getContext: jest.fn(original.getContext),
+ };
+});
+
+const runServerMocked = jest.mocked(runServer);
+const getContextMocked = jest.mocked(getContext);
+
+// Get an incremental port to prevent conflicts.
+let PORT = 1234;
+const getPort = () => PORT++;
+const getApiUrl = (port: number = getPort()) => `http://127.0.0.1:${port}`;
+const getInternalApiUrl = (port: number = getPort()) => `${getApiUrl(port)}/${API_PREFIX}`;
+const safeFetch = async (route: string, port: number) => {
+ try {
+ return await fetch(`${getInternalApiUrl(port)}${route}`);
+ } catch (e) {
+ // Do nothing.
+ }
+};
+
+// Wait for the local server to tell us that the build is complete.
+const waitingForBuild = (port: number, cb: (resp: ServerResponse) => void) => {
+ return new Promise((resolve, reject) => {
+ // Stop the polling after 10 seconds.
+ const timeout = setTimeout(() => {
+ clearInterval(interval);
+ reject(new Error('Timeout.'));
+ }, 10000);
+
+ // Poll all the local servers until we get the build status.
+ const interval = setInterval(async () => {
+ const res = await safeFetch('/build-status', port);
+ if (res?.ok) {
+ const data = (await res.json()) as ServerResponse;
+ cb(data);
+
+ if (['success', 'fail'].includes(data.status)) {
+ clearInterval(interval);
+ clearTimeout(timeout);
+
+ if (data.status === 'success') {
+ resolve();
+ }
+ if (data.status === 'fail') {
+ reject(new Error('Build failed.'));
+ }
+ }
+ }
+ }, 100);
+ });
+};
+
+describe('Synthetics Plugin', () => {
+ describe('getPlugins', () => {
+ test('Should not initialize the plugin if disabled', async () => {
+ expect(getPlugins({ synthetics: { disabled: true } }, getContextMock())).toHaveLength(
+ 0,
+ );
+ });
+
+ test('Should initialize the plugin if enabled and not configured', async () => {
+ expect(
+ getPlugins({ synthetics: { disabled: false } }, getContextMock()).length,
+ ).toBeGreaterThan(0);
+ expect(getPlugins({}, getContextMock()).length).toBeGreaterThan(0);
+ });
+ });
+
+ describe('Server', () => {
+ beforeAll(() => {
+ // Allow local server.
+ nock.enableNetConnect('127.0.0.1');
+ });
+
+ afterAll(() => {
+ nock.cleanAll();
+ nock.disableNetConnect();
+ });
+
+ describe('to run or not to run', () => {
+ afterEach(async () => {
+ // Remove the variable we may have set.
+ delete process.env.BUILD_PLUGINS_S8S_PORT;
+ });
+
+ const expectations = [
+ {
+ description: 'not run with no env',
+ env: {},
+ config: {},
+ triggers: 1,
+ expectedNumberOfCalls: 0,
+ },
+ {
+ description: 'run with port in env',
+ env: {
+ BUILD_PLUGINS_S8S_PORT: JSON.stringify(getPort()),
+ },
+ config: {},
+ triggers: 1,
+ expectedNumberOfCalls: 1,
+ },
+ {
+ description: 'not run with disabled and port in env',
+ env: {
+ BUILD_PLUGINS_S8S_PORT: JSON.stringify(getPort()),
+ },
+ config: {
+ synthetics: {
+ disabled: true,
+ },
+ },
+ triggers: 1,
+ expectedNumberOfCalls: 0,
+ },
+ {
+ description: 'not run more than once',
+ env: {
+ BUILD_PLUGINS_S8S_PORT: JSON.stringify(getPort()),
+ },
+ config: {},
+ triggers: 3,
+ expectedNumberOfCalls: 1,
+ },
+ ];
+
+ test.each(expectations)(
+ 'Should $description.',
+ async ({ config, env, expectedNumberOfCalls, triggers }) => {
+ // Set the variables.
+ Object.assign(process.env, env);
+
+ // Run the plugin.
+ const [plugin] = getPlugins(config, getContextMock());
+ if (plugin?.bundlerReport) {
+ // Trigger the bundlerReport hook where the server starts.
+ for (let i = 0; i < triggers; i++) {
+ plugin.bundlerReport(getContextMock().bundler);
+ }
+ }
+
+ // Check the server.
+ expect(runServerMocked).toHaveBeenCalledTimes(expectedNumberOfCalls);
+
+ // No error should have been logged.
+ expect(mockLogFn).not.toHaveBeenCalledWith(
+ expect.stringContaining('Error starting Synthetics local server'),
+ 'error',
+ );
+
+ if (env.BUILD_PLUGINS_S8S_PORT) {
+ // Kill the server.
+ await safeFetch('/kill', Number(env.BUILD_PLUGINS_S8S_PORT));
+ }
+ },
+ );
+
+ test('Should not throw if the server fails to start.', async () => {
+ // Set the variables.
+ Object.assign(process.env, {
+ BUILD_PLUGINS_S8S_PORT: JSON.stringify(getPort()),
+ });
+
+ // Make `runServer` throw an error.
+ runServerMocked.mockImplementationOnce(() => {
+ throw new Error('Not available.');
+ });
+
+ // Run the plugin.
+ const [plugin] = getPlugins({ synthetics: { disabled: false } }, getContextMock());
+ if (plugin?.bundlerReport) {
+ plugin.bundlerReport(getContextMock().bundler);
+ }
+
+ expect(runServerMocked).toHaveBeenCalledTimes(1);
+ expect(mockLogFn).toHaveBeenCalledWith(
+ expect.stringContaining('Not available'),
+ 'error',
+ );
+ });
+ });
+
+ // We need to loop over bundlers because we'll use a different port for each one of them
+ // to avoid port conflicts.
+ describe.each(BUNDLERS)('$name', (bundler) => {
+ const port = getPort();
+
+ let buildProm: Promise;
+ let outDir: string;
+ const serverResponses: Set = new Set();
+
+ beforeEach(async () => {
+ // Set the variables.
+ Object.assign(process.env, {
+ BUILD_PLUGINS_S8S_PORT: JSON.stringify(port),
+ });
+
+ // Mock the getPlugins function to mock the context.
+ getContextMocked.mockImplementation((opts) => {
+ const original = jest.requireActual('@dd/factory/helpers');
+ const mockContext = original.getContext(opts);
+ mockContext.getLogger = jest.fn(() => mockLogger);
+ return mockContext;
+ });
+
+ // Run the builds.
+ // Do not await the promise as the server will be running.
+ buildProm = runBundlers(
+ {
+ // Use a custom plugin to get the cwd and outdir of the build.
+ customPlugins: () => [
+ {
+ name: 'get-outdirs',
+ bundlerReport: (report) => {
+ outDir = report.outDir;
+ },
+ },
+ ],
+ },
+ undefined,
+ [bundler.name],
+ );
+
+ // Instead, wait for the server to tell us that the build is complete.
+ await waitingForBuild(port, (resp) => {
+ serverResponses.add(resp);
+ });
+ });
+
+ afterEach(async () => {
+ // Remove the variable we may have set.
+ delete process.env.BUILD_PLUGINS_S8S_PORT;
+ // Kill the server.
+ await safeFetch('/kill', port);
+ // Wait for the build to finish now that the server is killed.
+ if (buildProm) {
+ await buildProm;
+ }
+ });
+
+ // NOTE: Due to how mocks reset and the beforeEach/afterEach, we need to have only one test.
+ test('Should respond as expected.', async () => {
+ // Build status.
+ // Verify that we have the running and success statuses.
+ const reportedStatus = Array.from(serverResponses).filter((resp) =>
+ ['fail', 'success', 'running'].includes(resp.status),
+ );
+ expect(reportedStatus.length).toBeGreaterThan(0);
+
+ // Outdir.
+ // Verify that we have the running and success statuses.
+ const reportedOutDirs = new Set(
+ Array.from(serverResponses).map((resp) => resp.outDir),
+ );
+ // We should have only one outDir.
+ expect(reportedOutDirs.size).toBe(1);
+ // It should be the same as the one we reported from the build.
+ expect(reportedOutDirs.values().next().value).toEqual(outDir);
+
+ // Built files.
+ // Query a file from the server.
+ const res = await fetch(`${getApiUrl(port)}/main.js`);
+ expect(res.ok).toBe(true);
+ const text = await res.text();
+ // Confirm that the file served by the server is the same as the one on disk.
+ expect(text).toEqual(fs.readFileSync(path.join(outDir, 'main.js'), 'utf-8'));
+
+ // Only one log should have been called.
+ expect(mockLogFn).toHaveBeenCalledWith(
+ expect.stringContaining('Starting Synthetics local server'),
+ 'debug',
+ );
+ const nbCalls = mockLogFn.mock.calls.filter(
+ ([message, level]) =>
+ message.includes('Starting Synthetics local server') && level === 'debug',
+ ).length;
+ expect(nbCalls).toBe(1);
+
+ // No error should have been logged.
+ expect(mockLogFn).not.toHaveBeenCalledWith(
+ expect.stringContaining('Error starting Synthetics local server'),
+ 'error',
+ );
+ });
+ });
+ });
+});
diff --git a/packages/plugins/synthetics/src/index.ts b/packages/plugins/synthetics/src/index.ts
new file mode 100644
index 000000000..98a3d73d7
--- /dev/null
+++ b/packages/plugins/synthetics/src/index.ts
@@ -0,0 +1,29 @@
+// 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 { GlobalContext, GetPlugins, Options } from '@dd/core/types';
+
+import { CONFIG_KEY, PLUGIN_NAME } from './constants';
+import { getServerPlugin } from './helpers/server';
+import type { SyntheticsOptions } from './types';
+import { validateOptions } from './validate';
+
+export { CONFIG_KEY, PLUGIN_NAME };
+
+export type types = {
+ // Add the types you'd like to expose here.
+ SyntheticsOptions: SyntheticsOptions;
+};
+
+export const getPlugins: GetPlugins = (opts: Options, context: GlobalContext) => {
+ const log = context.getLogger(PLUGIN_NAME);
+ // Verify configuration.
+ const options = validateOptions(opts);
+
+ if (options.disabled) {
+ return [];
+ }
+
+ return [getServerPlugin(opts, options, log)];
+};
diff --git a/packages/plugins/synthetics/src/types.ts b/packages/plugins/synthetics/src/types.ts
new file mode 100644
index 000000000..a7dbb554d
--- /dev/null
+++ b/packages/plugins/synthetics/src/types.ts
@@ -0,0 +1,32 @@
+// 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 { Assign, Ensure } from '@dd/core/types';
+
+export type ServerOptions = {
+ port: number;
+ run: boolean;
+};
+
+export type SyntheticsOptions = {
+ disabled?: boolean;
+};
+
+export type SyntheticsOptionsWithDefaults = Assign<
+ Ensure,
+ {
+ server?: ServerOptions;
+ }
+>;
+
+export type BuildStatus = 'running' | 'success' | 'fail';
+export type ServerResponse = {
+ outDir?: string;
+ publicPath?: string;
+ status: BuildStatus;
+ /**
+ * Used to identify the server in case of multiple instances.
+ */
+ identifier: string;
+};
diff --git a/packages/plugins/synthetics/src/validate.ts b/packages/plugins/synthetics/src/validate.ts
new file mode 100644
index 000000000..196179714
--- /dev/null
+++ b/packages/plugins/synthetics/src/validate.ts
@@ -0,0 +1,30 @@
+// 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 { Options } from '@dd/core/types';
+
+import { CONFIG_KEY } from './constants';
+import type { SyntheticsOptionsWithDefaults } from './types';
+
+export const validateOptions = (config: Options): SyntheticsOptionsWithDefaults => {
+ // Get values from environment.
+ const { BUILD_PLUGINS_S8S_PORT } = process.env;
+
+ // Define defaults.
+ const validatedOptions: SyntheticsOptionsWithDefaults = {
+ // We don't want to disable it by default.
+ disabled: false,
+ ...config[CONFIG_KEY],
+ };
+
+ // We will only run the server if we have a port defined in the environment.
+ if (BUILD_PLUGINS_S8S_PORT) {
+ validatedOptions.server = {
+ run: true,
+ port: +BUILD_PLUGINS_S8S_PORT,
+ };
+ }
+
+ return validatedOptions;
+};
diff --git a/packages/plugins/synthetics/tsconfig.json b/packages/plugins/synthetics/tsconfig.json
new file mode 100644
index 000000000..6c1d3065e
--- /dev/null
+++ b/packages/plugins/synthetics/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../../../tsconfig.json",
+ "compilerOptions": {
+ "baseUrl": "./",
+ "rootDir": "./",
+ "outDir": "./dist"
+ },
+ "include": ["**/*"],
+ "exclude": ["dist", "node_modules"]
+}
\ No newline at end of file
diff --git a/packages/published/esbuild-plugin/src/index.ts b/packages/published/esbuild-plugin/src/index.ts
index 6ff523630..0444ce184 100644
--- a/packages/published/esbuild-plugin/src/index.ts
+++ b/packages/published/esbuild-plugin/src/index.ts
@@ -11,6 +11,7 @@ import type {
// #types-export-injection-marker
ErrorTrackingTypes,
RumTypes,
+ SyntheticsTypes,
TelemetryTypes,
// #types-export-injection-marker
} from '@dd/factory';
@@ -24,6 +25,7 @@ export type {
// #types-export-injection-marker
ErrorTrackingTypes,
RumTypes,
+ SyntheticsTypes,
TelemetryTypes,
// #types-export-injection-marker
};
diff --git a/packages/published/rollup-plugin/src/index.ts b/packages/published/rollup-plugin/src/index.ts
index 59c46cba7..55b7cb292 100644
--- a/packages/published/rollup-plugin/src/index.ts
+++ b/packages/published/rollup-plugin/src/index.ts
@@ -11,6 +11,7 @@ import type {
// #types-export-injection-marker
ErrorTrackingTypes,
RumTypes,
+ SyntheticsTypes,
TelemetryTypes,
// #types-export-injection-marker
} from '@dd/factory';
@@ -24,6 +25,7 @@ export type {
// #types-export-injection-marker
ErrorTrackingTypes,
RumTypes,
+ SyntheticsTypes,
TelemetryTypes,
// #types-export-injection-marker
};
diff --git a/packages/published/rspack-plugin/src/index.ts b/packages/published/rspack-plugin/src/index.ts
index aec0805e7..c36b2f570 100644
--- a/packages/published/rspack-plugin/src/index.ts
+++ b/packages/published/rspack-plugin/src/index.ts
@@ -11,6 +11,7 @@ import type {
// #types-export-injection-marker
ErrorTrackingTypes,
RumTypes,
+ SyntheticsTypes,
TelemetryTypes,
// #types-export-injection-marker
} from '@dd/factory';
@@ -24,6 +25,7 @@ export type {
// #types-export-injection-marker
ErrorTrackingTypes,
RumTypes,
+ SyntheticsTypes,
TelemetryTypes,
// #types-export-injection-marker
};
diff --git a/packages/published/vite-plugin/src/index.ts b/packages/published/vite-plugin/src/index.ts
index 7ea5cfbf6..492cbadd9 100644
--- a/packages/published/vite-plugin/src/index.ts
+++ b/packages/published/vite-plugin/src/index.ts
@@ -11,6 +11,7 @@ import type {
// #types-export-injection-marker
ErrorTrackingTypes,
RumTypes,
+ SyntheticsTypes,
TelemetryTypes,
// #types-export-injection-marker
} from '@dd/factory';
@@ -24,6 +25,7 @@ export type {
// #types-export-injection-marker
ErrorTrackingTypes,
RumTypes,
+ SyntheticsTypes,
TelemetryTypes,
// #types-export-injection-marker
};
diff --git a/packages/published/webpack-plugin/src/index.ts b/packages/published/webpack-plugin/src/index.ts
index 24a29bbfd..e7e847877 100644
--- a/packages/published/webpack-plugin/src/index.ts
+++ b/packages/published/webpack-plugin/src/index.ts
@@ -11,6 +11,7 @@ import type {
// #types-export-injection-marker
ErrorTrackingTypes,
RumTypes,
+ SyntheticsTypes,
TelemetryTypes,
// #types-export-injection-marker
} from '@dd/factory';
@@ -24,6 +25,7 @@ export type {
// #types-export-injection-marker
ErrorTrackingTypes,
RumTypes,
+ SyntheticsTypes,
TelemetryTypes,
// #types-export-injection-marker
};
diff --git a/yarn.lock b/yarn.lock
index 5466520b7..700d17de1 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1690,6 +1690,7 @@ __metadata:
"@dd/internal-git-plugin": "workspace:*"
"@dd/internal-injection-plugin": "workspace:*"
"@dd/rum-plugin": "workspace:*"
+ "@dd/synthetics-plugin": "workspace:*"
"@dd/telemetry-plugin": "workspace:*"
chalk: "npm:2.3.1"
unplugin: "npm:1.16.0"
@@ -1756,6 +1757,16 @@ __metadata:
languageName: unknown
linkType: soft
+"@dd/synthetics-plugin@workspace:*, @dd/synthetics-plugin@workspace:packages/plugins/synthetics":
+ version: 0.0.0-use.local
+ resolution: "@dd/synthetics-plugin@workspace:packages/plugins/synthetics"
+ dependencies:
+ "@dd/core": "workspace:*"
+ "@dd/error-tracking-plugin": "workspace:*"
+ chalk: "npm:2.3.1"
+ languageName: unknown
+ linkType: soft
+
"@dd/telemetry-plugin@workspace:*, @dd/telemetry-plugin@workspace:packages/plugins/telemetry":
version: 0.0.0-use.local
resolution: "@dd/telemetry-plugin@workspace:packages/plugins/telemetry"