From 00c2bb627bb3f4e852a59e455da5c9787010085b Mon Sep 17 00:00:00 2001
From: Ahmed Abbas
Date: Tue, 24 Feb 2026 23:01:37 +0200
Subject: [PATCH 1/7] feat: add Cloudflare Workers utility package and demo
Add @convertcom/js-sdk-cloudflare with edge-specific helpers for running
Convert experiments inside Cloudflare Workers: KV-backed DataStore adapter,
edge config cache, cookie helpers, and variation-aware cache utilities.
Includes a demo Worker (demo/cloudflare-workers/) demonstrating page-level
A/B testing with HTMLRewriter, asset swaps, split URL redirects, and SPA
injection patterns.
Updates release-please config, publish workflows, and root build scripts
to support the new package.
---
.github/workflows/publish-package.yml | 6 +
.github/workflows/release-please.yml | 1 +
.release-please-manifest.json | 3 +-
demo/cloudflare-workers/README.md | 71 ++
demo/cloudflare-workers/package.json | 21 +
demo/cloudflare-workers/src/index.ts | 294 ++++++
demo/cloudflare-workers/tsconfig.json | 16 +
demo/cloudflare-workers/wrangler.toml | 19 +
package.json | 9 +-
packages/cloudflare/README.md | 87 ++
packages/cloudflare/index.ts | 14 +
packages/cloudflare/package.json | 51 +
packages/cloudflare/src/cache-helpers.ts | 59 ++
packages/cloudflare/src/cookie-helpers.ts | 54 ++
packages/cloudflare/src/edge-config-cache.ts | 104 ++
packages/cloudflare/src/kv-data-store.ts | 98 ++
release-please-config.json | 6 +
yarn.lock | 953 ++++++++++++++++++-
18 files changed, 1848 insertions(+), 18 deletions(-)
create mode 100644 demo/cloudflare-workers/README.md
create mode 100644 demo/cloudflare-workers/package.json
create mode 100644 demo/cloudflare-workers/src/index.ts
create mode 100644 demo/cloudflare-workers/tsconfig.json
create mode 100644 demo/cloudflare-workers/wrangler.toml
create mode 100644 packages/cloudflare/README.md
create mode 100644 packages/cloudflare/index.ts
create mode 100644 packages/cloudflare/package.json
create mode 100644 packages/cloudflare/src/cache-helpers.ts
create mode 100644 packages/cloudflare/src/cookie-helpers.ts
create mode 100644 packages/cloudflare/src/edge-config-cache.ts
create mode 100644 packages/cloudflare/src/kv-data-store.ts
diff --git a/.github/workflows/publish-package.yml b/.github/workflows/publish-package.yml
index 9d672687..70c86d96 100644
--- a/.github/workflows/publish-package.yml
+++ b/.github/workflows/publish-package.yml
@@ -88,6 +88,12 @@ jobs:
(cd packages/experience && npm publish --access public)
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+ - if: ${{ startsWith(env.RELEASE_TAG, 'js-sdk-cloudflare-v') }}
+ run: |
+ yarn cloudflare:build
+ (cd packages/cloudflare && npm publish --access public)
+ env:
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- if: ${{ startsWith(env.RELEASE_TAG, 'js-sdk-v') }}
run: |
yarn sdk:build
diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml
index 9b76dc63..cb9142e2 100644
--- a/.github/workflows/release-please.yml
+++ b/.github/workflows/release-please.yml
@@ -56,5 +56,6 @@ jobs:
packages/data) yarn data:build && (cd packages/data && npm publish --access public) ;;
packages/experience) yarn experience:build && (cd packages/experience && npm publish --access public) ;;
packages/js-sdk) yarn sdk:build && (cd packages/js-sdk && npm publish --access public) ;;
+ packages/cloudflare) yarn cloudflare:build && (cd packages/cloudflare && npm publish --access public) ;;
*) echo "Unknown path: $REL_PATH" && exit 1 ;;
esac
diff --git a/.release-please-manifest.json b/.release-please-manifest.json
index 9ff278d1..c5517b45 100644
--- a/.release-please-manifest.json
+++ b/.release-please-manifest.json
@@ -10,5 +10,6 @@
"packages/segments": "2.1.2",
"packages/api": "2.1.4",
"packages/data": "3.3.3",
- "packages/experience": "2.3.2"
+ "packages/experience": "2.3.2",
+ "packages/cloudflare": "1.0.0"
}
diff --git a/demo/cloudflare-workers/README.md b/demo/cloudflare-workers/README.md
new file mode 100644
index 00000000..ed0996b8
--- /dev/null
+++ b/demo/cloudflare-workers/README.md
@@ -0,0 +1,71 @@
+# Convert SDK — Cloudflare Workers Demo
+
+A complete example of running Convert A/B tests at the Cloudflare edge with zero client-side flicker.
+
+## What This Demonstrates
+
+1. **Page-level A/B test** — HTMLRewriter modifies page content before delivery
+2. **Asset / image swap** — Replace images or stylesheets per variation
+3. **Split URL redirect** — Serve entirely different origin pages transparently
+4. **SPA injection** — Inject bucketing decisions as JSON for client-side SPAs
+5. **Edge caching** — Cache origin responses per variation
+
+## Setup
+
+### 1. Install Dependencies
+
+```bash
+yarn install
+```
+
+### 2. Create a KV Namespace
+
+```bash
+wrangler kv namespace create CONVERT_KV
+```
+
+Copy the output `id` into `wrangler.toml`.
+
+### 3. Configure
+
+Edit `wrangler.toml`:
+- Set your KV namespace ID
+- Set your Convert SDK key (`CONVERT_SDK_KEY`)
+
+### 4. Update Experiment Keys
+
+In `src/index.ts`, replace `'your-experience-key'` with your actual experience key from the Convert dashboard.
+
+### 5. Run
+
+```bash
+# Local development
+yarn dev
+
+# Deploy to production
+yarn deploy
+
+# View live logs
+yarn tail
+```
+
+## How It Works
+
+```
+Visitor → Cloudflare Edge → Worker
+ ├── Read config from KV (cached, ~1ms)
+ ├── Read visitor data from KV (~1ms)
+ ├── SDK: bucket visitor into variation
+ ├── Fetch origin page
+ ├── HTMLRewriter: modify HTML per variation
+ ├── Set visitor cookie
+ └── Respond (total edge overhead: ~5-8ms)
+
+ └── Background (waitUntil):
+ ├── Save bucketing to KV
+ └── Send tracking event to Convert
+```
+
+## Documentation
+
+Full guide: [Cloudflare Workers Edge Experimentation](https://github.com/convertcom/javascript-sdk/wiki/CloudflareWorkers)
diff --git a/demo/cloudflare-workers/package.json b/demo/cloudflare-workers/package.json
new file mode 100644
index 00000000..408873fc
--- /dev/null
+++ b/demo/cloudflare-workers/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "@convertcom/js-sdk-demo-cloudflare-workers",
+ "version": "1.0.0",
+ "author": "Convert Insights, Inc",
+ "license": "Apache-2.0",
+ "private": true,
+ "scripts": {
+ "dev": "wrangler dev",
+ "deploy": "wrangler deploy",
+ "tail": "wrangler tail"
+ },
+ "dependencies": {
+ "@convertcom/js-sdk": "^4.3.4",
+ "@convertcom/js-sdk-cloudflare": "^1.0.0"
+ },
+ "devDependencies": {
+ "@cloudflare/workers-types": "^4.20241205.0",
+ "typescript": "^5.9.3",
+ "wrangler": "^3.99.0"
+ }
+}
diff --git a/demo/cloudflare-workers/src/index.ts b/demo/cloudflare-workers/src/index.ts
new file mode 100644
index 00000000..3ea23509
--- /dev/null
+++ b/demo/cloudflare-workers/src/index.ts
@@ -0,0 +1,294 @@
+/*!
+ * Convert JS SDK - Cloudflare Workers Demo
+ * Version 1.0.0
+ * Copyright(c) 2020 Convert Insights, Inc
+ * License Apache-2.0
+ *
+ * This Worker demonstrates four edge experimentation patterns:
+ *
+ * 1. Page-level A/B test - HTMLRewriter modifies page content at the edge
+ * 2. Asset / image swap - Replace images or stylesheets per variation
+ * 3. Split URL redirect - Serve entirely different origin pages
+ * 4. SPA injection - Inject bucketing decisions as JSON for client-side SPAs
+ *
+ * All patterns are flicker-free because modifications happen server-side
+ * before the response reaches the browser.
+ */
+
+import ConvertSDK from '@convertcom/js-sdk';
+import {
+ KVDataStore,
+ EdgeConfigCache,
+ getVisitorId,
+ setVisitorIdCookie,
+ generateVisitorId,
+ buildCacheKey,
+ setCacheHeaders
+} from '@convertcom/js-sdk-cloudflare';
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+interface Env {
+ CONVERT_KV: KVNamespace;
+ CONVERT_SDK_KEY: string;
+}
+
+// ---------------------------------------------------------------------------
+// SDK Singleton
+// ---------------------------------------------------------------------------
+
+// The SDK instance persists across requests within the same Worker isolate.
+// Config is loaded from KV on the first request and reused afterwards.
+let sdk: InstanceType | null = null;
+let sdkReady = false;
+
+/**
+ * Initialise (or reuse) the SDK singleton.
+ * Uses EdgeConfigCache so the config is served from KV (~1 ms) instead
+ * of fetching from the CDN (~100 ms) on every cold start.
+ */
+async function getSDK(env: Env): Promise> {
+ if (sdk && sdkReady) return sdk;
+
+ const configCache = new EdgeConfigCache(
+ env.CONVERT_KV,
+ env.CONVERT_SDK_KEY,
+ 300 // cache TTL in seconds (5 minutes)
+ );
+ const configData = await configCache.getConfig();
+
+ sdk = new ConvertSDK({
+ data: configData,
+ // Passing data directly avoids the timer-based refresh (setTimeout)
+ // which is meaningless in a stateless Worker environment.
+ network: {tracking: true}
+ });
+ await sdk.onReady();
+ sdkReady = true;
+
+ return sdk;
+}
+
+// ---------------------------------------------------------------------------
+// Worker Entry Point
+// ---------------------------------------------------------------------------
+
+export default {
+ async fetch(
+ request: Request,
+ env: Env,
+ ctx: ExecutionContext
+ ): Promise {
+ const url = new URL(request.url);
+
+ // Only process HTML page requests (skip assets, API calls, etc.)
+ const accept = request.headers.get('Accept') || '';
+ if (
+ !accept.includes('text/html') ||
+ url.pathname.startsWith('/api/') ||
+ url.pathname.match(/\.\w{2,4}$/)
+ ) {
+ return fetch(request);
+ }
+
+ try {
+ // 1. Initialise the SDK
+ const convert = await getSDK(env);
+
+ // 2. Identify the visitor
+ let visitorId = getVisitorId(request);
+ const isNewVisitor = !visitorId;
+ if (isNewVisitor) {
+ visitorId = generateVisitorId();
+ }
+
+ // 3. Load persisted bucketing data from KV
+ const dataStore = new KVDataStore(env.CONVERT_KV);
+ await dataStore.load(visitorId);
+
+ // 4. Create visitor context
+ const context = convert.createContext(visitorId);
+ if (!context) {
+ return fetch(request);
+ }
+
+ // 5. Run experiments
+ // Replace the experience key with your actual experience key from Convert.
+ const variation = context.runExperience('your-experience-key', {
+ locationProperties: {url: url.pathname}
+ });
+
+ // If no valid variation (rule error, bucketing error, or null), passthrough
+ if (!variation || typeof variation === 'string') {
+ return fetch(request);
+ }
+
+ // 6. Fetch the origin page
+ const originResponse = await fetch(request);
+
+ // 7. Apply the variation using HTMLRewriter
+ const modifiedResponse = applyVariation(originResponse, variation);
+
+ // 8. Build response headers (visitor cookie + cache control)
+ const headers = new Headers(modifiedResponse.headers);
+ if (isNewVisitor) {
+ setVisitorIdCookie(headers, visitorId);
+ }
+ setCacheHeaders(headers, 300);
+
+ // 9. Persist bucketing data and release tracking events in the background
+ // waitUntil() ensures these complete even after the response is sent.
+ ctx.waitUntil(
+ Promise.all([
+ dataStore.save(visitorId),
+ context.releaseQueues('edge-request-complete')
+ ])
+ );
+
+ return new Response(modifiedResponse.body, {
+ status: modifiedResponse.status,
+ statusText: modifiedResponse.statusText,
+ headers
+ });
+ } catch (error) {
+ // On any SDK error, serve the origin page unmodified
+ console.error('Convert SDK error:', error);
+ return fetch(request);
+ }
+ }
+} satisfies ExportedHandler;
+
+// ---------------------------------------------------------------------------
+// Pattern 1: Page-Level A/B Test (HTMLRewriter)
+// ---------------------------------------------------------------------------
+
+/**
+ * Modify the HTML response based on the bucketed variation.
+ *
+ * HTMLRewriter is Cloudflare's streaming HTML parser. It modifies the response
+ * as it streams through the Worker -- no buffering, no DOM parsing overhead.
+ * The visitor receives the final page with zero flicker.
+ */
+function applyVariation(response: Response, variation: any): Response {
+ // Map variation keys to HTMLRewriter transformations.
+ // Customize these selectors and content for your experiments.
+ switch (variation.key) {
+ case 'variation-1':
+ return new HTMLRewriter()
+ .on('h1.hero-title', {
+ element(el) {
+ el.setInnerContent('Welcome to the New Experience');
+ }
+ })
+ .on('.cta-button', {
+ element(el) {
+ el.setInnerContent('Get Started Free');
+ el.setAttribute('class', 'cta-button cta-button--primary');
+ }
+ })
+ .transform(response);
+
+ case 'variation-2':
+ return new HTMLRewriter()
+ .on('h1.hero-title', {
+ element(el) {
+ el.setInnerContent('Discover What Works Best');
+ }
+ })
+ .on('img.hero-image', {
+ element(el) {
+ el.setAttribute('src', '/images/hero-v2.webp');
+ el.setAttribute('alt', 'Updated hero image');
+ }
+ })
+ .transform(response);
+
+ default:
+ // Control / original -- return unmodified
+ return response;
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Pattern 2: Asset / Image Swap
+// ---------------------------------------------------------------------------
+
+// To swap assets for an entire variation, use HTMLRewriter on specific selectors:
+//
+// function swapAssets(response: Response): Response {
+// return new HTMLRewriter()
+// .on('link[rel="stylesheet"][href*="main.css"]', {
+// element(el) {
+// el.setAttribute('href', '/css/main-v2.css');
+// }
+// })
+// .on('img[data-testable]', {
+// element(el) {
+// const src = el.getAttribute('src') || '';
+// el.setAttribute('src', src.replace('/images/', '/images/v2/'));
+// }
+// })
+// .transform(response);
+// }
+
+// ---------------------------------------------------------------------------
+// Pattern 3: Split URL Redirect
+// ---------------------------------------------------------------------------
+
+// For split URL tests, serve a completely different origin page:
+//
+// if (variation.key === 'new-checkout') {
+// const newUrl = new URL(request.url);
+// newUrl.pathname = '/checkout-v2' + newUrl.pathname.replace('/checkout', '');
+// return fetch(new Request(newUrl.toString(), request));
+// }
+
+// ---------------------------------------------------------------------------
+// Pattern 4: SPA Injection
+// ---------------------------------------------------------------------------
+
+// For SPAs, inject bucketing decisions as a global JS variable
+// so the client-side app can apply them without a second round-trip:
+//
+// function injectDecisions(response: Response, variations: any[]): Response {
+// const decisions = JSON.stringify(
+// variations.filter((v) => v && typeof v !== 'string')
+// );
+// return new HTMLRewriter()
+// .on('head', {
+// element(el) {
+// el.append(
+// ``,
+// {html: true}
+// );
+// }
+// })
+// .transform(response);
+// }
+
+// ---------------------------------------------------------------------------
+// Pattern 5: Edge-Cached Responses Per Variation
+// ---------------------------------------------------------------------------
+
+// Use Cloudflare's Cache API to cache origin responses per variation.
+// This avoids hitting the origin for every request once a variation
+// has been fetched at least once.
+//
+// async function fetchWithEdgeCache(
+// request: Request,
+// variationKey: string
+// ): Promise {
+// const cache = caches.default;
+// const cacheKey = buildCacheKey(request, variationKey);
+//
+// let response = await cache.match(cacheKey);
+// if (response) return response;
+//
+// response = await fetch(request);
+// const cloned = new Response(response.body, response);
+// cloned.headers.set('Cache-Control', 'public, max-age=300');
+// ctx.waitUntil(cache.put(cacheKey, cloned.clone()));
+// return cloned;
+// }
diff --git a/demo/cloudflare-workers/tsconfig.json b/demo/cloudflare-workers/tsconfig.json
new file mode 100644
index 00000000..4a9d79a3
--- /dev/null
+++ b/demo/cloudflare-workers/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ES2022",
+ "moduleResolution": "bundler",
+ "lib": ["ES2022"],
+ "types": ["@cloudflare/workers-types"],
+ "strict": true,
+ "noEmit": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true
+ },
+ "include": ["src/**/*.ts"]
+}
diff --git a/demo/cloudflare-workers/wrangler.toml b/demo/cloudflare-workers/wrangler.toml
new file mode 100644
index 00000000..973bb005
--- /dev/null
+++ b/demo/cloudflare-workers/wrangler.toml
@@ -0,0 +1,19 @@
+name = "convert-edge-experiments"
+main = "src/index.ts"
+compatibility_date = "2024-12-01"
+
+# KV namespace for config cache and visitor bucketing data.
+# Create with: wrangler kv namespace create CONVERT_KV
+# Then replace the id below with your actual namespace ID.
+[[kv_namespaces]]
+binding = "CONVERT_KV"
+id = "YOUR_KV_NAMESPACE_ID"
+
+# For local development:
+# wrangler kv namespace create CONVERT_KV --preview
+# Then add:
+# preview_id = "YOUR_PREVIEW_NAMESPACE_ID"
+
+[vars]
+# Your Convert SDK key (account_id/project_id)
+CONVERT_SDK_KEY = "YOUR_ACCOUNT_ID/YOUR_PROJECT_ID"
diff --git a/package.json b/package.json
index f12b949d..bbc1a68e 100644
--- a/package.json
+++ b/package.json
@@ -12,7 +12,8 @@
"types:lint": "cd packages/types && yarn lint",
"utils:lint": "cd packages/utils && yarn lint",
"sdk:lint": "cd packages/js-sdk && yarn lint",
- "lint": "yarn enums:lint && yarn types:lint && yarn utils:lint && yarn event:lint && yarn bucketing:lint && yarn logger:lint && yarn rules:lint && yarn segments:lint && yarn api:lint && yarn data:lint && yarn experience:lint && yarn sdk:lint",
+ "cloudflare:lint": "cd packages/cloudflare && yarn lint",
+ "lint": "yarn enums:lint && yarn types:lint && yarn utils:lint && yarn event:lint && yarn bucketing:lint && yarn logger:lint && yarn rules:lint && yarn segments:lint && yarn api:lint && yarn data:lint && yarn experience:lint && yarn sdk:lint && yarn cloudflare:lint",
"api:build": "cd packages/api && yarn build",
"bucketing:build": "cd packages/bucketing && yarn build",
"data:build": "cd packages/data && yarn build",
@@ -25,7 +26,8 @@
"types:build": "cd packages/types && yarn build",
"utils:build": "cd packages/utils && yarn build",
"sdk:build": "cd packages/js-sdk && yarn build",
- "build": "yarn enums:build && yarn types:build && yarn utils:build && yarn event:build && yarn bucketing:build && yarn logger:build && yarn rules:build && yarn segments:build && yarn api:build && yarn data:build && yarn experience:build && yarn sdk:build",
+ "cloudflare:build": "cd packages/cloudflare && yarn build",
+ "build": "yarn enums:build && yarn types:build && yarn utils:build && yarn event:build && yarn bucketing:build && yarn logger:build && yarn rules:build && yarn segments:build && yarn api:build && yarn data:build && yarn experience:build && yarn sdk:build && yarn cloudflare:build",
"api:test": "cd packages/api && yarn test:mocha",
"bucketing:test": "cd packages/bucketing && yarn test:mocha",
"data:test": "cd packages/data && yarn test:mocha",
@@ -43,7 +45,8 @@
"demo:nestjs:start": "cd demo/nestjs && yarn start",
"demo:nextjs:start": "cd demo/nextjs && yarn start",
"demo:remixjs:client:start": "cd demo/remixjs-client-side && yarn dev",
- "demo:remixjs:server:start": "cd demo/remixjs-server-side && yarn dev"
+ "demo:remixjs:server:start": "cd demo/remixjs-server-side && yarn dev",
+ "demo:cloudflare:start": "cd demo/cloudflare-workers && yarn dev"
},
"private": true,
"workspaces": [
diff --git a/packages/cloudflare/README.md b/packages/cloudflare/README.md
new file mode 100644
index 00000000..3550732a
--- /dev/null
+++ b/packages/cloudflare/README.md
@@ -0,0 +1,87 @@
+# @convertcom/js-sdk-cloudflare
+
+Cloudflare Workers utilities for the [Convert JavaScript SDK](https://github.com/convertcom/javascript-sdk). Provides the glue code between the FullStack SDK and Cloudflare-specific APIs (KV, HTMLRewriter, Workers Request/Response).
+
+## Installation
+
+```bash
+npm install @convertcom/js-sdk @convertcom/js-sdk-cloudflare
+# or
+yarn add @convertcom/js-sdk @convertcom/js-sdk-cloudflare
+```
+
+## What's Included
+
+| Export | Purpose |
+|--------|---------|
+| `EdgeConfigCache` | Cache SDK config in KV (~1ms reads vs ~100ms CDN) |
+| `KVDataStore` | KV-backed DataStore adapter for persisting bucketing decisions |
+| `getVisitorId` | Parse visitor ID from Workers Request cookies |
+| `setVisitorIdCookie` | Set visitor ID cookie on Workers Response |
+| `generateVisitorId` | Generate a new UUID visitor ID |
+| `buildCacheKey` | Create variation-aware cache keys for Cloudflare's Cache API |
+| `setCacheHeaders` | Set `Cache-Control` + `Vary: Cookie` headers |
+
+## Quick Start
+
+```typescript
+import ConvertSDK from '@convertcom/js-sdk';
+import {
+ EdgeConfigCache,
+ KVDataStore,
+ getVisitorId,
+ setVisitorIdCookie,
+ generateVisitorId
+} from '@convertcom/js-sdk-cloudflare';
+
+export default {
+ async fetch(request, env, ctx) {
+ // 1. Get config from KV cache
+ const config = await new EdgeConfigCache(env.CONVERT_KV, env.SDK_KEY).getConfig();
+
+ // 2. Init SDK
+ const sdk = new ConvertSDK({ data: config, network: { tracking: true } });
+ await sdk.onReady();
+
+ // 3. Identify visitor
+ const visitorId = getVisitorId(request) || generateVisitorId();
+
+ // 4. Load persisted bucketing from KV
+ const dataStore = new KVDataStore(env.CONVERT_KV);
+ await dataStore.load(visitorId);
+
+ // 5. Run experiment
+ const context = sdk.createContext(visitorId);
+ const variation = context.runExperience('my-experiment');
+
+ // 6. Modify the page with HTMLRewriter (zero flicker)
+ const origin = await fetch(request);
+ let response = origin;
+ if (variation?.key === 'variation-1') {
+ response = new HTMLRewriter()
+ .on('h1', { element(el) { el.setInnerContent('New Headline'); } })
+ .transform(origin);
+ }
+
+ // 7. Set cookie and respond
+ const headers = new Headers(response.headers);
+ setVisitorIdCookie(headers, visitorId);
+
+ // 8. Save KV + flush tracking in background
+ ctx.waitUntil(Promise.all([
+ dataStore.save(visitorId),
+ context.releaseQueues()
+ ]));
+
+ return new Response(response.body, { status: response.status, headers });
+ }
+};
+```
+
+## Documentation
+
+See the full guide: [Cloudflare Workers Edge Experimentation](https://github.com/convertcom/javascript-sdk/wiki/CloudflareWorkers)
+
+## License
+
+Apache-2.0
diff --git a/packages/cloudflare/index.ts b/packages/cloudflare/index.ts
new file mode 100644
index 00000000..640006de
--- /dev/null
+++ b/packages/cloudflare/index.ts
@@ -0,0 +1,14 @@
+/*!
+ * Convert JS SDK - Cloudflare Workers Utilities
+ * Version 1.0.0
+ * Copyright(c) 2020 Convert Insights, Inc
+ * License Apache-2.0
+ */
+export {KVDataStore} from './src/kv-data-store';
+export {EdgeConfigCache} from './src/edge-config-cache';
+export {
+ getVisitorId,
+ setVisitorIdCookie,
+ generateVisitorId
+} from './src/cookie-helpers';
+export {buildCacheKey, setCacheHeaders} from './src/cache-helpers';
diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json
new file mode 100644
index 00000000..039ec5ca
--- /dev/null
+++ b/packages/cloudflare/package.json
@@ -0,0 +1,51 @@
+{
+ "name": "@convertcom/js-sdk-cloudflare",
+ "version": "1.0.0",
+ "description": "Cloudflare Workers utilities for Convert JavaScript SDK",
+ "main": "./lib/index.js",
+ "module": "./lib/index.mjs",
+ "types": "./lib/index.d.ts",
+ "files": [
+ "lib/**/**/*"
+ ],
+ "author": "Convert Insights, Inc",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/convertcom/javascript-sdk.git",
+ "directory": "packages/cloudflare"
+ },
+ "license": "Apache-2.0",
+ "scripts": {
+ "clean": "rm -rf lib",
+ "prebuild": "yarn clean",
+ "build": "rollup -c ../../rollup.config.mjs",
+ "lint": "eslint src",
+ "lint:fix": "yarn lint -- --fix"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "devDependencies": {
+ "@babel/cli": "^7.28.3",
+ "@babel/core": "^7.28.5",
+ "@babel/preset-env": "^7.28.5",
+ "@eslint/eslintrc": "^3.3.1",
+ "@rollup/plugin-babel": "^6.1.0",
+ "@rollup/plugin-commonjs": "^29.0.0",
+ "@rollup/plugin-terser": "^0.4.4",
+ "@typescript-eslint/parser": "^8.46.4",
+ "eslint": "^9.39.1",
+ "eslint-config-prettier": "^10.1.8",
+ "eslint-plugin-prettier": "^5.5.4",
+ "prettier": "^3.6.2",
+ "rollup": "^4.53.2",
+ "rollup-plugin-generate-package-json": "^3.2.0",
+ "rollup-plugin-modify": "^3.0.0",
+ "rollup-plugin-typescript2": "^0.36.0",
+ "typescript": "^5.9.3",
+ "typescript-eslint": "^8.46.4"
+ },
+ "peerDependencies": {
+ "@convertcom/js-sdk": ">=4.0.0"
+ }
+}
diff --git a/packages/cloudflare/src/cache-helpers.ts b/packages/cloudflare/src/cache-helpers.ts
new file mode 100644
index 00000000..039c1260
--- /dev/null
+++ b/packages/cloudflare/src/cache-helpers.ts
@@ -0,0 +1,59 @@
+/*!
+ * Convert JS SDK - Cloudflare Workers Utilities
+ * Version 1.0.0
+ * Copyright(c) 2020 Convert Insights, Inc
+ * License Apache-2.0
+ */
+
+/**
+ * Build a variation-aware cache key for Cloudflare's Cache API.
+ *
+ * Without this, Cloudflare's edge cache would serve the same cached response
+ * to all visitors regardless of their bucketed variation. By appending the
+ * variation key as a query parameter, each variation gets its own cache entry.
+ *
+ * @param request - The original incoming request
+ * @param variationKey - The bucketed variation key (e.g. 'variation-1')
+ * @returns A new Request with the variation key appended as a query parameter
+ *
+ * @example
+ * ```typescript
+ * const cacheKey = buildCacheKey(request, variation.key);
+ * const cache = caches.default;
+ * let response = await cache.match(cacheKey);
+ * if (!response) {
+ * response = await fetch(request);
+ * await cache.put(cacheKey, response.clone());
+ * }
+ * ```
+ */
+export function buildCacheKey(request: Request, variationKey: string): Request {
+ const url = new URL(request.url);
+ url.searchParams.set('_conv_v', variationKey);
+ return new Request(url.toString(), {
+ method: request.method,
+ headers: request.headers
+ });
+}
+
+/**
+ * Set cache control headers appropriate for A/B tested responses.
+ *
+ * Adds `Vary: Cookie` so that Cloudflare's cache correctly separates
+ * responses by visitor cookie. Sets a configurable max-age.
+ *
+ * @param headers - The response Headers object to modify
+ * @param maxAge - Cache max-age in seconds (default: 300 = 5 minutes)
+ */
+export function setCacheHeaders(headers: Headers, maxAge = 300): void {
+ headers.set('Cache-Control', `public, max-age=${maxAge}`);
+ // Ensure Vary includes Cookie so cached responses respect visitor bucketing
+ const existingVary = headers.get('Vary');
+ if (existingVary) {
+ if (!existingVary.toLowerCase().includes('cookie')) {
+ headers.set('Vary', `${existingVary}, Cookie`);
+ }
+ } else {
+ headers.set('Vary', 'Cookie');
+ }
+}
diff --git a/packages/cloudflare/src/cookie-helpers.ts b/packages/cloudflare/src/cookie-helpers.ts
new file mode 100644
index 00000000..a35ea7f8
--- /dev/null
+++ b/packages/cloudflare/src/cookie-helpers.ts
@@ -0,0 +1,54 @@
+/*!
+ * Convert JS SDK - Cloudflare Workers Utilities
+ * Version 1.0.0
+ * Copyright(c) 2020 Convert Insights, Inc
+ * License Apache-2.0
+ */
+
+const DEFAULT_COOKIE_NAME = 'convert_visitor_id';
+const DEFAULT_MAX_AGE = 31536000; // 1 year in seconds
+
+/**
+ * Extract the visitor ID from a Workers Request cookie header.
+ *
+ * @param request - The incoming Workers Request
+ * @param cookieName - Cookie name to read (default: 'convert_visitor_id')
+ * @returns The visitor ID string, or null if not found
+ */
+export function getVisitorId(
+ request: Request,
+ cookieName = DEFAULT_COOKIE_NAME
+): string | null {
+ const cookie = request.headers.get('Cookie') || '';
+ const escaped = cookieName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ const match = cookie.match(new RegExp(`(?:^|;)\\s*${escaped}=([^;]+)`));
+ return match ? decodeURIComponent(match[1]) : null;
+}
+
+/**
+ * Append a Set-Cookie header for the visitor ID on the response.
+ *
+ * @param headers - The response Headers object to modify
+ * @param visitorId - The visitor ID to persist
+ * @param cookieName - Cookie name to set (default: 'convert_visitor_id')
+ * @param maxAge - Cookie max-age in seconds (default: 31536000 = 1 year)
+ */
+export function setVisitorIdCookie(
+ headers: Headers,
+ visitorId: string,
+ cookieName = DEFAULT_COOKIE_NAME,
+ maxAge = DEFAULT_MAX_AGE
+): void {
+ headers.append(
+ 'Set-Cookie',
+ `${cookieName}=${encodeURIComponent(visitorId)}; Path=/; Max-Age=${maxAge}; SameSite=Lax; Secure; HttpOnly`
+ );
+}
+
+/**
+ * Generate a new unique visitor ID using crypto.randomUUID().
+ * Available in all Cloudflare Workers runtimes.
+ */
+export function generateVisitorId(): string {
+ return crypto.randomUUID();
+}
diff --git a/packages/cloudflare/src/edge-config-cache.ts b/packages/cloudflare/src/edge-config-cache.ts
new file mode 100644
index 00000000..e3caf58c
--- /dev/null
+++ b/packages/cloudflare/src/edge-config-cache.ts
@@ -0,0 +1,104 @@
+/*!
+ * Convert JS SDK - Cloudflare Workers Utilities
+ * Version 1.0.0
+ * Copyright(c) 2020 Convert Insights, Inc
+ * License Apache-2.0
+ */
+
+/**
+ * Minimal interface compatible with Cloudflare Workers KVNamespace.
+ */
+interface KVNamespaceLike {
+ get(key: string): Promise;
+ put(
+ key: string,
+ value: string,
+ options?: {expirationTtl?: number}
+ ): Promise;
+}
+
+const DEFAULT_CONFIG_ENDPOINT = 'https://cdn-4.convertexperiments.com/api/v1';
+
+/**
+ * Caches the Convert SDK configuration in Cloudflare KV.
+ *
+ * Instead of fetching config from the CDN on every Worker invocation,
+ * this cache stores it in KV with a TTL. This reduces latency from
+ * ~100ms (CDN round-trip) to ~1ms (edge KV read).
+ *
+ * @example
+ * ```typescript
+ * const configCache = new EdgeConfigCache(env.CONVERT_KV, 'YOUR_SDK_KEY');
+ * const configData = await configCache.getConfig();
+ *
+ * const sdk = new ConvertSDK({ data: configData });
+ * ```
+ */
+export class EdgeConfigCache {
+ private _kv: KVNamespaceLike;
+ private _sdkKey: string;
+ private _ttl: number;
+ private _configEndpoint: string;
+
+ /**
+ * @param kv - A Cloudflare KV namespace binding
+ * @param sdkKey - Your Convert SDK key (e.g. 'ACCOUNT_ID/PROJECT_ID')
+ * @param ttl - Cache TTL in seconds (default: 300 = 5 minutes)
+ * @param configEndpoint - Override the config CDN endpoint
+ */
+ constructor(
+ kv: KVNamespaceLike,
+ sdkKey: string,
+ ttl = 300,
+ configEndpoint?: string
+ ) {
+ this._kv = kv;
+ this._sdkKey = sdkKey;
+ this._ttl = ttl;
+ this._configEndpoint = configEndpoint || DEFAULT_CONFIG_ENDPOINT;
+ }
+
+ /**
+ * Get the SDK configuration, serving from KV cache when available.
+ * Falls back to fetching from the Convert CDN if cache is empty or expired.
+ */
+ async getConfig(): Promise {
+ const cacheKey = `config:${this._sdkKey}`;
+
+ // Try KV cache first
+ const cached = await this._kv.get(cacheKey);
+ if (cached) return JSON.parse(cached);
+
+ // Cache miss: fetch from CDN and store in KV
+ return this._fetchAndCache(cacheKey);
+ }
+
+ /**
+ * Force-refresh the configuration from the Convert CDN.
+ * Use this for manual cache invalidation.
+ */
+ async refreshConfig(): Promise {
+ const cacheKey = `config:${this._sdkKey}`;
+ return this._fetchAndCache(cacheKey);
+ }
+
+ private async _fetchAndCache(cacheKey: string): Promise {
+ const url = `${this._configEndpoint}/config/${this._sdkKey}`;
+ const response = await fetch(url, {
+ headers: {'Content-Type': 'application/json'}
+ });
+
+ if (!response.ok) {
+ throw new Error(
+ `Convert config fetch failed: ${response.status} ${response.statusText}`
+ );
+ }
+
+ const data = await response.json();
+ await this._kv.put(cacheKey, JSON.stringify(data), {
+ expirationTtl: this._ttl
+ });
+
+ return data;
+ }
+}
diff --git a/packages/cloudflare/src/kv-data-store.ts b/packages/cloudflare/src/kv-data-store.ts
new file mode 100644
index 00000000..4bde30eb
--- /dev/null
+++ b/packages/cloudflare/src/kv-data-store.ts
@@ -0,0 +1,98 @@
+/*!
+ * Convert JS SDK - Cloudflare Workers Utilities
+ * Version 1.0.0
+ * Copyright(c) 2020 Convert Insights, Inc
+ * License Apache-2.0
+ */
+
+/**
+ * Minimal interface compatible with Cloudflare Workers KVNamespace.
+ * Avoids a hard dependency on @cloudflare/workers-types.
+ */
+interface KVNamespaceLike {
+ get(key: string): Promise;
+ put(
+ key: string,
+ value: string,
+ options?: {expirationTtl?: number}
+ ): Promise;
+}
+
+/**
+ * A DataStore adapter for Cloudflare Workers KV.
+ *
+ * The Convert SDK expects a synchronous DataStore (get/set).
+ * Since KV is async, this adapter works in three phases:
+ * 1. load() - async read from KV into memory (call before SDK operations)
+ * 2. get/set - sync read/write on in-memory snapshot (used by SDK)
+ * 3. save() - async write from memory back to KV (call after SDK operations)
+ *
+ * @example
+ * ```typescript
+ * const dataStore = new KVDataStore(env.CONVERT_KV);
+ * await dataStore.load(visitorId);
+ *
+ * const sdk = new ConvertSDK({ data: configData, dataStore });
+ * // ... run experiments ...
+ *
+ * await dataStore.save(visitorId);
+ * ```
+ */
+export class KVDataStore {
+ private _data: Record = {};
+ private _kv: KVNamespaceLike;
+ private _dirty = false;
+ private _prefix: string;
+
+ /**
+ * @param kv - A Cloudflare KV namespace binding
+ * @param prefix - Key prefix for KV entries (default: 'visitor')
+ */
+ constructor(kv: KVNamespaceLike, prefix = 'visitor') {
+ this._kv = kv;
+ this._prefix = prefix;
+ }
+
+ /**
+ * Load visitor data from KV into memory.
+ * Call this BEFORE creating the SDK context.
+ */
+ async load(visitorId: string): Promise {
+ const raw = await this._kv.get(`${this._prefix}:${visitorId}`);
+ this._data = raw ? JSON.parse(raw) : {};
+ this._dirty = false;
+ }
+
+ /**
+ * Get a value by key (synchronous, reads from memory).
+ * Called internally by the SDK.
+ */
+ get(key: string): any {
+ return this._data[key];
+ }
+
+ /**
+ * Set a value by key (synchronous, writes to memory).
+ * Called internally by the SDK.
+ */
+ set(key: string, value: any): void {
+ this._data[key] = value;
+ this._dirty = true;
+ }
+
+ /**
+ * Save visitor data from memory back to KV.
+ * Call this AFTER SDK operations are complete.
+ * @param visitorId - The visitor ID used in load()
+ * @param ttl - KV entry TTL in seconds (default: 86400 = 24 hours)
+ */
+ async save(visitorId: string, ttl = 86400): Promise {
+ if (!this._dirty) return;
+ await this._kv.put(
+ `${this._prefix}:${visitorId}`,
+ JSON.stringify(this._data),
+ {expirationTtl: ttl}
+ );
+ this._dirty = false;
+ }
+}
diff --git a/release-please-config.json b/release-please-config.json
index 4621a199..7eb84481 100644
--- a/release-please-config.json
+++ b/release-please-config.json
@@ -74,6 +74,12 @@
"draft": false,
"prerelease": false,
"monorepo-tags": true
+ },
+ "packages/cloudflare": {
+ "changelog-path": "CHANGELOG.md",
+ "draft": false,
+ "prerelease": false,
+ "monorepo-tags": true
}
},
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json"
diff --git a/yarn.lock b/yarn.lock
index 90f8c33b..ec41939c 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3185,6 +3185,70 @@ __metadata:
languageName: node
linkType: hard
+"@cloudflare/kv-asset-handler@npm:0.3.4":
+ version: 0.3.4
+ resolution: "@cloudflare/kv-asset-handler@npm:0.3.4"
+ dependencies:
+ mime: "npm:^3.0.0"
+ checksum: 10c0/5895d28a4489f470acd217485e3ffbbe2e4a63b0772bb2925ee0f646b6ccce1fd224e07c4610cf514b5e7d0100053c81745a21c0af9a89a98fe16990a4e38ce7
+ languageName: node
+ linkType: hard
+
+"@cloudflare/unenv-preset@npm:2.0.2":
+ version: 2.0.2
+ resolution: "@cloudflare/unenv-preset@npm:2.0.2"
+ peerDependencies:
+ unenv: 2.0.0-rc.14
+ workerd: ^1.20250124.0
+ peerDependenciesMeta:
+ workerd:
+ optional: true
+ checksum: 10c0/8efc49c9c8eec3c03e75bfc65115c54635aef886461f605e4d6e72a594c6e6dd20e05cdedb174feec7c4d7a88ef962eed7380e64c48209f31d56d34ee479a617
+ languageName: node
+ linkType: hard
+
+"@cloudflare/workerd-darwin-64@npm:1.20250718.0":
+ version: 1.20250718.0
+ resolution: "@cloudflare/workerd-darwin-64@npm:1.20250718.0"
+ conditions: os=darwin & cpu=x64
+ languageName: node
+ linkType: hard
+
+"@cloudflare/workerd-darwin-arm64@npm:1.20250718.0":
+ version: 1.20250718.0
+ resolution: "@cloudflare/workerd-darwin-arm64@npm:1.20250718.0"
+ conditions: os=darwin & cpu=arm64
+ languageName: node
+ linkType: hard
+
+"@cloudflare/workerd-linux-64@npm:1.20250718.0":
+ version: 1.20250718.0
+ resolution: "@cloudflare/workerd-linux-64@npm:1.20250718.0"
+ conditions: os=linux & cpu=x64
+ languageName: node
+ linkType: hard
+
+"@cloudflare/workerd-linux-arm64@npm:1.20250718.0":
+ version: 1.20250718.0
+ resolution: "@cloudflare/workerd-linux-arm64@npm:1.20250718.0"
+ conditions: os=linux & cpu=arm64
+ languageName: node
+ linkType: hard
+
+"@cloudflare/workerd-windows-64@npm:1.20250718.0":
+ version: 1.20250718.0
+ resolution: "@cloudflare/workerd-windows-64@npm:1.20250718.0"
+ conditions: os=win32 & cpu=x64
+ languageName: node
+ linkType: hard
+
+"@cloudflare/workers-types@npm:^4.20241205.0":
+ version: 4.20260303.0
+ resolution: "@cloudflare/workers-types@npm:4.20260303.0"
+ checksum: 10c0/ecf136da9474ea835eb2dd7ee05c7641489a35d26c861d4d1035ad9b5b42070a345fc924a675de815d3f6a02d5337a364e38678fc24fd1eec6660a7c5cc44336
+ languageName: node
+ linkType: hard
+
"@colors/colors@npm:1.5.0":
version: 1.5.0
resolution: "@colors/colors@npm:1.5.0"
@@ -3289,6 +3353,33 @@ __metadata:
languageName: unknown
linkType: soft
+"@convertcom/js-sdk-cloudflare@npm:^1.0.0, @convertcom/js-sdk-cloudflare@workspace:packages/cloudflare":
+ version: 0.0.0-use.local
+ resolution: "@convertcom/js-sdk-cloudflare@workspace:packages/cloudflare"
+ dependencies:
+ "@babel/cli": "npm:^7.28.3"
+ "@babel/core": "npm:^7.28.5"
+ "@babel/preset-env": "npm:^7.28.5"
+ "@eslint/eslintrc": "npm:^3.3.1"
+ "@rollup/plugin-babel": "npm:^6.1.0"
+ "@rollup/plugin-commonjs": "npm:^29.0.0"
+ "@rollup/plugin-terser": "npm:^0.4.4"
+ "@typescript-eslint/parser": "npm:^8.46.4"
+ eslint: "npm:^9.39.1"
+ eslint-config-prettier: "npm:^10.1.8"
+ eslint-plugin-prettier: "npm:^5.5.4"
+ prettier: "npm:^3.6.2"
+ rollup: "npm:^4.53.2"
+ rollup-plugin-generate-package-json: "npm:^3.2.0"
+ rollup-plugin-modify: "npm:^3.0.0"
+ rollup-plugin-typescript2: "npm:^0.36.0"
+ typescript: "npm:^5.9.3"
+ typescript-eslint: "npm:^8.46.4"
+ peerDependencies:
+ "@convertcom/js-sdk": ">=4.0.0"
+ languageName: unknown
+ linkType: soft
+
"@convertcom/js-sdk-data@workspace:packages/data":
version: 0.0.0-use.local
resolution: "@convertcom/js-sdk-data@workspace:packages/data"
@@ -3341,6 +3432,18 @@ __metadata:
languageName: unknown
linkType: soft
+"@convertcom/js-sdk-demo-cloudflare-workers@workspace:demo/cloudflare-workers":
+ version: 0.0.0-use.local
+ resolution: "@convertcom/js-sdk-demo-cloudflare-workers@workspace:demo/cloudflare-workers"
+ dependencies:
+ "@cloudflare/workers-types": "npm:^4.20241205.0"
+ "@convertcom/js-sdk": "npm:^4.3.4"
+ "@convertcom/js-sdk-cloudflare": "npm:^1.0.0"
+ typescript: "npm:^5.9.3"
+ wrangler: "npm:^3.99.0"
+ languageName: unknown
+ linkType: soft
+
"@convertcom/js-sdk-demo-nodejs@workspace:demo/nodejs":
version: 0.0.0-use.local
resolution: "@convertcom/js-sdk-demo-nodejs@workspace:demo/nodejs"
@@ -3714,7 +3817,7 @@ __metadata:
languageName: unknown
linkType: soft
-"@convertcom/js-sdk@workspace:packages/js-sdk":
+"@convertcom/js-sdk@npm:^4.3.4, @convertcom/js-sdk@workspace:packages/js-sdk":
version: 0.0.0-use.local
resolution: "@convertcom/js-sdk@workspace:packages/js-sdk"
dependencies:
@@ -3791,7 +3894,7 @@ __metadata:
languageName: unknown
linkType: soft
-"@cspotcode/source-map-support@npm:^0.8.0":
+"@cspotcode/source-map-support@npm:0.8.1, @cspotcode/source-map-support@npm:^0.8.0":
version: 0.8.1
resolution: "@cspotcode/source-map-support@npm:0.8.1"
dependencies:
@@ -3983,6 +4086,15 @@ __metadata:
languageName: node
linkType: hard
+"@emnapi/runtime@npm:^1.2.0":
+ version: 1.8.1
+ resolution: "@emnapi/runtime@npm:1.8.1"
+ dependencies:
+ tslib: "npm:^2.4.0"
+ checksum: 10c0/f4929d75e37aafb24da77d2f58816761fe3f826aad2e37fa6d4421dac9060cbd5098eea1ac3c9ecc4526b89deb58153852fa432f87021dc57863f2ff726d713f
+ languageName: node
+ linkType: hard
+
"@emnapi/runtime@npm:^1.4.3, @emnapi/runtime@npm:^1.5.0, @emnapi/runtime@npm:^1.6.0, @emnapi/runtime@npm:^1.7.0":
version: 1.7.0
resolution: "@emnapi/runtime@npm:1.7.0"
@@ -4031,6 +4143,27 @@ __metadata:
languageName: node
linkType: hard
+"@esbuild-plugins/node-globals-polyfill@npm:0.2.3":
+ version: 0.2.3
+ resolution: "@esbuild-plugins/node-globals-polyfill@npm:0.2.3"
+ peerDependencies:
+ esbuild: "*"
+ checksum: 10c0/da3591b3943076a8d4a78320c176f37e5a5802512e2c3a792d4dfe495c051e097668dc56513160147b43e86987078559490164905ef41d1326ac0a9e7a6498ac
+ languageName: node
+ linkType: hard
+
+"@esbuild-plugins/node-modules-polyfill@npm:0.2.2":
+ version: 0.2.2
+ resolution: "@esbuild-plugins/node-modules-polyfill@npm:0.2.2"
+ dependencies:
+ escape-string-regexp: "npm:^4.0.0"
+ rollup-plugin-node-polyfills: "npm:^0.2.1"
+ peerDependencies:
+ esbuild: "*"
+ checksum: 10c0/8573eb409d19769ea6a2f621d8d7e344d84a9f19d03f37f4ace053e23dab8eeea08feea871c1704a2d39c0859adadfba808b59a50de4d227cb3879dbd90e7f52
+ languageName: node
+ linkType: hard
+
"@esbuild/aix-ppc64@npm:0.19.12":
version: 0.19.12
resolution: "@esbuild/aix-ppc64@npm:0.19.12"
@@ -4052,6 +4185,13 @@ __metadata:
languageName: node
linkType: hard
+"@esbuild/android-arm64@npm:0.17.19":
+ version: 0.17.19
+ resolution: "@esbuild/android-arm64@npm:0.17.19"
+ conditions: os=android & cpu=arm64
+ languageName: node
+ linkType: hard
+
"@esbuild/android-arm64@npm:0.17.6":
version: 0.17.6
resolution: "@esbuild/android-arm64@npm:0.17.6"
@@ -4080,6 +4220,13 @@ __metadata:
languageName: node
linkType: hard
+"@esbuild/android-arm@npm:0.17.19":
+ version: 0.17.19
+ resolution: "@esbuild/android-arm@npm:0.17.19"
+ conditions: os=android & cpu=arm
+ languageName: node
+ linkType: hard
+
"@esbuild/android-arm@npm:0.17.6":
version: 0.17.6
resolution: "@esbuild/android-arm@npm:0.17.6"
@@ -4108,6 +4255,13 @@ __metadata:
languageName: node
linkType: hard
+"@esbuild/android-x64@npm:0.17.19":
+ version: 0.17.19
+ resolution: "@esbuild/android-x64@npm:0.17.19"
+ conditions: os=android & cpu=x64
+ languageName: node
+ linkType: hard
+
"@esbuild/android-x64@npm:0.17.6":
version: 0.17.6
resolution: "@esbuild/android-x64@npm:0.17.6"
@@ -4136,6 +4290,13 @@ __metadata:
languageName: node
linkType: hard
+"@esbuild/darwin-arm64@npm:0.17.19":
+ version: 0.17.19
+ resolution: "@esbuild/darwin-arm64@npm:0.17.19"
+ conditions: os=darwin & cpu=arm64
+ languageName: node
+ linkType: hard
+
"@esbuild/darwin-arm64@npm:0.17.6":
version: 0.17.6
resolution: "@esbuild/darwin-arm64@npm:0.17.6"
@@ -4164,6 +4325,13 @@ __metadata:
languageName: node
linkType: hard
+"@esbuild/darwin-x64@npm:0.17.19":
+ version: 0.17.19
+ resolution: "@esbuild/darwin-x64@npm:0.17.19"
+ conditions: os=darwin & cpu=x64
+ languageName: node
+ linkType: hard
+
"@esbuild/darwin-x64@npm:0.17.6":
version: 0.17.6
resolution: "@esbuild/darwin-x64@npm:0.17.6"
@@ -4192,6 +4360,13 @@ __metadata:
languageName: node
linkType: hard
+"@esbuild/freebsd-arm64@npm:0.17.19":
+ version: 0.17.19
+ resolution: "@esbuild/freebsd-arm64@npm:0.17.19"
+ conditions: os=freebsd & cpu=arm64
+ languageName: node
+ linkType: hard
+
"@esbuild/freebsd-arm64@npm:0.17.6":
version: 0.17.6
resolution: "@esbuild/freebsd-arm64@npm:0.17.6"
@@ -4220,6 +4395,13 @@ __metadata:
languageName: node
linkType: hard
+"@esbuild/freebsd-x64@npm:0.17.19":
+ version: 0.17.19
+ resolution: "@esbuild/freebsd-x64@npm:0.17.19"
+ conditions: os=freebsd & cpu=x64
+ languageName: node
+ linkType: hard
+
"@esbuild/freebsd-x64@npm:0.17.6":
version: 0.17.6
resolution: "@esbuild/freebsd-x64@npm:0.17.6"
@@ -4248,6 +4430,13 @@ __metadata:
languageName: node
linkType: hard
+"@esbuild/linux-arm64@npm:0.17.19":
+ version: 0.17.19
+ resolution: "@esbuild/linux-arm64@npm:0.17.19"
+ conditions: os=linux & cpu=arm64
+ languageName: node
+ linkType: hard
+
"@esbuild/linux-arm64@npm:0.17.6":
version: 0.17.6
resolution: "@esbuild/linux-arm64@npm:0.17.6"
@@ -4276,6 +4465,13 @@ __metadata:
languageName: node
linkType: hard
+"@esbuild/linux-arm@npm:0.17.19":
+ version: 0.17.19
+ resolution: "@esbuild/linux-arm@npm:0.17.19"
+ conditions: os=linux & cpu=arm
+ languageName: node
+ linkType: hard
+
"@esbuild/linux-arm@npm:0.17.6":
version: 0.17.6
resolution: "@esbuild/linux-arm@npm:0.17.6"
@@ -4304,6 +4500,13 @@ __metadata:
languageName: node
linkType: hard
+"@esbuild/linux-ia32@npm:0.17.19":
+ version: 0.17.19
+ resolution: "@esbuild/linux-ia32@npm:0.17.19"
+ conditions: os=linux & cpu=ia32
+ languageName: node
+ linkType: hard
+
"@esbuild/linux-ia32@npm:0.17.6":
version: 0.17.6
resolution: "@esbuild/linux-ia32@npm:0.17.6"
@@ -4332,6 +4535,13 @@ __metadata:
languageName: node
linkType: hard
+"@esbuild/linux-loong64@npm:0.17.19":
+ version: 0.17.19
+ resolution: "@esbuild/linux-loong64@npm:0.17.19"
+ conditions: os=linux & cpu=loong64
+ languageName: node
+ linkType: hard
+
"@esbuild/linux-loong64@npm:0.17.6":
version: 0.17.6
resolution: "@esbuild/linux-loong64@npm:0.17.6"
@@ -4360,6 +4570,13 @@ __metadata:
languageName: node
linkType: hard
+"@esbuild/linux-mips64el@npm:0.17.19":
+ version: 0.17.19
+ resolution: "@esbuild/linux-mips64el@npm:0.17.19"
+ conditions: os=linux & cpu=mips64el
+ languageName: node
+ linkType: hard
+
"@esbuild/linux-mips64el@npm:0.17.6":
version: 0.17.6
resolution: "@esbuild/linux-mips64el@npm:0.17.6"
@@ -4388,6 +4605,13 @@ __metadata:
languageName: node
linkType: hard
+"@esbuild/linux-ppc64@npm:0.17.19":
+ version: 0.17.19
+ resolution: "@esbuild/linux-ppc64@npm:0.17.19"
+ conditions: os=linux & cpu=ppc64
+ languageName: node
+ linkType: hard
+
"@esbuild/linux-ppc64@npm:0.17.6":
version: 0.17.6
resolution: "@esbuild/linux-ppc64@npm:0.17.6"
@@ -4416,6 +4640,13 @@ __metadata:
languageName: node
linkType: hard
+"@esbuild/linux-riscv64@npm:0.17.19":
+ version: 0.17.19
+ resolution: "@esbuild/linux-riscv64@npm:0.17.19"
+ conditions: os=linux & cpu=riscv64
+ languageName: node
+ linkType: hard
+
"@esbuild/linux-riscv64@npm:0.17.6":
version: 0.17.6
resolution: "@esbuild/linux-riscv64@npm:0.17.6"
@@ -4444,6 +4675,13 @@ __metadata:
languageName: node
linkType: hard
+"@esbuild/linux-s390x@npm:0.17.19":
+ version: 0.17.19
+ resolution: "@esbuild/linux-s390x@npm:0.17.19"
+ conditions: os=linux & cpu=s390x
+ languageName: node
+ linkType: hard
+
"@esbuild/linux-s390x@npm:0.17.6":
version: 0.17.6
resolution: "@esbuild/linux-s390x@npm:0.17.6"
@@ -4472,6 +4710,13 @@ __metadata:
languageName: node
linkType: hard
+"@esbuild/linux-x64@npm:0.17.19":
+ version: 0.17.19
+ resolution: "@esbuild/linux-x64@npm:0.17.19"
+ conditions: os=linux & cpu=x64
+ languageName: node
+ linkType: hard
+
"@esbuild/linux-x64@npm:0.17.6":
version: 0.17.6
resolution: "@esbuild/linux-x64@npm:0.17.6"
@@ -4507,6 +4752,13 @@ __metadata:
languageName: node
linkType: hard
+"@esbuild/netbsd-x64@npm:0.17.19":
+ version: 0.17.19
+ resolution: "@esbuild/netbsd-x64@npm:0.17.19"
+ conditions: os=netbsd & cpu=x64
+ languageName: node
+ linkType: hard
+
"@esbuild/netbsd-x64@npm:0.17.6":
version: 0.17.6
resolution: "@esbuild/netbsd-x64@npm:0.17.6"
@@ -4542,6 +4794,13 @@ __metadata:
languageName: node
linkType: hard
+"@esbuild/openbsd-x64@npm:0.17.19":
+ version: 0.17.19
+ resolution: "@esbuild/openbsd-x64@npm:0.17.19"
+ conditions: os=openbsd & cpu=x64
+ languageName: node
+ linkType: hard
+
"@esbuild/openbsd-x64@npm:0.17.6":
version: 0.17.6
resolution: "@esbuild/openbsd-x64@npm:0.17.6"
@@ -4577,6 +4836,13 @@ __metadata:
languageName: node
linkType: hard
+"@esbuild/sunos-x64@npm:0.17.19":
+ version: 0.17.19
+ resolution: "@esbuild/sunos-x64@npm:0.17.19"
+ conditions: os=sunos & cpu=x64
+ languageName: node
+ linkType: hard
+
"@esbuild/sunos-x64@npm:0.17.6":
version: 0.17.6
resolution: "@esbuild/sunos-x64@npm:0.17.6"
@@ -4605,6 +4871,13 @@ __metadata:
languageName: node
linkType: hard
+"@esbuild/win32-arm64@npm:0.17.19":
+ version: 0.17.19
+ resolution: "@esbuild/win32-arm64@npm:0.17.19"
+ conditions: os=win32 & cpu=arm64
+ languageName: node
+ linkType: hard
+
"@esbuild/win32-arm64@npm:0.17.6":
version: 0.17.6
resolution: "@esbuild/win32-arm64@npm:0.17.6"
@@ -4633,6 +4906,13 @@ __metadata:
languageName: node
linkType: hard
+"@esbuild/win32-ia32@npm:0.17.19":
+ version: 0.17.19
+ resolution: "@esbuild/win32-ia32@npm:0.17.19"
+ conditions: os=win32 & cpu=ia32
+ languageName: node
+ linkType: hard
+
"@esbuild/win32-ia32@npm:0.17.6":
version: 0.17.6
resolution: "@esbuild/win32-ia32@npm:0.17.6"
@@ -4661,6 +4941,13 @@ __metadata:
languageName: node
linkType: hard
+"@esbuild/win32-x64@npm:0.17.19":
+ version: 0.17.19
+ resolution: "@esbuild/win32-x64@npm:0.17.19"
+ conditions: os=win32 & cpu=x64
+ languageName: node
+ linkType: hard
+
"@esbuild/win32-x64@npm:0.17.6":
version: 0.17.6
resolution: "@esbuild/win32-x64@npm:0.17.6"
@@ -4819,6 +5106,13 @@ __metadata:
languageName: node
linkType: hard
+"@fastify/busboy@npm:^2.0.0":
+ version: 2.1.1
+ resolution: "@fastify/busboy@npm:2.1.1"
+ checksum: 10c0/6f8027a8cba7f8f7b736718b013f5a38c0476eea67034c94a0d3c375e2b114366ad4419e6a6fa7ffc2ef9c6d3e0435d76dd584a7a1cbac23962fda7650b579e3
+ languageName: node
+ linkType: hard
+
"@humanfs/core@npm:^0.19.1":
version: 0.19.1
resolution: "@humanfs/core@npm:0.19.1"
@@ -4875,6 +5169,18 @@ __metadata:
languageName: node
linkType: hard
+"@img/sharp-darwin-arm64@npm:0.33.5":
+ version: 0.33.5
+ resolution: "@img/sharp-darwin-arm64@npm:0.33.5"
+ dependencies:
+ "@img/sharp-libvips-darwin-arm64": "npm:1.0.4"
+ dependenciesMeta:
+ "@img/sharp-libvips-darwin-arm64":
+ optional: true
+ conditions: os=darwin & cpu=arm64
+ languageName: node
+ linkType: hard
+
"@img/sharp-darwin-arm64@npm:0.34.5":
version: 0.34.5
resolution: "@img/sharp-darwin-arm64@npm:0.34.5"
@@ -4887,6 +5193,18 @@ __metadata:
languageName: node
linkType: hard
+"@img/sharp-darwin-x64@npm:0.33.5":
+ version: 0.33.5
+ resolution: "@img/sharp-darwin-x64@npm:0.33.5"
+ dependencies:
+ "@img/sharp-libvips-darwin-x64": "npm:1.0.4"
+ dependenciesMeta:
+ "@img/sharp-libvips-darwin-x64":
+ optional: true
+ conditions: os=darwin & cpu=x64
+ languageName: node
+ linkType: hard
+
"@img/sharp-darwin-x64@npm:0.34.5":
version: 0.34.5
resolution: "@img/sharp-darwin-x64@npm:0.34.5"
@@ -4899,6 +5217,13 @@ __metadata:
languageName: node
linkType: hard
+"@img/sharp-libvips-darwin-arm64@npm:1.0.4":
+ version: 1.0.4
+ resolution: "@img/sharp-libvips-darwin-arm64@npm:1.0.4"
+ conditions: os=darwin & cpu=arm64
+ languageName: node
+ linkType: hard
+
"@img/sharp-libvips-darwin-arm64@npm:1.2.4":
version: 1.2.4
resolution: "@img/sharp-libvips-darwin-arm64@npm:1.2.4"
@@ -4906,6 +5231,13 @@ __metadata:
languageName: node
linkType: hard
+"@img/sharp-libvips-darwin-x64@npm:1.0.4":
+ version: 1.0.4
+ resolution: "@img/sharp-libvips-darwin-x64@npm:1.0.4"
+ conditions: os=darwin & cpu=x64
+ languageName: node
+ linkType: hard
+
"@img/sharp-libvips-darwin-x64@npm:1.2.4":
version: 1.2.4
resolution: "@img/sharp-libvips-darwin-x64@npm:1.2.4"
@@ -4913,6 +5245,13 @@ __metadata:
languageName: node
linkType: hard
+"@img/sharp-libvips-linux-arm64@npm:1.0.4":
+ version: 1.0.4
+ resolution: "@img/sharp-libvips-linux-arm64@npm:1.0.4"
+ conditions: os=linux & cpu=arm64 & libc=glibc
+ languageName: node
+ linkType: hard
+
"@img/sharp-libvips-linux-arm64@npm:1.2.4":
version: 1.2.4
resolution: "@img/sharp-libvips-linux-arm64@npm:1.2.4"
@@ -4920,6 +5259,13 @@ __metadata:
languageName: node
linkType: hard
+"@img/sharp-libvips-linux-arm@npm:1.0.5":
+ version: 1.0.5
+ resolution: "@img/sharp-libvips-linux-arm@npm:1.0.5"
+ conditions: os=linux & cpu=arm & libc=glibc
+ languageName: node
+ linkType: hard
+
"@img/sharp-libvips-linux-arm@npm:1.2.4":
version: 1.2.4
resolution: "@img/sharp-libvips-linux-arm@npm:1.2.4"
@@ -4941,6 +5287,13 @@ __metadata:
languageName: node
linkType: hard
+"@img/sharp-libvips-linux-s390x@npm:1.0.4":
+ version: 1.0.4
+ resolution: "@img/sharp-libvips-linux-s390x@npm:1.0.4"
+ conditions: os=linux & cpu=s390x & libc=glibc
+ languageName: node
+ linkType: hard
+
"@img/sharp-libvips-linux-s390x@npm:1.2.4":
version: 1.2.4
resolution: "@img/sharp-libvips-linux-s390x@npm:1.2.4"
@@ -4948,6 +5301,13 @@ __metadata:
languageName: node
linkType: hard
+"@img/sharp-libvips-linux-x64@npm:1.0.4":
+ version: 1.0.4
+ resolution: "@img/sharp-libvips-linux-x64@npm:1.0.4"
+ conditions: os=linux & cpu=x64 & libc=glibc
+ languageName: node
+ linkType: hard
+
"@img/sharp-libvips-linux-x64@npm:1.2.4":
version: 1.2.4
resolution: "@img/sharp-libvips-linux-x64@npm:1.2.4"
@@ -4955,6 +5315,13 @@ __metadata:
languageName: node
linkType: hard
+"@img/sharp-libvips-linuxmusl-arm64@npm:1.0.4":
+ version: 1.0.4
+ resolution: "@img/sharp-libvips-linuxmusl-arm64@npm:1.0.4"
+ conditions: os=linux & cpu=arm64 & libc=musl
+ languageName: node
+ linkType: hard
+
"@img/sharp-libvips-linuxmusl-arm64@npm:1.2.4":
version: 1.2.4
resolution: "@img/sharp-libvips-linuxmusl-arm64@npm:1.2.4"
@@ -4962,6 +5329,13 @@ __metadata:
languageName: node
linkType: hard
+"@img/sharp-libvips-linuxmusl-x64@npm:1.0.4":
+ version: 1.0.4
+ resolution: "@img/sharp-libvips-linuxmusl-x64@npm:1.0.4"
+ conditions: os=linux & cpu=x64 & libc=musl
+ languageName: node
+ linkType: hard
+
"@img/sharp-libvips-linuxmusl-x64@npm:1.2.4":
version: 1.2.4
resolution: "@img/sharp-libvips-linuxmusl-x64@npm:1.2.4"
@@ -4969,6 +5343,18 @@ __metadata:
languageName: node
linkType: hard
+"@img/sharp-linux-arm64@npm:0.33.5":
+ version: 0.33.5
+ resolution: "@img/sharp-linux-arm64@npm:0.33.5"
+ dependencies:
+ "@img/sharp-libvips-linux-arm64": "npm:1.0.4"
+ dependenciesMeta:
+ "@img/sharp-libvips-linux-arm64":
+ optional: true
+ conditions: os=linux & cpu=arm64 & libc=glibc
+ languageName: node
+ linkType: hard
+
"@img/sharp-linux-arm64@npm:0.34.5":
version: 0.34.5
resolution: "@img/sharp-linux-arm64@npm:0.34.5"
@@ -4981,6 +5367,18 @@ __metadata:
languageName: node
linkType: hard
+"@img/sharp-linux-arm@npm:0.33.5":
+ version: 0.33.5
+ resolution: "@img/sharp-linux-arm@npm:0.33.5"
+ dependencies:
+ "@img/sharp-libvips-linux-arm": "npm:1.0.5"
+ dependenciesMeta:
+ "@img/sharp-libvips-linux-arm":
+ optional: true
+ conditions: os=linux & cpu=arm & libc=glibc
+ languageName: node
+ linkType: hard
+
"@img/sharp-linux-arm@npm:0.34.5":
version: 0.34.5
resolution: "@img/sharp-linux-arm@npm:0.34.5"
@@ -5017,6 +5415,18 @@ __metadata:
languageName: node
linkType: hard
+"@img/sharp-linux-s390x@npm:0.33.5":
+ version: 0.33.5
+ resolution: "@img/sharp-linux-s390x@npm:0.33.5"
+ dependencies:
+ "@img/sharp-libvips-linux-s390x": "npm:1.0.4"
+ dependenciesMeta:
+ "@img/sharp-libvips-linux-s390x":
+ optional: true
+ conditions: os=linux & cpu=s390x & libc=glibc
+ languageName: node
+ linkType: hard
+
"@img/sharp-linux-s390x@npm:0.34.5":
version: 0.34.5
resolution: "@img/sharp-linux-s390x@npm:0.34.5"
@@ -5029,6 +5439,18 @@ __metadata:
languageName: node
linkType: hard
+"@img/sharp-linux-x64@npm:0.33.5":
+ version: 0.33.5
+ resolution: "@img/sharp-linux-x64@npm:0.33.5"
+ dependencies:
+ "@img/sharp-libvips-linux-x64": "npm:1.0.4"
+ dependenciesMeta:
+ "@img/sharp-libvips-linux-x64":
+ optional: true
+ conditions: os=linux & cpu=x64 & libc=glibc
+ languageName: node
+ linkType: hard
+
"@img/sharp-linux-x64@npm:0.34.5":
version: 0.34.5
resolution: "@img/sharp-linux-x64@npm:0.34.5"
@@ -5041,6 +5463,18 @@ __metadata:
languageName: node
linkType: hard
+"@img/sharp-linuxmusl-arm64@npm:0.33.5":
+ version: 0.33.5
+ resolution: "@img/sharp-linuxmusl-arm64@npm:0.33.5"
+ dependencies:
+ "@img/sharp-libvips-linuxmusl-arm64": "npm:1.0.4"
+ dependenciesMeta:
+ "@img/sharp-libvips-linuxmusl-arm64":
+ optional: true
+ conditions: os=linux & cpu=arm64 & libc=musl
+ languageName: node
+ linkType: hard
+
"@img/sharp-linuxmusl-arm64@npm:0.34.5":
version: 0.34.5
resolution: "@img/sharp-linuxmusl-arm64@npm:0.34.5"
@@ -5053,6 +5487,18 @@ __metadata:
languageName: node
linkType: hard
+"@img/sharp-linuxmusl-x64@npm:0.33.5":
+ version: 0.33.5
+ resolution: "@img/sharp-linuxmusl-x64@npm:0.33.5"
+ dependencies:
+ "@img/sharp-libvips-linuxmusl-x64": "npm:1.0.4"
+ dependenciesMeta:
+ "@img/sharp-libvips-linuxmusl-x64":
+ optional: true
+ conditions: os=linux & cpu=x64 & libc=musl
+ languageName: node
+ linkType: hard
+
"@img/sharp-linuxmusl-x64@npm:0.34.5":
version: 0.34.5
resolution: "@img/sharp-linuxmusl-x64@npm:0.34.5"
@@ -5065,6 +5511,15 @@ __metadata:
languageName: node
linkType: hard
+"@img/sharp-wasm32@npm:0.33.5":
+ version: 0.33.5
+ resolution: "@img/sharp-wasm32@npm:0.33.5"
+ dependencies:
+ "@emnapi/runtime": "npm:^1.2.0"
+ conditions: cpu=wasm32
+ languageName: node
+ linkType: hard
+
"@img/sharp-wasm32@npm:0.34.5":
version: 0.34.5
resolution: "@img/sharp-wasm32@npm:0.34.5"
@@ -5081,6 +5536,13 @@ __metadata:
languageName: node
linkType: hard
+"@img/sharp-win32-ia32@npm:0.33.5":
+ version: 0.33.5
+ resolution: "@img/sharp-win32-ia32@npm:0.33.5"
+ conditions: os=win32 & cpu=ia32
+ languageName: node
+ linkType: hard
+
"@img/sharp-win32-ia32@npm:0.34.5":
version: 0.34.5
resolution: "@img/sharp-win32-ia32@npm:0.34.5"
@@ -5088,6 +5550,13 @@ __metadata:
languageName: node
linkType: hard
+"@img/sharp-win32-x64@npm:0.33.5":
+ version: 0.33.5
+ resolution: "@img/sharp-win32-x64@npm:0.33.5"
+ conditions: os=win32 & cpu=x64
+ languageName: node
+ linkType: hard
+
"@img/sharp-win32-x64@npm:0.34.5":
version: 0.34.5
resolution: "@img/sharp-win32-x64@npm:0.34.5"
@@ -9245,6 +9714,13 @@ __metadata:
languageName: node
linkType: hard
+"acorn-walk@npm:8.3.2":
+ version: 8.3.2
+ resolution: "acorn-walk@npm:8.3.2"
+ checksum: 10c0/7e2a8dad5480df7f872569b9dccff2f3da7e65f5353686b1d6032ab9f4ddf6e3a2cb83a9b52cf50b1497fd522154dda92f0abf7153290cc79cd14721ff121e52
+ languageName: node
+ linkType: hard
+
"acorn-walk@npm:^7.0.0, acorn-walk@npm:^7.1.1":
version: 7.2.0
resolution: "acorn-walk@npm:7.2.0"
@@ -9261,6 +9737,15 @@ __metadata:
languageName: node
linkType: hard
+"acorn@npm:8.14.0, acorn@npm:^8.0.0, acorn@npm:^8.14.0":
+ version: 8.14.0
+ resolution: "acorn@npm:8.14.0"
+ bin:
+ acorn: bin/acorn
+ checksum: 10c0/6d4ee461a7734b2f48836ee0fbb752903606e576cc100eb49340295129ca0b452f3ba91ddd4424a1d4406a98adfb2ebb6bd0ff4c49d7a0930c10e462719bbfd7
+ languageName: node
+ linkType: hard
+
"acorn@npm:^3.1.0":
version: 3.3.0
resolution: "acorn@npm:3.3.0"
@@ -9288,15 +9773,6 @@ __metadata:
languageName: node
linkType: hard
-"acorn@npm:^8.0.0, acorn@npm:^8.14.0":
- version: 8.14.0
- resolution: "acorn@npm:8.14.0"
- bin:
- acorn: bin/acorn
- checksum: 10c0/6d4ee461a7734b2f48836ee0fbb752903606e576cc100eb49340295129ca0b452f3ba91ddd4424a1d4406a98adfb2ebb6bd0ff4c49d7a0930c10e462719bbfd7
- languageName: node
- linkType: hard
-
"acorn@npm:^8.11.0, acorn@npm:^8.12.0, acorn@npm:^8.2.4, acorn@npm:^8.4.1, acorn@npm:^8.7.1, acorn@npm:^8.8.2, acorn@npm:^8.9.0":
version: 8.12.0
resolution: "acorn@npm:8.12.0"
@@ -9912,6 +10388,15 @@ __metadata:
languageName: node
linkType: hard
+"as-table@npm:^1.0.36":
+ version: 1.0.55
+ resolution: "as-table@npm:1.0.55"
+ dependencies:
+ printable-characters: "npm:^1.0.42"
+ checksum: 10c0/8c5693a84621fe53c62fcad6b779dc55c5caf4d43b8e67077964baea4a337769ef53f590d7395c806805b4ef1a391b614ba9acdee19b2ca4309ddedaf13894e6
+ languageName: node
+ linkType: hard
+
"asap@npm:^2.0.0, asap@npm:~2.0.3, asap@npm:~2.0.6":
version: 2.0.6
resolution: "asap@npm:2.0.6"
@@ -10641,6 +11126,13 @@ __metadata:
languageName: node
linkType: hard
+"blake3-wasm@npm:2.1.5":
+ version: 2.1.5
+ resolution: "blake3-wasm@npm:2.1.5"
+ checksum: 10c0/5dc729d8e3a9d1d7ab016b36cdda264a327ada0239716df48435163e11d2bf6df25d6e421655a1f52649098ae49555268a654729b7d02768f77c571ab37ef814
+ languageName: node
+ linkType: hard
+
"bluebird@npm:^3.7.2":
version: 3.7.2
resolution: "bluebird@npm:3.7.2"
@@ -11884,13 +12376,33 @@ __metadata:
languageName: node
linkType: hard
-"color-name@npm:~1.1.4":
+"color-name@npm:^1.0.0, color-name@npm:~1.1.4":
version: 1.1.4
resolution: "color-name@npm:1.1.4"
checksum: 10c0/a1a3f914156960902f46f7f56bc62effc6c94e84b2cae157a526b1c1f74b677a47ec602bf68a61abfa2b42d15b7c5651c6dbe72a43af720bc588dff885b10f95
languageName: node
linkType: hard
+"color-string@npm:^1.9.0":
+ version: 1.9.1
+ resolution: "color-string@npm:1.9.1"
+ dependencies:
+ color-name: "npm:^1.0.0"
+ simple-swizzle: "npm:^0.2.2"
+ checksum: 10c0/b0bfd74c03b1f837f543898b512f5ea353f71630ccdd0d66f83028d1f0924a7d4272deb278b9aef376cacf1289b522ac3fb175e99895283645a2dc3a33af2404
+ languageName: node
+ linkType: hard
+
+"color@npm:^4.2.3":
+ version: 4.2.3
+ resolution: "color@npm:4.2.3"
+ dependencies:
+ color-convert: "npm:^2.0.1"
+ color-string: "npm:^1.9.0"
+ checksum: 10c0/7fbe7cfb811054c808349de19fb380252e5e34e61d7d168ec3353e9e9aacb1802674bddc657682e4e9730c2786592a4de6f8283e7e0d3870b829bb0b7b2f6118
+ languageName: node
+ linkType: hard
+
"colord@npm:^2.9.1":
version: 2.9.3
resolution: "colord@npm:2.9.3"
@@ -12797,6 +13309,13 @@ __metadata:
languageName: node
linkType: hard
+"data-uri-to-buffer@npm:^2.0.0":
+ version: 2.0.2
+ resolution: "data-uri-to-buffer@npm:2.0.2"
+ checksum: 10c0/341b6191ed65fa453e97a6d44db06082121ebc2ef3e6e096dfb6a1ebbc75e8be39d4199a5b4dba0f0efc43f2a3b2bcc276d85cf1407eba880eb09ebf17c3c31e
+ languageName: node
+ linkType: hard
+
"data-uri-to-buffer@npm:^3.0.1":
version: 3.0.1
resolution: "data-uri-to-buffer@npm:3.0.1"
@@ -13117,6 +13636,13 @@ __metadata:
languageName: node
linkType: hard
+"defu@npm:^6.1.4":
+ version: 6.1.4
+ resolution: "defu@npm:6.1.4"
+ checksum: 10c0/2d6cc366262dc0cb8096e429368e44052fdf43ed48e53ad84cc7c9407f890301aa5fcb80d0995abaaf842b3949f154d060be4160f7a46cb2bc2f7726c81526f5
+ languageName: node
+ linkType: hard
+
"degenerator@npm:^5.0.0":
version: 5.0.1
resolution: "degenerator@npm:5.0.1"
@@ -14202,6 +14728,83 @@ __metadata:
languageName: node
linkType: hard
+"esbuild@npm:0.17.19":
+ version: 0.17.19
+ resolution: "esbuild@npm:0.17.19"
+ dependencies:
+ "@esbuild/android-arm": "npm:0.17.19"
+ "@esbuild/android-arm64": "npm:0.17.19"
+ "@esbuild/android-x64": "npm:0.17.19"
+ "@esbuild/darwin-arm64": "npm:0.17.19"
+ "@esbuild/darwin-x64": "npm:0.17.19"
+ "@esbuild/freebsd-arm64": "npm:0.17.19"
+ "@esbuild/freebsd-x64": "npm:0.17.19"
+ "@esbuild/linux-arm": "npm:0.17.19"
+ "@esbuild/linux-arm64": "npm:0.17.19"
+ "@esbuild/linux-ia32": "npm:0.17.19"
+ "@esbuild/linux-loong64": "npm:0.17.19"
+ "@esbuild/linux-mips64el": "npm:0.17.19"
+ "@esbuild/linux-ppc64": "npm:0.17.19"
+ "@esbuild/linux-riscv64": "npm:0.17.19"
+ "@esbuild/linux-s390x": "npm:0.17.19"
+ "@esbuild/linux-x64": "npm:0.17.19"
+ "@esbuild/netbsd-x64": "npm:0.17.19"
+ "@esbuild/openbsd-x64": "npm:0.17.19"
+ "@esbuild/sunos-x64": "npm:0.17.19"
+ "@esbuild/win32-arm64": "npm:0.17.19"
+ "@esbuild/win32-ia32": "npm:0.17.19"
+ "@esbuild/win32-x64": "npm:0.17.19"
+ dependenciesMeta:
+ "@esbuild/android-arm":
+ optional: true
+ "@esbuild/android-arm64":
+ optional: true
+ "@esbuild/android-x64":
+ optional: true
+ "@esbuild/darwin-arm64":
+ optional: true
+ "@esbuild/darwin-x64":
+ optional: true
+ "@esbuild/freebsd-arm64":
+ optional: true
+ "@esbuild/freebsd-x64":
+ optional: true
+ "@esbuild/linux-arm":
+ optional: true
+ "@esbuild/linux-arm64":
+ optional: true
+ "@esbuild/linux-ia32":
+ optional: true
+ "@esbuild/linux-loong64":
+ optional: true
+ "@esbuild/linux-mips64el":
+ optional: true
+ "@esbuild/linux-ppc64":
+ optional: true
+ "@esbuild/linux-riscv64":
+ optional: true
+ "@esbuild/linux-s390x":
+ optional: true
+ "@esbuild/linux-x64":
+ optional: true
+ "@esbuild/netbsd-x64":
+ optional: true
+ "@esbuild/openbsd-x64":
+ optional: true
+ "@esbuild/sunos-x64":
+ optional: true
+ "@esbuild/win32-arm64":
+ optional: true
+ "@esbuild/win32-ia32":
+ optional: true
+ "@esbuild/win32-x64":
+ optional: true
+ bin:
+ esbuild: bin/esbuild
+ checksum: 10c0/c7ac14bfaaebe4745d5d18347b4f6854fd1140acb9389e88dbfa5c20d4e2122451d9647d5498920470a880a605d6e5502b5c2102da6c282b01f129ddd49d2874
+ languageName: node
+ linkType: hard
+
"esbuild@npm:0.17.6":
version: 0.17.6
resolution: "esbuild@npm:0.17.6"
@@ -15382,6 +15985,13 @@ __metadata:
languageName: node
linkType: hard
+"estree-walker@npm:^0.6.1":
+ version: 0.6.1
+ resolution: "estree-walker@npm:0.6.1"
+ checksum: 10c0/6dabc855faa04a1ffb17b6a9121b6008ba75ab5a163ad9dc3d7fca05cfda374c5f5e91418d783496620ca75e99a73c40874d8b75f23b4117508cc8bde78e7b41
+ languageName: node
+ linkType: hard
+
"estree-walker@npm:^1.0.1":
version: 1.0.1
resolution: "estree-walker@npm:1.0.1"
@@ -15676,6 +16286,13 @@ __metadata:
languageName: node
linkType: hard
+"exsolve@npm:^1.0.1":
+ version: 1.0.8
+ resolution: "exsolve@npm:1.0.8"
+ checksum: 10c0/65e44ae05bd4a4a5d87cfdbbd6b8f24389282cf9f85fa5feb17ca87ad3f354877e6af4cd99e02fc29044174891f82d1d68c77f69234410eb8f163530e6278c67
+ languageName: node
+ linkType: hard
+
"extend@npm:^3.0.0":
version: 3.0.2
resolution: "extend@npm:3.0.2"
@@ -16518,6 +17135,16 @@ __metadata:
languageName: node
linkType: hard
+"get-source@npm:^2.0.12":
+ version: 2.0.12
+ resolution: "get-source@npm:2.0.12"
+ dependencies:
+ data-uri-to-buffer: "npm:^2.0.0"
+ source-map: "npm:^0.6.1"
+ checksum: 10c0/b1db46d28902344fd9407e1f0ed0b8f3a85cb4650f85ba8cee9c0b422fc75118172f12f735706e2c6e034617b13a2fbc5266e7fab617ecb184f0cee074b9dd3e
+ languageName: node
+ linkType: hard
+
"get-stream@npm:^5.1.0":
version: 5.2.0
resolution: "get-stream@npm:5.2.0"
@@ -16604,7 +17231,7 @@ __metadata:
languageName: node
linkType: hard
-"glob-to-regexp@npm:^0.4.1":
+"glob-to-regexp@npm:0.4.1, glob-to-regexp@npm:^0.4.1":
version: 0.4.1
resolution: "glob-to-regexp@npm:0.4.1"
checksum: 10c0/0486925072d7a916f052842772b61c3e86247f0a80cc0deb9b5a3e8a1a9faad5b04fb6f58986a09f34d3e96cd2a22a24b7e9882fb1cf904c31e9a310de96c429
@@ -17638,6 +18265,13 @@ __metadata:
languageName: node
linkType: hard
+"is-arrayish@npm:^0.3.1":
+ version: 0.3.4
+ resolution: "is-arrayish@npm:0.3.4"
+ checksum: 10c0/1fa672a2f0bedb74154440310f616c0b6e53a95cf0625522ae050f06626d1cabd1a3d8085c882dc45c61ad0e7df2529aff122810b3b4a552880bf170d6df94e0
+ languageName: node
+ linkType: hard
+
"is-async-function@npm:^2.0.0":
version: 2.0.0
resolution: "is-async-function@npm:2.0.0"
@@ -20566,7 +21200,7 @@ __metadata:
languageName: node
linkType: hard
-"magic-string@npm:^0.25.0, magic-string@npm:^0.25.7":
+"magic-string@npm:^0.25.0, magic-string@npm:^0.25.3, magic-string@npm:^0.25.7":
version: 0.25.9
resolution: "magic-string@npm:0.25.9"
dependencies:
@@ -21424,6 +22058,15 @@ __metadata:
languageName: node
linkType: hard
+"mime@npm:^3.0.0":
+ version: 3.0.0
+ resolution: "mime@npm:3.0.0"
+ bin:
+ mime: cli.js
+ checksum: 10c0/402e792a8df1b2cc41cb77f0dcc46472b7944b7ec29cb5bbcd398624b6b97096728f1239766d3fdeb20551dd8d94738344c195a6ea10c4f906eb0356323b0531
+ languageName: node
+ linkType: hard
+
"mimic-fn@npm:^2.1.0":
version: 2.1.0
resolution: "mimic-fn@npm:2.1.0"
@@ -21450,6 +22093,27 @@ __metadata:
languageName: node
linkType: hard
+"miniflare@npm:3.20250718.3":
+ version: 3.20250718.3
+ resolution: "miniflare@npm:3.20250718.3"
+ dependencies:
+ "@cspotcode/source-map-support": "npm:0.8.1"
+ acorn: "npm:8.14.0"
+ acorn-walk: "npm:8.3.2"
+ exit-hook: "npm:2.2.1"
+ glob-to-regexp: "npm:0.4.1"
+ stoppable: "npm:1.1.0"
+ undici: "npm:^5.28.5"
+ workerd: "npm:1.20250718.0"
+ ws: "npm:8.18.0"
+ youch: "npm:3.3.4"
+ zod: "npm:3.22.3"
+ bin:
+ miniflare: bootstrap.js
+ checksum: 10c0/ebabf4640c6d736589a91833eb5bd00c218175d8fc7ae938fff9014c0ec01d1fc80b11f961df79cd8cdef8d26c4d5ccdb63d5f618f671a1f0b59dc0f5a368ae5
+ languageName: node
+ linkType: hard
+
"minimalistic-assert@npm:^1.0.0, minimalistic-assert@npm:^1.0.1":
version: 1.0.1
resolution: "minimalistic-assert@npm:1.0.1"
@@ -21817,6 +22481,15 @@ __metadata:
languageName: node
linkType: hard
+"mustache@npm:^4.2.0":
+ version: 4.2.0
+ resolution: "mustache@npm:4.2.0"
+ bin:
+ mustache: bin/mustache
+ checksum: 10c0/1f8197e8a19e63645a786581d58c41df7853da26702dbc005193e2437c98ca49b255345c173d50c08fe4b4dbb363e53cb655ecc570791f8deb09887248dd34a2
+ languageName: node
+ linkType: hard
+
"mute-stream@npm:^3.0.0":
version: 3.0.0
resolution: "mute-stream@npm:3.0.0"
@@ -22400,6 +23073,13 @@ __metadata:
languageName: node
linkType: hard
+"ohash@npm:^2.0.10":
+ version: 2.0.11
+ resolution: "ohash@npm:2.0.11"
+ checksum: 10c0/d07c8d79cc26da082c1a7c8d5b56c399dd4ed3b2bd069fcae6bae78c99a9bcc3ad813b1e1f49ca2f335292846d689c6141a762cf078727d2302a33d414e69c79
+ languageName: node
+ linkType: hard
+
"on-finished@npm:2.4.1, on-finished@npm:^2.4.1":
version: 2.4.1
resolution: "on-finished@npm:2.4.1"
@@ -24145,6 +24825,13 @@ __metadata:
languageName: node
linkType: hard
+"printable-characters@npm:^1.0.42":
+ version: 1.0.42
+ resolution: "printable-characters@npm:1.0.42"
+ checksum: 10c0/7c94d94c6041a37c385af770c7402ad5a2e8a3429ca4d2505a9f19fde39bac9a8fd1edfbfa02f1eae5b4b0f3536b6b8ee6c84621f7c0fcb41476b2df6ee20e4b
+ languageName: node
+ linkType: hard
+
"private@npm:^0.1.8":
version: 0.1.8
resolution: "private@npm:0.1.8"
@@ -25876,6 +26563,17 @@ __metadata:
languageName: node
linkType: hard
+"rollup-plugin-inject@npm:^3.0.0":
+ version: 3.0.2
+ resolution: "rollup-plugin-inject@npm:3.0.2"
+ dependencies:
+ estree-walker: "npm:^0.6.1"
+ magic-string: "npm:^0.25.3"
+ rollup-pluginutils: "npm:^2.8.1"
+ checksum: 10c0/35b9d955039b56b43750a9e458bb51b7956b048b6d3ca57b1f03462aa5a0cb176d1b677d95e909b64eee4e9adf73c02f569ad8c0ab5aafdec818ff51700c114c
+ languageName: node
+ linkType: hard
+
"rollup-plugin-jsdoc@npm:^0.1.2":
version: 0.1.2
resolution: "rollup-plugin-jsdoc@npm:0.1.2"
@@ -25895,6 +26593,15 @@ __metadata:
languageName: node
linkType: hard
+"rollup-plugin-node-polyfills@npm:^0.2.1":
+ version: 0.2.1
+ resolution: "rollup-plugin-node-polyfills@npm:0.2.1"
+ dependencies:
+ rollup-plugin-inject: "npm:^3.0.0"
+ checksum: 10c0/30f9e09cbbf979b1212e0c455d74c3a061994fc19ddf160da4634b11377222cea5903a5ba05db66be849f550cde9ffc80ecbfcfb48544045d08bfc408501417d
+ languageName: node
+ linkType: hard
+
"rollup-plugin-terser@npm:^7.0.0":
version: 7.0.2
resolution: "rollup-plugin-terser@npm:7.0.2"
@@ -25925,6 +26632,15 @@ __metadata:
languageName: node
linkType: hard
+"rollup-pluginutils@npm:^2.8.1":
+ version: 2.8.2
+ resolution: "rollup-pluginutils@npm:2.8.2"
+ dependencies:
+ estree-walker: "npm:^0.6.1"
+ checksum: 10c0/20947bec5a5dd68b5c5c8423911e6e7c0ad834c451f1a929b1f4e2bc08836ad3f1a722ef2bfcbeca921870a0a283f13f064a317dc7a6768496e98c9a641ba290
+ languageName: node
+ linkType: hard
+
"rollup@npm:>=2.79.2":
version: 4.53.2
resolution: "rollup@npm:4.53.2"
@@ -26576,6 +27292,75 @@ __metadata:
languageName: node
linkType: hard
+"sharp@npm:^0.33.5":
+ version: 0.33.5
+ resolution: "sharp@npm:0.33.5"
+ dependencies:
+ "@img/sharp-darwin-arm64": "npm:0.33.5"
+ "@img/sharp-darwin-x64": "npm:0.33.5"
+ "@img/sharp-libvips-darwin-arm64": "npm:1.0.4"
+ "@img/sharp-libvips-darwin-x64": "npm:1.0.4"
+ "@img/sharp-libvips-linux-arm": "npm:1.0.5"
+ "@img/sharp-libvips-linux-arm64": "npm:1.0.4"
+ "@img/sharp-libvips-linux-s390x": "npm:1.0.4"
+ "@img/sharp-libvips-linux-x64": "npm:1.0.4"
+ "@img/sharp-libvips-linuxmusl-arm64": "npm:1.0.4"
+ "@img/sharp-libvips-linuxmusl-x64": "npm:1.0.4"
+ "@img/sharp-linux-arm": "npm:0.33.5"
+ "@img/sharp-linux-arm64": "npm:0.33.5"
+ "@img/sharp-linux-s390x": "npm:0.33.5"
+ "@img/sharp-linux-x64": "npm:0.33.5"
+ "@img/sharp-linuxmusl-arm64": "npm:0.33.5"
+ "@img/sharp-linuxmusl-x64": "npm:0.33.5"
+ "@img/sharp-wasm32": "npm:0.33.5"
+ "@img/sharp-win32-ia32": "npm:0.33.5"
+ "@img/sharp-win32-x64": "npm:0.33.5"
+ color: "npm:^4.2.3"
+ detect-libc: "npm:^2.0.3"
+ semver: "npm:^7.6.3"
+ dependenciesMeta:
+ "@img/sharp-darwin-arm64":
+ optional: true
+ "@img/sharp-darwin-x64":
+ optional: true
+ "@img/sharp-libvips-darwin-arm64":
+ optional: true
+ "@img/sharp-libvips-darwin-x64":
+ optional: true
+ "@img/sharp-libvips-linux-arm":
+ optional: true
+ "@img/sharp-libvips-linux-arm64":
+ optional: true
+ "@img/sharp-libvips-linux-s390x":
+ optional: true
+ "@img/sharp-libvips-linux-x64":
+ optional: true
+ "@img/sharp-libvips-linuxmusl-arm64":
+ optional: true
+ "@img/sharp-libvips-linuxmusl-x64":
+ optional: true
+ "@img/sharp-linux-arm":
+ optional: true
+ "@img/sharp-linux-arm64":
+ optional: true
+ "@img/sharp-linux-s390x":
+ optional: true
+ "@img/sharp-linux-x64":
+ optional: true
+ "@img/sharp-linuxmusl-arm64":
+ optional: true
+ "@img/sharp-linuxmusl-x64":
+ optional: true
+ "@img/sharp-wasm32":
+ optional: true
+ "@img/sharp-win32-ia32":
+ optional: true
+ "@img/sharp-win32-x64":
+ optional: true
+ checksum: 10c0/6b81421ddfe6ee524d8d77e325c5e147fef22884e1c7b1656dfd89a88d7025894115da02d5f984261bf2e6daa16f98cadd1721c4ba408b4212b1d2a60f233484
+ languageName: node
+ linkType: hard
+
"sharp@npm:^0.34.4":
version: 0.34.5
resolution: "sharp@npm:0.34.5"
@@ -26773,6 +27558,15 @@ __metadata:
languageName: node
linkType: hard
+"simple-swizzle@npm:^0.2.2":
+ version: 0.2.4
+ resolution: "simple-swizzle@npm:0.2.4"
+ dependencies:
+ is-arrayish: "npm:^0.3.1"
+ checksum: 10c0/846c3fdd1325318d5c71295cfbb99bfc9edc4c8dffdda5e6e9efe30482bbcd32cf360fc2806f46ac43ff7d09bcfaff20337bb79f826f0e6a8e366efd3cdd7868
+ languageName: node
+ linkType: hard
+
"sisteransi@npm:^1.0.5":
version: 1.0.5
resolution: "sisteransi@npm:1.0.5"
@@ -27147,6 +27941,16 @@ __metadata:
languageName: node
linkType: hard
+"stacktracey@npm:^2.1.8":
+ version: 2.1.8
+ resolution: "stacktracey@npm:2.1.8"
+ dependencies:
+ as-table: "npm:^1.0.36"
+ get-source: "npm:^2.0.12"
+ checksum: 10c0/e17357d0a532d303138899b910ab660572009a1f4cde1cbf73b99416957a2378e6e1c791b3c31b043cf7c5f37647da1dd114e66c9203f23c65b34f783665405b
+ languageName: node
+ linkType: hard
+
"static-eval@npm:2.0.2":
version: 2.0.2
resolution: "static-eval@npm:2.0.2"
@@ -27187,6 +27991,13 @@ __metadata:
languageName: node
linkType: hard
+"stoppable@npm:1.1.0":
+ version: 1.1.0
+ resolution: "stoppable@npm:1.1.0"
+ checksum: 10c0/ba91b65e6442bf6f01ce837a727ece597a977ed92a05cb9aea6bf446c5e0dcbccc28f31b793afa8aedd8f34baaf3335398d35f903938d5493f7fbe386a1e090e
+ languageName: node
+ linkType: hard
+
"stream-browserify@npm:^3.0.0":
version: 3.0.0
resolution: "stream-browserify@npm:3.0.0"
@@ -29143,6 +29954,15 @@ __metadata:
languageName: node
linkType: hard
+"undici@npm:^5.28.5":
+ version: 5.29.0
+ resolution: "undici@npm:5.29.0"
+ dependencies:
+ "@fastify/busboy": "npm:^2.0.0"
+ checksum: 10c0/e4e4d631ca54ee0ad82d2e90e7798fa00a106e27e6c880687e445cc2f13b4bc87c5eba2a88c266c3eecffb18f26e227b778412da74a23acc374fca7caccec49b
+ languageName: node
+ linkType: hard
+
"undici@npm:^6.21.2":
version: 6.22.0
resolution: "undici@npm:6.22.0"
@@ -29150,6 +29970,19 @@ __metadata:
languageName: node
linkType: hard
+"unenv@npm:2.0.0-rc.14":
+ version: 2.0.0-rc.14
+ resolution: "unenv@npm:2.0.0-rc.14"
+ dependencies:
+ defu: "npm:^6.1.4"
+ exsolve: "npm:^1.0.1"
+ ohash: "npm:^2.0.10"
+ pathe: "npm:^2.0.3"
+ ufo: "npm:^1.5.4"
+ checksum: 10c0/85372028cafb1726dfbe4dbb34953580827936f527022f6b03eaaf1b947d9354cf39ed505203edb11e77791d01778cb2287b2db03f516ed4503de9182a821f94
+ languageName: node
+ linkType: hard
+
"unicode-canonical-property-names-ecmascript@npm:^2.0.0":
version: 2.0.0
resolution: "unicode-canonical-property-names-ecmascript@npm:2.0.0"
@@ -30715,6 +31548,32 @@ __metadata:
languageName: node
linkType: hard
+"workerd@npm:1.20250718.0":
+ version: 1.20250718.0
+ resolution: "workerd@npm:1.20250718.0"
+ dependencies:
+ "@cloudflare/workerd-darwin-64": "npm:1.20250718.0"
+ "@cloudflare/workerd-darwin-arm64": "npm:1.20250718.0"
+ "@cloudflare/workerd-linux-64": "npm:1.20250718.0"
+ "@cloudflare/workerd-linux-arm64": "npm:1.20250718.0"
+ "@cloudflare/workerd-windows-64": "npm:1.20250718.0"
+ dependenciesMeta:
+ "@cloudflare/workerd-darwin-64":
+ optional: true
+ "@cloudflare/workerd-darwin-arm64":
+ optional: true
+ "@cloudflare/workerd-linux-64":
+ optional: true
+ "@cloudflare/workerd-linux-arm64":
+ optional: true
+ "@cloudflare/workerd-windows-64":
+ optional: true
+ bin:
+ workerd: bin/workerd
+ checksum: 10c0/94ac61da99310331ec21c5fb10072791af78a8080059153793ff81796a7c372ce2b40274614590982ceb2f4b1b395997f1d93b635c3c282583624a36eff8970d
+ languageName: node
+ linkType: hard
+
"workerpool@npm:^9.2.0":
version: 9.3.4
resolution: "workerpool@npm:9.3.4"
@@ -30722,6 +31581,39 @@ __metadata:
languageName: node
linkType: hard
+"wrangler@npm:^3.99.0":
+ version: 3.114.17
+ resolution: "wrangler@npm:3.114.17"
+ dependencies:
+ "@cloudflare/kv-asset-handler": "npm:0.3.4"
+ "@cloudflare/unenv-preset": "npm:2.0.2"
+ "@esbuild-plugins/node-globals-polyfill": "npm:0.2.3"
+ "@esbuild-plugins/node-modules-polyfill": "npm:0.2.2"
+ blake3-wasm: "npm:2.1.5"
+ esbuild: "npm:0.17.19"
+ fsevents: "npm:~2.3.2"
+ miniflare: "npm:3.20250718.3"
+ path-to-regexp: "npm:6.3.0"
+ sharp: "npm:^0.33.5"
+ unenv: "npm:2.0.0-rc.14"
+ workerd: "npm:1.20250718.0"
+ peerDependencies:
+ "@cloudflare/workers-types": ^4.20250408.0
+ dependenciesMeta:
+ fsevents:
+ optional: true
+ sharp:
+ optional: true
+ peerDependenciesMeta:
+ "@cloudflare/workers-types":
+ optional: true
+ bin:
+ wrangler: bin/wrangler.js
+ wrangler2: bin/wrangler.js
+ checksum: 10c0/83cedac5b95b1f78ee6213fe585be5a7f7cfa2885ac482b8e75ca3e13d3ba5b0746fae5aaf82c2652c704755fe74889b69e0e2b75b6db8bb095ff7cb42b9a167
+ languageName: node
+ linkType: hard
+
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0, wrap-ansi@npm:^7.0.0":
version: 7.0.0
resolution: "wrap-ansi@npm:7.0.0"
@@ -30820,6 +31712,21 @@ __metadata:
languageName: node
linkType: hard
+"ws@npm:8.18.0":
+ version: 8.18.0
+ resolution: "ws@npm:8.18.0"
+ peerDependencies:
+ bufferutil: ^4.0.1
+ utf-8-validate: ">=5.0.2"
+ peerDependenciesMeta:
+ bufferutil:
+ optional: true
+ utf-8-validate:
+ optional: true
+ checksum: 10c0/25eb33aff17edcb90721ed6b0eb250976328533ad3cd1a28a274bd263682e7296a6591ff1436d6cbc50fa67463158b062f9d1122013b361cec99a05f84680e06
+ languageName: node
+ linkType: hard
+
"ws@npm:^7.4.6, ws@npm:^7.5.10":
version: 7.5.10
resolution: "ws@npm:7.5.10"
@@ -31082,6 +31989,17 @@ __metadata:
languageName: node
linkType: hard
+"youch@npm:3.3.4":
+ version: 3.3.4
+ resolution: "youch@npm:3.3.4"
+ dependencies:
+ cookie: "npm:^0.7.1"
+ mustache: "npm:^4.2.0"
+ stacktracey: "npm:^2.1.8"
+ checksum: 10c0/ab573c7dccebdaf2d6b084d262d5bfb22ad5c049fb1ad3e2d6a840af851042dd3a8a072665c5a5ee73c75bbc1618fbc08f1371ac896e54556bced0ddf996b026
+ languageName: node
+ linkType: hard
+
"zod-validation-error@npm:^3.5.0 || ^4.0.0":
version: 4.0.2
resolution: "zod-validation-error@npm:4.0.2"
@@ -31091,6 +32009,13 @@ __metadata:
languageName: node
linkType: hard
+"zod@npm:3.22.3":
+ version: 3.22.3
+ resolution: "zod@npm:3.22.3"
+ checksum: 10c0/cb4b24aed7dec98552eb9042e88cbd645455bf2830e5704174d2da96f554dabad4630e3b4f6623e1b6562b9eaa43535a37b7f2011f29b8d8e9eabe1ddf3b656b
+ languageName: node
+ linkType: hard
+
"zod@npm:^3.24.1":
version: 3.25.76
resolution: "zod@npm:3.25.76"
From 580e06ef15e86fc68d94b30d983bbeba0d9af61a Mon Sep 17 00:00:00 2001
From: Ahmed Abbas
Date: Tue, 24 Feb 2026 23:35:25 +0200
Subject: [PATCH 2/7] chore: address code review findings
Fix SDK singleton race condition by caching the initialization promise
so concurrent cold-start requests share one init. Guard JSON.parse calls
in KVDataStore and EdgeConfigCache against corrupted KV data. Type the
applyVariation parameter as BucketedVariation instead of any.
---
demo/cloudflare-workers/src/index.ts | 53 ++++++++++++--------
packages/cloudflare/src/edge-config-cache.ts | 10 +++-
packages/cloudflare/src/kv-data-store.ts | 7 ++-
3 files changed, 45 insertions(+), 25 deletions(-)
diff --git a/demo/cloudflare-workers/src/index.ts b/demo/cloudflare-workers/src/index.ts
index 3ea23509..b46948e3 100644
--- a/demo/cloudflare-workers/src/index.ts
+++ b/demo/cloudflare-workers/src/index.ts
@@ -15,7 +15,7 @@
* before the response reaches the browser.
*/
-import ConvertSDK from '@convertcom/js-sdk';
+import ConvertSDK, {BucketedVariation} from '@convertcom/js-sdk';
import {
KVDataStore,
EdgeConfigCache,
@@ -41,8 +41,10 @@ interface Env {
// The SDK instance persists across requests within the same Worker isolate.
// Config is loaded from KV on the first request and reused afterwards.
+// The initialization promise is cached to prevent race conditions when
+// concurrent requests hit a cold Worker simultaneously.
let sdk: InstanceType | null = null;
-let sdkReady = false;
+let sdkReadyPromise: Promise> | null = null;
/**
* Initialise (or reuse) the SDK singleton.
@@ -50,25 +52,29 @@ let sdkReady = false;
* of fetching from the CDN (~100 ms) on every cold start.
*/
async function getSDK(env: Env): Promise> {
- if (sdk && sdkReady) return sdk;
-
- const configCache = new EdgeConfigCache(
- env.CONVERT_KV,
- env.CONVERT_SDK_KEY,
- 300 // cache TTL in seconds (5 minutes)
- );
- const configData = await configCache.getConfig();
-
- sdk = new ConvertSDK({
- data: configData,
- // Passing data directly avoids the timer-based refresh (setTimeout)
- // which is meaningless in a stateless Worker environment.
- network: {tracking: true}
- });
- await sdk.onReady();
- sdkReady = true;
-
- return sdk;
+ if (sdk) return sdk;
+ if (sdkReadyPromise) return sdkReadyPromise;
+
+ sdkReadyPromise = (async () => {
+ const configCache = new EdgeConfigCache(
+ env.CONVERT_KV,
+ env.CONVERT_SDK_KEY,
+ 300 // cache TTL in seconds (5 minutes)
+ );
+ const configData = await configCache.getConfig();
+
+ const newSdk = new ConvertSDK({
+ data: configData,
+ // Passing data directly avoids the timer-based refresh (setTimeout)
+ // which is meaningless in a stateless Worker environment.
+ network: {tracking: true}
+ });
+ await newSdk.onReady();
+ sdk = newSdk;
+ return sdk;
+ })();
+
+ return sdkReadyPromise;
}
// ---------------------------------------------------------------------------
@@ -171,7 +177,10 @@ export default {
* as it streams through the Worker -- no buffering, no DOM parsing overhead.
* The visitor receives the final page with zero flicker.
*/
-function applyVariation(response: Response, variation: any): Response {
+function applyVariation(
+ response: Response,
+ variation: BucketedVariation
+): Response {
// Map variation keys to HTMLRewriter transformations.
// Customize these selectors and content for your experiments.
switch (variation.key) {
diff --git a/packages/cloudflare/src/edge-config-cache.ts b/packages/cloudflare/src/edge-config-cache.ts
index e3caf58c..c1bd2e62 100644
--- a/packages/cloudflare/src/edge-config-cache.ts
+++ b/packages/cloudflare/src/edge-config-cache.ts
@@ -67,9 +67,15 @@ export class EdgeConfigCache {
// Try KV cache first
const cached = await this._kv.get(cacheKey);
- if (cached) return JSON.parse(cached);
+ if (cached) {
+ try {
+ return JSON.parse(cached);
+ } catch (e) {
+ // Corrupted data in KV, fall through to re-fetch from CDN.
+ }
+ }
- // Cache miss: fetch from CDN and store in KV
+ // Cache miss or corrupted: fetch from CDN and store in KV
return this._fetchAndCache(cacheKey);
}
diff --git a/packages/cloudflare/src/kv-data-store.ts b/packages/cloudflare/src/kv-data-store.ts
index 4bde30eb..c40ac7fb 100644
--- a/packages/cloudflare/src/kv-data-store.ts
+++ b/packages/cloudflare/src/kv-data-store.ts
@@ -59,7 +59,12 @@ export class KVDataStore {
*/
async load(visitorId: string): Promise {
const raw = await this._kv.get(`${this._prefix}:${visitorId}`);
- this._data = raw ? JSON.parse(raw) : {};
+ try {
+ this._data = raw ? JSON.parse(raw) : {};
+ } catch (e) {
+ // Corrupted data in KV, start fresh for this visitor.
+ this._data = {};
+ }
this._dirty = false;
}
From a24523c1de4795f7662612e370c00651684c1def Mon Sep 17 00:00:00 2001
From: Ahmed Abbas
Date: Fri, 27 Feb 2026 05:00:44 +0200
Subject: [PATCH 3/7] chore: Replace KV config cache with native fetch cache
and clarify optional persistence
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Address code review on Cloudflare Workers integration:
- Replace KV-based EdgeConfigCache with Cloudflare built-in fetch cache
(cf: { cacheTtl, cacheEverything }) — simpler, free on all plans, no KV
dependency needed for basic setup
- Mark KVDataStore as optional — deterministic MurmurHash bucketing means
the same visitor ID always gets the same variation without persistence
- Document that releaseQueues() must be called in waitUntil() before the
Worker finishes, since the SDK setTimeout-based event timer will not fire
in stateless Workers
- Update demo, README, and wrangler.toml to reflect KV-free default setup
---
demo/cloudflare-workers/src/index.ts | 68 +++++++++++------
demo/cloudflare-workers/wrangler.toml | 25 ++++---
packages/cloudflare/README.md | 28 +++----
packages/cloudflare/src/edge-config-cache.ts | 77 ++++++--------------
packages/cloudflare/src/kv-data-store.ts | 14 +++-
5 files changed, 103 insertions(+), 109 deletions(-)
diff --git a/demo/cloudflare-workers/src/index.ts b/demo/cloudflare-workers/src/index.ts
index b46948e3..6308765f 100644
--- a/demo/cloudflare-workers/src/index.ts
+++ b/demo/cloudflare-workers/src/index.ts
@@ -17,7 +17,6 @@
import ConvertSDK, {BucketedVariation} from '@convertcom/js-sdk';
import {
- KVDataStore,
EdgeConfigCache,
getVisitorId,
setVisitorIdCookie,
@@ -31,8 +30,10 @@ import {
// ---------------------------------------------------------------------------
interface Env {
- CONVERT_KV: KVNamespace;
CONVERT_SDK_KEY: string;
+ // KV is optional — only needed if you enable the KVDataStore for
+ // persisting visitor bucketing data across experience config changes.
+ // CONVERT_KV: KVNamespace;
}
// ---------------------------------------------------------------------------
@@ -40,7 +41,8 @@ interface Env {
// ---------------------------------------------------------------------------
// The SDK instance persists across requests within the same Worker isolate.
-// Config is loaded from KV on the first request and reused afterwards.
+// Config is fetched from the Convert CDN and cached at the Cloudflare edge
+// using the native `cf` fetch cache (no KV required).
// The initialization promise is cached to prevent race conditions when
// concurrent requests hit a cold Worker simultaneously.
let sdk: InstanceType | null = null;
@@ -48,8 +50,8 @@ let sdkReadyPromise: Promise> | null = null;
/**
* Initialise (or reuse) the SDK singleton.
- * Uses EdgeConfigCache so the config is served from KV (~1 ms) instead
- * of fetching from the CDN (~100 ms) on every cold start.
+ * Uses EdgeConfigCache with Cloudflare's built-in fetch cache — the config
+ * response is cached at the edge for the TTL duration, no KV needed.
*/
async function getSDK(env: Env): Promise> {
if (sdk) return sdk;
@@ -57,7 +59,6 @@ async function getSDK(env: Env): Promise> {
sdkReadyPromise = (async () => {
const configCache = new EdgeConfigCache(
- env.CONVERT_KV,
env.CONVERT_SDK_KEY,
300 // cache TTL in seconds (5 minutes)
);
@@ -110,17 +111,15 @@ export default {
visitorId = generateVisitorId();
}
- // 3. Load persisted bucketing data from KV
- const dataStore = new KVDataStore(env.CONVERT_KV);
- await dataStore.load(visitorId);
-
- // 4. Create visitor context
+ // 3. Create visitor context
+ // No KV persistence needed — the SDK uses deterministic MurmurHash
+ // bucketing, so the same visitorId always gets the same variation.
const context = convert.createContext(visitorId);
if (!context) {
return fetch(request);
}
- // 5. Run experiments
+ // 4. Run experiments
// Replace the experience key with your actual experience key from Convert.
const variation = context.runExperience('your-experience-key', {
locationProperties: {url: url.pathname}
@@ -131,27 +130,30 @@ export default {
return fetch(request);
}
- // 6. Fetch the origin page
+ // 5. Fetch the origin page
const originResponse = await fetch(request);
- // 7. Apply the variation using HTMLRewriter
+ // 6. Apply the variation using HTMLRewriter
const modifiedResponse = applyVariation(originResponse, variation);
- // 8. Build response headers (visitor cookie + cache control)
+ // 7. Build response headers (visitor cookie + cache control)
const headers = new Headers(modifiedResponse.headers);
if (isNewVisitor) {
setVisitorIdCookie(headers, visitorId);
}
setCacheHeaders(headers, 300);
- // 9. Persist bucketing data and release tracking events in the background
- // waitUntil() ensures these complete even after the response is sent.
- ctx.waitUntil(
- Promise.all([
- dataStore.save(visitorId),
- context.releaseQueues('edge-request-complete')
- ])
- );
+ // 8. Release tracking events in the background
+ //
+ // IMPORTANT: The SDK batches tracking events and releases them on a
+ // timer (setTimeout). In Cloudflare Workers, the isolate may finish
+ // before that timer fires, so events would be lost. You MUST call
+ // releaseQueues() explicitly to flush all pending tracking events
+ // before the Worker completes.
+ //
+ // waitUntil() ensures the tracking POST completes even after the
+ // response is already sent to the visitor — no added latency.
+ ctx.waitUntil(context.releaseQueues('edge-request-complete'));
return new Response(modifiedResponse.body, {
status: modifiedResponse.status,
@@ -301,3 +303,23 @@ function applyVariation(
// ctx.waitUntil(cache.put(cacheKey, cloned.clone()));
// return cloned;
// }
+
+// ---------------------------------------------------------------------------
+// Optional: KV-Backed Visitor Persistence
+// ---------------------------------------------------------------------------
+
+// If you need to persist bucketing decisions across experience config changes
+// (e.g. ensuring a visitor stays in the same variation even when rules change),
+// add KV support:
+//
+// 1. Add to Env: CONVERT_KV: KVNamespace;
+// 2. Add to wrangler.toml: [[kv_namespaces]] binding/id
+// 3. Use in the handler:
+//
+// import { KVDataStore } from '@convertcom/js-sdk-cloudflare';
+//
+// const dataStore = new KVDataStore(env.CONVERT_KV);
+// await dataStore.load(visitorId);
+// const context = convert.createContext(visitorId, { dataStore });
+// // ... run experiments ...
+// ctx.waitUntil(dataStore.save(visitorId));
diff --git a/demo/cloudflare-workers/wrangler.toml b/demo/cloudflare-workers/wrangler.toml
index 973bb005..3ca55397 100644
--- a/demo/cloudflare-workers/wrangler.toml
+++ b/demo/cloudflare-workers/wrangler.toml
@@ -2,18 +2,19 @@ name = "convert-edge-experiments"
main = "src/index.ts"
compatibility_date = "2024-12-01"
-# KV namespace for config cache and visitor bucketing data.
-# Create with: wrangler kv namespace create CONVERT_KV
-# Then replace the id below with your actual namespace ID.
-[[kv_namespaces]]
-binding = "CONVERT_KV"
-id = "YOUR_KV_NAMESPACE_ID"
-
-# For local development:
-# wrangler kv namespace create CONVERT_KV --preview
-# Then add:
-# preview_id = "YOUR_PREVIEW_NAMESPACE_ID"
-
[vars]
# Your Convert SDK key (account_id/project_id)
CONVERT_SDK_KEY = "YOUR_ACCOUNT_ID/YOUR_PROJECT_ID"
+
+# ------------------------------------------------------------------
+# Optional: KV namespace for persisting visitor bucketing data.
+# Only needed if you use KVDataStore (see demo/index.ts comments).
+# Most setups do NOT need this — the SDK uses deterministic bucketing.
+#
+# Create with: wrangler kv namespace create CONVERT_KV
+# Then uncomment and replace the id below.
+# ------------------------------------------------------------------
+# [[kv_namespaces]]
+# binding = "CONVERT_KV"
+# id = "YOUR_KV_NAMESPACE_ID"
+# preview_id = "YOUR_PREVIEW_NAMESPACE_ID"
diff --git a/packages/cloudflare/README.md b/packages/cloudflare/README.md
index 3550732a..bd861cc1 100644
--- a/packages/cloudflare/README.md
+++ b/packages/cloudflare/README.md
@@ -1,6 +1,6 @@
# @convertcom/js-sdk-cloudflare
-Cloudflare Workers utilities for the [Convert JavaScript SDK](https://github.com/convertcom/javascript-sdk). Provides the glue code between the FullStack SDK and Cloudflare-specific APIs (KV, HTMLRewriter, Workers Request/Response).
+Cloudflare Workers utilities for the [Convert JavaScript SDK](https://github.com/convertcom/javascript-sdk). Provides the glue code between the FullStack SDK and Cloudflare-specific APIs (HTMLRewriter, Workers Request/Response, edge caching).
## Installation
@@ -14,8 +14,8 @@ yarn add @convertcom/js-sdk @convertcom/js-sdk-cloudflare
| Export | Purpose |
|--------|---------|
-| `EdgeConfigCache` | Cache SDK config in KV (~1ms reads vs ~100ms CDN) |
-| `KVDataStore` | KV-backed DataStore adapter for persisting bucketing decisions |
+| `EdgeConfigCache` | Cache SDK config using Cloudflare's native `fetch` cache (no KV needed) |
+| `KVDataStore` | **Optional** KV-backed DataStore adapter for persisting bucketing decisions |
| `getVisitorId` | Parse visitor ID from Workers Request cookies |
| `setVisitorIdCookie` | Set visitor ID cookie on Workers Response |
| `generateVisitorId` | Generate a new UUID visitor ID |
@@ -28,7 +28,6 @@ yarn add @convertcom/js-sdk @convertcom/js-sdk-cloudflare
import ConvertSDK from '@convertcom/js-sdk';
import {
EdgeConfigCache,
- KVDataStore,
getVisitorId,
setVisitorIdCookie,
generateVisitorId
@@ -36,8 +35,8 @@ import {
export default {
async fetch(request, env, ctx) {
- // 1. Get config from KV cache
- const config = await new EdgeConfigCache(env.CONVERT_KV, env.SDK_KEY).getConfig();
+ // 1. Get config (cached at edge via Cloudflare's fetch cache — no KV)
+ const config = await new EdgeConfigCache(env.SDK_KEY).getConfig();
// 2. Init SDK
const sdk = new ConvertSDK({ data: config, network: { tracking: true } });
@@ -46,15 +45,11 @@ export default {
// 3. Identify visitor
const visitorId = getVisitorId(request) || generateVisitorId();
- // 4. Load persisted bucketing from KV
- const dataStore = new KVDataStore(env.CONVERT_KV);
- await dataStore.load(visitorId);
-
- // 5. Run experiment
+ // 4. Run experiment (deterministic bucketing — no persistence needed)
const context = sdk.createContext(visitorId);
const variation = context.runExperience('my-experiment');
- // 6. Modify the page with HTMLRewriter (zero flicker)
+ // 5. Modify the page with HTMLRewriter (zero flicker)
const origin = await fetch(request);
let response = origin;
if (variation?.key === 'variation-1') {
@@ -63,15 +58,12 @@ export default {
.transform(origin);
}
- // 7. Set cookie and respond
+ // 6. Set cookie and respond
const headers = new Headers(response.headers);
setVisitorIdCookie(headers, visitorId);
- // 8. Save KV + flush tracking in background
- ctx.waitUntil(Promise.all([
- dataStore.save(visitorId),
- context.releaseQueues()
- ]));
+ // 7. Flush tracking events before Worker finishes
+ ctx.waitUntil(context.releaseQueues());
return new Response(response.body, { status: response.status, headers });
}
diff --git a/packages/cloudflare/src/edge-config-cache.ts b/packages/cloudflare/src/edge-config-cache.ts
index c1bd2e62..51bc17f3 100644
--- a/packages/cloudflare/src/edge-config-cache.ts
+++ b/packages/cloudflare/src/edge-config-cache.ts
@@ -5,94 +5,66 @@
* License Apache-2.0
*/
-/**
- * Minimal interface compatible with Cloudflare Workers KVNamespace.
- */
-interface KVNamespaceLike {
- get(key: string): Promise;
- put(
- key: string,
- value: string,
- options?: {expirationTtl?: number}
- ): Promise;
-}
-
const DEFAULT_CONFIG_ENDPOINT = 'https://cdn-4.convertexperiments.com/api/v1';
/**
- * Caches the Convert SDK configuration in Cloudflare KV.
+ * Caches the Convert SDK configuration using Cloudflare's built-in fetch cache.
+ *
+ * Instead of requiring a KV namespace, this leverages the `cf` option on
+ * `fetch()` to cache the config response at the edge. This is simpler,
+ * works on all Cloudflare plans, and avoids KV read/write costs.
*
- * Instead of fetching config from the CDN on every Worker invocation,
- * this cache stores it in KV with a TTL. This reduces latency from
- * ~100ms (CDN round-trip) to ~1ms (edge KV read).
+ * @see https://developers.cloudflare.com/workers/examples/cache-using-fetch/
*
* @example
* ```typescript
- * const configCache = new EdgeConfigCache(env.CONVERT_KV, 'YOUR_SDK_KEY');
+ * const configCache = new EdgeConfigCache('YOUR_SDK_KEY');
* const configData = await configCache.getConfig();
*
* const sdk = new ConvertSDK({ data: configData });
* ```
*/
export class EdgeConfigCache {
- private _kv: KVNamespaceLike;
private _sdkKey: string;
private _ttl: number;
private _configEndpoint: string;
/**
- * @param kv - A Cloudflare KV namespace binding
* @param sdkKey - Your Convert SDK key (e.g. 'ACCOUNT_ID/PROJECT_ID')
* @param ttl - Cache TTL in seconds (default: 300 = 5 minutes)
* @param configEndpoint - Override the config CDN endpoint
*/
- constructor(
- kv: KVNamespaceLike,
- sdkKey: string,
- ttl = 300,
- configEndpoint?: string
- ) {
- this._kv = kv;
+ constructor(sdkKey: string, ttl = 300, configEndpoint?: string) {
this._sdkKey = sdkKey;
this._ttl = ttl;
this._configEndpoint = configEndpoint || DEFAULT_CONFIG_ENDPOINT;
}
/**
- * Get the SDK configuration, serving from KV cache when available.
- * Falls back to fetching from the Convert CDN if cache is empty or expired.
+ * Get the SDK configuration, served from Cloudflare's edge cache when
+ * available. Falls back to fetching from the Convert CDN if the cache
+ * entry has expired.
*/
async getConfig(): Promise {
- const cacheKey = `config:${this._sdkKey}`;
-
- // Try KV cache first
- const cached = await this._kv.get(cacheKey);
- if (cached) {
- try {
- return JSON.parse(cached);
- } catch (e) {
- // Corrupted data in KV, fall through to re-fetch from CDN.
- }
- }
-
- // Cache miss or corrupted: fetch from CDN and store in KV
- return this._fetchAndCache(cacheKey);
+ return this._fetch(this._ttl);
}
/**
- * Force-refresh the configuration from the Convert CDN.
- * Use this for manual cache invalidation.
+ * Force-refresh the configuration by bypassing the edge cache.
+ * Use this for manual cache invalidation (e.g. via a cron trigger or webhook).
*/
async refreshConfig(): Promise {
- const cacheKey = `config:${this._sdkKey}`;
- return this._fetchAndCache(cacheKey);
+ return this._fetch(0);
}
- private async _fetchAndCache(cacheKey: string): Promise {
+ private async _fetch(cacheTtl: number): Promise {
const url = `${this._configEndpoint}/config/${this._sdkKey}`;
+ // The `cf` property is a Cloudflare Workers extension to the standard
+ // fetch API that controls edge caching behaviour.
const response = await fetch(url, {
- headers: {'Content-Type': 'application/json'}
- });
+ headers: {'Content-Type': 'application/json'},
+ cf: {cacheTtl, cacheEverything: true}
+ } as any);
if (!response.ok) {
throw new Error(
@@ -100,11 +72,6 @@ export class EdgeConfigCache {
);
}
- const data = await response.json();
- await this._kv.put(cacheKey, JSON.stringify(data), {
- expirationTtl: this._ttl
- });
-
- return data;
+ return response.json();
}
}
diff --git a/packages/cloudflare/src/kv-data-store.ts b/packages/cloudflare/src/kv-data-store.ts
index c40ac7fb..7074feb4 100644
--- a/packages/cloudflare/src/kv-data-store.ts
+++ b/packages/cloudflare/src/kv-data-store.ts
@@ -19,7 +19,15 @@ interface KVNamespaceLike {
}
/**
- * A DataStore adapter for Cloudflare Workers KV.
+ * **Optional** DataStore adapter for Cloudflare Workers KV.
+ *
+ * This is NOT required for basic A/B testing. The SDK uses deterministic
+ * MurmurHash bucketing, so the same visitor ID always gets the same
+ * variation. You only need this if you want to:
+ *
+ * - Preserve bucketing across experience config changes
+ * - Store custom visitor attributes between requests
+ * - Share state across multiple Workers or routes
*
* The Convert SDK expects a synchronous DataStore (get/set).
* Since KV is async, this adapter works in three phases:
@@ -27,6 +35,10 @@ interface KVNamespaceLike {
* 2. get/set - sync read/write on in-memory snapshot (used by SDK)
* 3. save() - async write from memory back to KV (call after SDK operations)
*
+ * **Note:** Cloudflare KV is a paid feature and may not be available on all
+ * plans. For most use cases, cookie-based visitor identification with
+ * deterministic bucketing is sufficient without KV persistence.
+ *
* @example
* ```typescript
* const dataStore = new KVDataStore(env.CONVERT_KV);
From be7e235828a00cec94d233c23463e1e8e224cd7a Mon Sep 17 00:00:00 2001
From: Ahmed Abbas
Date: Sun, 5 Apr 2026 15:57:32 +0200
Subject: [PATCH 4/7] feat: replace Karma with Playwright and add full-chain
integration tests
- Replace Karma + BrowserStack browser tests with Playwright
- Port all UMD bundle browser tests (19 tests)
- Add full-chain integration tests matching PHP SDK pattern (17 tests per mode)
- Support 3 auth modes: static, live (public key), live-secret (key+secret)
- Upgrade CI to Node 22, corepack, actions v4
- Remove Karma, BrowserStack, and Puppeteer dependencies
- Add TESTING.md documentation
---
.env.example | 7 +-
.github/workflows/qa.yml | 36 +-
.gitignore | 1 +
packages/js-sdk/TESTING.md | 199 +++
packages/js-sdk/index.browser.cjs.tests.js | 7 -
packages/js-sdk/index.browser.umd.tests.js | 12 -
packages/js-sdk/index.tests.js | 247 ----
packages/js-sdk/karma.base.conf.js | 104 --
packages/js-sdk/karma.cjs.conf.js | 28 -
packages/js-sdk/karma.umd.conf.js | 30 -
packages/js-sdk/package.json | 27 +-
packages/js-sdk/playwright.config.ts | 51 +
packages/js-sdk/tests/browser/test-page.html | 26 +
packages/js-sdk/tests/browser/test-server.js | 52 +
.../js-sdk/tests/browser/umd-bundle.spec.ts | 365 +++++
.../tests/integration/full-chain.spec.ts | 462 ++++++
.../tests/integration/static-config.json | 1 +
yarn.lock | 1270 ++---------------
18 files changed, 1289 insertions(+), 1636 deletions(-)
create mode 100644 packages/js-sdk/TESTING.md
delete mode 100644 packages/js-sdk/index.browser.cjs.tests.js
delete mode 100644 packages/js-sdk/index.browser.umd.tests.js
delete mode 100644 packages/js-sdk/index.tests.js
delete mode 100644 packages/js-sdk/karma.base.conf.js
delete mode 100644 packages/js-sdk/karma.cjs.conf.js
delete mode 100644 packages/js-sdk/karma.umd.conf.js
create mode 100644 packages/js-sdk/playwright.config.ts
create mode 100644 packages/js-sdk/tests/browser/test-page.html
create mode 100644 packages/js-sdk/tests/browser/test-server.js
create mode 100644 packages/js-sdk/tests/browser/umd-bundle.spec.ts
create mode 100644 packages/js-sdk/tests/integration/full-chain.spec.ts
create mode 100644 packages/js-sdk/tests/integration/static-config.json
diff --git a/.env.example b/.env.example
index 06f69b9a..71905afa 100644
--- a/.env.example
+++ b/.env.example
@@ -1,6 +1,7 @@
-# BrowserStack credentails
-BROWSER_STACK_USERNAME=
-BROWSER_STACK_ACCESS=
+# Convert Staging SDK Key
+CONVERT_STAGING_SDK_KEY=
+CONVERT_STAGING_SDK_KEY2=
+CONVERT_STAGING_SDK_KEY2_SECRET=
# Logger
LOG_LEVEL=2
diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml
index 63028700..dcb0225e 100644
--- a/.github/workflows/qa.yml
+++ b/.github/workflows/qa.yml
@@ -17,29 +17,37 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
- node: [18]
+ node: [22]
# Steps represent a sequence of tasks that will be executed as part of the job
steps:
- - uses: actions/setup-node@v1
+ - uses: actions/checkout@v4
with:
- # The Node.js version to configure
- node-version: ${{ matrix.node }}
+ fetch-depth: 2
- # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
- - uses: actions/checkout@v2
+ - name: Setup Node
+ uses: actions/setup-node@v4
with:
- fetch-depth: 2
- - name: Install needed libraries and packages
+ node-version: ${{ matrix.node }}
+
+ - name: Setup Yarn
+ run: |
+ corepack enable
+ corepack prepare yarn@stable --activate
+
+ - name: Install Playwright browsers
run: |
- wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | sudo apt-key add -
- sudo sh -c 'echo "deb http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list'
- sudo apt-get update
- sudo apt-get install -y google-chrome-stable
+ cd packages/js-sdk
+ npx playwright install --with-deps chromium
- name: Runs the SDK QA checks
+ env:
+ CONVERT_STAGING_SDK_KEY: ${{ secrets.CONVERT_STAGING_SDK_KEY }}
+ CONVERT_STAGING_SDK_KEY2: ${{ secrets.CONVERT_STAGING_SDK_KEY2 }}
+ CONVERT_STAGING_SDK_KEY2_SECRET: ${{ secrets.CONVERT_STAGING_SDK_KEY2_SECRET }}
run: |
- yarn set version berry
yarn
cd packages/js-sdk
yarn lint
- yarn test
+ yarn build
+ yarn test:mocha
+ yarn test:browser
diff --git a/.gitignore b/.gitignore
index 2ab095da..20e9e4df 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,6 +12,7 @@ lib/
dist/
docs/
coverage/
+test-results/
packages/demo-*
.next
demo/remixjs-server-side/build
diff --git a/packages/js-sdk/TESTING.md b/packages/js-sdk/TESTING.md
new file mode 100644
index 00000000..4ee0f790
--- /dev/null
+++ b/packages/js-sdk/TESTING.md
@@ -0,0 +1,199 @@
+# Testing Guide
+
+This document covers how to run and write tests for the `@convertcom/js-sdk` package.
+
+## Prerequisites
+
+- Node.js >= 22
+- Yarn (via corepack: `corepack enable && corepack prepare yarn@stable --activate`)
+- Playwright Chromium browser: `npx playwright install --with-deps chromium`
+- Built SDK bundles (run `yarn build` before browser/integration tests)
+
+## Test Suites
+
+The SDK has three test suites:
+
+| Suite | Runner | Scope | Command |
+|-------|--------|-------|---------|
+| **Unit tests** | Mocha + Chai | Core logic, context, feature manager, utilities | `yarn test:mocha` |
+| **Browser tests** | Playwright | UMD bundle loaded in Chromium | `yarn test:browser` |
+| **Integration tests** | Playwright | Full SDK lifecycle (init, bucket, feature, conversion) | `yarn test:browser` |
+
+### Run everything
+
+```bash
+yarn build
+yarn test:mocha
+yarn test:browser
+```
+
+Or use the combined command (includes coverage):
+
+```bash
+yarn build
+yarn test
+```
+
+## Unit Tests (Mocha)
+
+Located in `tests/**/*.tests.ts`. These test SDK internals using Mocha + Chai with `ts-node/register`.
+
+```bash
+yarn test:mocha
+```
+
+Test files:
+- `tests/core.tests.ts` — Core class (init, context creation, events)
+- `tests/context.tests.ts` — Context class (runExperience, runFeature, trackConversion)
+- `tests/feature-manager.tests.ts` — Feature manager logic
+- `tests/utils/*.tests.ts` — Array, object, string, and comparison utilities
+
+These tests use `tests/test-config.json` (fabricated IDs) and do **not** require network access.
+
+## Browser Tests (Playwright)
+
+Located in `tests/browser/`. These verify the built UMD bundle works correctly in a real browser environment.
+
+```bash
+yarn build # Must build first — tests serve the built bundles
+yarn test:browser
+```
+
+**How it works:**
+1. Playwright starts a local HTTP server (`tests/browser/test-server.js`) on port 3939
+2. The server serves the built UMD bundle (`lib/index.umd.min.js`) and a test HTML page
+3. Tests navigate to the page and use `page.evaluate()` to exercise the SDK in browser context
+
+Test file:
+- `tests/browser/umd-bundle.spec.ts` — 19 tests covering SDK instantiation, experiences, features, conversions, segments, and invalid visitor handling
+
+These tests use `tests/test-config.json` (fabricated IDs) and do **not** require network access.
+
+## Integration Tests (Playwright)
+
+Located in `tests/integration/`. These run the full SDK lifecycle in Node.js context (not browser), matching the PHP SDK's `FullChainIntegrationTest` pattern.
+
+```bash
+yarn build # Must build first — tests import from lib/
+yarn test:browser # Integration tests run as part of the Playwright suite
+```
+
+Test file:
+- `tests/integration/full-chain.spec.ts` — 17 tests per mode covering:
+ - **Happy path:** init, ready event, experience bucketing, bucketing determinism, bucketing events, typed feature variables, full chain verification
+ - **Negative path:** unknown feature key, non-qualifying location, audience mismatch
+ - **Conversion tracking:** basic conversion, conversion events, goal deduplication, revenue tracking, forced multiple transactions, nonexistent goal
+ - **Complete chain:** init -> context -> bucket -> feature -> conversion -> flush
+
+### Auth Modes
+
+Integration tests run in up to 3 modes, following the PHP SDK pattern:
+
+| Mode | Config source | Env vars required | Always runs? |
+|------|--------------|-------------------|-------------|
+| `static` | `tests/integration/static-config.json` | None | Yes |
+| `live` | CDN fetch (public key) | `CONVERT_STAGING_SDK_KEY` | No |
+| `live-secret` | CDN fetch (authenticated) | `CONVERT_STAGING_SDK_KEY2`, `CONVERT_STAGING_SDK_KEY2_SECRET` | No |
+
+The `static` mode always runs using a snapshot of the staging project config. The `live` and `live-secret` modes are skipped when the corresponding env vars are not set.
+
+### Setting up env vars for live tests
+
+Copy `.env.example` to `.env` and fill in the values:
+
+```bash
+# Public SDK key for unauthenticated CDN fetch
+CONVERT_STAGING_SDK_KEY=
+
+# SDK key + secret for authenticated CDN fetch
+CONVERT_STAGING_SDK_KEY2=
+CONVERT_STAGING_SDK_KEY2_SECRET=
+```
+
+Then run with the env vars loaded:
+
+```bash
+export $(grep -v '^#' .env | xargs)
+yarn build
+yarn test:browser
+```
+
+### Staging Project
+
+All integration tests use the shared staging project **"FS-Test-Proj - DO NOT DELETE"** (account `10035569`, project `10034190`). This is the same project used by the PHP SDK's integration tests.
+
+Key entities:
+- **Experience:** `test-experience-ab-fullstack-4` — 50/50 split, pricing-location, no audiences
+- **Feature-1:** boolean `enabled`, string `caption`
+- **Feature-2:** float `price` (100), integer `button-height` (40), json `additionalData`
+- **Goals:** `increase-engagement` (dom_interaction, no rules), `decrease-bounce-rate` (advanced)
+
+**Do not modify or delete this project.** Changes will break integration tests in both the JS and PHP SDKs.
+
+## Playwright Configuration
+
+Config file: `playwright.config.ts`
+
+- **Test server:** Auto-started on port 3939 (configurable via `PORT` env var)
+- **Browser:** Chromium only, headless, with `--no-sandbox`
+- **Workers:** 1 (sequential execution — tests share SDK state)
+- **Timeout:** 60 seconds per test
+- **Retries:** 2 on CI, 0 locally
+- **Traces:** Retained on failure
+
+## CI
+
+Tests run in GitHub Actions via `.github/workflows/qa.yml`:
+
+```
+yarn → build → lint → test:mocha → test:browser
+```
+
+Live integration tests run in CI when the `CONVERT_STAGING_SDK_KEY`, `CONVERT_STAGING_SDK_KEY2`, and `CONVERT_STAGING_SDK_KEY2_SECRET` secrets are configured in the repository.
+
+## Writing New Tests
+
+### Adding a unit test
+
+Add a `.tests.ts` file under `tests/`. It will be picked up automatically by the mocha glob `tests/**/*.tests.ts`.
+
+```typescript
+import 'mocha';
+import {expect} from 'chai';
+
+describe('MyFeature', () => {
+ it('should do something', () => {
+ expect(true).to.be.true;
+ });
+});
+```
+
+### Adding a browser test
+
+Add assertions to `tests/browser/umd-bundle.spec.ts` or create a new `.spec.ts` file under `tests/browser/`.
+
+```typescript
+import {test, expect} from '@playwright/test';
+
+test('SDK does something in browser', async ({page}) => {
+ await page.goto('/');
+ const result = await page.evaluate(() => {
+ // ConvertSDK is available as a global from the UMD bundle
+ return typeof ConvertSDK;
+ });
+ expect(result).toBe('function');
+});
+```
+
+### Adding an integration test
+
+Add tests inside the `for (const mode of modes)` loop in `tests/integration/full-chain.spec.ts` to ensure they run in all auth modes.
+
+```typescript
+test('My new integration test', async () => {
+ const sdk = createSdk(mode);
+ await sdk.onReady();
+ const context = sdk.createContext('my-visitor-id');
+ // ... exercise SDK and assert
+});
+```
diff --git a/packages/js-sdk/index.browser.cjs.tests.js b/packages/js-sdk/index.browser.cjs.tests.js
deleted file mode 100644
index 4efa54ef..00000000
--- a/packages/js-sdk/index.browser.cjs.tests.js
+++ /dev/null
@@ -1,7 +0,0 @@
-import * as ConvertSDK from './lib/index';
-import runTests from './index.tests';
-
-describe('Karma browser tests for CommonJS bundle', function () {
- // eslint-disable-next-line mocha/no-setup-in-describe
- runTests(ConvertSDK);
-});
diff --git a/packages/js-sdk/index.browser.umd.tests.js b/packages/js-sdk/index.browser.umd.tests.js
deleted file mode 100644
index 88982033..00000000
--- a/packages/js-sdk/index.browser.umd.tests.js
+++ /dev/null
@@ -1,12 +0,0 @@
-// No SDK library imports here. A UMD script should be already loaded in browser by karma
-import {assert} from 'chai';
-import runTests from './index.tests';
-
-describe('Karma browser tests for UMD bundle', function () {
- it('Should have an SDK instance in namespace', function () {
- // eslint-disable-next-line no-undef
- assert.isDefined(ConvertSDK);
- });
- // eslint-disable-next-line mocha/no-setup-in-describe,no-undef
- runTests(ConvertSDK);
-});
diff --git a/packages/js-sdk/index.tests.js b/packages/js-sdk/index.tests.js
deleted file mode 100644
index 053b6ffd..00000000
--- a/packages/js-sdk/index.tests.js
+++ /dev/null
@@ -1,247 +0,0 @@
-/* eslint-disable mocha/consistent-spacing-between-blocks */
-import {expect} from 'chai';
-import {assert} from 'chai';
-import testConfig from './tests/test-config.json';
-import {
- getFeaturesWithStatuses,
- getMultipleFeatureWithStatus,
- getSingleFeatureWithStatus,
- getVariationsAcrossAllExperiences
-} from './tests/setup/shared';
-
-class DataStore {
- data = {};
- get(key) {
- if (!key) return this.data;
- return this.data[key.toString()];
- }
- set(key, value) {
- if (!key) throw new Error('Invalid DataStore key!');
- this.data[key.toString()] = value;
- }
-}
-
-const dataStore = new DataStore();
-testConfig.dataStore = dataStore;
-testConfig.events = {
- batch_size: 1,
- release_interval: 1000
-};
-
-const accountId = testConfig.data.account_id;
-const projectId = testConfig.data.project.id;
-const visitorId = 'XXX';
-const storeKey = `${accountId}-${projectId}-${visitorId}`;
-
-const defaultSegments = {browser: 'chrome'};
-
-// eslint-disable-next-line mocha/no-exports
-export default function runTests(bundle) {
- const ConvertSDK = bundle.default;
- let convert, context;
- describe('Basic SDK instance', function () {
- // eslint-disable-next-line mocha/no-hooks-for-single-case
- beforeEach(function () {
- convert = new ConvertSDK(testConfig);
- });
- it('Should have an SDK instance as an object', function () {
- expect(bundle).to.be.an('object');
- });
- it('Should have a constructor', function () {
- expect(ConvertSDK).to.be.a('function');
- });
- it('Should create a default SDK instance and fire ready event. Expect no errors', function (done) {
- convert.on('ready', function (args, err) {
- expect(err).to.be.null;
- //expect(convert).to.have.property('version').which.is.a('string');
- expect(convert).to.be.an('object');
- done();
- });
- });
- it('Should create a default SDK instance and resolve a promise. Expect no errors', async function () {
- await convert.onReady();
- assert.equal(true, true);
- });
- it('Shoud successfully create visitor context', function () {
- const visitorContext = convert.createContext(visitorId, defaultSegments);
- expect(visitorContext).to.be.an('object');
- [
- 'runExperience',
- 'runExperiences',
- 'runFeature',
- 'runFeatures',
- 'trackConversion',
- 'setDefaultSegments',
- 'runCustomSegments'
- ].forEach((method) => {
- expect(visitorContext).to.have.a.property(method);
- });
- });
- });
- describe('Basic SDK methods', function () {
- // eslint-disable-next-line mocha/no-hooks-for-single-case
- before(function () {
- convert = new ConvertSDK(testConfig);
- });
- // eslint-disable-next-line mocha/no-hooks-for-single-case
- beforeEach(function () {
- context = convert.createContext(visitorId, defaultSegments);
- });
- it('Shoud successfully get variation from specific experience', function (done) {
- const experienceKey = 'test-experience-ab-fullstack-2';
- const variation = context.runExperience(experienceKey, {
- locationProperties: {url: 'https://convert.com/'},
- visitorProperties: {
- varName3: 'something'
- }
- });
- expect(variation)
- .to.be.an('object')
- .that.have.keys(
- 'experienceId',
- 'experienceKey',
- 'experienceName',
- 'bucketingAllocation',
- 'id',
- 'key',
- 'name',
- 'status',
- 'changes',
- 'is_baseline',
- 'traffic_allocation'
- );
- expect(variation.experienceKey).to.equal(experienceKey);
- done();
- });
- it('Shoud successfully get variations across all experiences', function (done) {
- getVariationsAcrossAllExperiences(
- {
- accountId,
- projectId,
- context
- },
- done
- );
- });
- it('Shoud successfully get a single feature and its status', function (done) {
- const featureId = '10025';
- getSingleFeatureWithStatus(
- {
- accountId,
- projectId,
- featureId,
- context
- },
- done
- );
- });
- it('Shoud successfully get multiple features and its status', function (done) {
- getMultipleFeatureWithStatus(
- {
- accountId,
- projectId,
- context
- },
- done
- );
- });
- it('Shoud successfully get features and their statuses', function (done) {
- getFeaturesWithStatuses(
- {
- accountId,
- projectId,
- context
- },
- done
- );
- });
- it('Should trigger Conversion', function () {
- const goalKey = 'increase-engagement';
- const response = context.trackConversion(goalKey, {
- ruleData: {
- action: 'buy'
- },
- conversionData: [
- {
- key: 'amount',
- value: 10.3
- },
- {
- key: 'productsCount',
- value: 2
- }
- ]
- });
- expect(response).to.be.undefined;
- });
- it('Should successfully set default segments', function (done) {
- const segments = {country: 'US'};
- context.setDefaultSegments(segments);
- setTimeout(function () {
- const localSegments = dataStore.get(storeKey);
- expect(localSegments)
- .to.have.property('segments')
- .that.deep.equal({
- ...segments,
- ...defaultSegments
- });
- done();
- }, testConfig.events.release_interval + 1);
- });
- it('Should successfully set custom segments', function (done) {
- const segmentKey = 'test-segments-1';
- const segmentId = '200299434';
- context.runCustomSegments(segmentKey, {
- ruleData: {
- enabled: true
- }
- });
- setTimeout(function () {
- const {segments} = dataStore.get(storeKey) || {};
- expect(segments)
- .to.be.an('object')
- .that.has.property('customSegments')
- .to.deep.equal([segmentId]);
- done();
- }, testConfig.events.release_interval + 1);
- });
- });
- describe('Test invalid visitor', function () {
- // eslint-disable-next-line mocha/no-hooks-for-single-case
- before(function () {
- convert = new ConvertSDK(testConfig);
- });
- // eslint-disable-next-line mocha/no-hooks-for-single-case
- beforeEach(function () {
- context = convert.createContext();
- });
- it('Shoud fail to get variation from specific experience if no visitor is set', function () {
- const experienceKey = 'test-experience-ab-fullstack-2';
- const variation = context.runExperience(experienceKey);
- expect(variation).to.be.undefined;
- });
- it('Shoud fail to get variations across all experiences if no visitor is set', function () {
- const variations = context.runExperiences();
- expect(variations).to.be.undefined;
- });
- it('Shoud fail to get feature and its status if no visitor is set', function () {
- const featureKey = 'feature-1';
- const features = context.runFeature(featureKey);
- expect(features).to.be.undefined;
- });
- it('Shoud fail to get features and their statuses if no visitor is set', function () {
- const features = context.runFeatures();
- expect(features).to.be.undefined;
- });
- it('Should fail to trigger Conversion if no visitor is set', function () {
- const goalKey = 'increase-engagement';
- const output = context.trackConversion(goalKey);
- expect(output).to.be.undefined;
- });
- it('Should fail to set custom segments if no visitor is set', function () {
- const segmentKey = 'test-segments-1';
- const output = context.runCustomSegments(segmentKey);
- expect(output).to.be.undefined;
- });
- });
-}
diff --git a/packages/js-sdk/karma.base.conf.js b/packages/js-sdk/karma.base.conf.js
deleted file mode 100644
index cdde42e4..00000000
--- a/packages/js-sdk/karma.base.conf.js
+++ /dev/null
@@ -1,104 +0,0 @@
-// eslint-disable-next-line @typescript-eslint/no-var-requires
-const dotenv = require('dotenv');
-// eslint-disable-next-line @typescript-eslint/no-var-requires
-const path = require('path');
-dotenv.config();
-// eslint-disable-next-line @typescript-eslint/no-var-requires
-process.env.CHROME_BIN = require('puppeteer').executablePath();
-module.exports = {
- // base path that will be used to resolve all patterns (eg. files, exclude)
- basePath: './',
-
- // frameworks to use
- // available frameworks: https://npmjs.org/browse/keyword/karma-adapter
- frameworks: ['mocha', 'chai', 'webpack'],
-
- // list of files / patterns to load in the browser
- files: [],
-
- // list of files / patterns to exclude
- exclude: [],
-
- // preprocess matching files before serving them to the browser
- // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor
- preprocessors: {},
-
- plugins: [
- 'karma-mocha',
- 'karma-chai',
- 'karma-webpack',
- 'karma-chrome-launcher',
- require('karma-browserstack-launcher'),
- 'karma-mocha-reporter'
- ],
- // test results reporter to use
- // possible values: 'dots', 'progress'
- // available reporters: https://npmjs.org/browse/keyword/karma-reporter
- // reporters: ['progress'],
- reporters: ['mocha', 'BrowserStack'],
-
- // web server port
- port: 9876,
-
- // enable / disable colors in the output (reporters and logs)
- colors: true,
-
- // enable / disable watching file and executing tests whenever any file changes
- autoWatch: false,
-
- // Continuous Integration mode
- // if true, Karma captures browsers, runs the tests and exits
- singleRun: true,
-
- // Concurrency level
- // how many browser should be started simultaneous
- concurrency: Infinity,
-
- browserStack: {
- username: process.env.BROWSER_STACK_USERNAME,
- accessKey: process.env.BROWSER_STACK_ACCESS,
- startTunnel: true
- },
- webpack: {
- mode: 'production',
- devtool: 'inline-source-map',
- performance: {
- hints: false
- }
- },
- // define browsers
- customLaunchers: {
- bs_firefox100_win10: {
- base: 'BrowserStack',
- os: 'Windows',
- os_version: '10',
- browser: 'firefox',
- device: null,
- browser_version: '100.0',
- real_mobile: null
- },
- bs_chrome_100_osx: {
- base: 'BrowserStack',
- os: 'OS X',
- os_version: 'Mojave',
- browser: 'chrome',
- device: null,
- browser_version: '100.0',
- real_mobile: null
- },
- custom_chrome_headless: {
- base: 'ChromeHeadless',
- chromeDataDir: path.resolve(__dirname, 'webpack-build/.chrome-karma'),
- flags: [
- '--disable-gpu',
- '--no-sandbox',
- '--crash-dumps-dir=' +
- path.resolve(__dirname, 'webpack-build/.chrome-karma-crash')
- ]
- }
- },
-
- // start these browsers
- // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher
- browsers: ['custom_chrome_headless']
-};
diff --git a/packages/js-sdk/karma.cjs.conf.js b/packages/js-sdk/karma.cjs.conf.js
deleted file mode 100644
index 735db53b..00000000
--- a/packages/js-sdk/karma.cjs.conf.js
+++ /dev/null
@@ -1,28 +0,0 @@
-// Karma configuration
-// eslint-disable-next-line @typescript-eslint/no-var-requires
-const baseConfig = require('./karma.base.conf.js');
-// eslint-disable-next-line @typescript-eslint/no-var-requires
-const path = require('path');
-// eslint-disable-next-line @typescript-eslint/no-var-requires
-const os = require('os');
-module.exports = function (config) {
- config.set({
- ...baseConfig,
-
- files: [{pattern: './index.browser.cjs.tests.js'}],
- preprocessors: {
- './index.browser.cjs.tests.js': ['webpack']
- },
- webpack: {
- output: {
- filename: '[name].js',
- path:
- path.join(os.tmpdir(), '_karma_webpack_') +
- Math.floor(Math.random() * 1000000)
- }
- },
- // level of logging
- // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG
- logLevel: config.LOG_INFO
- });
-};
diff --git a/packages/js-sdk/karma.umd.conf.js b/packages/js-sdk/karma.umd.conf.js
deleted file mode 100644
index 10f596d8..00000000
--- a/packages/js-sdk/karma.umd.conf.js
+++ /dev/null
@@ -1,30 +0,0 @@
-// Karma configuration
-// eslint-disable-next-line @typescript-eslint/no-var-requires
-const baseConfig = require('./karma.base.conf.js');
-// eslint-disable-next-line @typescript-eslint/no-var-requires
-const path = require('path');
-// eslint-disable-next-line @typescript-eslint/no-var-requires
-const os = require('os');
-module.exports = function (config) {
- config.set({
- ...baseConfig,
- // list of files / patterns to load in the browser
- files: ['./lib/index.umd.min.js', './index.browser.umd.tests.js'],
- // preprocess matching files before serving them to the browser
- // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor
- preprocessors: {
- './index.browser.umd.tests.js': ['webpack']
- },
- webpack: {
- output: {
- filename: '[name].js',
- path:
- path.join(os.tmpdir(), '_karma_webpack_') +
- Math.floor(Math.random() * 1000000)
- }
- },
- // level of logging
- // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG
- logLevel: config.LOG_INFO
- });
-};
diff --git a/packages/js-sdk/package.json b/packages/js-sdk/package.json
index bafceeb9..8bb9c249 100644
--- a/packages/js-sdk/package.json
+++ b/packages/js-sdk/package.json
@@ -16,12 +16,10 @@
},
"license": "Apache-2.0",
"scripts": {
- "pretest": "mkdir -p webpack-build && rm -rf coverage",
+ "pretest": "rm -rf coverage",
"test": "nyc yarn test:all && yarn coverage",
"test:all": "yarn test:mocha && yarn test:browser",
- "test:browser": "yarn build && yarn test:cjsbrowser && yarn test:umdbrowser",
- "test:cjsbrowser": "karma start karma.cjs.conf.js --single-run",
- "test:umdbrowser": "karma start karma.umd.conf.js --single-run",
+ "test:browser": "playwright test --config playwright.config.ts --project chromium",
"test:server": "nyc yarn test:mocha",
"test:mocha": "mocha -r ts-node/register --recursive \"tests/**/*.tests.ts\" --exit",
"clean": "rm -rf lib",
@@ -43,6 +41,7 @@
"@babel/preset-env": "^7.28.5",
"@eslint/eslintrc": "^3.3.1",
"@jsdoc/salty": "^0.2.9",
+ "@playwright/test": "^1.52.0",
"@rollup/plugin-babel": "^6.1.0",
"@rollup/plugin-commonjs": "^29.0.0",
"@rollup/plugin-json": "^6.1.0",
@@ -68,20 +67,9 @@
"eslint-plugin-prettier": "^5.5.4",
"glob": "^11.0.3",
"istanbul-cobertura-badger": "^1.3.1",
- "karma": "^6.4.4",
- "karma-browserify": "^8.1.0",
- "karma-browserstack-launcher": "^1.6.0",
- "karma-chai": "^0.1.0",
- "karma-chrome-launcher": "^3.2.0",
- "karma-cli": "^2.0.0",
- "karma-mocha": "^2.0.1",
- "karma-mocha-reporter": "^2.2.5",
- "karma-sourcemap-loader": "^0.4.0",
- "karma-webpack": "5.0.1",
"mocha": "^11.7.5",
"nyc": "^17.1.0",
"prettier": "^3.6.2",
- "puppeteer": "^24.29.1",
"rollup": "^4.53.2",
"rollup-plugin-copy": "^3.5.0",
"rollup-plugin-generate-package-json": "^3.2.0",
@@ -97,15 +85,6 @@
"watchify": "^4.0.0",
"webpack": "^5.102.1"
},
- "overrides": {
- "karma-browserstack-launcher": {
- "browserstack": {
- ".": "1.6.1",
- "https-proxy-agent": "7.0.4"
- },
- "browserstack-local": "1.5.5"
- }
- },
"version": "4.3.4",
"peerDependencies": {
"@convertcom/js-sdk-api": ">=2.1.4",
diff --git a/packages/js-sdk/playwright.config.ts b/packages/js-sdk/playwright.config.ts
new file mode 100644
index 00000000..e3654dbf
--- /dev/null
+++ b/packages/js-sdk/playwright.config.ts
@@ -0,0 +1,51 @@
+import {defineConfig, devices} from '@playwright/test';
+
+const PORT = process.env.PORT || 3939;
+
+/**
+ * Playwright configuration for JS SDK browser and integration tests.
+ * See https://playwright.dev/docs/test-configuration.
+ */
+export default defineConfig({
+ testDir: './tests',
+ testMatch: ['browser/**/*.spec.ts', 'integration/**/*.spec.ts'],
+ /* Run tests sequentially — SDK tests share state */
+ fullyParallel: false,
+ /* Fail the build on CI if you accidentally left test.only in the source code. */
+ forbidOnly: !!process.env.CI,
+ /* Retry on CI only */
+ retries: process.env.CI ? 2 : 0,
+ /* Single worker to avoid port conflicts and shared state issues */
+ workers: 1,
+ /* Reporter to use */
+ reporter: [['list']],
+ /* Shared settings for all the projects below */
+ use: {
+ /* Base URL to use in actions like `await page.goto('/')`. */
+ baseURL: `http://localhost:${PORT}`,
+ /* Collect trace when retrying the failed test */
+ trace: 'retain-on-failure'
+ },
+ /* Each test is given 60 seconds */
+ timeout: 60_000,
+
+ /* Configure projects for major browsers */
+ projects: [
+ {
+ name: 'chromium',
+ use: {
+ ...devices['Desktop Chrome'],
+ launchOptions: {
+ args: ['--disable-gpu', '--no-sandbox']
+ }
+ }
+ }
+ ],
+
+ /* Run local test server before starting the tests */
+ webServer: {
+ command: `node tests/browser/test-server.js`,
+ url: `http://localhost:${PORT}`,
+ reuseExistingServer: false
+ }
+});
diff --git a/packages/js-sdk/tests/browser/test-page.html b/packages/js-sdk/tests/browser/test-page.html
new file mode 100644
index 00000000..e2a79f5a
--- /dev/null
+++ b/packages/js-sdk/tests/browser/test-page.html
@@ -0,0 +1,26 @@
+
+
+
+
+
+ JS SDK Browser Test
+
+
+
+
+
+
+
diff --git a/packages/js-sdk/tests/browser/test-server.js b/packages/js-sdk/tests/browser/test-server.js
new file mode 100644
index 00000000..ae73e0d9
--- /dev/null
+++ b/packages/js-sdk/tests/browser/test-server.js
@@ -0,0 +1,52 @@
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+
+const PORT = process.env.PORT || 3939;
+const ROOT = path.resolve(__dirname, '..', '..');
+
+const MIME_TYPES = {
+ '.html': 'text/html',
+ '.js': 'application/javascript',
+ '.mjs': 'application/javascript',
+ '.json': 'application/json',
+ '.css': 'text/css'
+};
+
+const ROUTES = {
+ '/': path.join(ROOT, 'tests/browser/test-page.html'),
+ '/umd.html': path.join(ROOT, 'tests/browser/test-page.html'),
+ '/lib/index.js': path.join(ROOT, 'lib/index.js'),
+ '/lib/index.umd.min.js': path.join(ROOT, 'lib/index.umd.min.js'),
+ '/test-config.json': path.join(ROOT, 'tests/test-config.json'),
+ '/static-config.json': path.join(
+ ROOT,
+ 'tests/integration/static-config.json'
+ )
+};
+
+const server = http.createServer((req, res) => {
+ const url = req.url.split('?')[0];
+ const filePath = ROUTES[url];
+
+ if (!filePath) {
+ res.writeHead(404, {'Content-Type': 'text/plain'});
+ res.end('Not Found');
+ return;
+ }
+
+ try {
+ const content = fs.readFileSync(filePath);
+ const ext = path.extname(filePath);
+ const contentType = MIME_TYPES[ext] || 'application/octet-stream';
+ res.writeHead(200, {'Content-Type': contentType});
+ res.end(content);
+ } catch (err) {
+ res.writeHead(500, {'Content-Type': 'text/plain'});
+ res.end('Internal Server Error: ' + err.message);
+ }
+});
+
+server.listen(PORT, () => {
+ console.log(`Test server listening on http://localhost:${PORT}`);
+});
diff --git a/packages/js-sdk/tests/browser/umd-bundle.spec.ts b/packages/js-sdk/tests/browser/umd-bundle.spec.ts
new file mode 100644
index 00000000..3779685a
--- /dev/null
+++ b/packages/js-sdk/tests/browser/umd-bundle.spec.ts
@@ -0,0 +1,365 @@
+import {test, expect, Page} from '@playwright/test';
+
+// Helper: navigate to UMD test page and wait for config to load
+async function setupPage(page: Page) {
+ await page.goto('/umd.html');
+ await page.waitForFunction(
+ () => (window as any).__CONFIG_LOADED__ === true,
+ {timeout: 5000}
+ );
+}
+
+// Helper: inject SDK factory and DataStore onto window so page.evaluate()
+// callbacks can call window.__createSdk() / window.__createContext() without
+// repeating the boilerplate every time.
+async function injectHelpers(page: Page) {
+ await page.evaluate(() => {
+ const w = window as any;
+ w.__createSdk = (extraConfig?: Record) => {
+ const config = {...w.__TEST_CONFIG__};
+ config.events = {batch_size: 1, release_interval: 1000};
+ Object.assign(config, extraConfig || {});
+ return new w.ConvertSDK.default(config);
+ };
+ w.__createContext = (
+ visitorId?: string,
+ visitorProps?: Record,
+ extraConfig?: Record
+ ) => {
+ const sdk = w.__createSdk(extraConfig);
+ return sdk.createContext(visitorId, visitorProps);
+ };
+ // Shorthand: create context with default test visitor
+ w.__defaultContext = (extraConfig?: Record) =>
+ w.__createContext('XXX', {browser: 'chrome'}, extraConfig);
+ w.__makeDataStore = () => ({
+ data: {} as Record,
+ get(key: string) {
+ if (!key) return this.data;
+ return this.data[key.toString()];
+ },
+ set(key: string, value: any) {
+ if (!key) throw new Error('Invalid DataStore key!');
+ this.data[key.toString()] = value;
+ }
+ });
+ w.__createSegmentTestContext = () => {
+ const dataStore = w.__makeDataStore();
+ const sdk = w.__createSdk({dataStore});
+ const accountId = w.__TEST_CONFIG__.data.account_id;
+ const projectId = w.__TEST_CONFIG__.data.project.id;
+ const visitorId = 'XXX';
+ const storeKey = `${accountId}-${projectId}-${visitorId}`;
+ const context = sdk.createContext(visitorId, {browser: 'chrome'});
+ return {context, dataStore, storeKey};
+ };
+ });
+}
+
+// Combined setup: navigate + inject helpers
+async function setup(page: Page) {
+ await setupPage(page);
+ await injectHelpers(page);
+}
+
+// Qualifying attributes used across multiple tests
+const LOCATION_PROPS = {
+ locationProperties: {url: 'https://convert.com/'},
+ visitorProperties: {varName3: 'something'}
+};
+
+// Expected object keys for SDK return types (pre-sorted for assertion)
+const cmp = (a: string, b: string) => a.localeCompare(b);
+const VARIATION_KEYS = [
+ 'bucketingAllocation', 'changes', 'experienceId', 'experienceKey',
+ 'experienceName', 'id', 'is_baseline', 'key', 'name', 'status',
+ 'traffic_allocation'
+].sort(cmp);
+const FEATURE_KEYS = [
+ 'experienceId', 'experienceKey', 'experienceName', 'id', 'key',
+ 'name', 'status', 'variables'
+].sort(cmp);
+const FEATURE_DISABLED_KEYS = ['id', 'key', 'name', 'status'].sort(cmp);
+
+test.describe('UMD bundle browser tests', () => {
+ test.describe('Basic SDK instance', () => {
+ test('Should have ConvertSDK as a global', async ({page}) => {
+ await setupPage(page);
+ const hasSDK = await page.evaluate(
+ () => typeof (window as any).ConvertSDK !== 'undefined'
+ );
+ expect(hasSDK).toBe(true);
+ });
+
+ test('Should have a constructor', async ({page}) => {
+ await setupPage(page);
+ const isFunction = await page.evaluate(
+ () => typeof (window as any).ConvertSDK.default === 'function'
+ );
+ expect(isFunction).toBe(true);
+ });
+
+ test('Should create a default SDK instance and fire ready event with no errors', async ({
+ page
+ }) => {
+ await setup(page);
+ const result = await page.evaluate(() => {
+ return new Promise((resolve) => {
+ const sdk = (window as any).__createSdk();
+ sdk.on('ready', (_args: any, err: any) => {
+ resolve({hasError: err !== null, isObject: typeof sdk === 'object'});
+ });
+ });
+ });
+ expect((result as any).hasError).toBe(false);
+ expect((result as any).isObject).toBe(true);
+ });
+
+ test('Should create a default SDK instance and resolve onReady promise', async ({
+ page
+ }) => {
+ await setup(page);
+ const resolved = await page.evaluate(async () => {
+ const sdk = (window as any).__createSdk();
+ await sdk.onReady();
+ return true;
+ });
+ expect(resolved).toBe(true);
+ });
+
+ test('Should successfully create visitor context', async ({page}) => {
+ await setup(page);
+ const result = await page.evaluate(() => {
+ const context = (window as any).__defaultContext();
+ const methods = [
+ 'runExperience',
+ 'runExperiences',
+ 'runFeature',
+ 'runFeatures',
+ 'trackConversion',
+ 'setDefaultSegments',
+ 'runCustomSegments'
+ ];
+ const hasMethods = methods.every(
+ (m) => typeof context[m] === 'function'
+ );
+ return {isObject: typeof context === 'object', hasMethods};
+ });
+ expect((result as any).isObject).toBe(true);
+ expect((result as any).hasMethods).toBe(true);
+ });
+ });
+
+ test.describe('Basic SDK methods', () => {
+ test('Should successfully get variation from specific experience', async ({
+ page
+ }) => {
+ await setup(page);
+ const result = await page.evaluate((props) => {
+ const context = (window as any).__defaultContext();
+ const variation = context.runExperience(
+ 'test-experience-ab-fullstack-2',
+ props
+ );
+ return {
+ isObject: typeof variation === 'object' && variation !== null,
+ experienceKey: variation?.experienceKey,
+ keys: variation ? Object.keys(variation).sort((a, b) => a.localeCompare(b)) : []
+ };
+ }, LOCATION_PROPS);
+ expect((result as any).isObject).toBe(true);
+ expect((result as any).experienceKey).toBe(
+ 'test-experience-ab-fullstack-2'
+ );
+ expect((result as any).keys).toEqual(VARIATION_KEYS);
+ });
+
+ test('Should successfully get variations across all experiences', async ({
+ page
+ }) => {
+ await setup(page);
+ const result = await page.evaluate((props) => {
+ const context = (window as any).__defaultContext();
+ const variations = context.runExperiences(props);
+ return {
+ isArray: Array.isArray(variations),
+ length: variations?.length,
+ ids: variations?.map((v: any) => v.id)
+ };
+ }, LOCATION_PROPS);
+ expect((result as any).isArray).toBe(true);
+ expect((result as any).length).toBe(2);
+ const validIds = ['100299456', '100299457', '100299460', '100299461'];
+ for (const id of (result as any).ids) {
+ expect(validIds).toContain(id);
+ }
+ });
+
+ test('Should successfully get a single feature and its status', async ({
+ page
+ }) => {
+ await setup(page);
+ const result = await page.evaluate((props) => {
+ const context = (window as any).__defaultContext();
+ const feature = context.runFeature('feature-2', props);
+ return {
+ isObject: typeof feature === 'object' && feature !== null,
+ id: feature?.id,
+ keys: feature ? Object.keys(feature).sort((a, b) => a.localeCompare(b)) : []
+ };
+ }, LOCATION_PROPS);
+ expect((result as any).isObject).toBe(true);
+ expect((result as any).id).toBe('10025');
+ expect((result as any).keys).toEqual(FEATURE_KEYS);
+ });
+
+ test('Should successfully get multiple features and their status', async ({
+ page
+ }) => {
+ await setup(page);
+ const result = await page.evaluate((props) => {
+ const context = (window as any).__defaultContext();
+ const features = context.runFeature('feature-1', props);
+ return {
+ isArray: Array.isArray(features),
+ length: features?.length,
+ ids: features?.map((f: any) => f.id)
+ };
+ }, LOCATION_PROPS);
+ expect((result as any).isArray).toBe(true);
+ expect((result as any).length).toBe(2);
+ const validIds = ['10024', '10025'];
+ for (const id of (result as any).ids) {
+ expect(validIds).toContain(id);
+ }
+ });
+
+ test('Should successfully get features and their statuses', async ({
+ page
+ }) => {
+ await setup(page);
+ const result = await page.evaluate((props) => {
+ const context = (window as any).__defaultContext();
+ const features = context.runFeatures(props);
+ return {
+ isArray: Array.isArray(features),
+ length: features?.length,
+ ids: features?.map((f: any) => f.id),
+ enabledKeys: features
+ ?.filter((f: any) => f.status === 'enabled')
+ .map((f: any) => Object.keys(f).sort((a, b) => a.localeCompare(b))),
+ disabledKeys: features
+ ?.filter((f: any) => f.status === 'disabled')
+ .map((f: any) => Object.keys(f).sort((a, b) => a.localeCompare(b)))
+ };
+ }, LOCATION_PROPS);
+ expect((result as any).isArray).toBe(true);
+ expect((result as any).length).toBe(4);
+ const validIds = ['10024', '10025', '10026'];
+ for (const id of (result as any).ids.filter(
+ (id: string) => id !== undefined
+ )) {
+ expect(validIds).toContain(id);
+ }
+ for (const keys of (result as any).enabledKeys) {
+ expect(keys).toEqual(FEATURE_KEYS);
+ }
+ for (const keys of (result as any).disabledKeys) {
+ expect(keys).toEqual(FEATURE_DISABLED_KEYS);
+ }
+ });
+
+ test('Should trigger Conversion', async ({page}) => {
+ await setup(page);
+ const result = await page.evaluate((props) => {
+ const context = (window as any).__defaultContext();
+ context.runExperience('test-experience-ab-fullstack-2', props);
+ const response = context.trackConversion('increase-engagement', {
+ ruleData: {action: 'buy'},
+ conversionData: [
+ {key: 'amount', value: 10.3},
+ {key: 'productsCount', value: 2}
+ ]
+ });
+ return {isUndefined: response === undefined};
+ }, LOCATION_PROPS);
+ expect((result as any).isUndefined).toBe(true);
+ });
+
+ test('Should successfully set default segments', async ({page}) => {
+ await setup(page);
+ const result = await page.evaluate(() => {
+ return new Promise((resolve) => {
+ const {context, dataStore, storeKey} = (
+ window as any
+ ).__createSegmentTestContext();
+ context.setDefaultSegments({country: 'US'});
+
+ setTimeout(() => {
+ const localSegments = dataStore.get(storeKey);
+ resolve({
+ hasSegments: localSegments?.segments !== undefined,
+ segments: localSegments?.segments
+ });
+ }, 1100);
+ });
+ });
+ expect((result as any).hasSegments).toBe(true);
+ expect((result as any).segments).toEqual({
+ country: 'US',
+ browser: 'chrome'
+ });
+ });
+
+ test('Should successfully set custom segments', async ({page}) => {
+ await setup(page);
+ const result = await page.evaluate(() => {
+ return new Promise((resolve) => {
+ const {context, dataStore, storeKey} = (
+ window as any
+ ).__createSegmentTestContext();
+ context.runCustomSegments('test-segments-1', {
+ ruleData: {enabled: true}
+ });
+
+ setTimeout(() => {
+ const data = dataStore.get(storeKey);
+ resolve({
+ hasCustomSegments:
+ data?.segments?.customSegments !== undefined,
+ customSegments: data?.segments?.customSegments
+ });
+ }, 1100);
+ });
+ });
+ expect((result as any).hasCustomSegments).toBe(true);
+ expect((result as any).customSegments).toEqual(['200299434']);
+ });
+ });
+
+ test.describe('Test invalid visitor', () => {
+ // All SDK methods should return undefined when no visitor ID is set
+ const invalidVisitorCases = [
+ {method: 'runExperience', args: ['test-experience-ab-fullstack-2']},
+ {method: 'runExperiences', args: []},
+ {method: 'runFeature', args: ['feature-1']},
+ {method: 'runFeatures', args: []},
+ {method: 'trackConversion', args: ['increase-engagement']},
+ {method: 'runCustomSegments', args: ['test-segments-1']}
+ ];
+
+ for (const {method, args} of invalidVisitorCases) {
+ test(`Should fail ${method} if no visitor is set`, async ({page}) => {
+ await setup(page);
+ const result = await page.evaluate(
+ ({m, a}) => {
+ const context = (window as any).__createContext();
+ return context[m](...a);
+ },
+ {m: method, a: args}
+ );
+ expect(result).toBeUndefined();
+ });
+ }
+ });
+});
diff --git a/packages/js-sdk/tests/integration/full-chain.spec.ts b/packages/js-sdk/tests/integration/full-chain.spec.ts
new file mode 100644
index 00000000..5aba02e1
--- /dev/null
+++ b/packages/js-sdk/tests/integration/full-chain.spec.ts
@@ -0,0 +1,462 @@
+import {test, expect} from '@playwright/test';
+
+// Import from the built CJS bundle — same as consumers would use
+// eslint-disable-next-line @typescript-eslint/no-var-requires
+const SDK = require('../../lib/index');
+const ConvertSDK = SDK.default;
+const {SystemEvents} = SDK;
+
+// Static config for offline/always-run mode
+// eslint-disable-next-line @typescript-eslint/no-var-requires
+const staticConfig = require('./static-config.json');
+
+// --- Constants matching staging project "FS-Test-Proj - DO NOT DELETE" ---
+const EXPERIENCE_KEY = 'test-experience-ab-fullstack-4';
+const FEATURE_TYPED_KEY = 'feature-2';
+const FEATURE_BASIC_KEY = 'feature-1';
+const GOAL_KEY = 'increase-engagement';
+const VARIATION_IDS = ['1003180877', '1003180878'];
+
+// Experience -1 has audience "adv-audience" requiring (desktop=true AND browser!="CH") OR (mobile=true)
+const EXPERIENCE_WITH_AUDIENCE_KEY = 'test-experience-ab-fullstack-1';
+
+// Location "pricing-location" requires location=pricing
+const qualifyingAttributes = {
+ locationProperties: {location: 'pricing'}
+};
+
+// --- In-memory DataStore for dedup tests ---
+class MemoryDataStore {
+ private data: Record = {};
+ get(key: string): any {
+ if (!key) return this.data;
+ return this.data[key.toString()];
+ }
+ set(key: string, value: any): void {
+ if (!key) throw new Error('Invalid DataStore key!');
+ this.data[key.toString()] = value;
+ }
+}
+
+// --- Dual-mode: static always runs, live only when env var is set ---
+const modes: Array<'static' | 'live' | 'live-secret'> = [
+ 'static',
+ ...(process.env.CONVERT_STAGING_SDK_KEY ? (['live'] as const) : []),
+ ...(process.env.CONVERT_STAGING_SDK_KEY2 &&
+ process.env.CONVERT_STAGING_SDK_KEY2_SECRET
+ ? (['live-secret'] as const)
+ : [])
+];
+
+function createSdk(
+ mode: 'static' | 'live' | 'live-secret',
+ overrides: Record = {}
+) {
+ if (mode === 'live') {
+ return new ConvertSDK({
+ sdkKey: process.env.CONVERT_STAGING_SDK_KEY,
+ environment: 'staging',
+ network: {tracking: false},
+ ...overrides
+ });
+ }
+ if (mode === 'live-secret') {
+ return new ConvertSDK({
+ sdkKey: process.env.CONVERT_STAGING_SDK_KEY2,
+ sdkKeySecret: process.env.CONVERT_STAGING_SDK_KEY2_SECRET,
+ environment: 'staging',
+ network: {tracking: false},
+ ...overrides
+ });
+ }
+ return new ConvertSDK({
+ data: staticConfig,
+ environment: 'staging',
+ network: {tracking: false},
+ ...overrides
+ });
+}
+
+// Helper: create SDK, wait for ready, and create a visitor context
+async function createReadyContext(
+ mode: 'static' | 'live' | 'live-secret',
+ visitorId: string,
+ overrides: Record = {}
+) {
+ const sdk = createSdk(mode, overrides);
+ await sdk.onReady();
+ const context = sdk.createContext(visitorId);
+ return {sdk, context};
+}
+
+// Helper: create SDK with tracking enabled and a fresh MemoryDataStore
+function createTrackingSdk(
+ mode: 'static' | 'live' | 'live-secret',
+ overrides: Record = {}
+) {
+ const dataStore = new MemoryDataStore();
+ const sdk = createSdk(mode, {
+ network: {tracking: true},
+ dataStore,
+ ...overrides
+ });
+ return {sdk, dataStore};
+}
+
+// Helper: create tracking SDK, wait for ready, create context, and bucket into experience
+async function createBucketedTrackingContext(
+ mode: 'static' | 'live' | 'live-secret',
+ visitorId: string,
+ overrides: Record = {}
+) {
+ const {sdk, dataStore} = createTrackingSdk(mode, overrides);
+ await sdk.onReady();
+ const context = sdk.createContext(visitorId);
+ context.runExperience(EXPERIENCE_KEY, qualifyingAttributes);
+ return {sdk, dataStore, context};
+}
+
+// Helper: bucket into experience + run feature, return both with assertions
+function bucketAndVerifyFeature(context: any) {
+ const variation = context.runExperience(EXPERIENCE_KEY, qualifyingAttributes);
+ expect(variation).toBeDefined();
+ expect(VARIATION_IDS).toContain(variation.id);
+
+ const feature = context.runFeature(FEATURE_TYPED_KEY, qualifyingAttributes);
+ expect(feature).toBeDefined();
+ expect(feature.status).toBe('enabled');
+ expect(feature.variables.price).toBe(100);
+
+ return {variation, feature};
+}
+
+for (const mode of modes) {
+ test.describe(`Full-chain integration tests [${mode} mode]`, () => {
+ // --- Happy Path ---
+
+ test('SDK initializes and is ready', async () => {
+ const sdk = createSdk(mode);
+ await sdk.onReady();
+ });
+
+ test('Ready event fired on init', async () => {
+ const sdk = createSdk(mode);
+ let readyCalled = 0;
+ let readyError: any = null;
+ sdk.on(SystemEvents.READY, (_args: any, err: any) => {
+ readyCalled++;
+ readyError = err;
+ });
+ await sdk.onReady();
+ expect(readyCalled).toBe(1);
+ expect(readyError).toBeNull();
+ });
+
+ test('Create context and run experience', async () => {
+ const {context} = await createReadyContext(
+ mode,
+ 'visitor-integration-test'
+ );
+ const variation = context.runExperience(
+ EXPERIENCE_KEY,
+ qualifyingAttributes
+ );
+ expect(variation).toBeDefined();
+ expect(variation).not.toBeNull();
+ expect(variation.experienceKey).toBe(EXPERIENCE_KEY);
+ expect(VARIATION_IDS).toContain(variation.id);
+ expect(variation.changes).toBeDefined();
+ expect(Array.isArray(variation.changes)).toBe(true);
+ expect(variation.changes.length).toBeGreaterThan(0);
+ });
+
+ test('Bucketing determinism', async () => {
+ const {context} = await createReadyContext(
+ mode,
+ 'visitor-determinism-test'
+ );
+
+ const results: string[] = [];
+ for (let i = 0; i < 10; i++) {
+ const variation = context.runExperience(
+ EXPERIENCE_KEY,
+ qualifyingAttributes
+ );
+ expect(variation).toBeDefined();
+ results.push(variation.id);
+ }
+
+ // All 10 runs should return the same variation
+ const uniqueIds = [...new Set(results)];
+ expect(uniqueIds).toHaveLength(1);
+ expect(VARIATION_IDS).toContain(uniqueIds[0]);
+ });
+
+ test('Bucketing event fired on experience', async () => {
+ const {sdk, context} = await createReadyContext(
+ mode,
+ 'visitor-bucketing-event-test'
+ );
+
+ let bucketingFired = false;
+ sdk.on(SystemEvents.BUCKETING, () => {
+ bucketingFired = true;
+ });
+ context.runExperience(EXPERIENCE_KEY, qualifyingAttributes);
+ expect(bucketingFired).toBe(true);
+ });
+
+ test('Run feature with typed variables', async () => {
+ const {context} = await createReadyContext(
+ mode,
+ 'visitor-feature-typed-test'
+ );
+
+ // Must run experience first to bucket into a variation
+ context.runExperience(EXPERIENCE_KEY, qualifyingAttributes);
+
+ const feature = context.runFeature(
+ FEATURE_TYPED_KEY,
+ qualifyingAttributes
+ );
+ expect(feature).toBeDefined();
+ expect(feature).not.toBeNull();
+ expect(feature.status).toBe('enabled');
+
+ // Verify typed variables
+ expect(feature.variables).toBeDefined();
+ expect(typeof feature.variables.price).toBe('number');
+ expect(feature.variables.price).toBe(100);
+ expect(typeof feature.variables['button-height']).toBe('number');
+ expect(feature.variables['button-height']).toBe(40);
+ expect(typeof feature.variables.additionalData).toBe('object');
+ expect(feature.variables.additionalData.foo).toBe('bar');
+ expect(feature.variables.additionalData.v).toBe(2);
+ });
+
+ test('Full chain: init -> context -> bucket -> feature -> verify', async () => {
+ const {sdk, context} = await createReadyContext(
+ mode,
+ 'visitor-full-chain-test'
+ );
+
+ let readyFired = false;
+ let bucketingFired = false;
+ sdk.on(SystemEvents.READY, () => {
+ readyFired = true;
+ });
+ sdk.on(SystemEvents.BUCKETING, () => {
+ bucketingFired = true;
+ });
+
+ expect(readyFired).toBe(true);
+ bucketAndVerifyFeature(context);
+ expect(bucketingFired).toBe(true);
+ });
+
+ // --- Negative Path ---
+
+ test('Run feature with unknown key returns disabled status', async () => {
+ const {context} = await createReadyContext(
+ mode,
+ 'visitor-negative-test'
+ );
+ const result = context.runFeature('nonexistent-feature');
+ // SDK returns a BucketedFeature with status 'disabled' for unknown features
+ expect(result).toBeDefined();
+ expect(result.key).toBe('nonexistent-feature');
+ expect(result.status).toBe('disabled');
+ });
+
+ test('Run experience with non-qualifying location returns null', async () => {
+ const {context} = await createReadyContext(
+ mode,
+ 'visitor-non-qualifying-test'
+ );
+ const result = context.runExperience(EXPERIENCE_KEY, {
+ locationProperties: {location: 'nonexistent'}
+ });
+ // SDK returns null when location doesn't match
+ expect(result).toBeNull();
+ });
+
+ test('Run experience with audience and no visitor properties returns null', async () => {
+ const {context} = await createReadyContext(
+ mode,
+ 'visitor-audience-test'
+ );
+ // exp-1 has audience requiring (desktop=true AND browser!="CH") OR (mobile=true)
+ // Passing only locationProperties and no visitorProperties should fail audience check
+ const result = context.runExperience(EXPERIENCE_WITH_AUDIENCE_KEY, {
+ locationProperties: {location: 'pricing'}
+ });
+ // SDK returns null when audience doesn't match
+ expect(result).toBeNull();
+ });
+
+ // --- Conversion Tracking ---
+
+ test('Track conversion', async () => {
+ const {context} = await createBucketedTrackingContext(
+ mode,
+ 'visitor-conversion-test'
+ );
+ // increase-engagement goal has rules: null, so no ruleData should be passed
+ const result = context.trackConversion(GOAL_KEY);
+ // trackConversion returns undefined on success
+ expect(result).toBeUndefined();
+ });
+
+ test('Conversion event fired on trackConversion', async () => {
+ const {sdk, context} = await createBucketedTrackingContext(
+ mode,
+ 'visitor-conversion-event-test'
+ );
+
+ let conversionFired = false;
+ let conversionData: any = null;
+ sdk.on(SystemEvents.CONVERSION, (data: any) => {
+ conversionFired = true;
+ conversionData = data;
+ });
+
+ // increase-engagement goal has rules: null, so no ruleData should be passed
+ context.trackConversion(GOAL_KEY);
+
+ expect(conversionFired).toBe(true);
+ expect(conversionData).toBeDefined();
+ });
+
+ test('Goal deduplication', async () => {
+ const {sdk, context} = await createBucketedTrackingContext(
+ mode,
+ 'visitor-dedup-test'
+ );
+
+ let conversionCount = 0;
+ sdk.on(SystemEvents.CONVERSION, () => {
+ conversionCount++;
+ });
+
+ // First call — increase-engagement has no rules, so no ruleData
+ context.trackConversion(GOAL_KEY);
+ // Second call — should be deduplicated
+ context.trackConversion(GOAL_KEY);
+
+ expect(conversionCount).toBe(1);
+ });
+
+ test('Track conversion with revenue', async () => {
+ const {sdk, context} = await createBucketedTrackingContext(
+ mode,
+ 'visitor-revenue-test'
+ );
+
+ let queueReleased = false;
+ sdk.on(SystemEvents.API_QUEUE_RELEASED, () => {
+ queueReleased = true;
+ });
+
+ // increase-engagement goal has rules: null, so no ruleData
+ const result = context.trackConversion(GOAL_KEY, {
+ conversionData: [
+ {key: 'amount', value: 49.99},
+ {key: 'transactionId', value: 'txn-integration-001'}
+ ]
+ });
+
+ expect(result).toBeUndefined();
+
+ // Wait for the queue to release
+ await new Promise((resolve) => {
+ if (queueReleased) {
+ resolve();
+ } else {
+ const checkInterval = setInterval(() => {
+ if (queueReleased) {
+ clearInterval(checkInterval);
+ resolve();
+ }
+ }, 100);
+ // Timeout after 5 seconds
+ setTimeout(() => {
+ clearInterval(checkInterval);
+ resolve();
+ }, 5000);
+ }
+ });
+ });
+
+ test('Force multiple transactions', async () => {
+ const {sdk, context} = await createBucketedTrackingContext(
+ mode,
+ 'visitor-force-multi-test'
+ );
+
+ let conversionCount = 0;
+ sdk.on(SystemEvents.CONVERSION, () => {
+ conversionCount++;
+ });
+
+ // First call — no ruleData since increase-engagement has rules: null
+ context.trackConversion(GOAL_KEY, {
+ conversionData: [{key: 'amount', value: 10}]
+ });
+
+ // Second call with forceMultipleTransactions — should NOT be deduplicated
+ context.trackConversion(GOAL_KEY, {
+ conversionData: [{key: 'amount', value: 20}],
+ conversionSetting: {forceMultipleTransactions: true}
+ });
+
+ expect(conversionCount).toBe(2);
+ });
+
+ test('Track conversion with nonexistent goal returns undefined', async () => {
+ const {sdk, context} = await createBucketedTrackingContext(
+ mode,
+ 'visitor-fake-goal-test'
+ );
+
+ let conversionFired = false;
+ sdk.on(SystemEvents.CONVERSION, () => {
+ conversionFired = true;
+ });
+
+ const result = context.trackConversion('totally-fake-goal');
+ expect(result).toBeUndefined();
+ expect(conversionFired).toBe(false);
+ });
+
+ // --- Complete Chain ---
+
+ test('Complete chain: init -> context -> bucket -> feature -> conversion -> flush', async () => {
+ const {sdk, context} = await createBucketedTrackingContext(
+ mode,
+ 'visitor-complete-chain-test',
+ {events: {batch_size: 1, release_interval: 1000}}
+ );
+
+ let conversionFired = false;
+ sdk.on(SystemEvents.CONVERSION, () => {
+ conversionFired = true;
+ });
+
+ // Verify feature (experience already bucketed by createBucketedTrackingContext)
+ const feature = context.runFeature(
+ FEATURE_TYPED_KEY,
+ qualifyingAttributes
+ );
+ expect(feature).toBeDefined();
+ expect(feature.status).toBe('enabled');
+ expect(feature.variables.price).toBe(100);
+
+ // Track conversion — no ruleData since increase-engagement has rules: null
+ const conversionResult = context.trackConversion(GOAL_KEY, {
+ conversionData: [{key: 'amount', value: 99.99}]
+ });
+ expect(conversionResult).toBeUndefined();
+ expect(conversionFired).toBe(true);
+ });
+ });
+}
diff --git a/packages/js-sdk/tests/integration/static-config.json b/packages/js-sdk/tests/integration/static-config.json
new file mode 100644
index 00000000..f324b7bb
--- /dev/null
+++ b/packages/js-sdk/tests/integration/static-config.json
@@ -0,0 +1 @@
+{"account_id":"10035569","project":{"id":"10034190","name":"FS-Test-Proj - DO NOT DELETE","type":"fullstack","utc_offset":"0","domains":[],"global_javascript":"","settings":{"include_jquery":false,"include_jquery_v1":false,"disable_spa_functionality":false,"do_not_track_referral":false,"allow_crossdomain_tracking":false,"data_anonymization":false,"do_not_track":"OFF","global_privacy_control":"OFF","min_order_value":0,"max_order_value":99999,"version":"2026-03-22T07:46:28+00:00-259","tracking_script":null,"outliers":{"order_value":{"detection_type":"none"},"products_ordered_count":{"detection_type":"none"}},"placeholders":[],"global_javascript_placeholders":[],"integrations":{"google_analytics":{"enabled":false},"kissmetrics":{"enabled":false},"visitor_insights":{"tracking_id":null}}},"custom_domain":null},"experiences":[{"id":"100334665","name":"Test Experience AB Fullstack","type":"a\/b_fullstack","status":"active","global_js":"","global_css":"","environment":"staging","settings":{"min_order_value":0,"max_order_value":99999,"matching_options":{"audiences":"any","locations":"any"},"placeholders":[],"outliers":{"order_value":{"detection_type":"none"},"products_ordered_count":{"detection_type":"none"}}},"key":"test-experience-ab-fullstack-1","version":8,"locations":["1003350","1003351","1003352","10036409"],"site_area":null,"audiences":["10033684"],"goals":["100322784"],"integrations":[],"environments":["staging"],"variations":[{"id":"1003142550","name":"Original Page","key":"1003142550-original-page","status":"running","changes":[{"id":1003122037,"type":"fullStackFeature","data":{"feature_id":10031,"variables_data":{"enabled":false,"caption":"Click this"}}}],"traffic_allocation":50.0},{"id":"1003142551","name":"Variation 1","key":"1003142551-variation-1","status":"running","changes":[{"id":1003122038,"type":"fullStackFeature","data":{"feature_id":10031,"variables_data":{"enabled":false,"caption":"Click that"}}}],"traffic_allocation":50.0}]},{"id":"100349071","name":"Test Experience AB Fullstack 4","type":"a\/b_fullstack","status":"active","global_js":"","global_css":"","environment":"staging","settings":{"min_order_value":0,"max_order_value":99999,"matching_options":{"audiences":"any","locations":"any"},"placeholders":[],"outliers":{"order_value":{"detection_type":"none"},"products_ordered_count":{"detection_type":"none"}}},"key":"test-experience-ab-fullstack-4","version":11,"locations":["1003352"],"site_area":null,"audiences":[],"goals":["100322782","100322783"],"integrations":[],"environments":["staging"],"variations":[{"id":"1003180877","name":"Original","key":"original","status":"running","changes":[{"id":1003183443,"type":"fullStackFeature","data":{"feature_id":100334,"variables_data":{"price":100,"button-height":40,"additionalData":{"foo":"bar","v":2}}}},{"id":1003183444,"type":"fullStackFeature","data":{"feature_id":10031,"variables_data":{"enabled":false,"caption":"Click that"}}}],"traffic_allocation":50.0},{"id":"1003180878","name":"Variation 1","key":"variation-1","status":"running","changes":[{"id":1003183445,"type":"fullStackFeature","data":{"feature_id":100334,"variables_data":{"price":100,"button-height":40,"additionalData":{"foo":"bar","v":2}}}},{"id":1003183446,"type":"fullStackFeature","data":{"feature_id":10031,"variables_data":{"enabled":false,"caption":"Not allowed"}}}],"traffic_allocation":50.0}]}],"audiences":[{"id":"10033684","name":"Adv Audience","key":"adv-audience","rules":{"OR":[{"AND":[{"OR_WHEN":[{"rule_type":"generic_bool_key_value","matching":{"match_type":"equals","negated":false},"value":true,"key":"desktop"}]},{"OR_WHEN":[{"rule_type":"generic_text_key_value","matching":{"match_type":"matches","negated":true},"value":"CH","key":"browser"}]}]},{"AND":[{"OR_WHEN":[{"rule_type":"generic_bool_key_value","matching":{"match_type":"equals","negated":false},"value":true,"key":"mobile"}]}]}]},"type":"permanent"}],"segments":[{"id":"10033690","name":"Test Segments","key":"test-segment-1","rules":{"OR":[{"AND":[{"OR_WHEN":[{"rule_type":"generic_bool_key_value","matching":{"match_type":"equals","negated":false},"value":true,"key":"enabled"}]}]}]}}],"goals":[{"id":"100322782","name":"Decrease BounceRate","key":"decrease-bounce-rate","type":"advanced","rules":{"OR":[{"AND":[{"OR_WHEN":[{"rule_type":"pages_visited_count","matching":{"match_type":"lessEqual","negated":true},"value":1,"key":"d"},{"rule_type":"visit_duration","matching":{"match_type":"lessEqual","negated":true},"value":10}]}]}]}},{"id":"100322783","name":"Increase Engagement","key":"increase-engagement","type":"dom_interaction","rules":null,"settings":{"tracked_items":[{"event":"click","selector":"a"},{"event":"submit","selector":"form"}]}},{"id":"100322784","name":"primary button click","key":"button-primary-click","type":"revenue","rules":{"OR":[{"AND":[{"OR_WHEN":[{"rule_type":"generic_text_key_value","matching":{"match_type":"matches","negated":false},"value":"buy","key":"action"}]}]}]},"settings":{"triggering_type":"manual"}}],"locations":[{"id":"1003350","key":"events-location","name":"Events Location","rules":{"OR":[{"AND":[{"OR_WHEN":[{"rule_type":"generic_text_key_value","matching":{"match_type":"matches","negated":false},"value":"events","key":"location"}]}]}]},"trigger":{"type":"upon_run"}},{"id":"1003351","key":"statistics-location","name":"Statistics Location","rules":{"OR":[{"AND":[{"OR_WHEN":[{"rule_type":"generic_text_key_value","matching":{"match_type":"matches","negated":false},"value":"statistics","key":"location"}]}]}]},"trigger":{"type":"upon_run"}},{"id":"1003352","key":"pricing-location","name":"Pricing Location","rules":{"OR":[{"AND":[{"OR_WHEN":[{"rule_type":"generic_text_key_value","matching":{"match_type":"matches","negated":false},"value":"pricing","key":"location"}]}]}]},"trigger":{"type":"upon_run"}},{"id":"10036407","key":"homescreen","name":"HomeScreen","rules":{"OR":[{"AND":[{"OR_WHEN":[{"rule_type":"generic_text_key_value","matching":{"match_type":"matches","negated":false},"value":"home","key":"screen"},{"rule_type":"generic_text_key_value","matching":{"match_type":"matches","negated":false},"value":"https:\/\/test.com","key":"feature"}]}]}]},"trigger":{"type":"upon_run"}},{"id":"10036409","key":"dasadsa","name":"dasadsa","rules":{"OR":[{"AND":[{"OR_WHEN":[{"rule_type":"generic_numeric_key_value","matching":{"match_type":"less","negated":false},"value":5,"key":"feature"}]}]}]},"trigger":{"type":"upon_run"}}],"archived_experiences":["100334668","100344096"],"features":[{"id":"10031","name":"Feature 1","key":"feature-1","variables":[{"key":"enabled","type":"boolean"},{"key":"caption","type":"string"}]},{"id":"10032","name":"Feature 4","key":"feature-4","variables":[{"key":"statistics","type":"json"}]},{"id":"10033","name":"Feature 5","key":"feature-5","variables":[{"key":"plans","type":"json"}]},{"id":"100320","name":"Button","key":"button","variables":[{"key":"Border","type":"boolean"},{"key":"Color","type":"string"}]},{"id":"100334","name":"Feature 2","key":"feature-2","variables":[{"key":"price","type":"float"},{"key":"button-height","type":"integer"},{"key":"additionalData","type":"json"}]},{"id":"100335","name":"Not Attached Feature 3","key":"not-attached-feature-3","variables":[{"key":"fee","type":"float"},{"key":"link","type":"string"},{"key":"additionalData","type":"json"}]}],"_s_t":"2026-03-22 08:01:02Z","is_debug":false}
\ No newline at end of file
diff --git a/yarn.lock b/yarn.lock
index 90f8c33b..75c116f6 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3723,6 +3723,7 @@ __metadata:
"@babel/preset-env": "npm:^7.28.5"
"@eslint/eslintrc": "npm:^3.3.1"
"@jsdoc/salty": "npm:^0.2.9"
+ "@playwright/test": "npm:^1.52.0"
"@rollup/plugin-babel": "npm:^6.1.0"
"@rollup/plugin-commonjs": "npm:^29.0.0"
"@rollup/plugin-json": "npm:^6.1.0"
@@ -3748,20 +3749,9 @@ __metadata:
eslint-plugin-prettier: "npm:^5.5.4"
glob: "npm:^11.0.3"
istanbul-cobertura-badger: "npm:^1.3.1"
- karma: "npm:^6.4.4"
- karma-browserify: "npm:^8.1.0"
- karma-browserstack-launcher: "npm:^1.6.0"
- karma-chai: "npm:^0.1.0"
- karma-chrome-launcher: "npm:^3.2.0"
- karma-cli: "npm:^2.0.0"
- karma-mocha: "npm:^2.0.1"
- karma-mocha-reporter: "npm:^2.2.5"
- karma-sourcemap-loader: "npm:^0.4.0"
- karma-webpack: "npm:5.0.1"
mocha: "npm:^11.7.5"
nyc: "npm:^17.1.0"
prettier: "npm:^3.6.2"
- puppeteer: "npm:^24.29.1"
rollup: "npm:^4.53.2"
rollup-plugin-copy: "npm:^3.5.0"
rollup-plugin-generate-package-json: "npm:^3.2.0"
@@ -6470,6 +6460,17 @@ __metadata:
languageName: node
linkType: hard
+"@playwright/test@npm:^1.52.0":
+ version: 1.59.1
+ resolution: "@playwright/test@npm:1.59.1"
+ dependencies:
+ playwright: "npm:1.59.1"
+ bin:
+ playwright: cli.js
+ checksum: 10c0/8c2d94a860d3c254a0b114df2f888ad0a0e9310f45b6059bd5d4da196d965cadf6922267cef0881cfa9784d4bef6d78363d2c2d94caa64be67ff644c41162137
+ languageName: node
+ linkType: hard
+
"@pmmmwh/react-refresh-webpack-plugin@npm:^0.5.3":
version: 0.5.15
resolution: "@pmmmwh/react-refresh-webpack-plugin@npm:0.5.15"
@@ -6507,23 +6508,6 @@ __metadata:
languageName: node
linkType: hard
-"@puppeteer/browsers@npm:2.10.13":
- version: 2.10.13
- resolution: "@puppeteer/browsers@npm:2.10.13"
- dependencies:
- debug: "npm:^4.4.3"
- extract-zip: "npm:^2.0.1"
- progress: "npm:^2.0.3"
- proxy-agent: "npm:^6.5.0"
- semver: "npm:^7.7.3"
- tar-fs: "npm:^3.1.1"
- yargs: "npm:^17.7.2"
- bin:
- browsers: lib/cjs/main-cli.js
- checksum: 10c0/b6e003649f5d5231fa8c3aab32423cd3f89cdacc4cf8cf7e0e85b40c53858b09be4c5daf32bbbe481bdaaee1bc28bc78bfb5c19495de8d8736c9728ec25b7a02
- languageName: node
- linkType: hard
-
"@remix-run/css-bundle@npm:^2.17.2":
version: 2.17.2
resolution: "@remix-run/css-bundle@npm:2.17.2"
@@ -7154,13 +7138,6 @@ __metadata:
languageName: node
linkType: hard
-"@socket.io/component-emitter@npm:~3.1.0":
- version: 3.1.2
- resolution: "@socket.io/component-emitter@npm:3.1.2"
- checksum: 10c0/c4242bad66f67e6f7b712733d25b43cbb9e19a595c8701c3ad99cbeb5901555f78b095e24852f862fffb43e96f1d8552e62def885ca82ae1bb05da3668fd87d7
- languageName: node
- linkType: hard
-
"@surma/rollup-plugin-off-main-thread@npm:^2.2.3":
version: 2.2.3
resolution: "@surma/rollup-plugin-off-main-thread@npm:2.2.3"
@@ -7502,13 +7479,6 @@ __metadata:
languageName: node
linkType: hard
-"@tootallnate/quickjs-emscripten@npm:^0.23.0":
- version: 0.23.0
- resolution: "@tootallnate/quickjs-emscripten@npm:0.23.0"
- checksum: 10c0/2a939b781826fb5fd3edd0f2ec3b321d259d760464cf20611c9877205aaca3ccc0b7304dea68416baa0d568e82cd86b17d29548d1e5139fa3155a4a86a2b4b49
- languageName: node
- linkType: hard
-
"@trysound/sax@npm:0.2.0":
version: 0.2.0
resolution: "@trysound/sax@npm:0.2.0"
@@ -7692,13 +7662,6 @@ __metadata:
languageName: node
linkType: hard
-"@types/cookie@npm:^0.4.1":
- version: 0.4.1
- resolution: "@types/cookie@npm:0.4.1"
- checksum: 10c0/f96afe12bd51be1ec61410b0641243d93fa3a494702407c787a4c872b5c8bcd39b224471452055e44a9ce42af1a636e87d161994226eaf4c2be9c30f60418409
- languageName: node
- linkType: hard
-
"@types/cookie@npm:^0.6.0":
version: 0.6.0
resolution: "@types/cookie@npm:0.6.0"
@@ -7713,15 +7676,6 @@ __metadata:
languageName: node
linkType: hard
-"@types/cors@npm:^2.8.12":
- version: 2.8.17
- resolution: "@types/cors@npm:2.8.17"
- dependencies:
- "@types/node": "npm:*"
- checksum: 10c0/457364c28c89f3d9ed34800e1de5c6eaaf344d1bb39af122f013322a50bc606eb2aa6f63de4e41a7a08ba7ef454473926c94a830636723da45bf786df032696d
- languageName: node
- linkType: hard
-
"@types/debug@npm:^4.0.0":
version: 4.1.12
resolution: "@types/debug@npm:4.1.12"
@@ -8044,7 +7998,7 @@ __metadata:
languageName: node
linkType: hard
-"@types/node@npm:*, @types/node@npm:>=10.0.0":
+"@types/node@npm:*":
version: 20.14.5
resolution: "@types/node@npm:20.14.5"
dependencies:
@@ -8305,15 +8259,6 @@ __metadata:
languageName: node
linkType: hard
-"@types/yauzl@npm:^2.9.1":
- version: 2.10.3
- resolution: "@types/yauzl@npm:2.10.3"
- dependencies:
- "@types/node": "npm:*"
- checksum: 10c0/f1b7c1b99fef9f2fe7f1985ef7426d0cebe48cd031f1780fcdc7451eec7e31ac97028f16f50121a59bcf53086a1fc8c856fd5b7d3e00970e43d92ae27d6b43dc
- languageName: node
- linkType: hard
-
"@typescript-eslint/eslint-plugin@npm:8.46.4, @typescript-eslint/eslint-plugin@npm:^8.46.4":
version: 8.46.4
resolution: "@typescript-eslint/eslint-plugin@npm:8.46.4"
@@ -9332,7 +9277,7 @@ __metadata:
languageName: node
linkType: hard
-"agent-base@npm:6, agent-base@npm:^6.0.2":
+"agent-base@npm:6":
version: 6.0.2
resolution: "agent-base@npm:6.0.2"
dependencies:
@@ -9341,15 +9286,6 @@ __metadata:
languageName: node
linkType: hard
-"agent-base@npm:^4.3.0":
- version: 4.3.0
- resolution: "agent-base@npm:4.3.0"
- dependencies:
- es6-promisify: "npm:^5.0.0"
- checksum: 10c0/a618d4e4ca7c0c2023b2664346570773455c501a930718764f65016a8a9eea6d2ab5ba54255589e46de529bab4026a088523dce17f94e34ba385af1f644febe1
- languageName: node
- linkType: hard
-
"agent-base@npm:^7.0.2, agent-base@npm:^7.1.0, agent-base@npm:^7.1.1":
version: 7.1.1
resolution: "agent-base@npm:7.1.1"
@@ -9359,13 +9295,6 @@ __metadata:
languageName: node
linkType: hard
-"agent-base@npm:^7.1.2":
- version: 7.1.4
- resolution: "agent-base@npm:7.1.4"
- checksum: 10c0/c2c9ab7599692d594b6a161559ada307b7a624fa4c7b03e3afdb5a5e31cd0e53269115b620fcab024c5ac6a6f37fa5eb2e004f076ad30f5f7e6b8b671f7b35fe
- languageName: node
- linkType: hard
-
"aggregate-error@npm:^3.0.0":
version: 3.1.0
resolution: "aggregate-error@npm:3.1.0"
@@ -9526,13 +9455,6 @@ __metadata:
languageName: node
linkType: hard
-"ansi-regex@npm:^3.0.0":
- version: 3.0.1
- resolution: "ansi-regex@npm:3.0.1"
- checksum: 10c0/d108a7498b8568caf4a46eea4f1784ab4e0dfb2e3f3938c697dee21443d622d765c958f2b7e2b9f6b9e55e2e2af0584eaa9915d51782b89a841c28e744e7a167
- languageName: node
- linkType: hard
-
"ansi-regex@npm:^5.0.1":
version: 5.0.1
resolution: "ansi-regex@npm:5.0.1"
@@ -9988,15 +9910,6 @@ __metadata:
languageName: node
linkType: hard
-"ast-types@npm:^0.13.4":
- version: 0.13.4
- resolution: "ast-types@npm:0.13.4"
- dependencies:
- tslib: "npm:^2.0.1"
- checksum: 10c0/3a1a409764faa1471601a0ad01b3aa699292991aa9c8a30c7717002cabdf5d98008e7b53ae61f6e058f757fc6ba965e147967a93c13e62692c907d79cfb245f8
- languageName: node
- linkType: hard
-
"ast-types@npm:^0.14.2":
version: 0.14.2
resolution: "ast-types@npm:0.14.2"
@@ -10107,13 +10020,6 @@ __metadata:
languageName: node
linkType: hard
-"b4a@npm:^1.6.4":
- version: 1.6.6
- resolution: "b4a@npm:1.6.6"
- checksum: 10c0/56f30277666cb511a15829e38d369b114df7dc8cec4cedc09cc5d685bc0f27cb63c7bcfb58e09a19a1b3c4f2541069ab078b5328542e85d74a39620327709a38
- languageName: node
- linkType: hard
-
"babel-jest@npm:30.2.0":
version: 30.2.0
resolution: "babel-jest@npm:30.2.0"
@@ -10459,85 +10365,6 @@ __metadata:
languageName: node
linkType: hard
-"bare-events@npm:^2.2.0":
- version: 2.4.2
- resolution: "bare-events@npm:2.4.2"
- checksum: 10c0/09fa923061f31f815e83504e2ed4a8ba87732a01db40a7fae703dbb7eef7f05d99264b5e186074cbe9698213990d1af564c62cca07a5ff88baea8099ad9a6303
- languageName: node
- linkType: hard
-
-"bare-events@npm:^2.5.4, bare-events@npm:^2.7.0":
- version: 2.8.2
- resolution: "bare-events@npm:2.8.2"
- peerDependencies:
- bare-abort-controller: "*"
- peerDependenciesMeta:
- bare-abort-controller:
- optional: true
- checksum: 10c0/53fef240cf2cdcca62f78b6eead90ddb5a59b0929f414b13a63764c2b4f9de98ea8a578d033b04d64bb7b86dfbc402e937984e69950855cc3754c7b63da7db21
- languageName: node
- linkType: hard
-
-"bare-fs@npm:^4.0.1":
- version: 4.5.1
- resolution: "bare-fs@npm:4.5.1"
- dependencies:
- bare-events: "npm:^2.5.4"
- bare-path: "npm:^3.0.0"
- bare-stream: "npm:^2.6.4"
- bare-url: "npm:^2.2.2"
- fast-fifo: "npm:^1.3.2"
- peerDependencies:
- bare-buffer: "*"
- peerDependenciesMeta:
- bare-buffer:
- optional: true
- checksum: 10c0/ea977c101802dd1fcde80e8847443e584a9f1a8b13f688ddc2658a989cca6762c789db66b0de035a2fcc3d7497dbcb361986383bbbdcf73bb6bb8df1342eef01
- languageName: node
- linkType: hard
-
-"bare-os@npm:^3.0.1":
- version: 3.6.2
- resolution: "bare-os@npm:3.6.2"
- checksum: 10c0/7d917bc202b7efbb6b78658403fac04ae4e91db98d38cbd24037f896a2b1b4f4571d8cd408d12bed6a4c406d6abaf8d03836eacbcc4c75a0b6974e268574fc5a
- languageName: node
- linkType: hard
-
-"bare-path@npm:^3.0.0":
- version: 3.0.0
- resolution: "bare-path@npm:3.0.0"
- dependencies:
- bare-os: "npm:^3.0.1"
- checksum: 10c0/56a3ca82a9f808f4976cb1188640ac206546ce0ddff582afafc7bd2a6a5b31c3bd16422653aec656eeada2830cfbaa433c6cbf6d6b4d9eba033d5e06d60d9a68
- languageName: node
- linkType: hard
-
-"bare-stream@npm:^2.6.4":
- version: 2.7.0
- resolution: "bare-stream@npm:2.7.0"
- dependencies:
- streamx: "npm:^2.21.0"
- peerDependencies:
- bare-buffer: "*"
- bare-events: "*"
- peerDependenciesMeta:
- bare-buffer:
- optional: true
- bare-events:
- optional: true
- checksum: 10c0/3acd840b7b288dc066226c36446ff605fba2ecce98f1a0ce6aa611b81aabbcd204046a3209bce172373d17eaeaa5b7d35a85649c18ffcb9f2c783242854e99bd
- languageName: node
- linkType: hard
-
-"bare-url@npm:^2.2.2":
- version: 2.3.2
- resolution: "bare-url@npm:2.3.2"
- dependencies:
- bare-path: "npm:^3.0.0"
- checksum: 10c0/4fd0046314390a54404519d9db20e130ab3a341ef638d040f9603ae3fa0a1d84f6970357d21c8fc64e6163d1f61fd212cb1cfa4cb537dfead99fb06e3c030b15
- languageName: node
- linkType: hard
-
"base64-js@npm:^1.0.2, base64-js@npm:^1.3.1":
version: 1.5.1
resolution: "base64-js@npm:1.5.1"
@@ -10545,13 +10372,6 @@ __metadata:
languageName: node
linkType: hard
-"base64id@npm:2.0.0, base64id@npm:~2.0.0":
- version: 2.0.0
- resolution: "base64id@npm:2.0.0"
- checksum: 10c0/6919efd237ed44b9988cbfc33eca6f173a10e810ce50292b271a1a421aac7748ef232a64d1e6032b08f19aae48dce6ee8f66c5ae2c9e5066c82b884861d4d453
- languageName: node
- linkType: hard
-
"baseline-browser-mapping@npm:^2.8.25":
version: 2.8.25
resolution: "baseline-browser-mapping@npm:2.8.25"
@@ -10570,13 +10390,6 @@ __metadata:
languageName: node
linkType: hard
-"basic-ftp@npm:^5.0.2":
- version: 5.0.5
- resolution: "basic-ftp@npm:5.0.5"
- checksum: 10c0/be983a3997749856da87b839ffce6b8ed6c7dbf91ea991d5c980d8add275f9f2926c19f80217ac3e7f353815be879371d636407ca72b038cea8cab30e53928a6
- languageName: node
- linkType: hard
-
"batch@npm:0.6.1":
version: 0.6.1
resolution: "batch@npm:0.6.1"
@@ -11035,28 +10848,6 @@ __metadata:
languageName: node
linkType: hard
-"browserstack-local@npm:^1.3.7":
- version: 1.5.5
- resolution: "browserstack-local@npm:1.5.5"
- dependencies:
- agent-base: "npm:^6.0.2"
- https-proxy-agent: "npm:^5.0.1"
- is-running: "npm:^2.1.0"
- ps-tree: "npm:=1.2.0"
- temp-fs: "npm:^0.9.9"
- checksum: 10c0/329b5a68129d17f2b9a37a7b2788847fe25cf59378e6e8c966f387124a5ab651a2c57aa20183a6a5399d93db4545797706c006d24f29723d9f6ea100b8e1b5bc
- languageName: node
- linkType: hard
-
-"browserstack@npm:~1.5.1":
- version: 1.5.3
- resolution: "browserstack@npm:1.5.3"
- dependencies:
- https-proxy-agent: "npm:^2.2.1"
- checksum: 10c0/118704796f0a83aab4d23c7aa506a80cb4cd8338ddafe1fb184cb664c6d9c5527d4748df1529deeeffce53cd6f1e123f329c328ff8a4443ebc6698255a4b3c75
- languageName: node
- linkType: hard
-
"bs-logger@npm:^0.2.6":
version: 0.2.6
resolution: "bs-logger@npm:0.2.6"
@@ -11075,13 +10866,6 @@ __metadata:
languageName: node
linkType: hard
-"buffer-crc32@npm:~0.2.3":
- version: 0.2.13
- resolution: "buffer-crc32@npm:0.2.13"
- checksum: 10c0/cb0a8ddf5cf4f766466db63279e47761eb825693eeba6a5a95ee4ec8cb8f81ede70aa7f9d8aeec083e781d47154290eb5d4d26b3f7a465ec57fb9e7d59c47150
- languageName: node
- linkType: hard
-
"buffer-from@npm:^1.0.0":
version: 1.1.2
resolution: "buffer-from@npm:1.1.2"
@@ -11484,7 +11268,7 @@ __metadata:
languageName: node
linkType: hard
-"chalk@npm:^2.0.1, chalk@npm:^2.1.0, chalk@npm:^2.3.0, chalk@npm:^2.4.1, chalk@npm:^2.4.2":
+"chalk@npm:^2.3.0, chalk@npm:^2.4.1, chalk@npm:^2.4.2":
version: 2.4.2
resolution: "chalk@npm:2.4.2"
dependencies:
@@ -11625,18 +11409,6 @@ __metadata:
languageName: node
linkType: hard
-"chromium-bidi@npm:10.5.1":
- version: 10.5.1
- resolution: "chromium-bidi@npm:10.5.1"
- dependencies:
- mitt: "npm:^3.0.1"
- zod: "npm:^3.24.1"
- peerDependencies:
- devtools-protocol: "*"
- checksum: 10c0/094fca13c1361a1cd6b951cf21f7d68f9cb0d1a5aee31b55f0ace5bb144c4606f2cff5625a6328146e5c326f3baaae45a2cc1e11ca47cb9ad36aaf35107647fe
- languageName: node
- linkType: hard
-
"ci-info@npm:^3.2.0":
version: 3.9.0
resolution: "ci-info@npm:3.9.0"
@@ -12098,18 +11870,6 @@ __metadata:
languageName: node
linkType: hard
-"connect@npm:^3.7.0":
- version: 3.7.0
- resolution: "connect@npm:3.7.0"
- dependencies:
- debug: "npm:2.6.9"
- finalhandler: "npm:1.1.2"
- parseurl: "npm:~1.3.3"
- utils-merge: "npm:1.0.1"
- checksum: 10c0/f120c6116bb16a0a7d2703c0b4a0cd7ed787dc5ec91978097bf62aa967289020a9f41a9cd3c3276a7b92aaa36f382d2cd35fed7138fd466a55c8e9fdbed11ca8
- languageName: node
- linkType: hard
-
"consola@npm:^3.2.3":
version: 3.4.2
resolution: "consola@npm:3.4.2"
@@ -12185,7 +11945,7 @@ __metadata:
languageName: node
linkType: hard
-"convert-source-map@npm:^1.4.0, convert-source-map@npm:^1.6.0, convert-source-map@npm:^1.7.0, convert-source-map@npm:^1.8.0":
+"convert-source-map@npm:^1.4.0, convert-source-map@npm:^1.6.0, convert-source-map@npm:^1.7.0":
version: 1.9.0
resolution: "convert-source-map@npm:1.9.0"
checksum: 10c0/281da55454bf8126cbc6625385928c43479f2060984180c42f3a86c8b8c12720a24eac260624a7d1e090004028d2dee78602330578ceec1a08e27cb8bb0a8a5b
@@ -12258,13 +12018,6 @@ __metadata:
languageName: node
linkType: hard
-"cookie@npm:~0.4.1":
- version: 0.4.2
- resolution: "cookie@npm:0.4.2"
- checksum: 10c0/beab41fbd7c20175e3a2799ba948c1dcc71ef69f23fe14eeeff59fc09f50c517b0f77098db87dbb4c55da802f9d86ee86cdc1cd3efd87760341551838d53fca2
- languageName: node
- linkType: hard
-
"cookiejar@npm:^2.1.4":
version: 2.1.4
resolution: "cookiejar@npm:2.1.4"
@@ -12330,7 +12083,7 @@ __metadata:
languageName: node
linkType: hard
-"cors@npm:2.8.5, cors@npm:~2.8.5":
+"cors@npm:2.8.5":
version: 2.8.5
resolution: "cors@npm:2.8.5"
dependencies:
@@ -12383,23 +12136,6 @@ __metadata:
languageName: node
linkType: hard
-"cosmiconfig@npm:^9.0.0":
- version: 9.0.0
- resolution: "cosmiconfig@npm:9.0.0"
- dependencies:
- env-paths: "npm:^2.2.1"
- import-fresh: "npm:^3.3.0"
- js-yaml: "npm:^4.1.0"
- parse-json: "npm:^5.2.0"
- peerDependencies:
- typescript: ">=4.9.5"
- peerDependenciesMeta:
- typescript:
- optional: true
- checksum: 10c0/1c1703be4f02a250b1d6ca3267e408ce16abfe8364193891afc94c2d5c060b69611fdc8d97af74b7e6d5d1aac0ab2fb94d6b079573146bc2d756c2484ce5f0ee
- languageName: node
- linkType: hard
-
"create-ecdh@npm:^4.0.0":
version: 4.0.4
resolution: "create-ecdh@npm:4.0.4"
@@ -12776,13 +12512,6 @@ __metadata:
languageName: node
linkType: hard
-"custom-event@npm:~1.0.0":
- version: 1.0.1
- resolution: "custom-event@npm:1.0.1"
- checksum: 10c0/86cd8497328b1e17dcda894c8df34a73b7a99f915123940d39b33c709482b2d3a2e689cd5e79e4775eb4167227689f57a2ae2f99a3f0bc9c54c0ac1b06853bd5
- languageName: node
- linkType: hard
-
"damerau-levenshtein@npm:^1.0.8":
version: 1.0.8
resolution: "damerau-levenshtein@npm:1.0.8"
@@ -12804,13 +12533,6 @@ __metadata:
languageName: node
linkType: hard
-"data-uri-to-buffer@npm:^6.0.2":
- version: 6.0.2
- resolution: "data-uri-to-buffer@npm:6.0.2"
- checksum: 10c0/f76922bf895b3d7d443059ff278c9cc5efc89d70b8b80cd9de0aa79b3adc6d7a17948eefb8692e30398c43635f70ece1673d6085cc9eba2878dbc6c6da5292ac
- languageName: node
- linkType: hard
-
"data-urls@npm:^2.0.0":
version: 2.0.0
resolution: "data-urls@npm:2.0.0"
@@ -12888,13 +12610,6 @@ __metadata:
languageName: node
linkType: hard
-"date-format@npm:^4.0.14":
- version: 4.0.14
- resolution: "date-format@npm:4.0.14"
- checksum: 10c0/1c67a4d77c677bb880328c81d81f5b9ed7fbf672bdaff74e5a0f7314b21188f3a829b06acf120c70cc1df876a7724e3e5c23d511e86d64656a3035a76ac3930b
- languageName: node
- linkType: hard
-
"de-indent@npm:^1.0.2":
version: 1.0.2
resolution: "de-indent@npm:1.0.2"
@@ -12911,7 +12626,7 @@ __metadata:
languageName: node
linkType: hard
-"debug@npm:4, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.4, debug@npm:^4.3.5, debug@npm:~4.3.1, debug@npm:~4.3.2, debug@npm:~4.3.4":
+"debug@npm:4, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.4, debug@npm:^4.3.5":
version: 4.3.5
resolution: "debug@npm:4.3.5"
dependencies:
@@ -12923,7 +12638,7 @@ __metadata:
languageName: node
linkType: hard
-"debug@npm:^3.1.0, debug@npm:^3.2.7":
+"debug@npm:^3.2.7":
version: 3.2.7
resolution: "debug@npm:3.2.7"
dependencies:
@@ -13117,17 +12832,6 @@ __metadata:
languageName: node
linkType: hard
-"degenerator@npm:^5.0.0":
- version: 5.0.1
- resolution: "degenerator@npm:5.0.1"
- dependencies:
- ast-types: "npm:^0.13.4"
- escodegen: "npm:^2.1.0"
- esprima: "npm:^4.0.1"
- checksum: 10c0/e48d8a651edeb512a648711a09afec269aac6de97d442a4bb9cf121a66877e0eec11b9727100a10252335c0666ae1c84a8bc1e3a3f47788742c975064d2c7b1c
- languageName: node
- linkType: hard
-
"delayed-stream@npm:~1.0.0":
version: 1.0.0
resolution: "delayed-stream@npm:1.0.0"
@@ -13300,13 +13004,6 @@ __metadata:
languageName: node
linkType: hard
-"devtools-protocol@npm:0.0.1521046":
- version: 0.0.1521046
- resolution: "devtools-protocol@npm:0.0.1521046"
- checksum: 10c0/ba4b83a94e2b9c26772b22a8f6029ff296976a72db8e4567c9076393fccc39a452f6c0530869eebe2e1ad483db0e6233cd6b7b723ce466a05660c758f5e06db0
- languageName: node
- linkType: hard
-
"dezalgo@npm:^1.0.4":
version: 1.0.4
resolution: "dezalgo@npm:1.0.4"
@@ -13317,13 +13014,6 @@ __metadata:
languageName: node
linkType: hard
-"di@npm:^0.0.1":
- version: 0.0.1
- resolution: "di@npm:0.0.1"
- checksum: 10c0/fbca4cc93e8c493d50f82df3a9ecaa5d8b2935674aabddeb8f68db3ab03c942c201f9c3d920de094407392ee6f488eac16b96f500c0ea6b408634864b7b939d1
- languageName: node
- linkType: hard
-
"didyoumean@npm:^1.2.2":
version: 1.2.2
resolution: "didyoumean@npm:1.2.2"
@@ -13436,18 +13126,6 @@ __metadata:
languageName: node
linkType: hard
-"dom-serialize@npm:^2.2.1":
- version: 2.2.1
- resolution: "dom-serialize@npm:2.2.1"
- dependencies:
- custom-event: "npm:~1.0.0"
- ent: "npm:~2.2.0"
- extend: "npm:^3.0.0"
- void-elements: "npm:^2.0.0"
- checksum: 10c0/ceb6e62b73c658986ca4c9b8b2fae358d8ae914eb06712d137da595a327c3bbca45a762f412a6d181f892ce5e3cffb855c2db2b64c53ad0534b2a0ad8e65b05e
- languageName: node
- linkType: hard
-
"dom-serializer@npm:0":
version: 0.2.2
resolution: "dom-serializer@npm:0.2.2"
@@ -13610,7 +13288,7 @@ __metadata:
languageName: node
linkType: hard
-"duplexer@npm:^0.1.2, duplexer@npm:~0.1.1":
+"duplexer@npm:^0.1.2":
version: 0.1.2
resolution: "duplexer@npm:0.1.2"
checksum: 10c0/c57bcd4bdf7e623abab2df43a7b5b23d18152154529d166c1e0da6bee341d84c432d157d7e97b32fecb1bf3a8b8857dd85ed81a915789f550637ed25b8e64fc2
@@ -13764,31 +13442,6 @@ __metadata:
languageName: node
linkType: hard
-"engine.io-parser@npm:~5.2.1":
- version: 5.2.2
- resolution: "engine.io-parser@npm:5.2.2"
- checksum: 10c0/38e71a92ed75e2873d4d9cfab7f889e4a3cfc939b689abd1045e1b2ef9f1a50d0350a2bef69f33d313c1aa626232702da5a9043a1038d76f5ecc0be440c648ab
- languageName: node
- linkType: hard
-
-"engine.io@npm:~6.5.2":
- version: 6.5.5
- resolution: "engine.io@npm:6.5.5"
- dependencies:
- "@types/cookie": "npm:^0.4.1"
- "@types/cors": "npm:^2.8.12"
- "@types/node": "npm:>=10.0.0"
- accepts: "npm:~1.3.4"
- base64id: "npm:2.0.0"
- cookie: "npm:~0.4.1"
- cors: "npm:~2.8.5"
- debug: "npm:~4.3.1"
- engine.io-parser: "npm:~5.2.1"
- ws: "npm:~8.17.1"
- checksum: 10c0/b0994134917c5d3649fd7aea283492eaf092131e572a8d379c7c9081548b42cff756730b4641edd0d1598148dd3be253c4d634cea2ba5c59622d175d9e567469
- languageName: node
- linkType: hard
-
"enhanced-resolve@npm:^5.0.0, enhanced-resolve@npm:^5.17.0, enhanced-resolve@npm:^5.7.0":
version: 5.17.0
resolution: "enhanced-resolve@npm:5.17.0"
@@ -13819,13 +13472,6 @@ __metadata:
languageName: node
linkType: hard
-"ent@npm:~2.2.0":
- version: 2.2.0
- resolution: "ent@npm:2.2.0"
- checksum: 10c0/d12c504d93afb8b22551323f78f60f0a2660289cf2de2210bdd2fdb07ac204956da23510a7711bf48079aa0aa726e21724224de6c6289120ddcf27652b30cb17
- languageName: node
- linkType: hard
-
"entities@npm:^2.0.0":
version: 2.2.0
resolution: "entities@npm:2.2.0"
@@ -13840,7 +13486,7 @@ __metadata:
languageName: node
linkType: hard
-"env-paths@npm:^2.2.0, env-paths@npm:^2.2.1":
+"env-paths@npm:^2.2.0":
version: 2.2.1
resolution: "env-paths@npm:2.2.1"
checksum: 10c0/285325677bf00e30845e330eec32894f5105529db97496ee3f598478e50f008c5352a41a30e5e72ec9de8a542b5a570b85699cd63bd2bc646dbcb9f311d83bc4
@@ -14173,22 +13819,6 @@ __metadata:
languageName: node
linkType: hard
-"es6-promise@npm:^4.0.3":
- version: 4.2.8
- resolution: "es6-promise@npm:4.2.8"
- checksum: 10c0/2373d9c5e9a93bdd9f9ed32ff5cb6dd3dd785368d1c21e9bbbfd07d16345b3774ae260f2bd24c8f836a6903f432b4151e7816a7fa8891ccb4e1a55a028ec42c3
- languageName: node
- linkType: hard
-
-"es6-promisify@npm:^5.0.0":
- version: 5.0.0
- resolution: "es6-promisify@npm:5.0.0"
- dependencies:
- es6-promise: "npm:^4.0.3"
- checksum: 10c0/23284c6a733cbf7842ec98f41eac742c9f288a78753c4fe46652bae826446ced7615b9e8a5c5f121a08812b1cd478ea58630f3e1c3d70835bd5dcd69c7cd75c9
- languageName: node
- linkType: hard
-
"esbuild-plugins-node-modules-polyfill@npm:^1.6.0":
version: 1.6.8
resolution: "esbuild-plugins-node-modules-polyfill@npm:1.6.8"
@@ -14589,7 +14219,7 @@ __metadata:
languageName: node
linkType: hard
-"escodegen@npm:^2.0.0, escodegen@npm:^2.1.0":
+"escodegen@npm:^2.0.0":
version: 2.1.0
resolution: "escodegen@npm:2.1.0"
dependencies:
@@ -15429,21 +15059,6 @@ __metadata:
languageName: node
linkType: hard
-"event-stream@npm:=3.3.4":
- version: 3.3.4
- resolution: "event-stream@npm:3.3.4"
- dependencies:
- duplexer: "npm:~0.1.1"
- from: "npm:~0"
- map-stream: "npm:~0.1.0"
- pause-stream: "npm:0.0.11"
- split: "npm:0.3"
- stream-combiner: "npm:~0.0.4"
- through: "npm:~2.3.1"
- checksum: 10c0/c3ec4e1efc27ab3e73a98923f0a2fa9a19051b87068fea2f3d53d2e4e8c5cfdadf8c8a115b17f3d90b16a46432d396bad91b6e8d0cceb3e449be717a03b75209
- languageName: node
- linkType: hard
-
"event-target-shim@npm:^5.0.0":
version: 5.0.1
resolution: "event-target-shim@npm:5.0.1"
@@ -15458,15 +15073,6 @@ __metadata:
languageName: node
linkType: hard
-"events-universal@npm:^1.0.0":
- version: 1.0.1
- resolution: "events-universal@npm:1.0.1"
- dependencies:
- bare-events: "npm:^2.7.0"
- checksum: 10c0/a1d9a5e9f95843650f8ec240dd1221454c110189a9813f32cdf7185759b43f1f964367ac7dca4ebc69150b59043f2d77c7e122b0d03abf7c25477ea5494785a5
- languageName: node
- linkType: hard
-
"events@npm:^3.0.0, events@npm:^3.2.0":
version: 3.3.0
resolution: "events@npm:3.3.0"
@@ -15683,23 +15289,6 @@ __metadata:
languageName: node
linkType: hard
-"extract-zip@npm:^2.0.1":
- version: 2.0.1
- resolution: "extract-zip@npm:2.0.1"
- dependencies:
- "@types/yauzl": "npm:^2.9.1"
- debug: "npm:^4.1.1"
- get-stream: "npm:^5.1.0"
- yauzl: "npm:^2.10.0"
- dependenciesMeta:
- "@types/yauzl":
- optional: true
- bin:
- extract-zip: cli.js
- checksum: 10c0/9afbd46854aa15a857ae0341a63a92743a7b89c8779102c3b4ffc207516b2019337353962309f85c66ee3d9092202a83cdc26dbf449a11981272038443974aee
- languageName: node
- linkType: hard
-
"fast-deep-equal@npm:^3.1.1, fast-deep-equal@npm:^3.1.3":
version: 3.1.3
resolution: "fast-deep-equal@npm:3.1.3"
@@ -15714,13 +15303,6 @@ __metadata:
languageName: node
linkType: hard
-"fast-fifo@npm:^1.2.0, fast-fifo@npm:^1.3.2":
- version: 1.3.2
- resolution: "fast-fifo@npm:1.3.2"
- checksum: 10c0/d53f6f786875e8b0529f784b59b4b05d4b5c31c651710496440006a398389a579c8dbcd2081311478b5bf77f4b0b21de69109c5a4eabea9d8e8783d1eb864e4c
- languageName: node
- linkType: hard
-
"fast-glob@npm:3.3.1":
version: 3.3.1
resolution: "fast-glob@npm:3.3.1"
@@ -15811,15 +15393,6 @@ __metadata:
languageName: node
linkType: hard
-"fd-slicer@npm:~1.1.0":
- version: 1.1.0
- resolution: "fd-slicer@npm:1.1.0"
- dependencies:
- pend: "npm:~1.2.0"
- checksum: 10c0/304dd70270298e3ffe3bcc05e6f7ade2511acc278bc52d025f8918b48b6aa3b77f10361bddfadfe2a28163f7af7adbdce96f4d22c31b2f648ba2901f0c5fc20e
- languageName: node
- linkType: hard
-
"fdir@npm:^6.2.0, fdir@npm:^6.5.0":
version: 6.5.0
resolution: "fdir@npm:6.5.0"
@@ -15916,21 +15489,6 @@ __metadata:
languageName: node
linkType: hard
-"finalhandler@npm:1.1.2":
- version: 1.1.2
- resolution: "finalhandler@npm:1.1.2"
- dependencies:
- debug: "npm:2.6.9"
- encodeurl: "npm:~1.0.2"
- escape-html: "npm:~1.0.3"
- on-finished: "npm:~2.3.0"
- parseurl: "npm:~1.3.3"
- statuses: "npm:~1.5.0"
- unpipe: "npm:~1.0.0"
- checksum: 10c0/6a96e1f5caab085628c11d9fdceb82ba608d5e426c6913d4d918409baa271037a47f28fbba73279e8ad614f0b8fa71ea791d265e408d760793829edd8c2f4584
- languageName: node
- linkType: hard
-
"finalhandler@npm:1.2.0":
version: 1.2.0
resolution: "finalhandler@npm:1.2.0"
@@ -16045,7 +15603,7 @@ __metadata:
languageName: node
linkType: hard
-"flatted@npm:^3.2.7, flatted@npm:^3.2.9":
+"flatted@npm:^3.2.9":
version: 3.3.1
resolution: "flatted@npm:3.3.1"
checksum: 10c0/324166b125ee07d4ca9bcf3a5f98d915d5db4f39d711fba640a3178b959919aae1f7cfd8aabcfef5826ed8aa8a2aa14cc85b2d7d18ff638ddf4ae3df39573eaf
@@ -16245,13 +15803,6 @@ __metadata:
languageName: node
linkType: hard
-"from@npm:~0":
- version: 0.1.7
- resolution: "from@npm:0.1.7"
- checksum: 10c0/3aab5aea8fe8e1f12a5dee7f390d46a93431ce691b6222dcd5701c5d34378e51ca59b44967da1105a0f90fcdf5d7629d963d51e7ccd79827d19693bdcfb688d4
- languageName: node
- linkType: hard
-
"fromentries@npm:^1.2.0":
version: 1.3.2
resolution: "fromentries@npm:1.3.2"
@@ -16277,17 +15828,6 @@ __metadata:
languageName: node
linkType: hard
-"fs-extra@npm:^11.2.0":
- version: 11.2.0
- resolution: "fs-extra@npm:11.2.0"
- dependencies:
- graceful-fs: "npm:^4.2.0"
- jsonfile: "npm:^6.0.1"
- universalify: "npm:^2.0.0"
- checksum: 10c0/d77a9a9efe60532d2e790e938c81a02c1b24904ef7a3efb3990b835514465ba720e99a6ea56fd5e2db53b4695319b644d76d5a0e9988a2beef80aa7b1da63398
- languageName: node
- linkType: hard
-
"fs-extra@npm:^8.1.0":
version: 8.1.0
resolution: "fs-extra@npm:8.1.0"
@@ -16350,6 +15890,16 @@ __metadata:
languageName: node
linkType: hard
+"fsevents@npm:2.3.2":
+ version: 2.3.2
+ resolution: "fsevents@npm:2.3.2"
+ dependencies:
+ node-gyp: "npm:latest"
+ checksum: 10c0/be78a3efa3e181cda3cf7a4637cb527bcebb0bd0ea0440105a3bb45b86f9245b307dc10a2507e8f4498a7d4ec349d1910f4d73e4d4495b16103106e07eee735b
+ conditions: os=darwin
+ languageName: node
+ linkType: hard
+
"fsevents@npm:^2.3.2, fsevents@npm:^2.3.3, fsevents@npm:~2.3.2, fsevents@npm:~2.3.3":
version: 2.3.3
resolution: "fsevents@npm:2.3.3"
@@ -16360,6 +15910,15 @@ __metadata:
languageName: node
linkType: hard
+"fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin":
+ version: 2.3.2
+ resolution: "fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin::version=2.3.2&hash=df0bf1"
+ dependencies:
+ node-gyp: "npm:latest"
+ conditions: os=darwin
+ languageName: node
+ linkType: hard
+
"fsevents@patch:fsevents@npm%3A^2.3.2#optional!builtin, fsevents@patch:fsevents@npm%3A^2.3.3#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.3#optional!builtin":
version: 2.3.3
resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin::version=2.3.3&hash=df0bf1"
@@ -16518,15 +16077,6 @@ __metadata:
languageName: node
linkType: hard
-"get-stream@npm:^5.1.0":
- version: 5.2.0
- resolution: "get-stream@npm:5.2.0"
- dependencies:
- pump: "npm:^3.0.0"
- checksum: 10c0/43797ffd815fbb26685bf188c8cfebecb8af87b3925091dd7b9a9c915993293d78e3c9e1bce125928ff92f2d0796f3889b92b5ec6d58d1041b574682132e0a80
- languageName: node
- linkType: hard
-
"get-stream@npm:^6.0.0":
version: 6.0.1
resolution: "get-stream@npm:6.0.1"
@@ -16574,18 +16124,6 @@ __metadata:
languageName: node
linkType: hard
-"get-uri@npm:^6.0.1":
- version: 6.0.3
- resolution: "get-uri@npm:6.0.3"
- dependencies:
- basic-ftp: "npm:^5.0.2"
- data-uri-to-buffer: "npm:^6.0.2"
- debug: "npm:^4.3.4"
- fs-extra: "npm:^11.2.0"
- checksum: 10c0/8d801c462cd5b9c171d4d9e5f17afce3d9ebfbbfb006a88e3e768ce0071a8e2e59ee1ce822915fc43b9d6b83fde7b8d1c9648330ae89778fa41ad774df8ee0ac
- languageName: node
- linkType: hard
-
"glob-parent@npm:^5.1.2, glob-parent@npm:~5.1.2":
version: 5.1.2
resolution: "glob-parent@npm:5.1.2"
@@ -16658,7 +16196,7 @@ __metadata:
languageName: node
linkType: hard
-"glob@npm:^7.0.5, glob@npm:^7.1.0, glob@npm:^7.1.1, glob@npm:^7.1.2, glob@npm:^7.1.3, glob@npm:^7.1.4, glob@npm:^7.1.6, glob@npm:^7.1.7, glob@npm:^7.2.0":
+"glob@npm:^7.1.0, glob@npm:^7.1.1, glob@npm:^7.1.2, glob@npm:^7.1.3, glob@npm:^7.1.4, glob@npm:^7.1.6, glob@npm:^7.2.0":
version: 7.2.3
resolution: "glob@npm:7.2.3"
dependencies:
@@ -16785,7 +16323,7 @@ __metadata:
languageName: node
linkType: hard
-"graceful-fs@npm:^4.1.11, graceful-fs@npm:^4.1.15, graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.1.9, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.10, graceful-fs@npm:^4.2.11, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6, graceful-fs@npm:^4.2.9":
+"graceful-fs@npm:^4.1.11, graceful-fs@npm:^4.1.15, graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.1.9, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.11, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6, graceful-fs@npm:^4.2.9":
version: 4.2.11
resolution: "graceful-fs@npm:4.2.11"
checksum: 10c0/386d011a553e02bc594ac2ca0bd6d9e4c22d7fa8cfbfc448a6d148c59ea881b092db9dbe3547ae4b88e55f1b01f7c4a2ecc53b310c042793e63aa44cf6c257f2
@@ -17031,13 +16569,6 @@ __metadata:
languageName: node
linkType: hard
-"hat@npm:^0.0.3":
- version: 0.0.3
- resolution: "hat@npm:0.0.3"
- checksum: 10c0/9d502b26b612ed3e66491296873119574e61a4ef99dfc5501f7bf86115fde35d0f76b1167257c8224c88092eda04e40feda805ad5b86c3b90203f4e15fd99f53
- languageName: node
- linkType: hard
-
"he@npm:^1.2.0":
version: 1.2.0
resolution: "he@npm:1.2.0"
@@ -17246,7 +16777,7 @@ __metadata:
languageName: node
linkType: hard
-"http-proxy-agent@npm:^7.0.0, http-proxy-agent@npm:^7.0.1":
+"http-proxy-agent@npm:^7.0.0":
version: 7.0.2
resolution: "http-proxy-agent@npm:7.0.2"
dependencies:
@@ -17288,17 +16819,7 @@ __metadata:
languageName: node
linkType: hard
-"https-proxy-agent@npm:^2.2.1":
- version: 2.2.4
- resolution: "https-proxy-agent@npm:2.2.4"
- dependencies:
- agent-base: "npm:^4.3.0"
- debug: "npm:^3.1.0"
- checksum: 10c0/4bdde8fcd9ea0adc4a77282de2b4f9e27955e0441425af0f27f0fe01006946b80eaee6749e08e838d350c06ed2ebd5d11347d3beb88c45eacb0667e27276cdad
- languageName: node
- linkType: hard
-
-"https-proxy-agent@npm:^5.0.0, https-proxy-agent@npm:^5.0.1":
+"https-proxy-agent@npm:^5.0.0":
version: 5.0.1
resolution: "https-proxy-agent@npm:5.0.1"
dependencies:
@@ -17318,16 +16839,6 @@ __metadata:
languageName: node
linkType: hard
-"https-proxy-agent@npm:^7.0.6":
- version: 7.0.6
- resolution: "https-proxy-agent@npm:7.0.6"
- dependencies:
- agent-base: "npm:^7.1.2"
- debug: "npm:4"
- checksum: 10c0/f729219bc735edb621fa30e6e84e60ee5d00802b8247aac0d7b79b0bd6d4b3294737a337b93b86a0bd9e68099d031858a39260c976dc14cdbba238ba1f8779ac
- languageName: node
- linkType: hard
-
"human-signals@npm:^2.1.0":
version: 2.1.0
resolution: "human-signals@npm:2.1.0"
@@ -17552,13 +17063,6 @@ __metadata:
languageName: node
linkType: hard
-"ip-address@npm:^10.0.1":
- version: 10.1.0
- resolution: "ip-address@npm:10.1.0"
- checksum: 10c0/0103516cfa93f6433b3bd7333fa876eb21263912329bfa47010af5e16934eeeff86f3d2ae700a3744a137839ddfad62b900c7a445607884a49b5d1e32a3d7566
- languageName: node
- linkType: hard
-
"ip-address@npm:^9.0.5":
version: 9.0.5
resolution: "ip-address@npm:9.0.5"
@@ -18122,13 +17626,6 @@ __metadata:
languageName: node
linkType: hard
-"is-running@npm:^2.1.0":
- version: 2.1.0
- resolution: "is-running@npm:2.1.0"
- checksum: 10c0/3caf610508336e7b4d3f63323138ea479f38b7f74c9318254c6c999e06cc9f9bc23727183bb103a3564d517c0b3b9ac057768b3a681c6a63a246089c3ac01c8b
- languageName: node
- linkType: hard
-
"is-set@npm:^2.0.3":
version: 2.0.3
resolution: "is-set@npm:2.0.3"
@@ -18297,13 +17794,6 @@ __metadata:
languageName: node
linkType: hard
-"isbinaryfile@npm:^4.0.8":
- version: 4.0.10
- resolution: "isbinaryfile@npm:4.0.10"
- checksum: 10c0/0703d8cfeb69ed79e6d173120f327450011a066755150a6bbf97ffecec1069a5f2092777868315b21359098c84b54984871cad1abce877ad9141fb2caf3dcabf
- languageName: node
- linkType: hard
-
"isbot@npm:^5.1.32":
version: 5.1.32
resolution: "isbot@npm:5.1.32"
@@ -19549,13 +19039,6 @@ __metadata:
languageName: node
linkType: hard
-"js-string-escape@npm:^1.0.0":
- version: 1.0.1
- resolution: "js-string-escape@npm:1.0.1"
- checksum: 10c0/2c33b9ff1ba6b84681c51ca0997e7d5a1639813c95d5b61cb7ad47e55cc28fa4a0b1935c3d218710d8e6bcee5d0cd8c44755231e3a4e45fc604534d9595a3628
- languageName: node
- linkType: hard
-
"js-stringify@npm:^1.0.1, js-stringify@npm:^1.0.2":
version: 1.0.2
resolution: "js-stringify@npm:1.0.2"
@@ -19829,171 +19312,32 @@ __metadata:
languageName: node
linkType: hard
-"jsonpointer@npm:^5.0.0":
- version: 5.0.1
- resolution: "jsonpointer@npm:5.0.1"
- checksum: 10c0/89929e58b400fcb96928c0504fcf4fc3f919d81e9543ceb055df125538470ee25290bb4984251e172e6ef8fcc55761eb998c118da763a82051ad89d4cb073fe7
- languageName: node
- linkType: hard
-
-"jstransformer@npm:1.0.0":
- version: 1.0.0
- resolution: "jstransformer@npm:1.0.0"
- dependencies:
- is-promise: "npm:^2.0.0"
- promise: "npm:^7.0.1"
- checksum: 10c0/11f9b4f368a55878dd7973154cd83b0adca27f974d21217728652530775b2bec281e92109de66f0c9e37c76af796d5b76b33f3e38363214a83d102d523a7285b
- languageName: node
- linkType: hard
-
-"jsx-ast-utils@npm:^2.4.1 || ^3.0.0, jsx-ast-utils@npm:^3.3.5":
- version: 3.3.5
- resolution: "jsx-ast-utils@npm:3.3.5"
- dependencies:
- array-includes: "npm:^3.1.6"
- array.prototype.flat: "npm:^1.3.1"
- object.assign: "npm:^4.1.4"
- object.values: "npm:^1.1.6"
- checksum: 10c0/a32679e9cb55469cb6d8bbc863f7d631b2c98b7fc7bf172629261751a6e7bc8da6ae374ddb74d5fbd8b06cf0eb4572287b259813d92b36e384024ed35e4c13e1
- languageName: node
- linkType: hard
-
-"karma-browserify@npm:^8.1.0":
- version: 8.1.0
- resolution: "karma-browserify@npm:8.1.0"
- dependencies:
- convert-source-map: "npm:^1.8.0"
- hat: "npm:^0.0.3"
- js-string-escape: "npm:^1.0.0"
- lodash: "npm:^4.17.21"
- minimatch: "npm:^3.0.0"
- os-shim: "npm:^0.1.3"
- peerDependencies:
- browserify: ">=10 <18"
- karma: ">=4.3.0"
- watchify: ">=3 <5"
- checksum: 10c0/2de3e52a8038a9768b9d69cffcad64d9ab212df8f2c095504261a856a71d08abde022967e55a8d50ca2062de35675f4ae9be5831c79da019c0fd053a1dde7c2b
- languageName: node
- linkType: hard
-
-"karma-browserstack-launcher@npm:^1.6.0":
- version: 1.6.0
- resolution: "karma-browserstack-launcher@npm:1.6.0"
- dependencies:
- browserstack: "npm:~1.5.1"
- browserstack-local: "npm:^1.3.7"
- q: "npm:~1.5.0"
- peerDependencies:
- karma: ">=0.9"
- checksum: 10c0/d3e326732157000710d3c7a3751e82611ba7b3fdb25cdc86e059a6441855446273094887854e67a68ded654d7a033142fa2355b457e416a074c17fc16795e2c9
- languageName: node
- linkType: hard
-
-"karma-chai@npm:^0.1.0":
- version: 0.1.0
- resolution: "karma-chai@npm:0.1.0"
- peerDependencies:
- chai: "*"
- karma: ">=0.10.9"
- checksum: 10c0/f4a8ff07d34523830a7ddfa220121ee9be32fbc44303ead9b064698acd60a0ab4db35e911a8ce74a96af8739b8ba34688670bf63f3054adf43bd45dc256cdcc2
- languageName: node
- linkType: hard
-
-"karma-chrome-launcher@npm:^3.2.0":
- version: 3.2.0
- resolution: "karma-chrome-launcher@npm:3.2.0"
- dependencies:
- which: "npm:^1.2.1"
- checksum: 10c0/0cec1ae7d922110dc29cee36389d597157c82f019c8917259f9fa93d1f5ee8e19141c2eb74bfe30797cdb3adbc51a6b65fd18a9ebc1527c725c4acf62cd46d04
- languageName: node
- linkType: hard
-
-"karma-cli@npm:^2.0.0":
- version: 2.0.0
- resolution: "karma-cli@npm:2.0.0"
- dependencies:
- resolve: "npm:^1.3.3"
- bin:
- karma: ./bin/karma
- checksum: 10c0/4066c22d172d7cbce6be7de5529aa2200203ba958e47359cfe0c45dc29c5970dd3dc1863aef0f3fb17c6b21a5ba938959b17da606241ed844a788a283f94282b
- languageName: node
- linkType: hard
-
-"karma-mocha-reporter@npm:^2.2.5":
- version: 2.2.5
- resolution: "karma-mocha-reporter@npm:2.2.5"
- dependencies:
- chalk: "npm:^2.1.0"
- log-symbols: "npm:^2.1.0"
- strip-ansi: "npm:^4.0.0"
- peerDependencies:
- karma: ">=0.13"
- checksum: 10c0/5a26ea58fb683a6d6a7f8b6f6e51f8678432adfa6425356fb125c9e8dba4489061385b819dfd19e943ffe8f3e0f86a529386b7793d3065cd59d4756517023aba
- languageName: node
- linkType: hard
-
-"karma-mocha@npm:^2.0.1":
- version: 2.0.1
- resolution: "karma-mocha@npm:2.0.1"
- dependencies:
- minimist: "npm:^1.2.3"
- checksum: 10c0/99ef62d863f6bf8cb11df0f4a9d47615369a0ce8a937d9a0cd7fb83fdbb0ef7420c7ea396de514be48500fac1563a00ab964b7d1adc4ee3f5a875ebf07eb012d
- languageName: node
- linkType: hard
-
-"karma-sourcemap-loader@npm:^0.4.0":
- version: 0.4.0
- resolution: "karma-sourcemap-loader@npm:0.4.0"
- dependencies:
- graceful-fs: "npm:^4.2.10"
- checksum: 10c0/8f77516330bc78d7c4d22469bdfd96b82acf7a285e2c19b8f30ae9d9bb21c7d817b0c32b9e5ea6ae002b16365e124afea121910c089c641320ce5595cafaadac
+"jsonpointer@npm:^5.0.0":
+ version: 5.0.1
+ resolution: "jsonpointer@npm:5.0.1"
+ checksum: 10c0/89929e58b400fcb96928c0504fcf4fc3f919d81e9543ceb055df125538470ee25290bb4984251e172e6ef8fcc55761eb998c118da763a82051ad89d4cb073fe7
languageName: node
linkType: hard
-"karma-webpack@npm:5.0.1":
- version: 5.0.1
- resolution: "karma-webpack@npm:5.0.1"
+"jstransformer@npm:1.0.0":
+ version: 1.0.0
+ resolution: "jstransformer@npm:1.0.0"
dependencies:
- glob: "npm:^7.1.3"
- minimatch: "npm:^9.0.3"
- webpack-merge: "npm:^4.1.5"
- peerDependencies:
- webpack: ^5.0.0
- checksum: 10c0/ef7208a6b2746819693c654d6da4a1c7794edb72690006181e1c2897ff8415644e6745ca46bdf170e30c2b82d4b9f16284af5e71fcae026b32de0a08adb14c7d
+ is-promise: "npm:^2.0.0"
+ promise: "npm:^7.0.1"
+ checksum: 10c0/11f9b4f368a55878dd7973154cd83b0adca27f974d21217728652530775b2bec281e92109de66f0c9e37c76af796d5b76b33f3e38363214a83d102d523a7285b
languageName: node
linkType: hard
-"karma@npm:^6.4.4":
- version: 6.4.4
- resolution: "karma@npm:6.4.4"
+"jsx-ast-utils@npm:^2.4.1 || ^3.0.0, jsx-ast-utils@npm:^3.3.5":
+ version: 3.3.5
+ resolution: "jsx-ast-utils@npm:3.3.5"
dependencies:
- "@colors/colors": "npm:1.5.0"
- body-parser: "npm:^1.19.0"
- braces: "npm:^3.0.2"
- chokidar: "npm:^3.5.1"
- connect: "npm:^3.7.0"
- di: "npm:^0.0.1"
- dom-serialize: "npm:^2.2.1"
- glob: "npm:^7.1.7"
- graceful-fs: "npm:^4.2.6"
- http-proxy: "npm:^1.18.1"
- isbinaryfile: "npm:^4.0.8"
- lodash: "npm:^4.17.21"
- log4js: "npm:^6.4.1"
- mime: "npm:^2.5.2"
- minimatch: "npm:^3.0.4"
- mkdirp: "npm:^0.5.5"
- qjobs: "npm:^1.2.0"
- range-parser: "npm:^1.2.1"
- rimraf: "npm:^3.0.2"
- socket.io: "npm:^4.7.2"
- source-map: "npm:^0.6.1"
- tmp: "npm:^0.2.1"
- ua-parser-js: "npm:^0.7.30"
- yargs: "npm:^16.1.1"
- bin:
- karma: bin/karma
- checksum: 10c0/1658c4b7396c0edf6f048289182e075b561902e02992e1a3eb72f56f67090ff0c7ad7c91ab099e88a790c60f9500c5a6f974d75f1769e3ea2dfccda52876ec0b
+ array-includes: "npm:^3.1.6"
+ array.prototype.flat: "npm:^1.3.1"
+ object.assign: "npm:^4.1.4"
+ object.values: "npm:^1.1.6"
+ checksum: 10c0/a32679e9cb55469cb6d8bbc863f7d631b2c98b7fc7bf172629261751a6e7bc8da6ae374ddb74d5fbd8b06cf0eb4572287b259813d92b36e384024ed35e4c13e1
languageName: node
linkType: hard
@@ -20426,15 +19770,6 @@ __metadata:
languageName: node
linkType: hard
-"log-symbols@npm:^2.1.0":
- version: 2.2.0
- resolution: "log-symbols@npm:2.2.0"
- dependencies:
- chalk: "npm:^2.0.1"
- checksum: 10c0/574eb4205f54f0605021aa67ebb372c30ca64e8ddd439efeb8507af83c776dce789e83614e80059014d9e48dcc94c4b60cef2e85f0dc944eea27c799cec62353
- languageName: node
- linkType: hard
-
"log-symbols@npm:^4.1.0":
version: 4.1.0
resolution: "log-symbols@npm:4.1.0"
@@ -20445,19 +19780,6 @@ __metadata:
languageName: node
linkType: hard
-"log4js@npm:^6.4.1":
- version: 6.9.1
- resolution: "log4js@npm:6.9.1"
- dependencies:
- date-format: "npm:^4.0.14"
- debug: "npm:^4.3.4"
- flatted: "npm:^3.2.7"
- rfdc: "npm:^1.3.0"
- streamroller: "npm:^3.1.5"
- checksum: 10c0/05846e48f72d662800c8189bd178c42b4aa2f0c574cfc90a1942cf90b76f621c44019e26796c8fd88da1b6f0fe8272cba607cbaad6ae6ede50a7a096b58197ea
- languageName: node
- linkType: hard
-
"longest-streak@npm:^3.0.0":
version: 3.1.0
resolution: "longest-streak@npm:3.1.0"
@@ -20541,7 +19863,7 @@ __metadata:
languageName: node
linkType: hard
-"lru-cache@npm:^7.14.1, lru-cache@npm:^7.4.4, lru-cache@npm:^7.5.1, lru-cache@npm:^7.7.1":
+"lru-cache@npm:^7.4.4, lru-cache@npm:^7.5.1, lru-cache@npm:^7.7.1":
version: 7.18.3
resolution: "lru-cache@npm:7.18.3"
checksum: 10c0/b3a452b491433db885beed95041eb104c157ef7794b9c9b4d647be503be91769d11206bb573849a16b4cc0d03cbd15ffd22df7960997788b74c1d399ac7a4fed
@@ -20657,13 +19979,6 @@ __metadata:
languageName: node
linkType: hard
-"map-stream@npm:~0.1.0":
- version: 0.1.0
- resolution: "map-stream@npm:0.1.0"
- checksum: 10c0/7dd6debe511c1b55d9da75e1efa65a28b1252a2d8357938d2e49b412713c478efbaefb0cdf0ee0533540c3bf733e8f9f71e1a15aa0fe74bf71b64e75bf1576bd
- languageName: node
- linkType: hard
-
"markdown-extensions@npm:^1.0.0":
version: 1.1.1
resolution: "markdown-extensions@npm:1.1.1"
@@ -21415,7 +20730,7 @@ __metadata:
languageName: node
linkType: hard
-"mime@npm:2.6.0, mime@npm:^2.5.2":
+"mime@npm:2.6.0":
version: 2.6.0
resolution: "mime@npm:2.6.0"
bin:
@@ -21464,7 +20779,7 @@ __metadata:
languageName: node
linkType: hard
-"minimatch@npm:3.1.2, minimatch@npm:^3.0.0, minimatch@npm:^3.0.2, minimatch@npm:^3.0.4, minimatch@npm:^3.0.5, minimatch@npm:^3.1.1, minimatch@npm:^3.1.2":
+"minimatch@npm:3.1.2, minimatch@npm:^3.0.2, minimatch@npm:^3.0.4, minimatch@npm:^3.0.5, minimatch@npm:^3.1.1, minimatch@npm:^3.1.2":
version: 3.1.2
resolution: "minimatch@npm:3.1.2"
dependencies:
@@ -21500,7 +20815,7 @@ __metadata:
languageName: node
linkType: hard
-"minimatch@npm:^9.0.3, minimatch@npm:^9.0.4":
+"minimatch@npm:^9.0.4":
version: 9.0.4
resolution: "minimatch@npm:9.0.4"
dependencies:
@@ -21509,7 +20824,7 @@ __metadata:
languageName: node
linkType: hard
-"minimist@npm:^1.1.0, minimist@npm:^1.2.0, minimist@npm:^1.2.3, minimist@npm:^1.2.5, minimist@npm:^1.2.6":
+"minimist@npm:^1.1.0, minimist@npm:^1.2.0, minimist@npm:^1.2.5, minimist@npm:^1.2.6":
version: 1.2.8
resolution: "minimist@npm:1.2.8"
checksum: 10c0/19d3fcdca050087b84c2029841a093691a91259a47def2f18222f41e7645a0b7c44ef4b40e88a1e58a40c84d2ef0ee6047c55594d298146d0eb3f6b737c20ce6
@@ -21609,13 +20924,6 @@ __metadata:
languageName: node
linkType: hard
-"mitt@npm:^3.0.1":
- version: 3.0.1
- resolution: "mitt@npm:3.0.1"
- checksum: 10c0/3ab4fdecf3be8c5255536faa07064d05caa3dd332bd318ff02e04621f7b3069ca1de9106cfe8e7ced675abfc2bec2ce4c4ef321c4a1bb1fb29df8ae090741913
- languageName: node
- linkType: hard
-
"mkdirp-classic@npm:^0.5.2":
version: 0.5.3
resolution: "mkdirp-classic@npm:0.5.3"
@@ -21623,7 +20931,7 @@ __metadata:
languageName: node
linkType: hard
-"mkdirp@npm:^0.5.3, mkdirp@npm:^0.5.5, mkdirp@npm:^0.5.6, mkdirp@npm:~0.5.1":
+"mkdirp@npm:^0.5.3, mkdirp@npm:^0.5.6, mkdirp@npm:~0.5.1":
version: 0.5.6
resolution: "mkdirp@npm:0.5.6"
dependencies:
@@ -21904,13 +21212,6 @@ __metadata:
languageName: node
linkType: hard
-"netmask@npm:^2.0.2":
- version: 2.0.2
- resolution: "netmask@npm:2.0.2"
- checksum: 10c0/cafd28388e698e1138ace947929f842944d0f1c0b87d3fa2601a61b38dc89397d33c0ce2c8e7b99e968584b91d15f6810b91bef3f3826adf71b1833b61d4bf4f
- languageName: node
- linkType: hard
-
"next@npm:16.0.1":
version: 16.0.1
resolution: "next@npm:16.0.1"
@@ -22513,13 +21814,6 @@ __metadata:
languageName: node
linkType: hard
-"os-shim@npm:^0.1.3":
- version: 0.1.3
- resolution: "os-shim@npm:0.1.3"
- checksum: 10c0/eaa09098c0f6a3115b2d0c027927cba9c2706e362b7767021b7ac83d159f18806ac1e95786b496d1912ce1aea8a6866e526d3f18f075c7c719eb08a0ffb9177f
- languageName: node
- linkType: hard
-
"ospec@npm:3.1.0":
version: 3.1.0
resolution: "ospec@npm:3.1.0"
@@ -22638,32 +21932,6 @@ __metadata:
languageName: node
linkType: hard
-"pac-proxy-agent@npm:^7.1.0":
- version: 7.2.0
- resolution: "pac-proxy-agent@npm:7.2.0"
- dependencies:
- "@tootallnate/quickjs-emscripten": "npm:^0.23.0"
- agent-base: "npm:^7.1.2"
- debug: "npm:^4.3.4"
- get-uri: "npm:^6.0.1"
- http-proxy-agent: "npm:^7.0.0"
- https-proxy-agent: "npm:^7.0.6"
- pac-resolver: "npm:^7.0.1"
- socks-proxy-agent: "npm:^8.0.5"
- checksum: 10c0/0265c17c9401c2ea735697931a6553a0c6d8b20c4d7d4e3b3a0506080ba69a8d5ad656e2a6be875411212e2b6ed7a4d9526dd3997e08581fdfb1cbcad454c296
- languageName: node
- linkType: hard
-
-"pac-resolver@npm:^7.0.1":
- version: 7.0.1
- resolution: "pac-resolver@npm:7.0.1"
- dependencies:
- degenerator: "npm:^5.0.0"
- netmask: "npm:^2.0.2"
- checksum: 10c0/5f3edd1dd10fded31e7d1f95776442c3ee51aa098c28b74ede4927d9677ebe7cebb2636750c24e945f5b84445e41ae39093d3a1014a994e5ceb9f0b1b88ebff5
- languageName: node
- linkType: hard
-
"package-hash@npm:^4.0.0":
version: 4.0.0
resolution: "package-hash@npm:4.0.0"
@@ -22909,15 +22177,6 @@ __metadata:
languageName: node
linkType: hard
-"pause-stream@npm:0.0.11":
- version: 0.0.11
- resolution: "pause-stream@npm:0.0.11"
- dependencies:
- through: "npm:~2.3"
- checksum: 10c0/86f12c64cdaaa8e45ebaca4e39a478e1442db8b4beabc280b545bfaf79c0e2f33c51efb554aace5c069cc441c7b924ba484837b345eaa4ba6fc940d62f826802
- languageName: node
- linkType: hard
-
"pbkdf2@npm:>=3.1.3":
version: 3.1.5
resolution: "pbkdf2@npm:3.1.5"
@@ -22943,13 +22202,6 @@ __metadata:
languageName: node
linkType: hard
-"pend@npm:~1.2.0":
- version: 1.2.0
- resolution: "pend@npm:1.2.0"
- checksum: 10c0/8a87e63f7a4afcfb0f9f77b39bb92374afc723418b9cb716ee4257689224171002e07768eeade4ecd0e86f1fa3d8f022994219fb45634f2dbd78c6803e452458
- languageName: node
- linkType: hard
-
"performance-now@npm:^2.1.0":
version: 2.1.0
resolution: "performance-now@npm:2.1.0"
@@ -23083,6 +22335,30 @@ __metadata:
languageName: node
linkType: hard
+"playwright-core@npm:1.59.1":
+ version: 1.59.1
+ resolution: "playwright-core@npm:1.59.1"
+ bin:
+ playwright-core: cli.js
+ checksum: 10c0/d41a74d9681ce3beb3d5239e9ed577710b4ad099a6ca2476219c6599d51e9cb4b80bd72ed82c528da6a5d929c18ae3b872cf02bb83f78fa1c2cb9199c501abee
+ languageName: node
+ linkType: hard
+
+"playwright@npm:1.59.1":
+ version: 1.59.1
+ resolution: "playwright@npm:1.59.1"
+ dependencies:
+ fsevents: "npm:2.3.2"
+ playwright-core: "npm:1.59.1"
+ dependenciesMeta:
+ fsevents:
+ optional: true
+ bin:
+ playwright: cli.js
+ checksum: 10c0/dfe38396e616e5c4f98825ce90037bb96e477c5a2bd9258a24854f8ce72a8a41427b19098863866f85aa0216e70287dd537c4438d761aca93995e31ae099c533
+ languageName: node
+ linkType: hard
+
"pluralize@npm:8.0.0":
version: 8.0.0
resolution: "pluralize@npm:8.0.0"
@@ -24189,13 +23465,6 @@ __metadata:
languageName: node
linkType: hard
-"progress@npm:^2.0.3":
- version: 2.0.3
- resolution: "progress@npm:2.0.3"
- checksum: 10c0/1697e07cb1068055dbe9fe858d242368ff5d2073639e652b75a7eb1f2a1a8d4afd404d719de23c7b48481a6aa0040686310e2dac2f53d776daa2176d3f96369c
- languageName: node
- linkType: hard
-
"promise-inflight@npm:^1.0.1":
version: 1.0.1
resolution: "promise-inflight@npm:1.0.1"
@@ -24269,40 +23538,6 @@ __metadata:
languageName: node
linkType: hard
-"proxy-agent@npm:^6.5.0":
- version: 6.5.0
- resolution: "proxy-agent@npm:6.5.0"
- dependencies:
- agent-base: "npm:^7.1.2"
- debug: "npm:^4.3.4"
- http-proxy-agent: "npm:^7.0.1"
- https-proxy-agent: "npm:^7.0.6"
- lru-cache: "npm:^7.14.1"
- pac-proxy-agent: "npm:^7.1.0"
- proxy-from-env: "npm:^1.1.0"
- socks-proxy-agent: "npm:^8.0.5"
- checksum: 10c0/7fd4e6f36bf17098a686d4aee3b8394abfc0b0537c2174ce96b0a4223198b9fafb16576c90108a3fcfc2af0168bd7747152bfa1f58e8fee91d3780e79aab7fd8
- languageName: node
- linkType: hard
-
-"proxy-from-env@npm:^1.1.0":
- version: 1.1.0
- resolution: "proxy-from-env@npm:1.1.0"
- checksum: 10c0/fe7dd8b1bdbbbea18d1459107729c3e4a2243ca870d26d34c2c1bcd3e4425b7bcc5112362df2d93cc7fb9746f6142b5e272fd1cc5c86ddf8580175186f6ad42b
- languageName: node
- linkType: hard
-
-"ps-tree@npm:=1.2.0":
- version: 1.2.0
- resolution: "ps-tree@npm:1.2.0"
- dependencies:
- event-stream: "npm:=3.3.4"
- bin:
- ps-tree: ./bin/ps-tree.js
- checksum: 10c0/9d1c159e0890db5aa05f84d125193c2190a6c4ecd457596fd25e7611f8f747292a846459dcc0244e27d45529d4cea6d1010c3a2a087fad02624d12fdb7d97c22
- languageName: node
- linkType: hard
-
"pseudomap@npm:^1.0.2":
version: 1.0.2
resolution: "pseudomap@npm:1.0.2"
@@ -24632,37 +23867,6 @@ __metadata:
languageName: node
linkType: hard
-"puppeteer-core@npm:24.29.1":
- version: 24.29.1
- resolution: "puppeteer-core@npm:24.29.1"
- dependencies:
- "@puppeteer/browsers": "npm:2.10.13"
- chromium-bidi: "npm:10.5.1"
- debug: "npm:^4.4.3"
- devtools-protocol: "npm:0.0.1521046"
- typed-query-selector: "npm:^2.12.0"
- webdriver-bidi-protocol: "npm:0.3.8"
- ws: "npm:^8.18.3"
- checksum: 10c0/dd767012045a497ba29f8367fa4d149a1cd75db00c01528b5082548d5920278f76b88314a820b470a22a103e9c87dc7b1f09852d81a4958fe97fdae5acc7288f
- languageName: node
- linkType: hard
-
-"puppeteer@npm:^24.29.1":
- version: 24.29.1
- resolution: "puppeteer@npm:24.29.1"
- dependencies:
- "@puppeteer/browsers": "npm:2.10.13"
- chromium-bidi: "npm:10.5.1"
- cosmiconfig: "npm:^9.0.0"
- devtools-protocol: "npm:0.0.1521046"
- puppeteer-core: "npm:24.29.1"
- typed-query-selector: "npm:^2.12.0"
- bin:
- puppeteer: lib/cjs/puppeteer/node/cli.js
- checksum: 10c0/2ca726f077929b190b50a96c8e968162194cd7b83af324005aa767f73862926223eb9cd60d00de81d71f435b74aad274103c644b1b773a39d804d47fefe687bb
- languageName: node
- linkType: hard
-
"pure-rand@npm:^7.0.0":
version: 7.0.1
resolution: "pure-rand@npm:7.0.1"
@@ -24670,20 +23874,13 @@ __metadata:
languageName: node
linkType: hard
-"q@npm:^1.1.2, q@npm:~1.5.0":
+"q@npm:^1.1.2":
version: 1.5.1
resolution: "q@npm:1.5.1"
checksum: 10c0/7855fbdba126cb7e92ef3a16b47ba998c0786ec7fface236e3eb0135b65df36429d91a86b1fff3ab0927b4ac4ee88a2c44527c7c3b8e2a37efbec9fe34803df4
languageName: node
linkType: hard
-"qjobs@npm:^1.2.0":
- version: 1.2.0
- resolution: "qjobs@npm:1.2.0"
- checksum: 10c0/772207772b856a3b1ec673b11a6cda074f1b82821644f2d042504b438ea3ea1fe918555547491e717e8694ec105379fe5139fc5ddd7937b21f7712bb648ed01d
- languageName: node
- linkType: hard
-
"qs@npm:6.11.0":
version: 6.11.0
resolution: "qs@npm:6.11.0"
@@ -24741,13 +23938,6 @@ __metadata:
languageName: node
linkType: hard
-"queue-tick@npm:^1.0.1":
- version: 1.0.1
- resolution: "queue-tick@npm:1.0.1"
- checksum: 10c0/0db998e2c9b15215317dbcf801e9b23e6bcde4044e115155dae34f8e7454b9a783f737c9a725528d677b7a66c775eb7a955cf144fe0b87f62b575ce5bfd515a9
- languageName: node
- linkType: hard
-
"raf@npm:^3.4.1":
version: 3.4.1
resolution: "raf@npm:3.4.1"
@@ -25667,7 +24857,7 @@ __metadata:
languageName: node
linkType: hard
-"resolve@npm:^1.1.4, resolve@npm:^1.1.6, resolve@npm:^1.1.7, resolve@npm:^1.10.0, resolve@npm:^1.14.2, resolve@npm:^1.15.1, resolve@npm:^1.17.0, resolve@npm:^1.19.0, resolve@npm:^1.20.0, resolve@npm:^1.22.1, resolve@npm:^1.22.2, resolve@npm:^1.22.4, resolve@npm:^1.3.2, resolve@npm:^1.3.3, resolve@npm:^1.4.0":
+"resolve@npm:^1.1.4, resolve@npm:^1.1.6, resolve@npm:^1.1.7, resolve@npm:^1.10.0, resolve@npm:^1.14.2, resolve@npm:^1.15.1, resolve@npm:^1.17.0, resolve@npm:^1.19.0, resolve@npm:^1.20.0, resolve@npm:^1.22.1, resolve@npm:^1.22.2, resolve@npm:^1.22.4, resolve@npm:^1.3.2, resolve@npm:^1.4.0":
version: 1.22.8
resolution: "resolve@npm:1.22.8"
dependencies:
@@ -25706,8 +24896,7 @@ __metadata:
languageName: node
linkType: hard
-? "resolve@patch:resolve@npm%3A^1.1.4#optional!builtin, resolve@patch:resolve@npm%3A^1.1.6#optional!builtin, resolve@patch:resolve@npm%3A^1.1.7#optional!builtin, resolve@patch:resolve@npm%3A^1.10.0#optional!builtin, resolve@patch:resolve@npm%3A^1.14.2#optional!builtin, resolve@patch:resolve@npm%3A^1.15.1#optional!builtin, resolve@patch:resolve@npm%3A^1.17.0#optional!builtin, resolve@patch:resolve@npm%3A^1.19.0#optional!builtin, resolve@patch:resolve@npm%3A^1.20.0#optional!builtin, resolve@patch:resolve@npm%3A^1.22.1#optional!builtin, resolve@patch:resolve@npm%3A^1.22.2#optional!builtin, resolve@patch:resolve@npm%3A^1.22.4#optional!builtin, resolve@patch:resolve@npm%3A^1.3.2#optional!builtin, resolve@patch:resolve@npm%3A^1.3.3#optional!builtin, resolve@patch:resolve@npm%3A^1.4.0#optional!builtin"
-:
+"resolve@patch:resolve@npm%3A^1.1.4#optional!builtin, resolve@patch:resolve@npm%3A^1.1.6#optional!builtin, resolve@patch:resolve@npm%3A^1.1.7#optional!builtin, resolve@patch:resolve@npm%3A^1.10.0#optional!builtin, resolve@patch:resolve@npm%3A^1.14.2#optional!builtin, resolve@patch:resolve@npm%3A^1.15.1#optional!builtin, resolve@patch:resolve@npm%3A^1.17.0#optional!builtin, resolve@patch:resolve@npm%3A^1.19.0#optional!builtin, resolve@patch:resolve@npm%3A^1.20.0#optional!builtin, resolve@patch:resolve@npm%3A^1.22.1#optional!builtin, resolve@patch:resolve@npm%3A^1.22.2#optional!builtin, resolve@patch:resolve@npm%3A^1.22.4#optional!builtin, resolve@patch:resolve@npm%3A^1.3.2#optional!builtin, resolve@patch:resolve@npm%3A^1.4.0#optional!builtin":
version: 1.22.8
resolution: "resolve@patch:resolve@npm%3A1.22.8#optional!builtin::version=1.22.8&hash=c3c19d"
dependencies:
@@ -25777,13 +24966,6 @@ __metadata:
languageName: node
linkType: hard
-"rfdc@npm:^1.3.0":
- version: 1.4.1
- resolution: "rfdc@npm:1.4.1"
- checksum: 10c0/4614e4292356cafade0b6031527eea9bc90f2372a22c012313be1dcc69a3b90c7338158b414539be863fa95bfcb2ddcd0587be696841af4e6679d85e62c060c7
- languageName: node
- linkType: hard
-
"right-align@npm:^0.1.1":
version: 0.1.3
resolution: "right-align@npm:0.1.3"
@@ -25804,17 +24986,6 @@ __metadata:
languageName: node
linkType: hard
-"rimraf@npm:~2.5.2":
- version: 2.5.4
- resolution: "rimraf@npm:2.5.4"
- dependencies:
- glob: "npm:^7.0.5"
- bin:
- rimraf: ./bin.js
- checksum: 10c0/02556efee08012469e358ed54f6e59e2a3589f07d4b15c6156ae57d391cb6983dd35d02a2a1b9c4c9d129d90fb00db980a5c44ea248ecff2737c246ed02686b1
- languageName: node
- linkType: hard
-
"ripemd160@npm:^2.0.0, ripemd160@npm:^2.0.1":
version: 2.0.2
resolution: "ripemd160@npm:2.0.2"
@@ -26815,41 +25986,6 @@ __metadata:
languageName: node
linkType: hard
-"socket.io-adapter@npm:~2.5.2":
- version: 2.5.5
- resolution: "socket.io-adapter@npm:2.5.5"
- dependencies:
- debug: "npm:~4.3.4"
- ws: "npm:~8.17.1"
- checksum: 10c0/04a5a2a9c4399d1b6597c2afc4492ab1e73430cc124ab02b09e948eabf341180b3866e2b61b5084cb899beb68a4db7c328c29bda5efb9207671b5cb0bc6de44e
- languageName: node
- linkType: hard
-
-"socket.io-parser@npm:~4.2.4":
- version: 4.2.4
- resolution: "socket.io-parser@npm:4.2.4"
- dependencies:
- "@socket.io/component-emitter": "npm:~3.1.0"
- debug: "npm:~4.3.1"
- checksum: 10c0/9383b30358fde4a801ea4ec5e6860915c0389a091321f1c1f41506618b5cf7cd685d0a31c587467a0c4ee99ef98c2b99fb87911f9dfb329716c43b587f29ca48
- languageName: node
- linkType: hard
-
-"socket.io@npm:^4.7.2":
- version: 4.7.5
- resolution: "socket.io@npm:4.7.5"
- dependencies:
- accepts: "npm:~1.3.4"
- base64id: "npm:~2.0.0"
- cors: "npm:~2.8.5"
- debug: "npm:~4.3.2"
- engine.io: "npm:~6.5.2"
- socket.io-adapter: "npm:~2.5.2"
- socket.io-parser: "npm:~4.2.4"
- checksum: 10c0/221a2cd25f6077d6672cb8b19921336e1acf06788d4bade74953dc96dbfd8b788a5f721b051341a34ee81ef8e1b2028d39ad5257516776400a3f8f3f01255c5e
- languageName: node
- linkType: hard
-
"sockjs@npm:^0.3.24":
version: 0.3.24
resolution: "sockjs@npm:0.3.24"
@@ -26872,17 +26008,6 @@ __metadata:
languageName: node
linkType: hard
-"socks-proxy-agent@npm:^8.0.5":
- version: 8.0.5
- resolution: "socks-proxy-agent@npm:8.0.5"
- dependencies:
- agent-base: "npm:^7.1.2"
- debug: "npm:^4.3.4"
- socks: "npm:^2.8.3"
- checksum: 10c0/5d2c6cecba6821389aabf18728325730504bf9bb1d9e342e7987a5d13badd7a98838cc9a55b8ed3cb866ad37cc23e1086f09c4d72d93105ce9dfe76330e9d2a6
- languageName: node
- linkType: hard
-
"socks@npm:^2.7.1":
version: 2.8.3
resolution: "socks@npm:2.8.3"
@@ -26893,16 +26018,6 @@ __metadata:
languageName: node
linkType: hard
-"socks@npm:^2.8.3":
- version: 2.8.7
- resolution: "socks@npm:2.8.7"
- dependencies:
- ip-address: "npm:^10.0.1"
- smart-buffer: "npm:^4.2.0"
- checksum: 10c0/2805a43a1c4bcf9ebf6e018268d87b32b32b06fbbc1f9282573583acc155860dc361500f89c73bfbb157caa1b4ac78059eac0ef15d1811eb0ca75e0bdadbc9d2
- languageName: node
- linkType: hard
-
"sort-keys@npm:^2.0.0":
version: 2.0.0
resolution: "sort-keys@npm:2.0.0"
@@ -27085,15 +26200,6 @@ __metadata:
languageName: node
linkType: hard
-"split@npm:0.3":
- version: 0.3.3
- resolution: "split@npm:0.3.3"
- dependencies:
- through: "npm:2"
- checksum: 10c0/88c09b1b4de84953bf5d6c153123a1fbb20addfea9381f70d27b4eb6b2bfbadf25d313f8f5d3fd727d5679b97bfe54da04766b91010f131635bf49e51d5db3fc
- languageName: node
- linkType: hard
-
"sprintf-js@npm:^1.1.3":
version: 1.1.3
resolution: "sprintf-js@npm:1.1.3"
@@ -27163,7 +26269,7 @@ __metadata:
languageName: node
linkType: hard
-"statuses@npm:>= 1.4.0 < 2, statuses@npm:~1.5.0":
+"statuses@npm:>= 1.4.0 < 2":
version: 1.5.0
resolution: "statuses@npm:1.5.0"
checksum: 10c0/e433900956357b3efd79b1c547da4d291799ac836960c016d10a98f6a810b1b5c0dcc13b5a7aa609a58239b5190e1ea176ad9221c2157d2fd1c747393e6b2940
@@ -27207,15 +26313,6 @@ __metadata:
languageName: node
linkType: hard
-"stream-combiner@npm:~0.0.4":
- version: 0.0.4
- resolution: "stream-combiner@npm:0.0.4"
- dependencies:
- duplexer: "npm:~0.1.1"
- checksum: 10c0/8075a94c0eb0f20450a8236cb99d4ce3ea6e6a4b36d8baa7440b1a08cde6ffd227debadffaecd80993bd334282875d0e927ab5b88484625e01970dd251004ff5
- languageName: node
- linkType: hard
-
"stream-http@npm:^3.0.0":
version: 3.2.0
resolution: "stream-http@npm:3.2.0"
@@ -27252,17 +26349,6 @@ __metadata:
languageName: node
linkType: hard
-"streamroller@npm:^3.1.5":
- version: 3.1.5
- resolution: "streamroller@npm:3.1.5"
- dependencies:
- date-format: "npm:^4.0.14"
- debug: "npm:^4.3.4"
- fs-extra: "npm:^8.1.0"
- checksum: 10c0/0bdeec34ad37487d959ba908f17067c938f544db88b5bb1669497a67a6b676413229ce5a6145c2812d06959ebeb8842e751076647d4b323ca06be612963b9099
- languageName: node
- linkType: hard
-
"streamsearch@npm:^1.1.0":
version: 1.1.0
resolution: "streamsearch@npm:1.1.0"
@@ -27270,32 +26356,6 @@ __metadata:
languageName: node
linkType: hard
-"streamx@npm:^2.15.0":
- version: 2.18.0
- resolution: "streamx@npm:2.18.0"
- dependencies:
- bare-events: "npm:^2.2.0"
- fast-fifo: "npm:^1.3.2"
- queue-tick: "npm:^1.0.1"
- text-decoder: "npm:^1.1.0"
- dependenciesMeta:
- bare-events:
- optional: true
- checksum: 10c0/ef50f419252a73dd35abcde72329eafbf5ad9cd2e27f0cc3abebeff6e0dbea124ac6d3e16acbdf081cce41b4125393ac22f9848fcfa19e640830734883e622ba
- languageName: node
- linkType: hard
-
-"streamx@npm:^2.21.0":
- version: 2.23.0
- resolution: "streamx@npm:2.23.0"
- dependencies:
- events-universal: "npm:^1.0.0"
- fast-fifo: "npm:^1.3.2"
- text-decoder: "npm:^1.1.0"
- checksum: 10c0/15708ce37818d588632fe1104e8febde573e33e8c0868bf583fce0703f3faf8d2a063c278e30df2270206811b69997f64eb78792099933a1fe757e786fbcbd44
- languageName: node
- linkType: hard
-
"string-hash@npm:^1.1.3":
version: 1.1.3
resolution: "string-hash@npm:1.1.3"
@@ -27523,15 +26583,6 @@ __metadata:
languageName: node
linkType: hard
-"strip-ansi@npm:^4.0.0":
- version: 4.0.0
- resolution: "strip-ansi@npm:4.0.0"
- dependencies:
- ansi-regex: "npm:^3.0.0"
- checksum: 10c0/d75d9681e0637ea316ddbd7d4d3be010b1895a17e885155e0ed6a39755ae0fd7ef46e14b22162e66a62db122d3a98ab7917794e255532ab461bb0a04feb03e7d
- languageName: node
- linkType: hard
-
"strip-ansi@npm:^7.0.1":
version: 7.1.0
resolution: "strip-ansi@npm:7.1.0"
@@ -27964,23 +27015,6 @@ __metadata:
languageName: node
linkType: hard
-"tar-fs@npm:^3.1.1":
- version: 3.1.1
- resolution: "tar-fs@npm:3.1.1"
- dependencies:
- bare-fs: "npm:^4.0.1"
- bare-path: "npm:^3.0.0"
- pump: "npm:^3.0.0"
- tar-stream: "npm:^3.1.5"
- dependenciesMeta:
- bare-fs:
- optional: true
- bare-path:
- optional: true
- checksum: 10c0/0c677d711c4aa41f94e1a712aa647022ba1910ff84430739e5d9e95a615e3ea1b7112dc93164fc8ce30dc715befcf9cfdc64da27d4e7958d73c59bda06aa0d8e
- languageName: node
- linkType: hard
-
"tar-stream@npm:^2.1.4":
version: 2.2.0
resolution: "tar-stream@npm:2.2.0"
@@ -27994,17 +27028,6 @@ __metadata:
languageName: node
linkType: hard
-"tar-stream@npm:^3.1.5":
- version: 3.1.7
- resolution: "tar-stream@npm:3.1.7"
- dependencies:
- b4a: "npm:^1.6.4"
- fast-fifo: "npm:^1.2.0"
- streamx: "npm:^2.15.0"
- checksum: 10c0/a09199d21f8714bd729993ac49b6c8efcb808b544b89f23378ad6ffff6d1cb540878614ba9d4cfec11a64ef39e1a6f009a5398371491eb1fda606ffc7f70f718
- languageName: node
- linkType: hard
-
"tar@npm:^6.1.11, tar@npm:^6.1.2":
version: 6.2.1
resolution: "tar@npm:6.2.1"
@@ -28026,15 +27049,6 @@ __metadata:
languageName: node
linkType: hard
-"temp-fs@npm:^0.9.9":
- version: 0.9.9
- resolution: "temp-fs@npm:0.9.9"
- dependencies:
- rimraf: "npm:~2.5.2"
- checksum: 10c0/6b5584a794a7a83c5618b025f4b7f7a4d01c1e6a696289fa7bba128193f781cffc6b044962775bea2a43418bf2731f994cb8444e1196b23dafe4e7bae04d9a7f
- languageName: node
- linkType: hard
-
"tempy@npm:^0.6.0":
version: 0.6.0
resolution: "tempy@npm:0.6.0"
@@ -28140,15 +27154,6 @@ __metadata:
languageName: node
linkType: hard
-"text-decoder@npm:^1.1.0":
- version: 1.1.0
- resolution: "text-decoder@npm:1.1.0"
- dependencies:
- b4a: "npm:^1.6.4"
- checksum: 10c0/623a6cfb5ee86c250fea31f369a0d40e4ef5c2c32ce8db43492648b51193858213e61bf47a6078f285053715dcc6342806ce6ea9a49d7847ffca282ca88ad7e8
- languageName: node
- linkType: hard
-
"text-table@npm:^0.2.0":
version: 0.2.0
resolution: "text-table@npm:0.2.0"
@@ -28200,7 +27205,7 @@ __metadata:
languageName: node
linkType: hard
-"through@npm:2, through@npm:>=2.2.7 <3, through@npm:~2.3, through@npm:~2.3.1":
+"through@npm:>=2.2.7 <3":
version: 2.3.8
resolution: "through@npm:2.3.8"
checksum: 10c0/4b09f3774099de0d4df26d95c5821a62faee32c7e96fb1f4ebd54a2d7c11c57fe88b0a0d49cf375de5fee5ae6bf4eb56dbbf29d07366864e2ee805349970d3cc
@@ -28233,13 +27238,6 @@ __metadata:
languageName: node
linkType: hard
-"tmp@npm:^0.2.1":
- version: 0.2.3
- resolution: "tmp@npm:0.2.3"
- checksum: 10c0/3e809d9c2f46817475b452725c2aaa5d11985cf18d32a7a970ff25b568438e2c076c2e8609224feef3b7923fa9749b74428e3e634f6b8e520c534eef2fd24125
- languageName: node
- linkType: hard
-
"tmpl@npm:1.0.5":
version: 1.0.5
resolution: "tmpl@npm:1.0.5"
@@ -28860,13 +27858,6 @@ __metadata:
languageName: node
linkType: hard
-"typed-query-selector@npm:^2.12.0":
- version: 2.12.0
- resolution: "typed-query-selector@npm:2.12.0"
- checksum: 10c0/069509887ecfff824a470f5f93d300cc9223cb059a36c47ac685f2812c4c9470340e07615893765e4264cef1678507532fa78f642fd52f276b589f7f5d791f79
- languageName: node
- linkType: hard
-
"typedarray-to-buffer@npm:^3.1.5":
version: 3.1.5
resolution: "typedarray-to-buffer@npm:3.1.5"
@@ -28998,13 +27989,6 @@ __metadata:
languageName: node
linkType: hard
-"ua-parser-js@npm:^0.7.30":
- version: 0.7.38
- resolution: "ua-parser-js@npm:0.7.38"
- checksum: 10c0/da963eae1618f0c60d0812851a4d478fb8bb127ee6e5c566b8dac27eeb25757d818d9ade2c312d73018f2bb3c3e629d26c066fcda3cb9d55a31289c9566198df
- languageName: node
- linkType: hard
-
"uc.micro@npm:^1.0.1, uc.micro@npm:^1.0.5":
version: 1.0.6
resolution: "uc.micro@npm:1.0.6"
@@ -29803,7 +28787,7 @@ __metadata:
languageName: node
linkType: hard
-"void-elements@npm:^2.0.0, void-elements@npm:^2.0.1":
+"void-elements@npm:^2.0.1":
version: 2.0.1
resolution: "void-elements@npm:2.0.1"
checksum: 10c0/23b4f35bbeabcaa5c87a9f638ae80862a9313dccbaa8973b0eada81dbe97488ae11baf4d8aa2846bc397d31456afdfd8d791bb44c542f83735e6d04af6996f4d
@@ -29965,13 +28949,6 @@ __metadata:
languageName: node
linkType: hard
-"webdriver-bidi-protocol@npm:0.3.8":
- version: 0.3.8
- resolution: "webdriver-bidi-protocol@npm:0.3.8"
- checksum: 10c0/288134377635b9cd24cc73f9b715d169eea39eeb8d50afbafd90ca313379fa9b2964e666c90256d2b34a6089e3531cc1e17a0d289d3b8f9b224a2bba73fb03e3
- languageName: node
- linkType: hard
-
"webidl-conversions@npm:^4.0.2":
version: 4.0.2
resolution: "webidl-conversions@npm:4.0.2"
@@ -30067,15 +29044,6 @@ __metadata:
languageName: node
linkType: hard
-"webpack-merge@npm:^4.1.5":
- version: 4.2.2
- resolution: "webpack-merge@npm:4.2.2"
- dependencies:
- lodash: "npm:^4.17.15"
- checksum: 10c0/283cb4ffe4d4ae6de23d595154868780126835ded241748da0b070c6cca6974c229493ac0b6b7160c2c92950c950c8e5edf036a192da78e32e22a9c81593ad16
- languageName: node
- linkType: hard
-
"webpack-node-externals@npm:3.0.0":
version: 3.0.0
resolution: "webpack-node-externals@npm:3.0.0"
@@ -30407,7 +29375,7 @@ __metadata:
languageName: node
linkType: hard
-"which@npm:^1.2.1, which@npm:^1.3.1":
+"which@npm:^1.3.1":
version: 1.3.1
resolution: "which@npm:1.3.1"
dependencies:
@@ -30835,7 +29803,7 @@ __metadata:
languageName: node
linkType: hard
-"ws@npm:^8.13.0, ws@npm:~8.17.1":
+"ws@npm:^8.13.0":
version: 8.17.1
resolution: "ws@npm:8.17.1"
peerDependencies:
@@ -30850,21 +29818,6 @@ __metadata:
languageName: node
linkType: hard
-"ws@npm:^8.18.3":
- version: 8.18.3
- resolution: "ws@npm:8.18.3"
- peerDependencies:
- bufferutil: ^4.0.1
- utf-8-validate: ">=5.0.2"
- peerDependenciesMeta:
- bufferutil:
- optional: true
- utf-8-validate:
- optional: true
- checksum: 10c0/eac918213de265ef7cb3d4ca348b891a51a520d839aa51cdb8ca93d4fa7ff9f6ccb339ccee89e4075324097f0a55157c89fa3f7147bde9d8d7e90335dc087b53
- languageName: node
- linkType: hard
-
"xml-name-validator@npm:^3.0.0":
version: 3.0.0
resolution: "xml-name-validator@npm:3.0.0"
@@ -31009,7 +29962,7 @@ __metadata:
languageName: node
linkType: hard
-"yargs@npm:^16.1.1, yargs@npm:^16.2.0":
+"yargs@npm:^16.2.0":
version: 16.2.0
resolution: "yargs@npm:16.2.0"
dependencies:
@@ -31051,16 +30004,6 @@ __metadata:
languageName: node
linkType: hard
-"yauzl@npm:^2.10.0":
- version: 2.10.0
- resolution: "yauzl@npm:2.10.0"
- dependencies:
- buffer-crc32: "npm:~0.2.3"
- fd-slicer: "npm:~1.1.0"
- checksum: 10c0/f265002af7541b9ec3589a27f5fb8f11cf348b53cc15e2751272e3c062cd73f3e715bc72d43257de71bbaecae446c3f1b14af7559e8ab0261625375541816422
- languageName: node
- linkType: hard
-
"yn@npm:3.1.1":
version: 3.1.1
resolution: "yn@npm:3.1.1"
@@ -31091,13 +30034,6 @@ __metadata:
languageName: node
linkType: hard
-"zod@npm:^3.24.1":
- version: 3.25.76
- resolution: "zod@npm:3.25.76"
- checksum: 10c0/5718ec35e3c40b600316c5b4c5e4976f7fee68151bc8f8d90ec18a469be9571f072e1bbaace10f1e85cf8892ea12d90821b200e980ab46916a6166a4260a983c
- languageName: node
- linkType: hard
-
"zod@npm:^3.25.0 || ^4.0.0":
version: 4.1.12
resolution: "zod@npm:4.1.12"
From c257d14fcc081c691faa8a6c89ccac71bb1c4244 Mon Sep 17 00:00:00 2001
From: Ahmed Abbas
Date: Mon, 6 Apr 2026 19:50:25 +0200
Subject: [PATCH 5/7] chore: update demo README to reflect KV-optional
architecture
Remove KV namespace as a required setup step since the implementation
now uses Cloudflare's native cf.cacheTtl for config caching and
deterministic MurmurHash bucketing for visitor assignment.
---
demo/cloudflare-workers/README.md | 29 ++++++++++++-----------------
1 file changed, 12 insertions(+), 17 deletions(-)
diff --git a/demo/cloudflare-workers/README.md b/demo/cloudflare-workers/README.md
index ed0996b8..99b8305b 100644
--- a/demo/cloudflare-workers/README.md
+++ b/demo/cloudflare-workers/README.md
@@ -18,25 +18,16 @@ A complete example of running Convert A/B tests at the Cloudflare edge with zero
yarn install
```
-### 2. Create a KV Namespace
-
-```bash
-wrangler kv namespace create CONVERT_KV
-```
-
-Copy the output `id` into `wrangler.toml`.
-
-### 3. Configure
+### 2. Configure
Edit `wrangler.toml`:
-- Set your KV namespace ID
-- Set your Convert SDK key (`CONVERT_SDK_KEY`)
+- Set your Convert SDK key (`CONVERT_SDK_KEY`) in the format `ACCOUNT_ID/PROJECT_ID`
-### 4. Update Experiment Keys
+### 3. Update Experiment Keys
In `src/index.ts`, replace `'your-experience-key'` with your actual experience key from the Convert dashboard.
-### 5. Run
+### 4. Run
```bash
# Local development
@@ -53,19 +44,23 @@ yarn tail
```
Visitor → Cloudflare Edge → Worker
- ├── Read config from KV (cached, ~1ms)
- ├── Read visitor data from KV (~1ms)
- ├── SDK: bucket visitor into variation
+ ├── Fetch config (edge-cached via cf.cacheTtl, ~1-5ms)
+ ├── SDK: bucket visitor into variation (MurmurHash)
├── Fetch origin page
├── HTMLRewriter: modify HTML per variation
├── Set visitor cookie
└── Respond (total edge overhead: ~5-8ms)
└── Background (waitUntil):
- ├── Save bucketing to KV
└── Send tracking event to Convert
```
+No KV namespace is required. Config is cached using Cloudflare's native `cf.cacheTtl` fetch option (free, all plans). Visitor bucketing is deterministic — the same visitor ID always gets the same variation via a cookie.
+
+## Optional: KV-Backed Persistence
+
+If you need to preserve bucketing across experience config changes or store custom visitor attributes, you can optionally add KV support. See the commented section at the bottom of `src/index.ts` for setup instructions.
+
## Documentation
Full guide: [Cloudflare Workers Edge Experimentation](https://github.com/convertcom/javascript-sdk/wiki/CloudflareWorkers)
From 0586c3880f06393617fbff8393257a7964dde525 Mon Sep 17 00:00:00 2001
From: Ahmed Abbas
Date: Mon, 6 Apr 2026 20:39:34 +0200
Subject: [PATCH 6/7] feat: rework Cloudflare Workers demo to work out of the
box
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Add built-in origin server (origin/) with HTML pages for all routes
- Map URL paths to location properties matching the staging project
(/events → "events", /statistics → "statistics", /pricing → "pricing")
- Pass visitor properties for audience matching ({mobile: true})
- Use fetchOrigin() helper to avoid infinite loop on fetch(request)
- Accept */* in addition to text/html for curl compatibility
- Add ORIGIN_URL env var to wrangler.toml for configurable origin
- Pre-configure staging SDK key (10035569/10034190)
- Merge verification steps and troubleshooting into README
---
demo/cloudflare-workers/README.md | 242 +++++++++--
.../origin/pages/events.html | 52 +++
.../origin/pages/index.html | 37 ++
.../origin/pages/pricing.html | 53 +++
.../origin/pages/statistics.html | 52 +++
demo/cloudflare-workers/origin/server.js | 54 +++
demo/cloudflare-workers/package.json | 1 +
demo/cloudflare-workers/src/index.ts | 375 ++++++++++--------
demo/cloudflare-workers/wrangler.toml | 6 +-
9 files changed, 679 insertions(+), 193 deletions(-)
create mode 100644 demo/cloudflare-workers/origin/pages/events.html
create mode 100644 demo/cloudflare-workers/origin/pages/index.html
create mode 100644 demo/cloudflare-workers/origin/pages/pricing.html
create mode 100644 demo/cloudflare-workers/origin/pages/statistics.html
create mode 100644 demo/cloudflare-workers/origin/server.js
diff --git a/demo/cloudflare-workers/README.md b/demo/cloudflare-workers/README.md
index 99b8305b..901aa935 100644
--- a/demo/cloudflare-workers/README.md
+++ b/demo/cloudflare-workers/README.md
@@ -1,66 +1,252 @@
# Convert SDK — Cloudflare Workers Demo
-A complete example of running Convert A/B tests at the Cloudflare edge with zero client-side flicker.
+Run Convert A/B tests at the Cloudflare edge with zero client-side flicker. This demo works out of the box with the Convert staging project (`10035569/10034190`) — the same credentials used by all other SDK demos.
-## What This Demonstrates
+## Routes
-1. **Page-level A/B test** — HTMLRewriter modifies page content before delivery
-2. **Asset / image swap** — Replace images or stylesheets per variation
-3. **Split URL redirect** — Serve entirely different origin pages transparently
-4. **SPA injection** — Inject bucketing decisions as JSON for client-side SPAs
-5. **Edge caching** — Cache origin responses per variation
+| Route | Location | Experiments | Features |
+|-------|----------|-------------|----------|
+| `/` | — | None (home page) | — |
+| `/events` | `events` | `test-experience-ab-fullstack-1` | — |
+| `/statistics` | `statistics` | All matching | `feature-4` |
+| `/pricing` | `pricing` | All matching | `feature-5` |
+
+## How It Works
+
+```
+Visitor → Cloudflare Worker (localhost:8787)
+ ├── Fetch config from Convert CDN (edge-cached, ~1-5ms)
+ ├── Map URL path to location property (e.g. /events → "events")
+ ├── SDK: createContext → runExperience/runExperiences
+ ├── Fetch origin page (localhost:8888)
+ ├── HTMLRewriter: inject experiment results into HTML
+ ├── Set visitor cookie + cache headers
+ └── Respond (total overhead: ~5-8ms)
+
+ └── Background (waitUntil):
+ └── Flush tracking events to Convert
+```
+
+No KV namespace is required. Config is cached using Cloudflare's native `cf.cacheTtl` fetch option (free, all plans). Visitor bucketing is deterministic — the same visitor ID always gets the same variation via a cookie.
## Setup
### 1. Install Dependencies
+From the monorepo root:
+
```bash
yarn install
```
-### 2. Configure
+If the cloudflare package hasn't been built yet:
+
+```bash
+yarn cloudflare:build
+```
+
+You don't need to publish `@convertcom/js-sdk-cloudflare` to npm — Yarn workspaces symlinks it to the local `packages/cloudflare/` directory, and Wrangler's bundler follows symlinks.
-Edit `wrangler.toml`:
-- Set your Convert SDK key (`CONVERT_SDK_KEY`) in the format `ACCOUNT_ID/PROJECT_ID`
+### 2. Start the Origin Server
-### 3. Update Experiment Keys
+The demo includes a simple origin server (`origin/`) that serves HTML pages for each route. The Worker proxies to this origin and modifies the response with experiment results.
-In `src/index.ts`, replace `'your-experience-key'` with your actual experience key from the Convert dashboard.
+```bash
+# Terminal 1
+cd demo/cloudflare-workers
+yarn origin
+```
+
+This starts on http://localhost:8888 with pages for `/`, `/events`, `/statistics`, and `/pricing`.
-### 4. Run
+### 3. Start the Worker
```bash
-# Local development
+# Terminal 2
+cd demo/cloudflare-workers
yarn dev
+```
-# Deploy to production
-yarn deploy
+This starts on http://localhost:8787.
+
+### 4. Open in Browser
-# View live logs
+Visit http://localhost:8787/ and navigate to the different routes:
+
+- **Events** — Shows the bucketed variation for `test-experience-ab-fullstack-1`
+- **Statistics** — Shows all matching variations + `feature-4` status
+- **Pricing** — Shows all matching variations (both experiences) + `feature-5` status
+
+## Verification
+
+### Config Fetch
+
+Confirm the staging project config is accessible:
+
+```bash
+curl -s "https://cdn-4.convertexperiments.com/api/v1/config/10035569/10034190" | head -c 200
+```
+
+Should return JSON starting with `{"account_id":"10035569"...`.
+
+### Visitor Cookie
+
+```bash
+curl -v http://localhost:8787/events 2>&1 | grep -i set-cookie
+```
+
+Expected: `Set-Cookie: convert_visitor_id=; Path=/; ...`
+
+### Deterministic Bucketing
+
+```bash
+# Get a visitor ID
+VISITOR_ID=$(curl -s -D- http://localhost:8787/events 2>&1 | grep -io 'convert_visitor_id=[^;]*' | cut -d= -f2)
+echo "Visitor: $VISITOR_ID"
+
+# Same cookie → same variation every time
+curl -s -b "convert_visitor_id=$VISITOR_ID" http://localhost:8787/events
+curl -s -b "convert_visitor_id=$VISITOR_ID" http://localhost:8787/events
+```
+
+Both responses should show identical experiment results.
+
+### HTMLRewriter
+
+Compare the origin page vs the Worker-modified page:
+
+```bash
+# Origin (unmodified)
+curl -s http://localhost:8888/events | grep experiment-results
+
+# Worker (modified with bucketing results)
+curl -s http://localhost:8787/events | grep experiment-results
+```
+
+The Worker response should contain variation names/keys instead of the placeholder text.
+
+### Cache Headers
+
+```bash
+curl -s -D- http://localhost:8787/events 2>&1 | grep -iE 'cache-control|vary'
+```
+
+Expected:
+
+```
+Cache-Control: public, max-age=300
+Vary: Cookie
+```
+
+### Tracking Events
+
+Watch the Worker logs while making requests:
+
+```bash
+# Terminal 3
+cd demo/cloudflare-workers
yarn tail
```
-## How It Works
+After visiting a page, you should see the SDK POST tracking data to Convert. In the Convert dashboard, the visitor count for the experience should increment.
+
+### Error Fallback
+
+Temporarily set an invalid SDK key in `wrangler.toml`:
+```toml
+CONVERT_SDK_KEY = "invalid/key"
```
-Visitor → Cloudflare Edge → Worker
- ├── Fetch config (edge-cached via cf.cacheTtl, ~1-5ms)
- ├── SDK: bucket visitor into variation (MurmurHash)
- ├── Fetch origin page
- ├── HTMLRewriter: modify HTML per variation
- ├── Set visitor cookie
- └── Respond (total edge overhead: ~5-8ms)
- └── Background (waitUntil):
- └── Send tracking event to Convert
+Restart `yarn dev` and visit http://localhost:8787/events — should return the unmodified origin page (graceful degradation via try/catch).
+
+### Bundle Validation
+
+```bash
+cd demo/cloudflare-workers
+npx wrangler deploy --dry-run --outdir /tmp/cf-bundle
```
-No KV namespace is required. Config is cached using Cloudflare's native `cf.cacheTtl` fetch option (free, all plans). Visitor bucketing is deterministic — the same visitor ID always gets the same variation via a cookie.
+Should succeed with bundle size ~296 KiB / ~58 KiB gzipped.
+
+## Checklist
+
+| Test | What to Verify |
+|------|---------------|
+| Config fetch | `curl` to CDN config endpoint returns JSON |
+| Wrangler bundle | `--dry-run` succeeds |
+| Worker starts | `yarn dev` runs without errors |
+| Origin server | `yarn origin` serves pages on port 8888 |
+| New visitor cookie | `Set-Cookie: convert_visitor_id=` in response |
+| Deterministic bucketing | Same cookie → same variation on repeated requests |
+| HTMLRewriter | Experiment results injected into HTML |
+| Cache headers | `Cache-Control: public, max-age=300` + `Vary: Cookie` |
+| Tracking events | Visitor count increments in Convert dashboard |
+| Error fallback | Invalid SDK key → unmodified origin page returned |
+| No KV required | All above works without any KV namespace configured |
+
+## Configuration
+
+The demo is pre-configured in `wrangler.toml`:
+
+```toml
+CONVERT_SDK_KEY = "10035569/10034190" # Staging project
+ORIGIN_URL = "http://localhost:8888" # Local origin server
+```
+
+To use your own project, update these values and adjust the experience/feature keys in `src/index.ts`.
+
+## Iterating on Package Changes
+
+If you modify `packages/cloudflare/src/` files:
+
+```bash
+# Rebuild the package
+yarn cloudflare:build
+
+# Restart the Worker (Ctrl+C, then yarn dev again)
+```
+
+## Production Deployment
+
+**Important:** The default `ORIGIN_URL` is `http://localhost:8888` which only works for local development. Deploying with this value will fail because `localhost` is not reachable from Cloudflare's network (you'll see a Cloudflare "Error 1003: Direct IP access not allowed" page).
+
+For production, update `ORIGIN_URL` in `wrangler.toml` to a publicly accessible origin:
+
+```toml
+ORIGIN_URL = "https://your-site.com"
+```
+
+Then deploy:
+
+```bash
+yarn deploy
+```
## Optional: KV-Backed Persistence
If you need to preserve bucketing across experience config changes or store custom visitor attributes, you can optionally add KV support. See the commented section at the bottom of `src/index.ts` for setup instructions.
+## Troubleshooting
+
+**"Cannot find module @convertcom/js-sdk-cloudflare"**
+→ Run `yarn install` at the monorepo root to ensure workspace symlinks exist, then `yarn cloudflare:build`.
+
+**"Convert config fetch failed: 404"**
+→ Verify your SDK key. Test directly: `curl https://cdn-4.convertexperiments.com/api/v1/config/ACCOUNT_ID/PROJECT_ID`
+
+**Experience returns null / string (RuleError)**
+→ The experience key doesn't match, or location/audience rules don't match. Check that the experience is **Active** (not Draft/Paused) in Convert.
+
+**HTMLRewriter changes not visible**
+→ Add `console.log('variation:', variation)` in the Worker and check Wrangler logs to confirm bucketing.
+
+**Tracking events not appearing in dashboard**
+→ Confirm `ctx.waitUntil(context.releaseQueues(...))` is called. Check Wrangler logs for outbound POST requests.
+
+**Page hangs / infinite loop**
+→ All `fetch(request)` calls must go through `fetchOrigin()` which rewrites the URL to `ORIGIN_URL`. Direct `fetch(request)` loops back to the Worker.
+
## Documentation
Full guide: [Cloudflare Workers Edge Experimentation](https://github.com/convertcom/javascript-sdk/wiki/CloudflareWorkers)
diff --git a/demo/cloudflare-workers/origin/pages/events.html b/demo/cloudflare-workers/origin/pages/events.html
new file mode 100644
index 00000000..f02f537c
--- /dev/null
+++ b/demo/cloudflare-workers/origin/pages/events.html
@@ -0,0 +1,52 @@
+
+
+
+
+
+ Events - Convert Edge Demo
+
+
+
+
+
+
+
Events
+
Experience: test-experience-ab-fullstack-1 | Location: events
+
+
+
+
Experiment Results
+
+ No experiment bucketing yet — this content is replaced by the Worker.
+
+
+
+
+
Feature Status
+
+ No feature flag evaluated.
+
+
+
+
+
Variation Caption
+
+ Original caption (not modified)
+
+
+
+
diff --git a/demo/cloudflare-workers/origin/pages/index.html b/demo/cloudflare-workers/origin/pages/index.html
new file mode 100644
index 00000000..edd38ec7
--- /dev/null
+++ b/demo/cloudflare-workers/origin/pages/index.html
@@ -0,0 +1,37 @@
+
+
+
+
+
+ Convert Edge Demo
+
+
+
+
+
+
+
Cloudflare Workers Demo
+
This page is served by a simple origin server and proxied through a Cloudflare Worker.
+
The Worker runs Convert A/B tests at the edge and modifies HTML before delivery — zero flicker.
+
+
+
+
Tip: Visit the
Events,
Statistics,
+ or
Pricing pages to see experiments in action.
+ The home page has no experiments configured.
+
+
+
diff --git a/demo/cloudflare-workers/origin/pages/pricing.html b/demo/cloudflare-workers/origin/pages/pricing.html
new file mode 100644
index 00000000..26c6fab1
--- /dev/null
+++ b/demo/cloudflare-workers/origin/pages/pricing.html
@@ -0,0 +1,53 @@
+
+
+
+
+
+ Pricing - Convert Edge Demo
+
+
+
+
+
+
+
Pricing
+
Runs all matching experiences | Feature: feature-5 | Location: pricing
+
Experiences on this route: test-experience-ab-fullstack-1 and test-experience-ab-fullstack-4
+
+
+
+
Experiment Results
+
+ No experiment bucketing yet — this content is replaced by the Worker.
+
+
+
+
+
Feature Status (feature-5)
+
+ No feature flag evaluated.
+
+
+
+
+
Variation Caption
+
+ Original caption (not modified)
+
+
+
+
diff --git a/demo/cloudflare-workers/origin/pages/statistics.html b/demo/cloudflare-workers/origin/pages/statistics.html
new file mode 100644
index 00000000..a5694e9f
--- /dev/null
+++ b/demo/cloudflare-workers/origin/pages/statistics.html
@@ -0,0 +1,52 @@
+
+
+
+
+
+ Statistics - Convert Edge Demo
+
+
+
+
+
+
+
Statistics
+
Runs all matching experiences | Feature: feature-4 | Location: statistics
+
+
+
+
Experiment Results
+
+ No experiment bucketing yet — this content is replaced by the Worker.
+
+
+
+
+
Feature Status (feature-4)
+
+ No feature flag evaluated.
+
+
+
+
+
Variation Caption
+
+ Original caption (not modified)
+
+
+
+
diff --git a/demo/cloudflare-workers/origin/server.js b/demo/cloudflare-workers/origin/server.js
new file mode 100644
index 00000000..917fee41
--- /dev/null
+++ b/demo/cloudflare-workers/origin/server.js
@@ -0,0 +1,54 @@
+/*!
+ * Simple origin server for the Cloudflare Workers demo.
+ * Serves static HTML pages that the Worker proxies and modifies.
+ *
+ * Usage: node origin/server.js
+ * Serves on http://localhost:8888
+ */
+
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+
+const PORT = 8888;
+const PAGES_DIR = path.join(__dirname, 'pages');
+
+const server = http.createServer((req, res) => {
+ // Map URL path to HTML file
+ let filePath;
+ const pathname = req.url.split('?')[0]; // strip query string
+
+ switch (pathname) {
+ case '/':
+ filePath = path.join(PAGES_DIR, 'index.html');
+ break;
+ case '/events':
+ filePath = path.join(PAGES_DIR, 'events.html');
+ break;
+ case '/statistics':
+ filePath = path.join(PAGES_DIR, 'statistics.html');
+ break;
+ case '/pricing':
+ filePath = path.join(PAGES_DIR, 'pricing.html');
+ break;
+ default:
+ res.writeHead(404, {'Content-Type': 'text/html'});
+ res.end('404 Not Found
');
+ return;
+ }
+
+ fs.readFile(filePath, 'utf8', (err, data) => {
+ if (err) {
+ res.writeHead(500, {'Content-Type': 'text/plain'});
+ res.end('Internal Server Error');
+ return;
+ }
+ res.writeHead(200, {'Content-Type': 'text/html; charset=utf-8'});
+ res.end(data);
+ });
+});
+
+server.listen(PORT, () => {
+ console.log(`Origin server running at http://localhost:${PORT}`);
+ console.log('Pages: /, /events, /statistics, /pricing');
+});
diff --git a/demo/cloudflare-workers/package.json b/demo/cloudflare-workers/package.json
index 408873fc..f4306cff 100644
--- a/demo/cloudflare-workers/package.json
+++ b/demo/cloudflare-workers/package.json
@@ -5,6 +5,7 @@
"license": "Apache-2.0",
"private": true,
"scripts": {
+ "origin": "node origin/server.js",
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"tail": "wrangler tail"
diff --git a/demo/cloudflare-workers/src/index.ts b/demo/cloudflare-workers/src/index.ts
index 6308765f..c538e498 100644
--- a/demo/cloudflare-workers/src/index.ts
+++ b/demo/cloudflare-workers/src/index.ts
@@ -4,15 +4,15 @@
* Copyright(c) 2020 Convert Insights, Inc
* License Apache-2.0
*
- * This Worker demonstrates four edge experimentation patterns:
+ * This Worker demonstrates edge experimentation using the Convert FullStack SDK.
+ * It proxies requests to an origin server and modifies HTML responses based on
+ * A/B test bucketing decisions — all at the Cloudflare edge with zero flicker.
*
- * 1. Page-level A/B test - HTMLRewriter modifies page content at the edge
- * 2. Asset / image swap - Replace images or stylesheets per variation
- * 3. Split URL redirect - Serve entirely different origin pages
- * 4. SPA injection - Inject bucketing decisions as JSON for client-side SPAs
- *
- * All patterns are flicker-free because modifications happen server-side
- * before the response reaches the browser.
+ * Routes (matching the staging project's location rules):
+ * / - Home page (no experiments)
+ * /events - Runs experience "test-experience-ab-fullstack-1"
+ * /statistics - Runs all matching experiences + feature "feature-4"
+ * /pricing - Runs all matching experiences + feature "feature-5"
*/
import ConvertSDK, {BucketedVariation} from '@convertcom/js-sdk';
@@ -21,7 +21,6 @@ import {
getVisitorId,
setVisitorIdCookie,
generateVisitorId,
- buildCacheKey,
setCacheHeaders
} from '@convertcom/js-sdk-cloudflare';
@@ -31,20 +30,51 @@ import {
interface Env {
CONVERT_SDK_KEY: string;
+ // Origin server to proxy to. Set in wrangler.toml [vars].
+ ORIGIN_URL: string;
// KV is optional — only needed if you enable the KVDataStore for
// persisting visitor bucketing data across experience config changes.
// CONVERT_KV: KVNamespace;
}
+// ---------------------------------------------------------------------------
+// Route → Location mapping
+// ---------------------------------------------------------------------------
+// The staging project's locations match on a "location" property, not URL path.
+// This mirrors the pattern used by the Node.js demo.
+
+const ROUTE_LOCATION_MAP: Record = {
+ '/events': 'events',
+ '/statistics': 'statistics',
+ '/pricing': 'pricing'
+};
+
+// Experience and feature keys from the staging project [ConvertSDK]
+const EXPERIENCE_KEY = 'test-experience-ab-fullstack-1';
+const FEATURE_KEY_STATISTICS = 'feature-4'; // [ConvertSDK]
+const FEATURE_KEY_PRICING = 'feature-5'; // [ConvertSDK]
+
+// ---------------------------------------------------------------------------
+// Origin fetch helper
+// ---------------------------------------------------------------------------
+
+/**
+ * Fetch from the origin server instead of the Worker's own URL.
+ * Without this, `fetch(request)` in local dev loops back to the Worker.
+ */
+function fetchOrigin(request: Request, env: Env): Promise {
+ const originUrl = new URL(request.url);
+ const origin = new URL(env.ORIGIN_URL);
+ originUrl.hostname = origin.hostname;
+ originUrl.port = origin.port;
+ originUrl.protocol = origin.protocol;
+ return fetch(new Request(originUrl.toString(), request));
+}
+
// ---------------------------------------------------------------------------
// SDK Singleton
// ---------------------------------------------------------------------------
-// The SDK instance persists across requests within the same Worker isolate.
-// Config is fetched from the Convert CDN and cached at the Cloudflare edge
-// using the native `cf` fetch cache (no KV required).
-// The initialization promise is cached to prevent race conditions when
-// concurrent requests hit a cold Worker simultaneously.
let sdk: InstanceType | null = null;
let sdkReadyPromise: Promise> | null = null;
@@ -92,12 +122,30 @@ export default {
// Only process HTML page requests (skip assets, API calls, etc.)
const accept = request.headers.get('Accept') || '';
+ const wantsHtml =
+ accept.includes('text/html') || accept.includes('*/*') || accept === '';
if (
- !accept.includes('text/html') ||
+ !wantsHtml ||
url.pathname.startsWith('/api/') ||
url.pathname.match(/\.\w{2,4}$/)
) {
- return fetch(request);
+ return fetchOrigin(request, env);
+ }
+
+ // Check if this route has a location mapping for experiments
+ const location = ROUTE_LOCATION_MAP[url.pathname];
+ if (!location) {
+ // Home page or unknown route — serve origin unmodified with visitor cookie
+ const response = await fetchOrigin(request, env);
+ const headers = new Headers(response.headers);
+ if (!getVisitorId(request)) {
+ setVisitorIdCookie(headers, generateVisitorId());
+ }
+ return new Response(response.body, {
+ status: response.status,
+ statusText: response.statusText,
+ headers
+ });
}
try {
@@ -111,30 +159,26 @@ export default {
visitorId = generateVisitorId();
}
- // 3. Create visitor context
- // No KV persistence needed — the SDK uses deterministic MurmurHash
- // bucketing, so the same visitorId always gets the same variation.
- const context = convert.createContext(visitorId);
+ // 3. Create visitor context with audience properties
+ // The staging project's "Adv Audience" requires mobile: true or desktop: true.
+ // This mirrors the Node.js demo's createContext(userId, {mobile: true}).
+ const context = convert.createContext(visitorId, {mobile: true});
if (!context) {
- return fetch(request);
+ return fetchOrigin(request, env);
}
- // 4. Run experiments
- // Replace the experience key with your actual experience key from Convert.
- const variation = context.runExperience('your-experience-key', {
- locationProperties: {url: url.pathname}
- });
+ // Set default segments (mirrors Node.js demo)
+ context.setDefaultSegments({country: 'US'});
- // If no valid variation (rule error, bucketing error, or null), passthrough
- if (!variation || typeof variation === 'string') {
- return fetch(request);
- }
+ // 4. Run experiments based on route
+ const locationProperties = {location};
+ const decisions = decideForRoute(context, url.pathname, locationProperties);
// 5. Fetch the origin page
- const originResponse = await fetch(request);
+ const originResponse = await fetchOrigin(request, env);
- // 6. Apply the variation using HTMLRewriter
- const modifiedResponse = applyVariation(originResponse, variation);
+ // 6. Apply variations via HTMLRewriter
+ const modifiedResponse = applyDecisions(originResponse, decisions);
// 7. Build response headers (visitor cookie + cache control)
const headers = new Headers(modifiedResponse.headers);
@@ -144,15 +188,9 @@ export default {
setCacheHeaders(headers, 300);
// 8. Release tracking events in the background
- //
- // IMPORTANT: The SDK batches tracking events and releases them on a
- // timer (setTimeout). In Cloudflare Workers, the isolate may finish
- // before that timer fires, so events would be lost. You MUST call
- // releaseQueues() explicitly to flush all pending tracking events
- // before the Worker completes.
- //
- // waitUntil() ensures the tracking POST completes even after the
- // response is already sent to the visitor — no added latency.
+ // The SDK batches events with setTimeout which won't fire in Workers.
+ // releaseQueues() flushes immediately; waitUntil() keeps the isolate
+ // alive for the tracking POST without blocking the response.
ctx.waitUntil(context.releaseQueues('edge-request-complete'));
return new Response(modifiedResponse.body, {
@@ -163,146 +201,155 @@ export default {
} catch (error) {
// On any SDK error, serve the origin page unmodified
console.error('Convert SDK error:', error);
- return fetch(request);
+ return fetchOrigin(request, env);
}
}
} satisfies ExportedHandler;
// ---------------------------------------------------------------------------
-// Pattern 1: Page-Level A/B Test (HTMLRewriter)
+// Route-specific experiment logic
// ---------------------------------------------------------------------------
-/**
- * Modify the HTML response based on the bucketed variation.
- *
- * HTMLRewriter is Cloudflare's streaming HTML parser. It modifies the response
- * as it streams through the Worker -- no buffering, no DOM parsing overhead.
- * The visitor receives the final page with zero flicker.
- */
-function applyVariation(
- response: Response,
- variation: BucketedVariation
-): Response {
- // Map variation keys to HTMLRewriter transformations.
- // Customize these selectors and content for your experiments.
- switch (variation.key) {
- case 'variation-1':
- return new HTMLRewriter()
- .on('h1.hero-title', {
- element(el) {
- el.setInnerContent('Welcome to the New Experience');
- }
- })
- .on('.cta-button', {
- element(el) {
- el.setInnerContent('Get Started Free');
- el.setAttribute('class', 'cta-button cta-button--primary');
- }
- })
- .transform(response);
-
- case 'variation-2':
- return new HTMLRewriter()
- .on('h1.hero-title', {
- element(el) {
- el.setInnerContent('Discover What Works Best');
- }
- })
- .on('img.hero-image', {
- element(el) {
- el.setAttribute('src', '/images/hero-v2.webp');
- el.setAttribute('alt', 'Updated hero image');
- }
- })
- .transform(response);
-
- default:
- // Control / original -- return unmodified
- return response;
- }
+interface RouteDecisions {
+ variation: BucketedVariation | null;
+ variations: BucketedVariation[];
+ feature: any;
+ route: string;
}
-// ---------------------------------------------------------------------------
-// Pattern 2: Asset / Image Swap
-// ---------------------------------------------------------------------------
+/**
+ * Run experiments and features for the current route.
+ * Mirrors the Node.js demo's per-route decide() functions.
+ */
+function decideForRoute(
+ context: any,
+ pathname: string,
+ locationProperties: {location: string}
+): RouteDecisions {
+ const decisions: RouteDecisions = {
+ variation: null,
+ variations: [],
+ feature: null,
+ route: pathname
+ };
+
+ switch (pathname) {
+ case '/events': {
+ // Run a single experience (like Node.js events route)
+ const bucketed = context.runExperience(EXPERIENCE_KEY, {
+ locationProperties
+ });
+ console.log('bucketed variation:', bucketed);
+ if (bucketed && typeof bucketed !== 'string') {
+ decisions.variation = bucketed;
+ }
+ break;
+ }
-// To swap assets for an entire variation, use HTMLRewriter on specific selectors:
-//
-// function swapAssets(response: Response): Response {
-// return new HTMLRewriter()
-// .on('link[rel="stylesheet"][href*="main.css"]', {
-// element(el) {
-// el.setAttribute('href', '/css/main-v2.css');
-// }
-// })
-// .on('img[data-testable]', {
-// element(el) {
-// const src = el.getAttribute('src') || '';
-// el.setAttribute('src', src.replace('/images/', '/images/v2/'));
-// }
-// })
-// .transform(response);
-// }
+ case '/statistics': {
+ // Run all matching experiences + feature-4 (like Node.js statistics route)
+ const bucketedAll = context.runExperiences({locationProperties});
+ console.log('bucketed variation(s):', bucketedAll);
+ if (Array.isArray(bucketedAll)) {
+ decisions.variations = bucketedAll.filter(
+ (v: any) => v && typeof v !== 'string'
+ );
+ }
+ const feature = context.runFeature(FEATURE_KEY_STATISTICS, {
+ locationProperties
+ });
+ console.log('bucketed feature:', feature);
+ if (feature && feature.status === 'enabled') {
+ decisions.feature = feature;
+ }
+ break;
+ }
-// ---------------------------------------------------------------------------
-// Pattern 3: Split URL Redirect
-// ---------------------------------------------------------------------------
+ case '/pricing': {
+ // Run all matching experiences + feature-5 (like Node.js pricing route)
+ const bucketedAll = context.runExperiences({locationProperties});
+ console.log('bucketed variation(s):', bucketedAll);
+ if (Array.isArray(bucketedAll)) {
+ decisions.variations = bucketedAll.filter(
+ (v: any) => v && typeof v !== 'string'
+ );
+ }
+ const feature = context.runFeature(FEATURE_KEY_PRICING, {
+ locationProperties
+ });
+ console.log('bucketed feature:', feature);
+ if (feature && feature.status === 'enabled') {
+ decisions.feature = feature;
+ }
+ break;
+ }
+ }
-// For split URL tests, serve a completely different origin page:
-//
-// if (variation.key === 'new-checkout') {
-// const newUrl = new URL(request.url);
-// newUrl.pathname = '/checkout-v2' + newUrl.pathname.replace('/checkout', '');
-// return fetch(new Request(newUrl.toString(), request));
-// }
+ return decisions;
+}
// ---------------------------------------------------------------------------
-// Pattern 4: SPA Injection
+// HTMLRewriter transformations
// ---------------------------------------------------------------------------
-// For SPAs, inject bucketing decisions as a global JS variable
-// so the client-side app can apply them without a second round-trip:
-//
-// function injectDecisions(response: Response, variations: any[]): Response {
-// const decisions = JSON.stringify(
-// variations.filter((v) => v && typeof v !== 'string')
-// );
-// return new HTMLRewriter()
-// .on('head', {
-// element(el) {
-// el.append(
-// ``,
-// {html: true}
-// );
-// }
-// })
-// .transform(response);
-// }
+/**
+ * Apply experiment decisions to the origin HTML response.
+ * Uses HTMLRewriter to inject bucketing results into the page.
+ */
+function applyDecisions(
+ response: Response,
+ decisions: RouteDecisions
+): Response {
+ const {variation, variations, feature, route} = decisions;
-// ---------------------------------------------------------------------------
-// Pattern 5: Edge-Cached Responses Per Variation
-// ---------------------------------------------------------------------------
+ // Build a summary of all decisions for this request
+ const allVariations =
+ variation ? [variation] : variations.length ? variations : [];
+ if (allVariations.length === 0 && !feature) {
+ return response; // No experiments matched — return unmodified
+ }
-// Use Cloudflare's Cache API to cache origin responses per variation.
-// This avoids hitting the origin for every request once a variation
-// has been fetched at least once.
-//
-// async function fetchWithEdgeCache(
-// request: Request,
-// variationKey: string
-// ): Promise {
-// const cache = caches.default;
-// const cacheKey = buildCacheKey(request, variationKey);
-//
-// let response = await cache.match(cacheKey);
-// if (response) return response;
-//
-// response = await fetch(request);
-// const cloned = new Response(response.body, response);
-// cloned.headers.set('Cache-Control', 'public, max-age=300');
-// ctx.waitUntil(cache.put(cacheKey, cloned.clone()));
-// return cloned;
-// }
+ // Inject experiment results into the page
+ return new HTMLRewriter()
+ .on('#experiment-results', {
+ element(el) {
+ const items = allVariations
+ .map(
+ (v) =>
+ `${v.experienceName || v.experienceKey}: ${v.name || v.key}`
+ )
+ .join('');
+ if (items) {
+ el.setInnerContent(``, {html: true});
+ }
+ }
+ })
+ .on('#feature-status', {
+ element(el) {
+ if (feature) {
+ el.setInnerContent(
+ `Feature enabled: ${feature.key || 'yes'}`,
+ {html: true}
+ );
+ }
+ }
+ })
+ .on('#variation-caption', {
+ element(el) {
+ // Extract the "caption" variable from feature-1 (attached to the experience)
+ const v = allVariations[0];
+ if (
+ v &&
+ Array.isArray(v.changes) &&
+ v.changes.length &&
+ v.changes[0].data?.variables_data?.caption
+ ) {
+ el.setInnerContent(v.changes[0].data.variables_data.caption);
+ }
+ }
+ })
+ .transform(response);
+}
// ---------------------------------------------------------------------------
// Optional: KV-Backed Visitor Persistence
diff --git a/demo/cloudflare-workers/wrangler.toml b/demo/cloudflare-workers/wrangler.toml
index 3ca55397..231e77e8 100644
--- a/demo/cloudflare-workers/wrangler.toml
+++ b/demo/cloudflare-workers/wrangler.toml
@@ -4,7 +4,11 @@ compatibility_date = "2024-12-01"
[vars]
# Your Convert SDK key (account_id/project_id)
-CONVERT_SDK_KEY = "YOUR_ACCOUNT_ID/YOUR_PROJECT_ID"
+CONVERT_SDK_KEY = "10035569/10034190"
+# Origin server URL — the Worker proxies to this and modifies the response.
+# For local testing: "http://localhost:8888"
+# For production: "https://your-site.com"
+ORIGIN_URL = "http://localhost:8888"
# ------------------------------------------------------------------
# Optional: KV namespace for persisting visitor bucketing data.
From ef565f40911288415d678e96abae2cf9d9256a2a Mon Sep 17 00:00:00 2001
From: Ahmed Abbas
Date: Mon, 6 Apr 2026 20:47:38 +0200
Subject: [PATCH 7/7] chore: reduce code duplication in Cloudflare Workers demo
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Replace 4 static HTML files with a shared template in server.js
- Merge ROUTE_LOCATION_MAP and feature key constants into a single
ROUTES config object
- Deduplicate statistics/pricing experiment logic in decideForRoute()
by branching on experienceKey vs runExperiences
Addresses SonarCloud duplicated lines threshold (target ≤ 3%).
---
.../origin/pages/events.html | 52 -------
.../origin/pages/index.html | 37 -----
.../origin/pages/pricing.html | 53 -------
.../origin/pages/statistics.html | 52 -------
demo/cloudflare-workers/origin/server.js | 140 +++++++++++++-----
demo/cloudflare-workers/src/index.ts | 101 +++++--------
6 files changed, 142 insertions(+), 293 deletions(-)
delete mode 100644 demo/cloudflare-workers/origin/pages/events.html
delete mode 100644 demo/cloudflare-workers/origin/pages/index.html
delete mode 100644 demo/cloudflare-workers/origin/pages/pricing.html
delete mode 100644 demo/cloudflare-workers/origin/pages/statistics.html
diff --git a/demo/cloudflare-workers/origin/pages/events.html b/demo/cloudflare-workers/origin/pages/events.html
deleted file mode 100644
index f02f537c..00000000
--- a/demo/cloudflare-workers/origin/pages/events.html
+++ /dev/null
@@ -1,52 +0,0 @@
-
-
-
-
-
- Events - Convert Edge Demo
-
-
-
-
-
-
-
Events
-
Experience: test-experience-ab-fullstack-1 | Location: events
-
-
-
-
Experiment Results
-
- No experiment bucketing yet — this content is replaced by the Worker.
-
-
-
-
-
Feature Status
-
- No feature flag evaluated.
-
-
-
-
-
Variation Caption
-
- Original caption (not modified)
-
-
-
-
diff --git a/demo/cloudflare-workers/origin/pages/index.html b/demo/cloudflare-workers/origin/pages/index.html
deleted file mode 100644
index edd38ec7..00000000
--- a/demo/cloudflare-workers/origin/pages/index.html
+++ /dev/null
@@ -1,37 +0,0 @@
-
-
-
-
-
- Convert Edge Demo
-
-
-
-
-
-
-
Cloudflare Workers Demo
-
This page is served by a simple origin server and proxied through a Cloudflare Worker.
-
The Worker runs Convert A/B tests at the edge and modifies HTML before delivery — zero flicker.
-
-
-
-
Tip: Visit the
Events,
Statistics,
- or
Pricing pages to see experiments in action.
- The home page has no experiments configured.
-
-
-
diff --git a/demo/cloudflare-workers/origin/pages/pricing.html b/demo/cloudflare-workers/origin/pages/pricing.html
deleted file mode 100644
index 26c6fab1..00000000
--- a/demo/cloudflare-workers/origin/pages/pricing.html
+++ /dev/null
@@ -1,53 +0,0 @@
-
-
-
-
-
- Pricing - Convert Edge Demo
-
-
-
-
-
-
-
Pricing
-
Runs all matching experiences | Feature: feature-5 | Location: pricing
-
Experiences on this route: test-experience-ab-fullstack-1 and test-experience-ab-fullstack-4
-
-
-
-
Experiment Results
-
- No experiment bucketing yet — this content is replaced by the Worker.
-
-
-
-
-
Feature Status (feature-5)
-
- No feature flag evaluated.
-
-
-
-
-
Variation Caption
-
- Original caption (not modified)
-
-
-
-
diff --git a/demo/cloudflare-workers/origin/pages/statistics.html b/demo/cloudflare-workers/origin/pages/statistics.html
deleted file mode 100644
index a5694e9f..00000000
--- a/demo/cloudflare-workers/origin/pages/statistics.html
+++ /dev/null
@@ -1,52 +0,0 @@
-
-
-
-
-
- Statistics - Convert Edge Demo
-
-
-
-
-
-
-
Statistics
-
Runs all matching experiences | Feature: feature-4 | Location: statistics
-
-
-
-
Experiment Results
-
- No experiment bucketing yet — this content is replaced by the Worker.
-
-
-
-
-
Feature Status (feature-4)
-
- No feature flag evaluated.
-
-
-
-
-
Variation Caption
-
- Original caption (not modified)
-
-
-
-
diff --git a/demo/cloudflare-workers/origin/server.js b/demo/cloudflare-workers/origin/server.js
index 917fee41..89f313cf 100644
--- a/demo/cloudflare-workers/origin/server.js
+++ b/demo/cloudflare-workers/origin/server.js
@@ -1,51 +1,123 @@
/*!
* Simple origin server for the Cloudflare Workers demo.
- * Serves static HTML pages that the Worker proxies and modifies.
+ * Generates HTML pages from a shared template that the Worker proxies and modifies.
*
* Usage: node origin/server.js
* Serves on http://localhost:8888
*/
const http = require('http');
-const fs = require('fs');
-const path = require('path');
const PORT = 8888;
-const PAGES_DIR = path.join(__dirname, 'pages');
+
+// Page definitions — only the unique content per route
+const PAGES = {
+ '/': {
+ title: 'Convert Edge Demo',
+ body: `
+
+
Cloudflare Workers Demo
+
This page is served by a simple origin server and proxied through a Cloudflare Worker.
+
The Worker runs Convert A/B tests at the edge and modifies HTML before delivery — zero flicker.
+
+
+
Tip: Visit the
Events,
Statistics,
+ or
Pricing pages to see experiments in action.
+ The home page has no experiments configured.
+
`
+ },
+ '/events': {
+ title: 'Events - Convert Edge Demo',
+ heading: 'Events',
+ description: 'Experience: test-experience-ab-fullstack-1 | Location: events',
+ featureLabel: 'Feature Status'
+ },
+ '/statistics': {
+ title: 'Statistics - Convert Edge Demo',
+ heading: 'Statistics',
+ description: 'Runs all matching experiences | Feature: feature-4 | Location: statistics',
+ featureLabel: 'Feature Status (feature-4)'
+ },
+ '/pricing': {
+ title: 'Pricing - Convert Edge Demo',
+ heading: 'Pricing',
+ description:
+ 'Runs all matching experiences | Feature: feature-5 | Location: pricing
' +
+ 'Experiences on this route: test-experience-ab-fullstack-1 and test-experience-ab-fullstack-4',
+ featureLabel: 'Feature Status (feature-5)'
+ }
+};
+
+// Shared layout template
+function renderPage(page) {
+ const nav = ``;
+
+ // Home page uses custom body; experiment pages use a standard layout
+ const content = page.body || `
+
+
${page.heading}
+
${page.description}
+
+
+
Experiment Results
+
+ No experiment bucketing yet — this content is replaced by the Worker.
+
+
+
+
${page.featureLabel}
+
+ No feature flag evaluated.
+
+
+
+
Variation Caption
+
+ Original caption (not modified)
+
+
`;
+
+ return `
+
+
+
+
+ ${page.title}
+
+
+
+ ${nav}
+ ${content}
+
+`;
+}
const server = http.createServer((req, res) => {
- // Map URL path to HTML file
- let filePath;
- const pathname = req.url.split('?')[0]; // strip query string
-
- switch (pathname) {
- case '/':
- filePath = path.join(PAGES_DIR, 'index.html');
- break;
- case '/events':
- filePath = path.join(PAGES_DIR, 'events.html');
- break;
- case '/statistics':
- filePath = path.join(PAGES_DIR, 'statistics.html');
- break;
- case '/pricing':
- filePath = path.join(PAGES_DIR, 'pricing.html');
- break;
- default:
- res.writeHead(404, {'Content-Type': 'text/html'});
- res.end('404 Not Found
');
- return;
+ const pathname = req.url.split('?')[0];
+ const page = PAGES[pathname];
+
+ if (!page) {
+ res.writeHead(404, {'Content-Type': 'text/html'});
+ res.end('404 Not Found
');
+ return;
}
- fs.readFile(filePath, 'utf8', (err, data) => {
- if (err) {
- res.writeHead(500, {'Content-Type': 'text/plain'});
- res.end('Internal Server Error');
- return;
- }
- res.writeHead(200, {'Content-Type': 'text/html; charset=utf-8'});
- res.end(data);
- });
+ res.writeHead(200, {'Content-Type': 'text/html; charset=utf-8'});
+ res.end(renderPage(page));
});
server.listen(PORT, () => {
diff --git a/demo/cloudflare-workers/src/index.ts b/demo/cloudflare-workers/src/index.ts
index c538e498..01047842 100644
--- a/demo/cloudflare-workers/src/index.ts
+++ b/demo/cloudflare-workers/src/index.ts
@@ -43,17 +43,15 @@ interface Env {
// The staging project's locations match on a "location" property, not URL path.
// This mirrors the pattern used by the Node.js demo.
-const ROUTE_LOCATION_MAP: Record = {
- '/events': 'events',
- '/statistics': 'statistics',
- '/pricing': 'pricing'
+// Route configuration — maps URL paths to location properties and feature keys.
+// The staging project's locations match on a "location" property, not URL path.
+// This mirrors the pattern used by the Node.js demo. [ConvertSDK]
+const ROUTES: Record = {
+ '/events': {location: 'events', experienceKey: 'test-experience-ab-fullstack-1'},
+ '/statistics': {location: 'statistics', featureKey: 'feature-4'},
+ '/pricing': {location: 'pricing', featureKey: 'feature-5'}
};
-// Experience and feature keys from the staging project [ConvertSDK]
-const EXPERIENCE_KEY = 'test-experience-ab-fullstack-1';
-const FEATURE_KEY_STATISTICS = 'feature-4'; // [ConvertSDK]
-const FEATURE_KEY_PRICING = 'feature-5'; // [ConvertSDK]
-
// ---------------------------------------------------------------------------
// Origin fetch helper
// ---------------------------------------------------------------------------
@@ -132,9 +130,9 @@ export default {
return fetchOrigin(request, env);
}
- // Check if this route has a location mapping for experiments
- const location = ROUTE_LOCATION_MAP[url.pathname];
- if (!location) {
+ // Check if this route has experiment configuration
+ const route = ROUTES[url.pathname];
+ if (!route) {
// Home page or unknown route — serve origin unmodified with visitor cookie
const response = await fetchOrigin(request, env);
const headers = new Headers(response.headers);
@@ -171,8 +169,7 @@ export default {
context.setDefaultSegments({country: 'US'});
// 4. Run experiments based on route
- const locationProperties = {location};
- const decisions = decideForRoute(context, url.pathname, locationProperties);
+ const decisions = decideForRoute(context, route);
// 5. Fetch the origin page
const originResponse = await fetchOrigin(request, env);
@@ -214,7 +211,6 @@ interface RouteDecisions {
variation: BucketedVariation | null;
variations: BucketedVariation[];
feature: any;
- route: string;
}
/**
@@ -223,65 +219,40 @@ interface RouteDecisions {
*/
function decideForRoute(
context: any,
- pathname: string,
- locationProperties: {location: string}
+ route: {location: string; experienceKey?: string; featureKey?: string}
): RouteDecisions {
const decisions: RouteDecisions = {
variation: null,
variations: [],
- feature: null,
- route: pathname
+ feature: null
};
+ const locationProperties = {location: route.location};
- switch (pathname) {
- case '/events': {
- // Run a single experience (like Node.js events route)
- const bucketed = context.runExperience(EXPERIENCE_KEY, {
- locationProperties
- });
- console.log('bucketed variation:', bucketed);
- if (bucketed && typeof bucketed !== 'string') {
- decisions.variation = bucketed;
- }
- break;
+ if (route.experienceKey) {
+ // Single experience (like the Node.js events route)
+ const bucketed = context.runExperience(route.experienceKey, {
+ locationProperties
+ });
+ console.log('bucketed variation:', bucketed);
+ if (bucketed && typeof bucketed !== 'string') {
+ decisions.variation = bucketed;
}
-
- case '/statistics': {
- // Run all matching experiences + feature-4 (like Node.js statistics route)
- const bucketedAll = context.runExperiences({locationProperties});
- console.log('bucketed variation(s):', bucketedAll);
- if (Array.isArray(bucketedAll)) {
- decisions.variations = bucketedAll.filter(
- (v: any) => v && typeof v !== 'string'
- );
- }
- const feature = context.runFeature(FEATURE_KEY_STATISTICS, {
- locationProperties
- });
- console.log('bucketed feature:', feature);
- if (feature && feature.status === 'enabled') {
- decisions.feature = feature;
- }
- break;
+ } else {
+ // All matching experiences (like the Node.js statistics/pricing routes)
+ const bucketedAll = context.runExperiences({locationProperties});
+ console.log('bucketed variation(s):', bucketedAll);
+ if (Array.isArray(bucketedAll)) {
+ decisions.variations = bucketedAll.filter(
+ (v: any) => v && typeof v !== 'string'
+ );
}
+ }
- case '/pricing': {
- // Run all matching experiences + feature-5 (like Node.js pricing route)
- const bucketedAll = context.runExperiences({locationProperties});
- console.log('bucketed variation(s):', bucketedAll);
- if (Array.isArray(bucketedAll)) {
- decisions.variations = bucketedAll.filter(
- (v: any) => v && typeof v !== 'string'
- );
- }
- const feature = context.runFeature(FEATURE_KEY_PRICING, {
- locationProperties
- });
- console.log('bucketed feature:', feature);
- if (feature && feature.status === 'enabled') {
- decisions.feature = feature;
- }
- break;
+ if (route.featureKey) {
+ const feature = context.runFeature(route.featureKey, {locationProperties});
+ console.log('bucketed feature:', feature);
+ if (feature && feature.status === 'enabled') {
+ decisions.feature = feature;
}
}