diff --git a/.eslintignore b/.eslintignore
index 849ddff3..7be09517 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -1 +1,2 @@
dist/
+example/
diff --git a/.gitignore b/.gitignore
index 9f427d59..2a10b15b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,4 +23,7 @@ dist/
.idea
# For CI to ignore .npmrc file when publishing
-.npmrc
\ No newline at end of file
+.npmrc
+
+# Example Experiment tag script example
+packages/experiment-tag/example/
diff --git a/.prettierignore b/.prettierignore
index 94f48112..98c3c799 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -1,2 +1,3 @@
dist/
+example/
*.md
diff --git a/packages/experiment-tag/README.md b/packages/experiment-tag/README.md
new file mode 100644
index 00000000..b325675c
--- /dev/null
+++ b/packages/experiment-tag/README.md
@@ -0,0 +1,16 @@
+# Experiment Web Experimentation Javascript Snippet
+
+## Overview
+
+This is the Web Experimentation SDK for Amplitude Experiment. Currently, only split-URL experiments are supported.
+
+## Generate example
+
+To generate an example snippet with custom flag configurations:
+1. Set `apiKey` (your Amplitude Project API key) and `initialFlags` in `example/build_example.js`
+2. Run `yarn build` to build minified UMD `experiment-tag.umd.js` and example `script.js`
+
+To test the snippet's behavior on web pages relevant to your experiment, the pages should:
+1. Include `script.js`
+2. Have the Amplitude Analytics SDK loaded (see [examples](https://github.com/amplitude/Amplitude-TypeScript/tree/main/packages/analytics-browser))
+
diff --git a/packages/experiment-tag/example/build_example.js b/packages/experiment-tag/example/build_example.js
new file mode 100644
index 00000000..57136634
--- /dev/null
+++ b/packages/experiment-tag/example/build_example.js
@@ -0,0 +1,79 @@
+const fs = require('fs');
+const apiKey = 'a6dd847b9d2f03c816d4f3f8458cdc1d';
+const initialFlags = `[
+ {
+ "key": "test",
+ "metadata": {
+ "deployed": true,
+ "evaluationMode": "local",
+ "experimentKey": "exp-1",
+ "flagType": "experiment",
+ "flagVersion": 20,
+ "urlMatch": [
+ "http://localhost:63342/experiment-js-client/packages/experiment-tag/example/control.html/"
+ ]
+ },
+ "segments": [
+ {
+ "metadata": {
+ "segmentName": "All Other Users"
+ },
+ "variant": "treatment"
+ }
+ ],
+ "variants": {
+ "control": {
+ "key": "control",
+ "payload": [
+ {
+ "action": "redirect",
+ "data": {
+ "url": "http://localhost:63342/experiment-js-client/packages/experiment-tag/example/control.html"
+ }
+ }
+ ],
+ "value": "control"
+ },
+ "off": {
+ "key": "off",
+ "metadata": {
+ "default": true
+ }
+ },
+ "treatment": {
+ "key": "treatment",
+ "payload": [
+ {
+ "action": "redirect",
+ "data": {
+ "url": "http://localhost:63342/experiment-js-client/packages/experiment-tag/example/treatment.html"
+ }
+ }
+ ],
+ "value": "treatment"
+ }
+ }
+ }
+]
+`.replace(/\n\s*/g, '');
+
+fs.readFile('dist/experiment-tag.umd.js', 'utf8', (err, data) => {
+ if (err) {
+ console.error('Error reading file:', err);
+ return;
+ }
+
+ // Perform string replacements
+ const modifiedData = data
+ .replace(/{{DEPLOYMENT_KEY}}/g, apiKey)
+ .replace(/"{{INITIAL_FLAGS}}"/g, `'${initialFlags}'`);
+
+ // Write the modified content to a new file
+ fs.writeFile('example/script.js', modifiedData, 'utf8', (err) => {
+ if (err) {
+ console.error('Error writing file:', err);
+ return;
+ }
+ console.log('File successfully written!');
+ });
+});
diff --git a/packages/experiment-tag/example/control.html b/packages/experiment-tag/example/control.html
new file mode 100644
index 00000000..a0324699
--- /dev/null
+++ b/packages/experiment-tag/example/control.html
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+ Original page
+
+
+
+
+
+
+
+
+ Ingredients
+
+ 1 cup softened butter
+ 1 cup white sugar
+ 2 cups all-purpose flour
+
+
+
+
+ Instructions
+
+ Preheat oven to 350 degrees F (175 degrees C).
+ Cream together the butter, white sugar,
+ and brown sugar until smooth.
+
+ Beat in the eggs one at a time,
+ then stir in the vanilla.
+
+
+
+
+
+
+
+
diff --git a/packages/experiment-tag/example/css/recipe-styles.css b/packages/experiment-tag/example/css/recipe-styles.css
new file mode 100644
index 00000000..148b664a
--- /dev/null
+++ b/packages/experiment-tag/example/css/recipe-styles.css
@@ -0,0 +1,47 @@
+/* Basic Reset */
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
+ line-height: 1.6;
+ padding: 20px;
+ background-color: #fff9e8;
+}
+
+article {
+ max-width: 800px;
+ margin: 20px auto;
+ padding: 20px;
+ background: #fff;
+ border: 1px solid #ddd;
+ border-radius: 8px;
+}
+
+h1, h2 {
+ color: #5a2c02;
+}
+
+.recipe-img {
+ max-width: 100%;
+ height: auto;
+ border-radius: 8px;
+}
+
+.ingredients ul, .instructions ol {
+ margin: 20px 0;
+}
+
+.ingredients li, .instructions li {
+ margin-bottom: 10px;
+}
+
+footer {
+ text-align: center;
+ margin-top: 20px;
+ padding-top: 20px;
+ border-top: 1px solid #ddd;
+}
diff --git a/packages/experiment-tag/example/css/style.css b/packages/experiment-tag/example/css/style.css
new file mode 100644
index 00000000..654e8767
--- /dev/null
+++ b/packages/experiment-tag/example/css/style.css
@@ -0,0 +1,247 @@
+/*! HTML5 Boilerplate v9.0.0-RC1 | MIT License | https://html5boilerplate.com/ */
+
+/* main.css 3.0.0 | MIT License | https://github.com/h5bp/main.css#readme */
+/*
+ * What follows is the result of much research on cross-browser styling.
+ * Credit left inline and big thanks to Nicolas Gallagher, Jonathan Neal,
+ * Kroc Camen, and the H5BP dev community and team.
+ */
+
+/* ==========================================================================
+ Base styles: opinionated defaults
+ ========================================================================== */
+
+html {
+ color: #222;
+ font-size: 1em;
+ line-height: 1.4;
+}
+
+/*
+ * Remove text-shadow in selection highlight:
+ * https://twitter.com/miketaylr/status/12228805301
+ *
+ * Customize the background color to match your design.
+ */
+
+::-moz-selection {
+ background: #b3d4fc;
+ text-shadow: none;
+}
+
+::selection {
+ background: #b3d4fc;
+ text-shadow: none;
+}
+
+/*
+ * A better looking default horizontal rule
+ */
+
+hr {
+ display: block;
+ height: 1px;
+ border: 0;
+ border-top: 1px solid #ccc;
+ margin: 1em 0;
+ padding: 0;
+}
+
+/*
+ * Remove the gap between audio, canvas, iframes,
+ * images, videos and the bottom of their containers:
+ * https://github.com/h5bp/html5-boilerplate/issues/440
+ */
+
+audio,
+canvas,
+iframe,
+img,
+svg,
+video {
+ vertical-align: middle;
+}
+
+/*
+ * Remove default fieldset styles.
+ */
+
+fieldset {
+ border: 0;
+ margin: 0;
+ padding: 0;
+}
+
+/*
+ * Allow only vertical resizing of textareas.
+ */
+
+textarea {
+ resize: vertical;
+}
+
+/* ==========================================================================
+ Author's custom styles
+ ========================================================================== */
+
+/* ==========================================================================
+ Helper classes
+ ========================================================================== */
+
+/*
+ * Hide visually and from screen readers
+ */
+
+.hidden,
+[hidden] {
+ display: none !important;
+}
+
+/*
+ * Hide only visually, but have it available for screen readers:
+ * https://snook.ca/archives/html_and_css/hiding-content-for-accessibility
+ *
+ * 1. For long content, line feeds are not interpreted as spaces and small width
+ * causes content to wrap 1 word per line:
+ * https://medium.com/@jessebeach/beware-smushed-off-screen-accessible-text-5952a4c2cbfe
+ */
+
+.visually-hidden {
+ border: 0;
+ clip: rect(0, 0, 0, 0);
+ height: 1px;
+ margin: -1px;
+ overflow: hidden;
+ padding: 0;
+ position: absolute;
+ white-space: nowrap;
+ width: 1px;
+ /* 1 */
+}
+
+/*
+ * Extends the .visually-hidden class to allow the element
+ * to be focusable when navigated to via the keyboard:
+ * https://www.drupal.org/node/897638
+ */
+
+.visually-hidden.focusable:active,
+.visually-hidden.focusable:focus {
+ clip: auto;
+ height: auto;
+ margin: 0;
+ overflow: visible;
+ position: static;
+ white-space: inherit;
+ width: auto;
+}
+
+/*
+ * Hide visually and from screen readers, but maintain layout
+ */
+
+.invisible {
+ visibility: hidden;
+}
+
+/*
+ * Clearfix: contain floats
+ *
+ * The use of `table` rather than `block` is only necessary if using
+ * `::before` to contain the top-margins of child elements.
+ */
+
+.clearfix::before,
+.clearfix::after {
+ content: "";
+ display: table;
+}
+
+.clearfix::after {
+ clear: both;
+}
+
+/* ==========================================================================
+ EXAMPLE Media Queries for Responsive Design.
+ These examples override the primary ('mobile first') styles.
+ Modify as content requires.
+ ========================================================================== */
+
+@media only screen and (min-width: 35em) {
+ /* Style adjustments for viewports that meet the condition */
+}
+
+@media print,
+ (-webkit-min-device-pixel-ratio: 1.25),
+ (min-resolution: 1.25dppx),
+ (min-resolution: 120dpi) {
+ /* Style adjustments for high resolution devices */
+}
+
+/* ==========================================================================
+ Print styles.
+ Inlined to avoid the additional HTTP request:
+ https://www.phpied.com/delay-loading-your-print-css/
+ ========================================================================== */
+
+@media print {
+ *,
+ *::before,
+ *::after {
+ background: #fff !important;
+ color: #000 !important;
+ /* Black prints faster */
+ box-shadow: none !important;
+ text-shadow: none !important;
+ }
+
+ a,
+ a:visited {
+ text-decoration: underline;
+ }
+
+ a[href]::after {
+ content: " (" attr(href) ")";
+ }
+
+ abbr[title]::after {
+ content: " (" attr(title) ")";
+ }
+
+ /*
+ * Don't show links that are fragment identifiers,
+ * or use the `javascript:` pseudo protocol
+ */
+ a[href^="#"]::after,
+ a[href^="javascript:"]::after {
+ content: "";
+ }
+
+ pre {
+ white-space: pre-wrap !important;
+ }
+
+ pre,
+ blockquote {
+ border: 1px solid #999;
+ page-break-inside: avoid;
+ }
+
+ tr,
+ img {
+ page-break-inside: avoid;
+ }
+
+ p,
+ h2,
+ h3 {
+ orphans: 3;
+ widows: 3;
+ }
+
+ h2,
+ h3 {
+ page-break-after: avoid;
+ }
+}
+
diff --git a/packages/experiment-tag/example/css/styles_1.css b/packages/experiment-tag/example/css/styles_1.css
new file mode 100644
index 00000000..4077e765
--- /dev/null
+++ b/packages/experiment-tag/example/css/styles_1.css
@@ -0,0 +1,46 @@
+/* Basic Reset */
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: 'Arial', sans-serif;
+ line-height: 1.6;
+ padding: 20px;
+ background-color: #f4f4f4;
+}
+
+.header {
+ text-align: center;
+ margin-bottom: 20px;
+}
+
+.profile-img {
+ width: 150px;
+ height: auto;
+ border-radius: 50%;
+}
+
+h1 {
+ margin-top: 10px;
+}
+
+.bio, .projects {
+ margin-bottom: 20px;
+}
+
+a {
+ color: #333;
+ text-decoration: none;
+}
+
+a:hover {
+ text-decoration: underline;
+}
+
+footer {
+ text-align: center;
+ margin-top: 20px;
+}
diff --git a/packages/experiment-tag/example/treatment.html b/packages/experiment-tag/example/treatment.html
new file mode 100644
index 00000000..6213c400
--- /dev/null
+++ b/packages/experiment-tag/example/treatment.html
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+ Redirected page
+
+
+
+
+
+
+ Redirected page
+ Web Developer, Programmer, and Tech Student
+ Follow me on social media:
+
+ Twitter |
+ LinkedIn
+
+
+
+
+ About Me
+ Hello! I'm Your Name, a web developer with a passion
+ for front-end design and user experience.
+ I've been building websites for over 5 years
+ and love what I do.
+
+
+
+
+
+
+
diff --git a/packages/experiment-tag/jest.config.js b/packages/experiment-tag/jest.config.js
new file mode 100644
index 00000000..cadd7714
--- /dev/null
+++ b/packages/experiment-tag/jest.config.js
@@ -0,0 +1,18 @@
+/* eslint-disable @typescript-eslint/no-var-requires */
+const { pathsToModuleNameMapper } = require('ts-jest');
+
+const package = require('./package');
+const { compilerOptions } = require('./tsconfig.test.json');
+
+module.exports = {
+ preset: 'ts-jest',
+ testEnvironment: 'jsdom',
+ displayName: package.name,
+ rootDir: '.',
+ moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, {
+ prefix: '/',
+ }),
+ transform: {
+ '^.+\\.tsx?$': ['ts-jest', { tsconfig: './tsconfig.test.json' }],
+ },
+};
diff --git a/packages/experiment-tag/package.json b/packages/experiment-tag/package.json
new file mode 100644
index 00000000..68e641e6
--- /dev/null
+++ b/packages/experiment-tag/package.json
@@ -0,0 +1,36 @@
+{
+ "name": "@amplitude/experiment-tag",
+ "version": "0.1.0",
+ "description": "Amplitude Experiment Javascript Snippet",
+ "author": "Amplitude",
+ "homepage": "https://github.com/amplitude/experiment-js-client",
+ "license": "MIT",
+ "main": "dist/experiment-tag.umd.js",
+ "types": "dist/types/src/index.d.ts",
+ "publishConfig": {
+ "access": "public"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/amplitude/experiment-js-client.git",
+ "directory": "packages/experiment-tag"
+ },
+ "scripts": {
+ "build": "rm -rf dist && rollup -c && node example/build_example.js",
+ "clean": "rimraf node_modules dist",
+ "lint": "eslint . --ignore-path ../../.eslintignore && prettier -c . --ignore-path ../../.prettierignore",
+ "test": "jest"
+ },
+ "bugs": {
+ "url": "https://github.com/amplitude/experiment-js-client/issues"
+ },
+ "dependencies": {
+ "@amplitude/experiment-js-client": "^1.10.0"
+ },
+ "devDependencies": {
+ "@rollup/plugin-terser": "^0.4.4"
+ },
+ "files": [
+ "dist"
+ ]
+}
diff --git a/packages/experiment-tag/rollup.config.js b/packages/experiment-tag/rollup.config.js
new file mode 100644
index 00000000..cac63a7f
--- /dev/null
+++ b/packages/experiment-tag/rollup.config.js
@@ -0,0 +1,74 @@
+import { resolve as pathResolve } from 'path';
+
+import tsConfig from '@amplitude/experiment-js-client/tsconfig.json';
+import babel from '@rollup/plugin-babel';
+import commonjs from '@rollup/plugin-commonjs';
+import json from '@rollup/plugin-json';
+import resolve from '@rollup/plugin-node-resolve';
+import replace from '@rollup/plugin-replace';
+import terser from '@rollup/plugin-terser';
+import typescript from '@rollup/plugin-typescript';
+import analyze from 'rollup-plugin-analyzer';
+
+const getCommonBrowserConfig = (target) => ({
+ input: 'src/script.ts',
+ treeshake: {
+ moduleSideEffects: 'no-external',
+ },
+ plugins: [
+ replace({
+ preventAssignment: true,
+ BUILD_BROWSER: true,
+ }),
+ resolve(),
+ json(),
+ commonjs(),
+ typescript({
+ ...(target === 'es2015'
+ ? { target: 'es2015', downlevelIteration: true }
+ : { downlevelIteration: true }),
+ declaration: true,
+ declarationDir: 'dist/types',
+ include: tsConfig.include,
+ rootDir: '.',
+ }),
+ babel({
+ configFile:
+ target === 'es2015'
+ ? pathResolve(__dirname, '../..', 'babel.es2015.config.js')
+ : undefined,
+ babelHelpers: 'bundled',
+ exclude: ['node_modules/**'],
+ }),
+ analyze({
+ summaryOnly: true,
+ }),
+ ],
+});
+
+const getOutputConfig = (outputOptions) => ({
+ output: {
+ dir: 'dist',
+ name: 'Experiment-Tag',
+ ...outputOptions,
+ },
+});
+
+const configs = [
+ // legacy build for field "main" - ie8, umd, es5 syntax
+ {
+ ...getCommonBrowserConfig('es5'),
+ ...getOutputConfig({
+ entryFileNames: 'experiment-tag.umd.js',
+ exports: 'named',
+ format: 'umd',
+ }),
+ plugins: [
+ ...getCommonBrowserConfig('es5').plugins,
+ terser(), // Apply terser plugin for minification
+ ],
+ external: [],
+ },
+];
+
+export default configs;
diff --git a/packages/experiment-tag/src/experiment.ts b/packages/experiment-tag/src/experiment.ts
new file mode 100644
index 00000000..4432aef6
--- /dev/null
+++ b/packages/experiment-tag/src/experiment.ts
@@ -0,0 +1,117 @@
+import { EvaluationFlag } from '@amplitude/experiment-core';
+import { Experiment, ExperimentUser } from '@amplitude/experiment-js-client';
+
+import {
+ getGlobalScope,
+ getUrlParams,
+ isLocalStorageAvailable,
+ matchesUrl,
+ removeQueryParams,
+ urlWithoutParamsAndAnchor,
+ UUID,
+} from './util';
+
+export const initializeExperiment = (apiKey: string, initialFlags: string) => {
+ const globalScope = getGlobalScope();
+ const experimentStorageName = `EXP_${apiKey.slice(0, 10)}`;
+
+ if (isLocalStorageAvailable() && globalScope) {
+ let user: ExperimentUser = {};
+ try {
+ user = JSON.parse(
+ globalScope?.localStorage.getItem(experimentStorageName) || '{}',
+ );
+ } catch (error) {
+ // catch error
+ }
+
+ // create new user if it does not exist, or it does not have device_id
+ if (Object.keys(user).length === 0 || !user.device_id) {
+ user = {};
+ user.device_id = UUID();
+ globalScope?.localStorage.setItem(
+ experimentStorageName,
+ JSON.stringify(user),
+ );
+ }
+
+ const urlParams = getUrlParams();
+ // force variant if in preview mode
+ if (urlParams['PREVIEW']) {
+ const parsedFlags = JSON.parse(initialFlags);
+ parsedFlags.forEach((flag: EvaluationFlag) => {
+ if (flag.key in urlParams && urlParams[flag.key] in flag.variants) {
+ flag.segments = [
+ {
+ metadata: {
+ segmentName: 'preview',
+ },
+ variant: urlParams[flag.key],
+ },
+ ];
+ }
+ });
+ initialFlags = JSON.stringify(parsedFlags);
+ }
+ globalScope.experiment = Experiment.initializeWithAmplitudeAnalytics(
+ apiKey,
+ {
+ debug: true,
+ fetchOnStart: false,
+ initialFlags: initialFlags,
+ },
+ );
+
+ globalScope.experiment.setUser(user);
+
+ const variants = globalScope.experiment.all();
+
+ for (const key in variants) {
+ const variant = variants[key];
+
+ if (!Array.isArray(variant?.payload)) {
+ continue;
+ }
+ for (const action of variant.payload) {
+ if (action.action === 'redirect') {
+ const urlExactMatch = variant?.metadata?.['urlMatch'];
+ const currentUrl = urlWithoutParamsAndAnchor(
+ globalScope.location.href,
+ );
+ const referrerUrl = urlWithoutParamsAndAnchor(
+ globalScope.document.referrer,
+ );
+ const redirectUrl = action?.data?.url;
+ // if in preview mode, strip query params
+ if (variant.metadata?.segmentName === 'preview') {
+ globalScope.history.replaceState(
+ {},
+ '',
+ removeQueryParams(globalScope.location.href, ['PREVIEW', key]),
+ );
+ }
+ if (matchesUrl(urlExactMatch, currentUrl)) {
+ if (
+ !matchesUrl([redirectUrl], currentUrl) &&
+ currentUrl !== referrerUrl
+ ) {
+ // perform redirection
+ globalScope.location.replace(redirectUrl);
+ } else {
+ // if redirection is not required
+ globalScope.experiment.exposure(key);
+ }
+ } else if (
+ // if at the redirected page
+ matchesUrl(urlExactMatch, referrerUrl) &&
+ (matchesUrl([redirectUrl], currentUrl) ||
+ // case when redirected url has query and anchor
+ matchesUrl([redirectUrl], globalScope.location.href))
+ ) {
+ globalScope.experiment.exposure(key);
+ }
+ }
+ }
+ }
+ }
+};
diff --git a/packages/experiment-tag/src/script.ts b/packages/experiment-tag/src/script.ts
new file mode 100644
index 00000000..0e6c3f46
--- /dev/null
+++ b/packages/experiment-tag/src/script.ts
@@ -0,0 +1,5 @@
+import { initializeExperiment } from './experiment';
+
+const API_KEY = '{{DEPLOYMENT_KEY}}';
+const initialFlags = '{{INITIAL_FLAGS}}';
+initializeExperiment(API_KEY, initialFlags);
diff --git a/packages/experiment-tag/src/util.ts b/packages/experiment-tag/src/util.ts
new file mode 100644
index 00000000..1b5e2e74
--- /dev/null
+++ b/packages/experiment-tag/src/util.ts
@@ -0,0 +1,96 @@
+export const getGlobalScope = (): typeof globalThis | undefined => {
+ if (typeof globalThis !== 'undefined') {
+ return globalThis;
+ }
+ if (typeof window !== 'undefined') {
+ return window;
+ }
+ if (typeof self !== 'undefined') {
+ return self;
+ }
+ if (typeof global !== 'undefined') {
+ return global;
+ }
+ return undefined;
+};
+
+// Get URL parameters
+export const getUrlParams = (): Record => {
+ const globalScope = getGlobalScope();
+ const searchParams = new URLSearchParams(globalScope?.location.search);
+ const params: Record = {};
+ for (const [key, value] of searchParams) {
+ params[key] = value;
+ }
+ return params;
+};
+
+export const urlWithoutParamsAndAnchor = (url: string): string => {
+ if (!url) {
+ return '';
+ }
+ const urlObj = new URL(url);
+ urlObj.search = '';
+ urlObj.hash = '';
+ return urlObj.toString();
+};
+
+export const removeQueryParams = (
+ url: string,
+ paramsToRemove: string[],
+): string => {
+ const urlObj = new URL(url);
+ for (const param of paramsToRemove) {
+ urlObj.searchParams.delete(param);
+ }
+ return urlObj.toString();
+};
+
+export const UUID = function (a?: never): string {
+ return a // if the placeholder was passed, return
+ ? // a random number from 0 to 15
+ (
+ a ^ // unless b is 8,
+ ((Math.random() * // in which case
+ 16) >> // a random number from
+ (a / 4))
+ ) // 8 to 11
+ .toString(16) // in hexadecimal
+ : // or otherwise a concatenated string:
+ (
+ String(1e7) + // 10000000 +
+ String(-1e3) + // -1000 +
+ String(-4e3) + // -4000 +
+ String(-8e3) + // -80000000 +
+ String(-1e11)
+ ) // -100000000000,
+ .replace(
+ // replacing
+ /[018]/g, // zeroes, ones, and eights with
+ UUID(), // random hex digits
+ );
+};
+
+export const matchesUrl = (urlArray: string[], urlString: string): boolean => {
+ const cleanUrlString = urlString.replace(/\/$/, '');
+
+ return urlArray.some((url) => {
+ const cleanUrl = url.replace(/\/$/, '');
+ return cleanUrl === cleanUrlString;
+ });
+};
+
+export const isLocalStorageAvailable = (): boolean => {
+ const globalScope = getGlobalScope();
+ if (globalScope) {
+ try {
+ const testKey = 'EXP_test';
+ globalScope.localStorage.setItem(testKey, testKey);
+ globalScope.localStorage.removeItem(testKey);
+ return true;
+ } catch (e) {
+ return false;
+ }
+ }
+ return false;
+};
diff --git a/packages/experiment-tag/test/experiment.test.ts b/packages/experiment-tag/test/experiment.test.ts
new file mode 100644
index 00000000..b7a061af
--- /dev/null
+++ b/packages/experiment-tag/test/experiment.test.ts
@@ -0,0 +1,621 @@
+import { ExperimentClient } from '@amplitude/experiment-js-client';
+import { initializeExperiment } from 'src/experiment';
+import * as util from 'src/util';
+
+describe('initializeExperiment', () => {
+ const mockGetGlobalScope = jest.spyOn(util, 'getGlobalScope');
+ jest.spyOn(ExperimentClient.prototype, 'setUser');
+ jest.spyOn(ExperimentClient.prototype, 'all');
+ const mockExposure = jest.spyOn(ExperimentClient.prototype, 'exposure');
+ jest.spyOn(util, 'UUID').mockReturnValue('mock');
+ let mockGlobal;
+
+ beforeEach(() => {
+ jest.spyOn(util, 'isLocalStorageAvailable').mockReturnValue(true);
+ jest.clearAllMocks();
+ mockGlobal = {
+ localStorage: {
+ getItem: jest.fn().mockReturnValue(undefined),
+ setItem: jest.fn(),
+ },
+ location: {
+ href: 'http://test.com',
+ replace: jest.fn(),
+ search: '',
+ },
+ document: { referrer: '' },
+ history: { replaceState: jest.fn() },
+ };
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ mockGetGlobalScope.mockReturnValue(mockGlobal);
+ });
+
+ test('should initialize experiment with empty user', () => {
+ initializeExperiment(
+ 'apiKey_1',
+ JSON.stringify([
+ {
+ key: 'test',
+ metadata: {
+ deployed: true,
+ evaluationMode: 'local',
+ experimentKey: 'exp-1',
+ flagType: 'experiment',
+ flagVersion: 20,
+ urlMatch: ['http://test.com'],
+ },
+ segments: [
+ {
+ metadata: {
+ segmentName: 'All Other Users',
+ },
+ variant: 'treatment',
+ },
+ ],
+ variants: {
+ control: {
+ key: 'control',
+ payload: [
+ {
+ action: 'redirect',
+ data: {
+ url: 'http://test.com',
+ },
+ },
+ ],
+ value: 'control',
+ },
+ off: {
+ key: 'off',
+ metadata: {
+ default: true,
+ },
+ },
+ treatment: {
+ key: 'treatment',
+ payload: [
+ {
+ action: 'redirect',
+ data: {
+ url: 'http://test.com/2',
+ },
+ },
+ ],
+ value: 'treatment',
+ },
+ },
+ },
+ ]),
+ );
+ expect(ExperimentClient.prototype.setUser).toHaveBeenCalledWith({
+ device_id: 'mock',
+ });
+ expect(mockGlobal.localStorage.setItem).toHaveBeenCalledWith(
+ 'EXP_apiKey_1',
+ JSON.stringify({ device_id: 'mock' }),
+ );
+ });
+
+ test('experiment should not run without localStorage', () => {
+ jest.spyOn(util, 'isLocalStorageAvailable').mockReturnValue(false);
+ initializeExperiment('no_local', '');
+ expect(mockGlobal.localStorage.getItem).toHaveBeenCalledTimes(0);
+ });
+
+ test('should redirect and not call exposure', () => {
+ initializeExperiment(
+ 'apiKey_2',
+ JSON.stringify([
+ {
+ key: 'test',
+ metadata: {
+ deployed: true,
+ evaluationMode: 'local',
+ experimentKey: 'exp-1',
+ flagType: 'experiment',
+ flagVersion: 20,
+ urlMatch: ['http://test.com'],
+ },
+ segments: [
+ {
+ metadata: {
+ segmentName: 'All Other Users',
+ },
+ variant: 'treatment',
+ },
+ ],
+ variants: {
+ control: {
+ key: 'control',
+ payload: [
+ {
+ action: 'redirect',
+ data: {
+ url: 'http://test.com',
+ },
+ },
+ ],
+ value: 'control',
+ },
+ off: {
+ key: 'off',
+ metadata: {
+ default: true,
+ },
+ },
+ treatment: {
+ key: 'treatment',
+ payload: [
+ {
+ action: 'redirect',
+ data: {
+ url: 'http://test.com/2',
+ },
+ },
+ ],
+ value: 'treatment',
+ },
+ },
+ },
+ ]),
+ );
+
+ expect(mockGlobal.location.replace).toHaveBeenCalledWith(
+ 'http://test.com/2',
+ );
+ expect(mockExposure).toHaveBeenCalledTimes(0);
+ });
+
+ test('should not redirect but call exposure', () => {
+ initializeExperiment(
+ 'apiKey_3',
+ JSON.stringify([
+ {
+ key: 'test',
+ metadata: {
+ deployed: true,
+ evaluationMode: 'local',
+ experimentKey: 'exp-1',
+ flagType: 'experiment',
+ flagVersion: 20,
+ urlMatch: ['http://test.com'],
+ },
+ segments: [
+ {
+ metadata: {
+ segmentName: 'All Other Users',
+ },
+ variant: 'control',
+ },
+ ],
+ variants: {
+ control: {
+ key: 'control',
+ payload: [
+ {
+ action: 'redirect',
+ data: {
+ url: 'http://test.com',
+ },
+ },
+ ],
+ value: 'control',
+ },
+ off: {
+ key: 'off',
+ metadata: {
+ default: true,
+ },
+ },
+ treatment: {
+ key: 'treatment',
+ payload: [
+ {
+ action: 'redirect',
+ data: {
+ url: 'http://test.com/2',
+ },
+ },
+ ],
+ value: 'treatment',
+ },
+ },
+ },
+ ]),
+ );
+
+ expect(mockGlobal.location.replace).toBeCalledTimes(0);
+ expect(mockExposure).toHaveBeenCalledWith('test');
+ expect(mockGlobal.history.replaceState).toBeCalledTimes(0);
+ });
+
+ test('should not redirect or exposure', () => {
+ initializeExperiment(
+ 'apiKey_4',
+ JSON.stringify([
+ {
+ key: 'test',
+ metadata: {
+ deployed: true,
+ evaluationMode: 'local',
+ experimentKey: 'exp-1',
+ flagType: 'experiment',
+ flagVersion: 20,
+ urlMatch: ['http://should.not.match'],
+ },
+ segments: [
+ {
+ metadata: {
+ segmentName: 'All Other Users',
+ },
+ variant: 'control',
+ },
+ ],
+ variants: {
+ control: {
+ key: 'control',
+ payload: [
+ {
+ action: 'redirect',
+ data: {
+ url: 'http://test.com',
+ },
+ },
+ ],
+ value: 'control',
+ },
+ off: {
+ key: 'off',
+ metadata: {
+ default: true,
+ },
+ },
+ treatment: {
+ key: 'treatment',
+ payload: [
+ {
+ action: 'redirect',
+ data: {
+ url: 'http://test.com/2',
+ },
+ },
+ ],
+ value: 'treatment',
+ },
+ },
+ },
+ ]),
+ );
+
+ expect(mockGlobal.location.replace).toBeCalledTimes(0);
+ expect(mockExposure).toHaveBeenCalledTimes(0);
+ });
+
+ test('exposure fired when on redirected page', () => {
+ const mockGlobal = {
+ localStorage: {
+ getItem: jest.fn().mockReturnValue(undefined),
+ setItem: jest.fn(),
+ },
+ location: {
+ href: 'http://test.com/2',
+ replace: jest.fn(),
+ search: '',
+ },
+ document: { referrer: 'http://test.com' },
+ };
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ mockGetGlobalScope.mockReturnValue(mockGlobal);
+
+ initializeExperiment(
+ 'apiKey_5',
+ JSON.stringify([
+ {
+ key: 'test',
+ metadata: {
+ deployed: true,
+ evaluationMode: 'local',
+ experimentKey: 'exp-1',
+ flagType: 'experiment',
+ flagVersion: 20,
+ urlMatch: ['http://test.com'],
+ },
+ segments: [
+ {
+ metadata: {
+ segmentName: 'All Other Users',
+ },
+ variant: 'treatment',
+ },
+ ],
+ variants: {
+ control: {
+ key: 'control',
+ payload: [
+ {
+ action: 'redirect',
+ data: {
+ url: 'http://test.com',
+ },
+ },
+ ],
+ value: 'control',
+ },
+ off: {
+ key: 'off',
+ metadata: {
+ default: true,
+ },
+ },
+ treatment: {
+ key: 'treatment',
+ payload: [
+ {
+ action: 'redirect',
+ data: {
+ url: 'http://test.com/2',
+ },
+ },
+ ],
+ value: 'treatment',
+ },
+ },
+ },
+ ]),
+ );
+
+ expect(mockGlobal.location.replace).toHaveBeenCalledTimes(0);
+ expect(mockExposure).toHaveBeenCalledWith('test');
+ });
+
+ test('preview - force control variant', () => {
+ const mockGlobal = {
+ localStorage: {
+ getItem: jest.fn().mockReturnValue(undefined),
+ setItem: jest.fn(),
+ },
+ location: {
+ href: 'http://test.com',
+ replace: jest.fn(),
+ search: '?test=control&PREVIEW=true',
+ },
+ document: { referrer: '' },
+ history: { replaceState: jest.fn() },
+ };
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ mockGetGlobalScope.mockReturnValue(mockGlobal);
+
+ initializeExperiment(
+ 'prev_control',
+ JSON.stringify([
+ {
+ key: 'test',
+ metadata: {
+ deployed: true,
+ evaluationMode: 'local',
+ experimentKey: 'exp-1',
+ flagType: 'experiment',
+ flagVersion: 20,
+ urlMatch: ['http://test.com'],
+ },
+ segments: [
+ {
+ metadata: {
+ segmentName: 'All Other Users',
+ },
+ variant: 'treatment',
+ },
+ ],
+ variants: {
+ control: {
+ key: 'treatment',
+ payload: [
+ {
+ action: 'redirect',
+ data: {
+ url: 'http://test.com',
+ },
+ },
+ ],
+ value: 'control',
+ },
+ off: {
+ key: 'off',
+ metadata: {
+ default: true,
+ },
+ },
+ treatment: {
+ key: 'treatment',
+ payload: [
+ {
+ action: 'redirect',
+ data: {
+ url: 'http://test.com/2',
+ },
+ },
+ ],
+ value: 'treatment',
+ },
+ },
+ },
+ ]),
+ );
+
+ expect(mockGlobal.location.replace).toHaveBeenCalledTimes(0);
+ expect(mockGlobal.history.replaceState).toHaveBeenCalledWith(
+ {},
+ '',
+ 'http://test.com/',
+ );
+ expect(mockExposure).toHaveBeenCalledWith('test');
+ });
+
+ test('preview - force treatment variant when on control page', () => {
+ const mockGlobal = {
+ localStorage: {
+ getItem: jest.fn().mockReturnValue(undefined),
+ setItem: jest.fn(),
+ },
+ location: {
+ href: 'http://test.com/',
+ replace: jest.fn(),
+ search: '?test=treatment&PREVIEW=true',
+ },
+ document: { referrer: '' },
+ history: { replaceState: jest.fn() },
+ };
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ mockGetGlobalScope.mockReturnValue(mockGlobal);
+
+ initializeExperiment(
+ 'prev_treatment',
+ JSON.stringify([
+ {
+ key: 'test',
+ metadata: {
+ deployed: true,
+ evaluationMode: 'local',
+ experimentKey: 'exp-1',
+ flagType: 'experiment',
+ flagVersion: 20,
+ urlMatch: ['http://test.com'],
+ },
+ segments: [
+ {
+ metadata: {
+ segmentName: 'All Other Users',
+ },
+ variant: 'control',
+ },
+ ],
+ variants: {
+ control: {
+ key: 'treatment',
+ payload: [
+ {
+ action: 'redirect',
+ data: {
+ url: 'http://test.com',
+ },
+ },
+ ],
+ value: 'control',
+ },
+ off: {
+ key: 'off',
+ metadata: {
+ default: true,
+ },
+ },
+ treatment: {
+ key: 'treatment',
+ payload: [
+ {
+ action: 'redirect',
+ data: {
+ url: 'http://test.com/2',
+ },
+ },
+ ],
+ value: 'treatment',
+ },
+ },
+ },
+ ]),
+ );
+
+ expect(mockGlobal.location.replace).toHaveBeenCalledWith(
+ 'http://test.com/2',
+ );
+ expect(mockExposure).toHaveBeenCalledTimes(0);
+ });
+
+ test('preview - force treatment variant when on treatment page', () => {
+ const mockGlobal = {
+ localStorage: {
+ getItem: jest.fn().mockReturnValue(undefined),
+ setItem: jest.fn(),
+ },
+ location: {
+ href: 'http://test.com/2',
+ replace: jest.fn(),
+ search: '?test=treatment&PREVIEW=true',
+ },
+ document: { referrer: '' },
+ history: { replaceState: jest.fn() },
+ };
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ mockGetGlobalScope.mockReturnValue(mockGlobal);
+
+ initializeExperiment(
+ 'prev_treatment',
+ JSON.stringify([
+ {
+ key: 'test',
+ metadata: {
+ deployed: true,
+ evaluationMode: 'local',
+ experimentKey: 'exp-1',
+ flagType: 'experiment',
+ flagVersion: 20,
+ urlMatch: ['http://test.com'],
+ },
+ segments: [
+ {
+ metadata: {
+ segmentName: 'All Other Users',
+ },
+ variant: 'control',
+ },
+ ],
+ variants: {
+ control: {
+ key: 'treatment',
+ payload: [
+ {
+ action: 'redirect',
+ data: {
+ url: 'http://test.com',
+ },
+ },
+ ],
+ value: 'control',
+ },
+ off: {
+ key: 'off',
+ metadata: {
+ default: true,
+ },
+ },
+ treatment: {
+ key: 'treatment',
+ payload: [
+ {
+ action: 'redirect',
+ data: {
+ url: 'http://test.com/2',
+ },
+ },
+ ],
+ value: 'treatment',
+ },
+ },
+ },
+ ]),
+ );
+
+ expect(mockGlobal.location.replace).toHaveBeenCalledTimes(0);
+ expect(mockExposure).toHaveBeenCalledTimes(0);
+ expect(mockGlobal.history.replaceState).toHaveBeenCalledWith(
+ {},
+ '',
+ 'http://test.com/2',
+ );
+ });
+});
diff --git a/packages/experiment-tag/test/util.test.ts b/packages/experiment-tag/test/util.test.ts
new file mode 100644
index 00000000..8179e102
--- /dev/null
+++ b/packages/experiment-tag/test/util.test.ts
@@ -0,0 +1,123 @@
+import { getUrlParams, matchesUrl, urlWithoutParamsAndAnchor } from 'src/util';
+import * as util from 'src/util';
+
+// Mock the getGlobalScope function
+const spyGetGlobalScope = jest.spyOn(util, 'getGlobalScope');
+
+describe('matchesUrl', () => {
+ // Existing test cases
+ it('should return true if the URL matches in the array without trailing slash', () => {
+ const urlArray: string[] = ['http://example.com', 'http://example.org/'];
+ const urlString = 'http://example.org';
+
+ expect(matchesUrl(urlArray, urlString)).toBe(true);
+ });
+
+ it('should return false if the URL does not match in the array', () => {
+ const urlArray: string[] = ['http://example.com', 'http://example.org/'];
+ const urlString = 'http://example.net';
+
+ expect(matchesUrl(urlArray, urlString)).toBe(false);
+ });
+
+ // Additional test cases
+ it('should handle URLs with different protocols', () => {
+ const urlArray: string[] = ['https://example.com', 'http://example.org/'];
+ const urlString = 'https://example.com';
+
+ expect(matchesUrl(urlArray, urlString)).toBe(true);
+ });
+
+ it('should handle URLs with paths', () => {
+ const urlArray: string[] = [
+ 'http://example.com/page',
+ 'http://example.org/',
+ ];
+ const urlString = 'http://example.com/page';
+
+ expect(matchesUrl(urlArray, urlString)).toBe(true);
+ });
+
+ it('should handle URLs with query parameters', () => {
+ const urlArray: string[] = [
+ 'http://example.com?param=value',
+ 'http://example.org/',
+ ];
+ const urlString = 'http://example.com?param=value';
+
+ expect(matchesUrl(urlArray, urlString)).toBe(true);
+ });
+
+ it('should handle URLs with ports', () => {
+ const urlArray: string[] = [
+ 'http://example.com:8080',
+ 'http://example.org/',
+ ];
+ const urlString = 'http://example.com:8080';
+
+ expect(matchesUrl(urlArray, urlString)).toBe(true);
+ });
+});
+
+describe('urlWithoutParamsAndAnchor', () => {
+ // Existing test cases
+ it('should return the URL without parameters and anchor', () => {
+ const url = 'http://example.com/page?param1=value1¶m2=value2#section';
+
+ expect(urlWithoutParamsAndAnchor(url)).toBe('http://example.com/page');
+ });
+
+ it('should return the same URL if it does not contain parameters and anchor', () => {
+ const url = 'http://example.com/page';
+
+ expect(urlWithoutParamsAndAnchor(url)).toBe('http://example.com/page');
+ });
+
+ // Additional test cases
+ it('should handle URLs with anchors', () => {
+ const url = 'http://example.com/page#section';
+
+ expect(urlWithoutParamsAndAnchor(url)).toBe('http://example.com/page');
+ });
+});
+
+describe('getUrlParams', () => {
+ // Existing test cases
+ it('should return URL parameters as an object', () => {
+ const mockGlobal = {
+ location: {
+ search: '?param1=value1¶m2=value2',
+ },
+ };
+
+ // Mock the global scope and location
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ spyGetGlobalScope.mockReturnValue(mockGlobal);
+
+ expect(getUrlParams()).toEqual({
+ param1: 'value1',
+ param2: 'value2',
+ });
+ });
+
+ it('should return an empty object if there are no URL parameters', () => {
+ const mockGlobal = {
+ location: {
+ search: '',
+ },
+ };
+
+ // Mock the global scope and location
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ spyGetGlobalScope.mockReturnValue(mockGlobal);
+
+ expect(getUrlParams()).toEqual({});
+ });
+});
+
+afterAll(() => {
+ // Restore the original getGlobalScope function after all tests
+ spyGetGlobalScope.mockRestore();
+});
diff --git a/packages/experiment-tag/tsconfig.json b/packages/experiment-tag/tsconfig.json
new file mode 100644
index 00000000..ece1f0c8
--- /dev/null
+++ b/packages/experiment-tag/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "extends": "../../tsconfig.json",
+ "include": ["src/**/*.ts", "package.json"],
+ "compilerOptions": {
+ "noImplicitAny": false,
+ "declaration": true,
+ "declarationDir": "dist/types",
+ "downlevelIteration": true,
+ "strict": true,
+ "baseUrl": "./src",
+ "rootDir": "."
+ }
+}
diff --git a/packages/experiment-tag/tsconfig.test.json b/packages/experiment-tag/tsconfig.test.json
new file mode 100644
index 00000000..a6ff84d1
--- /dev/null
+++ b/packages/experiment-tag/tsconfig.test.json
@@ -0,0 +1,13 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "declaration": true,
+ "rootDir": ".",
+ "baseUrl": ".",
+ "paths": {
+ "src/*": ["./src/*"]
+ }
+ },
+ "include": ["src/**/*.ts", "test/**/*.ts"],
+ "exclude": ["dist"]
+}
diff --git a/yarn.lock b/yarn.lock
index d24a6c27..507e8a06 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1389,22 +1389,49 @@
"@jridgewell/sourcemap-codec" "^1.4.10"
"@jridgewell/trace-mapping" "^0.3.9"
+"@jridgewell/gen-mapping@^0.3.5":
+ version "0.3.5"
+ resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz#dcce6aff74bdf6dad1a95802b69b04a2fcb1fb36"
+ integrity sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==
+ dependencies:
+ "@jridgewell/set-array" "^1.2.1"
+ "@jridgewell/sourcemap-codec" "^1.4.10"
+ "@jridgewell/trace-mapping" "^0.3.24"
+
"@jridgewell/resolve-uri@3.1.0":
version "3.1.0"
resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz#2203b118c157721addfe69d47b70465463066d78"
integrity sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==
+"@jridgewell/resolve-uri@^3.1.0":
+ version "3.1.2"
+ resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6"
+ integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==
+
"@jridgewell/set-array@^1.0.1":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.1.2.tgz#7c6cf998d6d20b914c0a55a91ae928ff25965e72"
integrity sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==
+"@jridgewell/set-array@^1.2.1":
+ version "1.2.1"
+ resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.2.1.tgz#558fb6472ed16a4c850b889530e6b36438c49280"
+ integrity sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==
+
+"@jridgewell/source-map@^0.3.3":
+ version "0.3.6"
+ resolved "https://registry.yarnpkg.com/@jridgewell/source-map/-/source-map-0.3.6.tgz#9d71ca886e32502eb9362c9a74a46787c36df81a"
+ integrity sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==
+ dependencies:
+ "@jridgewell/gen-mapping" "^0.3.5"
+ "@jridgewell/trace-mapping" "^0.3.25"
+
"@jridgewell/sourcemap-codec@1.4.14":
version "1.4.14"
resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz#add4c98d341472a289190b424efbdb096991bb24"
integrity sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==
-"@jridgewell/sourcemap-codec@^1.4.10":
+"@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.14":
version "1.4.15"
resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz#d7c6e6755c78567a951e04ab52ef0fd26de59f32"
integrity sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==
@@ -1417,6 +1444,14 @@
"@jridgewell/resolve-uri" "3.1.0"
"@jridgewell/sourcemap-codec" "1.4.14"
+"@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25":
+ version "0.3.25"
+ resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz#15f190e98895f3fc23276ee14bc76b675c2e50f0"
+ integrity sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==
+ dependencies:
+ "@jridgewell/resolve-uri" "^3.1.0"
+ "@jridgewell/sourcemap-codec" "^1.4.14"
+
"@lerna/child-process@6.6.2":
version "6.6.2"
resolved "https://registry.yarnpkg.com/@lerna/child-process/-/child-process-6.6.2.tgz#5d803c8dee81a4e013dc428292e77b365cba876c"
@@ -1967,6 +2002,15 @@
"@rollup/pluginutils" "^3.1.0"
magic-string "^0.25.7"
+"@rollup/plugin-terser@^0.4.4":
+ version "0.4.4"
+ resolved "https://registry.yarnpkg.com/@rollup/plugin-terser/-/plugin-terser-0.4.4.tgz#15dffdb3f73f121aa4fbb37e7ca6be9aeea91962"
+ integrity sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A==
+ dependencies:
+ serialize-javascript "^6.0.1"
+ smob "^1.0.0"
+ terser "^5.17.4"
+
"@rollup/plugin-typescript@^11.1.0":
version "11.1.2"
resolved "https://registry.yarnpkg.com/@rollup/plugin-typescript/-/plugin-typescript-11.1.2.tgz#09eb5690a650bb0334bf84125bce9abd296442e4"
@@ -2403,6 +2447,11 @@ acorn@^8.1.0, acorn@^8.8.1:
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.10.0.tgz#8be5b3907a67221a81ab23c7889c4c5526b62ec5"
integrity sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==
+acorn@^8.8.2:
+ version "8.11.3"
+ resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.11.3.tgz#71e0b14e13a4ec160724b38fb7b0f233b1b81d7a"
+ integrity sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==
+
add-stream@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/add-stream/-/add-stream-1.0.0.tgz#6a7990437ca736d5e1288db92bd3266d5f5cb2aa"
@@ -3145,6 +3194,11 @@ combined-stream@^1.0.8:
dependencies:
delayed-stream "~1.0.0"
+commander@^2.20.0:
+ version "2.20.3"
+ resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
+ integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
+
common-ancestor-path@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/common-ancestor-path/-/common-ancestor-path-1.0.1.tgz#4f7d2d1394d91b7abdf51871c62f71eadb0182a7"
@@ -7177,6 +7231,13 @@ quick-lru@^4.0.1:
resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-4.0.1.tgz#5b8878f113a58217848c6482026c73e1ba57727f"
integrity sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==
+randombytes@^2.1.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a"
+ integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==
+ dependencies:
+ safe-buffer "^5.1.0"
+
react-is@^17.0.1:
version "17.0.2"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0"
@@ -7508,16 +7569,16 @@ safe-array-concat@^1.0.0:
has-symbols "^1.0.3"
isarray "^2.0.5"
+safe-buffer@^5.1.0, safe-buffer@~5.2.0:
+ version "5.2.1"
+ resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
+ integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
+
safe-buffer@~5.1.0, safe-buffer@~5.1.1:
version "5.1.2"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
-safe-buffer@~5.2.0:
- version "5.2.1"
- resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
- integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
-
safe-regex-test@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/safe-regex-test/-/safe-regex-test-1.0.0.tgz#793b874d524eb3640d1873aad03596db2d4f2295"
@@ -7570,6 +7631,13 @@ semver@^7.0.0, semver@^7.1.1, semver@^7.2.1, semver@^7.3.4, semver@^7.3.5, semve
dependencies:
lru-cache "^6.0.0"
+serialize-javascript@^6.0.1:
+ version "6.0.2"
+ resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.2.tgz#defa1e055c83bf6d59ea805d8da862254eb6a6c2"
+ integrity sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==
+ dependencies:
+ randombytes "^2.1.0"
+
set-blocking@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7"
@@ -7657,6 +7725,11 @@ smart-buffer@^4.2.0:
resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.2.0.tgz#6e1d71fa4f18c05f7d0ff216dd16a481d0e8d9ae"
integrity sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==
+smob@^1.0.0:
+ version "1.5.0"
+ resolved "https://registry.yarnpkg.com/smob/-/smob-1.5.0.tgz#85d79a1403abf128d24d3ebc1cdc5e1a9548d3ab"
+ integrity sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==
+
socks-proxy-agent@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/socks-proxy-agent/-/socks-proxy-agent-7.0.0.tgz#dc069ecf34436621acb41e3efa66ca1b5fed15b6"
@@ -7689,6 +7762,14 @@ source-map-support@0.5.13:
buffer-from "^1.0.0"
source-map "^0.6.0"
+source-map-support@~0.5.20:
+ version "0.5.21"
+ resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f"
+ integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==
+ dependencies:
+ buffer-from "^1.0.0"
+ source-map "^0.6.0"
+
source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.1:
version "0.6.1"
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
@@ -7985,6 +8066,16 @@ tempy@1.0.0:
type-fest "^0.16.0"
unique-string "^2.0.0"
+terser@^5.17.4:
+ version "5.30.3"
+ resolved "https://registry.yarnpkg.com/terser/-/terser-5.30.3.tgz#f1bb68ded42408c316b548e3ec2526d7dd03f4d2"
+ integrity sha512-STdUgOUx8rLbMGO9IOwHLpCqolkDITFFQSMYYwKE1N2lY6MVSaeoi10z/EhWxRc6ybqoVmKSkhKYH/XUpl7vSA==
+ dependencies:
+ "@jridgewell/source-map" "^0.3.3"
+ acorn "^8.8.2"
+ commander "^2.20.0"
+ source-map-support "~0.5.20"
+
test-exclude@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-6.0.0.tgz#04a8698661d805ea6fa293b6cb9e63ac044ef15e"