diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index 03105313e..b753256e3 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -20,7 +20,8 @@ steps: - PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 HUSKY=0 yarn install --immutable - echo "+++ Run tests" - yarn constraints - - yarn run test:scripts + - yarn scripts test + - yarn scripts lint - yarn run test:check-dts plugins: - ssh://git@github.com/segmentio/cache-buildkite-plugin#v2.0.0: @@ -98,6 +99,21 @@ steps: key: "v1.1-cache-dev-{{ checksum 'yarn.lock' }}" paths: ['.yarn/cache/'] + - label: '[Generic Utils] Lint + Test' + agents: + queue: v1 + commands: + - npm config set "//registry.npmjs.org/:_authToken" $${NPM_TOKEN} + - echo "--- Install dependencies" + - PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 HUSKY=0 yarn install --immutable + - echo "+++ Run QA" + - yarn turbo run --filter=analytics-generic-utils lint + - yarn turbo run --filter=analytics-generic-utils test + plugins: + - ssh://git@github.com/segmentio/cache-buildkite-plugin#v2.0.0: + key: "v1.1-cache-dev-{{ checksum 'yarn.lock' }}" + paths: ['.yarn/cache/'] + - label: '[Consent] Lint + Test' agents: queue: v1 @@ -148,8 +164,8 @@ steps: key: "v1.1-cache-dev-{{ checksum 'yarn.lock' }}" paths: ['.yarn/cache/'] - - label: '[Examples] Lint + Test :hammer:' - key: examples + - label: '[Playgrounds] Lint + Test :hammer:' + key: playgrounds agents: queue: v1 commands: @@ -157,9 +173,9 @@ steps: - echo "--- Install dependencies" - PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 HUSKY=0 yarn install --immutable - echo "--- Build bundles" - - yarn turbo run --filter='./examples/*' build + - yarn turbo run --filter='./playgrounds/*' build - echo "+++ Run tests" - - yarn turbo run --filter='./examples/*' lint + - yarn turbo run --filter='./playgrounds/*' lint plugins: - ssh://git@github.com/segmentio/cache-buildkite-plugin#v2.0.0: key: "v1.1-cache-dev-{{ checksum 'yarn.lock' }}" diff --git a/.changeset/config.json b/.changeset/config.json index e656a5735..29819e3d6 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -1,6 +1,6 @@ { "$schema": "https://unpkg.com/@changesets/config@1.6.0/schema.json", - "ignore": ["@example/*"], + "ignore": ["@playground/*"], "changelog": [ "@changesets/changelog-github", { diff --git a/.eslintrc.js b/.eslintrc.js index b52fb4eb5..380e87742 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -19,7 +19,7 @@ module.exports = { project: [ './tsconfig.json', './packages/*/tsconfig.json', - './examples/*/tsconfig.json', + './playgrounds/*/tsconfig.json', ], }, extends: [ @@ -61,11 +61,30 @@ module.exports = { 'jest/no-focused-tests': process.env.CI ? 'error' : 'off', }, overrides: [ + { + files: ['**/src/**'], + rules: { + '@typescript-eslint/no-restricted-imports': [ + 'error', + { + paths: [ + { + // Prevent accidental imports from 'lodash' + name: 'lodash', + message: + 'Lodash should only be used for dev-related things, and not in any public library src code (e.g. @segment/analytics-next)', + }, + ], + }, + ], + }, + }, { files: ['**/__tests__/**', '**/scripts/**'], rules: { 'require-await': 'off', '@typescript-eslint/require-await': 'off', + '@typescript-eslint/no-restricted-imports': 'off', }, }, ], diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6bd3821a8..e27e2ab49 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,17 +6,17 @@ env: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 jobs: analytics-node: - name: "analytics-node QA (Node.js v${{ matrix.node-version }})" + name: 'analytics-node QA (Node.js v${{ matrix.node-version }})' runs-on: ubuntu-latest strategy: matrix: - node-version: [ 14, 16, 18 ] + node-version: [18] steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: node-version: ${{ matrix.node-version }} - cache: "yarn" + cache: 'yarn' - run: yarn install --immutable - name: Turbo cache uses: actions/cache@v3 @@ -27,6 +27,28 @@ jobs: ${{ runner.os }}-turbo- - run: yarn turbo run --filter='./packages/node*' lint - run: yarn turbo run --filter='./packages/node*' test + - run: yarn turbo run --filter='./packages/node-integration-tests' test:perf-and-durability + analytics-node-cf-workers: + name: 'analytics-node QA (Cloudflare Workers)' + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [18] + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + cache: 'yarn' + - run: yarn install --immutable + - name: Turbo cache + uses: actions/cache@v3 + with: + path: node_modules/.cache/turbo + key: ${{ runner.os }}-turbo-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-turbo- + - run: yarn turbo run --filter='./packages/node-integration-tests' test:cloudflare-workers consent-intg-tests: name: Consent Integration Tests runs-on: ubuntu-latest @@ -37,7 +59,7 @@ jobs: - uses: actions/setup-node@v3 with: node-version: 16 - cache: "yarn" + cache: 'yarn' - run: yarn install --immutable - name: Turbo cache uses: actions/cache@v3 @@ -47,6 +69,3 @@ jobs: restore-keys: | ${{ runner.os }}-turbo- - run: yarn turbo run --filter='consent-tools-integration-tests' test:int - - - diff --git a/.github/workflows/create-github-release.yml b/.github/workflows/create-github-release.yml index 870bc6347..5a6957951 100644 --- a/.github/workflows/create-github-release.yml +++ b/.github/workflows/create-github-release.yml @@ -24,4 +24,4 @@ jobs: run: | git config --global user.name "Segment Github" git config --global user.email "github-actions@segment.com" - yarn ts-node-script --files scripts/create-release-from-tags/run.ts + yarn scripts create-release-from-tags diff --git a/.vscode/settings.json b/.vscode/settings.json index 21dcfc3c9..6d5f56539 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -29,4 +29,10 @@ "[typescriptreact]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, + "editor.formatOnSave": false, + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit" + }, + "eslint.codeActionsOnSave.mode": "all", + "eslint.format.enable": true, } diff --git a/README.md b/README.md index ce4ef60fc..e5ee0655e 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,7 @@ +## πŸŽ‰ Flagship πŸŽ‰ +This library is one of Segment’s most popular Flagship libraries. It is actively maintained by Segment, benefitting from new feature releases and ongoing support. + +
diff --git a/constraints.pro b/constraints.pro index 8dc1175c2..8106e7cba 100644 --- a/constraints.pro +++ b/constraints.pro @@ -24,7 +24,7 @@ gen_enforced_dependency(WorkspaceCwd, DependencyIdent, DependencyRange2, Depende DependencyType2 \= 'peerDependencies', % A list of exception to same version rule \+ member(DependencyIdent, [ - % Allow examples to use different versions of react and + % Allow playgrounds to use different versions of react and 'react', 'react-dom', '@types/react', % Allow the usage of workspace^ -- there is a better way to do this =) diff --git a/examples/with-next-js/README.md b/examples/with-next-js/README.md deleted file mode 100644 index b9f3fc718..000000000 --- a/examples/with-next-js/README.md +++ /dev/null @@ -1,9 +0,0 @@ -## Getting Started - -First, run the development server: - -```bash -yarn dev -``` - -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. diff --git a/jest.config.js b/jest.config.js index e03283868..a5aa55afe 100644 --- a/jest.config.js +++ b/jest.config.js @@ -13,7 +13,9 @@ module.exports = () => '/packages/core-integration-tests', '/packages/node', '/packages/browser', + '/packages/generic-utils', '/packages/consent/consent-tools', '/packages/consent/consent-wrapper-onetrust', + '/scripts', ], }) diff --git a/package.json b/package.json index 64dee97c1..8cffce7bd 100644 --- a/package.json +++ b/package.json @@ -3,22 +3,22 @@ "private": true, "version": "0.0.0", "workspaces": [ - "examples/*", + "playgrounds/*", "packages/*", - "packages/consent/*" + "packages/consent/*", + "scripts" ], "engines": { "node": "^16.16.0" }, "scripts": { "test": "jest", - "test:scripts": "jest --config scripts/jest.config.js", "test:check-dts": "yarn build && yarn ts-node meta-tests/check-dts.ts", "test:node-int": "turbo run --filter=node-integration-tests test", "lint": "yarn constraints && turbo run lint --continue", "build": "turbo run build --filter='./packages/**'", "watch": "turbo run watch --filter='./packages/**'", - "dev": "yarn workspace @example/with-next-js run dev", + "dev": "yarn workspace @playground/next-playground run dev", "postinstall": "husky install", "changeset": "changeset", "update-versions-and-changelogs": "changeset version && yarn version-run-all && bash scripts/update-lockfile.sh", @@ -27,6 +27,7 @@ "core": "yarn workspace @segment/analytics-core", "browser": "yarn workspace @segment/analytics-next", "node": "yarn workspace @segment/analytics-node", + "scripts": "yarn workspace @internal/scripts", "clean": "bash scripts/clean.sh", "turbo": "turbo" }, @@ -34,9 +35,9 @@ "devDependencies": { "@changesets/changelog-github": "^0.4.5", "@changesets/cli": "^2.23.2", - "@npmcli/promise-spawn": "^3.0.0", + "@npmcli/promise-spawn": "^7.0.0", "@types/express": "4", - "@types/jest": "^28.1.1", + "@types/jest": "^29.5.11", "@types/lodash": "^4", "@types/node-fetch": "^2.6.2", "@typescript-eslint/eslint-plugin": "^5.21.0", @@ -50,15 +51,15 @@ "express": "^4.18.2", "get-monorepo-packages": "^1.2.0", "husky": "^8.0.0", - "jest": "^28.1.0", + "jest": "^29.7.0", "lint-staged": "^13.0.0", "lodash": "^4.17.21", "nock": "^13.3.0", "node-gyp": "^9.0.0", "prettier": "^2.6.2", - "ts-jest": "^28.0.4", + "ts-jest": "^29.1.1", "ts-node": "^10.8.0", - "turbo": "^1.3.1", + "turbo": "^1.10.14", "typescript": "^4.7.0", "webpack": "^5.76.0", "webpack-dev-server": "^4.15.1" diff --git a/packages/browser-integration-tests/src/custom-global-key.test.ts b/packages/browser-integration-tests/src/custom-global-key.test.ts new file mode 100644 index 000000000..5e1ed6187 --- /dev/null +++ b/packages/browser-integration-tests/src/custom-global-key.test.ts @@ -0,0 +1,54 @@ +import { test, expect } from '@playwright/test' +import { standaloneMock } from './helpers/standalone-mock' +import { extractWriteKeyFromUrl } from './helpers/extract-writekey' +import { CDNSettingsBuilder } from '@internal/test-helpers' + +test.describe('Segment with custom global key', () => { + test.beforeEach(standaloneMock) + test.beforeEach(async ({ context }) => { + await context.route( + 'https://cdn.segment.com/v1/projects/*/settings', + (route, request) => { + if (request.method().toLowerCase() !== 'get') { + return route.continue() + } + + const writeKey = extractWriteKeyFromUrl(request.url()) || 'writeKey' + return route.fulfill({ + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(new CDNSettingsBuilder({ writeKey }).build()), + }) + } + ) + }) + + test('supports using a custom global key', async ({ page }) => { + // Load analytics.js + await page.goto('/standalone-custom-key.html') + await page.evaluate(() => { + ;(window as any).segment_analytics.track('track before load') + ;(window as any).segment_analytics.load('fake-key') + }) + + const req = await page.waitForRequest('https://api.segment.io/v1/t') + + // confirm that any events triggered before load have been sent + expect(req.postDataJSON().event).toBe('track before load') + + const contextObj = await page.evaluate(() => + (window as any).segment_analytics.track('track after load') + ) + + // confirm that any events triggered after load return a regular context object + expect(contextObj).toMatchObject( + expect.objectContaining({ + attempts: expect.anything(), + event: expect.objectContaining({ event: 'track after load' }), + stats: expect.objectContaining({ metrics: expect.anything() }), + }) + ) + }) +}) diff --git a/packages/browser-integration-tests/standalone-custom-key.html b/packages/browser-integration-tests/standalone-custom-key.html new file mode 100644 index 000000000..f8d01f773 --- /dev/null +++ b/packages/browser-integration-tests/standalone-custom-key.html @@ -0,0 +1,94 @@ + + + + + + + diff --git a/packages/browser/CHANGELOG.md b/packages/browser/CHANGELOG.md index 954808360..ce1088627 100644 --- a/packages/browser/CHANGELOG.md +++ b/packages/browser/CHANGELOG.md @@ -1,5 +1,103 @@ # @segment/analytics-next +## 1.66.0 + +### Minor Changes + +- [#1037](https://github.com/segmentio/analytics-next/pull/1037) [`e435279`](https://github.com/segmentio/analytics-next/commit/e4352792ed5e58a95009a28d83abb8cfea308a82) Thanks [@danieljackins](https://github.com/danieljackins)! - Allow custom metrics endpoint on load + +## 1.65.0 + +### Minor Changes + +- [#945](https://github.com/segmentio/analytics-next/pull/945) [`d212633`](https://github.com/segmentio/analytics-next/commit/d21263369d5980f4f57b13795524dbc345a02e5c) Thanks [@zikaari](https://github.com/zikaari)! - Load destinations lazily and start sending events as each becomes available instead of waiting for all to load first + +### Patch Changes + +- [#1036](https://github.com/segmentio/analytics-next/pull/1036) [`f65c131`](https://github.com/segmentio/analytics-next/commit/f65c131a62f979b6629b086b3eb9cd9b3ffefe31) Thanks [@danieljackins](https://github.com/danieljackins)! - Fix schema-filter bug + +- Updated dependencies [[`95fd2fd`](https://github.com/segmentio/analytics-next/commit/95fd2fd801da26505ddcead96ffaa83aa4364994), [`d212633`](https://github.com/segmentio/analytics-next/commit/d21263369d5980f4f57b13795524dbc345a02e5c)]: + - @segment/analytics-core@1.5.0 + - @segment/analytics-generic-utils@1.2.0 + +## 1.64.0 + +### Minor Changes + +- [#1032](https://github.com/segmentio/analytics-next/pull/1032) [`5c1511f`](https://github.com/segmentio/analytics-next/commit/5c1511fe1e1d1df94967623b29ec12ffe770aacf) Thanks [@zikaari](https://github.com/zikaari)! - Support loading analytics into a custom global variable when using snippet version 5.2.1 or later + +## 1.63.0 + +### Minor Changes + +- [#1008](https://github.com/segmentio/analytics-next/pull/1008) [`e57960e`](https://github.com/segmentio/analytics-next/commit/e57960e84f5ce5b214dde09928bee6e6bdba3a69) Thanks [@danieljackins](https://github.com/danieljackins)! - Change segmentio to destination type + +* [#1023](https://github.com/segmentio/analytics-next/pull/1023) [`b5b929e`](https://github.com/segmentio/analytics-next/commit/b5b929ea432198ae6aecb2b03ea2194972bcc029) Thanks [@silesky](https://github.com/silesky)! - Deprecate AnalyticsNode class (in favor of the standalone @segment/analytics-node) + +## 1.62.1 + +### Patch Changes + +- [#1009](https://github.com/segmentio/analytics-next/pull/1009) [`f476038`](https://github.com/segmentio/analytics-next/commit/f47603881b787cc81fa1da4496bdbde9eb325a0f) Thanks [@silesky](https://github.com/silesky)! - If initialPageview is true, capture page context as early as possible + +- Updated dependencies [[`7b93e7b`](https://github.com/segmentio/analytics-next/commit/7b93e7b50fa293aebaf6767a44bf7708b231d5cd)]: + - @segment/analytics-generic-utils@1.1.1 + - @segment/analytics-core@1.4.1 + +## 1.62.0 + +### Minor Changes + +- [#992](https://github.com/segmentio/analytics-next/pull/992) [`a72f473`](https://github.com/segmentio/analytics-next/commit/a72f4736a743e6a6487fd7b5c764639402f9e7ba) Thanks [@silesky](https://github.com/silesky)! - Add 'disable' boolean option to allow for disabling Segment in a testing environment. + +### Patch Changes + +- [#1001](https://github.com/segmentio/analytics-next/pull/1001) [`57be1ac`](https://github.com/segmentio/analytics-next/commit/57be1acd556a9779edbc5fd4d3f820fb50b65697) Thanks [@silesky](https://github.com/silesky)! - add hasUnmappedDestinations property to types + +- Updated dependencies [[`d9b47c4`](https://github.com/segmentio/analytics-next/commit/d9b47c43e5e08efce14fe4150536ff60b8df91e0), [`d9b47c4`](https://github.com/segmentio/analytics-next/commit/d9b47c43e5e08efce14fe4150536ff60b8df91e0)]: + - @segment/analytics-core@1.4.0 + - @segment/analytics-generic-utils@1.1.0 + +## 1.61.0 + +### Minor Changes + +- [#985](https://github.com/segmentio/analytics-next/pull/985) [`083f9a1`](https://github.com/segmentio/analytics-next/commit/083f9a18e2cde4132cd31a61d76f26d07c35cad9) Thanks [@zikaari](https://github.com/zikaari)! - Update integration metrics capturing strategy + +## 1.60.0 + +### Minor Changes + +- [#989](https://github.com/segmentio/analytics-next/pull/989) [`1faabf1`](https://github.com/segmentio/analytics-next/commit/1faabf1f51de63423f8995adf837137ab2d9d800) Thanks [@silesky](https://github.com/silesky)! - Change default retries to 10 to match docs + ajs classic + +## 1.59.0 + +### Minor Changes + +- [#971](https://github.com/segmentio/analytics-next/pull/971) [`2f1ae75`](https://github.com/segmentio/analytics-next/commit/2f1ae75896123e0aeaa1608fde15312adddd5614) Thanks [@zikaari](https://github.com/zikaari)! - Capture action plugin metrics + +### Patch Changes + +- [#950](https://github.com/segmentio/analytics-next/pull/950) [`c0dadc7`](https://github.com/segmentio/analytics-next/commit/c0dadc759dccd88c6d95d14fcf7732fad2b051a1) Thanks [@oscb](https://github.com/oscb)! - Fixes calls to .identify() with null as id + +## 1.58.0 + +### Minor Changes + +- [#852](https://github.com/segmentio/analytics-next/pull/852) [`897f4cc`](https://github.com/segmentio/analytics-next/commit/897f4cc69de4cdd38efd0cd70567bfed0c454fec) Thanks [@silesky](https://github.com/silesky)! - - Capture page context information faster, so context.campaign and context.page are more resilient to quick navigation changes. + - Parse UTM params into context.campaign if users pass an object to a page call. + +### Patch Changes + +- Updated dependencies [[`897f4cc`](https://github.com/segmentio/analytics-next/commit/897f4cc69de4cdd38efd0cd70567bfed0c454fec)]: + - @segment/analytics-core@1.3.2 + +## 1.57.0 + +### Minor Changes + +- [#956](https://github.com/segmentio/analytics-next/pull/956) [`f5cdb82`](https://github.com/segmentio/analytics-next/commit/f5cdb824050c22a9aaa86a450b8f1f4a7f4fb144) Thanks [@danieljackins](https://github.com/danieljackins)! - Set timezone and allow userAgentData to be overridden + ## 1.56.0 ### Minor Changes diff --git a/packages/browser/Makefile b/packages/browser/Makefile index 530e5bb49..f55ee1770 100644 --- a/packages/browser/Makefile +++ b/packages/browser/Makefile @@ -93,5 +93,5 @@ analyze: .PHONY: analyze dev: ## Starts a dev server that is ready for development - yarn workspace @example/with-next-js run dev + yarn workspace @playground/next-playground run dev .PHONY: dev diff --git a/packages/browser/README.md b/packages/browser/README.md index 262bb7c09..658b15eb4 100644 --- a/packages/browser/README.md +++ b/packages/browser/README.md @@ -86,8 +86,13 @@ analytics [Self Hosting or Proxying Analytics.js documentation]( https://segment.com/docs/connections/sources/catalog/libraries/website/javascript/custom-proxy/#custom-cdn--api-proxy) -## Usage in Common Frameworks -### React +## Examples / Usage in Common Frameworks and SPAs + +### Next.js +- https://github.com/vercel/next.js/tree/canary/examples/with-segment-analytics +- https://github.com/vercel/next.js/tree/canary/examples/with-segment-analytics-pages-router + +### Vanilla React ```tsx import { AnalyticsBrowser } from '@segment/analytics-next' @@ -101,12 +106,9 @@ const App = () => ( ) ``` -More React Examples: -> Warning ⚠️ Some of these examples may be overly-complex for your use case, we recommend the simple approach outlined above. -- Our [playground](/examples/with-next-js/) (written in NextJS) -- this can be run with `yarn dev`. -- Complex [React example repo](https://github.com/segmentio/react-example/) which outlines using the [Segment snippet](https://github.com/segmentio/react-example/tree/main/src/examples/analytics-quick-start) and using the [Segment npm package](https://github.com/segmentio/react-example/tree/main/src/examples/analytics-package). -### `Vue` + +### Vue 1. Export analytics instance. @@ -146,9 +148,9 @@ export default defineComponent({ ## Support for Web Workers (Experimental) While this package does not support web workers out of the box, there are options: -1. Run analytics.js in a web worker via [partytown.io](https://partytown.builder.io/). See [our partytown example](../../examples/with-next-js/pages/partytown). **Supports both cloud and device mode destinations, but not all device mode destinations may work.** +1. Run analytics.js in a web worker via [partytown.io](https://partytown.builder.io/). See [our partytown example](../../playgrounds/next-playground/pages/partytown). **Supports both cloud and device mode destinations, but not all device mode destinations may work.** -2. Try [@segment/analytics-node](../node) with `maxEventsInBatch: 1`, which should work in any runtime where `fetch` is available. **Warning: cloud destinations only!** +2. Try [@segment/analytics-node](../node) with `flushAt: 1`, which should work in any runtime where `fetch` is available. **Warning: cloud destinations only!** diff --git a/packages/browser/package.json b/packages/browser/package.json index d33f61f9a..c7efcd901 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -1,6 +1,6 @@ { "name": "@segment/analytics-next", - "version": "1.56.0", + "version": "1.66.0", "repository": { "type": "git", "url": "https://github.com/segmentio/analytics-next", @@ -24,7 +24,7 @@ ], "sideEffects": false, "scripts": { - ".": "yarn run -T turbo run --filter=@segment/analytics-next", + ".": "yarn run -T turbo run --filter=@segment/analytics-next...", "build-prep": "sh scripts/build-prep.sh", "version": "yarn run build-prep && git add src/generated/version.ts", "umd": "webpack", @@ -44,12 +44,13 @@ "size-limit": [ { "path": "dist/umd/index.js", - "limit": "28.5 KB" + "limit": "29.6 KB" } ], "dependencies": { "@lukeed/uuid": "^2.0.0", - "@segment/analytics-core": "1.3.1", + "@segment/analytics-core": "1.5.0", + "@segment/analytics-generic-utils": "1.2.0", "@segment/analytics.js-video-plugins": "^0.2.1", "@segment/facade": "^3.4.9", "@segment/tsub": "^2.0.0", diff --git a/packages/browser/qa/__tests__/backwards-compatibility.test.ts b/packages/browser/qa/__tests__/backwards-compatibility.test.ts index 644e0c2af..9b2ceb007 100644 --- a/packages/browser/qa/__tests__/backwards-compatibility.test.ts +++ b/packages/browser/qa/__tests__/backwards-compatibility.test.ts @@ -144,17 +144,17 @@ describe('Backwards compatibility', () => { expect(next['identify']).toEqual(classic['identify']) expect(classic['page']).toMatchInlineSnapshot(` - Array [ - Object { + [ + { "path": "/", "referrer": "", "search": "?type=classic&wk=D8frB7upBChqDN9PMWksNvZYDaKJIYo6", "title": "", "url": "http://localhost:4000/?type=classic&wk=D8frB7upBChqDN9PMWksNvZYDaKJIYo6", }, - Object { - "context": Object { - "page": Object { + { + "context": { + "page": { "path": "/", "referrer": "", "search": "?type=classic&wk=D8frB7upBChqDN9PMWksNvZYDaKJIYo6", diff --git a/packages/browser/qa/lib/runner.ts b/packages/browser/qa/lib/runner.ts index bac33a49a..2ff502fe4 100644 --- a/packages/browser/qa/lib/runner.ts +++ b/packages/browser/qa/lib/runner.ts @@ -94,7 +94,10 @@ export async function run(params: ComparisonParams) { await page.goto(url) await page.waitForLoadState('networkidle') - await page.waitForFunction(`window.analytics.initialized === true`) + await page.waitForFunction( + `window.analytics.initialized === true`, + undefined + ) // This forces every timestamp to look exactly the same. // Moving this prototype manipulation after networkidle fixed a race condition around Object.freeze that interfered with certain scripts. diff --git a/packages/browser/src/browser/__tests__/analytics-lazy-init.integration.test.ts b/packages/browser/src/browser/__tests__/analytics-lazy-init.integration.test.ts index 526a7a972..8cc373708 100644 --- a/packages/browser/src/browser/__tests__/analytics-lazy-init.integration.test.ts +++ b/packages/browser/src/browser/__tests__/analytics-lazy-init.integration.test.ts @@ -1,8 +1,13 @@ -import { sleep } from '@segment/analytics-core' +import { CorePlugin, PluginType, sleep } from '@segment/analytics-core' +import { getBufferedPageCtxFixture } from '../../test-helpers/fixtures' import unfetch from 'unfetch' import { AnalyticsBrowser } from '..' import { Analytics } from '../../core/analytics' import { createSuccess } from '../../test-helpers/factories' +import { createDeferred } from '@segment/analytics-generic-utils' +import { PluginFactory } from '../../plugins/remote-loader' + +const nextTickP = () => new Promise((r) => setTimeout(r, 0)) jest.mock('unfetch') @@ -27,7 +32,7 @@ describe('Lazy initialization', () => { expect(trackSpy).not.toBeCalled() analytics.load({ writeKey: 'abc' }) await track - expect(trackSpy).toBeCalledWith('foo') + expect(trackSpy).toBeCalledWith('foo', getBufferedPageCtxFixture()) }) it('.load method return an analytics instance', async () => { @@ -47,3 +52,139 @@ describe('Lazy initialization', () => { ) }) }) + +const createTestPluginFactory = (name: string, type: PluginType) => { + const lock = createDeferred() + const load = createDeferred() + const trackSpy = jest.fn() + + const factory: PluginFactory = () => { + return { + name, + type, + version: '1.0.0', + load: jest + .fn() + .mockImplementation(() => lock.promise.then(() => load.resolve())), + isLoaded: () => lock.isSettled(), + track: trackSpy, + } + } + + factory.pluginName = name + + return { + loadingGuard: lock, + trackSpy, + factory, + loadPromise: load.promise, + } +} + +describe('Lazy destination loading', () => { + beforeEach(() => { + jest.mock('unfetch') + jest.mocked(unfetch).mockImplementation(() => + createSuccess({ + integrations: {}, + remotePlugins: [ + { + name: 'braze', + libraryName: 'braze', + }, + { + name: 'google', + libraryName: 'google', + }, + ], + }) + ) + }) + + afterAll(() => jest.resetAllMocks()) + + it('loads destinations in the background', async () => { + const testEnrichmentHarness = createTestPluginFactory( + 'enrichIt', + 'enrichment' + ) + const dest1Harness = createTestPluginFactory('braze', 'destination') + const dest2Harness = createTestPluginFactory('google', 'destination') + + const analytics = new AnalyticsBrowser() + + const testEnrichmentPlugin = testEnrichmentHarness.factory( + null + ) as CorePlugin + + analytics.register(testEnrichmentPlugin).catch(() => {}) + + await analytics.load({ + writeKey: 'abc', + plugins: [dest1Harness.factory, dest2Harness.factory], + }) + + // we won't hold enrichment plugin from loading since they are not lazy loaded + testEnrichmentHarness.loadingGuard.resolve() + // and we'll also let one destination load so we can assert some behaviours + dest1Harness.loadingGuard.resolve() + + await testEnrichmentHarness.loadPromise + await dest1Harness.loadPromise + + analytics.track('test event 1').catch(() => {}) + + // even though there's one destination that still hasn't loaded, the next assertions + // prove that the event pipeline is flowing regardless + + await nextTickP() + expect(testEnrichmentHarness.trackSpy).toHaveBeenCalledTimes(1) + + await nextTickP() + expect(dest1Harness.trackSpy).toHaveBeenCalledTimes(1) + + // now we'll send another event + + analytics.track('test event 2').catch(() => {}) + + // even though there's one destination that still hasn't loaded, the next assertions + // prove that the event pipeline is flowing regardless + + await nextTickP() + expect(testEnrichmentHarness.trackSpy).toHaveBeenCalledTimes(2) + + await nextTickP() + expect(dest1Harness.trackSpy).toHaveBeenCalledTimes(2) + + // this whole time the other destination was not engaged with at all + expect(dest2Harness.trackSpy).not.toHaveBeenCalled() + + // now "after some time" the other destination will load + dest2Harness.loadingGuard.resolve() + await dest2Harness.loadPromise + + // and now that it is "online" - the previous events that it missed will be handed over + await nextTickP() + expect(dest2Harness.trackSpy).toHaveBeenCalledTimes(2) + }) + + it('emits initialize regardless of whether all destinations have loaded', async () => { + const dest1Harness = createTestPluginFactory('braze', 'destination') + const dest2Harness = createTestPluginFactory('google', 'destination') + + const analytics = new AnalyticsBrowser() + + let initializeEmitted = false + + analytics.on('initialize', () => { + initializeEmitted = true + }) + + await analytics.load({ + writeKey: 'abc', + plugins: [dest1Harness.factory, dest2Harness.factory], + }) + + expect(initializeEmitted).toBe(true) + }) +}) diff --git a/packages/browser/src/browser/__tests__/analytics-pre-init.integration.test.ts b/packages/browser/src/browser/__tests__/analytics-pre-init.integration.test.ts index 10384fca6..74952b455 100644 --- a/packages/browser/src/browser/__tests__/analytics-pre-init.integration.test.ts +++ b/packages/browser/src/browser/__tests__/analytics-pre-init.integration.test.ts @@ -6,6 +6,7 @@ import * as Factory from '../../test-helpers/factories' import { sleep } from '../../lib/sleep' import { setGlobalCDNUrl } from '../../lib/parse-cdn' import { User } from '../../core/user' +import { getBufferedPageCtxFixture } from '../../test-helpers/fixtures' jest.mock('unfetch') @@ -61,7 +62,11 @@ describe('Pre-initialization', () => { const trackCtxPromise = ajsBrowser.track('foo', { name: 'john' }) const result = await trackCtxPromise expect(result).toBeInstanceOf(Context) - expect(trackSpy).toBeCalledWith('foo', { name: 'john' }) + expect(trackSpy).toBeCalledWith( + 'foo', + { name: 'john' }, + getBufferedPageCtxFixture() + ) expect(trackSpy).toBeCalledTimes(1) }) @@ -107,11 +112,19 @@ describe('Pre-initialization', () => { await Promise.all([trackCtxPromise, trackCtxPromise2, identifyCtxPromise]) - expect(trackSpy).toBeCalledWith('foo', { name: 'john' }) - expect(trackSpy).toBeCalledWith('bar', { age: 123 }) + expect(trackSpy).toBeCalledWith( + 'foo', + { name: 'john' }, + getBufferedPageCtxFixture() + ) + expect(trackSpy).toBeCalledWith( + 'bar', + { age: 123 }, + getBufferedPageCtxFixture() + ) expect(trackSpy).toBeCalledTimes(2) - expect(identifySpy).toBeCalledWith('hello') + expect(identifySpy).toBeCalledWith('hello', getBufferedPageCtxFixture()) expect(identifySpy).toBeCalledTimes(1) }) @@ -234,8 +247,8 @@ describe('Pre-initialization', () => { await AnalyticsBrowser.standalone(writeKey) await sleep(100) // the snippet does not return a promise (pre-initialization) ... it sometimes has a callback as the third argument. - expect(trackSpy).toBeCalledWith('foo') - expect(trackSpy).toBeCalledWith('bar') + expect(trackSpy).toBeCalledWith('foo', getBufferedPageCtxFixture()) + expect(trackSpy).toBeCalledWith('bar', getBufferedPageCtxFixture()) expect(trackSpy).toBeCalledTimes(2) expect(identifySpy).toBeCalledTimes(1) @@ -262,11 +275,11 @@ describe('Pre-initialization', () => { await AnalyticsBrowser.standalone(writeKey) await sleep(100) // the snippet does not return a promise (pre-initialization) ... it sometimes has a callback as the third argument. - expect(trackSpy).toBeCalledWith('foo') - expect(trackSpy).toBeCalledWith('bar') + expect(trackSpy).toBeCalledWith('foo', getBufferedPageCtxFixture()) + expect(trackSpy).toBeCalledWith('bar', getBufferedPageCtxFixture()) expect(trackSpy).toBeCalledTimes(2) - expect(identifySpy).toBeCalledWith() + expect(identifySpy).toBeCalledWith(getBufferedPageCtxFixture()) expect(identifySpy).toBeCalledTimes(1) expect(consoleErrorSpy).toBeCalledTimes(1) @@ -292,8 +305,8 @@ describe('Pre-initialization', () => { }) await sleep(100) // the snippet does not return a promise (pre-initialization) ... it sometimes has a callback as the third argument. - expect(trackSpy).toBeCalledWith('foo') - expect(trackSpy).toBeCalledWith('bar') + expect(trackSpy).toBeCalledWith('foo', getBufferedPageCtxFixture()) + expect(trackSpy).toBeCalledWith('bar', getBufferedPageCtxFixture()) expect(trackSpy).toBeCalledTimes(2) expect(identifySpy).toBeCalledTimes(1) diff --git a/packages/browser/src/browser/__tests__/inspector.integration.test.ts b/packages/browser/src/browser/__tests__/inspector.integration.test.ts index c56e6d986..a5b8df713 100644 --- a/packages/browser/src/browser/__tests__/inspector.integration.test.ts +++ b/packages/browser/src/browser/__tests__/inspector.integration.test.ts @@ -55,7 +55,7 @@ describe('Inspector', () => { await deliveryPromise - expect(enrichedFn).toHaveBeenCalledTimes(2) + expect(enrichedFn).toHaveBeenCalledTimes(2) // will be called once for every before or enrichment plugin. expect(deliveredFn).toHaveBeenCalledTimes(1) }) diff --git a/packages/browser/src/browser/__tests__/integration.test.ts b/packages/browser/src/browser/__tests__/integration.test.ts index f66471157..02ec2aee9 100644 --- a/packages/browser/src/browser/__tests__/integration.test.ts +++ b/packages/browser/src/browser/__tests__/integration.test.ts @@ -23,7 +23,8 @@ import { highEntropyTestData, lowEntropyTestData, } from '../../test-helpers/fixtures/client-hints' -import { getGlobalAnalytics } from '../..' +import { getGlobalAnalytics, NullAnalytics } from '../..' +import { recordIntegrationMetric } from '../../core/stats/metric-helpers' let fetchCalls: ReturnType[] = [] @@ -75,6 +76,16 @@ const googleAnalytics: Plugin = { type: 'destination', } +const slowPlugin: Plugin = { + ...xt, + name: 'Slow Plugin', + type: 'destination', + track: async (ctx) => { + await sleep(3000) + return ctx + }, +} + const enrichBilling: Plugin = { ...xt, name: 'Billing Enrichment', @@ -94,11 +105,11 @@ const amplitudeWriteKey = 'bar' beforeEach(() => { setGlobalCDNUrl(undefined as any) + fetchCalls = [] }) describe('Initialization', () => { beforeEach(async () => { - fetchCalls = [] jest.resetAllMocks() jest.resetModules() }) @@ -300,21 +311,17 @@ describe('Initialization', () => { }) }) it('calls page if initialpageview is set', async () => { - jest.mock('../../core/analytics') - const mockPage = jest.fn().mockImplementation(() => Promise.resolve()) - Analytics.prototype.page = mockPage - + const page = jest.spyOn(Analytics.prototype, 'page') await AnalyticsBrowser.load({ writeKey }, { initialPageview: true }) - - expect(mockPage).toHaveBeenCalled() + await sleep(0) // flushed in new task + expect(page).toHaveBeenCalledTimes(1) }) it('does not call page if initialpageview is not set', async () => { - jest.mock('../../core/analytics') - const mockPage = jest.fn() - Analytics.prototype.page = mockPage + const page = jest.spyOn(Analytics.prototype, 'page') await AnalyticsBrowser.load({ writeKey }, { initialPageview: false }) - expect(mockPage).not.toHaveBeenCalled() + await sleep(0) // flush happens async + expect(page).not.toHaveBeenCalled() }) it('does not use a persisted queue when disableClientPersistence is true', async () => { @@ -578,6 +585,34 @@ describe('Dispatch', () => { expect(segmentSpy).toHaveBeenCalledWith(boo) }) + it('dispatching to Segmentio not blocked by other destinations', async () => { + const [ajs] = await AnalyticsBrowser.load({ + writeKey, + plugins: [slowPlugin], + }) + + const segmentio = ajs.queue.plugins.find((p) => p.name === 'Segment.io') + const segmentSpy = jest.spyOn(segmentio!, 'track') + + await Promise.race([ + ajs.track( + 'Boo!', + { + total: 25, + userId: 'πŸ‘»', + }, + { + integrations: { + All: true, + }, + } + ), + sleep(100), + ]) + + expect(segmentSpy).toHaveBeenCalled() + }) + it('enriches events before dispatching', async () => { const [ajs] = await AnalyticsBrowser.load({ writeKey, @@ -589,7 +624,7 @@ describe('Dispatch', () => { }) expect(boo.event.properties).toMatchInlineSnapshot(` - Object { + { "billingPlan": "free-99", "total": 25, } @@ -610,17 +645,54 @@ describe('Dispatch', () => { const metrics = delivered.stats.metrics expect(metrics.map((m) => m.metric)).toMatchInlineSnapshot(` - Array [ + [ "message_dispatched", "plugin_time", "plugin_time", "plugin_time", - "message_delivered", "plugin_time", + "message_delivered", "delivered", ] `) }) + + it('respects api and protocol overrides for metrics endpoint', async () => { + const [ajs] = await AnalyticsBrowser.load( + { + writeKey, + cdnSettings: { + integrations: { + 'Segment.io': { + apiHost: 'cdnSettings.api.io', + }, + }, + metrics: { + flushTimer: 0, + }, + }, + }, + { + integrations: { + 'Segment.io': { + apiHost: 'new.api.io', + protocol: 'http', + }, + }, + } + ) + + const event = await ajs.track('foo') + + recordIntegrationMetric(event, { + integrationName: 'foo', + methodName: 'bar', + type: 'action', + }) + + await sleep(10) + expect(fetchCalls[1].url).toBe('http://new.api.io/m') + }) }) describe('Group', () => { @@ -1126,7 +1198,7 @@ describe('Options', () => { iso: '2020-10-10', }) - const [integrationEvent] = integrationMock.mock.lastCall + const [integrationEvent] = integrationMock.mock.lastCall! expect(integrationEvent.properties()).toEqual({ date: expect.any(Date), @@ -1163,7 +1235,7 @@ describe('Options', () => { iso: '2020-10-10', }) - const [integrationEvent] = integrationMock.mock.lastCall + const [integrationEvent] = integrationMock.mock.lastCall! expect(integrationEvent.properties()).toEqual({ date: expect.any(Date), @@ -1200,7 +1272,7 @@ describe('Options', () => { iso: '2020-10-10', }) - const [integrationEvent] = integrationMock.mock.lastCall + const [integrationEvent] = integrationMock.mock.lastCall! expect(integrationEvent.properties()).toEqual({ date: expect.any(Date), @@ -1209,4 +1281,56 @@ describe('Options', () => { expect(integrationEvent.timestamp()).toBeInstanceOf(Date) }) }) + + describe('disable', () => { + /** + * Note: other tests in null-analytics.test.ts cover the NullAnalytics class (including persistence) + */ + it('should return a null version of analytics / context', async () => { + const [analytics, context] = await AnalyticsBrowser.load( + { + writeKey, + }, + { disable: true } + ) + expect(context).toBeInstanceOf(Context) + expect(analytics).toBeInstanceOf(NullAnalytics) + expect(analytics.initialized).toBe(true) + }) + + it('should not fetch cdn settings or dispatch events', async () => { + const [analytics] = await AnalyticsBrowser.load( + { + writeKey, + }, + { disable: true } + ) + await analytics.track('foo') + expect(fetchCalls.length).toBe(0) + }) + + it('should only accept a boolean value', async () => { + const [analytics] = await AnalyticsBrowser.load( + { + writeKey, + }, + // @ts-ignore + { disable: 'true' } + ) + expect(analytics).not.toBeInstanceOf(NullAnalytics) + }) + + it('should allow access to cdnSettings', async () => { + const disableSpy = jest.fn().mockReturnValue(true) + const [analytics] = await AnalyticsBrowser.load( + { + cdnSettings: { integrations: {}, foo: 123 }, + writeKey, + }, + { disable: disableSpy } + ) + expect(analytics).toBeInstanceOf(NullAnalytics) + expect(disableSpy).toBeCalledWith({ integrations: {}, foo: 123 }) + }) + }) }) diff --git a/packages/browser/src/browser/__tests__/integrations.integration.test.ts b/packages/browser/src/browser/__tests__/integrations.integration.test.ts index a70f12095..12ec75789 100644 --- a/packages/browser/src/browser/__tests__/integrations.integration.test.ts +++ b/packages/browser/src/browser/__tests__/integrations.integration.test.ts @@ -19,6 +19,17 @@ const mockFetchCdnSettings = (cdnSettings: any = {}) => { .mockImplementation(createMockFetchImplementation(cdnSettings)) } +jest.spyOn(console, 'warn').mockImplementation((...errMsgs) => { + if (errMsgs[0].includes('deprecate')) { + // get rid of deprecation wawrning spam + return + } + console.warn( + 'Unexpected console.warn spam in your jest test - please stub out. ' + + JSON.stringify(errMsgs) + ) +}) + describe('Integrations', () => { beforeEach(async () => { mockFetchCdnSettings() @@ -153,11 +164,11 @@ describe('Integrations', () => { await analytics.ready() expect(analytics.Integrations).toMatchInlineSnapshot(` - Object { - "Amplitude": [Function], - "Google-Analytics": [Function], - } - `) + { + "Amplitude": [Function], + "Google-Analytics": [Function], + } + `) }) it('catches destinations with dots in their names', async () => { @@ -194,12 +205,12 @@ describe('Integrations', () => { await analytics.ready() expect(analytics.Integrations).toMatchInlineSnapshot(` - Object { - "Amplitude": [Function], - "Customer.io": [Function], - "Google-Analytics": [Function], - } - `) + { + "Amplitude": [Function], + "Customer.io": [Function], + "Google-Analytics": [Function], + } + `) }) it('uses directly provided classic integrations without fetching them from cdn', async () => { diff --git a/packages/browser/src/browser/__tests__/page-enrichment.integration.test.ts b/packages/browser/src/browser/__tests__/page-enrichment.integration.test.ts new file mode 100644 index 000000000..23aca8285 --- /dev/null +++ b/packages/browser/src/browser/__tests__/page-enrichment.integration.test.ts @@ -0,0 +1,216 @@ +import unfetch from 'unfetch' +import { Analytics, AnalyticsBrowser } from '../..' +import { PageContext } from '../../core/page' +import { + cdnSettingsMinimal, + createMockFetchImplementation, +} from '../../test-helpers/fixtures' + +jest.mock('unfetch') +jest.mocked(unfetch).mockImplementation(createMockFetchImplementation()) + +let ajs: Analytics + +beforeEach(async () => { + const [analytics] = await AnalyticsBrowser.load({ + writeKey: 'abc_123', + cdnSettings: { ...cdnSettingsMinimal }, + }) + ajs = analytics +}) +describe('Page Enrichment', () => { + it('enriches page calls', async () => { + const ctx = await ajs.page('Checkout', {}) + + expect(ctx.event.properties).toMatchInlineSnapshot(` + { + "name": "Checkout", + "path": "/", + "referrer": "", + "search": "", + "title": "", + "url": "http://localhost/", + } + `) + }) + + it('enriches track events with the page context', async () => { + const ctx = await ajs.track('My event', { + banana: 'phone', + }) + expect(ctx.event.context?.page).toMatchInlineSnapshot(` + { + "path": "/", + "referrer": "", + "search": "", + "title": "", + "url": "http://localhost/", + } + `) + }) + + describe('event.properties override behavior', () => { + it('special page properties in event.properties (url, referrer, etc) are copied to context.page', async () => { + const pageProps: PageContext & { [key: string]: unknown } = Object.freeze( + { + path: 'foo', + referrer: 'bar', + search: 'baz', + title: 'qux', + url: 'http://fake.com', + should_not_show_up: 'hello', + } + ) + const ctx = await ajs.page('My Event', pageProps) + const page = ctx.event.context!.page + expect(page).toMatchInlineSnapshot(` + { + "path": "foo", + "referrer": "bar", + "search": "baz", + "title": "qux", + "url": "http://fake.com", + } + `) + }) + + it('special page properties in event.properties (url, referrer, etc) are not copied to context.page in non-page calls', async () => { + const eventProps = Object.freeze({ + path: 'foo', + referrer: 'bar', + search: 'baz', + url: 'http://fake.com', + foo: 'hello', + }) + const ctx = await ajs.track('My Event', eventProps) + expect(ctx.event.properties).toMatchInlineSnapshot(` + { + "foo": "hello", + "path": "foo", + "referrer": "bar", + "search": "baz", + "url": "http://fake.com", + } + `) + expect(ctx.event.context!.page).toMatchInlineSnapshot(` + { + "path": "/", + "referrer": "", + "search": "", + "title": "", + "url": "http://localhost/", + } + `) + }) + + it('page properties should override defaults in page calls', async () => { + const pageProps = Object.freeze({ path: 'override' }) + const ctx = await ajs.page('My Event', pageProps) + const page = ctx.event.context!.page + expect(page).toMatchInlineSnapshot(` + { + "path": "override", + "referrer": "", + "search": "", + "title": "", + "url": "http://localhost/", + } + `) + }) + + it('undefined / null / empty string properties on event get overridden as usual', async () => { + const eventProps = Object.freeze({ + referrer: '', + path: undefined, + title: null, + }) + + const ctx = await ajs.page('My Event', eventProps) + const page = ctx.event.context!.page + expect(page).toEqual( + expect.objectContaining({ referrer: '', path: undefined, title: null }) + ) + }) + }) + + it('enriches page events with the page context', async () => { + const ctx = await ajs.page( + 'My event', + { banana: 'phone' }, + { page: { url: 'not-localhost' } } + ) + + expect(ctx.event.context?.page).toMatchInlineSnapshot(` + { + "path": "/", + "referrer": "", + "search": "", + "title": "", + "url": "not-localhost", + } + `) + }) + it('enriches page events using properties', async () => { + const ctx = await ajs.page('My event', { banana: 'phone', referrer: 'foo' }) + + expect(ctx.event.context?.page).toMatchInlineSnapshot(` + { + "path": "/", + "referrer": "foo", + "search": "", + "title": "", + "url": "http://localhost/", + } + `) + }) + + it('in page events, event.name overrides event.properties.name', async () => { + const ctx = await ajs.page('My Event', undefined, undefined, { + name: 'some propery name', + }) + expect(ctx.event.properties!.name).toBe('My Event') + }) + + it('in non-page events, event.name does not override event.properties.name', async () => { + const ctx = await ajs.track('My Event', { + name: 'some propery name', + }) + expect(ctx.event.properties!.name).toBe('some propery name') + }) + + it('enriches identify events with the page context', async () => { + const ctx = await ajs.identify('Netto', { + banana: 'phone', + }) + + expect(ctx.event.context?.page).toMatchInlineSnapshot(` + { + "path": "/", + "referrer": "", + "search": "", + "title": "", + "url": "http://localhost/", + } + `) + }) + + it('page object is accessible in all plugins', async () => { + await ajs.addSourceMiddleware(({ payload, next }) => { + expect(payload.obj?.context?.page).toMatchInlineSnapshot(` + { + "path": "/", + "referrer": "", + "search": "", + "title": "", + "url": "http://localhost/", + } + `) + next(payload) + }) + + await ajs.track('My event', { + banana: 'phone', + }) + expect.assertions(1) + }) +}) diff --git a/packages/browser/src/browser/__tests__/standalone-analytics.test.ts b/packages/browser/src/browser/__tests__/standalone-analytics.test.ts index c13c5f426..e569b9f93 100644 --- a/packages/browser/src/browser/__tests__/standalone-analytics.test.ts +++ b/packages/browser/src/browser/__tests__/standalone-analytics.test.ts @@ -9,6 +9,7 @@ import { sleep } from '../../lib/sleep' import * as Factory from '../../test-helpers/factories' import { EventQueue } from '../../core/queue/event-queue' import { AnalyticsStandalone } from '../standalone-interface' +import { getBufferedPageCtxFixture } from '../../test-helpers/fixtures' const track = jest.fn() const identify = jest.fn() @@ -27,10 +28,9 @@ jest.mock('../../core/analytics', () => ({ addSourceMiddleware, register, emit: jest.fn(), + ready: () => Promise.resolve(), on, - queue: new EventQueue( - new PersistedPriorityQueue(1, 'foo:event-queue') as any - ), + queue: new EventQueue(new PersistedPriorityQueue(1, 'event-queue') as any), options, }), })) @@ -68,6 +68,7 @@ describe('standalone bundle', () => { `.trim() const virtualConsole = new jsdom.VirtualConsole() + const jsd = new JSDOM(html, { runScripts: 'dangerously', resources: 'usable', @@ -159,12 +160,20 @@ describe('standalone bundle', () => { await sleep(0) - expect(track).toHaveBeenCalledWith('fruit basket', { - fruits: ['🍌', 'πŸ‡'], - }) - expect(identify).toHaveBeenCalledWith('netto', { - employer: 'segment', - }) + expect(track).toHaveBeenCalledWith( + 'fruit basket', + { + fruits: ['🍌', 'πŸ‡'], + }, + getBufferedPageCtxFixture() + ) + expect(identify).toHaveBeenCalledWith( + 'netto', + { + employer: 'segment', + }, + getBufferedPageCtxFixture() + ) expect(page).toHaveBeenCalled() }) @@ -270,13 +279,25 @@ describe('standalone bundle', () => { await sleep(0) - expect(track).toHaveBeenCalledWith('fruit basket', { - fruits: ['🍌', 'πŸ‡'], - }) - expect(track).toHaveBeenCalledWith('race conditions', { foo: 'bar' }) - expect(identify).toHaveBeenCalledWith('netto', { - employer: 'segment', - }) + expect(track).toHaveBeenCalledWith( + 'fruit basket', + { + fruits: ['🍌', 'πŸ‡'], + }, + getBufferedPageCtxFixture() + ) + expect(track).toHaveBeenCalledWith( + 'race conditions', + { foo: 'bar' }, + getBufferedPageCtxFixture() + ) + expect(identify).toHaveBeenCalledWith( + 'netto', + { + employer: 'segment', + }, + getBufferedPageCtxFixture() + ) expect(page).toHaveBeenCalled() }) diff --git a/packages/browser/src/browser/__tests__/standalone-errors.test.ts b/packages/browser/src/browser/__tests__/standalone-errors.test.ts index b402b26b3..636a30743 100644 --- a/packages/browser/src/browser/__tests__/standalone-errors.test.ts +++ b/packages/browser/src/browser/__tests__/standalone-errors.test.ts @@ -111,10 +111,10 @@ describe('standalone bundle', () => { ) expect(metrics).toMatchInlineSnapshot(` - Array [ - Object { + [ + { "metric": "analytics_js.invoke.error", - "tags": Array [ + "tags": [ "type:initialization", "message:Ohhh nooo", "name:Error", @@ -124,7 +124,7 @@ describe('standalone bundle', () => { ] `) expect(errorMessages).toMatchInlineSnapshot(` - Array [ + [ "[analytics.js],Failed to load Analytics.js,Error: Ohhh nooo", ] `) diff --git a/packages/browser/src/browser/index.ts b/packages/browser/src/browser/index.ts index 104f416b6..1c8eed21e 100644 --- a/packages/browser/src/browser/index.ts +++ b/packages/browser/src/browser/index.ts @@ -2,14 +2,19 @@ import { getProcessEnv } from '../lib/get-process-env' import { getCDN, setGlobalCDNUrl } from '../lib/parse-cdn' import { fetch } from '../lib/fetch' -import { Analytics, AnalyticsSettings, InitOptions } from '../core/analytics' +import { + Analytics, + AnalyticsSettings, + NullAnalytics, + InitOptions, +} from '../core/analytics' import { Context } from '../core/context' import { Plan } from '../core/events' import { Plugin } from '../core/plugin' import { MetricsOptions } from '../core/stats/remote-metrics' import { mergedOptions } from '../lib/merged-options' -import { createDeferred } from '../lib/create-deferred' -import { pageEnrichment } from '../plugins/page-enrichment' +import { createDeferred } from '@segment/analytics-generic-utils' +import { envEnrichment } from '../plugins/env-enrichment' import { PluginFactory, remoteLoader, @@ -25,8 +30,8 @@ import { flushAddSourceMiddleware, flushSetAnonymousID, flushOn, + PreInitMethodCall, } from '../core/buffer' -import { popSnippetWindowBuffer } from '../core/buffer/snippet' import { ClassicIntegrationSource } from '../plugins/ajs-destination/types' import { attachInspector } from '../core/inspector' import { Stats } from '../core/stats' @@ -87,11 +92,16 @@ export interface LegacySettings { */ consentSettings?: { /** - * All unique consent categories. + * All unique consent categories for enabled destinations. * There can be categories in this array that are important for consent that are not included in any integration (e.g. 2 cloud mode categories). * @example ["Analytics", "Advertising", "CAT001"] */ allCategories: string[] + + /** + * Whether or not there are any unmapped destinations for enabled destinations. + */ + hasUnmappedDestinations: boolean } } @@ -156,7 +166,6 @@ function flushPreBuffer( analytics: Analytics, buffer: PreInitMethodCallBuffer ): void { - buffer.push(...popSnippetWindowBuffer()) flushSetAnonymousID(analytics, buffer) flushOn(analytics, buffer) } @@ -170,9 +179,7 @@ async function flushFinalBuffer( ): Promise { // Call popSnippetWindowBuffer before each flush task since there may be // analytics calls during async function calls. - buffer.push(...popSnippetWindowBuffer()) await flushAddSourceMiddleware(analytics, buffer) - buffer.push(...popSnippetWindowBuffer()) flushAnalyticsCallsInNewTask(analytics, buffer) // Clear buffer, just in case analytics is loaded twice; we don't want to fire events off again. buffer.clear() @@ -182,7 +189,6 @@ async function registerPlugins( writeKey: string, legacySettings: LegacySettings, analytics: Analytics, - opts: InitOptions, options: InitOptions, pluginLikes: (Plugin | PluginFactory)[] = [], legacyIntegrationSources: ClassicIntegrationSource[] @@ -216,7 +222,7 @@ async function registerPlugins( writeKey, legacySettings, analytics.integrations, - opts, + options, tsubMiddleware, legacyIntegrationSources ) @@ -231,11 +237,11 @@ async function registerPlugins( }) } - const schemaFilter = opts.plan?.track + const schemaFilter = options.plan?.track ? await import( /* webpackChunkName: "schemaFilter" */ '../plugins/schema-filter' ).then((mod) => { - return mod.schemaFilter(opts.plan?.track, legacySettings) + return mod.schemaFilter(options.plan?.track, legacySettings) }) : undefined @@ -244,14 +250,14 @@ async function registerPlugins( legacySettings, analytics.integrations, mergedSettings, - options.obfuscate, + options, tsubMiddleware, pluginSources ).catch(() => []) const toRegister = [ validation, - pageEnrichment, + envEnrichment, ...plugins, ...legacyDestinations, ...remotePlugins, @@ -262,8 +268,9 @@ async function registerPlugins( } const shouldIgnoreSegmentio = - (opts.integrations?.All === false && !opts.integrations['Segment.io']) || - (opts.integrations && opts.integrations['Segment.io'] === false) + (options.integrations?.All === false && + !options.integrations['Segment.io']) || + (options.integrations && options.integrations['Segment.io'] === false) if (!shouldIgnoreSegmentio) { toRegister.push( @@ -301,29 +308,59 @@ async function loadAnalytics( options: InitOptions = {}, preInitBuffer: PreInitMethodCallBuffer ): Promise<[Analytics, Context]> { + // return no-op analytics instance if disabled + if (options.disable === true) { + return [new NullAnalytics(), Context.system()] + } + if (options.globalAnalyticsKey) setGlobalAnalyticsKey(options.globalAnalyticsKey) // this is an ugly side-effect, but it's for the benefits of the plugins that get their cdn via getCDN() if (settings.cdnURL) setGlobalCDNUrl(settings.cdnURL) let legacySettings: any = { integrations: {} } + if (options.initialPageview) { + // capture the page context early, so it's always up-to-date + preInitBuffer.push(new PreInitMethodCall('page', [])) + } if (options.updateCDNSettings) { legacySettings = options.updateCDNSettings(legacySettings) } + // if options.disable is a function, we allow user to disable analytics based on CDN Settings + if (typeof options.disable === 'function') { + const disabled = await options.disable(legacySettings) + if (disabled) { + return [new NullAnalytics(), Context.system()] + } + } + const retryQueue: boolean = legacySettings.integrations['Segment.io']?.retryQueue ?? true - const opts: InitOptions = { retryQueue, ...options } - const analytics = new Analytics(settings, opts) + options = { + retryQueue, + ...options, + } + + const analytics = new Analytics(settings, options) attachInspector(analytics) const plugins = settings.plugins ?? [] const classicIntegrations = settings.classicIntegrations ?? [] - Stats.initRemoteMetrics(legacySettings.metrics) + + const segmentLoadOptions = options.integrations?.['Segment.io'] as + | SegmentioSettings + | undefined + + Stats.initRemoteMetrics({ + ...legacySettings.metrics, + host: segmentLoadOptions?.apiHost ?? legacySettings.metrics?.host, + protocol: segmentLoadOptions?.protocol, + }) // needs to be flushed before plugins are registered flushPreBuffer(analytics, preInitBuffer) @@ -332,7 +369,6 @@ async function loadAnalytics( settings.writeKey, legacySettings, analytics, - opts, options, plugins, classicIntegrations @@ -354,10 +390,6 @@ async function loadAnalytics( analytics.initialized = true analytics.emit('initialize', settings, options) - if (options.initialPageview) { - analytics.page().catch(console.error) - } - await flushFinalBuffer(analytics, preInitBuffer) return [analytics, ctx] diff --git a/packages/browser/src/browser/standalone.ts b/packages/browser/src/browser/standalone.ts index 69f86f6fe..039653eee 100644 --- a/packages/browser/src/browser/standalone.ts +++ b/packages/browser/src/browser/standalone.ts @@ -28,6 +28,7 @@ import { loadAjsClassicFallback, isAnalyticsCSPError, } from '../lib/csp-detection' +import { setGlobalAnalyticsKey } from '../lib/global-analytics-helper' let ajsIdentifiedCSP = false @@ -71,6 +72,16 @@ async function attempt(promise: () => Promise) { } } +const globalAnalyticsKey = ( + document.querySelector( + 'script[data-global-segment-analytics-key]' + ) as HTMLScriptElement +)?.dataset.globalSegmentAnalyticsKey + +if (globalAnalyticsKey) { + setGlobalAnalyticsKey(globalAnalyticsKey) +} + if (shouldPolyfill()) { // load polyfills in order to get AJS to work with old browsers const script = document.createElement('script') diff --git a/packages/browser/src/core/__tests__/auto-track.test.ts b/packages/browser/src/core/__tests__/auto-track.test.ts deleted file mode 100644 index 1c9d27ebd..000000000 --- a/packages/browser/src/core/__tests__/auto-track.test.ts +++ /dev/null @@ -1,440 +0,0 @@ -import { JSDOM } from 'jsdom' -import { Analytics } from '../analytics' - -const sleep = (time: number): Promise => - new Promise((resolve) => { - setTimeout(resolve, time) - }) - -async function resolveWhen( - condition: () => boolean, - timeout?: number -): Promise { - return new Promise((resolve, _reject) => { - if (condition()) { - resolve() - return - } - - const check = () => - setTimeout(() => { - if (condition()) { - resolve() - } else { - check() - } - }, timeout) - - check() - }) -} - -describe('track helpers', () => { - describe('trackLink', () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let link: any - let wrap: SVGSVGElement - let svg: SVGAElement - - let analytics = new Analytics({ writeKey: 'foo' }) - let mockTrack = jest.spyOn(analytics, 'track') - - beforeEach(() => { - analytics = new Analytics({ writeKey: 'foo' }) - - // @ts-ignore - global.jQuery = require('jquery') - - const jsd = new JSDOM('', { - runScripts: 'dangerously', - resources: 'usable', - }) - // eslint-disable-next-line no-global-assign - document = jsd.window.document - - jest.spyOn(console, 'error').mockImplementationOnce(() => {}) - - document.querySelector('html')!.innerHTML = ` - - - -
-
- -
-
- - ` - - link = document.getElementById('foo') - wrap = document.createElementNS('http://www.w3.org/2000/svg', 'svg') - svg = document.createElementNS('http://www.w3.org/2000/svg', 'a') - wrap.appendChild(svg) - document.body.appendChild(wrap) - - jest.spyOn(window, 'location', 'get').mockReturnValue({ - ...window.location, - }) - - mockTrack = jest.spyOn(analytics, 'track') - - // We need to mock the track function for the .catch() call not to break when testing - // eslint-disable-next-line @typescript-eslint/unbound-method - mockTrack.mockImplementation(Analytics.prototype.track) - }) - - it('should respect options object', async () => { - await analytics.trackLink( - link!, - 'foo', - {}, - { context: { ip: '0.0.0.0' } } - ) - link.click() - - expect(mockTrack).toHaveBeenCalledWith( - 'foo', - {}, - { context: { ip: '0.0.0.0' } } - ) - }) - - it('should stay on same page with blank href', async () => { - link.href = '' - await analytics.trackLink(link!, 'foo') - link.click() - - expect(mockTrack).toHaveBeenCalled() - expect(window.location.href).toBe('http://localhost/') - }) - - it('should work with nested link', async () => { - const nested = document.getElementById('bar') - await analytics.trackLink(nested, 'foo') - nested!.click() - - expect(mockTrack).toHaveBeenCalled() - - await resolveWhen(() => window.location.href === 'bar.com') - expect(window.location.href).toBe('bar.com') - }) - - it('should make a track call', async () => { - await analytics.trackLink(link!, 'foo') - link.click() - - expect(mockTrack).toHaveBeenCalled() - }) - - it('should still navigate even if the track call fails', async () => { - mockTrack.mockClear() - - let rejected = false - mockTrack.mockImplementationOnce(() => { - rejected = true - return Promise.reject(new Error('boo!')) - }) - - const nested = document.getElementById('bar') - await analytics.trackLink(nested, 'foo') - nested!.click() - - await resolveWhen(() => rejected) - await resolveWhen(() => window.location.href === 'bar.com') - expect(window.location.href).toBe('bar.com') - }) - - it('should still navigate even if the track call times out', async () => { - mockTrack.mockClear() - - let timedOut = false - mockTrack.mockImplementationOnce(async () => { - await sleep(600) - timedOut = true - return Promise.resolve() as any - }) - - const nested = document.getElementById('bar') - await analytics.trackLink(nested, 'foo') - nested!.click() - - await resolveWhen(() => window.location.href === 'bar.com') - expect(window.location.href).toBe('bar.com') - expect(timedOut).toBe(false) - - await resolveWhen(() => timedOut) - }) - - it('should accept a jquery object for an element', async () => { - const $link = jQuery(link) - await analytics.trackLink($link, 'foo') - link.click() - expect(mockTrack).toBeCalled() - }) - - it('accepts array of elements', async () => { - const links = [link, link] - await analytics.trackLink(links, 'foo') - link.click() - - expect(mockTrack).toHaveBeenCalled() - }) - - it('should send an event and properties', async () => { - await analytics.trackLink(link, 'event', { property: true }) - link.click() - - expect(mockTrack).toBeCalledWith('event', { property: true }, {}) - }) - - it('should accept an event function', async () => { - function event(el: Element): string { - return el.nodeName - } - await analytics.trackLink(link, event, { foo: 'bar' }) - link.click() - - expect(mockTrack).toBeCalledWith('A', { foo: 'bar' }, {}) - }) - - it('should accept a properties function', async () => { - function properties(el: Record): Record { - return { type: el.nodeName } - } - await analytics.trackLink(link, 'event', properties) - link.click() - - expect(mockTrack).toBeCalledWith('event', { type: 'A' }, {}) - }) - - it('should load an href on click', async () => { - link.href = '#test' - await analytics.trackLink(link, 'foo') - link.click() - - await resolveWhen(() => window.location.href === '#test') - expect(global.window.location.href).toBe('#test') - }) - - it('should only navigate after the track call has been completed', async () => { - link.href = '#test' - await analytics.trackLink(link, 'foo') - link.click() - - await resolveWhen(() => mockTrack.mock.calls.length === 1) - await resolveWhen(() => window.location.href === '#test') - - expect(global.window.location.href).toBe('#test') - }) - - it('should support svg .href attribute', async () => { - svg.setAttribute('href', '#svg') - await analytics.trackLink(svg, 'foo') - const clickEvent = new Event('click') - svg.dispatchEvent(clickEvent) - - await resolveWhen(() => window.location.href === '#svg') - expect(global.window.location.href).toBe('#svg') - }) - - it('should fallback to getAttributeNS', async () => { - svg.setAttributeNS('http://www.w3.org/1999/xlink', 'href', '#svg') - await analytics.trackLink(svg, 'foo') - const clickEvent = new Event('click') - svg.dispatchEvent(clickEvent) - - await resolveWhen(() => window.location.href === '#svg') - expect(global.window.location.href).toBe('#svg') - }) - - it('should support xlink:href', async () => { - svg.setAttribute('xlink:href', '#svg') - await analytics.trackLink(svg, 'foo') - const clickEvent = new Event('click') - svg.dispatchEvent(clickEvent) - - await resolveWhen(() => window.location.href === '#svg') - expect(global.window.location.href).toBe('#svg') - }) - - it('should not load an href for a link with a blank target', async () => { - link.href = '/base/test/support/mock.html' - link.target = '_blank' - await analytics.trackLink(link, 'foo') - link.click() - - await sleep(300) - expect(global.window.location.href).not.toBe('#test') - }) - }) - - describe('trackForm', () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let form: any - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let submit: any - - const analytics = new Analytics({ writeKey: 'foo' }) - let mockTrack = jest.spyOn(analytics, 'track') - - beforeEach(() => { - document.querySelector('html')!.innerHTML = ` - - -
- -
- - ` - form = document.getElementById('form') - submit = document.getElementById('submit') - - // @ts-ignore - global.jQuery = require('jquery') - - mockTrack = jest.spyOn(analytics, 'track') - // eslint-disable-next-line @typescript-eslint/unbound-method - mockTrack.mockImplementation(Analytics.prototype.track) - }) - - afterEach(() => { - window.location.hash = '' - document.body.removeChild(form) - }) - - it('should not error or send track event on null form', async () => { - const form = document.getElementById('fake-form') as HTMLFormElement - - await analytics.trackForm(form, 'Signed Up', { - plan: 'Premium', - revenue: 99.0, - }) - submit.click() - expect(mockTrack).not.toBeCalled() - }) - - it('should respect options object', async () => { - await analytics.trackForm(form, 'foo', {}, { context: { ip: '0.0.0.0' } }) - submit.click() - - expect(mockTrack).toHaveBeenCalledWith( - 'foo', - {}, - { context: { ip: '0.0.0.0' } } - ) - }) - - it('should trigger a track on a form submit', async () => { - await analytics.trackForm(form, 'foo') - submit.click() - expect(mockTrack).toBeCalled() - }) - - it('should accept a jquery object for an element', async () => { - await analytics.trackForm(form, 'foo') - submit.click() - expect(mockTrack).toBeCalled() - }) - - it('should not accept a string for an element', async () => { - try { - // @ts-expect-error - await analytics.trackForm('foo') - submit.click() - } catch (e) { - expect(e instanceof TypeError).toBe(true) - } - expect(mockTrack).not.toBeCalled() - }) - - it('should send an event and properties', async () => { - await analytics.trackForm(form, 'event', { property: true }) - submit.click() - expect(mockTrack).toBeCalledWith('event', { property: true }, {}) - }) - - it('should accept an event function', async () => { - function event(): string { - return 'event' - } - await analytics.trackForm(form, event, { foo: 'bar' }) - submit.click() - expect(mockTrack).toBeCalledWith('event', { foo: 'bar' }, {}) - }) - - it('should accept a properties function', async () => { - function properties(): Record { - return { property: true } - } - await analytics.trackForm(form, 'event', properties) - submit.click() - expect(mockTrack).toBeCalledWith('event', { property: true }, {}) - }) - - it('should call submit after a timeout', async () => { - const submitSpy = jest.spyOn(form, 'submit') - const mockedTrack = jest.fn() - - // eslint-disable-next-line @typescript-eslint/unbound-method - mockedTrack.mockImplementation(Analytics.prototype.track) - - analytics.track = mockedTrack - await analytics.trackForm(form, 'foo') - - submit.click() - - await sleep(500) - - expect(submitSpy).toHaveBeenCalled() - }) - - it('should trigger an existing submit handler', async () => { - const submitPromise = new Promise((resolve) => { - form.addEventListener('submit', () => { - resolve() - }) - }) - - await analytics.trackForm(form, 'foo') - submit.click() - await submitPromise - }) - - it('should trigger an existing jquery submit handler', async () => { - const $form = jQuery(form) - - const submitPromise = new Promise((resolve) => { - $form.submit(function () { - resolve() - }) - }) - - await analytics.trackForm(form, 'foo') - submit.click() - await submitPromise - }) - - it('should track on a form submitted via jquery', async () => { - const $form = jQuery(form) - - await analytics.trackForm(form, 'foo') - $form.submit() - - expect(mockTrack).toBeCalled() - }) - - it('should trigger an existing jquery submit handler on a form submitted via jquery', async () => { - const $form = jQuery(form) - - const submitPromise = new Promise((resolve) => { - $form.submit(function () { - resolve() - }) - }) - - await analytics.trackForm(form, 'foo') - $form.submit() - await submitPromise - }) - }) -}) diff --git a/packages/browser/src/core/__tests__/track-form.test.ts b/packages/browser/src/core/__tests__/track-form.test.ts new file mode 100644 index 000000000..92fff0d58 --- /dev/null +++ b/packages/browser/src/core/__tests__/track-form.test.ts @@ -0,0 +1,193 @@ +import { Analytics } from '../analytics' +import { sleep } from '../../lib/sleep' +import { getDefaultPageContext } from '../page' + +let analytics: Analytics +let mockTrack: jest.SpiedFunction +const ogLocation = { + ...global.window.location, +} + +beforeEach(() => { + // @ts-ignore + global.jQuery = require('jquery') + + jest.spyOn(console, 'error').mockImplementationOnce(() => {}) + Object.defineProperty(window, 'location', { + value: ogLocation, + writable: true, + }) + mockTrack = jest.spyOn(Analytics.prototype, 'track') + analytics = new Analytics({ writeKey: 'foo' }) +}) + +describe('trackForm', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let form: any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let submit: any + + beforeEach(() => { + document.querySelector('html')!.innerHTML = ` + + +
+ +
+ + ` + form = document.getElementById('form') + submit = document.getElementById('submit') + + // @ts-ignore + global.jQuery = require('jquery') + }) + + afterEach(() => { + document.querySelector('html')!.innerHTML = '' + }) + + it('should have the correct page context', async () => { + window.location.href = 'http://bar.com?foo=123' + window.location.search = '?foo=123' + await analytics.trackForm(form, 'foo', {}, { context: { ip: '0.0.0.0' } }) + submit.click() + const [, , properties] = mockTrack.mock.lastCall as any[] + + expect((properties.context as any).page).toEqual({ + ...getDefaultPageContext(), + url: 'http://bar.com?foo=123', + search: '?foo=123', + }) + }) + + it('should not error or send track event on null form', async () => { + const form = document.getElementById('fake-form') as HTMLFormElement + + await analytics.trackForm(form, 'Signed Up', { + plan: 'Premium', + revenue: 99.0, + }) + submit.click() + expect(mockTrack).not.toBeCalled() + }) + + it('should respect options object', async () => { + await analytics.trackForm(form, 'foo', {}, { context: { ip: '0.0.0.0' } }) + submit.click() + + expect(mockTrack).toHaveBeenCalledWith( + 'foo', + {}, + { context: expect.objectContaining({ ip: '0.0.0.0' }) } + ) + }) + + it('should trigger a track on a form submit', async () => { + await analytics.trackForm(form, 'foo') + submit.click() + expect(mockTrack).toBeCalled() + }) + + it('should accept a jquery object for an element', async () => { + await analytics.trackForm(form, 'foo') + submit.click() + expect(mockTrack).toBeCalled() + }) + + it('should not accept a string for an element', async () => { + try { + // @ts-expect-error + await analytics.trackForm('foo') + submit.click() + } catch (e) { + expect(e instanceof TypeError).toBe(true) + } + expect(mockTrack).not.toBeCalled() + }) + + it('should send an event and properties', async () => { + await analytics.trackForm(form, 'event', { property: true }) + submit.click() + expect(mockTrack).toBeCalledWith('event', { property: true }, {}) + }) + + it('should accept an event function', async () => { + function event(): string { + return 'event' + } + await analytics.trackForm(form, event, { foo: 'bar' }) + submit.click() + expect(mockTrack).toBeCalledWith('event', { foo: 'bar' }, {}) + }) + + it('should accept a properties function', async () => { + function properties(): Record { + return { property: true } + } + await analytics.trackForm(form, 'event', properties) + submit.click() + expect(mockTrack).toBeCalledWith('event', { property: true }, {}) + }) + + it('should call submit after a timeout', async () => { + const submitSpy = jest.spyOn(form, 'submit') + + await analytics.trackForm(form, 'foo') + + submit.click() + + await sleep(300) + + expect(submitSpy).toHaveBeenCalled() + }) + + it('should trigger an existing submit handler', async () => { + const submitPromise = new Promise((resolve) => { + form.addEventListener('submit', () => { + resolve() + }) + }) + + await analytics.trackForm(form, 'foo') + submit.click() + await submitPromise + }) + + it('should trigger an existing jquery submit handler', async () => { + const $form = jQuery(form) + + const submitPromise = new Promise((resolve) => { + $form.submit(function () { + resolve() + }) + }) + + await analytics.trackForm(form, 'foo') + submit.click() + await submitPromise + }) + + it('should track on a form submitted via jquery', async () => { + const $form = jQuery(form) + + await analytics.trackForm(form, 'foo') + $form.submit() + + expect(mockTrack).toBeCalled() + }) + + it('should trigger an existing jquery submit handler on a form submitted via jquery', async () => { + const $form = jQuery(form) + + const submitPromise = new Promise((resolve) => { + $form.submit(function () { + resolve() + }) + }) + + await analytics.trackForm(form, 'foo') + $form.submit() + await submitPromise + }) +}) diff --git a/packages/browser/src/core/__tests__/track-link.test.ts b/packages/browser/src/core/__tests__/track-link.test.ts new file mode 100644 index 000000000..363de0cfd --- /dev/null +++ b/packages/browser/src/core/__tests__/track-link.test.ts @@ -0,0 +1,252 @@ +import { Analytics } from '../analytics' +import { sleep } from '../../lib/sleep' + +async function resolveWhen( + condition: () => boolean, + timeout?: number +): Promise { + return new Promise((resolve, _reject) => { + if (condition()) { + resolve() + return + } + + const check = () => + setTimeout(() => { + if (condition()) { + resolve() + } else { + check() + } + }, timeout) + + check() + }) +} + +const ogLocation = { + ...global.window.location, +} + +let analytics: Analytics +let mockTrack: jest.SpiedFunction +beforeEach(() => { + Object.defineProperty(window, 'location', { + value: ogLocation, + writable: true, + }) + mockTrack = jest.spyOn(Analytics.prototype, 'track') + analytics = new Analytics({ writeKey: 'foo' }) +}) +describe('trackLink', () => { + let link: any + let wrap: SVGSVGElement + let svg: SVGAElement + + beforeEach(() => { + // @ts-ignore + global.jQuery = require('jquery') + + jest.spyOn(console, 'error').mockImplementationOnce(() => {}) + + document.querySelector('html')!.innerHTML = ` + + + +
+
+ +
+
+ + ` + + link = document.getElementById('foo') + wrap = document.createElementNS('http://www.w3.org/2000/svg', 'svg') + svg = document.createElementNS('http://www.w3.org/2000/svg', 'a') + wrap.appendChild(svg) + document.body.appendChild(wrap) + }) + + afterEach(() => { + document.querySelector('html')!.innerHTML = '' + }) + it('should respect options object', async () => { + await analytics.trackLink(link!, 'foo', {}, { context: { ip: '0.0.0.0' } }) + link.click() + + expect(mockTrack).toHaveBeenCalledWith( + 'foo', + {}, + { context: expect.objectContaining({ ip: '0.0.0.0' }) } + ) + }) + + it('should stay on same page with blank href', async () => { + link.href = '' + await analytics.trackLink(link!, 'foo') + link.click() + + expect(mockTrack).toHaveBeenCalled() + expect(window.location.href).toBe('http://localhost/') + }) + + it('should work with nested link', async () => { + const nested = document.getElementById('bar') + await analytics.trackLink(nested, 'foo') + nested!.click() + + expect(mockTrack).toHaveBeenCalled() + + await resolveWhen(() => window.location.href === 'bar.com') + expect(window.location.href).toBe('bar.com') + }) + + it('should make a track call', async () => { + await analytics.trackLink(link!, 'foo') + link.click() + + expect(mockTrack).toHaveBeenCalled() + }) + + it('should still navigate even if the track call fails', async () => { + mockTrack.mockClear() + + let rejected = false + mockTrack.mockImplementationOnce(() => { + rejected = true + return Promise.reject(new Error('boo!')) + }) + + const nested = document.getElementById('bar') + await analytics.trackLink(nested, 'foo') + nested!.click() + + await resolveWhen(() => rejected) + await resolveWhen(() => window.location.href === 'bar.com') + expect(window.location.href).toBe('bar.com') + }) + + it('should still navigate even if the track call times out', async () => { + mockTrack.mockClear() + + let timedOut = false + mockTrack.mockImplementationOnce(async () => { + await sleep(600) + timedOut = true + return Promise.resolve() as any + }) + + const nested = document.getElementById('bar') + await analytics.trackLink(nested, 'foo') + nested!.click() + + await resolveWhen(() => window.location.href === 'bar.com') + expect(window.location.href).toBe('bar.com') + expect(timedOut).toBe(false) + + await resolveWhen(() => timedOut) + }) + + it('should accept a jquery object for an element', async () => { + const $link = jQuery(link) + await analytics.trackLink($link, 'foo') + link.click() + expect(mockTrack).toBeCalled() + }) + + it('accepts array of elements', async () => { + const links = [link, link] + await analytics.trackLink(links, 'foo') + link.click() + + expect(mockTrack).toHaveBeenCalled() + }) + + it('should send an event and properties', async () => { + await analytics.trackLink(link, 'event', { property: true }) + link.click() + + expect(mockTrack).toBeCalledWith('event', { property: true }, {}) + }) + + it('should accept an event function', async () => { + function event(el: Element): string { + return el.nodeName + } + await analytics.trackLink(link, event, { foo: 'bar' }) + link.click() + + expect(mockTrack).toBeCalledWith('A', { foo: 'bar' }, {}) + }) + + it('should accept a properties function', async () => { + function properties(el: Record): Record { + return { type: el.nodeName } + } + await analytics.trackLink(link, 'event', properties) + link.click() + + expect(mockTrack).toBeCalledWith('event', { type: 'A' }, {}) + }) + + it('should load an href on click', async () => { + link.href = '#test' + await analytics.trackLink(link, 'foo') + link.click() + + await resolveWhen(() => window.location.href === '#test') + expect(global.window.location.href).toBe('#test') + }) + + it('should only navigate after the track call has been completed', async () => { + link.href = '#test' + await analytics.trackLink(link, 'foo') + link.click() + + await resolveWhen(() => mockTrack.mock.calls.length === 1) + await resolveWhen(() => window.location.href === '#test') + + expect(global.window.location.href).toBe('#test') + }) + + it('should support svg .href attribute', async () => { + svg.setAttribute('href', '#svg') + await analytics.trackLink(svg, 'foo') + const clickEvent = new Event('click') + svg.dispatchEvent(clickEvent) + + await resolveWhen(() => window.location.href === '#svg') + expect(global.window.location.href).toBe('#svg') + }) + + it('should fallback to getAttributeNS', async () => { + svg.setAttributeNS('http://www.w3.org/1999/xlink', 'href', '#svg') + await analytics.trackLink(svg, 'foo') + const clickEvent = new Event('click') + svg.dispatchEvent(clickEvent) + + await resolveWhen(() => window.location.href === '#svg') + expect(global.window.location.href).toBe('#svg') + }) + + it('should support xlink:href', async () => { + svg.setAttribute('xlink:href', '#svg') + await analytics.trackLink(svg, 'foo') + const clickEvent = new Event('click') + svg.dispatchEvent(clickEvent) + + await resolveWhen(() => window.location.href === '#svg') + expect(global.window.location.href).toBe('#svg') + }) + + it('should not load an href for a link with a blank target', async () => { + link.href = '/base/test/support/mock.html' + link.target = '_blank' + await analytics.trackLink(link, 'foo') + link.click() + + await sleep(300) + expect(global.window.location.href).not.toBe('#test') + }) +}) diff --git a/packages/browser/src/core/analytics/__tests__/integration.test.ts b/packages/browser/src/core/analytics/__tests__/analytics.test.ts similarity index 100% rename from packages/browser/src/core/analytics/__tests__/integration.test.ts rename to packages/browser/src/core/analytics/__tests__/analytics.test.ts diff --git a/packages/browser/src/core/analytics/__tests__/null-analytics.test.ts b/packages/browser/src/core/analytics/__tests__/null-analytics.test.ts new file mode 100644 index 000000000..b2af82578 --- /dev/null +++ b/packages/browser/src/core/analytics/__tests__/null-analytics.test.ts @@ -0,0 +1,37 @@ +import { getAjsBrowserStorage } from '../../../test-helpers/browser-storage' +import { Analytics, NullAnalytics } from '..' + +describe(NullAnalytics, () => { + it('should return an instance of Analytics / NullAnalytics', () => { + const analytics = new NullAnalytics() + expect(analytics).toBeInstanceOf(Analytics) + expect(analytics).toBeInstanceOf(NullAnalytics) + }) + + it('should have initialized set to true', () => { + const analytics = new NullAnalytics() + expect(analytics.initialized).toBe(true) + }) + + it('should have no plugins', async () => { + const analytics = new NullAnalytics() + expect(analytics.queue.plugins).toHaveLength(0) + }) + it('should dispatch events', async () => { + const analytics = new NullAnalytics() + const ctx = await analytics.track('foo') + expect(ctx.event.event).toBe('foo') + }) + + it('should have disableClientPersistence set to true', () => { + const analytics = new NullAnalytics() + expect(analytics.options.disableClientPersistence).toBe(true) + }) + + it('integration: should not touch cookies or localStorage', async () => { + const analytics = new NullAnalytics() + await analytics.track('foo') + const storage = getAjsBrowserStorage() + expect(Object.values(storage).every((v) => !v)).toBe(true) + }) +}) diff --git a/packages/browser/src/core/analytics/index.ts b/packages/browser/src/core/analytics/index.ts index fd3853d9f..78570a3ab 100644 --- a/packages/browser/src/core/analytics/index.ts +++ b/packages/browser/src/core/analytics/index.ts @@ -13,7 +13,8 @@ import { import type { FormArgs, LinkArgs } from '../auto-track' import { isOffline } from '../connection' import { Context } from '../context' -import { dispatch, Emitter } from '@segment/analytics-core' +import { dispatch } from '@segment/analytics-core' +import { Emitter } from '@segment/analytics-generic-utils' import { Callback, EventFactory, @@ -54,6 +55,7 @@ import { } from '../storage' import { PluginFactory } from '../../plugins/remote-loader' import { setGlobalAnalytics } from '../../lib/global-analytics-helper' +import { popPageContext } from '../buffer' const deprecationWarning = 'This is being deprecated and will be not be available in future releases of Analytics JS' @@ -67,7 +69,7 @@ function createDefaultQueue( retryQueue = false, disablePersistance = false ) { - const maxAttempts = retryQueue ? 4 : 1 + const maxAttempts = retryQueue ? 10 : 1 const priorityQueue = disablePersistance ? new PriorityQueue(maxAttempts, []) : new PersistedPriorityQueue(maxAttempts, name) @@ -128,6 +130,24 @@ export interface InitOptions { * default: analytics */ globalAnalyticsKey?: string + + /** + * Disable sending any data to Segment's servers. All emitted events and API calls (including .ready()), will be no-ops, and no cookies or localstorage will be used. + * + * @example + * ### Basic (Will not not fetch any CDN settings) + * ```ts + * disable: process.env.NODE_ENV === 'test' + * ``` + * + * ### Advanced (Fetches CDN Settings. Do not use this unless you require CDN settings for some reason) + * ```ts + * disable: (cdnSettings) => cdnSettings.foo === 'bar' + * ``` + */ + disable?: + | boolean + | ((cdnSettings: LegacySettings) => boolean | Promise) } /* analytics-classic stubs */ @@ -203,7 +223,6 @@ export class Analytics this.eventFactory = new EventFactory(this._user) this.integrations = options?.integrations ?? {} this.options = options ?? {} - autoBind(this) } @@ -253,13 +272,15 @@ export class Analytics } async track(...args: EventParams): Promise { + const pageCtx = popPageContext(args) const [name, data, opts, cb] = resolveArguments(...args) const segmentEvent = this.eventFactory.track( name, data as EventProperties, opts, - this.integrations + this.integrations, + pageCtx ) return this._dispatch(segmentEvent, cb).then((ctx) => { @@ -269,6 +290,7 @@ export class Analytics } async page(...args: PageParams): Promise { + const pageCtx = popPageContext(args) const [category, page, properties, options, callback] = resolvePageArguments(...args) @@ -277,7 +299,8 @@ export class Analytics page, properties, options, - this.integrations + this.integrations, + pageCtx ) return this._dispatch(segmentEvent, callback).then((ctx) => { @@ -287,6 +310,7 @@ export class Analytics } async identify(...args: IdentifyParams): Promise { + const pageCtx = popPageContext(args) const [id, _traits, options, callback] = resolveUserArguments(this._user)( ...args ) @@ -296,7 +320,8 @@ export class Analytics this._user.id(), this._user.traits(), options, - this.integrations + this.integrations, + pageCtx ) return this._dispatch(segmentEvent, callback).then((ctx) => { @@ -313,6 +338,7 @@ export class Analytics group(): Group group(...args: GroupParams): Promise group(...args: GroupParams): Promise | Group { + const pageCtx = popPageContext(args) if (args.length === 0) { return this._group } @@ -329,7 +355,8 @@ export class Analytics groupId, groupTraits, options, - this.integrations + this.integrations, + pageCtx ) return this._dispatch(segmentEvent, callback).then((ctx) => { @@ -339,12 +366,14 @@ export class Analytics } async alias(...args: AliasParams): Promise { + const pageCtx = popPageContext(args) const [to, from, options, callback] = resolveAliasArguments(...args) const segmentEvent = this.eventFactory.alias( to, from, options, - this.integrations + this.integrations, + pageCtx ) return this._dispatch(segmentEvent, callback).then((ctx) => { this.emit('alias', to, from, ctx.event.options) @@ -353,6 +382,7 @@ export class Analytics } async screen(...args: PageParams): Promise { + const pageCtx = popPageContext(args) const [category, page, properties, options, callback] = resolvePageArguments(...args) @@ -361,7 +391,8 @@ export class Analytics page, properties, options, - this.integrations + this.integrations, + pageCtx ) return this._dispatch(segmentEvent, callback).then((ctx) => { this.emit( @@ -640,3 +671,13 @@ export class Analytics an[method].apply(this, args) } } + +/** + * @returns a no-op analytics instance that does not create cookies or localstorage, or send any events to segment. + */ +export class NullAnalytics extends Analytics { + constructor() { + super({ writeKey: '' }, { disableClientPersistence: true }) + this.initialized = true + } +} diff --git a/packages/browser/src/core/arguments-resolver/__tests__/index.test.ts b/packages/browser/src/core/arguments-resolver/__tests__/index.test.ts index 7c56255d3..2bc6f6572 100644 --- a/packages/browser/src/core/arguments-resolver/__tests__/index.test.ts +++ b/packages/browser/src/core/arguments-resolver/__tests__/index.test.ts @@ -429,6 +429,31 @@ describe(resolveUserArguments, () => { expect(traits).toEqual(userTraits) expect(options).toEqual({}) }) + + it('should accept (undefined, traits)', () => { + user.reset() + const [id, traits, options] = resolver(undefined, userTraits) + expect(traits).toEqual(userTraits) + expect(options).toEqual({}) + expect(id).toEqual(null) + }) + + it('should accept (null, traits) with unknown identity', () => { + user.reset() + const [id, traits, options] = resolver(null, userTraits) + expect(traits).toEqual(userTraits) + expect(options).toEqual({}) + expect(id).toEqual(null) + }) + + it('should accept (null, traits) when identity is set', () => { + user.reset() + user.identify('something') + const [id, traits, options] = resolver(null, userTraits) + expect(traits).toEqual(userTraits) + expect(options).toEqual({}) + expect(id).toEqual('something') + }) }) describe(resolveAliasArguments, () => { diff --git a/packages/browser/src/core/arguments-resolver/index.ts b/packages/browser/src/core/arguments-resolver/index.ts index 2274d6926..795381e9e 100644 --- a/packages/browser/src/core/arguments-resolver/index.ts +++ b/packages/browser/src/core/arguments-resolver/index.ts @@ -111,22 +111,56 @@ export const resolveUserArguments = ( user: U ): ResolveUser => { return (...args): ReturnType> => { - let id: string | ID | null = null - id = args.find(isString) ?? args.find(isNumber)?.toString() ?? user.id() - - const objects = args.filter((obj) => { - if (id === null) { - return isPlainObject(obj) + const values: { + id?: ID + traits?: T | null + options?: Options + callback?: Callback + } = {} + // It's a stack so it's reversed so that we go through each of the expected arguments + const orderStack: Array = [ + 'callback', + 'options', + 'traits', + 'id', + ] + + // Read each argument and eval the possible values here + for (const arg of args) { + let current = orderStack.pop() + if (current === 'id') { + if (isString(arg) || isNumber(arg)) { + values.id = arg.toString() + continue + } + if (arg === null || arg === undefined) { + continue + } + // First argument should always be the id, if it is not a valid value we can skip it + current = orderStack.pop() } - return isPlainObject(obj) || obj === null - }) as Array - const traits = (objects[0] ?? {}) as T - const opts = (objects[1] ?? {}) as Options + // Traits and Options + if ( + (current === 'traits' || current === 'options') && + (arg === null || arg === undefined || isPlainObject(arg)) + ) { + values[current] = arg as T + } - const resolvedCallback = args.find(isFunction) as Callback | undefined + // Callback + if (isFunction(arg)) { + values.callback = arg as Callback + break // This is always the last argument + } + } - return [id, traits, opts, resolvedCallback] + return [ + values.id ?? user.id(), + (values.traits ?? {}) as T, + values.options ?? {}, + values.callback, + ] } } diff --git a/packages/browser/src/core/buffer/__tests__/index.test.ts b/packages/browser/src/core/buffer/__tests__/index.test.ts index 071a4b6c1..5ff1c0dbc 100644 --- a/packages/browser/src/core/buffer/__tests__/index.test.ts +++ b/packages/browser/src/core/buffer/__tests__/index.test.ts @@ -4,65 +4,146 @@ import { PreInitMethodCall, flushAnalyticsCallsInNewTask, PreInitMethodCallBuffer, + PreInitMethodName, } from '..' import { Analytics } from '../../analytics' import { Context } from '../../context' import { sleep } from '../../../lib/sleep' import { User } from '../../user' +import { getBufferedPageCtxFixture } from '../../../test-helpers/fixtures' +import * as GlobalAnalytics from '../../../lib/global-analytics-helper' -describe('PreInitMethodCallBuffer', () => { - describe('push', () => { - it('should return this', async () => { - const buffer = new PreInitMethodCallBuffer() - const result = buffer.push({} as any) - expect(result).toBeInstanceOf(PreInitMethodCallBuffer) +describe(PreInitMethodCallBuffer, () => { + beforeEach(() => { + GlobalAnalytics.setGlobalAnalytics(undefined as any) + }) + + describe('toArray()', () => { + it('should convert the map back to an array', () => { + const call1 = new PreInitMethodCall('identify', [], jest.fn()) + const call2 = new PreInitMethodCall('identify', [], jest.fn()) + const call3 = new PreInitMethodCall('group', [], jest.fn()) + const buffer = new PreInitMethodCallBuffer(call1, call2, call3) + expect(buffer.toArray()).toEqual([call1, call2, call3]) + }) + + it('should also read from global analytics buffer', () => { + const call1 = new PreInitMethodCall('identify', ['foo'], jest.fn()) + ;(window as any).analytics = [['track', 'snippet']] + + const buffer = new PreInitMethodCallBuffer(call1) + const calls = buffer.toArray() + expect(calls.length).toBe(2) + expect(calls[0]).toEqual( + expect.objectContaining>({ + method: 'track', + args: ['snippet', getBufferedPageCtxFixture()], + }) + ) + expect(calls[1]).toEqual(call1) }) }) - describe('toArray should return an array', () => { - it('toArray() should convert the map back to an array', async () => { + + describe('push()', () => { + it('should add method calls', () => { + const call1 = new PreInitMethodCall('identify', [], jest.fn()) const buffer = new PreInitMethodCallBuffer() - const method1 = { method: 'foo' } as any - const method2 = { method: 'foo', args: [1] } as any - const method3 = { method: 'bar' } as any - buffer.push(method1, method2, method3) - expect(buffer.toArray()).toEqual([method1, method2, method3]) + buffer.push(call1) + expect(buffer.toArray()).toEqual([call1]) + }) + + it('should work if the calls were added at different times or in different ways', () => { + const call1 = new PreInitMethodCall('identify', [], jest.fn()) + const call2 = new PreInitMethodCall('identify', [], jest.fn()) + const call3 = new PreInitMethodCall('group', [], jest.fn()) + const call4 = new PreInitMethodCall('group', [], jest.fn()) + const buffer = new PreInitMethodCallBuffer(call1) + buffer.push(call2, call3) + buffer.push(call4) + expect(buffer.toArray()).toEqual([call1, call2, call3, call4]) }) }) - describe('clear', () => { - it('should return this', async () => { + describe('getCalls()', () => { + it('should fetch calls by name', async () => { const buffer = new PreInitMethodCallBuffer() - const result = buffer.push({} as any) - expect(result).toBeInstanceOf(PreInitMethodCallBuffer) + const call1 = new PreInitMethodCall('identify', [], jest.fn()) + const call2 = new PreInitMethodCall('identify', [], jest.fn()) + const call3 = new PreInitMethodCall('group', [], jest.fn()) + buffer.push(call1, call2, call3) + expect(buffer.getCalls('identify')).toEqual([call1, call2]) + expect(buffer.getCalls('group')).toEqual([call3]) + }) + it('should read from Snippet Buffer', () => { + const call1 = new PreInitMethodCall('identify', ['foo'], jest.fn()) + GlobalAnalytics.setGlobalAnalytics([['identify', 'snippet']] as any) + + const buffer = new PreInitMethodCallBuffer(call1) + const calls = buffer.getCalls('identify') + expect(calls.length).toBe(2) + expect(calls[0]).toEqual( + expect.objectContaining>({ + method: 'identify', + args: ['snippet', getBufferedPageCtxFixture()], + }) + ) + expect(calls[1]).toEqual(call1) + }) + }) + describe('clear()', () => { + it('should clear calls', () => { + const call1 = new PreInitMethodCall('identify', [], jest.fn()) + const call2 = new PreInitMethodCall('identify', [], jest.fn()) + const call3 = new PreInitMethodCall('group', [], jest.fn()) + GlobalAnalytics.setGlobalAnalytics([['track', 'bar']] as any) + const buffer = new PreInitMethodCallBuffer(call1, call2, call3) + buffer.clear() + expect(buffer.toArray()).toEqual([]) + expect(GlobalAnalytics.getGlobalAnalytics()).toEqual([]) }) }) - describe('getCalls', () => { - it('should return calls', async () => { - const buffer = new PreInitMethodCallBuffer() - - const fooCall1 = { - method: 'foo', - args: ['bar'], - } as any - - const barCall = { - method: 'bar', - args: ['foobar'], - } as any - const fooCall2 = { - method: 'foo', - args: ['baz'], - } as any + describe('Snippet buffer (method calls)', () => { + it('should be read from the global analytics instance', () => { + const getGlobalAnalyticsSpy = jest.spyOn( + GlobalAnalytics, + 'getGlobalAnalytics' + ) - const calls: PreInitMethodCall[] = [fooCall1, fooCall2, barCall] - const result = buffer.push(...calls) - expect(result.getCalls('foo' as any)).toEqual([fooCall1, fooCall2]) + const buffer = new PreInitMethodCallBuffer() + expect(getGlobalAnalyticsSpy).not.toBeCalled() + buffer.toArray() + expect(getGlobalAnalyticsSpy).toBeCalled() + }) + }) + describe('BufferedPageContext', () => { + test.each([ + 'track', + 'screen', + 'alias', + 'group', + 'page', + 'identify', + ] as PreInitMethodName[])('should be appended to %p calls.', (method) => { + const buffer = new PreInitMethodCallBuffer( + new PreInitMethodCall(method, ['foo'], jest.fn()) + ) + expect(buffer.getCalls(method)[0].args).toEqual([ + 'foo', + getBufferedPageCtxFixture(), + ]) + }) + it('should not be appended for other method calls', () => { + const fn = jest.fn() + const onCall = new PreInitMethodCall('on', ['foo', fn]) + expect(onCall.args).toEqual(['foo', fn]) + const setAnonIdCall = new PreInitMethodCall('setAnonymousId', []) + expect(setAnonIdCall.args).toEqual([]) }) }) }) -describe('AnalyticsBuffered', () => { +describe(AnalyticsBuffered, () => { describe('Happy path', () => { it('should return a promise-like object', async () => { const ajs = new Analytics({ writeKey: 'foo' }) @@ -220,7 +301,7 @@ describe('AnalyticsBuffered', () => { }) }) -describe('callAnalyticsMethod', () => { +describe(callAnalyticsMethod, () => { let ajs!: Analytics let resolveSpy!: jest.Mock let rejectSpy!: jest.Mock @@ -228,14 +309,12 @@ describe('callAnalyticsMethod', () => { beforeEach(() => { resolveSpy = jest.fn().mockImplementation((el) => `resolved: ${el}`) rejectSpy = jest.fn().mockImplementation((el) => `rejected: ${el}`) - methodCall = { - args: ['foo', {}], - called: false, - method: 'track', - resolve: resolveSpy, - reject: rejectSpy, - } as PreInitMethodCall - + methodCall = new PreInitMethodCall( + 'track', + ['foo', {}], + resolveSpy, + rejectSpy + ) ajs = new Analytics({ writeKey: 'abc', }) @@ -297,7 +376,7 @@ describe('callAnalyticsMethod', () => { }) }) -describe('flushAnalyticsCallsInNewTask', () => { +describe(flushAnalyticsCallsInNewTask, () => { test('should defer buffered method calls, regardless of whether or not they are async', async () => { // @ts-ignore Analytics.prototype['synchronousMethod'] = () => 123 @@ -305,27 +384,21 @@ describe('flushAnalyticsCallsInNewTask', () => { // @ts-ignore Analytics.prototype['asyncMethod'] = () => Promise.resolve(123) - const synchronousMethod = { - method: 'synchronousMethod' as any, - args: ['foo'], - called: false, - resolve: jest.fn(), - reject: jest.fn(), - } as PreInitMethodCall - - const asyncMethod = { - method: 'asyncMethod' as any, - args: ['foo'], - called: false, - resolve: jest.fn(), - reject: jest.fn(), - } as PreInitMethodCall - - const buffer = new PreInitMethodCallBuffer().push( - synchronousMethod, - asyncMethod + const synchronousMethod = new PreInitMethodCall( + 'synchronousMethod' as any, + ['foo'], + jest.fn(), + jest.fn() + ) + + const asyncMethod = new PreInitMethodCall( + 'asyncMethod' as any, + ['foo'], + jest.fn(), + jest.fn() ) + const buffer = new PreInitMethodCallBuffer(synchronousMethod, asyncMethod) flushAnalyticsCallsInNewTask(new Analytics({ writeKey: 'abc' }), buffer) expect(synchronousMethod.resolve).not.toBeCalled() expect(asyncMethod.resolve).not.toBeCalled() @@ -338,15 +411,14 @@ describe('flushAnalyticsCallsInNewTask', () => { // @ts-ignore Analytics.prototype['asyncMethod'] = () => Promise.reject('oops!') - const asyncMethod = { - method: 'asyncMethod' as any, - args: ['foo'], - called: false, - resolve: jest.fn(), - reject: jest.fn(), - } as PreInitMethodCall + const asyncMethod = new PreInitMethodCall( + 'asyncMethod' as any, + ['foo'], + jest.fn(), + jest.fn() + ) - const buffer = new PreInitMethodCallBuffer().push(asyncMethod) + const buffer = new PreInitMethodCallBuffer(asyncMethod) flushAnalyticsCallsInNewTask(new Analytics({ writeKey: 'abc' }), buffer) await sleep(0) expect(asyncMethod.reject).toBeCalledWith('oops!') @@ -361,29 +433,23 @@ describe('flushAnalyticsCallsInNewTask', () => { throw new Error('Ooops!') } - const synchronousMethod = { - method: 'synchronousMethod' as any, - args: ['foo'], - called: false, - resolve: jest.fn(), - reject: jest.fn(), - } as PreInitMethodCall - - const asyncMethod = { - method: 'asyncMethod' as any, - args: ['foo'], - called: false, - resolve: jest.fn(), - reject: jest.fn(), - } as PreInitMethodCall - - const buffer = new PreInitMethodCallBuffer().push( - synchronousMethod, - asyncMethod + const synchronousMethod = new PreInitMethodCall( + 'synchronousMethod' as any, + ['foo'], + jest.fn(), + jest.fn() + ) + const asyncMethod = new PreInitMethodCall( + 'asyncMethod' as any, + ['foo'], + jest.fn(), + jest.fn() ) + + const buffer = new PreInitMethodCallBuffer(synchronousMethod, asyncMethod) flushAnalyticsCallsInNewTask(new Analytics({ writeKey: 'abc' }), buffer) await sleep(0) - expect(synchronousMethod.reject).toBeCalled() - expect(asyncMethod.resolve).toBeCalled() + expect(synchronousMethod.reject).toBeCalledTimes(1) + expect(asyncMethod.resolve).toBeCalledTimes(1) }) }) diff --git a/packages/browser/src/core/buffer/index.ts b/packages/browser/src/core/buffer/index.ts index d7b42edb0..e5604cad1 100644 --- a/packages/browser/src/core/buffer/index.ts +++ b/packages/browser/src/core/buffer/index.ts @@ -3,6 +3,14 @@ import { Context } from '../context' import { isThenable } from '../../lib/is-thenable' import { AnalyticsBrowserCore } from '../analytics/interfaces' import { version } from '../../generated/version' +import { getGlobalAnalytics } from '../../lib/global-analytics-helper' +import { + isBufferedPageContext, + BufferedPageContext, + getDefaultBufferedPageContext, + createPageContext, + PageContext, +} from '../page' /** * The names of any AnalyticsBrowser methods that also exist on Analytics @@ -80,10 +88,24 @@ export const flushAnalyticsCallsInNewTask = ( }) } +export const popPageContext = (args: unknown[]): PageContext | undefined => { + if (hasBufferedPageContextAsLastArg(args)) { + const ctx = args.pop() as BufferedPageContext + return createPageContext(ctx) + } +} + +export const hasBufferedPageContextAsLastArg = ( + args: unknown[] +): args is [...unknown[], BufferedPageContext] | [BufferedPageContext] => { + const lastArg = args[args.length - 1] + return isBufferedPageContext(lastArg) +} + /** * Represents a buffered method call that occurred before initialization. */ -export interface PreInitMethodCall< +export class PreInitMethodCall< MethodName extends PreInitMethodName = PreInitMethodName > { method: MethodName @@ -91,10 +113,23 @@ export interface PreInitMethodCall< called: boolean resolve: (v: ReturnType) => void reject: (reason: any) => void + constructor( + method: PreInitMethodCall['method'], + args: PreInitMethodParams, + resolve: PreInitMethodCall['resolve'] = () => {}, + reject: PreInitMethodCall['reject'] = console.error + ) { + this.method = method + this.resolve = resolve + this.reject = reject + this.called = false + this.args = args + } } export type PreInitMethodParams = - Parameters + | [...Parameters, BufferedPageContext] + | Parameters /** * Infer return type; if return type is promise, unwrap it. @@ -107,34 +142,91 @@ type ReturnTypeUnwrap = Fn extends (...args: any[]) => infer ReturnT type MethodCallMap = Partial> +type SnippetWindowBufferedMethodCall< + MethodName extends PreInitMethodName = PreInitMethodName +> = [MethodName, ...PreInitMethodParams] + +/** + * A list of the method calls before initialization for snippet users + * For example, [["track", "foo", {bar: 123}], ["page"], ["on", "ready", function(){..}] + */ +type SnippetBuffer = SnippetWindowBufferedMethodCall[] + /** * Represents any and all the buffered method calls that occurred before initialization. */ export class PreInitMethodCallBuffer { - private _value = {} as MethodCallMap + private _callMap: MethodCallMap = {} - toArray(): PreInitMethodCall[] { - return ([] as PreInitMethodCall[]).concat(...Object.values(this._value)) + constructor(...calls: PreInitMethodCall[]) { + this.push(...calls) + } + + /** + * Pull any buffered method calls from the window object, and use them to hydrate the instance buffer. + */ + private get calls() { + this._pushSnippetWindowBuffer() + return this._callMap + } + + private set calls(calls: MethodCallMap) { + this._callMap = calls } getCalls(methodName: T): PreInitMethodCall[] { - return (this._value[methodName] ?? []) as PreInitMethodCall[] + return (this.calls[methodName] ?? []) as PreInitMethodCall[] } - push(...calls: PreInitMethodCall[]): PreInitMethodCallBuffer { + push(...calls: PreInitMethodCall[]): void { calls.forEach((call) => { - if (this._value[call.method]) { - this._value[call.method]!.push(call) + const eventsExpectingPageContext: PreInitMethodName[] = [ + 'track', + 'screen', + 'alias', + 'group', + 'page', + 'identify', + ] + if ( + eventsExpectingPageContext.includes(call.method) && + !hasBufferedPageContextAsLastArg(call.args) + ) { + call.args = [...call.args, getDefaultBufferedPageContext()] + } + + if (this.calls[call.method]) { + this.calls[call.method]!.push(call) } else { - this._value[call.method] = [call] + this.calls[call.method] = [call] } }) - return this } - clear(): PreInitMethodCallBuffer { - this._value = {} as MethodCallMap - return this + clear(): void { + // clear calls in the global snippet buffered array. + this._pushSnippetWindowBuffer() + // clear calls in this instance + this.calls = {} + } + + toArray(): PreInitMethodCall[] { + return ([] as PreInitMethodCall[]).concat(...Object.values(this.calls)) + } + + /** + * Fetch the buffered method calls from the window object, + * normalize them, and use them to hydrate the buffer. + * This removes existing buffered calls from the window object. + */ + private _pushSnippetWindowBuffer(): void { + const wa = getGlobalAnalytics() + if (!Array.isArray(wa)) return undefined + const buffered: SnippetBuffer = wa.splice(0, wa.length) + const calls = buffered.map( + ([methodName, ...args]) => new PreInitMethodCall(methodName, args) + ) + this.push(...calls) } } @@ -176,9 +268,10 @@ export class AnalyticsBuffered { instance?: Analytics ctx?: Context - private _preInitBuffer = new PreInitMethodCallBuffer() + private _preInitBuffer: PreInitMethodCallBuffer private _promise: Promise<[Analytics, Context]> constructor(loader: AnalyticsLoader) { + this._preInitBuffer = new PreInitMethodCallBuffer() this._promise = loader(this._preInitBuffer) this._promise .then(([ajs, ctx]) => { @@ -251,15 +344,10 @@ export class AnalyticsBuffered const result = (this.instance[methodName] as Function)(...args) return Promise.resolve(result) } - return new Promise((resolve, reject) => { - this._preInitBuffer.push({ - method: methodName, - args, - resolve: resolve, - reject: reject, - called: false, - } as PreInitMethodCall) + this._preInitBuffer.push( + new PreInitMethodCall(methodName, args, resolve as any, reject) + ) }) } } @@ -274,13 +362,7 @@ export class AnalyticsBuffered void (this.instance[methodName] as Function)(...args) return this } else { - this._preInitBuffer.push({ - method: methodName, - args, - resolve: () => {}, - reject: console.error, - called: false, - } as PreInitMethodCall) + this._preInitBuffer.push(new PreInitMethodCall(methodName, args)) } return this diff --git a/packages/browser/src/core/buffer/snippet.ts b/packages/browser/src/core/buffer/snippet.ts deleted file mode 100644 index 73612b3f6..000000000 --- a/packages/browser/src/core/buffer/snippet.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { - PreInitMethodCall, - PreInitMethodName, - PreInitMethodParams, -} from '.' -import { getGlobalAnalytics } from '../../lib/global-analytics-helper' - -export function transformSnippetCall([ - methodName, - ...args -]: SnippetWindowBufferedMethodCall): PreInitMethodCall { - return { - method: methodName, - resolve: () => {}, - reject: console.error, - args, - called: false, - } -} - -const normalizeSnippetBuffer = (buffer: SnippetBuffer): PreInitMethodCall[] => { - return buffer.map(transformSnippetCall) -} - -type SnippetWindowBufferedMethodCall< - MethodName extends PreInitMethodName = PreInitMethodName -> = [MethodName, ...PreInitMethodParams] - -/** - * A list of the method calls before initialization for snippet users - * For example, [["track", "foo", {bar: 123}], ["page"], ["on", "ready", function(){..}] - */ -export type SnippetBuffer = SnippetWindowBufferedMethodCall[] - -/** - * Fetch the buffered method calls from the window object and normalize them. - * This removes existing buffered calls from the window object. - */ -export const popSnippetWindowBuffer = ( - buffer: unknown = getGlobalAnalytics() -): PreInitMethodCall[] => { - const wa = buffer - if (!Array.isArray(wa)) return [] - const buffered = wa.splice(0, wa.length) - return normalizeSnippetBuffer(buffered) -} diff --git a/packages/browser/src/core/events/__tests__/index.test.ts b/packages/browser/src/core/events/__tests__/index.test.ts index 1b939011f..d9b53250a 100644 --- a/packages/browser/src/core/events/__tests__/index.test.ts +++ b/packages/browser/src/core/events/__tests__/index.test.ts @@ -1,6 +1,7 @@ import uuid from '@lukeed/uuid' import { range, uniq } from 'lodash' import { EventFactory } from '..' +import { getDefaultPageContext } from '../../page' import { User } from '../../user' import { SegmentEvent, Options } from '../interfaces' @@ -11,10 +12,15 @@ describe('Event Factory', () => { const shoes = { product: 'shoes', total: '$35', category: 'category' } const shopper = { totalSpent: 100 } + const defaultContext = { + page: getDefaultPageContext(), + } + beforeEach(() => { user = new User() user.reset() factory = new EventFactory(user) + defaultContext.page = getDefaultPageContext() }) describe('alias', () => { @@ -67,7 +73,7 @@ describe('Event Factory', () => { it('accepts properties', () => { const page = factory.page('category', 'name', shoes) - expect(page.properties).toEqual(shoes) + expect(page.properties).toEqual(expect.objectContaining(shoes)) }) it('ignores category and page if not passed in', () => { @@ -153,7 +159,7 @@ describe('Event Factory', () => { const track = factory.track('Order Completed', shoes, { opt1: true, }) - expect(track.context).toEqual({ opt1: true }) + expect(track.context).toEqual({ ...defaultContext, opt1: true }) }) test('sets context correctly if property arg is undefined', () => { @@ -161,7 +167,10 @@ describe('Event Factory', () => { context: { page: { path: '/custom' } }, }) - expect(track.context).toEqual({ page: { path: '/custom' } }) + expect(track.context?.page).toEqual({ + ...defaultContext.page, + path: '/custom', + }) }) test('sets integrations', () => { @@ -243,7 +252,11 @@ describe('Event Factory', () => { }, }) - expect(track.context).toEqual({ opt1: true, opt2: 'πŸ₯' }) + expect(track.context).toEqual({ + ...defaultContext, + opt1: true, + opt2: 'πŸ₯', + }) }) test('should not move known options into `context`', () => { @@ -257,7 +270,11 @@ describe('Event Factory', () => { timestamp: new Date(), }) - expect(track.context).toEqual({ opt1: true, opt2: 'πŸ₯' }) + expect(track.context).toEqual({ + ...defaultContext, + opt1: true, + opt2: 'πŸ₯', + }) }) test('accepts an anonymous id', () => { @@ -265,7 +282,7 @@ describe('Event Factory', () => { anonymousId: 'anon-1', }) - expect(track.context).toEqual({}) + expect(track.context).toEqual(defaultContext) expect(track.anonymousId).toEqual('anon-1') }) @@ -275,7 +292,7 @@ describe('Event Factory', () => { timestamp, }) - expect(track.context).toEqual({}) + expect(track.context).toEqual(defaultContext) expect(track.timestamp).toEqual(timestamp) }) @@ -302,6 +319,7 @@ describe('Event Factory', () => { }) expect(track.context).toEqual({ + ...defaultContext, library: { name: 'ajs-next', version: '0.1.0', @@ -322,6 +340,7 @@ describe('Event Factory', () => { }) expect(track.context).toEqual({ + ...defaultContext, library: { name: 'ajs-next', version: '0.1.0', @@ -395,9 +414,37 @@ describe('Event Factory', () => { integrations: { Segment: true }, type: 'track', userId: 'user-id', - context: {}, + context: defaultContext, }) }) }) }) + + describe('Page context augmentation', () => { + // minimal tests -- more tests should be specifically around addPageContext + factory = new EventFactory(new User()) + it('adds a default pageContext if pageContext is not defined', () => { + const event = factory.identify('foo') + expect(event.context?.page).toEqual(defaultContext.page) + }) + + const pageCtx = { ...defaultContext.page, title: 'foo test' } + test.each([ + factory.identify('foo', undefined, undefined, undefined, { + ...pageCtx, + }), + factory.track('foo', undefined, undefined, undefined, { + ...pageCtx, + }), + factory.group('foo', undefined, undefined, undefined, { + ...pageCtx, + }), + factory.page('foo', 'bar', undefined, undefined, undefined, { + ...pageCtx, + }), + factory.alias('foo', 'bar', undefined, undefined, { ...pageCtx }), + ])(`$type: Event has the expected page properties`, async (event) => { + expect(event.context?.page).toEqual(pageCtx) + }) + }) }) diff --git a/packages/browser/src/core/events/index.ts b/packages/browser/src/core/events/index.ts index 8af4820f9..b213cc55b 100644 --- a/packages/browser/src/core/events/index.ts +++ b/packages/browser/src/core/events/index.ts @@ -9,30 +9,31 @@ import { SegmentEvent, } from './interfaces' import md5 from 'spark-md5' +import { addPageContext, PageContext } from '../page' export * from './interfaces' export class EventFactory { - user: User - - constructor(user: User) { - this.user = user - } + constructor(public user: User) {} track( event: string, properties?: EventProperties, options?: Options, - globalIntegrations?: Integrations + globalIntegrations?: Integrations, + pageCtx?: PageContext ): SegmentEvent { - return this.normalize({ - ...this.baseEvent(), - event, - type: 'track' as const, - properties, - options: { ...options }, - integrations: { ...globalIntegrations }, - }) + return this.normalize( + { + ...this.baseEvent(), + event, + type: 'track' as const, + properties, + options: { ...options }, + integrations: { ...globalIntegrations }, + }, + pageCtx + ) } page( @@ -40,7 +41,8 @@ export class EventFactory { page: string | null, properties?: EventProperties, options?: Options, - globalIntegrations?: Integrations + globalIntegrations?: Integrations, + pageCtx?: PageContext ): SegmentEvent { const event: Partial = { type: 'page' as const, @@ -59,10 +61,13 @@ export class EventFactory { event.name = page } - return this.normalize({ - ...this.baseEvent(), - ...event, - } as SegmentEvent) + return this.normalize( + { + ...this.baseEvent(), + ...event, + } as SegmentEvent, + pageCtx + ) } screen( @@ -70,7 +75,8 @@ export class EventFactory { screen: string | null, properties?: EventProperties, options?: Options, - globalIntegrations?: Integrations + globalIntegrations?: Integrations, + pageCtx?: PageContext ): SegmentEvent { const event: Partial = { type: 'screen' as const, @@ -86,50 +92,61 @@ export class EventFactory { if (screen !== null) { event.name = screen } - - return this.normalize({ - ...this.baseEvent(), - ...event, - } as SegmentEvent) + return this.normalize( + { + ...this.baseEvent(), + ...event, + } as SegmentEvent, + pageCtx + ) } identify( userId: ID, traits?: Traits, options?: Options, - globalIntegrations?: Integrations + globalIntegrations?: Integrations, + pageCtx?: PageContext ): SegmentEvent { - return this.normalize({ - ...this.baseEvent(), - type: 'identify' as const, - userId, - traits, - options: { ...options }, - integrations: { ...globalIntegrations }, - }) + return this.normalize( + { + ...this.baseEvent(), + type: 'identify' as const, + userId, + traits, + options: { ...options }, + integrations: { ...globalIntegrations }, + }, + pageCtx + ) } group( groupId: ID, traits?: Traits, options?: Options, - globalIntegrations?: Integrations + globalIntegrations?: Integrations, + pageCtx?: PageContext ): SegmentEvent { - return this.normalize({ - ...this.baseEvent(), - type: 'group' as const, - traits, - options: { ...options }, - integrations: { ...globalIntegrations }, - groupId, - }) + return this.normalize( + { + ...this.baseEvent(), + type: 'group' as const, + traits, + options: { ...options }, + integrations: { ...globalIntegrations }, + groupId, + }, + pageCtx + ) } alias( to: string, from: string | null, options?: Options, - globalIntegrations?: Integrations + globalIntegrations?: Integrations, + pageCtx?: PageContext ): SegmentEvent { const base: Partial = { userId: to, @@ -149,10 +166,13 @@ export class EventFactory { } as SegmentEvent) } - return this.normalize({ - ...this.baseEvent(), - ...base, - } as SegmentEvent) + return this.normalize( + { + ...this.baseEvent(), + ...base, + } as SegmentEvent, + pageCtx + ) } private baseEvent(): Partial { @@ -204,7 +224,7 @@ export class EventFactory { return [context, overrides] } - public normalize(event: SegmentEvent): SegmentEvent { + public normalize(event: SegmentEvent, pageCtx?: PageContext): SegmentEvent { // set anonymousId globally if we encounter an override //segment.com/docs/connections/sources/catalog/libraries/website/javascript/identity/#override-the-anonymous-id-using-the-options-object event.options?.anonymousId && @@ -235,21 +255,16 @@ export class EventFactory { const [context, overrides] = this.context(event) const { options, ...rest } = event - const body = { + const newEvent: SegmentEvent = { timestamp: new Date(), ...rest, context, integrations: allIntegrations, ...overrides, + messageId: 'ajs-next-' + md5.hash(JSON.stringify(event) + uuid()), } + addPageContext(newEvent, pageCtx) - const messageId = 'ajs-next-' + md5.hash(JSON.stringify(body) + uuid()) - - const evt: SegmentEvent = { - ...body, - messageId, - } - - return evt + return newEvent } } diff --git a/packages/browser/src/core/page/__tests__/index.test.ts b/packages/browser/src/core/page/__tests__/index.test.ts new file mode 100644 index 000000000..b1f166cff --- /dev/null +++ b/packages/browser/src/core/page/__tests__/index.test.ts @@ -0,0 +1,130 @@ +import { + BufferedPageContextDiscriminant, + getDefaultBufferedPageContext, + getDefaultPageContext, + isBufferedPageContext, +} from '../' +import { pickBy } from 'lodash' + +const originalLocation = window.location +beforeEach(() => { + Object.defineProperty(window, 'location', { + value: { + ...originalLocation, + }, + writable: true, + }) +}) + +describe(isBufferedPageContext, () => { + it('should return true if object is page context', () => { + expect(isBufferedPageContext(getDefaultBufferedPageContext())).toBe(true) + }) + it('should return false if object is not page context', () => { + expect(isBufferedPageContext(undefined)).toBe(false) + expect(isBufferedPageContext({})).toBe(false) + expect(isBufferedPageContext('')).toBe(false) + expect(isBufferedPageContext({ foo: false })).toBe(false) + expect(isBufferedPageContext({ u: 'hello' })).toBe(false) + expect(isBufferedPageContext(null)).toBe(false) + + expect( + isBufferedPageContext({ + ...getDefaultBufferedPageContext(), + some_unknown_key: 123, + }) + ).toBe(false) + + const missingDiscriminant = pickBy( + getDefaultBufferedPageContext(), + (v) => v !== BufferedPageContextDiscriminant + ) + // should not be missing the dscriminant + expect(isBufferedPageContext(missingDiscriminant)).toBe(false) + }) +}) + +describe(getDefaultPageContext, () => { + describe('hash', () => { + it('strips the hash from the URL', () => { + window.location.href = 'http://www.segment.local#test' + const defs = getDefaultPageContext() + expect(defs.url).toBe('http://www.segment.local') + + window.location.href = 'http://www.segment.local/#test' + const defs2 = getDefaultPageContext() + expect(defs2.url).toBe('http://www.segment.local/') + }) + }) + + describe('canonical URL', () => { + const el = document.createElement('link') + beforeEach(() => { + el.setAttribute('rel', 'canonical') + el.setAttribute('href', '') + document.clear() + }) + + it('returns location.href if canonical URL does not exist', () => { + el.setAttribute('rel', 'nope') + document.body.appendChild(el) + const defs = getDefaultPageContext() + expect(defs.url).toEqual(window.location.href) + }) + + it('does not throw an error if canonical URL is not a valid URL', () => { + el.setAttribute('href', 'foo.com/bar') + document.body.appendChild(el) + const defs = getDefaultPageContext() + expect(defs.url).toEqual('foo.com/bar') // this is analytics.js classic behavior + expect(defs.path).toEqual('/foo.com/bar') // this is analytics.js classic behavior + }) + + it('handles a leading slash', () => { + el.setAttribute('href', 'foo') + document.body.appendChild(el) + const defs = getDefaultPageContext() + expect(defs.url).toEqual('foo') + expect(defs.path).toEqual('/foo') // this is analytics.js classic behavior + }) + + it('handles canonical links', () => { + el.setAttribute('href', 'http://www.segment.local') + document.body.appendChild(el) + const defs = getDefaultPageContext() + expect(defs.url).toEqual('http://www.segment.local') + }) + + it('favors canonical path over location.pathname', () => { + window.location.pathname = '/nope' + el.setAttribute('href', 'http://www.segment.local/test') + document.body.appendChild(el) + const defs = getDefaultPageContext() + expect(defs.path).toEqual('/test') + }) + + it('handles canonical links with a path', () => { + el.setAttribute('href', 'http://www.segment.local/test') + document.body.appendChild(el) + const defs = getDefaultPageContext() + expect(defs.url).toEqual('http://www.segment.local/test') + expect(defs.path).toEqual('/test') + }) + + it('handles canonical links with search params in the url', () => { + el.setAttribute('href', 'http://www.segment.local?test=true') + document.body.appendChild(el) + const defs = getDefaultPageContext() + expect(defs.url).toEqual('http://www.segment.local?test=true') + }) + + it('will add search params from the document to the canonical path if it does not have search params', () => { + // This seems like weird behavior to me, but I found it in the codebase so adding a test for it. + window.location.search = '?foo=123' + el.setAttribute('href', 'http://www.segment.local') + document.body.appendChild(el) + const defs = getDefaultPageContext() + expect(defs.url).toEqual('http://www.segment.local?foo=123') + }) + }) +}) diff --git a/packages/browser/src/core/page/add-page-context.ts b/packages/browser/src/core/page/add-page-context.ts new file mode 100644 index 000000000..9d2c61148 --- /dev/null +++ b/packages/browser/src/core/page/add-page-context.ts @@ -0,0 +1,33 @@ +import { pick } from '../../lib/pick' +import { EventProperties, SegmentEvent } from '../events' +import { getDefaultPageContext } from './get-page-context' + +/** + * Augments a segment event with information about the current page. + * Page information like URL changes frequently, so this is meant to be captured as close to the event call as possible. + * Things like `userAgent` do not change, so they can be added later in the flow. + * We prefer not to add this information to this function, as it increases the main bundle size. + */ +export const addPageContext = ( + event: SegmentEvent, + pageCtx = getDefaultPageContext() +): void => { + const evtCtx = event.context! // Context should be set earlier in the flow + let pageContextFromEventProps: Pick | undefined + if (event.type === 'page') { + pageContextFromEventProps = + event.properties && pick(event.properties, Object.keys(pageCtx)) + + event.properties = { + ...pageCtx, + ...event.properties, + ...(event.name ? { name: event.name } : {}), + } + } + + evtCtx.page = { + ...pageCtx, + ...pageContextFromEventProps, + ...evtCtx.page, + } +} diff --git a/packages/browser/src/core/page/get-page-context.ts b/packages/browser/src/core/page/get-page-context.ts new file mode 100644 index 000000000..3a56bd783 --- /dev/null +++ b/packages/browser/src/core/page/get-page-context.ts @@ -0,0 +1,140 @@ +import { isPlainObject } from '@segment/analytics-core' + +/** + * Final Page Context object expected in the Segment Event context + */ +export interface PageContext { + path: string + referrer: string + search: string + title: string + url: string +} + +type CanonicalUrl = string | undefined + +export const BufferedPageContextDiscriminant = 'bpc' as const +/** + * Page Context expected to be built by the snippet. + * Note: The key names are super short because we want to keep the strings in the html snippet short to save bytes. + */ +export interface BufferedPageContext { + __t: typeof BufferedPageContextDiscriminant // for extra uniqeness + c: CanonicalUrl + p: PageContext['path'] + u: PageContext['url'] + s: PageContext['search'] + t: PageContext['title'] + r: PageContext['referrer'] +} + +/** + * `BufferedPageContext` object builder + */ +export const createBufferedPageContext = ( + url: string, + canonicalUrl: CanonicalUrl, + search: string, + path: string, + title: string, + referrer: string +): BufferedPageContext => ({ + __t: BufferedPageContextDiscriminant, + c: canonicalUrl, + p: path, + u: url, + s: search, + t: title, + r: referrer, +}) + +// my clever/dubious way of making sure this type guard does not get out sync with the type definition +const BUFFERED_PAGE_CONTEXT_KEYS = Object.keys( + createBufferedPageContext('', '', '', '', '', '') +) as (keyof BufferedPageContext)[] + +export function isBufferedPageContext( + bufferedPageCtx: unknown +): bufferedPageCtx is BufferedPageContext { + if (!isPlainObject(bufferedPageCtx)) return false + if (bufferedPageCtx.__t !== BufferedPageContextDiscriminant) return false + + // ensure obj has all the keys we expect, and none we don't. + for (const k in bufferedPageCtx) { + if (!BUFFERED_PAGE_CONTEXT_KEYS.includes(k as keyof BufferedPageContext)) { + return false + } + } + return true +} + +// Legacy logic: we are we appending search parameters to the canonical URL -- I guess the canonical URL is "not canonical enough" (lol) +const createCanonicalURL = (canonicalUrl: string, searchParams: string) => { + return canonicalUrl.indexOf('?') > -1 + ? canonicalUrl + : canonicalUrl + searchParams +} + +/** + * Strips hash from URL. + * http://www.segment.local#test -> http://www.segment.local + */ +const removeHash = (href: string) => { + const hashIdx = href.indexOf('#') + return hashIdx === -1 ? href : href.slice(0, hashIdx) +} + +const parseCanonicalPath = (canonicalUrl: string): string => { + try { + return new URL(canonicalUrl).pathname + } catch (_e) { + // this is classic behavior -- we assume that if the canonical URL is invalid, it's a raw path. + return canonicalUrl[0] === '/' ? canonicalUrl : '/' + canonicalUrl + } +} + +/** + * Create a `PageContext` from a `BufferedPageContext`. + * `BufferedPageContext` keys are minified to save bytes in the snippet. + */ +export const createPageContext = ({ + c: canonicalUrl, + p: pathname, + s: search, + u: url, + r: referrer, + t: title, +}: BufferedPageContext): PageContext => { + const newPath = canonicalUrl ? parseCanonicalPath(canonicalUrl) : pathname + const newUrl = canonicalUrl + ? createCanonicalURL(canonicalUrl, search) + : removeHash(url) + return { + path: newPath, + referrer, + search, + title, + url: newUrl, + } +} + +/** + * Get page properties from the browser window/document. + */ +export const getDefaultBufferedPageContext = (): BufferedPageContext => { + const c = document.querySelector("link[rel='canonical']") + return createBufferedPageContext( + location.href, + (c && c.getAttribute('href')) || undefined, + location.search, + location.pathname, + document.title, + document.referrer + ) +} + +/** + * Get page properties from the browser window/document. + */ +export const getDefaultPageContext = (): PageContext => + createPageContext(getDefaultBufferedPageContext()) diff --git a/packages/browser/src/core/page/index.ts b/packages/browser/src/core/page/index.ts new file mode 100644 index 000000000..c6ff6b3cf --- /dev/null +++ b/packages/browser/src/core/page/index.ts @@ -0,0 +1,2 @@ +export * from './get-page-context' +export * from './add-page-context' diff --git a/packages/browser/src/core/stats/__tests__/remote-metrics.test.ts b/packages/browser/src/core/stats/__tests__/remote-metrics.test.ts index 354a9cc3e..5decef366 100644 --- a/packages/browser/src/core/stats/__tests__/remote-metrics.test.ts +++ b/packages/browser/src/core/stats/__tests__/remote-metrics.test.ts @@ -32,7 +32,7 @@ describe('remote metrics', () => { }) remote.increment('analytics_js.banana', ['phone:1']) - expect(remote.queue).toMatchInlineSnapshot(`Array []`) + expect(remote.queue).toMatchInlineSnapshot(`[]`) }) test('ignores messages after reaching threshold', () => { @@ -80,9 +80,9 @@ describe('remote metrics', () => { expect(request).toMatchInlineSnapshot( { body: expect.anything() }, ` - Object { + { "body": Anything, - "headers": Object { + "headers": { "Content-Type": "text/plain", }, "method": "POST", @@ -101,11 +101,11 @@ describe('remote metrics', () => { ], }, ` - Object { - "series": Array [ - Object { + { + "series": [ + { "metric": "analytics_js.banana", - "tags": Object { + "tags": { "library": "analytics.js", "library_version": Any, "phone": "1", diff --git a/packages/browser/src/core/stats/metric-helpers.ts b/packages/browser/src/core/stats/metric-helpers.ts new file mode 100644 index 000000000..f6f88eb33 --- /dev/null +++ b/packages/browser/src/core/stats/metric-helpers.ts @@ -0,0 +1,28 @@ +import { Context } from '../context' + +export interface RecordIntegrationMetricProps { + integrationName: string + methodName: string + didError?: boolean + type: 'classic' | 'action' +} + +export function recordIntegrationMetric( + ctx: Context, + { + methodName, + integrationName, + type, + didError = false, + }: RecordIntegrationMetricProps +): void { + ctx.stats.increment( + `analytics_js.integration.invoke${didError ? '.error' : ''}`, + 1, + [ + `method:${methodName}`, + `integration_name:${integrationName}`, + `type:${type}`, + ] + ) +} diff --git a/packages/browser/src/core/stats/remote-metrics.ts b/packages/browser/src/core/stats/remote-metrics.ts index 65c23aa1a..82f5bfacf 100644 --- a/packages/browser/src/core/stats/remote-metrics.ts +++ b/packages/browser/src/core/stats/remote-metrics.ts @@ -8,6 +8,7 @@ export interface MetricsOptions { sampleRate?: number flushTimer?: number maxQueueSize?: number + protocol?: 'http' | 'https' } /** @@ -56,6 +57,7 @@ export class RemoteMetrics { private host: string private flushTimer: number private maxQueueSize: number + private protocol: string sampleRate: number queue: RemoteMetric[] @@ -65,6 +67,7 @@ export class RemoteMetrics { this.sampleRate = options?.sampleRate ?? 1 this.flushTimer = options?.flushTimer ?? 30 * 1000 /* 30s */ this.maxQueueSize = options?.maxQueueSize ?? 20 + this.protocol = options?.protocol ?? 'https' this.queue = [] @@ -130,7 +133,7 @@ export class RemoteMetrics { this.queue = [] const headers = { 'Content-Type': 'text/plain' } - const url = `https://${this.host}/m` + const url = `${this.protocol}://${this.host}/m` return fetch(url, { headers, diff --git a/packages/browser/src/core/storage/__tests__/test-helpers.ts b/packages/browser/src/core/storage/__tests__/test-helpers.ts index 20faf069a..0971bdc57 100644 --- a/packages/browser/src/core/storage/__tests__/test-helpers.ts +++ b/packages/browser/src/core/storage/__tests__/test-helpers.ts @@ -1,16 +1,15 @@ import jar from 'js-cookie' +const throwDisabledError = () => { + throw new Error('__sorry brah, this storage is disabled__') +} /** * Disables Cookies * @returns jest spy */ export function disableCookies(): void { jest.spyOn(window.navigator, 'cookieEnabled', 'get').mockReturnValue(false) - jest.spyOn(jar, 'set').mockImplementation(() => { - throw new Error() - }) - jest.spyOn(jar, 'get').mockImplementation(() => { - throw new Error() - }) + jest.spyOn(jar, 'set').mockImplementation(throwDisabledError) + jest.spyOn(jar, 'get').mockImplementation(throwDisabledError) } /** @@ -18,10 +17,10 @@ export function disableCookies(): void { * @returns jest spy */ export function disableLocalStorage(): void { - jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { - throw new Error() - }) - jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { - throw new Error() - }) + jest + .spyOn(Storage.prototype, 'setItem') + .mockImplementation(throwDisabledError) + jest + .spyOn(Storage.prototype, 'getItem') + .mockImplementation(throwDisabledError) } diff --git a/packages/browser/src/core/storage/__tests__/universalStorage.test.ts b/packages/browser/src/core/storage/__tests__/universalStorage.test.ts index 3c813ebb9..e872fa98d 100644 --- a/packages/browser/src/core/storage/__tests__/universalStorage.test.ts +++ b/packages/browser/src/core/storage/__tests__/universalStorage.test.ts @@ -12,12 +12,14 @@ describe('UniversalStorage', function () { new MemoryStorage(), ] const getFromLS = (key: string) => JSON.parse(localStorage.getItem(key) ?? '') - beforeEach(function () { - clear() + jest.spyOn(console, 'warn').mockImplementation(() => { + // avoid accidental noise in console + throw new Error('console.warn should be mocked!') }) - afterEach(() => { + beforeEach(function () { jest.restoreAllMocks() + clear() }) function clear(): void { @@ -104,6 +106,10 @@ describe('UniversalStorage', function () { }) it('handles cookie errors gracefully', function () { + const consoleWarnSpy = jest + .spyOn(console, 'warn') + .mockImplementation(() => {}) + disableCookies() // Cookies is going to throw exceptions now const us = new UniversalStorage([ new LocalStorage(), @@ -113,9 +119,17 @@ describe('UniversalStorage', function () { us.set('ajs_test_key', 'πŸ’°') expect(getFromLS('ajs_test_key')).toEqual('πŸ’°') expect(us.get('ajs_test_key')).toEqual('πŸ’°') + expect(consoleWarnSpy.mock.calls.length).toEqual(1) + expect(consoleWarnSpy.mock.lastCall![0]).toContain( + "CookieStorage: Can't set key" + ) }) it('does not write to LS when LS is not available', function () { + const consoleWarnSpy = jest + .spyOn(console, 'warn') + .mockImplementation(() => {}) + disableLocalStorage() // Localstorage will throw exceptions const us = new UniversalStorage([ new LocalStorage(), @@ -125,9 +139,14 @@ describe('UniversalStorage', function () { us.set('ajs_test_key', 'πŸ’°') expect(jar.get('ajs_test_key')).toEqual('πŸ’°') expect(us.get('ajs_test_key')).toEqual('πŸ’°') + expect(consoleWarnSpy.mock.lastCall![0]).toContain('localStorage') }) it('handles cookie getter overrides gracefully', function () { + const consoleWarnSpy = jest + .spyOn(console, 'warn') + .mockImplementation(() => {}) + ;(document as any).__defineGetter__('cookie', function () { return '' }) @@ -139,6 +158,10 @@ describe('UniversalStorage', function () { us.set('ajs_test_key', 'πŸ’°') expect(getFromLS('ajs_test_key')).toEqual('πŸ’°') expect(us.get('ajs_test_key')).toEqual('πŸ’°') + expect(consoleWarnSpy.mock.lastCall![0]).toContain( + "CookieStorage: Can't set key" + ) + expect(consoleWarnSpy.mock.lastCall![0]).toContain('TypeError') }) }) }) diff --git a/packages/browser/src/core/storage/universalStorage.ts b/packages/browser/src/core/storage/universalStorage.ts index 41cebf926..23dfefa3c 100644 --- a/packages/browser/src/core/storage/universalStorage.ts +++ b/packages/browser/src/core/storage/universalStorage.ts @@ -1,5 +1,17 @@ import { Store, StorageObject } from './types' +// not adding to private method because those method names do not get minified atm, and does not use 'this' +const _logStoreKeyError = ( + store: Store, + action: 'set' | 'get' | 'remove', + key: string, + err: unknown +) => { + console.warn( + `${store.constructor.name}: Can't ${action} key "${key}" | Err: ${err}` + ) +} + /** * Uses multiple storages in a priority list to get/set values in the order they are specified. */ @@ -20,28 +32,28 @@ export class UniversalStorage { return val } } catch (e) { - console.warn(`Can't access ${key}: ${e}`) + _logStoreKeyError(store, 'get', key, e) } } return null } set(key: K, value: Data[K] | null): void { - this.stores.forEach((s) => { + this.stores.forEach((store) => { try { - s.set(key, value) + store.set(key, value) } catch (e) { - console.warn(`Can't set ${key}: ${e}`) + _logStoreKeyError(store, 'set', key, e) } }) } clear(key: K): void { - this.stores.forEach((s) => { + this.stores.forEach((store) => { try { - s.remove(key) + store.remove(key) } catch (e) { - console.warn(`Can't remove ${key}: ${e}`) + _logStoreKeyError(store, 'remove', key, e) } }) } diff --git a/packages/browser/src/generated/version.ts b/packages/browser/src/generated/version.ts index 3e0d2d55c..b78db69d5 100644 --- a/packages/browser/src/generated/version.ts +++ b/packages/browser/src/generated/version.ts @@ -1,2 +1,2 @@ // This file is generated. -export const version = '1.56.0' +export const version = '1.66.0' diff --git a/packages/browser/src/lib/__tests__/merged-options.test.ts b/packages/browser/src/lib/__tests__/merged-options.test.ts index 4d384516d..6f1a96f0d 100644 --- a/packages/browser/src/lib/__tests__/merged-options.test.ts +++ b/packages/browser/src/lib/__tests__/merged-options.test.ts @@ -21,11 +21,11 @@ describe(mergedOptions, () => { ) expect(merged).toMatchInlineSnapshot(` - Object { - "Amplitude": Object { + { + "Amplitude": { "apiKey": "🍌", }, - "CustomerIO": Object { + "CustomerIO": { "ghost": "πŸ‘»", }, } @@ -52,8 +52,8 @@ describe(mergedOptions, () => { ) expect(merged).toMatchInlineSnapshot(` - Object { - "Amplitude": Object { + { + "Amplitude": { "apiKey": "🍌", }, } @@ -81,11 +81,11 @@ describe(mergedOptions, () => { ) expect(merged).toMatchInlineSnapshot(` - Object { - "Amplitude": Object { + { + "Amplitude": { "apiKey": "🍌", }, - "CustomerIO": Object { + "CustomerIO": { "ghost": "πŸ‘»", }, } @@ -110,11 +110,11 @@ describe(mergedOptions, () => { } expect(mergedOptions(cdn, overrides)).toMatchInlineSnapshot(` - Object { - "Google Tag Manager": Object { + { + "Google Tag Manager": { "ghost": "πŸ‘»", }, - "Segment.io": Object { + "Segment.io": { "apiHost": "mgs.instacart.com/v2", }, } diff --git a/packages/browser/src/lib/__tests__/pick.test.ts b/packages/browser/src/lib/__tests__/pick.test.ts index 103dd5c27..2729ab492 100644 --- a/packages/browser/src/lib/__tests__/pick.test.ts +++ b/packages/browser/src/lib/__tests__/pick.test.ts @@ -24,7 +24,7 @@ describe(pick, () => { it('does not mutate object reference', () => { const e = { obj: { a: 1, '0': false, c: 3 }, - keys: ['a', '0'] as const, + keys: ['a', '0'], expected: { a: 1, '0': false }, } const ogObj = { ...e.obj } diff --git a/packages/browser/src/lib/__tests__/pick.typedef.ts b/packages/browser/src/lib/__tests__/pick.typedef.ts new file mode 100644 index 000000000..82182aab0 --- /dev/null +++ b/packages/browser/src/lib/__tests__/pick.typedef.ts @@ -0,0 +1,39 @@ +import { assertNotAny, assertIs } from '../../test-helpers/type-assertions' +import { pick } from '../pick' + +{ + // should work with literals + const res = pick({ id: 123 }, ['id']) + + assertIs<{ id: number }>(res) + assertNotAny(res) +} +{ + // should work if only keys are read-only + const obj: { id?: number } = {} + const res = pick(obj, ['id'] as const) + assertNotAny(res) + assertIs<{ id?: number }>(res) + + // @ts-expect-error + assertIs<{ id: number }>(res) +} + +{ + // should work with keys as string + const res = pick({ id: 123 }, [] as string[]) + assertNotAny(res) + + assertIs>(res) + // @ts-expect-error - should be partial + assertIs<{ id: number }>(res) +} + +{ + // should work with object type + const res = pick({} as object, ['id']) + assertNotAny(res) + assertIs(res) + // @ts-expect-error + assertIs<{ id: any }>(res) +} diff --git a/packages/browser/src/lib/pick.ts b/packages/browser/src/lib/pick.ts index e2c327dba..a71d5a18c 100644 --- a/packages/browser/src/lib/pick.ts +++ b/packages/browser/src/lib/pick.ts @@ -1,13 +1,23 @@ +export function pick, K extends keyof T>( + object: T, + keys: readonly K[] +): Pick + +export function pick>( + object: T, + keys: string[] +): Partial + /** * @example * pick({ 'a': 1, 'b': '2', 'c': 3 }, ['a', 'c']) * => { 'a': 1, 'c': 3 } */ -export const pick = ( +export function pick, K extends keyof T>( object: T, - keys: readonly K[] -): Pick => - Object.assign( + keys: string[] | K[] | readonly K[] +) { + return Object.assign( {}, ...keys.map((key) => { if (object && Object.prototype.hasOwnProperty.call(object, key)) { @@ -15,3 +25,4 @@ export const pick = ( } }) ) +} diff --git a/packages/browser/src/node/index.ts b/packages/browser/src/node/index.ts index f2a89084c..5362979a5 100644 --- a/packages/browser/src/node/index.ts +++ b/packages/browser/src/node/index.ts @@ -6,6 +6,9 @@ import { Plugin } from '../core/plugin' import { EventQueue } from '../core/queue/event-queue' import { PriorityQueue } from '../lib/priority-queue' +/** + * @deprecated Please use the standalone `@segment/analytics-node` package. + */ export class AnalyticsNode { static async load(settings: { writeKey: string diff --git a/packages/browser/src/plugins/ajs-destination/__tests__/index.test.ts b/packages/browser/src/plugins/ajs-destination/__tests__/index.test.ts index 5a12d8249..381053a60 100644 --- a/packages/browser/src/plugins/ajs-destination/__tests__/index.test.ts +++ b/packages/browser/src/plugins/ajs-destination/__tests__/index.test.ts @@ -571,7 +571,7 @@ describe('plan', () => { expect(dest.integration?.track).not.toHaveBeenCalled() expect(ctx.event.integrations).toMatchInlineSnapshot(` - Object { + { "All": false, "Segment.io": true, } @@ -601,7 +601,7 @@ describe('plan', () => { expect(dest.integration?.track).not.toHaveBeenCalled() expect(ctx.event.integrations).toMatchInlineSnapshot(` - Object { + { "All": false, "Segment.io": true, } diff --git a/packages/browser/src/plugins/ajs-destination/index.ts b/packages/browser/src/plugins/ajs-destination/index.ts index ac2a938cf..b51a8f86e 100644 --- a/packages/browser/src/plugins/ajs-destination/index.ts +++ b/packages/browser/src/plugins/ajs-destination/index.ts @@ -29,6 +29,8 @@ import { isDisabledIntegration as shouldSkipIntegration, isInstallableIntegration, } from './utils' +import { recordIntegrationMetric } from '../../core/stats/metric-helpers' +import { createDeferred } from '@segment/analytics-generic-utils' export type ClassType = new (...args: unknown[]) => T @@ -71,10 +73,10 @@ export class LegacyDestination implements DestinationPlugin { type: Plugin['type'] = 'destination' middleware: DestinationMiddlewareFunction[] = [] - private _ready = false - private _initialized = false + private _ready: boolean | undefined + private _initialized: boolean | undefined private onReady: Promise | undefined - private onInitialize: Promise | undefined + private initializePromise = createDeferred() private disableAutoISOConversion: boolean integrationSource?: ClassicIntegrationSource @@ -103,6 +105,11 @@ export class LegacyDestination implements DestinationPlugin { delete this.settings['type'] } + this.initializePromise.promise.then( + (isInitialized) => (this._initialized = isInitialized), + () => {} + ) + this.options = options this.buffer = options.disableClientPersistence ? new PriorityQueue(4, []) @@ -112,11 +119,13 @@ export class LegacyDestination implements DestinationPlugin { } isLoaded(): boolean { - return this._ready + return !!this._ready } ready(): Promise { - return this.onReady ?? Promise.resolve() + return this.initializePromise.promise.then( + () => this.onReady ?? Promise.resolve() + ) } async load(ctx: Context, analyticsInstance: Analytics): Promise { @@ -148,28 +157,25 @@ export class LegacyDestination implements DestinationPlugin { this.integration!.once('ready', onReadyFn) }) - this.onInitialize = new Promise((resolve) => { - const onInit = (): void => { - this._initialized = true - resolve(true) - } - - this.integration!.on('initialize', onInit) + this.integration!.on('initialize', () => { + this.initializePromise.resolve(true) }) try { - ctx.stats.increment('analytics_js.integration.invoke', 1, [ - `method:initialize`, - `integration_name:${this.name}`, - ]) - + recordIntegrationMetric(ctx, { + integrationName: this.name, + methodName: 'initialize', + type: 'classic', + }) this.integration.initialize() } catch (error) { - ctx.stats.increment('analytics_js.integration.invoke.error', 1, [ - `method:initialize`, - `integration_name:${this.name}`, - ]) - + recordIntegrationMetric(ctx, { + integrationName: this.name, + methodName: 'initialize', + type: 'classic', + didError: true, + }) + this.initializePromise.resolve(false) throw error } } @@ -254,20 +260,23 @@ export class LegacyDestination implements DestinationPlugin { traverse: !this.disableAutoISOConversion, }) - ctx.stats.increment('analytics_js.integration.invoke', 1, [ - `method:${eventType}`, - `integration_name:${this.name}`, - ]) + recordIntegrationMetric(ctx, { + integrationName: this.name, + methodName: eventType, + type: 'classic', + }) try { if (this.integration) { - await this.integration.invoke.call(this.integration, eventType, event) + await this.integration!.invoke.call(this.integration, eventType, event) } } catch (err) { - ctx.stats.increment('analytics_js.integration.invoke.error', 1, [ - `method:${eventType}`, - `integration_name:${this.name}`, - ]) + recordIntegrationMetric(ctx, { + integrationName: this.name, + methodName: eventType, + type: 'classic', + didError: true, + }) throw err } @@ -283,9 +292,8 @@ export class LegacyDestination implements DestinationPlugin { this.integration.initialize() } - return this.onInitialize!.then(() => { - return this.send(ctx, Page as ClassType, 'page') - }) + await this.initializePromise.promise + return this.send(ctx, Page as ClassType, 'page') } async identify(ctx: Context): Promise { diff --git a/packages/browser/src/plugins/ajs-destination/types.ts b/packages/browser/src/plugins/ajs-destination/types.ts index 659f6c41f..a3b39d6f9 100644 --- a/packages/browser/src/plugins/ajs-destination/types.ts +++ b/packages/browser/src/plugins/ajs-destination/types.ts @@ -1,6 +1,6 @@ import { Group, Identify, Track, Page, Alias } from '@segment/facade' import { Analytics } from '../../core/analytics' -import { Emitter } from '@segment/analytics-core' +import { Emitter } from '@segment/analytics-generic-utils' import { User } from '../../core/user' export interface LegacyIntegration extends Emitter { diff --git a/packages/browser/src/plugins/page-enrichment/__tests__/index.test.ts b/packages/browser/src/plugins/env-enrichment/__tests__/index.test.ts similarity index 56% rename from packages/browser/src/plugins/page-enrichment/__tests__/index.test.ts rename to packages/browser/src/plugins/env-enrichment/__tests__/index.test.ts index 90723cfcd..a2d114de7 100644 --- a/packages/browser/src/plugins/page-enrichment/__tests__/index.test.ts +++ b/packages/browser/src/plugins/env-enrichment/__tests__/index.test.ts @@ -1,25 +1,15 @@ import cookie from 'js-cookie' import assert from 'assert' import { Analytics } from '../../../core/analytics' -import { pageEnrichment, pageDefaults } from '..' -import { pick } from '../../../lib/pick' +import { envEnrichment } from '..' import { SegmentioSettings } from '../../segmentio' import { version } from '../../../generated/version' import { CoreExtraContext } from '@segment/analytics-core' - -let ajs: Analytics - -const helpers = { - get pageProps() { - return { - url: 'http://foo.com/bar?foo=hello_world', - path: '/bar', - search: '?foo=hello_world', - referrer: 'http://google.com', - title: 'Hello World', - } - }, -} +import { UADataValues } from '../../../lib/client-hints/interfaces' +import { + highEntropyTestData, + lowEntropyTestData, +} from '../../../test-helpers/fixtures/client-hints' /** * Filters out the calls made for probing cookie availability @@ -35,233 +25,32 @@ const ignoreProbeCookieWrites = ( > ) => fn.mock.calls.filter((c) => c[0] !== 'ajs_cookies_check') -describe('Page Enrichment', () => { - beforeEach(async () => { - ajs = new Analytics({ - writeKey: 'abc_123', - }) - - await ajs.register(pageEnrichment) - }) - - test('enriches page calls', async () => { - const ctx = await ajs.page('Checkout', {}) - - expect(ctx.event.properties).toMatchInlineSnapshot(` - Object { - "name": "Checkout", - "path": "/", - "referrer": "", - "search": "", - "title": "", - "url": "http://localhost/", - } - `) - }) - - test('enriches track events with the page context', async () => { - const ctx = await ajs.track('My event', { - banana: 'phone', - }) - - expect(ctx.event.context?.page).toMatchInlineSnapshot(` - Object { - "path": "/", - "referrer": "", - "search": "", - "title": "", - "url": "http://localhost/", - } - `) - }) - - describe('event.properties override behavior', () => { - test('special page properties in event.properties (url, referrer, etc) are copied to context.page', async () => { - const eventProps = { ...helpers.pageProps } - ;(eventProps as any)['should_not_show_up'] = 'hello' - const ctx = await ajs.page('My Event', eventProps) - const page = ctx.event.context!.page - expect(page).toEqual( - pick(eventProps, ['url', 'path', 'referrer', 'search', 'title']) - ) - }) - - test('special page properties in event.properties (url, referrer, etc) are not copied to context.page in non-page calls', async () => { - const eventProps = { ...helpers.pageProps } - ;(eventProps as any)['should_not_show_up'] = 'hello' - const ctx = await ajs.track('My Event', eventProps) - const page = ctx.event.context!.page - expect(page).toMatchInlineSnapshot(` - Object { - "path": "/", - "referrer": "", - "search": "", - "title": "", - "url": "http://localhost/", - } - `) - }) - - test('event page properties should not be mutated', async () => { - const eventProps = { ...helpers.pageProps } - const ctx = await ajs.page('My Event', eventProps) - const page = ctx.event.context!.page - expect(page).toEqual(eventProps) - }) - - test('page properties should have defaults', async () => { - const eventProps = pick(helpers.pageProps, ['path', 'referrer']) - const ctx = await ajs.page('My Event', eventProps) - const page = ctx.event.context!.page - expect(page).toEqual({ - ...eventProps, - url: 'http://localhost/', - search: '', - title: '', - }) - }) - - test('undefined / null / empty string properties on event get overridden as usual', async () => { - const eventProps = { ...helpers.pageProps } - eventProps.referrer = '' - eventProps.path = undefined as any - eventProps.title = null as any - const ctx = await ajs.page('My Event', eventProps) - const page = ctx.event.context!.page - expect(page).toEqual( - expect.objectContaining({ referrer: '', path: undefined, title: null }) - ) - }) - }) - - test('enriches page events with the page context', async () => { - const ctx = await ajs.page( - 'My event', - { banana: 'phone' }, - { page: { url: 'not-localhost' } } - ) - - expect(ctx.event.context?.page).toMatchInlineSnapshot(` - Object { - "path": "/", - "referrer": "", - "search": "", - "title": "", - "url": "not-localhost", - } - `) - }) - test('enriches page events using properties', async () => { - const ctx = await ajs.page('My event', { banana: 'phone', referrer: 'foo' }) - - expect(ctx.event.context?.page).toMatchInlineSnapshot(` - Object { - "path": "/", - "referrer": "foo", - "search": "", - "title": "", - "url": "http://localhost/", - } - `) - }) - - test('in page events, event.name overrides event.properties.name', async () => { - const ctx = await ajs.page('My Event', undefined, undefined, { - name: 'some propery name', - }) - expect(ctx.event.properties!.name).toBe('My Event') - }) - - test('in non-page events, event.name does not override event.properties.name', async () => { - const ctx = await ajs.track('My Event', { - name: 'some propery name', - }) - expect(ctx.event.properties!.name).toBe('some propery name') - }) - - test('enriches identify events with the page context', async () => { - const ctx = await ajs.identify('Netto', { - banana: 'phone', - }) - - expect(ctx.event.context?.page).toMatchInlineSnapshot(` - Object { - "path": "/", - "referrer": "", - "search": "", - "title": "", - "url": "http://localhost/", - } - `) - }) - - test('runs before any other plugin', async () => { - let called = false - - await ajs.addSourceMiddleware(({ payload, next }) => { - called = true - expect(payload.obj?.context?.page).not.toBeFalsy() - next(payload) - }) - - await ajs.track('My event', { - banana: 'phone', - }) - - expect(called).toBe(true) - }) -}) - -describe('pageDefaults', () => { - const el = document.createElement('link') - el.setAttribute('rel', 'canonical') - - beforeEach(() => { - el.setAttribute('href', '') - document.clear() - }) - - afterEach(() => { - jest.restoreAllMocks() - }) - - it('handles no canonical links', () => { - const defs = pageDefaults() - expect(defs.url).not.toBeNull() - }) - - it('handles canonical links', () => { - el.setAttribute('href', 'http://www.segment.local') - document.body.appendChild(el) - const defs = pageDefaults() - expect(defs.url).toEqual('http://www.segment.local') - }) - - it('handles canonical links with a path', () => { - el.setAttribute('href', 'http://www.segment.local/test') - document.body.appendChild(el) - const defs = pageDefaults() - expect(defs.url).toEqual('http://www.segment.local/test') - expect(defs.path).toEqual('/test') - }) - - it('handles canonical links with search params in the url', () => { - el.setAttribute('href', 'http://www.segment.local?test=true') - document.body.appendChild(el) - const defs = pageDefaults() - expect(defs.url).toEqual('http://www.segment.local?test=true') - }) - - it('if canonical does not exist, returns fallback', () => { - document.body.appendChild(el) - const defs = pageDefaults() - expect(defs.url).toEqual(window.location.href) - }) -}) - describe('Other visitor metadata', () => { let options: SegmentioSettings let analytics: Analytics + ;(window.navigator as any).userAgentData = { + ...lowEntropyTestData, + getHighEntropyValues: jest + .fn() + .mockImplementation((hints: string[]): Promise => { + let result = {} + Object.entries(highEntropyTestData).forEach(([k, v]) => { + if (hints.includes(k)) { + result = { + ...result, + [k]: v, + } + } + }) + return Promise.resolve({ + ...lowEntropyTestData, + ...result, + }) + }), + toJSON: jest.fn(() => { + return lowEntropyTestData + }), + } const amendSearchParams = (search?: any): CoreExtraContext => ({ page: { search }, @@ -271,7 +60,7 @@ describe('Other visitor metadata', () => { options = { apiKey: 'foo' } analytics = new Analytics({ writeKey: options.apiKey }) - await analytics.register(pageEnrichment) + await analytics.register(envEnrichment) }) afterEach(() => { @@ -283,6 +72,11 @@ describe('Other visitor metadata', () => { } }) + it('should add .timezone', async () => { + const ctx = await analytics.track('test') + assert(typeof ctx.event.context?.timezone === 'string') + }) + it('should add .library', async () => { const ctx = await analytics.track('test') assert(ctx.event.context?.library) @@ -313,6 +107,11 @@ describe('Other visitor metadata', () => { assert(userAgent1 === userAgent2) }) + it('should add .userAgentData when available', async () => { + const ctx = await analytics.track('event') + expect(ctx.event.context?.userAgentData).toEqual(lowEntropyTestData) + }) + it('should add .locale', async () => { const ctx = await analytics.track('test') assert(ctx.event.context?.locale === navigator.language) @@ -452,21 +251,76 @@ describe('Other visitor metadata', () => { }) it('should allow override of .search with object', async () => { + const searchParams = { + something_else: 'bar', + utm_custom: 'foo', + utm_campaign: 'hello', + } const ctx = await analytics.track( 'test', {}, { - context: amendSearchParams({ - someObject: 'foo', - }), + context: amendSearchParams(searchParams), } ) assert(ctx.event) assert(ctx.event.context) - assert(ctx.event.context.campaign === undefined) assert(ctx.event.context.referrer === undefined) + assert(ctx.event.context.campaign) + assert(ctx.event.context.page?.search) + expect(ctx.event.context.page.search).toEqual(searchParams) + expect(ctx.event.context.campaign).toEqual({ name: 'hello', custom: 'foo' }) }) + it('should not throw an error if the object is invalid', async () => { + const searchParams = { + invalidNested: { + foo: { + bar: null, + }, + }, + } + const ctx = await analytics.track( + 'test', + {}, + { + context: amendSearchParams(searchParams), + } + ) + assert(ctx.event) + assert(ctx.event.context) + assert(ctx.event.context.referrer === undefined) + expect(ctx.event.context.page?.search).toEqual(searchParams) + }) + + test.each([ + { + bar: ['123', '456'], + utm_campaign: 'hello', + utm_custom: ['foo', 'bar'], + }, + '?bar=123&bar=456&utm_campaign=hello&utm_custom=foo&utm_custom=bar', + ])( + 'should work as expected if there are multiple values for the same param (%p)', + async (params) => { + const ctx = await analytics.track( + 'test', + {}, + { + context: amendSearchParams(params), + } + ) + assert(ctx.event) + assert(ctx.event.context) + assert(ctx.event.context.referrer === undefined) + expect(ctx.event.context.page?.search).toEqual(params) + expect(ctx.event.context.campaign).toEqual({ + name: 'hello', + custom: 'bar', + }) + } + ) + it('should add .referrer.id and .referrer.type (cookies)', async () => { const ctx = await analytics.track( 'test', @@ -496,7 +350,7 @@ describe('Other visitor metadata', () => { { disableClientPersistence: true } ) - await analytics.register(pageEnrichment) + await analytics.register(envEnrichment) const ctx = await analytics.track( 'test', diff --git a/packages/browser/src/plugins/page-enrichment/index.ts b/packages/browser/src/plugins/env-enrichment/index.ts similarity index 60% rename from packages/browser/src/plugins/page-enrichment/index.ts rename to packages/browser/src/plugins/env-enrichment/index.ts index 91cf1162f..f1a981dc5 100644 --- a/packages/browser/src/plugins/page-enrichment/index.ts +++ b/packages/browser/src/plugins/env-enrichment/index.ts @@ -1,24 +1,16 @@ import jar from 'js-cookie' -import { pick } from '../../lib/pick' import type { Context } from '../../core/context' import type { Plugin } from '../../core/plugin' import { version } from '../../generated/version' import { SegmentEvent } from '../../core/events' -import { Campaign, EventProperties, PluginType } from '@segment/analytics-core' +import { Campaign, PluginType } from '@segment/analytics-core' import { getVersionType } from '../../lib/version-type' import { tld } from '../../core/user/tld' import { gracefulDecodeURIComponent } from '../../core/query-string/gracefulDecodeURIComponent' import { CookieStorage, UniversalStorage } from '../../core/storage' import { Analytics } from '../../core/analytics' - -interface PageDefault { - [key: string]: unknown - path: string - referrer: string - search: string - title: string - url: string -} +import { clientHints } from '../../lib/client-hints' +import { UADataValues } from '../../lib/client-hints/interfaces' let cookieOptions: jar.CookieAttributes | undefined function getCookieOptions(): jar.CookieAttributes { @@ -64,64 +56,6 @@ function ads(query: string): Ad | undefined { } } -/** - * Get the current page's canonical URL. - */ -function canonical(): string | undefined { - const canon = document.querySelector("link[rel='canonical']") - if (canon) { - return canon.getAttribute('href') || undefined - } -} - -/** - * Return the canonical path for the page. - */ - -function canonicalPath(): string { - const canon = canonical() - if (!canon) { - return window.location.pathname - } - - const a = document.createElement('a') - a.href = canon - const pathname = !a.pathname.startsWith('/') ? '/' + a.pathname : a.pathname - - return pathname -} - -/** - * Return the canonical URL for the page concat the given `search` - * and strip the hash. - */ - -export function canonicalUrl(search = ''): string { - const canon = canonical() - if (canon) { - return canon.includes('?') ? canon : `${canon}${search}` - } - const url = window.location.href - const i = url.indexOf('#') - return i === -1 ? url : url.slice(0, i) -} - -/** - * Return a default `options.context.page` object. - * - * https://segment.com/docs/spec/page/#properties - */ - -export function pageDefaults(): PageDefault { - return { - path: canonicalPath(), - referrer: document.referrer, - search: location.search, - title: document.title, - url: canonicalUrl(location.search), - } -} - export function utm(query: string): Campaign { if (query.startsWith('?')) { query = query.substring(1) @@ -131,7 +65,7 @@ export function utm(query: string): Campaign { return query.split('&').reduce((acc, str) => { const [k, v = ''] = str.split('=') if (k.includes('utm_') && k.length > 4) { - let utmParam = k.substr(4) as keyof Campaign + let utmParam = k.slice(4) as keyof Campaign if (utmParam === 'campaign') { utmParam = 'name' } @@ -172,47 +106,60 @@ function referrerId( storage.set('s:context.referrer', ad) } -class PageEnrichmentPlugin implements Plugin { +/** + * + * @param obj e.g. { foo: 'b', bar: 'd', baz: ['123', '456']} + * @returns e.g. 'foo=b&bar=d&baz=123&baz=456' + */ +const objectToQueryString = ( + obj: Record +): string => { + try { + const searchParams = new URLSearchParams() + Object.entries(obj).forEach(([k, v]) => { + if (Array.isArray(v)) { + v.forEach((value) => searchParams.append(k, value)) + } else { + searchParams.append(k, v) + } + }) + return searchParams.toString() + } catch { + return '' + } +} + +class EnvironmentEnrichmentPlugin implements Plugin { private instance!: Analytics + private userAgentData: UADataValues | undefined name = 'Page Enrichment' type: PluginType = 'before' version = '0.1.0' isLoaded = () => true - load = (_ctx: Context, instance: Analytics) => { + load = async (_ctx: Context, instance: Analytics) => { this.instance = instance + try { + this.userAgentData = await clientHints( + this.instance.options.highEntropyValuesClientHints + ) + } catch (_) { + // if client hints API doesn't return anything leave undefined + } return Promise.resolve() } private enrich = (ctx: Context): Context => { - const event = ctx.event - const evtCtx = (event.context ??= {}) - - const defaultPageContext = pageDefaults() + // Note: Types are off - context should never be undefined here, since it is set as part of event creation. + const evtCtx = ctx.event.context! - let pageContextFromEventProps: Pick | undefined + const search = evtCtx.page!.search || '' - if (event.type === 'page') { - pageContextFromEventProps = - event.properties && - pick(event.properties, Object.keys(defaultPageContext)) - - event.properties = { - ...defaultPageContext, - ...event.properties, - ...(event.name ? { name: event.name } : {}), - } - } - - evtCtx.page = { - ...defaultPageContext, - ...pageContextFromEventProps, - ...evtCtx.page, - } - - const query: string = evtCtx.page.search || '' + const query = + typeof search === 'object' ? objectToQueryString(search) : search evtCtx.userAgent = navigator.userAgent + evtCtx.userAgentData = this.userAgentData // @ts-ignore const locale = navigator.userLanguage || navigator.language @@ -241,6 +188,12 @@ class PageEnrichmentPlugin implements Plugin { this.instance.options.disableClientPersistence ?? false ) + try { + evtCtx.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone + } catch (_) { + // If browser doesn't have support leave timezone undefined + } + return ctx } @@ -252,4 +205,4 @@ class PageEnrichmentPlugin implements Plugin { screen = this.enrich } -export const pageEnrichment = new PageEnrichmentPlugin() +export const envEnrichment = new EnvironmentEnrichmentPlugin() diff --git a/packages/browser/src/plugins/legacy-video-plugins/__tests__/index.test.ts b/packages/browser/src/plugins/legacy-video-plugins/__tests__/index.test.ts index 6c809c760..231535303 100644 --- a/packages/browser/src/plugins/legacy-video-plugins/__tests__/index.test.ts +++ b/packages/browser/src/plugins/legacy-video-plugins/__tests__/index.test.ts @@ -39,7 +39,7 @@ describe(loadLegacyVideoPlugins.name, () => { await loadLegacyVideoPlugins(ajs) expect(ajs.plugins).toMatchInlineSnapshot(` - Object { + { "VimeoAnalytics": [Function], "YouTubeAnalytics": [Function], } diff --git a/packages/browser/src/plugins/middleware/__tests__/index.test.ts b/packages/browser/src/plugins/middleware/__tests__/index.test.ts index 5c44d8a5a..0d95ecbcf 100644 --- a/packages/browser/src/plugins/middleware/__tests__/index.test.ts +++ b/packages/browser/src/plugins/middleware/__tests__/index.test.ts @@ -211,7 +211,7 @@ describe(sourceMiddlewarePlugin, () => { }) expect((await xt.track!(evt)).event.context).toMatchInlineSnapshot(` - Object { + { "hello": "from the other side", } `) @@ -223,7 +223,7 @@ describe(sourceMiddlewarePlugin, () => { }) expect((await xt.identify!(evt)).event.context).toMatchInlineSnapshot(` - Object { + { "hello": "from the other side", } `) @@ -235,7 +235,7 @@ describe(sourceMiddlewarePlugin, () => { }) expect((await xt.page!(evt)).event.context).toMatchInlineSnapshot(` - Object { + { "hello": "from the other side", } `) @@ -247,7 +247,7 @@ describe(sourceMiddlewarePlugin, () => { }) expect((await xt.group!(evt)).event.context).toMatchInlineSnapshot(` - Object { + { "hello": "from the other side", } `) @@ -259,7 +259,7 @@ describe(sourceMiddlewarePlugin, () => { }) expect((await xt.alias!(evt)).event.context).toMatchInlineSnapshot(` - Object { + { "hello": "from the other side", } `) diff --git a/packages/browser/src/plugins/remote-loader/__tests__/action-destination.test.ts b/packages/browser/src/plugins/remote-loader/__tests__/action-destination.test.ts new file mode 100644 index 000000000..91eae97cd --- /dev/null +++ b/packages/browser/src/plugins/remote-loader/__tests__/action-destination.test.ts @@ -0,0 +1,80 @@ +import unfetch from 'unfetch' +import { PluginFactory } from '..' + +import { AnalyticsBrowser } from '../../../browser' +import { createSuccess } from '../../../test-helpers/factories' + +jest.mock('unfetch') +jest.mocked(unfetch).mockImplementation(() => + createSuccess({ + integrations: {}, + remotePlugins: [ + { + name: 'testDestination', + libraryName: 'testDestination', + settings: { + subscriptions: [ + { + name: 'Track Calls', + enabled: true, + partnerAction: 'trackEvent', + subscribe: 'type = "track"', + }, + ], + }, + }, + ], + }) +) + +const testDestination: PluginFactory = () => { + return { + name: 'testDestination', + version: '1.0.0', + type: 'destination', + isLoaded: () => true, + load: () => Promise.resolve(), + track: (ctx) => Promise.resolve(ctx), + } +} + +testDestination.pluginName = 'testDestination' + +describe('ActionDestination', () => { + it('captures essential metrics when invoking methods on an action plugin', async () => { + const ajs = AnalyticsBrowser.load({ + writeKey: 'abc', + plugins: [testDestination], + }) + + await ajs.ready() + + expect(ajs.ctx?.stats.metrics[0]).toMatchObject( + expect.objectContaining({ + metric: 'analytics_js.integration.invoke', + tags: [ + 'method:load', + 'integration_name:testDestination', + 'type:action', + ], + }) + ) + + const trackCtx = await ajs.track('test') + + const actionInvokeMetric = trackCtx.stats.metrics.find( + (m) => m.metric === 'analytics_js.integration.invoke' + ) + + expect(actionInvokeMetric).toMatchObject( + expect.objectContaining({ + metric: 'analytics_js.integration.invoke', + tags: [ + 'method:track', + 'integration_name:testDestination', + 'type:action', + ], + }) + ) + }) +}) diff --git a/packages/browser/src/plugins/remote-loader/__tests__/index.test.ts b/packages/browser/src/plugins/remote-loader/__tests__/index.test.ts index 95d3c0abe..a85ec9022 100644 --- a/packages/browser/src/plugins/remote-loader/__tests__/index.test.ts +++ b/packages/browser/src/plugins/remote-loader/__tests__/index.test.ts @@ -60,7 +60,7 @@ describe('Remote Loader', () => { }, {}, {}, - true + { obfuscate: true } ) const btoaName = btoa('to').replace(/=/g, '') expect(loader.loadScript).toHaveBeenCalledWith( @@ -185,7 +185,7 @@ describe('Remote Loader', () => { }, {}, {}, - false, + undefined, undefined, [brazeSpy as unknown as PluginFactory] ) @@ -357,7 +357,7 @@ describe('Remote Loader', () => { expect(plugins).toHaveLength(3) expect(plugins).toEqual( expect.arrayContaining([ - { + expect.objectContaining({ action: one, name: 'multiple plugins', version: '1.0.0', @@ -370,8 +370,8 @@ describe('Remote Loader', () => { identify: expect.any(Function), page: expect.any(Function), screen: expect.any(Function), - }, - { + }), + expect.objectContaining({ action: two, name: 'multiple plugins', version: '1.0.0', @@ -384,8 +384,8 @@ describe('Remote Loader', () => { identify: expect.any(Function), page: expect.any(Function), screen: expect.any(Function), - }, - { + }), + expect.objectContaining({ action: three, name: 'single plugin', version: '1.0.0', @@ -398,7 +398,7 @@ describe('Remote Loader', () => { identify: expect.any(Function), page: expect.any(Function), screen: expect.any(Function), - }, + }), ]) ) expect(multiPluginFactory).toHaveBeenCalledWith({ foo: true }) @@ -500,7 +500,7 @@ describe('Remote Loader', () => { expect(plugins).toHaveLength(1) expect(plugins).toEqual( expect.arrayContaining([ - { + expect.objectContaining({ action: validPlugin, name: 'valid plugin', version: '1.0.0', @@ -513,7 +513,7 @@ describe('Remote Loader', () => { identify: expect.any(Function), page: expect.any(Function), screen: expect.any(Function), - }, + }), ]) ) expect(console.warn).toHaveBeenCalledTimes(1) @@ -827,7 +827,13 @@ describe('Remote Loader', () => { cdnSettings.middlewareSettings!.routingRules ) - const plugins = await remoteLoader(cdnSettings, {}, {}, false, middleware) + const plugins = await remoteLoader( + cdnSettings, + {}, + {}, + undefined, + middleware + ) const plugin = plugins[0] await expect(() => plugin.track!(new Context({ type: 'track', event: 'Item Impression' })) @@ -845,7 +851,7 @@ describe('Remote Loader', () => { name: 'valid', version: '1.0.0', type: 'enrichment', - load: () => {}, + load: () => Promise.resolve(), isLoaded: () => true, track: (ctx: Context) => ctx, } @@ -890,8 +896,15 @@ describe('Remote Loader', () => { const middleware = jest.fn().mockImplementation(() => true) - const plugins = await remoteLoader(cdnSettings, {}, {}, false, middleware) + const plugins = await remoteLoader( + cdnSettings, + {}, + {}, + undefined, + middleware + ) const plugin = plugins[0] as ActionDestination + await plugin.load(new Context(null as any), null as any) plugin.addMiddleware(middleware) await plugin.track(new Context({ type: 'track' })) expect(middleware).not.toHaveBeenCalled() @@ -902,7 +915,7 @@ describe('Remote Loader', () => { name: 'valid', version: '1.0.0', type: 'enrichment', - load: () => {}, + load: () => Promise.resolve(), isLoaded: () => true, track: (ctx: Context) => { ctx.event.name += 'bar' @@ -932,8 +945,9 @@ describe('Remote Loader', () => { return Promise.resolve(true) }) - const plugins = await remoteLoader(cdnSettings, {}, {}, false) + const plugins = await remoteLoader(cdnSettings, {}, {}) const plugin = plugins[0] as ActionDestination + await plugin.load(new Context(null as any), null as any) const newCtx = await plugin.track( new Context({ type: 'track', name: 'foo' }) ) diff --git a/packages/browser/src/plugins/remote-loader/index.ts b/packages/browser/src/plugins/remote-loader/index.ts index 73d797c02..b0979fa19 100644 --- a/packages/browser/src/plugins/remote-loader/index.ts +++ b/packages/browser/src/plugins/remote-loader/index.ts @@ -9,7 +9,9 @@ import { DestinationMiddlewareFunction, } from '../middleware' import { Context, ContextCancelation } from '../../core/context' -import { Analytics } from '../../core/analytics' +import { recordIntegrationMetric } from '../../core/stats/metric-helpers' +import { Analytics, InitOptions } from '../../core/analytics' +import { createDeferred } from '@segment/analytics-generic-utils' export interface RemotePlugin { /** The name of the remote plugin */ @@ -31,6 +33,8 @@ export class ActionDestination implements DestinationPlugin { alternativeNames: string[] = [] + private loadPromise = createDeferred() + middleware: DestinationMiddlewareFunction[] = [] action: Plugin @@ -79,7 +83,29 @@ export class ActionDestination implements DestinationPlugin { transformedContext = await this.transform(ctx) } - await this.action[methodName]!(transformedContext) + try { + if (!(await this.ready())) { + throw new Error( + 'Something prevented the destination from getting ready' + ) + } + + recordIntegrationMetric(ctx, { + integrationName: this.action.name, + methodName, + type: 'action', + }) + + await this.action[methodName]!(transformedContext) + } catch (error) { + recordIntegrationMetric(ctx, { + integrationName: this.action.name, + methodName, + type: 'action', + didError: true, + }) + throw error + } return ctx } @@ -97,12 +123,42 @@ export class ActionDestination implements DestinationPlugin { return this.action.isLoaded() } - ready(): Promise { - return this.action.ready ? this.action.ready() : Promise.resolve() + async ready(): Promise { + try { + await this.loadPromise.promise + return true + } catch { + return false + } } - load(ctx: Context, analytics: Analytics): Promise { - return this.action.load(ctx, analytics) + async load(ctx: Context, analytics: Analytics): Promise { + if (this.loadPromise.isSettled()) { + return this.loadPromise.promise + } + + try { + recordIntegrationMetric(ctx, { + integrationName: this.action.name, + methodName: 'load', + type: 'action', + }) + + const loadP = this.action.load(ctx, analytics) + + this.loadPromise.resolve(await loadP) + return loadP + } catch (error) { + recordIntegrationMetric(ctx, { + integrationName: this.action.name, + methodName: 'load', + type: 'action', + didError: true, + }) + + this.loadPromise.reject(error) + throw error + } } unload(ctx: Context, analytics: Analytics): Promise | unknown { @@ -196,7 +252,7 @@ export async function remoteLoader( settings: LegacySettings, userIntegrations: Integrations, mergedIntegrations: Record, - obfuscate?: boolean, + options?: InitOptions, routingMiddleware?: DestinationMiddlewareFunction, pluginSources?: PluginFactory[] ): Promise { @@ -212,7 +268,7 @@ export async function remoteLoader( const pluginFactory = pluginSources?.find( ({ pluginName }) => pluginName === remotePlugin.name - ) || (await loadPluginFactory(remotePlugin, obfuscate)) + ) || (await loadPluginFactory(remotePlugin, options?.obfuscate)) if (pluginFactory) { const plugin = await pluginFactory({ diff --git a/packages/browser/src/plugins/remote-middleware/__tests__/index.test.ts b/packages/browser/src/plugins/remote-middleware/__tests__/index.test.ts index dfb1674b0..1c77dc3e4 100644 --- a/packages/browser/src/plugins/remote-middleware/__tests__/index.test.ts +++ b/packages/browser/src/plugins/remote-middleware/__tests__/index.test.ts @@ -85,7 +85,7 @@ describe('Remote Middleware', () => { .filter(Boolean) expect(sources).toMatchInlineSnapshot(` - Array [ + [ "https://cdn.segment.com/next-integrations/middleware/analytics.js-middleware-braze-deduplicate/latest/analytics.js-middleware-braze-deduplicate.js.gz", ] `) @@ -107,7 +107,7 @@ describe('Remote Middleware', () => { expect(md[0]).toMatchInlineSnapshot(`[Function]`) expect(ctx.logs().map((l) => l.message)).toMatchInlineSnapshot(` - Array [ + [ [Error: Failed to load https://cdn.segment.com/next-integrations/middleware/analytics.js-middleware-that-does-not-exist/latest/analytics.js-middleware-that-does-not-exist.js.gz], ] `) diff --git a/packages/browser/src/plugins/routing-middleware/__tests__/index.test.ts b/packages/browser/src/plugins/routing-middleware/__tests__/index.test.ts index b36fa208e..41d33b6cf 100644 --- a/packages/browser/src/plugins/routing-middleware/__tests__/index.test.ts +++ b/packages/browser/src/plugins/routing-middleware/__tests__/index.test.ts @@ -55,7 +55,7 @@ describe('tsub middleware', () => { expect(next).toHaveBeenCalled() expect(payload).toMatchInlineSnapshot(` - Object { + { "event": "Item Impression", "type": "track", } diff --git a/packages/browser/src/plugins/schema-filter/__tests__/index.test.ts b/packages/browser/src/plugins/schema-filter/__tests__/index.test.ts index abc84559d..3e8292a28 100644 --- a/packages/browser/src/plugins/schema-filter/__tests__/index.test.ts +++ b/packages/browser/src/plugins/schema-filter/__tests__/index.test.ts @@ -10,6 +10,8 @@ const settings: LegacySettings = { 'Braze Web Mode (Actions)': {}, // note that Fullstory's name here doesn't contain 'Actions' Fullstory: {}, + 'Google Analytics': {}, + 'Google Analytics 4 Web': {}, 'Segment.io': {}, }, remotePlugins: [ @@ -35,7 +37,7 @@ const settings: LegacySettings = { { // note that Fullstory name contains 'Actions' name: 'Fullstory (Actions)', - creationName: 'Fullstory (Actions)', + creationName: 'Fullstory', libraryName: 'fullstoryDestination', url: 'https://cdn.segment.com/next-integrations/actions/fullstory/35ea1d304f85f3306f48.js', settings: { @@ -49,6 +51,19 @@ const settings: LegacySettings = { ], }, }, + { + name: 'Google Analytics 4 Web', + creationName: 'Google Analytics 4 Web', + libraryName: 'google-analytics-4-webDestination', + url: 'https://cdn.segment.com/next-integrations/actions/google-analytics-4-web/bfab87631cbcb7d70964.js', + settings: { + subscriptions: [ + { + partnerAction: 'Custom Event', + }, + ], + }, + }, ], } @@ -92,6 +107,11 @@ const fullstory: Plugin = { name: 'Fullstory (Actions) trackEvent', } +const ga4: Plugin = { + ...trackEvent, + name: 'Google Analytics 4 Web Custom Event', +} + describe('schema filter', () => { let options: SegmentioSettings let filterXt: Plugin @@ -113,6 +133,7 @@ describe('schema filter', () => { jest.spyOn(updateUserProfile, 'track') jest.spyOn(amplitude, 'track') jest.spyOn(fullstory, 'track') + jest.spyOn(ga4, 'track') }) describe('plugins and destinations', () => { @@ -472,7 +493,7 @@ describe('schema filter', () => { expect(updateUserProfile.track).toHaveBeenCalled() }) - it('covers different names between remote plugins and integrations', async () => { + it('works when current name differs from creation name', async () => { const filterXt = schemaFilter( { hi: { @@ -516,5 +537,25 @@ describe('schema filter', () => { expect(updateUserProfile.track).toHaveBeenCalled() expect(fullstory.track).toHaveBeenCalled() }) + + it('doesnt block destinations with similar names', async () => { + const filterXt = schemaFilter( + { + hi: { + enabled: true, + integrations: { + 'Google Analytics': false, + }, + }, + }, + settings + ) + + await ajs.register(segment, ga4, filterXt) + + await ajs.track('hi') + + expect(ga4.track).toHaveBeenCalled() + }) }) }) diff --git a/packages/browser/src/plugins/schema-filter/index.ts b/packages/browser/src/plugins/schema-filter/index.ts index 7c181502e..a43e4dac1 100644 --- a/packages/browser/src/plugins/schema-filter/index.ts +++ b/packages/browser/src/plugins/schema-filter/index.ts @@ -24,7 +24,7 @@ function disabledActionDestinations( const disabledRemotePlugins: string[] = [] ;(settings.remotePlugins ?? []).forEach((p: RemotePlugin) => { disabledIntegrations.forEach((int) => { - if (p.name.includes(int) || int.includes(p.name)) { + if (p.creationName == int) { disabledRemotePlugins.push(p.name) } }) diff --git a/packages/browser/src/plugins/segmentio/__tests__/batched-dispatcher.test.ts b/packages/browser/src/plugins/segmentio/__tests__/batched-dispatcher.test.ts index 56e2e2a67..d45ddd32a 100644 --- a/packages/browser/src/plugins/segmentio/__tests__/batched-dispatcher.test.ts +++ b/packages/browser/src/plugins/segmentio/__tests__/batched-dispatcher.test.ts @@ -91,11 +91,11 @@ describe('Batching', () => { expect(fetch).toHaveBeenCalledTimes(1) expect(fetch.mock.calls[0]).toMatchInlineSnapshot(` - Array [ + [ "https://https://api.segment.io/b", - Object { - "body": "{\\"batch\\":[{\\"event\\":\\"first\\"},{\\"event\\":\\"second\\"},{\\"event\\":\\"third\\"}],\\"sentAt\\":\\"1993-06-09T00:00:00.000Z\\"}", - "headers": Object { + { + "body": "{"batch":[{"event":"first"},{"event":"second"},{"event":"third"}],"sentAt":"1993-06-09T00:00:00.000Z"}", + "headers": { "Content-Type": "text/plain", }, "keepalive": false, @@ -149,11 +149,11 @@ describe('Batching', () => { expect(fetch).toHaveBeenCalledTimes(1) expect(fetch.mock.calls[0]).toMatchInlineSnapshot(` - Array [ + [ "https://https://api.segment.io/b", - Object { - "body": "{\\"batch\\":[{\\"event\\":\\"first\\"},{\\"event\\":\\"second\\"}],\\"sentAt\\":\\"1993-06-09T00:00:10.000Z\\"}", - "headers": Object { + { + "body": "{"batch":[{"event":"first"},{"event":"second"}],"sentAt":"1993-06-09T00:00:10.000Z"}", + "headers": { "Content-Type": "text/plain", }, "keepalive": false, @@ -184,11 +184,11 @@ describe('Batching', () => { expect(fetch).toHaveBeenCalledTimes(2) expect(fetch.mock.calls[0]).toMatchInlineSnapshot(` - Array [ + [ "https://https://api.segment.io/b", - Object { - "body": "{\\"batch\\":[{\\"event\\":\\"first\\"}],\\"sentAt\\":\\"1993-06-09T00:00:10.000Z\\"}", - "headers": Object { + { + "body": "{"batch":[{"event":"first"}],"sentAt":"1993-06-09T00:00:10.000Z"}", + "headers": { "Content-Type": "text/plain", }, "keepalive": false, @@ -198,11 +198,11 @@ describe('Batching', () => { `) expect(fetch.mock.calls[1]).toMatchInlineSnapshot(` - Array [ + [ "https://https://api.segment.io/b", - Object { - "body": "{\\"batch\\":[{\\"event\\":\\"second\\"}],\\"sentAt\\":\\"1993-06-09T00:00:21.000Z\\"}", - "headers": Object { + { + "body": "{"batch":[{"event":"second"}],"sentAt":"1993-06-09T00:00:21.000Z"}", + "headers": { "Content-Type": "text/plain", }, "keepalive": false, @@ -229,11 +229,11 @@ describe('Batching', () => { expect(fetch).toHaveBeenCalledTimes(1) expect(fetch.mock.calls[0]).toMatchInlineSnapshot(` - Array [ + [ "https://https://api.segment.io/b", - Object { - "body": "{\\"batch\\":[{\\"event\\":\\"first\\"},{\\"event\\":\\"second\\"}],\\"sentAt\\":\\"1993-06-09T00:00:00.000Z\\"}", - "headers": Object { + { + "body": "{"batch":[{"event":"first"},{"event":"second"}],"sentAt":"1993-06-09T00:00:00.000Z"}", + "headers": { "Content-Type": "text/plain", }, "keepalive": false, diff --git a/packages/browser/src/plugins/segmentio/__tests__/index.test.ts b/packages/browser/src/plugins/segmentio/__tests__/index.test.ts index d9858cb57..ec60ad350 100644 --- a/packages/browser/src/plugins/segmentio/__tests__/index.test.ts +++ b/packages/browser/src/plugins/segmentio/__tests__/index.test.ts @@ -3,13 +3,8 @@ import unfetch from 'unfetch' import { segmentio, SegmentioSettings } from '..' import { Analytics } from '../../../core/analytics' import { Plugin } from '../../../core/plugin' -import { pageEnrichment } from '../../page-enrichment' +import { envEnrichment } from '../../env-enrichment' import cookie from 'js-cookie' -import { UADataValues } from '../../../lib/client-hints/interfaces' -import { - highEntropyTestData, - lowEntropyTestData, -} from '../../../test-helpers/fixtures/client-hints' jest.mock('unfetch', () => { return jest.fn() @@ -24,35 +19,12 @@ describe('Segment.io', () => { beforeEach(async () => { jest.resetAllMocks() jest.restoreAllMocks() - ;(window.navigator as any).userAgentData = { - ...lowEntropyTestData, - getHighEntropyValues: jest - .fn() - .mockImplementation((hints: string[]): Promise => { - let result = {} - Object.entries(highEntropyTestData).forEach(([k, v]) => { - if (hints.includes(k)) { - result = { - ...result, - [k]: v, - } - } - }) - return Promise.resolve({ - ...lowEntropyTestData, - ...result, - }) - }), - toJSON: jest.fn(() => { - return lowEntropyTestData - }), - } options = { apiKey: 'foo' } analytics = new Analytics({ writeKey: options.apiKey }) segment = await segmentio(analytics, options, {}) - await analytics.register(segment, pageEnrichment) + await analytics.register(segment, envEnrichment) window.localStorage.clear() @@ -83,7 +55,7 @@ describe('Segment.io', () => { } const analytics = new Analytics({ writeKey: options.apiKey }) const segment = await segmentio(analytics, options, {}) - await analytics.register(segment, pageEnrichment) + await analytics.register(segment, envEnrichment) // @ts-ignore test a valid ajsc page call await analytics.page(null, { foo: 'bar' }) @@ -199,14 +171,6 @@ describe('Segment.io', () => { assert(body.traits == null) assert(body.timestamp) }) - - it('should add userAgentData when available', async () => { - await analytics.track('event') - const [_, params] = spyMock.mock.calls[0] - const body = JSON.parse(params.body) - - expect(body.context?.userAgentData).toEqual(lowEntropyTestData) - }) }) describe('#group', () => { diff --git a/packages/browser/src/plugins/segmentio/__tests__/retries.test.ts b/packages/browser/src/plugins/segmentio/__tests__/retries.test.ts index e73ce2ec9..0360b8aa1 100644 --- a/packages/browser/src/plugins/segmentio/__tests__/retries.test.ts +++ b/packages/browser/src/plugins/segmentio/__tests__/retries.test.ts @@ -3,7 +3,7 @@ import { Analytics } from '../../../core/analytics' // @ts-ignore isOffline mocked dependency is accused as unused import { isOffline } from '../../../core/connection' import { Plugin } from '../../../core/plugin' -import { pageEnrichment } from '../../page-enrichment' +import { envEnrichment } from '../../env-enrichment' import { scheduleFlush } from '../schedule-flush' import * as PPQ from '../../../lib/priority-queue/persisted' import * as PQ from '../../../lib/priority-queue' @@ -59,7 +59,7 @@ describe('Segment.io retries', () => { segment = await segmentio(analytics, options, {}) - await analytics.register(segment, pageEnrichment) + await analytics.register(segment, envEnrichment) }) test(`add events to the queue`, async () => { diff --git a/packages/browser/src/plugins/segmentio/index.ts b/packages/browser/src/plugins/segmentio/index.ts index 62a53460c..98150c5e3 100644 --- a/packages/browser/src/plugins/segmentio/index.ts +++ b/packages/browser/src/plugins/segmentio/index.ts @@ -12,8 +12,6 @@ import standard, { StandardDispatcherConfig } from './fetch-dispatcher' import { normalize } from './normalize' import { scheduleFlush } from './schedule-flush' import { SEGMENT_API_HOST } from '../../core/constants' -import { clientHints } from '../../lib/client-hints' -import { UADataValues } from '../../lib/client-hints/interfaces' type DeliveryStrategy = | { @@ -52,11 +50,11 @@ function onAlias(analytics: Analytics, json: JSON): JSON { return json } -export async function segmentio( +export function segmentio( analytics: Analytics, settings?: any, integrations?: LegacySettings['integrations'] -): Promise { +): Plugin { // Attach `pagehide` before buffer is created so that inflight events are added // to the buffer before the buffer persists events in its own `pagehide` handler. window.addEventListener('pagehide', () => { @@ -86,15 +84,6 @@ export async function segmentio( ? batch(apiHost, deliveryStrategy.config) : standard(deliveryStrategy?.config as StandardDispatcherConfig) - let userAgentData: UADataValues | undefined - try { - userAgentData = await clientHints( - analytics.options.highEntropyValuesClientHints - ) - } catch { - userAgentData = undefined - } - async function send(ctx: Context): Promise { if (isOffline()) { buffer.push(ctx) @@ -107,10 +96,6 @@ export async function segmentio( const path = ctx.event.type.charAt(0) - if (userAgentData && ctx.event.context) { - ctx.event.context.userAgentData = userAgentData - } - let json = toFacade(ctx.event).json() if (ctx.event.type === 'track') { @@ -140,7 +125,7 @@ export async function segmentio( const segmentio: Plugin = { name: 'Segment.io', - type: 'after', + type: 'destination', version: '0.1.0', isLoaded: (): boolean => true, load: (): Promise => Promise.resolve(), diff --git a/packages/browser/src/test-helpers/fixtures/cdn-settings.ts b/packages/browser/src/test-helpers/fixtures/cdn-settings.ts index fa22413b7..58a684208 100644 --- a/packages/browser/src/test-helpers/fixtures/cdn-settings.ts +++ b/packages/browser/src/test-helpers/fixtures/cdn-settings.ts @@ -1,7 +1,9 @@ -import { LegacySettings } from '../..' +import { AnalyticsBrowserSettings } from '../..' import { mockIntegrationName } from './classic-destination' -export const cdnSettingsKitchenSink: LegacySettings = { +type CDNSettings = NonNullable + +export const cdnSettingsKitchenSink: CDNSettings = { integrations: { [mockIntegrationName]: {}, 'Customer.io': { @@ -292,7 +294,7 @@ export const cdnSettingsKitchenSink: LegacySettings = { remotePlugins: [], } -export const cdnSettingsMinimal: LegacySettings = { +export const cdnSettingsMinimal: CDNSettings = { integrations: { [mockIntegrationName]: {}, }, diff --git a/packages/browser/src/test-helpers/fixtures/create-fetch-method.ts b/packages/browser/src/test-helpers/fixtures/create-fetch-method.ts index 3ba5b64c2..85cd7e41e 100644 --- a/packages/browser/src/test-helpers/fixtures/create-fetch-method.ts +++ b/packages/browser/src/test-helpers/fixtures/create-fetch-method.ts @@ -3,7 +3,7 @@ import { createSuccess } from '../factories' import { cdnSettingsMinimal } from './cdn-settings' export const createMockFetchImplementation = ( - cdnSettings: Partial = {} + cdnSettings: Partial = cdnSettingsMinimal ) => { return (...[url, req]: Parameters) => { const reqUrl = url.toString() diff --git a/packages/browser/src/test-helpers/fixtures/index.ts b/packages/browser/src/test-helpers/fixtures/index.ts new file mode 100644 index 000000000..9379c2698 --- /dev/null +++ b/packages/browser/src/test-helpers/fixtures/index.ts @@ -0,0 +1,4 @@ +export * from './page-context' +export * from './create-fetch-method' +export * from './classic-destination' +export * from './cdn-settings' diff --git a/packages/browser/src/test-helpers/fixtures/page-context.ts b/packages/browser/src/test-helpers/fixtures/page-context.ts new file mode 100644 index 000000000..c8949d4c5 --- /dev/null +++ b/packages/browser/src/test-helpers/fixtures/page-context.ts @@ -0,0 +1,11 @@ +import { + BufferedPageContext, + getDefaultBufferedPageContext, + getDefaultPageContext, + PageContext, +} from '../../core/page' + +export const getPageCtxFixture = (): PageContext => getDefaultPageContext() + +export const getBufferedPageCtxFixture = (): BufferedPageContext => + getDefaultBufferedPageContext() diff --git a/packages/config-webpack/package.json b/packages/config-webpack/package.json index c243c7314..fabafc794 100644 --- a/packages/config-webpack/package.json +++ b/packages/config-webpack/package.json @@ -11,6 +11,7 @@ "@types/circular-dependency-plugin": "^5", "babel-loader": "^8.0.0", "circular-dependency-plugin": "^5.2.2", + "ecma-version-validator-webpack-plugin": "^1.2.1", "terser-webpack-plugin": "^5.1.4", "webpack": "^5.76.0", "webpack-cli": "^4.8.0", diff --git a/packages/config-webpack/webpack.config.common.js b/packages/config-webpack/webpack.config.common.js index 4710464c2..d69005c4d 100644 --- a/packages/config-webpack/webpack.config.common.js +++ b/packages/config-webpack/webpack.config.common.js @@ -1,5 +1,8 @@ const TerserPlugin = require('terser-webpack-plugin') const CircularDependencyPlugin = require('circular-dependency-plugin') +const { + ECMAVersionValidatorPlugin, +} = require('ecma-version-validator-webpack-plugin') const isProd = process.env.NODE_ENV === 'production' const isWatch = process.env.WATCH === 'true' @@ -15,6 +18,7 @@ module.exports = { devtool: 'source-map', stats: isWatch ? 'errors-warnings' : 'normal', mode: isProd ? 'production' : 'development', + target: ['web', 'es5'], // target es5 for ie11 support (generates module boilerplate in es5) module: { rules: [ { @@ -37,7 +41,6 @@ module.exports = { }, }, ], - exclude: /node_modules/, }, ], }, @@ -67,5 +70,6 @@ module.exports = { new CircularDependencyPlugin({ failOnError: true, }), + new ECMAVersionValidatorPlugin({ ecmaVersion: 5 }), // ensure our js bundle only contains syntax supported in ie11. This does not check polyfills. ], } diff --git a/packages/config/src/jest/config.js b/packages/config/src/jest/config.js index 7adfde85f..f40d0fb65 100644 --- a/packages/config/src/jest/config.js +++ b/packages/config/src/jest/config.js @@ -4,8 +4,8 @@ const path = require('path') /** * Create Config * @param {string} dirname - __dirname from the package that's importing this config. - * @param {import('jest').Config} Overrides. - * @returns {import('jest').Config} + * @param {import('ts-jest').JestConfigWithTsJest} Overrides. + * @returns {import('ts-jest').JestConfigWithTsJest} */ const createJestTSConfig = ( dirname, @@ -36,10 +36,13 @@ const createJestTSConfig = ( * Equivalent to calling jest.clearAllMocks() before each test. */ clearMocks: true, - globals: { - 'ts-jest': { - isolatedModules: true, - }, + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + isolatedModules: true, + }, + ], }, ...(overridesToMerge || {}), } diff --git a/packages/consent/consent-tools/CHANGELOG.md b/packages/consent/consent-tools/CHANGELOG.md index 9821b8ab7..d09ebcec9 100644 --- a/packages/consent/consent-tools/CHANGELOG.md +++ b/packages/consent/consent-tools/CHANGELOG.md @@ -1,5 +1,51 @@ # @segment/analytics-consent-tools +## 1.2.0 + +### Minor Changes + +- [#1009](https://github.com/segmentio/analytics-next/pull/1009) [`f476038`](https://github.com/segmentio/analytics-next/commit/f47603881b787cc81fa1da4496bdbde9eb325a0f) Thanks [@silesky](https://github.com/silesky)! - If initialPageview is true, call analytics.page() as early as possible to avoid stale page context. + +### Patch Changes + +- [#1020](https://github.com/segmentio/analytics-next/pull/1020) [`7b93e7b`](https://github.com/segmentio/analytics-next/commit/7b93e7b50fa293aebaf6767a44bf7708b231d5cd) Thanks [@silesky](https://github.com/silesky)! - Add tslib to resolve unsound dependency warning. + +## 1.1.0 + +### Minor Changes + +- [#1001](https://github.com/segmentio/analytics-next/pull/1001) [`57be1ac`](https://github.com/segmentio/analytics-next/commit/57be1acd556a9779edbc5fd4d3f820fb50b65697) Thanks [@silesky](https://github.com/silesky)! - analytics will not initialize if all of the following conditions are met: + + 1. No destinations without a consent mapping (consentSettings.hasUnmappedDestinations == false) + + AND + + 2. User has not consented to any category present in the consentSettings.allCategories array. + +### Patch Changes + +- [#997](https://github.com/segmentio/analytics-next/pull/997) [`dcf279c`](https://github.com/segmentio/analytics-next/commit/dcf279c4591c84952c78022ddfbad945aab8cfde) Thanks [@silesky](https://github.com/silesky)! - Refactor internally to add AnalyticsService + +## 1.0.0 + +### Major Changes + +- [#983](https://github.com/segmentio/analytics-next/pull/983) [`930af49`](https://github.com/segmentio/analytics-next/commit/930af49b27f7c2973304c7ae76b67d264223e6f6) Thanks [@silesky](https://github.com/silesky)! - \* Rename `shouldLoad` -> `shouldLoadSegment` + - Remove redundant `shouldDisableConsentRequirement` setting, in favor of shouldLoad's `ctx.abort({loadSegmentNormally: true})` + - Create `shouldLoadWrapper` API for waiting for consent script initialization. + +### Patch Changes + +- [#990](https://github.com/segmentio/analytics-next/pull/990) [`a361575`](https://github.com/segmentio/analytics-next/commit/a361575152f8313dfded3b0cc4b9912b4e2a41c3) Thanks [@silesky](https://github.com/silesky)! - Refactor consent wrapper; export GetCategoriesFunction + +* [#991](https://github.com/segmentio/analytics-next/pull/991) [`008a019`](https://github.com/segmentio/analytics-next/commit/008a01927973340bd93cd0097e45c455d49baea5) Thanks [@silesky](https://github.com/silesky)! - Import from local utils rather than lodash + +## 0.2.1 + +### Patch Changes + +- [#959](https://github.com/segmentio/analytics-next/pull/959) [`32da78b`](https://github.com/segmentio/analytics-next/commit/32da78b922d6ffe030585dc7ba1b271b78d5f6dd) Thanks [@silesky](https://github.com/silesky)! - Support older browsers + ## 0.2.0 ### Minor Changes diff --git a/packages/consent/consent-tools/README.md b/packages/consent/consent-tools/README.md index 658c6803d..83b12b762 100644 --- a/packages/consent/consent-tools/README.md +++ b/packages/consent/consent-tools/README.md @@ -7,12 +7,15 @@ import { createWrapper, resolveWhen } from '@segment/analytics-consent-tools' export const withCMP = createWrapper({ + // Wait to load wrapper or call "shouldLoadSegment" until window.CMP exists. + shouldLoadWrapper: async () => { + await resolveWhen(() => window.CMP !== undefined, 500) + }, // Wrapper waits to load segment / get categories until this function returns / resolves - shouldLoad: (ctx) => { - const CMP = await getCMP() + shouldLoadSegment: async (ctx) => { await resolveWhen( - () => !CMP.popUpVisible(), + () => !window.CMP.popUpVisible(), 500 ) @@ -25,23 +28,15 @@ export const withCMP = createWrapper({ }, getCategories: () => { - const CMP = await getCMP() - return normalizeCategories(CMP.consentedCategories()) // Expected format: { foo: true, bar: false } + return normalizeCategories(window.CMP.consentedCategories()) // Expected format: { foo: true, bar: false } }, registerOnConsentChanged: (setCategories) => { - const CMP = await getCMP() - CMP.onConsentChanged((event) => { + window.CMP.onConsentChanged((event) => { setCategories(normalizeCategories(event.detail)) }) }, }) - - -const getCMP = async () => { - await resolveWhen(() => window.CMP !== undefined, 500) - return window.CMP -} ``` ## Wrapper Usage API diff --git a/packages/consent/consent-tools/package.json b/packages/consent/consent-tools/package.json index cf666b316..1ce4507fe 100644 --- a/packages/consent/consent-tools/package.json +++ b/packages/consent/consent-tools/package.json @@ -1,6 +1,6 @@ { "name": "@segment/analytics-consent-tools", - "version": "0.2.0", + "version": "1.2.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/types/index.d.ts", @@ -9,10 +9,11 @@ "dist/", "src/", "!**/__tests__/**", - "!*.tsbuildinfo" + "!*.tsbuildinfo", + "!**/test-helpers/**" ], "scripts": { - ".": "yarn run -T turbo run --filter=@segment/analytics-consent-tools", + ".": "yarn run -T turbo run --filter=@segment/analytics-consent-tools...", "test": "yarn jest", "lint": "yarn concurrently 'yarn:eslint .' 'yarn:tsc --noEmit'", "build": "rm -rf dist && yarn concurrently 'yarn:build:*'", @@ -44,5 +45,8 @@ "directory": "packages/consent/consent-tools", "type": "git", "url": "https://github.com/segmentio/analytics-next" + }, + "dependencies": { + "tslib": "^2.4.1" } } diff --git a/packages/consent/consent-tools/src/domain/__tests__/consent-stamping.test.ts b/packages/consent/consent-tools/src/domain/__tests__/consent-stamping.test.ts index 4900bdc61..30849914e 100644 --- a/packages/consent/consent-tools/src/domain/__tests__/consent-stamping.test.ts +++ b/packages/consent/consent-tools/src/domain/__tests__/consent-stamping.test.ts @@ -5,10 +5,8 @@ describe(createConsentStampingMiddleware, () => { let middlewareFn: MiddlewareFunction const nextFn = jest.fn() const getCategories = jest.fn() - // @ts-ignore const payload = { obj: { - type: 'track', context: new Context({ type: 'track' }), }, } @@ -42,4 +40,30 @@ describe(createConsentStampingMiddleware, () => { Advertising: true, }) }) + + it('should throw an error if getCategories returns an invalid value', async () => { + middlewareFn = createConsentStampingMiddleware(getCategories) + getCategories.mockReturnValue(null as any) + await expect(() => + middlewareFn({ + next: nextFn, + // @ts-ignore + payload, + }) + ).rejects.toThrowError(/Validation/) + expect(nextFn).not.toHaveBeenCalled() + }) + + it('should throw an error if getCategories returns an invalid async value', async () => { + middlewareFn = createConsentStampingMiddleware(getCategories) + getCategories.mockResolvedValue(null as any) + await expect(() => + middlewareFn({ + next: nextFn, + // @ts-ignore + payload, + }) + ).rejects.toThrowError(/Validation/) + expect(nextFn).not.toHaveBeenCalled() + }) }) diff --git a/packages/consent/consent-tools/src/domain/__tests__/create-wrapper.test.ts b/packages/consent/consent-tools/src/domain/__tests__/create-wrapper.test.ts index 4ba852d98..7c14a5c56 100644 --- a/packages/consent/consent-tools/src/domain/__tests__/create-wrapper.test.ts +++ b/packages/consent/consent-tools/src/domain/__tests__/create-wrapper.test.ts @@ -1,5 +1,5 @@ import * as ConsentStamping from '../consent-stamping' -import * as ConsentChanged from '../consent-changed' +import * as DisableSegment from '../disable-segment' import { createWrapper } from '../create-wrapper' import { AbortLoadError, LoadContext } from '../load-cancellation' import type { @@ -11,31 +11,28 @@ import type { } from '../../types' import { CDNSettingsBuilder } from '@internal/test-helpers' import { assertIntegrationsContainOnly } from './assertions/integrations-assertions' +import { AnalyticsService } from '../analytics' + +jest.mock('../disable-segment') +const disableSegmentMock = jest.mocked(DisableSegment) const DEFAULT_LOAD_SETTINGS = { writeKey: 'foo', cdnSettings: { integrations: {} }, } -/** - * Create consent settings for integrations - */ -const createConsentSettings = (categories: string[] = []) => ({ - consentSettings: { - categories, - }, -}) const mockGetCategories: jest.MockedFn = jest.fn().mockImplementation(() => ({ Advertising: true })) const analyticsLoadSpy: jest.MockedFn = jest.fn() +const analyticsPageSpy: jest.MockedFn = jest.fn() const addSourceMiddlewareSpy = jest.fn() let analyticsOnSpy: jest.MockedFn const analyticsTrackSpy: jest.MockedFn = jest.fn() let consoleErrorSpy: jest.SpiedFunction const getAnalyticsLoadLastCall = () => { - const [arg1, arg2] = analyticsLoadSpy.mock.lastCall + const [arg1, arg2] = analyticsLoadSpy.mock.lastCall! const cdnSettings = (arg1 as any).cdnSettings as CDNSettings const updateCDNSettings = arg2!.updateCDNSettings || ((id) => id) const updatedCDNSettings = updateCDNSettings(cdnSettings) as CDNSettings @@ -46,24 +43,20 @@ const getAnalyticsLoadLastCall = () => { } } -let analytics: AnyAnalytics, settingsBuilder: CDNSettingsBuilder +let analytics: AnyAnalytics, cdnSettingsBuilder: CDNSettingsBuilder beforeEach(() => { consoleErrorSpy = jest.spyOn(console, 'error') - - settingsBuilder = new CDNSettingsBuilder().addActionDestinationSettings({ - // add a default plugin just for safety - creationName: 'nope', - ...createConsentSettings(['Nope', 'Never']), - }) + cdnSettingsBuilder = new CDNSettingsBuilder() analyticsOnSpy = jest.fn().mockImplementation((event, fn) => { if (event === 'initialize') { - fn(settingsBuilder.build()) + fn(cdnSettingsBuilder.build()) } else { console.error('event not recognized') } }) class MockAnalytics implements AnyAnalytics { + page = analyticsPageSpy track = analyticsTrackSpy on = analyticsOnSpy load = analyticsLoadSpy @@ -80,7 +73,7 @@ const wrapTestAnalytics = (overrides: Partial = {}) => describe(createWrapper, () => { it('should allow load arguments to be forwarded correctly from the patched analytics.load to the underlying load method', async () => { - const mockCdnSettings = settingsBuilder.build() + const mockCdnSettings = cdnSettingsBuilder.build() wrapTestAnalytics() @@ -98,13 +91,15 @@ describe(createWrapper, () => { await analytics.load(loadSettings1, loadSettings2) const { args: loadCallArgs, updatedCDNSettings } = getAnalyticsLoadLastCall() - const [loadedSettings1, loadedSettings2] = loadCallArgs expect(loadCallArgs.length).toBe(2) - expect(loadedSettings1).toEqual(loadSettings1) - expect(Object.keys(loadedSettings1)).toEqual(Object.keys(loadSettings1)) + expect(Object.keys(loadCallArgs[0])).toEqual( + expect.arrayContaining(['cdnSettings', 'writeKey']) + ) - expect(Object.keys(loadedSettings2)).toEqual(Object.keys(loadSettings2)) + expect(Object.keys(loadCallArgs[1])).toEqual( + expect.arrayContaining(['anyOption', 'updateCDNSettings']) + ) expect(loadSettings2).toEqual(expect.objectContaining({ anyOption: 'foo' })) expect(updatedCDNSettings).toEqual( expect.objectContaining({ some_new_key: 123 }) @@ -123,29 +118,30 @@ describe(createWrapper, () => { expect(args.length).toBeTruthy() }) - describe('shouldLoad', () => { + describe('shouldLoadSegment', () => { describe('Throwing errors / aborting load', () => { const createShouldLoadThatThrows = ( ...args: Parameters ) => { let err: Error - const shouldLoad = jest.fn().mockImplementation((ctx: LoadContext) => { - try { - ctx.abort(...args) - throw new Error('Fail') - } catch (_err: any) { - err = _err - } - }) - return { shouldLoad, getError: () => err } + const shouldLoadSegment = jest + .fn() + .mockImplementation((ctx: LoadContext) => { + try { + ctx.abort(...args) + } catch (_err: any) { + err = _err + } + }) + return { shouldLoadSegment, getError: () => err } } it('should throw a special error if ctx.abort is called', async () => { - const { shouldLoad, getError } = createShouldLoadThatThrows({ + const { shouldLoadSegment, getError } = createShouldLoadThatThrows({ loadSegmentNormally: true, }) wrapTestAnalytics({ - shouldLoad, + shouldLoadSegment, }) await analytics.load(DEFAULT_LOAD_SETTINGS) expect(getError() instanceof AbortLoadError).toBeTruthy() @@ -155,7 +151,7 @@ describe(createWrapper, () => { `should not log a console error or throw an error if ctx.abort is called (%p)`, async (args) => { wrapTestAnalytics({ - shouldLoad: (ctx) => ctx.abort(args), + shouldLoadSegment: (ctx) => ctx.abort(args), }) const result = await analytics.load(DEFAULT_LOAD_SETTINGS) expect(result).toBeUndefined() @@ -163,22 +159,35 @@ describe(createWrapper, () => { } ) - it('should allow segment to be loaded normally (with all consent wrapper behavior disabled) via ctx.abort', async () => { + it('should pass analytics.load args straight through to the analytics instance if ctx.abort() is called', async () => { wrapTestAnalytics({ - shouldLoad: (ctx) => { + shouldLoadSegment: (ctx) => { ctx.abort({ - loadSegmentNormally: true, // magic config option + loadSegmentNormally: true, }) }, }) - await analytics.load(DEFAULT_LOAD_SETTINGS) + const mockCdnSettings = cdnSettingsBuilder.build() + await analytics.load( + { + ...DEFAULT_LOAD_SETTINGS, + cdnSettings: mockCdnSettings, + }, + { foo: 'bar' } as any + ) expect(analyticsLoadSpy).toBeCalled() + const { args } = getAnalyticsLoadLastCall() + expect(args[0]).toEqual({ + ...DEFAULT_LOAD_SETTINGS, + cdnSettings: mockCdnSettings, + }) + expect(args[1]).toEqual({ foo: 'bar' } as any) }) it('should allow segment loading to be completely aborted via ctx.abort', async () => { wrapTestAnalytics({ - shouldLoad: (ctx) => { + shouldLoadSegment: (ctx) => { ctx.abort({ loadSegmentNormally: false, // magic config option }) @@ -189,11 +198,11 @@ describe(createWrapper, () => { expect(analyticsLoadSpy).not.toBeCalled() }) it('should throw a validation error if ctx.abort is called incorrectly', async () => { - const { getError, shouldLoad } = createShouldLoadThatThrows( + const { getError, shouldLoadSegment } = createShouldLoadThatThrows( undefined as any ) wrapTestAnalytics({ - shouldLoad, + shouldLoadSegment, }) await analytics.load(DEFAULT_LOAD_SETTINGS) expect(getError().message).toMatch(/validation/i) @@ -202,7 +211,7 @@ describe(createWrapper, () => { it('An unrecognized Error (non-consent) error should bubble up, but we should not log any additional console error', async () => { const err = new Error('hello') wrapTestAnalytics({ - shouldLoad: () => { + shouldLoadSegment: () => { throw err }, }) @@ -215,7 +224,7 @@ describe(createWrapper, () => { expect(analyticsLoadSpy).not.toBeCalled() }) }) - it('should first call shouldLoad(), then wait for it to resolve/return before calling analytics.load()', async () => { + it('should first call shouldLoadSegment(), then wait for it to resolve/return before calling analytics.load()', async () => { const fnCalls: string[] = [] analyticsLoadSpy.mockImplementationOnce(() => { fnCalls.push('analytics.load') @@ -224,41 +233,40 @@ describe(createWrapper, () => { const shouldLoadMock: jest.Mock = jest .fn() .mockImplementationOnce(async () => { - fnCalls.push('shouldLoad') + fnCalls.push('shouldLoadSegment') }) wrapTestAnalytics({ - shouldLoad: shouldLoadMock, + shouldLoadSegment: shouldLoadMock, }) await analytics.load(DEFAULT_LOAD_SETTINGS) - expect(fnCalls).toEqual(['shouldLoad', 'analytics.load']) + expect(fnCalls).toEqual(['shouldLoadSegment', 'analytics.load']) }) }) describe('getCategories', () => { test.each([ { - shouldLoad: () => undefined, + shouldLoadSegment: () => undefined, returnVal: 'undefined', }, { - shouldLoad: () => Promise.resolve(undefined), + shouldLoadSegment: () => Promise.resolve(undefined), returnVal: 'Promise', }, ])( - 'if shouldLoad() returns nil ($returnVal), intial categories will come from getCategories()', - async ({ shouldLoad }) => { - const mockCdnSettings = { - integrations: { - mockIntegration: { - ...createConsentSettings(['Advertising']), - }, - }, - } + 'if shouldLoadSegment() returns nil ($returnVal), intial categories will come from getCategories()', + async ({ shouldLoadSegment }) => { + const mockCdnSettings = cdnSettingsBuilder + .addActionDestinationSettings({ + creationName: 'mockIntegration', + consentSettings: { categories: ['Advertising'] }, + }) + .build() wrapTestAnalytics({ - shouldLoad: shouldLoad, + shouldLoadSegment: shouldLoadSegment, }) await analytics.load({ ...DEFAULT_LOAD_SETTINGS, @@ -282,21 +290,20 @@ describe(createWrapper, () => { returnVal: 'Promise', }, ])( - 'if shouldLoad() returns categories ($returnVal), those will be the initial categories', + 'if shouldLoadSegment() returns categories ($returnVal), those will be the initial categories', async ({ getCategories }) => { - const mockCdnSettings = { - integrations: { - mockIntegration: { - ...createConsentSettings(['Advertising']), - }, - }, - } + const mockCdnSettings = cdnSettingsBuilder + .addActionDestinationSettings({ + creationName: 'mockIntegration', + consentSettings: { categories: ['Advertising'] }, + }) + .build() mockGetCategories.mockImplementationOnce(getCategories) wrapTestAnalytics({ getCategories: mockGetCategories, - shouldLoad: () => undefined, + shouldLoadSegment: () => undefined, }) await analytics.load({ ...DEFAULT_LOAD_SETTINGS, @@ -321,7 +328,7 @@ describe(createWrapper, () => { test('analytics.load should reject if categories are in the wrong format', async () => { wrapTestAnalytics({ - shouldLoad: () => Promise.resolve('sup' as any), + shouldLoadSegment: () => Promise.resolve('sup' as any), }) await expect(() => analytics.load(DEFAULT_LOAD_SETTINGS)).rejects.toThrow( /validation/i @@ -331,7 +338,7 @@ describe(createWrapper, () => { test('analytics.load should reject if categories are undefined', async () => { wrapTestAnalytics({ getCategories: () => undefined as any, - shouldLoad: () => undefined, + shouldLoadSegment: () => undefined, }) await expect(() => analytics.load(DEFAULT_LOAD_SETTINGS)).rejects.toThrow( /validation/i @@ -347,18 +354,22 @@ describe(createWrapper, () => { const creationNameWithConsentMatch = 'should.be.enabled.bc.consent.match' const creationNameWithConsentMismatch = 'should.be.disabled' - const mockCdnSettings = settingsBuilder + const mockCdnSettings = cdnSettingsBuilder .addActionDestinationSettings( { creationName: creationNameWithConsentMismatch, - ...createConsentSettings(['Foo']), + consentSettings: { + categories: ['Foo'], + }, }, { creationName: creationNameNoConsentData, }, { creationName: creationNameWithConsentMatch, - ...createConsentSettings(['Advertising']), + consentSettings: { + categories: ['Advertising'], + }, } ) .build() @@ -382,15 +393,17 @@ describe(createWrapper, () => { }) it('should allow integration if it has one category and user has consented to that category', async () => { - const mockCdnSettings = settingsBuilder + const mockCdnSettings = cdnSettingsBuilder .addActionDestinationSettings({ creationName: 'mockIntegration', - ...createConsentSettings(['Foo']), + consentSettings: { + categories: ['Foo'], + }, }) .build() wrapTestAnalytics({ - shouldLoad: () => ({ Foo: true }), + shouldLoadSegment: () => ({ Foo: true }), }) await analytics.load({ ...DEFAULT_LOAD_SETTINGS, @@ -407,15 +420,17 @@ describe(createWrapper, () => { }) it('should allow integration if it has multiple categories and user consents to all of them.', async () => { - const mockCdnSettings = settingsBuilder + const mockCdnSettings = cdnSettingsBuilder .addActionDestinationSettings({ creationName: 'mockIntegration', - ...createConsentSettings(['Foo', 'Bar']), + consentSettings: { + categories: ['Foo', 'Bar'], + }, }) .build() wrapTestAnalytics({ - shouldLoad: () => ({ Foo: true, Bar: true }), + shouldLoadSegment: () => ({ Foo: true, Bar: true }), }) await analytics.load({ ...DEFAULT_LOAD_SETTINGS, @@ -432,15 +447,17 @@ describe(createWrapper, () => { }) it('should disable integration if it has multiple categories but user has only consented to one', async () => { - const mockCdnSettings = settingsBuilder + const mockCdnSettings = cdnSettingsBuilder .addActionDestinationSettings({ creationName: 'mockIntegration', - ...createConsentSettings(['Foo', 'Bar']), + consentSettings: { + categories: ['Foo', 'Bar'], + }, }) .build() wrapTestAnalytics({ - shouldLoad: () => ({ Foo: true }), + shouldLoadSegment: () => ({ Foo: true }), }) await analytics.load({ ...DEFAULT_LOAD_SETTINGS, @@ -456,75 +473,6 @@ describe(createWrapper, () => { }) }) - describe('shouldDisableConsentRequirement', () => { - describe('if true on wrapper initialization', () => { - it('should load analytics as usual', async () => { - wrapTestAnalytics({ - shouldDisableConsentRequirement: () => true, - }) - await analytics.load(DEFAULT_LOAD_SETTINGS) - expect(analyticsLoadSpy).toBeCalled() - }) - - it('should not call shouldLoad if called on first', async () => { - const shouldLoad = jest.fn() - wrapTestAnalytics({ - shouldDisableConsentRequirement: () => true, - shouldLoad, - }) - await analytics.load(DEFAULT_LOAD_SETTINGS) - expect(shouldLoad).not.toBeCalled() - }) - - it('should work with promises if false', async () => { - const shouldLoad = jest.fn() - wrapTestAnalytics({ - shouldDisableConsentRequirement: () => Promise.resolve(false), - shouldLoad, - }) - await analytics.load(DEFAULT_LOAD_SETTINGS) - expect(shouldLoad).toBeCalled() - }) - - it('should work with promises if true', async () => { - const shouldLoad = jest.fn() - wrapTestAnalytics({ - shouldDisableConsentRequirement: () => Promise.resolve(true), - shouldLoad, - }) - await analytics.load(DEFAULT_LOAD_SETTINGS) - expect(shouldLoad).not.toBeCalled() - }) - - it('should forward all arguments to the original analytics.load method', async () => { - const mockCdnSettings = settingsBuilder.build() - - wrapTestAnalytics({ - shouldDisableConsentRequirement: () => true, - }) - - const loadArgs: [any, any] = [ - { - ...DEFAULT_LOAD_SETTINGS, - cdnSettings: mockCdnSettings, - }, - {}, - ] - await analytics.load(...loadArgs) - expect(analyticsLoadSpy).toBeCalled() - expect(getAnalyticsLoadLastCall().args).toEqual(loadArgs) - }) - - it('should not stamp the event with consent info', async () => { - wrapTestAnalytics({ - shouldDisableConsentRequirement: () => true, - }) - await analytics.load(DEFAULT_LOAD_SETTINGS) - expect(addSourceMiddlewareSpy).not.toBeCalled() - }) - }) - }) - describe('shouldDisableSegment', () => { it('should load analytics if disableAll returns false', async () => { wrapTestAnalytics({ @@ -544,50 +492,11 @@ describe(createWrapper, () => { expect(analyticsLoadSpy).not.toBeCalled() }) }) - test.each([ - { - getCategories: () => - ({ - invalidCategory: 'hello', - } as any), - returnVal: 'Categories', - }, - { - getCategories: () => - Promise.resolve({ - invalidCategory: 'hello', - }) as any, - returnVal: 'Promise', - }, - ])( - 'should throw an error if getCategories() returns invalid categories during consent stamping ($returnVal))', - async ({ getCategories }) => { - const fn = jest.spyOn(ConsentStamping, 'createConsentStampingMiddleware') - const mockCdnSettings = settingsBuilder.build() - - wrapTestAnalytics({ - getCategories, - shouldLoad: () => { - // on first load, we should not get an error because this is a valid category setting - return { invalidCategory: true } - }, - }) - await analytics.load({ - ...DEFAULT_LOAD_SETTINGS, - cdnSettings: mockCdnSettings, - }) - - const getCategoriesFn = fn.mock.lastCall[0] - await expect(getCategoriesFn()).rejects.toMatchInlineSnapshot( - `[ValidationError: [Validation] Consent Categories should be {[categoryName: string]: boolean} (Received: {"invalidCategory":"hello"})]` - ) - } - ) describe('shouldEnableIntegration', () => { it('should let user customize the logic that determines whether or not a destination is enabled', async () => { const disabledDestinationCreationName = 'DISABLED' - const mockCdnSettings = settingsBuilder + const mockCdnSettings = cdnSettingsBuilder .addActionDestinationSettings( { creationName: disabledDestinationCreationName, @@ -641,7 +550,7 @@ describe(createWrapper, () => { ConsentStamping, 'createConsentStampingMiddleware' ) - const mockCdnSettings = settingsBuilder.build() + const mockCdnSettings = cdnSettingsBuilder.build() wrapTestAnalytics({ getCategories, @@ -651,7 +560,7 @@ describe(createWrapper, () => { cdnSettings: mockCdnSettings, }) - const getCategoriesFn = fn.mock.lastCall[0] + const getCategoriesFn = fn.mock.lastCall![0] await expect(getCategoriesFn()).resolves.toEqual({ Something: true, SomethingElse: false, @@ -665,7 +574,7 @@ describe(createWrapper, () => { ConsentStamping, 'createConsentStampingMiddleware' ) - const mockCdnSettings = settingsBuilder + const mockCdnSettings = cdnSettingsBuilder .addActionDestinationSettings({ creationName: 'Some Other Plugin', }) @@ -677,7 +586,7 @@ describe(createWrapper, () => { cdnSettings: mockCdnSettings, }) - const getCategoriesFn = fn.mock.lastCall[0] + const getCategoriesFn = fn.mock.lastCall![0] await expect(() => getCategoriesFn() ).rejects.toThrowErrorMatchingInlineSnapshot( @@ -690,10 +599,12 @@ describe(createWrapper, () => { ConsentStamping, 'createConsentStampingMiddleware' ) - const mockCdnSettings = settingsBuilder + const mockCdnSettings = cdnSettingsBuilder .addActionDestinationSettings({ creationName: 'Some Other Plugin', - ...createConsentSettings(['Foo']), + consentSettings: { + categories: ['Foo'], + }, }) .build() @@ -715,7 +626,7 @@ describe(createWrapper, () => { cdnSettings: mockCdnSettings, }) - const getCategoriesFn = fn.mock.lastCall[0] + const getCategoriesFn = fn.mock.lastCall![0] await expect(getCategoriesFn()).resolves.toEqual({ Foo: true, Bar: false, @@ -727,7 +638,7 @@ describe(createWrapper, () => { ConsentStamping, 'createConsentStampingMiddleware' ) - const mockCdnSettings = settingsBuilder + const mockCdnSettings = cdnSettingsBuilder .addActionDestinationSettings({ creationName: 'Some Other Plugin', }) @@ -749,7 +660,7 @@ describe(createWrapper, () => { cdnSettings: mockCdnSettings, }) - const getCategoriesFn = fn.mock.lastCall[0] + const getCategoriesFn = fn.mock.lastCall![0] await expect(getCategoriesFn()).resolves.toEqual({ Foo: true }) }) }) @@ -757,8 +668,8 @@ describe(createWrapper, () => { describe('registerOnConsentChanged', () => { const sendConsentChangedEventSpy = jest.spyOn( - ConsentChanged, - 'sendConsentChangedEvent' + AnalyticsService.prototype, + 'consentChange' ) let categoriesChangedCb: (categories: Categories) => void = () => { @@ -803,11 +714,157 @@ describe(createWrapper, () => { expect(consoleErrorSpy).not.toBeCalled() categoriesChangedCb(['OOPS'] as any) expect(consoleErrorSpy).toBeCalledTimes(1) - const err = consoleErrorSpy.mock.lastCall[0] + const err = consoleErrorSpy.mock.lastCall![0] expect(err.toString()).toMatch(/validation/i) - // if OnConsentChanged callback is called with categories, it should send event - expect(sendConsentChangedEventSpy).not.toBeCalled() expect(analyticsTrackSpy).not.toBeCalled() }) }) + + describe('Disabling Segment Automatically', () => { + // if user has no unmapped destinations and only irrelevant categories, we disable segment. + // for more tests, see disable-segment.test.ts + it('should always disable if segmentShouldBeDisabled returns true', async () => { + disableSegmentMock.segmentShouldBeDisabled.mockReturnValue(true) + wrapTestAnalytics() + await analytics.load({ + ...DEFAULT_LOAD_SETTINGS, + }) + expect( + // @ts-ignore + analyticsLoadSpy.mock.lastCall[1].disable!( + DEFAULT_LOAD_SETTINGS.cdnSettings + ) + ).toBe(true) + }) + it('should disable if segmentShouldBeDisabled returns false and disable is not overridden', async () => { + disableSegmentMock.segmentShouldBeDisabled.mockReturnValue(false) + wrapTestAnalytics() + await analytics.load({ + ...DEFAULT_LOAD_SETTINGS, + }) + expect( + // @ts-ignore + analyticsLoadSpy.mock.lastCall[1].disable!( + DEFAULT_LOAD_SETTINGS.cdnSettings + ) + ).toBe(false) + }) + + it('should be disabled if if a user overrides disabled with boolean: true, and pass through a boolean', async () => { + disableSegmentMock.segmentShouldBeDisabled.mockReturnValue(false) + wrapTestAnalytics() + await analytics.load( + { + ...DEFAULT_LOAD_SETTINGS, + }, + { disable: true } + ) + expect( + // @ts-ignore + analyticsLoadSpy.mock.lastCall[1].disable + ).toBe(true) + }) + + it('should return true if segment should be disabled, but a user loads a false value', async () => { + disableSegmentMock.segmentShouldBeDisabled.mockReturnValue(true) + wrapTestAnalytics() + await analytics.load( + { + ...DEFAULT_LOAD_SETTINGS, + }, + { disable: false } + ) + expect( + // @ts-ignore + analyticsLoadSpy.mock.lastCall[1].disable!( + DEFAULT_LOAD_SETTINGS.cdnSettings + ) + ).toBe(true) + }) + + it('should handle if a user overrides the value with a function', async () => { + disableSegmentMock.segmentShouldBeDisabled.mockReturnValue(false) + wrapTestAnalytics() + await analytics.load( + { + ...DEFAULT_LOAD_SETTINGS, + }, + { disable: () => true } + ) + expect( + // @ts-ignore + analyticsLoadSpy.mock.lastCall[1].disable!( + DEFAULT_LOAD_SETTINGS.cdnSettings + ) + ).toBe(true) + }) + + it('should enable if user passes the wrong option to "load"', async () => { + disableSegmentMock.segmentShouldBeDisabled.mockReturnValue(false) + wrapTestAnalytics() + await analytics.load( + { + ...DEFAULT_LOAD_SETTINGS, + }, + // @ts-ignore + { disable: 'foo' } + ) + expect( + // @ts-ignore + analyticsLoadSpy.mock.lastCall[1].disable!( + DEFAULT_LOAD_SETTINGS.cdnSettings + ) + ).toBe(false) + }) + }) + + describe('initialPageview', () => { + it('should send a page event if initialPageview is true', async () => { + wrapTestAnalytics() + await analytics.load(DEFAULT_LOAD_SETTINGS, { initialPageview: true }) + expect(analyticsPageSpy).toBeCalledTimes(1) + }) + + it('should not send a page event if set to false', async () => { + wrapTestAnalytics() + await analytics.load(DEFAULT_LOAD_SETTINGS, { initialPageview: false }) + expect(analyticsPageSpy).not.toBeCalled() + }) + + it('should not be called if set to undefined', async () => { + wrapTestAnalytics() + await analytics.load(DEFAULT_LOAD_SETTINGS, { + initialPageview: undefined, + }) + expect(analyticsPageSpy).not.toBeCalled() + }) + + it('setting should always be set to false when forwarding to the underlying analytics instance', async () => { + wrapTestAnalytics() + await analytics.load(DEFAULT_LOAD_SETTINGS, { initialPageview: true }) + const lastCall = getAnalyticsLoadLastCall() + // ensure initialPageview is always set to false so page doesn't get called twice + expect(lastCall.args[1].initialPageview).toBe(false) + }) + + it('should call page early, even if wrapper is aborted', async () => { + // shouldLoadSegment can take a while to load, so we want to capture page context early so info is not stale + wrapTestAnalytics({ + shouldLoadSegment: (ctx) => ctx.abort({ loadSegmentNormally: true }), + }) + await analytics.load(DEFAULT_LOAD_SETTINGS, { initialPageview: true }) + expect(analyticsPageSpy).toBeCalled() + + const lastCall = getAnalyticsLoadLastCall() + // ensure initialPageview is always set to false so analytics.page() doesn't get called twice + expect(lastCall.args[1].initialPageview).toBe(false) + }) + + it('should buffer a page event even if shouldDisableSegment returns true', async () => { + // in order to capture page info as early as possible + wrapTestAnalytics({ shouldDisableSegment: () => true }) + await analytics.load(DEFAULT_LOAD_SETTINGS, { initialPageview: true }) + expect(analyticsPageSpy).toBeCalledTimes(1) + }) + }) }) diff --git a/packages/consent/consent-tools/src/domain/__tests__/disable-segment.test.ts b/packages/consent/consent-tools/src/domain/__tests__/disable-segment.test.ts new file mode 100644 index 000000000..33f3226f4 --- /dev/null +++ b/packages/consent/consent-tools/src/domain/__tests__/disable-segment.test.ts @@ -0,0 +1,67 @@ +import { CDNSettingsConsent } from '../../types' +import { segmentShouldBeDisabled } from '../disable-segment' + +describe('segmentShouldBeDisabled', () => { + it('should be disabled if user has only consented to irrelevant categories: multiple', () => { + const consentCategories = { foo: true, bar: true, baz: false } + const consentSettings: CDNSettingsConsent = { + allCategories: ['baz', 'qux'], + hasUnmappedDestinations: false, + } + expect(segmentShouldBeDisabled(consentCategories, consentSettings)).toBe( + true + ) + }) + + it('should be disabled if user has only consented to irrelevant categories: single', () => { + const consentCategories = { foo: true } + const consentSettings = { + allCategories: ['bar'], + hasUnmappedDestinations: false, + } + expect(segmentShouldBeDisabled(consentCategories, consentSettings)).toBe( + true + ) + }) + + it('should be enabled if there are any relevant categories consented to', () => { + const consentCategories = { foo: true, bar: true, baz: true } + const consentSettings: CDNSettingsConsent = { + allCategories: ['baz'], + hasUnmappedDestinations: false, + } + expect(segmentShouldBeDisabled(consentCategories, consentSettings)).toBe( + false + ) + }) + + it('should be enabled if consentSettings is undefined', () => { + const consentCategories = { foo: true } + const consentSettings = undefined + expect(segmentShouldBeDisabled(consentCategories, consentSettings)).toBe( + false + ) + }) + + it('should be enabled if consentSettings has unmapped destinations', () => { + const consentCategories = { foo: true } + const consentSettings = { + allCategories: ['foo'], + hasUnmappedDestinations: true, + } + expect(segmentShouldBeDisabled(consentCategories, consentSettings)).toBe( + false + ) + }) + + it('should be enabled if user has consented to all relevant categories', () => { + const consentCategories = { foo: true } + const consentSettings = { + allCategories: ['foo'], + hasUnmappedDestinations: false, + } + expect(segmentShouldBeDisabled(consentCategories, consentSettings)).toBe( + false + ) + }) +}) diff --git a/packages/consent/consent-tools/src/domain/analytics/__tests__/analytics-service.test.ts b/packages/consent/consent-tools/src/domain/analytics/__tests__/analytics-service.test.ts new file mode 100644 index 000000000..77921fc2c --- /dev/null +++ b/packages/consent/consent-tools/src/domain/analytics/__tests__/analytics-service.test.ts @@ -0,0 +1,178 @@ +import { AnalyticsService, getInitializedAnalytics } from '../analytics-service' +import { analyticsMock } from '../../../test-helpers/mocks' +import { ValidationError } from '../../validation/validation-error' + +describe(AnalyticsService, () => { + let analyticsService: AnalyticsService + + beforeEach(() => { + analyticsService = new AnalyticsService(analyticsMock) + }) + + describe('constructor', () => { + it('should throw an error if the analytics instance is not valid', () => { + // @ts-ignore + expect(() => new AnalyticsService(undefined)).toThrowError( + ValidationError + ) + }) + }) + + describe('cdnSettings', () => { + it('should be a promise', async () => { + expect(analyticsMock.on).toBeCalledTimes(1) + expect(analyticsMock.on.mock.lastCall![0]).toBe('initialize') + analyticsMock.on.mock.lastCall![1]({ integrations: {} }) + + await expect(analyticsService['cdnSettings']).resolves.toEqual({ + integrations: {}, + }) + }) + }) + + describe('loadNormally', () => { + it('loads normally', () => { + analyticsService = new AnalyticsService(analyticsMock) + analyticsService.loadNormally('foo') + expect(analyticsMock.load).toBeCalled() + }) + + it('uses the correct value of *this*', () => { + let that: any + function fn(this: any) { + // eslint-disable-next-line @typescript-eslint/no-this-alias + that = this + } + const _analyticsMock = { + ...analyticsMock, + load: fn, + name: 'some instance', + } + analyticsService = new AnalyticsService(_analyticsMock) + analyticsService.loadNormally('foo') + expect(that.name).toEqual('some instance') + }) + + it('will always call the original .load method', () => { + const ogLoad = jest.fn() + analyticsService = new AnalyticsService({ + ...analyticsMock, + load: ogLoad, + }) + const replaceLoadMethod = jest.fn() + analyticsService.replaceLoadMethod(replaceLoadMethod) + analyticsService.loadNormally('foo') + expect(ogLoad).toHaveBeenCalled() + analyticsService.replaceLoadMethod(replaceLoadMethod) + analyticsService.loadNormally('foo') + expect(replaceLoadMethod).not.toBeCalled() + }) + }) + + describe('replaceLoadMethod', () => { + it('should replace the load method with the provided function', () => { + const replaceLoadMethod = jest.fn() + analyticsService.replaceLoadMethod(replaceLoadMethod) + expect(analyticsService['analytics'].load).toBe(replaceLoadMethod) + }) + }) + + describe('configureConsentStampingMiddleware', () => { + // More tests are in create-wrapper.test.ts... should probably move the integration-y tests here + it('should add the middleware to the analytics instance', () => { + analyticsService.configureConsentStampingMiddleware({ + getCategories: () => ({ + C0001: true, + }), + }) + expect(analyticsMock.addSourceMiddleware).toBeCalledTimes(1) + expect(analyticsMock.addSourceMiddleware).toBeCalledWith( + expect.any(Function) + ) + }) + + it('should stamp consent', async () => { + const payload = { + obj: { + context: {}, + }, + } + analyticsService.configureConsentStampingMiddleware({ + getCategories: () => ({ + C0001: true, + C0002: false, + }), + }) + await analyticsMock.addSourceMiddleware.mock.lastCall![0]({ + payload, + next: jest.fn(), + }) + expect((payload.obj.context as any).consent).toEqual({ + categoryPreferences: { + C0001: true, + C0002: false, + }, + }) + }) + }) + + describe('consentChange', () => { + it('should call the track method with the expected arguments', () => { + const mockCategories = { C0001: true, C0002: false } + analyticsService.consentChange(mockCategories) + expect(analyticsMock.track).toBeCalledWith( + 'Segment Consent Preference', + undefined, + { consent: { categoryPreferences: mockCategories } } + ) + }) + + it('should log an error if the categories are invalid', () => { + const mockCategories = { invalid: 'nope' } as any + console.error = jest.fn() + analyticsService.consentChange(mockCategories) + expect(console.error).toBeCalledTimes(1) + expect(console.error).toBeCalledWith(expect.any(ValidationError)) + }) + }) +}) + +describe(getInitializedAnalytics, () => { + beforeEach(() => { + delete (window as any).analytics + delete (window as any).foo + }) + + it('should return the window.analytics object if the snippet user passes a stale reference', () => { + ;(window as any).analytics = { initialized: true } + const analytics = [] as any + expect(getInitializedAnalytics(analytics)).toEqual( + (window as any).analytics + ) + }) + + it('should return the correct global analytics instance if the user has set a globalAnalyticsKey', () => { + ;(window as any).foo = { initialized: true } + const analytics = [] as any + analytics._loadOptions = { globalAnalyticsKey: 'foo' } + expect(getInitializedAnalytics(analytics)).toEqual((window as any).foo) + }) + + it('should return the buffered instance if analytics is not initialized', () => { + const analytics = [] as any + const globalAnalytics = { initialized: false } + // @ts-ignore + window['analytics'] = globalAnalytics + expect(getInitializedAnalytics(analytics)).toEqual(analytics) + }) + it('invariant: should not throw if global analytics is undefined', () => { + ;(window as any).analytics = undefined + const analytics = [] as any + expect(getInitializedAnalytics(analytics)).toBe(analytics) + }) + + it('should return the analytics object if it is not an array', () => { + const analytics = { initialized: false } as any + expect(getInitializedAnalytics(analytics)).toBe(analytics) + }) +}) diff --git a/packages/consent/consent-tools/src/domain/analytics/analytics-service.ts b/packages/consent/consent-tools/src/domain/analytics/analytics-service.ts new file mode 100644 index 000000000..3cf35edab --- /dev/null +++ b/packages/consent/consent-tools/src/domain/analytics/analytics-service.ts @@ -0,0 +1,130 @@ +import { + AnyAnalytics, + Categories, + CDNSettings, + CreateWrapperSettings, + MaybeInitializedAnalytics, +} from '../../types' +import { createConsentStampingMiddleware } from '../consent-stamping' +import { getPrunedCategories } from '../pruned-categories' +import { validateAnalyticsInstance, validateCategories } from '../validation' + +/** + * This class is a wrapper around the analytics.js library. + */ +export class AnalyticsService { + cdnSettings: Promise + /** + * The original analytics.load fn + */ + loadNormally: AnyAnalytics['load'] + + private get analytics() { + return getInitializedAnalytics(this._uninitializedAnalytics) + } + + private _uninitializedAnalytics: AnyAnalytics + + constructor(analytics: AnyAnalytics) { + validateAnalyticsInstance(analytics) + this._uninitializedAnalytics = analytics + this.loadNormally = analytics.load.bind(this._uninitializedAnalytics) + this.cdnSettings = new Promise((resolve) => + this.analytics.on('initialize', resolve) + ) + } + + /** + * Replace the load fn with a new one + */ + replaceLoadMethod(loadFn: AnyAnalytics['load']) { + this.analytics.load = loadFn + } + + page(): void { + this.analytics.page() + } + + configureConsentStampingMiddleware({ + getCategories, + pruneUnmappedCategories, + integrationCategoryMappings, + }: Pick< + CreateWrapperSettings, + 'getCategories' | 'pruneUnmappedCategories' | 'integrationCategoryMappings' + >): void { + // normalize getCategories pruning is turned on or off + const getCategoriesForConsentStamping = async (): Promise => { + if (pruneUnmappedCategories) { + return getPrunedCategories( + getCategories, + await this.cdnSettings, + integrationCategoryMappings + ) + } else { + return getCategories() + } + } + + const MW = createConsentStampingMiddleware(getCategoriesForConsentStamping) + return this.analytics.addSourceMiddleware(MW) + } + + /** + * Dispatch an event that looks like: + * ```ts + * { + * "type": "track", + * "event": "Segment Consent Preference", + * "context": { + * "consent": { + * "categoryPreferences" : { + * "C0001": true, + * "C0002": false, + * } + * } + * ... + * ``` + */ + consentChange(categories: Categories): void { + try { + validateCategories(categories) + } catch (e: unknown) { + // not sure if there's a better way to handle this + return console.error(e) + } + const CONSENT_CHANGED_EVENT = 'Segment Consent Preference' + this.analytics.track(CONSENT_CHANGED_EVENT, undefined, { + consent: { categoryPreferences: categories }, + }) + } +} + +/** + * Get possibly-initialized analytics. + * + * Reason: + * There is a known bug for people who attempt to to wrap the library: the analytics reference does not get updated when the analytics.js library loads. + * Thus, we need to proxy events to the global reference instead. + * + * There is a universal fix here: however, many users may not have updated it: + * https://github.com/segmentio/snippet/commit/081faba8abab0b2c3ec840b685c4ff6d6cccf79c + */ +export const getInitializedAnalytics = ( + analytics: AnyAnalytics +): MaybeInitializedAnalytics => { + const isSnippetUser = Array.isArray(analytics) + if (isSnippetUser) { + const opts = (analytics as any)._loadOptions ?? {} + const globalAnalytics: MaybeInitializedAnalytics | undefined = ( + window as any + )[opts?.globalAnalyticsKey ?? 'analytics'] + // we could probably skip this check and always return globalAnalytics, since they _should_ be set to the same thing at this point + // however, it is safer to keep buffering. + if ((globalAnalytics as any)?.initialized) { + return globalAnalytics! + } + } + + return analytics +} diff --git a/packages/consent/consent-tools/src/domain/analytics/index.ts b/packages/consent/consent-tools/src/domain/analytics/index.ts new file mode 100644 index 000000000..3876ab393 --- /dev/null +++ b/packages/consent/consent-tools/src/domain/analytics/index.ts @@ -0,0 +1 @@ +export { AnalyticsService } from './analytics-service' diff --git a/packages/consent/consent-tools/src/domain/consent-changed.ts b/packages/consent/consent-tools/src/domain/consent-changed.ts deleted file mode 100644 index 6e17738aa..000000000 --- a/packages/consent/consent-tools/src/domain/consent-changed.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { AnyAnalytics, Categories } from '../types' -import { getInitializedAnalytics } from './get-initialized-analytics' -import { validateCategories } from './validation' - -/** - * Dispatch an event that looks like: - * ```ts - * { - * "type": "track", - * "event": "Segment Consent Preference", - * "context": { - * "consent": { - * "categoryPreferences" : { - * "C0001": true, - * "C0002": false, - * } - * } - * ... - * ``` - */ -export const sendConsentChangedEvent = ( - analytics: AnyAnalytics, - categories: Categories -): void => { - getInitializedAnalytics(analytics).track( - CONSENT_CHANGED_EVENT, - undefined, - createConsentChangedCtxDto(categories) - ) -} - -const CONSENT_CHANGED_EVENT = 'Segment Consent Preference' - -const createConsentChangedCtxDto = (categories: Categories) => ({ - consent: { - categoryPreferences: categories, - }, -}) - -export const validateAndSendConsentChangedEvent = ( - analytics: AnyAnalytics, - categories: Categories -) => { - try { - validateCategories(categories) - sendConsentChangedEvent(analytics, categories) - } catch (err) { - // Not sure if there's a better way to handle this, but this makes testing a bit easier. - console.error(err) - } -} diff --git a/packages/consent/consent-tools/src/domain/consent-stamping.ts b/packages/consent/consent-tools/src/domain/consent-stamping.ts index 955aa4484..bbfa11838 100644 --- a/packages/consent/consent-tools/src/domain/consent-stamping.ts +++ b/packages/consent/consent-tools/src/domain/consent-stamping.ts @@ -1,4 +1,5 @@ import { AnyAnalytics, Categories } from '../types' +import { validateCategories } from './validation' type CreateConsentMw = ( getCategories: () => Promise @@ -11,6 +12,7 @@ export const createConsentStampingMiddleware: CreateConsentMw = (getCategories) => async ({ payload, next }) => { const categories = await getCategories() + validateCategories(categories) payload.obj.context.consent = { ...payload.obj.context.consent, categoryPreferences: categories, diff --git a/packages/consent/consent-tools/src/domain/create-wrapper.ts b/packages/consent/consent-tools/src/domain/create-wrapper.ts index 97a417fcc..77ddf866a 100644 --- a/packages/consent/consent-tools/src/domain/create-wrapper.ts +++ b/packages/consent/consent-tools/src/domain/create-wrapper.ts @@ -6,71 +6,69 @@ import { CreateWrapperSettings, CDNSettings, } from '../types' -import { - validateAnalyticsInstance, - validateCategories, - validateSettings, -} from './validation' -import { createConsentStampingMiddleware } from './consent-stamping' -import { pipe, pick, uniq } from '../utils' +import { validateCategories, validateSettings } from './validation' +import { pipe } from '../utils' import { AbortLoadError, LoadContext } from './load-cancellation' -import { ValidationError } from './validation/validation-error' -import { validateAndSendConsentChangedEvent } from './consent-changed' +import { AnalyticsService } from './analytics' +import { segmentShouldBeDisabled } from './disable-segment' export const createWrapper = ( - ...[createWrapperOptions]: Parameters> + ...[createWrapperSettings]: Parameters> ): ReturnType> => { - validateSettings(createWrapperOptions) + validateSettings(createWrapperSettings) const { shouldDisableSegment, - shouldDisableConsentRequirement, getCategories, - shouldLoad, + shouldLoadSegment, integrationCategoryMappings, - shouldEnableIntegration, pruneUnmappedCategories, + shouldEnableIntegration, registerOnConsentChanged, - } = createWrapperOptions + shouldLoadWrapper, + } = createWrapperSettings return (analytics: Analytics) => { - validateAnalyticsInstance(analytics) - - // Call this function as early as possible. OnConsentChanged events can happen before .load is called. - registerOnConsentChanged?.((categories) => - // whenever consent changes, dispatch a new event with the latest consent information - validateAndSendConsentChangedEvent(analytics, categories) - ) - - const ogLoad = analytics.load + const analyticsService = new AnalyticsService(analytics) + const loadWrapper = shouldLoadWrapper?.() || Promise.resolve() + void loadWrapper.then(() => { + // Call this function as early as possible. OnConsentChanged events can happen before .load is called. + registerOnConsentChanged?.((categories) => + // whenever consent changes, dispatch a new event with the latest consent information + analyticsService.consentChange(categories) + ) + }) const loadWithConsent: AnyAnalytics['load'] = async ( settings, options ): Promise => { + // Prevent stale page context by handling initialPageview ourself. + // By calling page() here early, the current page context (url, etc) gets stored in the pre-init buffer. + // We then set initialPageView to false when we call the underlying analytics library, so page() doesn't get called twice. + if (options?.initialPageview) { + analyticsService.page() + options = { ...options, initialPageview: false } + } + // do not load anything -- segment included if (await shouldDisableSegment?.()) { return } - const consentRequirementDisabled = - await shouldDisableConsentRequirement?.() - if (consentRequirementDisabled) { - // ignore consent -- just call analytics.load as usual - return ogLoad.call(analytics, settings, options) - } - // use these categories to disable/enable the appropriate device mode plugins let initialCategories: Categories try { + await loadWrapper initialCategories = - (await shouldLoad?.(new LoadContext())) || (await getCategories()) + (await shouldLoadSegment?.(new LoadContext())) || + (await getCategories()) } catch (e: unknown) { // consumer can call ctx.abort({ loadSegmentNormally: true }) // to load Segment but disable consent requirement if (e instanceof AbortLoadError) { if (e.loadSegmentNormally === true) { - ogLoad.call(analytics, settings, options) + analyticsService.loadNormally(settings, options) } // do not load anything, but do not log anything either // if someone calls ctx.abort(), they are handling the error themselves @@ -82,57 +80,12 @@ export const createWrapper = ( validateCategories(initialCategories) - const getPrunedCategories = async ( - cdnSettingsP: Promise - ): Promise => { - const cdnSettings = await cdnSettingsP - // we don't want to send _every_ category to segment, only the ones that the user has explicitly configured in their integrations - let allCategories: string[] - // We need to get all the unique categories so we can prune the consent object down to only the categories that are configured - // There can be categories that are not included in any integration in the integrations object (e.g. 2 cloud mode categories), which is why we need a special allCategories array - if (integrationCategoryMappings) { - allCategories = uniq( - Object.values(integrationCategoryMappings).reduce((p, n) => - p.concat(n) - ) - ) - } else { - allCategories = cdnSettings.consentSettings?.allCategories || [] - } - - if (!allCategories.length) { - // No configured integrations found, so no categories will be sent (should not happen unless there's a configuration error) - throw new ValidationError( - 'Invariant: No consent categories defined in Segment', - [] - ) - } - - const categories = await getCategories() - - return pick(categories, allCategories) - } - - // create getCategories and validate them regardless of whether pruning is turned on or off - const getValidCategoriesForConsentStamping = pipe( - pruneUnmappedCategories - ? getPrunedCategories.bind( - this, - new Promise((resolve) => - analytics.on('initialize', resolve) - ) - ) - : getCategories, - async (categories) => { - validateCategories(await categories) - return categories - } - ) as () => Promise - // register listener to stamp all events with latest consent information - analytics.addSourceMiddleware( - createConsentStampingMiddleware(getValidCategoriesForConsentStamping) - ) + analyticsService.configureConsentStampingMiddleware({ + getCategories, + integrationCategoryMappings, + pruneUnmappedCategories, + }) const updateCDNSettings: InitOptions['updateCDNSettings'] = ( cdnSettings @@ -149,15 +102,16 @@ export const createWrapper = ( ) } - return ogLoad.call(analytics, settings, { + return analyticsService.loadNormally(settings, { ...options, updateCDNSettings: pipe( updateCDNSettings, - options?.updateCDNSettings ? options.updateCDNSettings : (f) => f + options?.updateCDNSettings || ((id) => id) ), + disable: createDisableOption(initialCategories, options?.disable), }) } - analytics.load = loadWithConsent + analyticsService.replaceLoadMethod(loadWithConsent) return analytics } } @@ -236,3 +190,18 @@ const disableIntegrations = ( ) return results } + +const createDisableOption = ( + initialCategories: Categories, + disable: InitOptions['disable'] +): NonNullable => { + if (disable === true) { + return true + } + return (cdnSettings: CDNSettings) => { + return ( + segmentShouldBeDisabled(initialCategories, cdnSettings.consentSettings) || + (typeof disable === 'function' ? disable(cdnSettings) : false) + ) + } +} diff --git a/packages/consent/consent-tools/src/domain/disable-segment.ts b/packages/consent/consent-tools/src/domain/disable-segment.ts new file mode 100644 index 000000000..7a06751ab --- /dev/null +++ b/packages/consent/consent-tools/src/domain/disable-segment.ts @@ -0,0 +1,18 @@ +import { Categories, CDNSettingsConsent } from '../types' + +/** + * @returns whether or not analytics.js should be completely disabled (never load, or drop cookies) + */ +export const segmentShouldBeDisabled = ( + consentCategories: Categories, + consentSettings: CDNSettingsConsent | undefined +): boolean => { + if (!consentSettings || consentSettings.hasUnmappedDestinations) { + return false + } + + // disable if _all_ of the the consented categories are irrelevant to segment + return Object.keys(consentCategories) + .filter((c) => consentCategories[c]) + .every((c) => !consentSettings.allCategories.includes(c)) +} diff --git a/packages/consent/consent-tools/src/domain/get-initialized-analytics.ts b/packages/consent/consent-tools/src/domain/get-initialized-analytics.ts deleted file mode 100644 index 0a445ed79..000000000 --- a/packages/consent/consent-tools/src/domain/get-initialized-analytics.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { AnyAnalytics } from '../types' - -/** - * There is a known bug for people who attempt to to wrap the library: the analytics reference does not get updated when the analytics.js library loads. - * Thus, we need to proxy events to the global reference instead. - * - * There is a universal fix here: however, many users may not have updated it: - * https://github.com/segmentio/snippet/commit/081faba8abab0b2c3ec840b685c4ff6d6cccf79c - */ -export const getInitializedAnalytics = ( - analytics: AnyAnalytics -): AnyAnalytics => { - const isSnippetUser = Array.isArray(analytics) - if (isSnippetUser) { - const opts = (analytics as any)._loadOptions ?? {} - const globalAnalytics = (window as any)[ - opts?.globalAnalyticsKey ?? 'analytics' - ] - if ((globalAnalytics as any).initialized) { - return globalAnalytics - } - } - - return analytics -} diff --git a/packages/consent/consent-tools/src/domain/load-cancellation.ts b/packages/consent/consent-tools/src/domain/load-cancellation.ts index 1e2ca0b80..0286ac4d1 100644 --- a/packages/consent/consent-tools/src/domain/load-cancellation.ts +++ b/packages/consent/consent-tools/src/domain/load-cancellation.ts @@ -12,8 +12,8 @@ export class AbortLoadError extends AnalyticsConsentError { export interface AbortLoadOptions { /** - * Whether or not to load segment. - * If true -- load segment normally (and disable consent requirement.) Wrapper is essentially a no-op + * Whether or not to disable the consent requirement that is normally enforced by the wrapper. + * If true -- load segment normally. */ loadSegmentNormally: boolean } diff --git a/packages/consent/consent-tools/src/domain/pruned-categories.ts b/packages/consent/consent-tools/src/domain/pruned-categories.ts new file mode 100644 index 000000000..f517d279c --- /dev/null +++ b/packages/consent/consent-tools/src/domain/pruned-categories.ts @@ -0,0 +1,38 @@ +import { uniq, pick } from '../utils' +import { + CDNSettings, + CreateWrapperSettings, + Categories, + GetCategoriesFunction, +} from '../types' +import { ValidationError } from './validation/validation-error' + +export const getPrunedCategories = async ( + getCategories: GetCategoriesFunction, + cdnSettings: CDNSettings, + integrationCategoryMappings?: CreateWrapperSettings['integrationCategoryMappings'] +): Promise => { + // we don't want to send _every_ category to segment, only the ones that the user has explicitly configured in their integrations + let allCategories: string[] + // We need to get all the unique categories so we can prune the consent object down to only the categories that are configured + // There can be categories that are not included in any integration in the integrations object (e.g. 2 cloud mode categories), which is why we need a special allCategories array + if (integrationCategoryMappings) { + allCategories = uniq( + Object.values(integrationCategoryMappings).reduce((p, n) => p.concat(n)) + ) + } else { + allCategories = cdnSettings.consentSettings?.allCategories || [] + } + + if (!allCategories.length) { + // No configured integrations found, so no categories will be sent (should not happen unless there's a configuration error) + throw new ValidationError( + 'Invariant: No consent categories defined in Segment', + [] + ) + } + + const categories = await getCategories() + + return pick(categories, allCategories) +} diff --git a/packages/consent/consent-tools/src/domain/validation/__tests__/validation-error.test.ts b/packages/consent/consent-tools/src/domain/validation/__tests__/validation-error.test.ts index 0580c904f..468d90c5a 100644 --- a/packages/consent/consent-tools/src/domain/validation/__tests__/validation-error.test.ts +++ b/packages/consent/consent-tools/src/domain/validation/__tests__/validation-error.test.ts @@ -9,7 +9,7 @@ describe(ValidationError, () => { expect(err.name).toBe('ValidationError') expect(err.message).toMatchInlineSnapshot( - `"[Validation] foo (Received: \\"bar\\")"` + `"[Validation] foo (Received: "bar")"` ) }) }) diff --git a/packages/consent/consent-tools/src/domain/validation/options-validators.ts b/packages/consent/consent-tools/src/domain/validation/options-validators.ts index a07af6ac0..183ac5a52 100644 --- a/packages/consent/consent-tools/src/domain/validation/options-validators.ts +++ b/packages/consent/consent-tools/src/domain/validation/options-validators.ts @@ -32,13 +32,8 @@ export function validateSettings(options: { assertIsFunction(options.getCategories, 'getCategories') - options.shouldLoad && assertIsFunction(options.shouldLoad, 'shouldLoad') - - options.shouldDisableConsentRequirement && - assertIsFunction( - options.shouldDisableConsentRequirement, - 'shouldDisableConsentRequirement' - ) + options.shouldLoadSegment && + assertIsFunction(options.shouldLoadSegment, 'shouldLoadSegment') options.shouldEnableIntegration && assertIsFunction(options.shouldEnableIntegration, 'shouldEnableIntegration') diff --git a/packages/consent/consent-tools/src/index.ts b/packages/consent/consent-tools/src/index.ts index 6019d8e91..fdca76598 100644 --- a/packages/consent/consent-tools/src/index.ts +++ b/packages/consent/consent-tools/src/index.ts @@ -11,6 +11,7 @@ export type { CreateWrapperSettings, IntegrationCategoryMappings, Categories, + GetCategoriesFunction, RegisterOnConsentChangedFunction, AnyAnalytics, } from './types' diff --git a/packages/consent/consent-tools/src/test-helpers/mocks/analytics-mock.ts b/packages/consent/consent-tools/src/test-helpers/mocks/analytics-mock.ts new file mode 100644 index 000000000..89fdbb9a4 --- /dev/null +++ b/packages/consent/consent-tools/src/test-helpers/mocks/analytics-mock.ts @@ -0,0 +1,9 @@ +import { AnyAnalytics } from '../../types' + +export const analyticsMock: jest.Mocked = { + addSourceMiddleware: jest.fn(), + page: jest.fn(), + load: jest.fn(), + on: jest.fn(), + track: jest.fn(), +} diff --git a/packages/consent/consent-tools/src/test-helpers/mocks/index.ts b/packages/consent/consent-tools/src/test-helpers/mocks/index.ts new file mode 100644 index 000000000..e33eb167b --- /dev/null +++ b/packages/consent/consent-tools/src/test-helpers/mocks/index.ts @@ -0,0 +1 @@ +export * from './analytics-mock' diff --git a/packages/consent/consent-tools/src/types/settings.ts b/packages/consent/consent-tools/src/types/settings.ts index 0d637a879..0fc6e7d41 100644 --- a/packages/consent/consent-tools/src/types/settings.ts +++ b/packages/consent/consent-tools/src/types/settings.ts @@ -5,31 +5,67 @@ import type { CDNSettingsRemotePlugin, } from './wrapper' +/** + * See {@link CreateWrapperSettings.registerOnConsentChanged} + */ export type RegisterOnConsentChangedFunction = ( categoriesChangedCb: (categories: Categories) => void ) => void +/** + * See {@link CreateWrapperSettings.getCategories} + */ +export type GetCategoriesFunction = () => Categories | Promise + /** * Consent wrapper function configuration */ export interface CreateWrapperSettings { + /** + * Wait until this function's Promise resolves before attempting to initialize the wrapper with any settings passed into it. + * Typically, this is used to wait for a CMP global object (e.g. window.OneTrust) to be available. + * This function is called as early possible in the lifecycle, before `shouldLoadWrapper`, `registerOnConsentChanged` and `getCategories`. + * Throwing an error here will prevent the wrapper from loading (just as if `shouldDisableSegment` returned true). + * @example + * ```ts + * () => resolveWhen(() => window.myCMP !== undefined, 500) + * ``` + **/ + shouldLoadWrapper?: () => Promise + /** * Wait until this function resolves/returns before loading analytics. * This function should return a list of initial categories. * If this function returns `undefined`, `getCategories()` function will be called to get initial categories. + * @example + * ```ts + * // Wrapper waits to load segment / get categories until this function returns / resolves + * shouldLoadSegment: async (ctx) => { + * await resolveWhen( + * () => !window.CMP.popUpVisible(), + * 500 + * ) + * // Optional + * if (noConsentNeeded) { + * // load Segment normally + * ctx.abort({ loadSegmentNormally: true }) + * } else if (noTrackingNeeded) { + * // do not load Segment + * ctx.abort({ loadSegmentNormally: false }) + * } + * }, + * ``` **/ - shouldLoad?: ( - context: LoadContext + shouldLoadSegment?: ( + ctx: LoadContext ) => Categories | void | Promise /** * Fetch the categories which stamp every event. Called each time a new Segment event is dispatched. * @example - * ```ts * () => ({ "Advertising": true, "Analytics": false }) - * ``` **/ - getCategories: () => Categories | Promise + getCategories: GetCategoriesFunction /** * Function to register a listener for consent changes to programatically send a "Segment Consent Preference" event to Segment when consent preferences change. @@ -59,17 +95,11 @@ export interface CreateWrapperSettings { */ registerOnConsentChanged?: RegisterOnConsentChangedFunction - /** - * This permanently disables any consent requirement (i.e device mode gating, event pref stamping). - * Called on wrapper initialization. **shouldLoad will never be called** - **/ - shouldDisableConsentRequirement?: () => boolean | Promise - /** * Disable the Segment analytics SDK completely. analytics.load() will have no effect. * .track / .identify etc calls should not throw any errors, but analytics settings will never be fetched and no events will be sent to Segment. * Called on wrapper initialization. This can be useful in dev environments (e.g. 'devMode'). - * **shouldLoad will never be called** + * **shouldLoadSegment will never be called** **/ shouldDisableSegment?: () => boolean | Promise diff --git a/packages/consent/consent-tools/src/types/wrapper.ts b/packages/consent/consent-tools/src/types/wrapper.ts index edb646026..c10fe1ffc 100644 --- a/packages/consent/consent-tools/src/types/wrapper.ts +++ b/packages/consent/consent-tools/src/types/wrapper.ts @@ -1,3 +1,4 @@ +import { OptionalField } from '../utils' import type { CreateWrapperSettings } from './settings' export interface AnalyticsBrowserSettings { @@ -10,23 +11,32 @@ export interface AnalyticsBrowserSettings { * 2nd arg to AnalyticsBrowser.load / analytics */ export interface InitOptions { - updateCDNSettings(cdnSettings: CDNSettings): CDNSettings + updateCDNSettings?(cdnSettings: CDNSettings): CDNSettings + disable?: boolean | ((cdnSettings: CDNSettings) => boolean) + initialPageview?: boolean } +/** + * Underling analytics instance so it does not have a load method. + * This type is neccessary because the final 'initialized' Analytics instance in `window.analytics` does not have a load method (ditto, new AnalyticsBrowser().instance) + * This is compatible with one of the following interfaces: `Analytics`, `AnalyticsSnippet`, `AnalyticsBrowser`. + */ +export type MaybeInitializedAnalytics = { + initialized?: boolean +} & OptionalField + /** * This interface is a stub of the actual Segment analytics instance. - * This can be either: - * - window.analytics (i.e `AnalyticsSnippet`) - * - the instance returned by `AnalyticsBrowser.load({...})` - * - the instance created by `new AnalyticsBrowser(...)` + * Either `AnalyticsSnippet` _or_ `AnalyticsBrowser`. */ export interface AnyAnalytics { addSourceMiddleware(...args: any[]): any on(event: 'initialize', callback: (cdnSettings: CDNSettings) => void): void track(event: string, properties?: unknown, ...args: any[]): void + page(): void /** - * This interface is meant to be compatible with both the snippet (`window.analytics.load`) + * This interface is meant to be compatible with both the snippet (`analytics.load`) * and the npm lib (`AnalyticsBrowser.load`) */ load( @@ -60,13 +70,17 @@ export interface IntegrationCategoryMappings { [integrationName: string]: string[] } +export interface CDNSettingsConsent { + // all unique categories keys + allCategories: string[] + // where user has unmapped enabled destinations + hasUnmappedDestinations: boolean +} + export interface CDNSettings { integrations: CDNSettingsIntegrations remotePlugins?: CDNSettingsRemotePlugin[] - consentSettings?: { - // all unique categories keys - allCategories: string[] - } + consentSettings?: CDNSettingsConsent } /** diff --git a/packages/consent/consent-tools/src/utils/index.ts b/packages/consent/consent-tools/src/utils/index.ts index 3d2e19124..3524db676 100644 --- a/packages/consent/consent-tools/src/utils/index.ts +++ b/packages/consent/consent-tools/src/utils/index.ts @@ -2,3 +2,4 @@ export * from './pipe' export * from './resolve-when' export * from './uniq' export * from './pick' +export * from './ts-helpers' diff --git a/packages/consent/consent-tools/src/utils/ts-helpers.ts b/packages/consent/consent-tools/src/utils/ts-helpers.ts new file mode 100644 index 000000000..043349bb4 --- /dev/null +++ b/packages/consent/consent-tools/src/utils/ts-helpers.ts @@ -0,0 +1,24 @@ +/** + * Resolve a type to its final form. + */ +type Compute = { [K in keyof T]: T[K] } & {} + +/** + * A utility type that makes a specified set of fields optional in a given type. + * @template T - The type to make fields optional in. + * @template K - The keys of the fields to make optional. + * @example + * type MyType = { + * a: string + * b: number + * } + * type MyTypeWithOptionalA = OptionalField + * // MyTypeWithOptionalA is equivalent to: + * type MyTypeWithOptionalA = { + * a?: string + * b: number + * } + */ +export type OptionalField = Compute< + Omit & Partial> +> diff --git a/packages/consent/consent-tools/tsconfig.json b/packages/consent/consent-tools/tsconfig.json index fb6cefa38..302ac0687 100644 --- a/packages/consent/consent-tools/tsconfig.json +++ b/packages/consent/consent-tools/tsconfig.json @@ -3,9 +3,10 @@ "exclude": ["node_modules", "dist"], "compilerOptions": { "module": "ESNext", // es6 modules - "target": "ESNext", // assume that consumers will be using webpack, so don't down-compile + "target": "ES2020", // don't down-compile *too much* -- if users are using webpack, they can always transpile this library themselves "lib": ["ES2020", "DOM", "DOM.Iterable"], // assume that consumers will be polyfilling at least down to es2020 "moduleResolution": "node", - "resolveJsonModule": true + "resolveJsonModule": true, + "isolatedModules": true // ensure we are friendly to build systems } } diff --git a/packages/consent/consent-wrapper-onetrust/CHANGELOG.md b/packages/consent/consent-wrapper-onetrust/CHANGELOG.md index 9d10ddf0e..376db5ece 100644 --- a/packages/consent/consent-wrapper-onetrust/CHANGELOG.md +++ b/packages/consent/consent-wrapper-onetrust/CHANGELOG.md @@ -1,5 +1,47 @@ # @segment/analytics-consent-wrapper-onetrust +## 0.4.0 + +### Minor Changes + +- [#1009](https://github.com/segmentio/analytics-next/pull/1009) [`f476038`](https://github.com/segmentio/analytics-next/commit/f47603881b787cc81fa1da4496bdbde9eb325a0f) Thanks [@silesky](https://github.com/silesky)! - If initialPageview is true, call analytics.page() as early as possible to avoid stale page context. + +### Patch Changes + +- [#1020](https://github.com/segmentio/analytics-next/pull/1020) [`7b93e7b`](https://github.com/segmentio/analytics-next/commit/7b93e7b50fa293aebaf6767a44bf7708b231d5cd) Thanks [@silesky](https://github.com/silesky)! - Add tslib to resolve unsound dependency warning. + +- Updated dependencies [[`7b93e7b`](https://github.com/segmentio/analytics-next/commit/7b93e7b50fa293aebaf6767a44bf7708b231d5cd), [`f476038`](https://github.com/segmentio/analytics-next/commit/f47603881b787cc81fa1da4496bdbde9eb325a0f)]: + - @segment/analytics-consent-tools@1.2.0 + +## 0.3.3 + +### Patch Changes + +- Updated dependencies [[`57be1ac`](https://github.com/segmentio/analytics-next/commit/57be1acd556a9779edbc5fd4d3f820fb50b65697), [`dcf279c`](https://github.com/segmentio/analytics-next/commit/dcf279c4591c84952c78022ddfbad945aab8cfde)]: + - @segment/analytics-consent-tools@1.1.0 + +## 0.3.2 + +### Patch Changes + +- [`4ca9f84`](https://github.com/segmentio/analytics-next/commit/4ca9f84106ba1f86953b6b8632bb49929416cb64) Thanks [@silesky](https://github.com/silesky)! - #987 + + Fix bug: if showAlertNotice is false and Segment has default categories, we want to immediately load Segment with any default categories. + +- Updated dependencies [[`930af49`](https://github.com/segmentio/analytics-next/commit/930af49b27f7c2973304c7ae76b67d264223e6f6), [`a361575`](https://github.com/segmentio/analytics-next/commit/a361575152f8313dfded3b0cc4b9912b4e2a41c3), [`008a019`](https://github.com/segmentio/analytics-next/commit/008a01927973340bd93cd0097e45c455d49baea5)]: + - @segment/analytics-consent-tools@1.0.0 + +## 0.3.1 + +### Patch Changes + +- [#959](https://github.com/segmentio/analytics-next/pull/959) [`32da78b`](https://github.com/segmentio/analytics-next/commit/32da78b922d6ffe030585dc7ba1b271b78d5f6dd) Thanks [@silesky](https://github.com/silesky)! - Support older browsers + +* [#962](https://github.com/segmentio/analytics-next/pull/962) [`a725117`](https://github.com/segmentio/analytics-next/commit/a7251174c293be1845d6dbfb20a47c4a490c2d3a) Thanks [@silesky](https://github.com/silesky)! - Change tsconfig compile target from ESNext->ES2020 + +* Updated dependencies [[`32da78b`](https://github.com/segmentio/analytics-next/commit/32da78b922d6ffe030585dc7ba1b271b78d5f6dd)]: + - @segment/analytics-consent-tools@0.2.1 + ## 0.3.0 ### Minor Changes diff --git a/packages/consent/consent-wrapper-onetrust/README.md b/packages/consent/consent-wrapper-onetrust/README.md index 8d317d69b..2247587fa 100644 --- a/packages/consent/consent-wrapper-onetrust/README.md +++ b/packages/consent/consent-wrapper-onetrust/README.md @@ -8,31 +8,57 @@ ## Configure OneTrust + Segment -### Ensure that the OneTrust Banner SDK is loaded first + +### Requirements +Ensure that consent is enabled and that you have registered your integration-to-category mappings in Segment, which you can do through the Segment UI. + +Note: "categories" are called "groups" in OneTrust. + +If you don't see a "Consent Management" option like the one below, please contact support or your Solutions Engineer to have it enabled on your workspace. + +![Segment.io consent management UI](img/consent-mgmt-ui.png) + +- Debugging hints: this library expects the [OneTrust Banner SDK](https://community.cookiepro.com/s/article/UUID-d8291f61-aa31-813a-ef16-3f6dec73d643?language=en_US) to be available in order interact with OneTrust. This library derives the group IDs that are active for the current user from the `window.OneTrustActiveGroups` object provided by the OneTrust SDK. [Read this for more information [community.cookiepro.com]](https://community.cookiepro.com/s/article/UUID-66bcaaf1-c7ca-5f32-6760-c75a1337c226?language=en_US). + + +## For snippet users +### Add OneTrust snippet and integration to your page ```html - + - -``` - -### Ensure that consent is enabled and that you have created your Integration -> Consent Category Mappings + -- Ensure that your integrations in the Segment UI have consent enabled, and that they map to your Consent Category IDs (also called Cookie Group IDs or Cookie Consent IDs). - The IDs look like "C0001", "C0002"and are configurable in OneTrust - ![onetrust category ids](img/onetrust-cat-id.jpg) + + -- Debugging: this library expects the [OneTrust Banner SDK](https://community.cookiepro.com/s/article/UUID-d8291f61-aa31-813a-ef16-3f6dec73d643?language=en_US) to be available in order interact with OneTrust. This library derives the group IDs that are active for the current user from the `window.OneTrustActiveGroups` object provided by the OneTrust SDK. [Read this for more information [community.cookiepro.com]](https://community.cookiepro.com/s/article/UUID-66bcaaf1-c7ca-5f32-6760-c75a1337c226?language=en_US). + + + +``` +#### ⚠️ Reminder: _you must modify_ `analytics.load('....')` from the original Segment snippet. See markup comment in example above. ## For `npm` library users -1. Install the package +1. Ensure that OneTrust Snippet is loaded. [See example above.](#add-onetrust-snippet-and-integration-to-your-page) + +2. Install the package from npm ```sh # npm @@ -45,7 +71,7 @@ yarn add @segment/analytics-consent-wrapper-onetrust pnpm add @segment/analytics-consent-wrapper-onetrust ``` -2. Initialize alongside analytics +3. Initialize alongside analytics ```ts import { withOneTrust } from '@segment/analytics-consent-wrapper-onetrust' @@ -57,43 +83,12 @@ withOneTrust(analytics).load({ writeKey: ' }) ``` -## For snippet users (window.analytics) - -1. In your head - -```html - - - - - - - - - - -``` - -#### ⚠️ Reminder: _you must modify_ `analytics.load('....')` from the original Segment snippet. See markup comment in example above. - ## Other examples: > Note: Playgrounds are meant for experimentation / testing, and as such, may be a bit overly complicated. > We recommend you try to follcaow the documentation for best practice. -- [Standalone playground](/examples/standalone-playground/pages/index-consent.html) +- [Standalone playground](/playgrounds/standalone-playground/pages/index-consent.html) ## Environments @@ -101,13 +96,13 @@ withOneTrust(analytics).load({ writeKey: ' }) - We build three versions of the library: -1. `cjs` (CommonJS modules) - for library users -2. `esm` (es6 modules) - for library users +1. `cjs` (CommonJS modules) - for npm library users +2. `esm` (es6 modules) - for npm library users 3. `umd` (bundle) - for snippet users (typically) ### Browser Support -- `cjs/esm` - ONLY support modern JS syntax. We expect our typical `npm install` users to employ something like babel if they need legacy browser support. +- `cjs/esm` - Support modern JS syntax (ES2020). These are our npm library users, so we expect them to transpile this module themselves using something like babel/webpack if they need extra legacy browser support. - `umd` - Support back to IE11, but **do not** polyfill . See our docs on [supported browsers](https://segment.com/docs/connections/sources/catalog/libraries/website/javascript/supported-browsers). diff --git a/packages/consent/consent-wrapper-onetrust/img/consent-mgmt-ui.png b/packages/consent/consent-wrapper-onetrust/img/consent-mgmt-ui.png new file mode 100644 index 000000000..a35280dcf Binary files /dev/null and b/packages/consent/consent-wrapper-onetrust/img/consent-mgmt-ui.png differ diff --git a/packages/consent/consent-wrapper-onetrust/package.json b/packages/consent/consent-wrapper-onetrust/package.json index acf2de1d8..b55957a2e 100644 --- a/packages/consent/consent-wrapper-onetrust/package.json +++ b/packages/consent/consent-wrapper-onetrust/package.json @@ -1,6 +1,6 @@ { "name": "@segment/analytics-consent-wrapper-onetrust", - "version": "0.3.0", + "version": "0.4.0", "keywords": [ "segment", "analytics", @@ -13,7 +13,7 @@ "sideEffects": [ "./dist/umd/analytics-onetrust.umd.js" ], - "jsdeliver": "./dist/umd/analytics-onetrust.umd.js", + "jsdelivr": "./dist/umd/analytics-onetrust.umd.js", "unpkg": "./dist/umd/analytics-onetrust.umd.js", "files": [ "LICENSE", @@ -41,7 +41,8 @@ "webpack": "yarn run -T webpack" }, "dependencies": { - "@segment/analytics-consent-tools": "0.2.0" + "@segment/analytics-consent-tools": "1.2.0", + "tslib": "^2.4.1" }, "peerDependencies": { "@segment/analytics-next": ">=1.53.1" diff --git a/packages/consent/consent-wrapper-onetrust/src/domain/__tests__/wrapper.test.ts b/packages/consent/consent-wrapper-onetrust/src/domain/__tests__/wrapper.test.ts index 933d2daf2..4f24702fb 100644 --- a/packages/consent/consent-wrapper-onetrust/src/domain/__tests__/wrapper.test.ts +++ b/packages/consent/consent-wrapper-onetrust/src/domain/__tests__/wrapper.test.ts @@ -2,90 +2,103 @@ import * as ConsentTools from '@segment/analytics-consent-tools' import * as OneTrustAPI from '../../lib/onetrust-api' import { sleep } from '@internal/test-helpers' import { withOneTrust } from '../wrapper' -import { OneTrustMockGlobal, analyticsMock } from '../../test-helpers/mocks' - -const throwNotImplemented = (): never => { - throw new Error('not implemented') -} - -const grpFixture = { - StrictlyNeccessary: { - CustomGroupId: 'C0001', - }, - Targeting: { - CustomGroupId: 'C0004', - }, - Performance: { - CustomGroupId: 'C0005', - }, -} +import { + OneTrustMockGlobal, + analyticsMock, + domainDataMock, + domainGroupMock, +} from '../../test-helpers/mocks' const getConsentedGroupIdsSpy = jest .spyOn(OneTrustAPI, 'getConsentedGroupIds') - .mockImplementationOnce(throwNotImplemented) + .mockImplementationOnce(() => { + throw new Error('not implemented') + }) const createWrapperSpyHelper = { _spy: jest.spyOn(ConsentTools, 'createWrapper'), - get shouldLoad() { - return createWrapperSpyHelper._spy.mock.lastCall[0].shouldLoad! + get shouldLoadWrapper() { + return createWrapperSpyHelper._spy.mock.lastCall![0].shouldLoadWrapper! + }, + get shouldLoadSegment() { + return createWrapperSpyHelper._spy.mock.lastCall![0].shouldLoadSegment! }, get getCategories() { - return createWrapperSpyHelper._spy.mock.lastCall[0].getCategories! + return createWrapperSpyHelper._spy.mock.lastCall![0].getCategories! }, get registerOnConsentChanged() { - return createWrapperSpyHelper._spy.mock.lastCall[0] + return createWrapperSpyHelper._spy.mock.lastCall![0] .registerOnConsentChanged! }, } - /** * These tests are not meant to be comprehensive, but they should cover the most important cases. * We should prefer unit tests for most functionality (see lib/__tests__) */ describe('High level "integration" tests', () => { - let resolveResolveWhen = () => {} + let checkResolveWhen = () => {} beforeEach(() => { jest .spyOn(OneTrustAPI, 'getOneTrustGlobal') .mockImplementation(() => OneTrustMockGlobal) getConsentedGroupIdsSpy.mockReset() + analyticsMock.on = jest.fn() Object.values(OneTrustMockGlobal).forEach((fn) => fn.mockReset()) /** * Typically, resolveWhen triggers when a predicate is true. We can manually 'check' so we don't have to use timeouts. */ jest.spyOn(ConsentTools, 'resolveWhen').mockImplementation(async (fn) => { - return new Promise((_resolve) => { - resolveResolveWhen = () => { - if (fn()) { - _resolve() - } else { - throw new Error('Refuse to resolve, resolveWhen condition is false') - } + return new Promise((_resolve, _reject) => { + checkResolveWhen = () => { + fn() ? _resolve() : _reject('predicate failed.') } }) }) }) - describe('shouldLoad', () => { - it('should be resolved successfully', async () => { + describe('shouldLoadSegment', () => { + it('should load if alert box is closed and groups are defined', async () => { + withOneTrust(analyticsMock) + + const shouldLoadSegment = Promise.resolve( + createWrapperSpyHelper.shouldLoadSegment({} as any) + ) + OneTrustMockGlobal.GetDomainData.mockReturnValueOnce(domainDataMock) + OneTrustMockGlobal.IsAlertBoxClosed.mockReturnValueOnce(true) + getConsentedGroupIdsSpy.mockImplementation(() => [ + domainGroupMock.StrictlyNeccessary.CustomGroupId, + ]) + checkResolveWhen() + await expect(shouldLoadSegment).resolves.toBeUndefined() + }) + + it('should not load at all if no groups are defined', async () => { + withOneTrust(analyticsMock) + getConsentedGroupIdsSpy.mockImplementation(() => []) + const shouldLoadSegment = Promise.resolve( + createWrapperSpyHelper.shouldLoadSegment({} as any) + ) + void shouldLoadSegment.catch(() => {}) + OneTrustMockGlobal.IsAlertBoxClosed.mockReturnValueOnce(true) + checkResolveWhen() + await expect(shouldLoadSegment).rejects.toEqual(expect.anything()) + }) + + it("should load regardless of AlertBox status if showAlertNotice is true (e.g. 'show banner is unchecked')", async () => { withOneTrust(analyticsMock) OneTrustMockGlobal.GetDomainData.mockReturnValueOnce({ - Groups: [grpFixture.StrictlyNeccessary, grpFixture.Performance], + ...domainDataMock, + ShowAlertNotice: false, // meaning, it's open }) getConsentedGroupIdsSpy.mockImplementation(() => [ - grpFixture.StrictlyNeccessary.CustomGroupId, + domainGroupMock.StrictlyNeccessary.CustomGroupId, ]) - const shouldLoadP = Promise.resolve( - createWrapperSpyHelper.shouldLoad({} as any) + const shouldLoadSegment = Promise.resolve( + createWrapperSpyHelper.shouldLoadSegment({} as any) ) - let shouldLoadResolved = false - void shouldLoadP.then(() => (shouldLoadResolved = true)) - await sleep(0) - expect(shouldLoadResolved).toBe(false) - OneTrustMockGlobal.IsAlertBoxClosed.mockReturnValueOnce(true) - resolveResolveWhen() - const result = await shouldLoadP - expect(result).toBe(undefined) + OneTrustMockGlobal.IsAlertBoxClosed.mockReturnValueOnce(false) // alert box is _never open + checkResolveWhen() + await expect(shouldLoadSegment).resolves.toBeUndefined() }) }) @@ -93,14 +106,15 @@ describe('High level "integration" tests', () => { it('should get categories successfully', async () => { withOneTrust(analyticsMock) OneTrustMockGlobal.GetDomainData.mockReturnValue({ + ...domainDataMock, Groups: [ - grpFixture.StrictlyNeccessary, - grpFixture.Performance, - grpFixture.Targeting, + domainGroupMock.StrictlyNeccessary, + domainGroupMock.Performance, + domainGroupMock.Targeting, ], }) getConsentedGroupIdsSpy.mockImplementation(() => [ - grpFixture.StrictlyNeccessary.CustomGroupId, + domainGroupMock.StrictlyNeccessary.CustomGroupId, ]) const categories = createWrapperSpyHelper.getCategories() // contain both consented and denied category @@ -116,35 +130,40 @@ describe('High level "integration" tests', () => { it('should enable consent changed by default', async () => { withOneTrust(analyticsMock) OneTrustMockGlobal.GetDomainData.mockReturnValue({ + ...domainDataMock, Groups: [ - grpFixture.StrictlyNeccessary, - grpFixture.Performance, - grpFixture.Targeting, + domainGroupMock.StrictlyNeccessary, + domainGroupMock.Performance, + domainGroupMock.Targeting, ], }) const onCategoriesChangedCb = jest.fn() + void createWrapperSpyHelper.shouldLoadWrapper() createWrapperSpyHelper.registerOnConsentChanged(onCategoriesChangedCb) onCategoriesChangedCb() - resolveResolveWhen() // wait for OneTrust global to be available + checkResolveWhen() // wait for OneTrust global to be available await sleep(0) + analyticsMock.track.mockImplementationOnce(() => {}) // ignore track event sent by consent changed + const onConsentChangedArg = - OneTrustMockGlobal.OnConsentChanged.mock.lastCall[0] + OneTrustMockGlobal.OnConsentChanged.mock.lastCall![0] onConsentChangedArg( new CustomEvent('', { detail: [ - grpFixture.StrictlyNeccessary.CustomGroupId, - grpFixture.Performance.CustomGroupId, + domainGroupMock.StrictlyNeccessary.CustomGroupId, + domainGroupMock.Performance.CustomGroupId, ], }) ) + // expect to be normalized! expect(onCategoriesChangedCb.mock.lastCall[0]).toEqual({ - [grpFixture.StrictlyNeccessary.CustomGroupId]: true, - [grpFixture.Performance.CustomGroupId]: true, - [grpFixture.Targeting.CustomGroupId]: false, + [domainGroupMock.StrictlyNeccessary.CustomGroupId]: true, + [domainGroupMock.Performance.CustomGroupId]: true, + [domainGroupMock.Targeting.CustomGroupId]: false, }) }) }) diff --git a/packages/consent/consent-wrapper-onetrust/src/domain/wrapper.ts b/packages/consent/consent-wrapper-onetrust/src/domain/wrapper.ts index 39458c2cb..3035f7275 100644 --- a/packages/consent/consent-wrapper-onetrust/src/domain/wrapper.ts +++ b/packages/consent/consent-wrapper-onetrust/src/domain/wrapper.ts @@ -2,7 +2,6 @@ import { AnyAnalytics, createWrapper, CreateWrapperSettings, - RegisterOnConsentChangedFunction, resolveWhen, } from '@segment/analytics-consent-tools' @@ -27,25 +26,22 @@ export const withOneTrust = ( analyticsInstance: Analytics, settings: OneTrustSettings = {} ): Analytics => { - const registerOnConsentChanged: RegisterOnConsentChangedFunction = async ( - onCategoriesChangedCb - ) => { - await resolveWhen(() => getOneTrustGlobal() !== undefined, 500) - getOneTrustGlobal()!.OnConsentChanged((event) => { - const normalizedCategories = getNormalizedCategoriesFromGroupIds( - event.detail - ) - onCategoriesChangedCb(normalizedCategories) - }) - } return createWrapper({ - shouldLoad: async () => { + // wait for OneTrust global to be available before wrapper is loaded + shouldLoadWrapper: async () => { + await resolveWhen(() => getOneTrustGlobal() !== undefined, 500) + }, + // wait for AlertBox to be closed before segment can be loaded. If no consented groups, do not load Segment. + shouldLoadSegment: async () => { await resolveWhen(() => { - const oneTrustGlobal = getOneTrustGlobal() + const OneTrust = getOneTrustGlobal()! return ( - oneTrustGlobal !== undefined && + // if any groups at all are consented to Boolean(getConsentedGroupIds().length) && - oneTrustGlobal.IsAlertBoxClosed() + // if show banner is unchecked in the UI + (OneTrust.GetDomainData().ShowAlertNotice === false || + // if alert box is closed by end user + OneTrust.IsAlertBoxClosed()) ) }, 500) }, @@ -55,7 +51,14 @@ export const withOneTrust = ( }, registerOnConsentChanged: settings.disableConsentChangedEvent ? undefined - : registerOnConsentChanged, + : (setCategories) => { + getOneTrustGlobal()!.OnConsentChanged((event) => { + const normalizedCategories = getNormalizedCategoriesFromGroupIds( + event.detail + ) + setCategories(normalizedCategories) + }) + }, integrationCategoryMappings: settings.integrationCategoryMappings, })(analyticsInstance) } diff --git a/packages/consent/consent-wrapper-onetrust/src/lib/__tests__/onetrust-api.test.ts b/packages/consent/consent-wrapper-onetrust/src/lib/__tests__/onetrust-api.test.ts index fdce55850..8cd512d95 100644 --- a/packages/consent/consent-wrapper-onetrust/src/lib/__tests__/onetrust-api.test.ts +++ b/packages/consent/consent-wrapper-onetrust/src/lib/__tests__/onetrust-api.test.ts @@ -9,7 +9,7 @@ import { getOneTrustGlobal, getAllGroups, } from '../onetrust-api' -import { OneTrustMockGlobal } from '../../test-helpers/mocks' +import { domainDataMock, OneTrustMockGlobal } from '../../test-helpers/mocks' import { OneTrustApiValidationError } from '../validation' beforeEach(() => { @@ -21,13 +21,39 @@ beforeEach(() => { describe(getOneTrustGlobal, () => { it('should get the global', () => { + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementationOnce(() => {}) ;(window as any).OneTrust = OneTrustMockGlobal expect(getOneTrustGlobal()).toEqual(OneTrustMockGlobal) + expect(consoleErrorSpy).not.toHaveBeenCalled() }) - it('should throw an error if the global is missing fields', () => { + it('should handle null or undefined', () => { + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementationOnce(() => {}) + ;(window as any).OneTrust = undefined + expect(getOneTrustGlobal()).toBeUndefined() + ;(window as any).OneTrust = null + expect(getOneTrustGlobal()).toBeUndefined() + expect(consoleErrorSpy).not.toHaveBeenCalled() + }) + + it('should log an error if the global is an unexpected type', () => { ;(window as any).OneTrust = {} - expect(() => getOneTrustGlobal()).toThrow(OneTrustApiValidationError) + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementationOnce(() => {}) + expect(getOneTrustGlobal()).toBeUndefined() + expect(consoleErrorSpy.mock.lastCall![0]).toMatch(/window.OneTrust/i) + }) + + it('should not log an error if OneTrust just returns geolocationResponse', () => { + ;(window as any).OneTrust = { geolocationResponse: {} as any } + const consoleErrorSpy = jest.spyOn(console, 'error') + expect(getOneTrustGlobal()).toBeUndefined() + expect(consoleErrorSpy).not.toHaveBeenCalled() }) }) @@ -41,6 +67,7 @@ describe(getAllGroups, () => { window.OneTrust = { ...OneTrustMockGlobal, GetDomainData: () => ({ + ...domainDataMock, Groups: [ { CustomGroupId: 'C0001', @@ -125,6 +152,7 @@ describe(getGroupDataFromGroupIds, () => { window.OneTrust = { ...OneTrustMockGlobal, GetDomainData: () => ({ + ...domainDataMock, Groups: [ { CustomGroupId: 'C0001', @@ -162,6 +190,7 @@ describe(getNormalizedCategoriesFromGroupIds, () => { window.OneTrust = { ...OneTrustMockGlobal, GetDomainData: () => ({ + ...domainDataMock, Groups: [ { CustomGroupId: 'C0001', diff --git a/packages/consent/consent-wrapper-onetrust/src/lib/onetrust-api.ts b/packages/consent/consent-wrapper-onetrust/src/lib/onetrust-api.ts index 25c81ccc9..8791bdf6f 100644 --- a/packages/consent/consent-wrapper-onetrust/src/lib/onetrust-api.ts +++ b/packages/consent/consent-wrapper-onetrust/src/lib/onetrust-api.ts @@ -21,13 +21,15 @@ type GroupInfoDto = { type OtConsentChangedEvent = CustomEvent +export interface OneTrustDomainData { + ShowAlertNotice: boolean + Groups: GroupInfoDto[] +} /** * The data model used by the OneTrust lib */ export interface OneTrustGlobal { - GetDomainData: () => { - Groups: GroupInfoDto[] - } + GetDomainData: () => OneTrustDomainData /** * This callback appears to fire whenever the alert box is closed, no matter what. * E.g: @@ -50,10 +52,19 @@ export const getOneTrustGlobal = (): OneTrustGlobal | undefined => { ) { return oneTrust } + // if "show banner" is unchecked, window.OneTrust returns {geolocationResponse: {…}} before it actually returns the OneTrust object + if ('geolocationResponse' in oneTrust) { + return undefined + } - throw new OneTrustApiValidationError( - 'window.OneTrust is not in expected format', - oneTrust + console.error( + // OneTrust API has some gotchas -- since this function is often as a polling loop, not + // throwing an error since it's possible that some setup is happening behind the scenes and + // the OneTrust API is not available yet (e.g. see the geolocationResponse edge case). + new OneTrustApiValidationError( + 'window.OneTrust is unexpected type', + oneTrust + ).message ) } diff --git a/packages/consent/consent-wrapper-onetrust/src/test-helpers/mocks.ts b/packages/consent/consent-wrapper-onetrust/src/test-helpers/mocks.ts index 32640d75b..488e549ec 100644 --- a/packages/consent/consent-wrapper-onetrust/src/test-helpers/mocks.ts +++ b/packages/consent/consent-wrapper-onetrust/src/test-helpers/mocks.ts @@ -1,5 +1,5 @@ -import { OneTrustGlobal } from '../lib/onetrust-api' -import { throwNotImplemented } from './utils' +import { OneTrustDomainData, OneTrustGlobal } from '../lib/onetrust-api' +import { addDebugMockImplementation } from './utils' import type { AnyAnalytics } from '@segment/analytics-consent-tools' /** * This can be used to mock the OneTrust global object in individual tests @@ -10,14 +10,35 @@ import type { AnyAnalytics } from '@segment/analytics-consent-tools' * ```` */ export const OneTrustMockGlobal: jest.Mocked = { - GetDomainData: jest.fn().mockImplementation(throwNotImplemented), - IsAlertBoxClosed: jest.fn().mockImplementation(throwNotImplemented), - OnConsentChanged: jest.fn().mockImplementation(throwNotImplemented), // not implemented atm + GetDomainData: jest.fn(), + IsAlertBoxClosed: jest.fn(), + OnConsentChanged: jest.fn(), } export const analyticsMock: jest.Mocked = { - addSourceMiddleware: jest.fn().mockImplementation(throwNotImplemented), - load: jest.fn().mockImplementation(throwNotImplemented), - on: jest.fn().mockImplementation(throwNotImplemented), - track: jest.fn().mockImplementation(throwNotImplemented), + page: jest.fn(), + addSourceMiddleware: jest.fn(), + load: jest.fn(), + on: jest.fn(), + track: jest.fn(), } + +export const domainGroupMock = { + StrictlyNeccessary: { + CustomGroupId: 'C0001', + }, + Targeting: { + CustomGroupId: 'C0004', + }, + Performance: { + CustomGroupId: 'C0005', + }, +} + +export const domainDataMock: jest.Mocked = { + Groups: [domainGroupMock.StrictlyNeccessary], + ShowAlertNotice: true, +} + +addDebugMockImplementation(OneTrustMockGlobal) +addDebugMockImplementation(analyticsMock) diff --git a/packages/consent/consent-wrapper-onetrust/src/test-helpers/utils.ts b/packages/consent/consent-wrapper-onetrust/src/test-helpers/utils.ts index dca42f3ae..c448d5a3c 100644 --- a/packages/consent/consent-wrapper-onetrust/src/test-helpers/utils.ts +++ b/packages/consent/consent-wrapper-onetrust/src/test-helpers/utils.ts @@ -1,3 +1,13 @@ -export const throwNotImplemented = (): never => { - throw new Error('not implemented') +/** + * Allows mocked objects to throw a helpful error message when a method is called without an implementation + */ +export const addDebugMockImplementation = (mock: jest.Mocked) => { + Object.entries(mock).forEach(([method, value]) => { + // automatically add mock implementation for debugging purposes + if (typeof value === 'function') { + mock[method] = mock[method].mockImplementation((...args: any[]) => { + throw new Error(`Not Implemented: ${method}(${JSON.stringify(args)})`) + }) + } + }) } diff --git a/packages/consent/consent-wrapper-onetrust/tsconfig.json b/packages/consent/consent-wrapper-onetrust/tsconfig.json index 2773eacef..963a8bc52 100644 --- a/packages/consent/consent-wrapper-onetrust/tsconfig.json +++ b/packages/consent/consent-wrapper-onetrust/tsconfig.json @@ -3,7 +3,7 @@ "exclude": ["node_modules", "dist"], "compilerOptions": { "module": "ESNext", // es6 modules - "target": "ESNext", // assume that consumers will be using webpack, so don't down-compile + "target": "ES2020", // don't down-compile *too much* -- if users are using webpack, they can always transpile this library themselves "lib": ["ES2020", "DOM", "DOM.Iterable"], // assume that consumers will be polyfilling at least down to es2020 "moduleResolution": "node", "isolatedModules": true // ensure we are friendly to build systems diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 6d8a196ec..cf8bc0e3a 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,44 @@ # @segment/analytics-core +## 1.5.0 + +### Minor Changes + +- [#945](https://github.com/segmentio/analytics-next/pull/945) [`d212633`](https://github.com/segmentio/analytics-next/commit/d21263369d5980f4f57b13795524dbc345a02e5c) Thanks [@zikaari](https://github.com/zikaari)! - Load destinations lazily and start sending events as each becomes available instead of waiting for all to load first + +### Patch Changes + +- [#1043](https://github.com/segmentio/analytics-next/pull/1043) [`95fd2fd`](https://github.com/segmentio/analytics-next/commit/95fd2fd801da26505ddcead96ffaa83aa4364994) Thanks [@silesky](https://github.com/silesky)! - This ensures backward compatibility with analytics-node by modifying '@segment/analytics-core'. Specifically, the changes prevent the generation of a messageId if it is already set. This adjustment aligns with the behavior outlined in analytics-node's source code [here](https://github.com/segmentio/analytics-node/blob/master/index.js#L195-L201). + + While this is a core release, only the node library is affected, as the browser has its own EventFactory atm. + +- Updated dependencies [[`d212633`](https://github.com/segmentio/analytics-next/commit/d21263369d5980f4f57b13795524dbc345a02e5c)]: + - @segment/analytics-generic-utils@1.2.0 + +## 1.4.1 + +### Patch Changes + +- Updated dependencies [[`7b93e7b`](https://github.com/segmentio/analytics-next/commit/7b93e7b50fa293aebaf6767a44bf7708b231d5cd)]: + - @segment/analytics-generic-utils@1.1.1 + +## 1.4.0 + +### Minor Changes + +- [#993](https://github.com/segmentio/analytics-next/pull/993) [`d9b47c4`](https://github.com/segmentio/analytics-next/commit/d9b47c43e5e08efce14fe4150536ff60b8df91e0) Thanks [@silesky](https://github.com/silesky)! - Consume Emitter module from `@segment/analytics-generic-utils` + +### Patch Changes + +- Updated dependencies [[`d9b47c4`](https://github.com/segmentio/analytics-next/commit/d9b47c43e5e08efce14fe4150536ff60b8df91e0)]: + - @segment/analytics-generic-utils@1.1.0 + +## 1.3.2 + +### Patch Changes + +- [#852](https://github.com/segmentio/analytics-next/pull/852) [`897f4cc`](https://github.com/segmentio/analytics-next/commit/897f4cc69de4cdd38efd0cd70567bfed0c454fec) Thanks [@silesky](https://github.com/silesky)! - Tighten isPlainObject type guard + ## 1.3.1 ### Patch Changes diff --git a/packages/core/README.md b/packages/core/README.md index 96ece9350..73c7ec681 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -1,3 +1,3 @@ -# Analytics Core +# @segment/analytics-core -This package represents core 'shared' functionality that is shared by analytics packages. This is not designed to be used directly, but internal to analytics-node and analytics-browser. \ No newline at end of file +This package represents core 'shared' functionality that is shared by analytics packages. This is not designed to be used directly, but internal to analytics-node and analytics-browser. diff --git a/packages/core/jest.config.js b/packages/core/jest.config.js index a32ed4700..68ccd2e05 100644 --- a/packages/core/jest.config.js +++ b/packages/core/jest.config.js @@ -1,5 +1,3 @@ const { createJestTSConfig } = require('@internal/config') -module.exports = createJestTSConfig(__dirname, { - projects: ['', '/../core-integration-tests'], -}) +module.exports = createJestTSConfig(__dirname) diff --git a/packages/core/package.json b/packages/core/package.json index e83359ca7..01c908d68 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@segment/analytics-core", - "version": "1.3.1", + "version": "1.5.0", "repository": { "type": "git", "url": "https://github.com/segmentio/analytics-next", @@ -34,6 +34,7 @@ "packageManager": "yarn@3.4.1", "dependencies": { "@lukeed/uuid": "^2.0.0", + "@segment/analytics-generic-utils": "1.2.0", "dset": "^3.1.2", "tslib": "^2.4.1" } diff --git a/packages/core/src/analytics/__tests__/dispatch.test.ts b/packages/core/src/analytics/__tests__/dispatch.test.ts index bcef422d2..829facfb4 100644 --- a/packages/core/src/analytics/__tests__/dispatch.test.ts +++ b/packages/core/src/analytics/__tests__/dispatch.test.ts @@ -16,7 +16,7 @@ jest.mock('../../callback', () => ({ })) import { CoreEventQueue } from '../../queue/event-queue' -import { Emitter } from '../../emitter' +import { Emitter } from '@segment/analytics-generic-utils' import { dispatch, getDelay } from '../dispatch' import { CoreContext } from '../../context' import { TestCtx, TestEventQueue } from '../../../test-helpers' diff --git a/packages/core/src/analytics/dispatch.ts b/packages/core/src/analytics/dispatch.ts index 182af351f..ac17bfab3 100644 --- a/packages/core/src/analytics/dispatch.ts +++ b/packages/core/src/analytics/dispatch.ts @@ -2,7 +2,7 @@ import { CoreContext } from '../context' import { Callback } from '../events/interfaces' import { CoreEventQueue } from '../queue/event-queue' import { invokeCallback } from '../callback' -import { Emitter } from '../emitter' +import { Emitter } from '@segment/analytics-generic-utils' export type DispatchOptions = { timeout?: number diff --git a/packages/core/src/callback/__tests__/index.test.ts b/packages/core/src/callback/__tests__/index.test.ts index 4bdf2a7f7..4547461b9 100644 --- a/packages/core/src/callback/__tests__/index.test.ts +++ b/packages/core/src/callback/__tests__/index.test.ts @@ -54,7 +54,7 @@ describe(invokeCallback, () => { const logs = returned.logs() expect(logs[0].extras).toMatchInlineSnapshot(` - Object { + { "error": [Error: Promise timed out], } `) @@ -76,7 +76,7 @@ describe(invokeCallback, () => { const logs = returned.logs() expect(logs[0].extras).toMatchInlineSnapshot(` - Object { + { "error": [Error: πŸ‘» boo!], } `) diff --git a/packages/core/src/events/__tests__/index.test.ts b/packages/core/src/events/__tests__/index.test.ts index eba8961b7..27c482a5c 100644 --- a/packages/core/src/events/__tests__/index.test.ts +++ b/packages/core/src/events/__tests__/index.test.ts @@ -350,6 +350,27 @@ describe('Event Factory', () => { innerProp: 'πŸ‘»', }) }) + + test('accepts a messageId', () => { + const messageId = 'business-id-123' + const track = factory.track('Order Completed', shoes, { + messageId, + }) + + expect(track.context).toEqual({}) + expect(track.messageId).toEqual(messageId) + }) + + it('should ignore undefined options', () => { + const event = factory.track( + 'Order Completed', + { ...shoes }, + { timestamp: undefined, traits: { foo: 123 } } + ) + + expect(typeof event.timestamp).toBe('object') + expect(isDate(event.timestamp)).toBeTruthy() + }) }) describe('normalize', () => { @@ -380,15 +401,4 @@ describe('Event Factory', () => { }) }) }) - - it('should ignore undefined options', () => { - const event = factory.track( - 'Order Completed', - { ...shoes }, - { timestamp: undefined, traits: { foo: 123 } } - ) - - expect(typeof event.timestamp).toBe('object') - expect(isDate(event.timestamp)).toBeTruthy() - }) }) diff --git a/packages/core/src/events/index.ts b/packages/core/src/events/index.ts index 366d942a9..2c7d80ee0 100644 --- a/packages/core/src/events/index.ts +++ b/packages/core/src/events/index.ts @@ -19,6 +19,10 @@ interface EventFactorySettings { user?: User } +/** + * This is currently only used by node.js, but the original idea was to have something that could be shared between browser and node. + * Unfortunately, there are some differences in the way the two environments handle events, so this is not currently shared. + */ export class EventFactory { createMessageId: EventFactorySettings['createMessageId'] user?: User @@ -201,6 +205,7 @@ export class EventFactory { 'userId', 'anonymousId', 'timestamp', + 'messageId', ] delete options['integrations'] @@ -271,7 +276,7 @@ export class EventFactory { const evt: CoreSegmentEvent = { ...body, - messageId: this.createMessageId(), + messageId: options.messageId || this.createMessageId(), } validateEvent(evt) diff --git a/packages/core/src/events/interfaces.ts b/packages/core/src/events/interfaces.ts index 0bc59bf5c..0db091874 100644 --- a/packages/core/src/events/interfaces.ts +++ b/packages/core/src/events/interfaces.ts @@ -33,6 +33,12 @@ export interface CoreOptions { anonymousId?: string userId?: string traits?: Traits + /** + * Override the messageId. Under normal circumstances, this is not recommended -- but neccessary for deduping events. + * + * **Currently, This option only works in `@segment/analytics-node`.** + */ + messageId?: string // ugh, this is ugly, but we allow literally any property to be passed to options (which get spread onto the event) [key: string]: any } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 64195c34c..3193a5629 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,4 +1,3 @@ -export * from './emitter' export * from './emitter/interface' export * from './plugins' export * from './events/interfaces' diff --git a/packages/core/src/priority-queue/index.ts b/packages/core/src/priority-queue/index.ts index 49932ea11..3be5eccf2 100644 --- a/packages/core/src/priority-queue/index.ts +++ b/packages/core/src/priority-queue/index.ts @@ -1,4 +1,4 @@ -import { Emitter } from '../emitter' +import { Emitter } from '@segment/analytics-generic-utils' import { backoff } from './backoff' /** diff --git a/packages/core/src/queue/__tests__/extension-flushing.test.ts b/packages/core/src/queue/__tests__/extension-flushing.test.ts index d94de8118..e8eb11965 100644 --- a/packages/core/src/queue/__tests__/extension-flushing.test.ts +++ b/packages/core/src/queue/__tests__/extension-flushing.test.ts @@ -192,32 +192,32 @@ describe('Plugin flushing', () => { .map((l) => ({ message: l.message, extras: l.extras })) expect(messages).toMatchInlineSnapshot(` - Array [ - Object { + [ + { "extras": undefined, "message": "Dispatching", }, - Object { - "extras": Object { + { + "extras": { "plugin": "Amplitude", }, "message": "plugin", }, - Object { - "extras": Object { + { + "extras": { "plugin": "FullStory", }, "message": "plugin", }, - Object { - "extras": Object { + { + "extras": { "error": [Error: Boom!], "plugin": "Amplitude", }, "message": "plugin Error", }, - Object { - "extras": Object { + { + "extras": { "type": "track", }, "message": "Delivered", @@ -257,32 +257,32 @@ describe('Plugin flushing', () => { .logs() .map((l) => ({ message: l.message, extras: l.extras })) expect(messages).toMatchInlineSnapshot(` - Array [ - Object { + [ + { "extras": undefined, "message": "Dispatching", }, - Object { - "extras": Object { + { + "extras": { "plugin": "after-failed", }, "message": "plugin", }, - Object { - "extras": Object { + { + "extras": { "plugin": "after", }, "message": "plugin", }, - Object { - "extras": Object { + { + "extras": { "error": [Error: Boom!], "plugin": "after-failed", }, "message": "plugin Error", }, - Object { - "extras": Object { + { + "extras": { "type": "track", }, "message": "Delivered", diff --git a/packages/core/src/queue/event-queue.ts b/packages/core/src/queue/event-queue.ts index 95e43ba08..4ca14d60a 100644 --- a/packages/core/src/queue/event-queue.ts +++ b/packages/core/src/queue/event-queue.ts @@ -3,7 +3,7 @@ import { groupBy } from '../utils/group-by' import { ON_REMOVE_FROM_FUTURE, PriorityQueue } from '../priority-queue' import { CoreContext, ContextCancelation } from '../context' -import { Emitter } from '../emitter' +import { Emitter } from '@segment/analytics-generic-utils' import { Integrations, JSONObject } from '../events/interfaces' import { CorePlugin } from '../plugins' import { createTaskGroup, TaskGroup } from '../task/task-group' @@ -16,6 +16,7 @@ export type EventQueueEmitterContract = { delivery_retry: [ctx: Ctx] delivery_failure: [ctx: Ctx, err: Ctx | Error | ContextCancelation] flush: [ctx: Ctx, delivered: boolean] + initialization_failure: [CorePlugin] } export abstract class CoreEventQueue< @@ -49,25 +50,24 @@ export abstract class CoreEventQueue< plugin: Plugin, instance: CoreAnalytics ): Promise { - await Promise.resolve(plugin.load(ctx, instance)) - .then(() => { - this.plugins.push(plugin) + if (plugin.type === 'destination' && plugin.name !== 'Segment.io') { + plugin.load(ctx, instance).catch((err) => { + this.failedInitializations.push(plugin.name) + this.emit('initialization_failure', plugin) + console.warn(plugin.name, err) + + ctx.log('warn', 'Failed to load destination', { + plugin: plugin.name, + error: err, + }) + + this.plugins = this.plugins.filter((p) => p === plugin) }) - .catch((err) => { - if (plugin.type === 'destination') { - this.failedInitializations.push(plugin.name) - console.warn(plugin.name, err) - - ctx.log('warn', 'Failed to load destination', { - plugin: plugin.name, - error: err, - }) - - return - } + } else { + await plugin.load(ctx, instance) + } - throw err - }) + this.plugins.push(plugin) } async deregister( diff --git a/packages/core/src/validation/__tests__/assertions.test.ts b/packages/core/src/validation/__tests__/assertions.test.ts index c6f9b81c3..df4735c14 100644 --- a/packages/core/src/validation/__tests__/assertions.test.ts +++ b/packages/core/src/validation/__tests__/assertions.test.ts @@ -4,7 +4,9 @@ import { validateEvent } from '../assertions' const baseEvent: Partial = { userId: 'foo', event: 'Test Event', + messageId: 'foo', } + describe(validateEvent, () => { test('should be capable of working with empty properties and traits', () => { expect(() => validateEvent(undefined)).toThrowError() @@ -40,6 +42,7 @@ describe(validateEvent, () => { test('identify: traits should be an object', () => { expect(() => validateEvent({ + ...baseEvent, type: 'identify', traits: undefined, }) @@ -151,5 +154,29 @@ describe(validateEvent, () => { }) ).toThrowError(/nil/i) }) + + test('should fail if messageId is _not_ string', () => { + expect(() => + validateEvent({ + ...baseEvent, + type: 'track', + properties: {}, + userId: undefined, + anonymousId: 'foo', + messageId: 'bar', + }) + ).not.toThrow() + + expect(() => + validateEvent({ + ...baseEvent, + type: 'track', + properties: {}, + userId: undefined, + anonymousId: 'foo', + messageId: 123 as any, + }) + ).toThrow(/messageId/) + }) }) }) diff --git a/packages/core/src/validation/assertions.ts b/packages/core/src/validation/assertions.ts index 8717f4800..e563a6a0e 100644 --- a/packages/core/src/validation/assertions.ts +++ b/packages/core/src/validation/assertions.ts @@ -55,9 +55,16 @@ export function assertTraits(event: CoreSegmentEvent): void { } } +export function assertMessageId(event: CoreSegmentEvent): void { + if (!isString(event.messageId)) { + throw new ValidationError('.messageId', stringError) + } +} + export function validateEvent(event?: CoreSegmentEvent | null) { assertEventExists(event) assertEventType(event) + assertMessageId(event) if (event.type === 'track') { assertTrackEventName(event) diff --git a/packages/core/src/validation/helpers.ts b/packages/core/src/validation/helpers.ts index b1d6de641..511b03a64 100644 --- a/packages/core/src/validation/helpers.ts +++ b/packages/core/src/validation/helpers.ts @@ -16,7 +16,7 @@ export function exists(val: unknown): val is NonNullable { export function isPlainObject( obj: unknown -): obj is Record { +): obj is Record { return ( Object.prototype.toString.call(obj).slice(8, -1).toLowerCase() === 'object' ) diff --git a/packages/generic-utils/.eslintrc.js b/packages/generic-utils/.eslintrc.js new file mode 100644 index 000000000..fb97c4cf5 --- /dev/null +++ b/packages/generic-utils/.eslintrc.js @@ -0,0 +1,4 @@ +/** @type { import('eslint').Linter.Config } */ +module.exports = { + extends: ['../../.eslintrc.isomorphic'], +} diff --git a/packages/generic-utils/.lintstagedrc.js b/packages/generic-utils/.lintstagedrc.js new file mode 100644 index 000000000..bc1f1c780 --- /dev/null +++ b/packages/generic-utils/.lintstagedrc.js @@ -0,0 +1 @@ +module.exports = require("@internal/config").lintStagedConfig diff --git a/packages/generic-utils/CHANGELOG.md b/packages/generic-utils/CHANGELOG.md new file mode 100644 index 000000000..34a3463b1 --- /dev/null +++ b/packages/generic-utils/CHANGELOG.md @@ -0,0 +1,25 @@ +# @segment/analytics-generic-utils + +## 1.2.0 + +### Minor Changes + +- [#945](https://github.com/segmentio/analytics-next/pull/945) [`d212633`](https://github.com/segmentio/analytics-next/commit/d21263369d5980f4f57b13795524dbc345a02e5c) Thanks [@zikaari](https://github.com/zikaari)! - Load destinations lazily and start sending events as each becomes available instead of waiting for all to load first + +## 1.1.1 + +### Patch Changes + +- [#1020](https://github.com/segmentio/analytics-next/pull/1020) [`7b93e7b`](https://github.com/segmentio/analytics-next/commit/7b93e7b50fa293aebaf6767a44bf7708b231d5cd) Thanks [@silesky](https://github.com/silesky)! - Add tslib to resolve unsound dependency warning. + +## 1.1.0 + +### Minor Changes + +- [#993](https://github.com/segmentio/analytics-next/pull/993) [`d9b47c4`](https://github.com/segmentio/analytics-next/commit/d9b47c43e5e08efce14fe4150536ff60b8df91e0) Thanks [@silesky](https://github.com/silesky)! - Add Emitter library. Log default warning if a listeners exceeds 10 for a specific event type (configurable) + +## 1.0.0 + +### Major Changes + +- [#974](https://github.com/segmentio/analytics-next/pull/974) [`c879377`](https://github.com/segmentio/analytics-next/commit/c87937720941ad830c5fdd76b0c049435a6ddec6) Thanks [@silesky](https://github.com/silesky)! - Refactor to get createDeferred from @segment/analytics-generic-utils lib diff --git a/packages/generic-utils/LICENSE b/packages/generic-utils/LICENSE new file mode 100644 index 000000000..a0378adfd --- /dev/null +++ b/packages/generic-utils/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright Β© 2023 Segment + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/packages/generic-utils/README.md b/packages/generic-utils/README.md new file mode 100644 index 000000000..bd31b923d --- /dev/null +++ b/packages/generic-utils/README.md @@ -0,0 +1,3 @@ +# @segment/analytics-generic-utils + +This monorepo's version of "lodash". This package contains shared generic utilities that can be used within the ecosystem. This package should not have dependencies, and should not contain any references to the Analytics domain. diff --git a/packages/generic-utils/jest.config.js b/packages/generic-utils/jest.config.js new file mode 100644 index 000000000..68ccd2e05 --- /dev/null +++ b/packages/generic-utils/jest.config.js @@ -0,0 +1,3 @@ +const { createJestTSConfig } = require('@internal/config') + +module.exports = createJestTSConfig(__dirname) diff --git a/packages/generic-utils/package.json b/packages/generic-utils/package.json new file mode 100644 index 000000000..2223a24eb --- /dev/null +++ b/packages/generic-utils/package.json @@ -0,0 +1,39 @@ +{ + "name": "@segment/analytics-generic-utils", + "version": "1.2.0", + "repository": { + "type": "git", + "url": "https://github.com/segmentio/analytics-next", + "directory": "packages/generic-utils" + }, + "license": "MIT", + "main": "./dist/cjs/index.js", + "module": "./dist/esm/index.js", + "types": "./dist/types/index.d.ts", + "files": [ + "dist/", + "src/", + "!**/__tests__/**", + "!*.tsbuildinfo", + "LICENSE" + ], + "sideEffects": false, + "scripts": { + ".": "yarn run -T turbo run --filter=@segment/analytics-generic-utils", + "test": "yarn jest", + "lint": "yarn concurrently 'yarn:eslint .' 'yarn:tsc --noEmit'", + "build": "yarn concurrently 'yarn:build:*'", + "build:esm": "yarn tsc -p tsconfig.build.json", + "build:cjs": "yarn tsc -p tsconfig.build.json --outDir ./dist/cjs --module commonjs", + "watch": "yarn build:esm --watch", + "watch:test": "yarn test --watch", + "tsc": "yarn run -T tsc", + "eslint": "yarn run -T eslint", + "concurrently": "yarn run -T concurrently", + "jest": "yarn run -T jest" + }, + "packageManager": "yarn@3.4.1", + "dependencies": { + "tslib": "^2.4.1" + } +} diff --git a/packages/generic-utils/src/create-deferred/__tests__/create-deferred.test.ts b/packages/generic-utils/src/create-deferred/__tests__/create-deferred.test.ts new file mode 100644 index 000000000..f2b2cc80f --- /dev/null +++ b/packages/generic-utils/src/create-deferred/__tests__/create-deferred.test.ts @@ -0,0 +1,54 @@ +import { createDeferred } from '../create-deferred' + +describe(createDeferred, () => { + it('should return a deferred object', async () => { + const o = createDeferred() + expect(o.promise).toBeInstanceOf(Promise) + expect(o.resolve).toBeInstanceOf(Function) + expect(o.reject).toBeInstanceOf(Function) + }) + it('should resolve', async () => { + const { promise, resolve } = createDeferred() + let isResolved = false + let isResolvedVal = undefined + void promise.then((value) => { + isResolvedVal = value + isResolved = true + }) + expect(isResolved).toBe(false) + expect(isResolvedVal).toBeUndefined() + await resolve('foo') + expect(isResolved).toBe(true) + expect(isResolvedVal).toBe('foo') + }) + + it('should reject', async () => { + const { promise, reject } = createDeferred() + let isRejected = false + let isRejectedVal = undefined + void promise.catch((value) => { + isRejectedVal = value + isRejected = true + }) + expect(isRejected).toBe(false) + expect(isRejectedVal).toBeUndefined() + await reject('foo') + expect(isRejected).toBe(true) + expect(isRejectedVal).toBe('foo') + }) + + test('isSettled return true on either reject or resolve', async () => { + const deferred1 = createDeferred() + const deferred2 = createDeferred() + + expect(deferred1.isSettled()).toBe(false) + expect(deferred2.isSettled()).toBe(false) + + deferred1.resolve('foo') + expect(deferred1.isSettled()).toBe(true) + + deferred2.promise.catch(() => {}) + deferred2.reject('foo') + expect(deferred2.isSettled()).toBe(true) + }) +}) diff --git a/packages/browser/src/lib/create-deferred.ts b/packages/generic-utils/src/create-deferred/create-deferred.ts similarity index 59% rename from packages/browser/src/lib/create-deferred.ts rename to packages/generic-utils/src/create-deferred/create-deferred.ts index 66c3b5d7a..88b92ea71 100644 --- a/packages/browser/src/lib/create-deferred.ts +++ b/packages/generic-utils/src/create-deferred/create-deferred.ts @@ -4,13 +4,22 @@ export const createDeferred = () => { let resolve!: (value: T | PromiseLike) => void let reject!: (reason: any) => void + let settled = false const promise = new Promise((_resolve, _reject) => { - resolve = _resolve - reject = _reject + resolve = (...args) => { + settled = true + _resolve(...args) + } + reject = (...args) => { + settled = true + _reject(...args) + } }) + return { resolve, reject, promise, + isSettled: () => settled, } } diff --git a/packages/generic-utils/src/create-deferred/index.ts b/packages/generic-utils/src/create-deferred/index.ts new file mode 100644 index 000000000..0a6560caf --- /dev/null +++ b/packages/generic-utils/src/create-deferred/index.ts @@ -0,0 +1 @@ +export * from './create-deferred' diff --git a/packages/core/src/emitter/__tests__/emitter.test.ts b/packages/generic-utils/src/emitter/__tests__/emitter.test.ts similarity index 60% rename from packages/core/src/emitter/__tests__/emitter.test.ts rename to packages/generic-utils/src/emitter/__tests__/emitter.test.ts index b73855792..7edcb283c 100644 --- a/packages/core/src/emitter/__tests__/emitter.test.ts +++ b/packages/generic-utils/src/emitter/__tests__/emitter.test.ts @@ -1,4 +1,4 @@ -import { Emitter } from '../' +import { Emitter } from '../emitter' describe(Emitter, () => { it('emits events', () => { @@ -71,4 +71,36 @@ describe(Emitter, () => { expect(fn).toHaveBeenCalledTimes(2) }) + + it('has a default max listeners of 10', () => { + const em = new Emitter() + expect(em.maxListeners).toBe(10) + }) + + it('should warn if possible memory leak', () => { + const fn = jest.fn() + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}) + const em = new Emitter({ maxListeners: 3 }) + em.on('test', fn) + em.on('test', fn) + em.on('test', fn) + expect(warnSpy).not.toHaveBeenCalled() + // call on 4th + em.on('test', fn) + expect(warnSpy).toHaveBeenCalledTimes(1) + // do not call additional times + em.on('test', fn) + expect(warnSpy).toHaveBeenCalledTimes(1) + }) + + it('has no warning if listener limit is set to 0', () => { + const fn = jest.fn() + const warnSpy = jest.spyOn(console, 'warn') + const em = new Emitter({ maxListeners: 0 }) + expect(em.maxListeners).toBe(0) + for (let i = 0; i++; i < 20) { + em.on('test', fn) + } + expect(warnSpy).not.toHaveBeenCalled() + }) }) diff --git a/packages/core/src/emitter/index.ts b/packages/generic-utils/src/emitter/emitter.ts similarity index 68% rename from packages/core/src/emitter/index.ts rename to packages/generic-utils/src/emitter/emitter.ts index d681b20f4..f5b331be9 100644 --- a/packages/core/src/emitter/index.ts +++ b/packages/generic-utils/src/emitter/emitter.ts @@ -2,6 +2,13 @@ type EventName = string type EventFnArgs = any[] type EmitterContract = Record +export interface EmitterOptions { + /** How many event listeners for a particular event before emitting a warning (0 = disabled) + * @default 10 + **/ + maxListeners?: number +} + /** * Event Emitter that takes the expected contract as a generic * @example @@ -16,7 +23,32 @@ type EmitterContract = Record * ``` */ export class Emitter { + maxListeners: number + constructor(options?: EmitterOptions) { + this.maxListeners = options?.maxListeners ?? 10 + } private callbacks: Partial = {} + private warned = false + + private warnIfPossibleMemoryLeak( + event: EventName + ) { + if (this.warned) { + return + } + if ( + this.maxListeners && + this.callbacks[event]!.length > this.maxListeners + ) { + console.warn( + `Event Emitter: Possible memory leak detected; ${String( + event + )} has exceeded ${this.maxListeners} listeners.` + ) + this.warned = true + } + } + on( event: EventName, callback: (...args: Contract[EventName]) => void @@ -25,6 +57,7 @@ export class Emitter { this.callbacks[event] = [callback] as Contract[EventName] } else { this.callbacks[event]!.push(callback) + this.warnIfPossibleMemoryLeak(event) } return this } diff --git a/packages/generic-utils/src/emitter/index.ts b/packages/generic-utils/src/emitter/index.ts new file mode 100644 index 000000000..9b03d6cc3 --- /dev/null +++ b/packages/generic-utils/src/emitter/index.ts @@ -0,0 +1 @@ +export * from './emitter' diff --git a/packages/generic-utils/src/index.ts b/packages/generic-utils/src/index.ts new file mode 100644 index 000000000..8e99205a4 --- /dev/null +++ b/packages/generic-utils/src/index.ts @@ -0,0 +1,2 @@ +export * from './create-deferred' +export * from './emitter' diff --git a/packages/generic-utils/tsconfig.build.json b/packages/generic-utils/tsconfig.build.json new file mode 100644 index 000000000..830170446 --- /dev/null +++ b/packages/generic-utils/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "include": ["src"], + "exclude": ["**/__tests__/**", "**/*.test.*"], + "compilerOptions": { + "outDir": "./dist/esm", + "declarationDir": "./dist/types" + } +} diff --git a/packages/generic-utils/tsconfig.json b/packages/generic-utils/tsconfig.json new file mode 100644 index 000000000..4e403a914 --- /dev/null +++ b/packages/generic-utils/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "exclude": ["node_modules", "dist"], + "compilerOptions": { + "module": "esnext", + "target": "ES5", + "moduleResolution": "node", + "resolveJsonModule": true, + "lib": ["es2020"] + } +} diff --git a/packages/node-integration-tests/README.md b/packages/node-integration-tests/README.md index 9e6114f77..f5f5b7934 100644 --- a/packages/node-integration-tests/README.md +++ b/packages/node-integration-tests/README.md @@ -1,15 +1,15 @@ # Integration Tests for @segment/analytics-node ## Tests -| Test Path | Description | -| ---- | ----------- | -| [./src/durability-tests](src/durability-tests/) | Test that all the events created by the Analytics SDK end up as HTTP Requests, and that graceful shutdown does not result in events getting lost. | -| [./src/perf-tests](src/perf-tests/) | These tests confirm that performance has not regresssed relative to the old SDK or from the baseline (which a handler _without_ analytics). + +| Test Path | Description | +| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| [./src/durability-tests](src/durability-tests/) | Test that all the events created by the Analytics SDK end up as HTTP Requests, and that graceful shutdown does not result in events getting lost. | +| [./src/perf-tests](src/perf-tests/) | These tests confirm that performance has not regresssed relative to the old SDK or from the baseline (which a handler _without_ analytics). | +| [./src/cloudflare-tests/](src/cloudflare-tests/) | These tests confirm that the SDK runs as expected in cloudflare workers. | Build deps and run tests: + ```sh yarn turbo run --filter=node-integration-tests test # from repo root ``` - - - diff --git a/packages/node-integration-tests/jest.config.js b/packages/node-integration-tests/jest.config.js new file mode 100644 index 000000000..68ccd2e05 --- /dev/null +++ b/packages/node-integration-tests/jest.config.js @@ -0,0 +1,3 @@ +const { createJestTSConfig } = require('@internal/config') + +module.exports = createJestTSConfig(__dirname) diff --git a/packages/node-integration-tests/package.json b/packages/node-integration-tests/package.json index 9c2af2c60..3ed6a1f36 100644 --- a/packages/node-integration-tests/package.json +++ b/packages/node-integration-tests/package.json @@ -3,6 +3,7 @@ "version": "0.0.0", "private": true, "scripts": { + ".": "yarn run -T turbo run --filter=@segment/analytics-node...", "tsc": "yarn run -T tsc", "eslint": "yarn run -T eslint", "lint": "yarn concurrently 'yarn:eslint .' 'yarn:tsc --noEmit'", @@ -10,16 +11,20 @@ "durability": "yarn ts-node src/durability-tests/durability-tests.ts", "concurrently": "yarn run -T concurrently", "ts-node": "yarn run -T ts-node", - "test": "yarn perf && yarn durability" + "test:perf-and-durability": "yarn perf && yarn durability", + "test:cloudflare-workers": "yarn run -T jest src/cloudflare-tests" }, "devDependencies": { + "@cloudflare/workers-types": "^4.20231002.0", + "@internal/config": "workspace:^", "@internal/test-helpers": "workspace:^", "@segment/analytics-node": "workspace:^", "@types/analytics-node": "^3.1.9", "@types/autocannon": "^7", "@types/node": "^16", "analytics-node": "^6.2.0", - "autocannon": "^7.10.0" + "autocannon": "^7.10.0", + "wrangler": "^3.11.0" }, "packageManager": "yarn@3.4.1" } diff --git a/packages/node-integration-tests/src/cloudflare-tests/index.test.ts b/packages/node-integration-tests/src/cloudflare-tests/index.test.ts new file mode 100644 index 000000000..e49efe9c5 --- /dev/null +++ b/packages/node-integration-tests/src/cloudflare-tests/index.test.ts @@ -0,0 +1,500 @@ +import { join as joinPath } from 'path' +import { unstable_dev } from 'wrangler' +import { MockSegmentServer } from '../server/mock-segment-workers' + +const isoDateRegEx = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/ + +const snapshotMatchers = { + get batchEvent() { + return { + messageId: expect.any(String), + context: { + library: { + name: '@segment/analytics-node', + version: expect.any(String), + }, + }, + _metadata: { + jsRuntime: 'cloudflare-worker', + }, + timestamp: expect.stringMatching(isoDateRegEx), + } + }, + getReqBody(eventCount = 1) { + const batch = [] + for (let i = 0; i < eventCount; i++) { + batch.push(snapshotMatchers.batchEvent) + } + return { + batch, + sentAt: expect.stringMatching(isoDateRegEx), + } + }, +} + +describe('Analytics in Cloudflare workers', () => { + let mockSegmentServer: MockSegmentServer + beforeEach(async () => { + mockSegmentServer = new MockSegmentServer(3000) + await mockSegmentServer.start() + }) + + afterEach(async () => { + await mockSegmentServer.stop() + }) + + it('can send a single event', async () => { + const batches: any[] = [] + mockSegmentServer.on('batch', (batch) => { + batches.push(batch) + }) + + const worker = await unstable_dev( + joinPath(__dirname, 'workers', 'send-single-event.ts'), + { + experimental: { + disableExperimentalWarning: true, + }, + bundle: true, + } + ) + const response = await worker.fetch('http://localhost') + await response.text() + await worker.stop() + + expect(batches).toHaveLength(1) + const events = batches[0].batch + expect(events).toHaveLength(1) + expect(batches).toMatchInlineSnapshot( + batches.map(() => snapshotMatchers.getReqBody(1)), + ` + [ + { + "batch": [ + { + "_metadata": { + "jsRuntime": "cloudflare-worker", + }, + "context": { + "library": { + "name": "@segment/analytics-node", + "version": Any, + }, + }, + "event": "some-event", + "integrations": {}, + "messageId": Any, + "properties": {}, + "timestamp": StringMatching /\\^\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.\\\\d\\{3\\}Z\\$/, + "type": "track", + "userId": "some-user", + }, + ], + "sentAt": StringMatching /\\^\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.\\\\d\\{3\\}Z\\$/, + "writeKey": "__TEST__", + }, + ] + ` + ) + }) + + it('can send an oauth event', async () => { + const batches: any[] = [] + const oauths: any[] = [] + mockSegmentServer.on('batch', (batch) => { + batches.push(batch) + }) + + mockSegmentServer.on('token', (oauth) => { + oauths.push(oauth) + }) + + const worker = await unstable_dev( + joinPath(__dirname, 'workers', 'send-oauth-event.ts'), + { + experimental: { + disableExperimentalWarning: true, + }, + bundle: true, + } + ) + const response = await worker.fetch('http://localhost') + await response.text() + await worker.stop() + + console.log(batches) + + expect(oauths[0]).toHaveLength(756) + + expect(batches).toMatchInlineSnapshot( + batches.map(() => snapshotMatchers.getReqBody(1)), + ` + [ + { + "batch": [ + { + "_metadata": { + "jsRuntime": "cloudflare-worker", + }, + "context": { + "library": { + "name": "@segment/analytics-node", + "version": Any, + }, + }, + "event": "some-event", + "integrations": {}, + "messageId": Any, + "properties": {}, + "timestamp": StringMatching /\\^\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.\\\\d\\{3\\}Z\\$/, + "type": "track", + "userId": "some-user", + }, + ], + "sentAt": StringMatching /\\^\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.\\\\d\\{3\\}Z\\$/, + "writeKey": "__TEST__", + }, + ] + ` + ) + }) + + it('can send each event type in a batch', async () => { + const batches: any[] = [] + mockSegmentServer.on('batch', (batch) => { + batches.push(batch) + }) + + const worker = await unstable_dev( + joinPath(__dirname, 'workers', 'send-each-event-type.ts'), + { + experimental: { + disableExperimentalWarning: true, + }, + bundle: true, + } + ) + const response = await worker.fetch('http://localhost') + await response.text() + await worker.stop() + + expect(batches).toHaveLength(1) + const events = batches[0].batch + expect(events).toHaveLength(6) + expect(batches).toMatchInlineSnapshot( + batches.map(() => snapshotMatchers.getReqBody(6)), + ` + [ + { + "batch": [ + { + "_metadata": { + "jsRuntime": "cloudflare-worker", + }, + "context": { + "library": { + "name": "@segment/analytics-node", + "version": Any, + }, + }, + "integrations": {}, + "messageId": Any, + "previousId": "other-user", + "timestamp": StringMatching /\\^\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.\\\\d\\{3\\}Z\\$/, + "type": "alias", + "userId": "some-user", + }, + { + "_metadata": { + "jsRuntime": "cloudflare-worker", + }, + "context": { + "library": { + "name": "@segment/analytics-node", + "version": Any, + }, + }, + "groupId": "some-group", + "integrations": {}, + "messageId": Any, + "timestamp": StringMatching /\\^\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.\\\\d\\{3\\}Z\\$/, + "traits": {}, + "type": "group", + "userId": "some-user", + }, + { + "_metadata": { + "jsRuntime": "cloudflare-worker", + }, + "context": { + "library": { + "name": "@segment/analytics-node", + "version": Any, + }, + }, + "integrations": {}, + "messageId": Any, + "timestamp": StringMatching /\\^\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.\\\\d\\{3\\}Z\\$/, + "traits": { + "favoriteColor": "Seattle Grey", + }, + "type": "identify", + "userId": "some-user", + }, + { + "_metadata": { + "jsRuntime": "cloudflare-worker", + }, + "context": { + "library": { + "name": "@segment/analytics-node", + "version": Any, + }, + }, + "integrations": {}, + "messageId": Any, + "name": "Test Page", + "properties": {}, + "timestamp": StringMatching /\\^\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.\\\\d\\{3\\}Z\\$/, + "type": "page", + "userId": "some-user", + }, + { + "_metadata": { + "jsRuntime": "cloudflare-worker", + }, + "context": { + "library": { + "name": "@segment/analytics-node", + "version": Any, + }, + }, + "event": "some-event", + "integrations": {}, + "messageId": Any, + "properties": {}, + "timestamp": StringMatching /\\^\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.\\\\d\\{3\\}Z\\$/, + "type": "track", + "userId": "some-user", + }, + { + "_metadata": { + "jsRuntime": "cloudflare-worker", + }, + "context": { + "library": { + "name": "@segment/analytics-node", + "version": Any, + }, + }, + "integrations": {}, + "messageId": Any, + "name": "Test Screen", + "properties": {}, + "timestamp": StringMatching /\\^\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.\\\\d\\{3\\}Z\\$/, + "type": "screen", + "userId": "some-user", + }, + ], + "sentAt": StringMatching /\\^\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.\\\\d\\{3\\}Z\\$/, + "writeKey": "__TEST__", + }, + ] + ` + ) + }) + + it('can send multiple events in a multiple batches', async () => { + const batches: any[] = [] + mockSegmentServer.on('batch', (batch) => { + batches.push(batch) + }) + + const worker = await unstable_dev( + joinPath(__dirname, 'workers', 'send-multiple-events.ts'), + { + experimental: { + disableExperimentalWarning: true, + }, + bundle: true, + } + ) + const response = await worker.fetch( + 'http://localhost?flushAt=2&eventCount=6' + ) + await response.text() + await worker.stop() + + expect(batches).toHaveLength(3) + for (const { batch } of batches) { + expect(batch).toHaveLength(2) + } + expect(batches).toMatchInlineSnapshot( + batches.map(() => snapshotMatchers.getReqBody(2)), + ` + [ + { + "batch": [ + { + "_metadata": { + "jsRuntime": "cloudflare-worker", + }, + "context": { + "library": { + "name": "@segment/analytics-node", + "version": Any, + }, + }, + "event": "some-event", + "integrations": {}, + "messageId": Any, + "properties": { + "count": 0, + }, + "timestamp": StringMatching /\\^\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.\\\\d\\{3\\}Z\\$/, + "type": "track", + "userId": "some-user", + }, + { + "_metadata": { + "jsRuntime": "cloudflare-worker", + }, + "context": { + "library": { + "name": "@segment/analytics-node", + "version": Any, + }, + }, + "event": "some-event", + "integrations": {}, + "messageId": Any, + "properties": { + "count": 1, + }, + "timestamp": StringMatching /\\^\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.\\\\d\\{3\\}Z\\$/, + "type": "track", + "userId": "some-user", + }, + ], + "sentAt": StringMatching /\\^\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.\\\\d\\{3\\}Z\\$/, + "writeKey": "__TEST__", + }, + { + "batch": [ + { + "_metadata": { + "jsRuntime": "cloudflare-worker", + }, + "context": { + "library": { + "name": "@segment/analytics-node", + "version": Any, + }, + }, + "event": "some-event", + "integrations": {}, + "messageId": Any, + "properties": { + "count": 2, + }, + "timestamp": StringMatching /\\^\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.\\\\d\\{3\\}Z\\$/, + "type": "track", + "userId": "some-user", + }, + { + "_metadata": { + "jsRuntime": "cloudflare-worker", + }, + "context": { + "library": { + "name": "@segment/analytics-node", + "version": Any, + }, + }, + "event": "some-event", + "integrations": {}, + "messageId": Any, + "properties": { + "count": 3, + }, + "timestamp": StringMatching /\\^\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.\\\\d\\{3\\}Z\\$/, + "type": "track", + "userId": "some-user", + }, + ], + "sentAt": StringMatching /\\^\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.\\\\d\\{3\\}Z\\$/, + "writeKey": "__TEST__", + }, + { + "batch": [ + { + "_metadata": { + "jsRuntime": "cloudflare-worker", + }, + "context": { + "library": { + "name": "@segment/analytics-node", + "version": Any, + }, + }, + "event": "some-event", + "integrations": {}, + "messageId": Any, + "properties": { + "count": 4, + }, + "timestamp": StringMatching /\\^\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.\\\\d\\{3\\}Z\\$/, + "type": "track", + "userId": "some-user", + }, + { + "_metadata": { + "jsRuntime": "cloudflare-worker", + }, + "context": { + "library": { + "name": "@segment/analytics-node", + "version": Any, + }, + }, + "event": "some-event", + "integrations": {}, + "messageId": Any, + "properties": { + "count": 5, + }, + "timestamp": StringMatching /\\^\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.\\\\d\\{3\\}Z\\$/, + "type": "track", + "userId": "some-user", + }, + ], + "sentAt": StringMatching /\\^\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.\\\\d\\{3\\}Z\\$/, + "writeKey": "__TEST__", + }, + ] + ` + ) + }) + + it('may exit without sending events if you forget closeAndFlush', async () => { + const batches: any[] = [] + mockSegmentServer.on('batch', (batch) => { + batches.push(batch) + }) + + const worker = await unstable_dev( + joinPath(__dirname, 'workers', 'forgot-close-and-flush.ts'), + { + experimental: { + disableExperimentalWarning: true, + }, + bundle: true, + } + ) + const response = await worker.fetch('http://localhost') + await response.text() + await worker.stop() + + expect(batches).toHaveLength(0) + }) +}) diff --git a/packages/node-integration-tests/src/cloudflare-tests/workers/README.md b/packages/node-integration-tests/src/cloudflare-tests/workers/README.md new file mode 100644 index 000000000..a1ffc1b67 --- /dev/null +++ b/packages/node-integration-tests/src/cloudflare-tests/workers/README.md @@ -0,0 +1,7 @@ +These workers can also be ran directly via: + +``` +npx wrangler dev ./src/cloudflare/workers/ +``` + +This can be useful if you need to debug a worker with dev tools. diff --git a/packages/node-integration-tests/src/cloudflare-tests/workers/forgot-close-and-flush.ts b/packages/node-integration-tests/src/cloudflare-tests/workers/forgot-close-and-flush.ts new file mode 100644 index 000000000..4c91062ac --- /dev/null +++ b/packages/node-integration-tests/src/cloudflare-tests/workers/forgot-close-and-flush.ts @@ -0,0 +1,14 @@ +/// +import { Analytics } from '@segment/analytics-node' + +export default { + fetch(_request: Request, _env: {}, _ctx: ExecutionContext) { + const analytics = new Analytics({ + writeKey: '__TEST__', + host: 'http://localhost:3000', + }) + + analytics.track({ userId: 'some-user', event: 'some-event' }) + return new Response('ok') + }, +} diff --git a/packages/node-integration-tests/src/cloudflare-tests/workers/send-each-event-type.ts b/packages/node-integration-tests/src/cloudflare-tests/workers/send-each-event-type.ts new file mode 100644 index 000000000..aa6126d0e --- /dev/null +++ b/packages/node-integration-tests/src/cloudflare-tests/workers/send-each-event-type.ts @@ -0,0 +1,24 @@ +/// +import { Analytics } from '@segment/analytics-node' + +export default { + async fetch(_request: Request, _env: {}, _ctx: ExecutionContext) { + const analytics = new Analytics({ + writeKey: '__TEST__', + host: 'http://localhost:3000', + }) + + analytics.alias({ userId: 'some-user', previousId: 'other-user' }) + analytics.group({ userId: 'some-user', groupId: 'some-group' }) + analytics.identify({ + userId: 'some-user', + traits: { favoriteColor: 'Seattle Grey' }, + }) + analytics.page({ userId: 'some-user', name: 'Test Page' }) + analytics.track({ userId: 'some-user', event: 'some-event' }) + analytics.screen({ userId: 'some-user', name: 'Test Screen' }) + + await analytics.closeAndFlush() + return new Response('ok') + }, +} diff --git a/packages/node-integration-tests/src/cloudflare-tests/workers/send-multiple-events.ts b/packages/node-integration-tests/src/cloudflare-tests/workers/send-multiple-events.ts new file mode 100644 index 000000000..eb532738d --- /dev/null +++ b/packages/node-integration-tests/src/cloudflare-tests/workers/send-multiple-events.ts @@ -0,0 +1,26 @@ +/// +import { Analytics } from '@segment/analytics-node' + +export default { + async fetch(request: Request, _env: {}, _ctx: ExecutionContext) { + const url = new URL(request.url) + const flushAt = parseInt(url.searchParams.get('flushAt') ?? '15', 10) + const eventCount = parseInt(url.searchParams.get('eventCount') ?? '1', 10) + const analytics = new Analytics({ + writeKey: '__TEST__', + host: 'http://localhost:3000', + flushAt, + }) + + for (let i = 0; i < eventCount; i++) { + analytics.track({ + userId: 'some-user', + event: 'some-event', + properties: { count: i }, + }) + } + + await analytics.closeAndFlush() + return new Response('ok') + }, +} diff --git a/packages/node-integration-tests/src/cloudflare-tests/workers/send-oauth-event.ts b/packages/node-integration-tests/src/cloudflare-tests/workers/send-oauth-event.ts new file mode 100644 index 000000000..d758ae1c1 --- /dev/null +++ b/packages/node-integration-tests/src/cloudflare-tests/workers/send-oauth-event.ts @@ -0,0 +1,54 @@ +/// +import { Analytics, OAuthSettings } from '@segment/analytics-node' + +export default { + async fetch(_request: Request, _env: {}, _ctx: ExecutionContext) { + const settings: OAuthSettings = { + clientId: '__CLIENTID__', + // Has to be a valid key to encrypt the JWT, not used for anything else + clientKey: `-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDVll7uJaH322IN +PQsH2aOXZJ2r1q+6hpVK1R5JV1p41PUzn8pOxyXFHWB+53dUd4B8qywKS36XQjp0 +VmhR1tQ22znQ9ZCM6y4LGeOJBjAZiFZLcGQNNrDFC0WGWTrK1ZTS2K7p5qy4fIXG +laNkMXiGGCawkgcHAdOvPTy8m1d9a6YSetYVmBP/tEYN95jPyZFIoHQfkQPBPr9W +cWPpdEBzasHV5d957akjurPpleDiD5as66UW4dkWXvS7Wu7teCLCyDApcyJKTb2Z +SXybmWjhIZuctZMAx3wT/GgW3FbkGaW5KLQgBUMzjpL0fCtMatlqckMD92ll1FuK +R+HnXu05AgMBAAECggEBAK4o2il4GDUh9zbyQo9ZIPLuwT6AZXRED3Igi3ykNQp4 +I6S/s9g+vQaY6LkyBnSiqOt/K/8NBiFSiJWaa5/n+8zrP56qzf6KOlYk+wsdN5Vq +PWtwLrUzljpl8YAWPEFunNa8hwwE42vfZbnDBKNLT4qQIOQzfnVxQOoQlfj49gM2 +iSrblvsnQTyucFy3UyTeioHbh8q2Xqcxry5WUCOrFDd3IIwATTpLZGw0IPeuFJbJ +NfBizLEcyJaM9hujQU8PRCRd16MWX+bbYM6Mh4dkT40QXWsVnHBHwsgPdQgDgseF +Na4ajtHoC0DlwYCXpCm3IzJfKfq/LR2q8NDUgKeF4AECgYEA9nD4czza3SRbzhpZ +bBoK77CSNqCcMAqyuHB0hp/XX3yB7flF9PIPb2ReO8wwmjbxn+bm7PPz2Uwd2SzO +pU+FXmyKJr53Jxw/hmDWZCoh42gsGDlVqpmytzsj74KlaYiMyZmEGbD7t/FGfNGV +LdLDJaHIYxEimFviOTXKCeKvPAECgYEA3d8tv4jdp1uAuRZiU9Z/tfw5mJOi3oXF +8AdFFDwaPzcTorEAxjrt9X6IjPbLIDJNJtuXYpe+dG6720KyuNnhLhWW9oZEJTwT +dUgqZ2fTCOS9uH0jSn+ZFlgTWI6UDQXRwE7z8avlhMIrQVmPsttGTo7V6sQVtGRx +bNj2RSVekTkCgYAJvy4UYLPHS0jWPfSLcfw8vp8JyhBjVgj7gncZW/kIrcP1xYYe +yfQSU8XmV40UjFfCGz/G318lmP0VOdByeVKtCV3talsMEPHyPqI8E+6DL/uOebYJ +qUqINK6XKnOgWOY4kvnGillqTQCcry1XQp61PlDOmj7kB75KxPXYrj6AAQKBgQDa ++ixCv6hURuEyy77cE/YT/Q4zYnL6wHjtP5+UKwWUop1EkwG6o+q7wtiul90+t6ah +1VUCP9X/QFM0Qg32l0PBohlO0pFrVnG17TW8vSHxwyDkds1f97N19BOT8ZR5jebI +sKPfP9LVRnY+l1BWLEilvB+xBzqMwh2YWkIlWI6PMQKBgGi6TBnxp81lOYrxVRDj +/3ycRnVDmBdlQKFunvfzUBmG1mG/G0YHeVSUKZJGX7w2l+jnDwIA383FcUeA8X6A +l9q+amhtkwD/6fbkAu/xoWNl+11IFoxd88y2ByBFoEKB6UVLuCTSKwXDqzEZet7x +mDyRxq7ohIzLkw8b8buDeuXZ +-----END PRIVATE KEY-----`, + keyId: '__KEYID__', + maxRetries: 3, + authServer: 'http://localhost:3000', + scope: 'tracking_api:write', + } + + const analytics = new Analytics({ + writeKey: '__TEST__', + host: 'http://localhost:3000', + oauthSettings: settings, + }) + + analytics.track({ userId: 'some-user', event: 'some-event' }) + + await analytics.closeAndFlush() + return new Response('ok') + }, +} diff --git a/packages/node-integration-tests/src/cloudflare-tests/workers/send-single-event.ts b/packages/node-integration-tests/src/cloudflare-tests/workers/send-single-event.ts new file mode 100644 index 000000000..f8d7452cd --- /dev/null +++ b/packages/node-integration-tests/src/cloudflare-tests/workers/send-single-event.ts @@ -0,0 +1,16 @@ +/// +import { Analytics } from '@segment/analytics-node' + +export default { + async fetch(_request: Request, _env: {}, _ctx: ExecutionContext) { + const analytics = new Analytics({ + writeKey: '__TEST__', + host: 'http://localhost:3000', + }) + + analytics.track({ userId: 'some-user', event: 'some-event' }) + + await analytics.closeAndFlush() + return new Response('ok') + }, +} diff --git a/packages/node-integration-tests/src/durability-tests/server-start-analytics.ts b/packages/node-integration-tests/src/durability-tests/server-start-analytics.ts index e0b2817fe..adde0cf7d 100644 --- a/packages/node-integration-tests/src/durability-tests/server-start-analytics.ts +++ b/packages/node-integration-tests/src/durability-tests/server-start-analytics.ts @@ -5,7 +5,7 @@ import { trackEventSmall } from '../server/fixtures' const analytics = new Analytics({ writeKey: 'foo', flushInterval: 1000, - maxEventsInBatch: 15, + flushAt: 15, }) startServer({ onClose: analytics.closeAndFlush }) diff --git a/packages/node-integration-tests/src/perf-tests/server-start-analytics.ts b/packages/node-integration-tests/src/perf-tests/server-start-analytics.ts index e0b2817fe..adde0cf7d 100644 --- a/packages/node-integration-tests/src/perf-tests/server-start-analytics.ts +++ b/packages/node-integration-tests/src/perf-tests/server-start-analytics.ts @@ -5,7 +5,7 @@ import { trackEventSmall } from '../server/fixtures' const analytics = new Analytics({ writeKey: 'foo', flushInterval: 1000, - maxEventsInBatch: 15, + flushAt: 15, }) startServer({ onClose: analytics.closeAndFlush }) diff --git a/packages/node-integration-tests/src/server/mock-segment-workers.ts b/packages/node-integration-tests/src/server/mock-segment-workers.ts new file mode 100644 index 000000000..6d442ff0c --- /dev/null +++ b/packages/node-integration-tests/src/server/mock-segment-workers.ts @@ -0,0 +1,109 @@ +import { createServer, IncomingMessage } from 'http' + +async function getRequestText(req: IncomingMessage) { + return new Promise((resolve, reject) => { + const data: Buffer[] = [] + req.on('error', reject) + req.on('data', (chunk) => { + data.push(chunk) + }) + req.on('end', () => { + resolve(Buffer.concat(data).toString()) + }) + }) +} + +function isBatchRequest(req: IncomingMessage) { + if (req.url?.endsWith('/v1/batch')) { + return true + } + return false +} + +function isTokenRequest(req: IncomingMessage) { + if (req.url?.endsWith('/token')) { + return true + } + return false +} + +type BatchHandler = (batch: any) => void +type TokenHandler = (token: any) => void + +export class MockSegmentServer { + private server: ReturnType + private port: number + private onBatchHandlers: Set = new Set() + private onTokenHandlers: Set = new Set() + + constructor(port: number) { + this.port = port + this.server = createServer(async (req, res) => { + if (!isBatchRequest(req) && !isTokenRequest(req)) { + res.writeHead(404, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false })) + return + } + + const text = await getRequestText(req) + res.writeHead(200, { 'Content-Type': 'application/json' }) + if (isBatchRequest(req)) { + const batch = JSON.parse(text) + this.onBatchHandlers.forEach((handler) => { + handler(batch) + }) + res.end(JSON.stringify({ success: true })) + } else if (isTokenRequest(req)) { + this.onTokenHandlers.forEach((handler) => { + handler(text) + }) + res.end( + JSON.stringify({ + access_token: '__TOKEN__', + token_type: 'Bearer', + scope: 'tracking_api:write', + expires_in: 86400, + }) + ) + } + }) + } + + start(): Promise { + return new Promise((resolve, reject) => { + this.server.on('error', reject) + this.server.listen(this.port, () => { + resolve() + }) + }) + } + + stop(): Promise { + return new Promise((resolve, reject) => { + this.onBatchHandlers.clear() + this.server.close((err) => { + if (err) { + reject(err) + } else { + resolve() + } + }) + }) + } + + on(_event: 'batch' | 'token', handler: BatchHandler) { + if (_event === 'batch') { + this.onBatchHandlers.add(handler) + } else if (_event === 'token') { + this.onTokenHandlers.add(handler) + } + } + + off(_event: 'batch' | 'token', handler: BatchHandler) { + if (_event === 'batch') { + this.onBatchHandlers.delete(handler) + } else if (_event === 'token') { + this.onTokenHandlers.delete(handler) + } + } +} diff --git a/packages/node/CHANGELOG.md b/packages/node/CHANGELOG.md index f91d0f3f3..80e05dd7d 100644 --- a/packages/node/CHANGELOG.md +++ b/packages/node/CHANGELOG.md @@ -1,5 +1,81 @@ # @segment/analytics-node +## 2.1.0 + +### Minor Changes + +- [#1043](https://github.com/segmentio/analytics-next/pull/1043) [`95fd2fd`](https://github.com/segmentio/analytics-next/commit/95fd2fd801da26505ddcead96ffaa83aa4364994) Thanks [@silesky](https://github.com/silesky)! - This ensures backward compatibility with analytics-node by modifying '@segment/analytics-core'. Specifically, the changes prevent the generation of a messageId if it is already set. This adjustment aligns with the behavior outlined in analytics-node's source code [here](https://github.com/segmentio/analytics-node/blob/master/index.js#L195-L201). + + While this is a core release, only the node library is affected, as the browser has its own EventFactory atm. + +### Patch Changes + +- Updated dependencies [[`95fd2fd`](https://github.com/segmentio/analytics-next/commit/95fd2fd801da26505ddcead96ffaa83aa4364994), [`d212633`](https://github.com/segmentio/analytics-next/commit/d21263369d5980f4f57b13795524dbc345a02e5c)]: + - @segment/analytics-core@1.5.0 + - @segment/analytics-generic-utils@1.2.0 + +## 2.0.0 + +### Major Changes + +- [#935](https://github.com/segmentio/analytics-next/pull/935) [`833ade8`](https://github.com/segmentio/analytics-next/commit/833ade8571319a029f8e23511967ccb02d3496d4) Thanks [@MichaelGHSeg](https://github.com/MichaelGHSeg)! - Removing support for Node.js 14 and 16 as they are EOL + +* [#935](https://github.com/segmentio/analytics-next/pull/935) [`833ade8`](https://github.com/segmentio/analytics-next/commit/833ade8571319a029f8e23511967ccb02d3496d4) Thanks [@MichaelGHSeg](https://github.com/MichaelGHSeg)! - Support for Segment OAuth2 + + OAuth2 must be enabled from the Segment dashboard. You will need a PEM format + private/public key pair. Once you've uploaded your public key, you will need + the OAuth Client Id, the Key Id, and your private key. You can set these in + the new OAuthSettings type and provide it in your Analytics configuration. + + Note: This introduces a breaking change only if you have implemented a custom + HTTPClient. HTTPClientRequest `data: Record` has changed to + `body: string`. Processing data into a string now occurs before calls to + `makeRequest` + +## 1.3.0 + +### Minor Changes + +- [#1010](https://github.com/segmentio/analytics-next/pull/1010) [`5f37f4f`](https://github.com/segmentio/analytics-next/commit/5f37f4f6ea15b2457e6edf11cc92ddbf0dd11736) Thanks [@silesky](https://github.com/silesky)! - Add analytics.flush({ timeout: ..., close: ... }) method + +* [#1008](https://github.com/segmentio/analytics-next/pull/1008) [`e57960e`](https://github.com/segmentio/analytics-next/commit/e57960e84f5ce5b214dde09928bee6e6bdba3a69) Thanks [@danieljackins](https://github.com/danieljackins)! - Change segmentio to destination type + +## 1.2.0 + +### Minor Changes + +- [#1015](https://github.com/segmentio/analytics-next/pull/1015) [`8fbe1a0`](https://github.com/segmentio/analytics-next/commit/8fbe1a0d4cecff850c28b7da57f903c6df285231) Thanks [@silesky](https://github.com/silesky)! - Deprecate `maxEventsInBatch` in favor of our commonly used: `flushAt`. The purpose is to establish consistency between our SDKs, regardless of language. + +### Patch Changes + +- Updated dependencies [[`7b93e7b`](https://github.com/segmentio/analytics-next/commit/7b93e7b50fa293aebaf6767a44bf7708b231d5cd)]: + - @segment/analytics-generic-utils@1.1.1 + - @segment/analytics-core@1.4.1 + +## 1.1.4 + +### Patch Changes + +- Updated dependencies [[`d9b47c4`](https://github.com/segmentio/analytics-next/commit/d9b47c43e5e08efce14fe4150536ff60b8df91e0), [`d9b47c4`](https://github.com/segmentio/analytics-next/commit/d9b47c43e5e08efce14fe4150536ff60b8df91e0)]: + - @segment/analytics-core@1.4.0 + - @segment/analytics-generic-utils@1.1.0 + +## 1.1.3 + +### Patch Changes + +- [#974](https://github.com/segmentio/analytics-next/pull/974) [`c879377`](https://github.com/segmentio/analytics-next/commit/c87937720941ad830c5fdd76b0c049435a6ddec6) Thanks [@silesky](https://github.com/silesky)! - Refactor to get createDeferred from @segment/analytics-generic-utils lib + +- Updated dependencies [[`c879377`](https://github.com/segmentio/analytics-next/commit/c87937720941ad830c5fdd76b0c049435a6ddec6)]: + - @segment/analytics-generic-utils@1.0.0 + +## 1.1.2 + +### Patch Changes + +- Updated dependencies [[`897f4cc`](https://github.com/segmentio/analytics-next/commit/897f4cc69de4cdd38efd0cd70567bfed0c454fec)]: + - @segment/analytics-core@1.3.2 + ## 1.1.1 ### Patch Changes diff --git a/packages/node/README.md b/packages/node/README.md index f24687291..170014285 100644 --- a/packages/node/README.md +++ b/packages/node/README.md @@ -10,11 +10,11 @@ https://www.npmjs.com/package/@segment/analytics-node ## Runtime Support -- Node.js >= 14 +- Node.js >= 18 - AWS Lambda - Cloudflare Workers - Vercel Edge Functions -- Web Workers (experimental) +- Web Workers / Browser (no device mode destination support) ## Quick Start ### Install library @@ -56,7 +56,7 @@ app.post('/cart', (req, res) => { ``` -## Settings & Configuration +## Settings & Configuration See the documentation: https://segment.com/docs/connections/sources/catalog/libraries/server/node/#configuration You can also see the complete list of settings in the [AnalyticsSettings interface](src/app/settings.ts). @@ -67,7 +67,8 @@ You can also see the complete list of settings in the [AnalyticsSettings interfa -## Usage in AWS Lambda +## Usage in non-node runtimes +### Usage in AWS Lambda - [AWS lambda execution environment](https://docs.aws.amazon.com/lambda/latest/dg/lambda-runtime-environment.html) is challenging for typically non-response-blocking async activites like tracking or logging, since the runtime terminates / freezes after a response is emitted. Here is an example of using analytics.js within a handler: @@ -77,7 +78,7 @@ const { Analytics } = require('@segment/analytics-node'); // since analytics has the potential to be stateful if there are any plugins added, // to be on the safe side, we should instantiate a new instance of analytics on every request (the cost of instantiation is low). const analytics = () => new Analytics({ - maxEventsInBatch: 1, + flushAt: 1, writeKey: '', }) .on('error', console.error); @@ -97,14 +98,14 @@ module.exports.handler = async (event) => { }; ``` -## Usage in Vercel Edge Functions +### Usage in Vercel Edge Functions ```ts import { Analytics } from '@segment/analytics-node'; import { NextRequest, NextResponse } from 'next/server'; export const analytics = new Analytics({ writeKey: '', - maxEventsInBatch: 1, + flushAt: 1, }) .on('error', console.error) @@ -120,7 +121,7 @@ export default async (req: NextRequest) => { }; ``` -## Usage in Cloudflare Workers +### Usage in Cloudflare Workers ```ts import { Analytics, Context } from '@segment/analytics-node'; @@ -131,14 +132,14 @@ export default { ctx: ExecutionContext ): Promise { const analytics = new Analytics({ - maxEventsInBatch: 1, + flushAt: 1, writeKey: '', }).on('error', console.error); await new Promise((resolve, reject) => analytics.track({ ... }, resolve) ); - + ... return new Response(...) }, @@ -146,4 +147,30 @@ export default { ``` +### OAuth 2 +In order to guarantee authorized communication between your server environment and Segment's Tracking API, you can [enable OAuth 2 in your Segment workspace](https://segment.com/docs/partners/enable-with-segment/). To support the non-interactive server environment, the OAuth workflow used is a signed client assertion JWT. You will need a public and private key pair where the public key is uploaded to the segment dashboard and the private key is kept in your server environment to be used by this SDK. Your server will verify its identity by signing a token request and will receive a token that is used to to authorize all communication with the Segment Tracking API. + +You will also need to provide the OAuth Application ID and the public key's ID, both of which are provided in the Segment dashboard. You should ensure that you are implementing handling for Analytics SDK errors. Good logging will help distinguish any configuration issues. + +```ts +import { Analytics, OAuthSettings } from '@segment/analytics-node'; +import { readFileSync } from 'fs' + +const privateKey = readFileSync('private.pem', 'utf8') + +const settings: OAuthSettings = { + clientId: '', + clientKey: privateKey, + keyId: '', +} +const analytics = new Analytics({ + writeKey: '', + oauthSettings: settings, +}) + +analytics.on('error', (err) => { console.error(err) }) + +analytics.track({ userId: 'foo', event: 'bar' }) + +``` diff --git a/packages/node/package.json b/packages/node/package.json index b6f590b6e..7674cb4e2 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -1,6 +1,6 @@ { "name": "@segment/analytics-node", - "version": "1.1.1", + "version": "2.1.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/types/index.d.ts", @@ -17,7 +17,7 @@ "!*.tsbuildinfo" ], "engines": { - "node": ">=14" + "node": ">=18" }, "scripts": { ".": "yarn run -T turbo run --filter=@segment/analytics-node", @@ -36,15 +36,17 @@ }, "dependencies": { "@lukeed/uuid": "^2.0.0", - "@segment/analytics-core": "1.3.1", + "@segment/analytics-core": "1.5.0", + "@segment/analytics-generic-utils": "1.2.0", "buffer": "^6.0.3", + "jose": "^5.1.0", "node-fetch": "^2.6.7", "tslib": "^2.4.1" }, "devDependencies": { "@internal/config": "0.0.0", "@types/node": "^16", - "axios": "^1.4.0" + "axios": "^1.6.0" }, "packageManager": "yarn@3.4.1" } diff --git a/packages/node/src/__tests__/callback.test.ts b/packages/node/src/__tests__/callback.test.ts index c9aa6d80f..d8a22af02 100644 --- a/packages/node/src/__tests__/callback.test.ts +++ b/packages/node/src/__tests__/callback.test.ts @@ -4,7 +4,7 @@ import { Context } from '../app/context' describe('Callback behavior', () => { it('should handle success', async () => { const ajs = createTestAnalytics({ - maxEventsInBatch: 1, + flushAt: 1, }) const ctx = await new Promise((resolve, reject) => ajs.track( @@ -25,7 +25,7 @@ describe('Callback behavior', () => { it('should handle errors', async () => { const ajs = createTestAnalytics( { - maxEventsInBatch: 1, + flushAt: 1, }, { withError: true } ) diff --git a/packages/node/src/__tests__/graceful-shutdown-integration.test.ts b/packages/node/src/__tests__/graceful-shutdown-integration.test.ts index e82889c99..52c9e6fc4 100644 --- a/packages/node/src/__tests__/graceful-shutdown-integration.test.ts +++ b/packages/node/src/__tests__/graceful-shutdown-integration.test.ts @@ -22,17 +22,17 @@ describe('Ability for users to exit without losing events', () => { beforeEach(async () => { ajs = new Analytics({ writeKey: 'abc123', - maxEventsInBatch: 1, + flushAt: 1, httpClient: testClient, }) }) const _helpers = { getFetchCalls: () => - makeReqSpy.mock.calls.map(([{ url, method, data, headers }]) => ({ + makeReqSpy.mock.calls.map(([{ url, method, body, headers }]) => ({ url, method, headers, - data, + body, })), makeTrackCall: (analytics = ajs, cb?: (...args: any[]) => void) => { analytics.track({ userId: 'foo', event: 'Thing Updated' }, cb) @@ -190,7 +190,7 @@ describe('Ability for users to exit without losing events', () => { const analytics = new Analytics({ writeKey: 'foo', flushInterval: 10000, - maxEventsInBatch: 15, + flushAt: 15, httpClient: testClient, }) _helpers.makeTrackCall(analytics) @@ -206,7 +206,7 @@ describe('Ability for users to exit without losing events', () => { expect(elapsedTime).toBeLessThan(100) const calls = _helpers.getFetchCalls() expect(calls.length).toBe(1) - expect(calls[0].data.batch.length).toBe(2) + expect(JSON.parse(calls[0].body).batch.length).toBe(2) }) test('should wait to flush if close is called and an event has not made it to the segment.io plugin yet', async () => { @@ -221,7 +221,7 @@ describe('Ability for users to exit without losing events', () => { const analytics = new Analytics({ writeKey: 'foo', flushInterval: 10000, - maxEventsInBatch: 15, + flushAt: 15, httpClient: testClient, }) await analytics.register(_testPlugin) @@ -238,7 +238,141 @@ describe('Ability for users to exit without losing events', () => { expect(elapsedTime).toBeLessThan(TRACK_DELAY * 2) const calls = _helpers.getFetchCalls() expect(calls.length).toBe(1) - expect(calls[0].data.batch.length).toBe(2) + expect(JSON.parse(calls[0].body).batch.length).toBe(2) + }) + }) + + describe('.flush()', () => { + beforeEach(() => { + ajs = new Analytics({ + writeKey: 'abc123', + httpClient: testClient, + maxEventsInBatch: 15, + }) + }) + + it('should be able to flush multiple times', async () => { + let drainedCalls = 0 + ajs.on('drained', () => { + drainedCalls++ + }) + let trackCalls = 0 + ajs.on('track', () => { + trackCalls++ + }) + // make track call + _helpers.makeTrackCall() + + // flush first time + await ajs.flush() + expect(_helpers.getFetchCalls().length).toBe(1) + expect(trackCalls).toBe(1) + expect(drainedCalls).toBe(1) + + // make another 2 track calls + _helpers.makeTrackCall() + _helpers.makeTrackCall() + + // flush second time + await ajs.flush() + expect(drainedCalls).toBe(2) + expect(_helpers.getFetchCalls().length).toBe(2) + expect(trackCalls).toBe(3) + }) + + test('should handle events normally if new events enter the pipeline _after_ flush is called', async () => { + let drainedCalls = 0 + ajs.on('drained', () => { + drainedCalls++ + }) + let trackCallCount = 0 + ajs.on('track', () => { + trackCallCount += 1 + }) + + // make regular call + _helpers.makeTrackCall() + const flushed = ajs.flush() + + // add another event to the queue to simulate late-arriving track call. flush should not wait for this event. + await sleep(100) + _helpers.makeTrackCall() + + await flushed + expect(trackCallCount).toBe(1) + expect(_helpers.getFetchCalls().length).toBe(1) + expect(drainedCalls).toBe(1) + + // should be one event left in the queue (the late-arriving track call). This will be included in the next flush. + // add a second event to the queue. + _helpers.makeTrackCall() + + await ajs.flush() + expect(drainedCalls).toBe(2) + expect(_helpers.getFetchCalls().length).toBe(2) + expect(trackCallCount).toBe(3) + }) + + test('overlapping flush calls should be ignored with a wwarning', async () => { + ajs = new Analytics({ + writeKey: 'abc123', + httpClient: testClient, + maxEventsInBatch: 2, + }) + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}) + let drainedCalls = 0 + ajs.on('drained', () => { + drainedCalls++ + }) + let trackCallCount = 0 + ajs.on('track', () => { + trackCallCount += 1 + }) + + _helpers.makeTrackCall() + // overlapping flush calls + const flushes = Promise.all([ajs.flush(), ajs.flush()]) + _helpers.makeTrackCall() + _helpers.makeTrackCall() + await flushes + expect(warnSpy).toHaveBeenCalledTimes(1) + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Overlapping flush calls detected') + ) + expect(trackCallCount).toBe(3) + expect(drainedCalls).toBe(1) + + // just to be ensure the pipeline is operating as usual, make another track call and flush + _helpers.makeTrackCall() + await ajs.flush() + expect(trackCallCount).toBe(4) + expect(drainedCalls).toBe(2) + }) + + test('should call console.warn only once', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}) + let drainedCalls = 0 + ajs.on('drained', () => { + drainedCalls++ + }) + + _helpers.makeTrackCall() + + // overlapping flush calls + await Promise.all([ajs.flush(), ajs.flush()]) + expect(warnSpy).toHaveBeenCalledTimes(1) + expect(drainedCalls).toBe(1) + + _helpers.makeTrackCall() + // non-overlapping flush calls + await ajs.flush() + expect(drainedCalls).toBe(2) + + // there are no additional events to flush + await ajs.flush() + expect(drainedCalls).toBe(2) + + expect(warnSpy).toHaveBeenCalledTimes(1) }) }) }) diff --git a/packages/node/src/__tests__/http-client.integration.test.ts b/packages/node/src/__tests__/http-client.integration.test.ts index d84562fd2..ee7572ceb 100644 --- a/packages/node/src/__tests__/http-client.integration.test.ts +++ b/packages/node/src/__tests__/http-client.integration.test.ts @@ -16,17 +16,16 @@ const helpers = { analytics.track({ event: 'foo', userId: 'bar' }, resolve) ), assertFetchCallRequest: ( - ...[url, options]: typeof testFetch['mock']['lastCall'] + ...[url, options]: NonNullable ) => { expect(url).toBe('https://api.segment.io/v1/batch') expect(options.headers).toEqual({ - Authorization: 'Basic Zm9vOg==', 'Content-Type': 'application/json', 'User-Agent': 'analytics-node-next/latest', }) expect(options.method).toBe('POST') const getLastBatch = (): object[] => { - const [, options] = testFetch.mock.lastCall + const [, options] = testFetch.mock.lastCall! const batch = JSON.parse(options.body!).batch return batch } @@ -57,13 +56,13 @@ describe('httpClient option', () => { expect(testFetch).toHaveBeenCalledTimes(0) await helpers.makeTrackCall() expect(testFetch).toHaveBeenCalledTimes(1) - helpers.assertFetchCallRequest(...testFetch.mock.lastCall) + helpers.assertFetchCallRequest(...testFetch.mock.lastCall!) }) it('can be a simple function that matches the fetch interface', async () => { analytics = createTestAnalytics({ httpClient: testFetch }) expect(testFetch).toHaveBeenCalledTimes(0) await helpers.makeTrackCall() expect(testFetch).toHaveBeenCalledTimes(1) - helpers.assertFetchCallRequest(...testFetch.mock.lastCall) + helpers.assertFetchCallRequest(...testFetch.mock.lastCall!) }) }) diff --git a/packages/node/src/__tests__/http-integration.test.ts b/packages/node/src/__tests__/http-integration.test.ts index fd5ff4ff9..09618ae72 100644 --- a/packages/node/src/__tests__/http-integration.test.ts +++ b/packages/node/src/__tests__/http-integration.test.ts @@ -58,7 +58,7 @@ describe('Method Smoke Tests', () => { expect(calls[0].batch[0]._metadata).toMatchInlineSnapshot( { nodeVersion: expect.any(String), jsRuntime: 'node' }, ` - Object { + { "jsRuntime": "node", "nodeVersion": Any, } @@ -80,18 +80,15 @@ describe('Method Smoke Tests', () => { expect(pick(headers, 'authorization', 'user-agent', 'content-type')) .toMatchInlineSnapshot(` - Object { - "authorization": Array [ - "Basic Zm9vOg==", - ], - "content-type": Array [ - "application/json", - ], - "user-agent": Array [ - "analytics-node-next/latest", - ], - } - `) + { + "content-type": [ + "application/json", + ], + "user-agent": [ + "analytics-node-next/latest", + ], + } + `) expect(scope.isDone()).toBeTruthy() }) }) @@ -136,20 +133,20 @@ describe('Method Smoke Tests', () => { expect(calls[0]).toMatchInlineSnapshot( snapshotMatchers.defaultReqBody, ` - Object { - "batch": Array [ - Object { + { + "batch": [ + { "_metadata": Any, - "context": Object { - "library": Object { + "context": { + "library": { "name": "@segment/analytics-node", "version": Any, }, }, - "integrations": Object {}, + "integrations": {}, "messageId": Any, "timestamp": StringMatching /\\^\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.\\\\d\\{3\\}Z\\$/, - "traits": Object { + "traits": { "foo": "bar", }, "type": "identify", @@ -157,6 +154,7 @@ describe('Method Smoke Tests', () => { }, ], "sentAt": StringMatching /\\^\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.\\\\d\\{3\\}Z\\$/, + "writeKey": "foo", } ` ) @@ -172,20 +170,20 @@ describe('Method Smoke Tests', () => { expect(calls[0]).toMatchInlineSnapshot( snapshotMatchers.defaultReqBody, ` - Object { - "batch": Array [ - Object { + { + "batch": [ + { "_metadata": Any, - "context": Object { - "library": Object { + "context": { + "library": { "name": "@segment/analytics-node", "version": Any, }, }, "event": "foo", - "integrations": Object {}, + "integrations": {}, "messageId": Any, - "properties": Object { + "properties": { "hello": "world", }, "timestamp": StringMatching /\\^\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.\\\\d\\{3\\}Z\\$/, @@ -194,6 +192,7 @@ describe('Method Smoke Tests', () => { }, ], "sentAt": StringMatching /\\^\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.\\\\d\\{3\\}Z\\$/, + "writeKey": "foo", } ` ) @@ -205,26 +204,27 @@ describe('Method Smoke Tests', () => { expect(calls[0]).toMatchInlineSnapshot( snapshotMatchers.defaultReqBody, ` - Object { - "batch": Array [ - Object { + { + "batch": [ + { "_metadata": Any, "anonymousId": "foo", - "context": Object { - "library": Object { + "context": { + "library": { "name": "@segment/analytics-node", "version": Any, }, }, - "integrations": Object {}, + "integrations": {}, "messageId": Any, "name": "page", - "properties": Object {}, + "properties": {}, "timestamp": StringMatching /\\^\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.\\\\d\\{3\\}Z\\$/, "type": "page", }, ], "sentAt": StringMatching /\\^\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.\\\\d\\{3\\}Z\\$/, + "writeKey": "foo", } ` ) @@ -240,28 +240,29 @@ describe('Method Smoke Tests', () => { expect(calls[0]).toMatchInlineSnapshot( snapshotMatchers.defaultReqBody, ` - Object { - "batch": Array [ - Object { + { + "batch": [ + { "_metadata": Any, "anonymousId": "foo", - "context": Object { - "library": Object { + "context": { + "library": { "name": "@segment/analytics-node", "version": Any, }, }, "groupId": "myGroupId", - "integrations": Object {}, + "integrations": {}, "messageId": Any, "timestamp": StringMatching /\\^\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.\\\\d\\{3\\}Z\\$/, - "traits": Object { + "traits": { "some_traits": 123, }, "type": "group", }, ], "sentAt": StringMatching /\\^\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.\\\\d\\{3\\}Z\\$/, + "writeKey": "foo", } ` ) @@ -273,17 +274,17 @@ describe('Method Smoke Tests', () => { expect(calls[0]).toMatchInlineSnapshot( snapshotMatchers.defaultReqBody, ` - Object { - "batch": Array [ - Object { + { + "batch": [ + { "_metadata": Any, - "context": Object { - "library": Object { + "context": { + "library": { "name": "@segment/analytics-node", "version": Any, }, }, - "integrations": Object {}, + "integrations": {}, "messageId": Any, "previousId": "previous", "timestamp": StringMatching /\\^\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.\\\\d\\{3\\}Z\\$/, @@ -292,6 +293,7 @@ describe('Method Smoke Tests', () => { }, ], "sentAt": StringMatching /\\^\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.\\\\d\\{3\\}Z\\$/, + "writeKey": "foo", } ` ) @@ -307,21 +309,21 @@ describe('Method Smoke Tests', () => { expect(calls[0]).toMatchInlineSnapshot( snapshotMatchers.defaultReqBody, ` - Object { - "batch": Array [ - Object { + { + "batch": [ + { "_metadata": Any, "anonymousId": "foo", - "context": Object { - "library": Object { + "context": { + "library": { "name": "@segment/analytics-node", "version": Any, }, }, - "integrations": Object {}, + "integrations": {}, "messageId": Any, "name": "screen", - "properties": Object { + "properties": { "title": "wip", }, "timestamp": StringMatching /\\^\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.\\\\d\\{3\\}Z\\$/, @@ -329,6 +331,7 @@ describe('Method Smoke Tests', () => { }, ], "sentAt": StringMatching /\\^\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.\\\\d\\{3\\}Z\\$/, + "writeKey": "foo", } ` ) @@ -346,7 +349,7 @@ describe('Client: requestTimeout', () => { jest.useRealTimers() const ajs = createTestAnalytics( { - maxEventsInBatch: 1, + flushAt: 1, httpRequestTimeout: 0, }, { useRealHTTPClient: true } diff --git a/packages/node/src/__tests__/integration.test.ts b/packages/node/src/__tests__/integration.test.ts index 8a2f702cd..2554c115d 100644 --- a/packages/node/src/__tests__/integration.test.ts +++ b/packages/node/src/__tests__/integration.test.ts @@ -7,6 +7,8 @@ import { TestFetchClient, } from './test-helpers/create-test-analytics' +const isoDateRegEx = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/ + const writeKey = 'foo' jest.setTimeout(10000) const timestamp = new Date() @@ -86,6 +88,16 @@ describe('alias', () => { expect(ctx.event.userId).toEqual('chris radek') expect(ctx.event.previousId).toEqual('chris') expect(ctx.event.timestamp).toEqual(timestamp) + expect(ctx.event.messageId).toEqual(expect.any(String)) + }) + it('allows messageId to be overridden', async () => { + const analytics = createTestAnalytics({ + httpClient: testClient, + }) + const messageId = 'overridden' + analytics.alias({ userId: 'foo', previousId: 'bar', messageId }) + const ctx = await resolveCtx(analytics, 'alias') + expect(ctx.event.messageId).toBe(messageId) }) }) @@ -105,6 +117,7 @@ describe('group', () => { expect(ctx.event.userId).toEqual('foo') expect(ctx.event.anonymousId).toBe('bar') expect(ctx.event.timestamp).toEqual(timestamp) + expect(ctx.event.messageId).toEqual(expect.any(String)) }) it('invocations are isolated', async () => { @@ -132,6 +145,16 @@ describe('group', () => { expect(ctx2.event.anonymousId).toBeUndefined() expect(ctx2.event.userId).toEqual('me') }) + + it('allows messageId to be overridden', async () => { + const analytics = createTestAnalytics({ + httpClient: testClient, + }) + const messageId = 'overridden' + analytics.group({ groupId: 'foo', userId: 'sup', messageId }) + const ctx = await resolveCtx(analytics, 'group') + expect(ctx.event.messageId).toBe(messageId) + }) }) describe('identify', () => { @@ -179,6 +202,7 @@ describe('page', () => { expect(ctx1.event.userId).toBeUndefined() expect(ctx1.event.properties).toEqual({ category }) expect(ctx1.event.timestamp).toEqual(timestamp) + expect(ctx1.event.messageId).toEqual(expect.any(String)) analytics.page({ name, properties: { title: 'wip' }, userId: 'user-id' }) @@ -190,6 +214,7 @@ describe('page', () => { expect(ctx2.event.userId).toEqual('user-id') expect(ctx2.event.properties).toEqual({ title: 'wip' }) expect(ctx2.event.timestamp).toEqual(expect.any(Date)) + expect(ctx2.event.messageId).toEqual(expect.any(String)) analytics.page({ properties: { title: 'invisible' }, userId: 'user-id' }) const ctx3 = await resolveCtx(analytics, 'page') @@ -199,6 +224,17 @@ describe('page', () => { expect(ctx3.event.anonymousId).toBeUndefined() expect(ctx3.event.userId).toEqual('user-id') expect(ctx3.event.properties).toEqual({ title: 'invisible' }) + expect(ctx3.event.messageId).toEqual(expect.any(String)) + }) + + it('allows messageId to be overridden', async () => { + const analytics = createTestAnalytics({ + httpClient: testClient, + }) + const messageId = 'overridden' + analytics.page({ name: 'foo', userId: 'sup', messageId }) + const ctx = await resolveCtx(analytics, 'page') + expect(ctx.event.messageId).toBe(messageId) }) }) @@ -222,6 +258,7 @@ describe('screen', () => { expect(ctx1.event.userId).toEqual('user-id') expect(ctx1.event.properties).toEqual({ title: 'wip' }) expect(ctx1.event.timestamp).toEqual(timestamp) + expect(ctx1.event.messageId).toEqual(expect.any(String)) analytics.screen({ properties: { title: 'invisible' }, @@ -236,6 +273,16 @@ describe('screen', () => { expect(ctx2.event.userId).toEqual('user-id') expect(ctx2.event.properties).toEqual({ title: 'invisible' }) expect(ctx2.event.timestamp).toEqual(expect.any(Date)) + expect(ctx2.event.messageId).toEqual(expect.any(String)) + }) + it('allows messageId to be overridden', async () => { + const analytics = createTestAnalytics({ + httpClient: testClient, + }) + const messageId = 'overridden' + analytics.screen({ name: 'foo', userId: 'sup', messageId }) + const ctx = await resolveCtx(analytics, 'screen') + expect(ctx.event.messageId).toBe(messageId) }) }) @@ -247,7 +294,9 @@ describe('track', () => { const track = resolveCtx(analytics, 'track') analytics.track({ event: 'hello', userId: 'foo' }) await track - expect(makeReqSpy.mock.calls[0][0].data.sentAt).toBeInstanceOf(Date) + expect(JSON.parse(makeReqSpy.mock.calls[0][0].body).sentAt).toMatch( + isoDateRegEx + ) }) it('generates track events', async () => { const analytics = createTestAnalytics() @@ -268,6 +317,7 @@ describe('track', () => { expect(ctx1.event.anonymousId).toEqual('unknown') expect(ctx1.event.userId).toEqual('known') expect(ctx1.event.timestamp).toEqual(timestamp) + expect(ctx1.event.messageId).toEqual(expect.any(String)) analytics.track({ event: eventName, @@ -282,6 +332,17 @@ describe('track', () => { expect(ctx2.event.anonymousId).toBeUndefined() expect(ctx2.event.userId).toEqual('known') expect(ctx2.event.timestamp).toEqual(expect.any(Date)) + expect(ctx2.event.messageId).toEqual(expect.any(String)) + }) + + it('allows messageId to be overridden', async () => { + const analytics = createTestAnalytics({ + httpClient: testClient, + }) + const messageId = 'overridden' + analytics.track({ event: 'foo', userId: 'sup', messageId }) + const ctx = await resolveCtx(analytics, 'track') + expect(ctx.event.messageId).toBe(messageId) }) }) diff --git a/packages/node/src/__tests__/oauth.integration.test.ts b/packages/node/src/__tests__/oauth.integration.test.ts new file mode 100644 index 000000000..5d826e92f --- /dev/null +++ b/packages/node/src/__tests__/oauth.integration.test.ts @@ -0,0 +1,334 @@ +import { HTTPResponse } from '../lib/http-client' +import { OAuthSettings } from '../lib/types' +import { + TestFetchClient, + createTestAnalytics, +} from './test-helpers/create-test-analytics' +import { createError } from './test-helpers/factories' +import { resolveCtx } from './test-helpers/resolve-ctx' + +// NOTE: Fake private key for illustrative purposes only +const privateKey = `-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDVll7uJaH322IN +PQsH2aOXZJ2r1q+6hpVK1R5JV1p41PUzn8pOxyXFHWB+53dUd4B8qywKS36XQjp0 +VmhR1tQ22znQ9ZCM6y4LGeOJBjAZiFZLcGQNNrDFC0WGWTrK1ZTS2K7p5qy4fIXG +laNkMXiGGCawkgcHAdOvPTy8m1d9a6YSetYVmBP/tEYN95jPyZFIoHQfkQPBPr9W +cWPpdEBzasHV5d957akjurPpleDiD5as66UW4dkWXvS7Wu7teCLCyDApcyJKTb2Z +SXybmWjhIZuctZMAx3wT/GgW3FbkGaW5KLQgBUMzjpL0fCtMatlqckMD92ll1FuK +R+HnXu05AgMBAAECggEBAK4o2il4GDUh9zbyQo9ZIPLuwT6AZXRED3Igi3ykNQp4 +I6S/s9g+vQaY6LkyBnSiqOt/K/8NBiFSiJWaa5/n+8zrP56qzf6KOlYk+wsdN5Vq +PWtwLrUzljpl8YAWPEFunNa8hwwE42vfZbnDBKNLT4qQIOQzfnVxQOoQlfj49gM2 +iSrblvsnQTyucFy3UyTeioHbh8q2Xqcxry5WUCOrFDd3IIwATTpLZGw0IPeuFJbJ +NfBizLEcyJaM9hujQU8PRCRd16MWX+bbYM6Mh4dkT40QXWsVnHBHwsgPdQgDgseF +Na4ajtHoC0DlwYCXpCm3IzJfKfq/LR2q8NDUgKeF4AECgYEA9nD4czza3SRbzhpZ +bBoK77CSNqCcMAqyuHB0hp/XX3yB7flF9PIPb2ReO8wwmjbxn+bm7PPz2Uwd2SzO +pU+FXmyKJr53Jxw/hmDWZCoh42gsGDlVqpmytzsj74KlaYiMyZmEGbD7t/FGfNGV +LdLDJaHIYxEimFviOTXKCeKvPAECgYEA3d8tv4jdp1uAuRZiU9Z/tfw5mJOi3oXF +8AdFFDwaPzcTorEAxjrt9X6IjPbLIDJNJtuXYpe+dG6720KyuNnhLhWW9oZEJTwT +dUgqZ2fTCOS9uH0jSn+ZFlgTWI6UDQXRwE7z8avlhMIrQVmPsttGTo7V6sQVtGRx +bNj2RSVekTkCgYAJvy4UYLPHS0jWPfSLcfw8vp8JyhBjVgj7gncZW/kIrcP1xYYe +yfQSU8XmV40UjFfCGz/G318lmP0VOdByeVKtCV3talsMEPHyPqI8E+6DL/uOebYJ +qUqINK6XKnOgWOY4kvnGillqTQCcry1XQp61PlDOmj7kB75KxPXYrj6AAQKBgQDa ++ixCv6hURuEyy77cE/YT/Q4zYnL6wHjtP5+UKwWUop1EkwG6o+q7wtiul90+t6ah +1VUCP9X/QFM0Qg32l0PBohlO0pFrVnG17TW8vSHxwyDkds1f97N19BOT8ZR5jebI +sKPfP9LVRnY+l1BWLEilvB+xBzqMwh2YWkIlWI6PMQKBgGi6TBnxp81lOYrxVRDj +/3ycRnVDmBdlQKFunvfzUBmG1mG/G0YHeVSUKZJGX7w2l+jnDwIA383FcUeA8X6A +l9q+amhtkwD/6fbkAu/xoWNl+11IFoxd88y2ByBFoEKB6UVLuCTSKwXDqzEZet7x +mDyRxq7ohIzLkw8b8buDeuXZ +-----END PRIVATE KEY-----` + +jest.setTimeout(10000) +const timestamp = new Date() + +class OauthFetchClient extends TestFetchClient {} + +const oauthTestClient = new OauthFetchClient() +const oauthFetcher = jest.spyOn(oauthTestClient, 'makeRequest') + +const tapiTestClient = new TestFetchClient() +const tapiFetcher = jest.spyOn(tapiTestClient, 'makeRequest') + +const getOAuthSettings = (): OAuthSettings => ({ + httpClient: oauthTestClient, + maxRetries: 3, + clientId: 'clientId', + clientKey: privateKey, + keyId: 'keyId', + scope: 'scope', + authServer: 'http://127.0.0.1:1234', +}) + +const createOAuthSuccess = async (body?: any): Promise => ({ + text: async () => JSON.stringify(body), + status: 200, + statusText: 'OK', +}) + +const createOAuthError = async ( + overrides: Partial = {} +): Promise => ({ + status: 400, + statusText: 'Foo', + text: async () => '', + ...overrides, +}) + +describe('OAuth Integration Success', () => { + it('track event with OAuth', async () => { + const analytics = createTestAnalytics({ + oauthSettings: getOAuthSettings(), + }) + const eventName = 'Test Event' + + oauthFetcher.mockReturnValue( + createOAuthSuccess({ access_token: 'token', expires_in: 100 }) + ) + + analytics.track({ + event: eventName, + anonymousId: 'unknown', + userId: 'known', + timestamp: timestamp, + }) + + const ctx1 = await resolveCtx(analytics, 'track') + + expect(ctx1.event.type).toEqual('track') + expect(ctx1.event.event).toEqual(eventName) + expect(ctx1.event.properties).toEqual({}) + expect(ctx1.event.anonymousId).toEqual('unknown') + expect(ctx1.event.userId).toEqual('known') + expect(ctx1.event.timestamp).toEqual(timestamp) + + expect(oauthFetcher).toHaveBeenCalledTimes(1) + + await analytics.closeAndFlush() + }) + it('track event with OAuth after retry', async () => { + const analytics = createTestAnalytics({ + oauthSettings: getOAuthSettings(), + }) + oauthFetcher + .mockReturnValueOnce(createOAuthError({ status: 425 })) + .mockReturnValueOnce( + createOAuthSuccess({ access_token: 'token', expires_in: 100 }) + ) + + const eventName = 'Test Event' + + analytics.track({ + event: eventName, + anonymousId: 'unknown', + userId: 'known', + timestamp: timestamp, + }) + + const ctx1 = await resolveCtx(analytics, 'track') + + expect(ctx1.event.type).toEqual('track') + expect(ctx1.event.event).toEqual(eventName) + expect(ctx1.event.properties).toEqual({}) + expect(ctx1.event.anonymousId).toEqual('unknown') + expect(ctx1.event.userId).toEqual('known') + expect(ctx1.event.timestamp).toEqual(timestamp) + + expect(oauthFetcher).toHaveBeenCalledTimes(2) + + await analytics.closeAndFlush() + }) + + it('delays appropriately on 429 error', async () => { + const analytics = createTestAnalytics({ + oauthSettings: getOAuthSettings(), + }) + const retryTime = Date.now() + 250 + oauthFetcher + .mockReturnValueOnce( + createOAuthError({ + status: 429, + headers: { 'X-RateLimit-Reset': retryTime }, + }) + ) + .mockReturnValue( + createOAuthSuccess({ access_token: 'token', expires_in: 100 }) + ) + + analytics.track({ + event: 'Test Event', + anonymousId: 'unknown', + userId: 'known', + timestamp: timestamp, + }) + const ctx1 = await resolveCtx(analytics, 'track') // forces exception to be thrown + expect(ctx1.event.type).toEqual('track') + await analytics.closeAndFlush() + expect(retryTime).toBeLessThan(Date.now()) + }) +}) + +describe('OAuth Failure', () => { + it('surfaces error after retries', async () => { + const analytics = createTestAnalytics({ + oauthSettings: getOAuthSettings(), + }) + + oauthFetcher.mockReturnValue(createOAuthError({ status: 500 })) + + const eventName = 'Test Event' + + try { + analytics.track({ + event: eventName, + anonymousId: 'unknown', + userId: 'known', + timestamp: timestamp, + }) + + const ctx1 = await resolveCtx(analytics, 'track') + + expect(ctx1.event.type).toEqual('track') + expect(ctx1.event.event).toEqual(eventName) + expect(ctx1.event.properties).toEqual({}) + expect(ctx1.event.anonymousId).toEqual('unknown') + expect(ctx1.event.userId).toEqual('known') + expect(ctx1.event.timestamp).toEqual(timestamp) + + expect(oauthFetcher).toHaveBeenCalledTimes(3) + + await analytics.closeAndFlush() + + throw new Error('fail') + } catch (err: any) { + expect(err.reason).toEqual(new Error('[500] Foo')) + expect(err.code).toMatch(/delivery_failure/) + } + }) + + it('surfaces error after failing immediately', async () => { + const logger = jest.fn() + const analytics = createTestAnalytics({ + oauthSettings: getOAuthSettings(), + }).on('error', (err) => { + logger(err) + }) + + oauthFetcher.mockReturnValue(createOAuthError({ status: 400 })) + + try { + analytics.track({ + event: 'Test Event', + anonymousId: 'unknown', + userId: 'known', + timestamp: timestamp, + }) + + const ctx1 = await resolveCtx(analytics, 'track') // forces exception to be thrown + expect(ctx1.event.type).toEqual('track') + await analytics.closeAndFlush() + + expect(logger).toHaveBeenCalledWith('foo') + throw new Error('fail') + } catch (err: any) { + expect(err.reason).toEqual(new Error('[400] Foo')) + expect(err.code).toMatch(/delivery_failure/) + } + }) + + it('handles a bad key', async () => { + const props = getOAuthSettings() + props.clientKey = 'Garbage' + const analytics = createTestAnalytics({ + oauthSettings: props, + }) + + try { + analytics.track({ + event: 'Test Event', + anonymousId: 'unknown', + userId: 'known', + timestamp: timestamp, + }) + await analytics.closeAndFlush() + const ctx1 = await resolveCtx(analytics, 'track') // forces exception to be thrown + expect(ctx1.event.type).toEqual('track') + throw new Error('fail') + } catch (err: any) { + expect(err.reason).toEqual( + new TypeError('"pkcs8" must be PKCS#8 formatted string') + ) + } + }) + + it('OAuth inherits Analytics custom client', async () => { + const oauthSettings = getOAuthSettings() + oauthSettings.httpClient = undefined + const analytics = createTestAnalytics({ + oauthSettings: oauthSettings, + httpClient: tapiTestClient, + }) + tapiFetcher.mockReturnValue(createOAuthError({ status: 415 })) + + try { + analytics.track({ + event: 'Test Event', + anonymousId: 'unknown', + userId: 'known', + timestamp: timestamp, + }) + + await resolveCtx(analytics, 'track') + await analytics.closeAndFlush() + + throw new Error('fail') + } catch (err: any) { + expect(err.reason).toEqual(new Error('[415] Foo')) + expect(err.code).toMatch(/delivery_failure/) + } + }) +}) + +describe('TAPI rejection', () => { + it('surfaces error', async () => { + const analytics = createTestAnalytics({ + oauthSettings: getOAuthSettings(), + httpClient: tapiTestClient, + }) + const eventName = 'Test Event' + + oauthFetcher.mockReturnValue( + createOAuthSuccess({ access_token: 'token', expires_in: 100 }) + ) + tapiFetcher.mockReturnValue( + createError({ + status: 400, + statusText: + '{"success":false,"message":"malformed JSON","code":"invalid_request"}', + }) + ) + + try { + analytics.track({ + event: eventName, + anonymousId: 'unknown', + userId: 'known', + timestamp: timestamp, + }) + + const ctx1 = await resolveCtx(analytics, 'track') + + expect(ctx1.event.type).toEqual('track') + expect(ctx1.event.event).toEqual(eventName) + expect(ctx1.event.properties).toEqual({}) + expect(ctx1.event.anonymousId).toEqual('unknown') + expect(ctx1.event.userId).toEqual('known') + expect(ctx1.event.timestamp).toEqual(timestamp) + + expect(oauthFetcher).toHaveBeenCalledTimes(1) + + await analytics.closeAndFlush() + throw new Error('fail') + } catch (err: any) { + expect(err.code).toBe('delivery_failure') + } + }) +}) diff --git a/packages/node/src/__tests__/test-helpers/assert-shape/http-request-event.ts b/packages/node/src/__tests__/test-helpers/assert-shape/http-request-event.ts index 684a32dc2..7c0b733ce 100644 --- a/packages/node/src/__tests__/test-helpers/assert-shape/http-request-event.ts +++ b/packages/node/src/__tests__/test-helpers/assert-shape/http-request-event.ts @@ -4,7 +4,7 @@ type HttpRequestEmitterEvent = NodeEmitterEvents['http_request'][0] export const assertHttpRequestEmittedEvent = ( event: HttpRequestEmitterEvent ) => { - const body = event.body + const body = JSON.parse(event.body) expect(Array.isArray(body.batch)).toBeTruthy() expect(body.batch.length).toBe(1) expect(typeof event.headers).toBe('object') diff --git a/packages/node/src/__tests__/test-helpers/assert-shape/segment-http-api.ts b/packages/node/src/__tests__/test-helpers/assert-shape/segment-http-api.ts index 4c6a02743..f3540679c 100644 --- a/packages/node/src/__tests__/test-helpers/assert-shape/segment-http-api.ts +++ b/packages/node/src/__tests__/test-helpers/assert-shape/segment-http-api.ts @@ -6,7 +6,9 @@ import { HTTPClientRequest } from '../../../lib/http-client' */ export const httpClientOptionsBodyMatcher = { messageId: expect.stringMatching(/^node-next-\d*-\w*-\w*-\w*-\w*-\w*/), - timestamp: expect.any(Date), + timestamp: expect.stringMatching( + /^20\d{2}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d+Z/ + ), _metadata: expect.any(Object), context: { library: { @@ -18,23 +20,20 @@ export const httpClientOptionsBodyMatcher = { } export function assertHTTPRequestOptions( - { data, headers, method, url }: HTTPClientRequest, + { body, headers, method, url }: HTTPClientRequest, contexts: Context[] ) { expect(url).toBe('https://api.segment.io/v1/batch') expect(method).toBe('POST') - expect(headers).toMatchInlineSnapshot(` - Object { - "Authorization": "Basic Og==", - "Content-Type": "application/json", - "User-Agent": "analytics-node-next/latest", - } - `) + expect(headers).toEqual({ + 'Content-Type': 'application/json', + 'User-Agent': 'analytics-node-next/latest', + }) - expect(data.batch).toHaveLength(contexts.length) + expect(JSON.parse(body).batch).toHaveLength(contexts.length) let idx = 0 for (const context of contexts) { - expect(data.batch[idx]).toEqual({ + expect(JSON.parse(body).batch[idx]).toEqual({ ...context.event, ...httpClientOptionsBodyMatcher, }) diff --git a/packages/node/src/__tests__/test-helpers/create-test-analytics.ts b/packages/node/src/__tests__/test-helpers/create-test-analytics.ts index 6d91535a2..df15e038b 100644 --- a/packages/node/src/__tests__/test-helpers/create-test-analytics.ts +++ b/packages/node/src/__tests__/test-helpers/create-test-analytics.ts @@ -1,6 +1,9 @@ -import { Analytics } from '../../app/analytics-node' -import { AnalyticsSettings } from '../../app/settings' -import { FetchHTTPClient, HTTPFetchFn } from '../../lib/http-client' +import { + Analytics, + AnalyticsSettings, + FetchHTTPClient, + HTTPFetchFn, +} from '../../index' import { createError, createSuccess } from './factories' export const createTestAnalytics = ( diff --git a/packages/node/src/__tests__/test-helpers/factories.ts b/packages/node/src/__tests__/test-helpers/factories.ts index b2c670bf0..5d9cb761e 100644 --- a/packages/node/src/__tests__/test-helpers/factories.ts +++ b/packages/node/src/__tests__/test-helpers/factories.ts @@ -1,6 +1,7 @@ export const createSuccess = (body?: any) => { return Promise.resolve({ json: () => Promise.resolve(body), + text: () => Promise.resolve(JSON.stringify(body)), ok: true, status: 200, statusText: 'OK', diff --git a/packages/node/src/__tests__/typedef-tests.ts b/packages/node/src/__tests__/typedef-tests.ts index 2f8e626f6..f847c26c8 100644 --- a/packages/node/src/__tests__/typedef-tests.ts +++ b/packages/node/src/__tests__/typedef-tests.ts @@ -110,7 +110,7 @@ export default { return axios({ url: options.url, method: options.method, - data: options.data, + data: options.body, headers: options.headers, timeout: options.httpRequestTimeout, }) diff --git a/packages/node/src/app/analytics-node.ts b/packages/node/src/app/analytics-node.ts index 35edf8ffb..a5ceb8ab0 100644 --- a/packages/node/src/app/analytics-node.ts +++ b/packages/node/src/app/analytics-node.ts @@ -13,6 +13,8 @@ import { TrackParams, Plugin, SegmentEvent, + FlushParams, + CloseAndFlushParams, } from './types' import { Context } from './context' import { NodeEventQueue } from './event-queue' @@ -27,6 +29,8 @@ export class Analytics extends NodeEmitter implements CoreAnalytics { typeof createConfiguredNodePlugin >['publisher'] + private _isFlushing = false + private readonly _queue: NodeEventQueue ready: Promise @@ -48,7 +52,7 @@ export class Analytics extends NodeEmitter implements CoreAnalytics { host: settings.host, path: settings.path, maxRetries: settings.maxRetries ?? 3, - maxEventsInBatch: settings.maxEventsInBatch ?? 15, + flushAt: settings.flushAt ?? settings.maxEventsInBatch ?? 15, httpRequestTimeout: settings.httpRequestTimeout, disable: settings.disable, flushInterval, @@ -56,10 +60,12 @@ export class Analytics extends NodeEmitter implements CoreAnalytics { typeof settings.httpClient === 'function' ? new FetchHTTPClient(settings.httpClient) : settings.httpClient ?? new FetchHTTPClient(), + oauthSettings: settings.oauthSettings, }, this as NodeEmitter ) this._publisher = publisher + this.ready = this.register(plugin).then(() => undefined) this.emit('initialize', settings) @@ -78,18 +84,42 @@ export class Analytics extends NodeEmitter implements CoreAnalytics { */ public closeAndFlush({ timeout = this._closeAndFlushDefaultTimeout, - }: { - /** Set a maximum time permitted to wait before resolving. */ - timeout?: number - } = {}): Promise { - this._publisher.flushAfterClose(this._pendingEvents) - this._isClosed = true + }: CloseAndFlushParams = {}): Promise { + return this.flush({ timeout, close: true }) + } + + /** + * Call this method to flush all existing events.. + * This method also waits for any event method-specific callbacks to be triggered, + * and any of their subsequent promises to be resolved/rejected. + */ + public async flush({ + timeout, + close = false, + }: FlushParams = {}): Promise { + if (this._isFlushing) { + // if we're already flushing, then we don't need to do anything + console.warn( + 'Overlapping flush calls detected. Please wait for the previous flush to finish before calling .flush again' + ) + return + } else { + this._isFlushing = true + } + if (close) { + this._isClosed = true + } + this._publisher.flush(this._pendingEvents) const promise = new Promise((resolve) => { if (!this._pendingEvents) { resolve() } else { - this.once('drained', () => resolve()) + this.once('drained', () => { + resolve() + }) } + }).finally(() => { + this._isFlushing = false }) return timeout ? pTimeout(promise, timeout).catch(() => undefined) : promise } @@ -118,13 +148,21 @@ export class Analytics extends NodeEmitter implements CoreAnalytics { * @link https://segment.com/docs/connections/sources/catalog/libraries/server/node/#alias */ alias( - { userId, previousId, context, timestamp, integrations }: AliasParams, + { + userId, + previousId, + context, + timestamp, + integrations, + messageId, + }: AliasParams, callback?: Callback ): void { const segmentEvent = this._eventFactory.alias(userId, previousId, { context, integrations, timestamp, + messageId, }) this._dispatch(segmentEvent, callback) } @@ -142,6 +180,7 @@ export class Analytics extends NodeEmitter implements CoreAnalytics { traits = {}, context, integrations, + messageId, }: GroupParams, callback?: Callback ): void { @@ -151,6 +190,7 @@ export class Analytics extends NodeEmitter implements CoreAnalytics { userId, timestamp, integrations, + messageId, }) this._dispatch(segmentEvent, callback) @@ -168,6 +208,7 @@ export class Analytics extends NodeEmitter implements CoreAnalytics { context, timestamp, integrations, + messageId, }: IdentifyParams, callback?: Callback ): void { @@ -177,6 +218,7 @@ export class Analytics extends NodeEmitter implements CoreAnalytics { userId, timestamp, integrations, + messageId, }) this._dispatch(segmentEvent, callback) } @@ -195,6 +237,7 @@ export class Analytics extends NodeEmitter implements CoreAnalytics { context, timestamp, integrations, + messageId, }: PageParams, callback?: Callback ): void { @@ -202,7 +245,7 @@ export class Analytics extends NodeEmitter implements CoreAnalytics { category ?? null, name ?? null, properties, - { context, anonymousId, userId, timestamp, integrations } + { context, anonymousId, userId, timestamp, integrations, messageId } ) this._dispatch(segmentEvent, callback) } @@ -223,6 +266,7 @@ export class Analytics extends NodeEmitter implements CoreAnalytics { context, timestamp, integrations, + messageId, }: PageParams, callback?: Callback ): void { @@ -230,7 +274,7 @@ export class Analytics extends NodeEmitter implements CoreAnalytics { category ?? null, name ?? null, properties, - { context, anonymousId, userId, timestamp, integrations } + { context, anonymousId, userId, timestamp, integrations, messageId } ) this._dispatch(segmentEvent, callback) @@ -249,6 +293,7 @@ export class Analytics extends NodeEmitter implements CoreAnalytics { context, timestamp, integrations, + messageId, }: TrackParams, callback?: Callback ): void { @@ -258,6 +303,7 @@ export class Analytics extends NodeEmitter implements CoreAnalytics { anonymousId, timestamp, integrations, + messageId, }) this._dispatch(segmentEvent, callback) diff --git a/packages/node/src/app/emitter.ts b/packages/node/src/app/emitter.ts index b1d9abf2f..0a577c1c5 100644 --- a/packages/node/src/app/emitter.ts +++ b/packages/node/src/app/emitter.ts @@ -1,4 +1,5 @@ -import { CoreEmitterContract, Emitter } from '@segment/analytics-core' +import type { CoreEmitterContract } from '@segment/analytics-core' +import { Emitter } from '@segment/analytics-generic-utils' import { Context } from './context' import type { AnalyticsSettings } from './settings' import { SegmentEvent } from './types' @@ -14,7 +15,7 @@ export type NodeEmitterEvents = CoreEmitterContract & { url: string method: string headers: Record - body: Record + body: string } ] drained: [] diff --git a/packages/node/src/app/settings.ts b/packages/node/src/app/settings.ts index 0f2ee02f6..0de577b4f 100644 --- a/packages/node/src/app/settings.ts +++ b/packages/node/src/app/settings.ts @@ -1,5 +1,6 @@ import { ValidationError } from '@segment/analytics-core' import { HTTPClient, HTTPFetchFn } from '../lib/http-client' +import { OAuthSettings } from '../lib/types' export interface AnalyticsSettings { /** @@ -19,7 +20,12 @@ export interface AnalyticsSettings { */ maxRetries?: number /** - * The number of messages to enqueue before flushing. Default: 15 + * The number of events to enqueue before flushing. Default: 15. + */ + flushAt?: number + /** + * @deprecated + * The number of events to enqueue before flushing. This is deprecated in favor of `flushAt`. Default: 15. */ maxEventsInBatch?: number /** @@ -40,6 +46,10 @@ export interface AnalyticsSettings { * Default: an HTTP client that uses globalThis.fetch, with node-fetch as a fallback. */ httpClient?: HTTPFetchFn | HTTPClient + /** + * Set up OAuth2 authentication between the client and Segment's endpoints + */ + oauthSettings?: OAuthSettings } export const validateSettings = (settings: AnalyticsSettings) => { diff --git a/packages/node/src/app/types/params.ts b/packages/node/src/app/types/params.ts index 722464c49..983be3b2b 100644 --- a/packages/node/src/app/types/params.ts +++ b/packages/node/src/app/types/params.ts @@ -30,6 +30,11 @@ export type AliasParams = { context?: ExtraContext timestamp?: Timestamp integrations?: Integrations + /** + * Override the default messageId for the purposes of deduping events. Using a uuid library is strongly encouraged. + * @link https://segment.com/docs/partners/faqs/#does-segment-de-dupe-messages + */ + messageId?: string } export type GroupParams = { @@ -43,6 +48,11 @@ export type GroupParams = { context?: ExtraContext timestamp?: Timestamp integrations?: Integrations + /** + * Override the default messageId for the purposes of deduping events. Using a uuid library is strongly encouraged. + * @link https://segment.com/docs/partners/faqs/#does-segment-de-dupe-messages + */ + messageId?: string } & IdentityOptions export type IdentifyParams = { @@ -55,6 +65,11 @@ export type IdentifyParams = { context?: ExtraContext timestamp?: Timestamp integrations?: Integrations + /** + * Override the default messageId for the purposes of deduping events. Using a uuid library is strongly encouraged. + * @link https://segment.com/docs/partners/faqs/#does-segment-de-dupe-messages + */ + messageId?: string } & IdentityOptions export type PageParams = { @@ -67,6 +82,11 @@ export type PageParams = { timestamp?: Timestamp context?: ExtraContext integrations?: Integrations + /** + * Override the default messageId for the purposes of deduping events. Using a uuid library is strongly encouraged. + * @link https://segment.com/docs/partners/faqs/#does-segment-de-dupe-messages + */ + messageId?: string } & IdentityOptions export type TrackParams = { @@ -75,4 +95,27 @@ export type TrackParams = { context?: ExtraContext timestamp?: Timestamp integrations?: Integrations + /** + * Override the default messageId for the purposes of deduping events. Using a uuid library is strongly encouraged. + * @link https://segment.com/docs/partners/faqs/#does-segment-de-dupe-messages + */ + messageId?: string } & IdentityOptions + +export type FlushParams = { + /** + * Max time in milliseconds to wait until the resulting promise resolves. + */ + timeout?: number + /** + * If true, will prevent new events from entering the pipeline. Default: false + */ + close?: boolean +} + +export type CloseAndFlushParams = { + /** + * Max time in milliseconds to wait until the resulting promise resolves. + */ + timeout?: FlushParams['timeout'] +} diff --git a/packages/node/src/generated/version.ts b/packages/node/src/generated/version.ts index 6e1322d28..b18906897 100644 --- a/packages/node/src/generated/version.ts +++ b/packages/node/src/generated/version.ts @@ -1,2 +1,2 @@ // This file is generated. -export const version = '1.1.1' +export const version = '2.1.0' diff --git a/packages/node/src/index.common.ts b/packages/node/src/index.common.ts new file mode 100644 index 000000000..55d7ac05f --- /dev/null +++ b/packages/node/src/index.common.ts @@ -0,0 +1,24 @@ +export { Analytics } from './app/analytics-node' +export { Context } from './app/context' +export { + HTTPClient, + FetchHTTPClient, + HTTPFetchRequest, + HTTPResponse, + HTTPFetchFn, + HTTPClientRequest, +} from './lib/http-client' + +export { OAuthSettings } from './lib/types' + +export type { + Plugin, + GroupTraits, + UserTraits, + TrackParams, + IdentifyParams, + AliasParams, + GroupParams, + PageParams, +} from './app/types' +export type { AnalyticsSettings } from './app/settings' diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index e1facda3a..326adb60b 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -1,26 +1,5 @@ -export { Analytics } from './app/analytics-node' -export { Context } from './app/context' -export { - HTTPClient, - FetchHTTPClient, - HTTPFetchRequest, - HTTPResponse, - HTTPFetchFn, - HTTPClientRequest, -} from './lib/http-client' - -export type { - Plugin, - GroupTraits, - UserTraits, - TrackParams, - IdentifyParams, - AliasParams, - GroupParams, - PageParams, -} from './app/types' -export type { AnalyticsSettings } from './app/settings' +export * from './index.common' // export Analytics as both a named export and a default export (for backwards-compat. reasons) -import { Analytics } from './app/analytics-node' +import { Analytics } from './index.common' export default Analytics diff --git a/packages/node/src/lib/__tests__/token-manager.test.ts b/packages/node/src/lib/__tests__/token-manager.test.ts new file mode 100644 index 000000000..4a2ba0f59 --- /dev/null +++ b/packages/node/src/lib/__tests__/token-manager.test.ts @@ -0,0 +1,142 @@ +import { sleep } from '@segment/analytics-core' +import { TestFetchClient } from '../../__tests__/test-helpers/create-test-analytics' +import { HTTPResponse } from '../http-client' +import { TokenManager, TokenManagerSettings } from '../token-manager' + +// NOTE: Fake private key for illustrative purposes only +const privateKey = `-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDVll7uJaH322IN +PQsH2aOXZJ2r1q+6hpVK1R5JV1p41PUzn8pOxyXFHWB+53dUd4B8qywKS36XQjp0 +VmhR1tQ22znQ9ZCM6y4LGeOJBjAZiFZLcGQNNrDFC0WGWTrK1ZTS2K7p5qy4fIXG +laNkMXiGGCawkgcHAdOvPTy8m1d9a6YSetYVmBP/tEYN95jPyZFIoHQfkQPBPr9W +cWPpdEBzasHV5d957akjurPpleDiD5as66UW4dkWXvS7Wu7teCLCyDApcyJKTb2Z +SXybmWjhIZuctZMAx3wT/GgW3FbkGaW5KLQgBUMzjpL0fCtMatlqckMD92ll1FuK +R+HnXu05AgMBAAECggEBAK4o2il4GDUh9zbyQo9ZIPLuwT6AZXRED3Igi3ykNQp4 +I6S/s9g+vQaY6LkyBnSiqOt/K/8NBiFSiJWaa5/n+8zrP56qzf6KOlYk+wsdN5Vq +PWtwLrUzljpl8YAWPEFunNa8hwwE42vfZbnDBKNLT4qQIOQzfnVxQOoQlfj49gM2 +iSrblvsnQTyucFy3UyTeioHbh8q2Xqcxry5WUCOrFDd3IIwATTpLZGw0IPeuFJbJ +NfBizLEcyJaM9hujQU8PRCRd16MWX+bbYM6Mh4dkT40QXWsVnHBHwsgPdQgDgseF +Na4ajtHoC0DlwYCXpCm3IzJfKfq/LR2q8NDUgKeF4AECgYEA9nD4czza3SRbzhpZ +bBoK77CSNqCcMAqyuHB0hp/XX3yB7flF9PIPb2ReO8wwmjbxn+bm7PPz2Uwd2SzO +pU+FXmyKJr53Jxw/hmDWZCoh42gsGDlVqpmytzsj74KlaYiMyZmEGbD7t/FGfNGV +LdLDJaHIYxEimFviOTXKCeKvPAECgYEA3d8tv4jdp1uAuRZiU9Z/tfw5mJOi3oXF +8AdFFDwaPzcTorEAxjrt9X6IjPbLIDJNJtuXYpe+dG6720KyuNnhLhWW9oZEJTwT +dUgqZ2fTCOS9uH0jSn+ZFlgTWI6UDQXRwE7z8avlhMIrQVmPsttGTo7V6sQVtGRx +bNj2RSVekTkCgYAJvy4UYLPHS0jWPfSLcfw8vp8JyhBjVgj7gncZW/kIrcP1xYYe +yfQSU8XmV40UjFfCGz/G318lmP0VOdByeVKtCV3talsMEPHyPqI8E+6DL/uOebYJ +qUqINK6XKnOgWOY4kvnGillqTQCcry1XQp61PlDOmj7kB75KxPXYrj6AAQKBgQDa ++ixCv6hURuEyy77cE/YT/Q4zYnL6wHjtP5+UKwWUop1EkwG6o+q7wtiul90+t6ah +1VUCP9X/QFM0Qg32l0PBohlO0pFrVnG17TW8vSHxwyDkds1f97N19BOT8ZR5jebI +sKPfP9LVRnY+l1BWLEilvB+xBzqMwh2YWkIlWI6PMQKBgGi6TBnxp81lOYrxVRDj +/3ycRnVDmBdlQKFunvfzUBmG1mG/G0YHeVSUKZJGX7w2l+jnDwIA383FcUeA8X6A +l9q+amhtkwD/6fbkAu/xoWNl+11IFoxd88y2ByBFoEKB6UVLuCTSKwXDqzEZet7x +mDyRxq7ohIzLkw8b8buDeuXZ +-----END PRIVATE KEY-----` + +const testClient = new TestFetchClient() +const fetcher = jest.spyOn(testClient, 'makeRequest') + +const createOAuthSuccess = async (body?: any): Promise => ({ + text: async () => JSON.stringify(body), + status: 200, + statusText: 'OK', +}) + +const createOAuthError = async ( + overrides: Partial = {} +): Promise => ({ + text: async () => '', + status: 400, + statusText: 'Foo', + ...overrides, +}) + +const getTokenManager = () => { + const oauthSettings: TokenManagerSettings = { + httpClient: testClient, + maxRetries: 3, + clientId: 'clientId', + clientKey: privateKey, + keyId: 'keyId', + scope: 'scope', + authServer: 'http://127.0.0.1:1234', + } + + return new TokenManager(oauthSettings) +} + +test( + 'OAuth Success', + async () => { + fetcher.mockReturnValueOnce( + createOAuthSuccess({ access_token: 'token', expires_in: 100 }) + ) + + const tokenManager = getTokenManager() + const token = await tokenManager.getAccessToken() + tokenManager.stopPoller() + + expect(tokenManager.isValidToken(token)).toBeTruthy() + expect(token.access_token).toBe('token') + expect(token.expires_in).toBe(100) + expect(fetcher).toHaveBeenCalledTimes(1) + }, + 30 * 1000 +) + +test('OAuth retry failure', async () => { + fetcher.mockReturnValue(createOAuthError({ status: 425 })) + + const tokenManager = getTokenManager() + + await expect(tokenManager.getAccessToken()).rejects.toThrowError('Foo') + tokenManager.stopPoller() + + expect(fetcher).toHaveBeenCalledTimes(3) +}) + +test('OAuth immediate failure', async () => { + fetcher.mockReturnValue(createOAuthError({ status: 400 })) + + const tokenManager = getTokenManager() + + await expect(tokenManager.getAccessToken()).rejects.toThrowError('Foo') + tokenManager.stopPoller() + + expect(fetcher).toHaveBeenCalledTimes(1) +}) + +test('OAuth rate limit', async () => { + fetcher + .mockReturnValueOnce( + createOAuthError({ + status: 429, + headers: { 'X-RateLimit-Reset': Date.now() + 250 }, + }) + ) + .mockReturnValueOnce( + createOAuthError({ + status: 429, + headers: { 'X-RateLimit-Reset': Date.now() + 500 }, + }) + ) + .mockReturnValue( + createOAuthSuccess({ access_token: 'token', expires_in: 100 }) + ) + + const tokenManager = getTokenManager() + + const tokenPromise = tokenManager.getAccessToken() + await sleep(25) + expect(fetcher).toHaveBeenCalledTimes(1) + await sleep(250) + expect(fetcher).toHaveBeenCalledTimes(2) + await sleep(250) + expect(fetcher).toHaveBeenCalledTimes(3) + + const token = await tokenPromise + expect(tokenManager.isValidToken(token)).toBeTruthy() + expect(token.access_token).toBe('token') + expect(token.expires_in).toBe(100) + expect(fetcher).toHaveBeenCalledTimes(3) +}) diff --git a/packages/node/src/lib/abort.ts b/packages/node/src/lib/abort.ts index dae0dd341..4e3a17f38 100644 --- a/packages/node/src/lib/abort.ts +++ b/packages/node/src/lib/abort.ts @@ -1,7 +1,7 @@ /** * use non-native event emitter for the benefit of non-node runtimes like CF workers. */ -import { Emitter } from '@segment/analytics-core' +import { Emitter } from '@segment/analytics-generic-utils' import { detectRuntime } from './env' /** @@ -41,7 +41,7 @@ export class AbortSignal { * This polyfill is only neccessary to support versions of node < 14.17. * Can be removed once node 14 support is dropped. */ -class AbortController { +export class AbortController { signal = new AbortSignal() abort() { if (this.signal.aborted) return diff --git a/packages/node/src/lib/extract-promise-parts.ts b/packages/node/src/lib/extract-promise-parts.ts deleted file mode 100644 index eb0a053e1..000000000 --- a/packages/node/src/lib/extract-promise-parts.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Returns a promise and its associated `resolve` and `reject` methods. - */ -export function extractPromiseParts(): { - promise: Promise - resolve: (value: T) => void - reject: (reason?: unknown) => void -} { - let resolver: (value: T) => void - let rejecter: (reason?: unknown) => void - const promise = new Promise((resolve, reject) => { - resolver = resolve - rejecter = reject - }) - - return { - promise, - resolve: resolver!, - reject: rejecter!, - } -} diff --git a/packages/node/src/lib/http-client.ts b/packages/node/src/lib/http-client.ts index 93c1c5a96..316b0936f 100644 --- a/packages/node/src/lib/http-client.ts +++ b/packages/node/src/lib/http-client.ts @@ -21,11 +21,23 @@ export interface HTTPFetchRequest { signal: any // AbortSignal type does not play nicely with node-fetch } +/** + * This interface is meant to be compatible with the Headers interface. + * @link https://developer.mozilla.org/en-US/docs/Web/API/Headers + */ +export interface HTTPHeaders { + get: (key: string) => string | null + has: (key: string) => boolean + entries: () => IterableIterator<[string, any]> +} + /** * This interface is meant to very minimally conform to the Response interface. * @link https://developer.mozilla.org/en-US/docs/Web/API/Response */ export interface HTTPResponse { + headers?: Record | HTTPHeaders + text?: () => Promise status: number statusText: string } @@ -49,10 +61,9 @@ export interface HTTPClientRequest { */ headers: Record /** - * JSON data to be sent with the request (will be stringified) - * @example { "batch": [ ... ]} + * Data to be sent with the request */ - data: Record + body: string /** * Specifies the timeout (in milliseconds) for an HTTP client to get an HTTP response from the server * @example 10000 @@ -84,7 +95,7 @@ export class FetchHTTPClient implements HTTPClient { url: options.url, method: options.method, headers: options.headers, - body: JSON.stringify(options.data), + body: options.body, signal: signal, } diff --git a/packages/node/src/lib/token-manager.ts b/packages/node/src/lib/token-manager.ts new file mode 100644 index 000000000..19d4ec841 --- /dev/null +++ b/packages/node/src/lib/token-manager.ts @@ -0,0 +1,327 @@ +import { uuid } from './uuid' +import { HTTPClient, HTTPClientRequest, HTTPResponse } from './http-client' +import { SignJWT, importPKCS8 } from 'jose' +import { backoff, sleep } from '@segment/analytics-core' +import { Emitter } from '@segment/analytics-generic-utils' +import type { + AccessToken, + OAuthSettings, + TokenManager as ITokenManager, +} from './types' + +const isAccessToken = (thing: unknown): thing is AccessToken => { + return Boolean( + thing && + typeof thing === 'object' && + 'access_token' in thing && + 'expires_in' in thing && + typeof thing.access_token === 'string' && + typeof thing.expires_in === 'number' + ) +} + +const isValidCustomResponse = ( + response: HTTPResponse +): response is HTTPResponse & Required> => { + return typeof response.text === 'function' +} + +function convertHeaders( + headers: HTTPResponse['headers'] +): Record { + const lowercaseHeaders: Record = {} + if (!headers) return {} + if (isHeaders(headers)) { + for (const [name, value] of headers.entries()) { + lowercaseHeaders[name.toLowerCase()] = value + } + return lowercaseHeaders + } + for (const [name, value] of Object.entries(headers)) { + lowercaseHeaders[name.toLowerCase()] = value as string + } + return lowercaseHeaders +} + +function isHeaders(thing: unknown): thing is HTTPResponse['headers'] { + if ( + typeof thing === 'object' && + thing !== null && + 'entries' in Object(thing) && + typeof Object(thing).entries === 'function' + ) { + return true + } + return false +} + +export interface TokenManagerSettings extends OAuthSettings { + httpClient: HTTPClient + maxRetries: number +} + +export class TokenManager implements ITokenManager { + private alg = 'RS256' as const + private grantType = 'client_credentials' as const + private clientAssertionType = + 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' as const + private clientId: string + private clientKey: string + private keyId: string + private scope: string + private authServer: string + private httpClient: HTTPClient + private maxRetries: number + private clockSkewInSeconds = 0 + + private accessToken?: AccessToken + private tokenEmitter = new Emitter<{ + access_token: [{ token: AccessToken } | { error: unknown }] + }>() + private retryCount: number + private pollerTimer?: ReturnType + + constructor(props: TokenManagerSettings) { + this.keyId = props.keyId + this.clientId = props.clientId + this.clientKey = props.clientKey + this.authServer = props.authServer ?? 'https://oauth2.segment.io' + this.scope = props.scope ?? 'tracking_api:write' + this.httpClient = props.httpClient + this.maxRetries = props.maxRetries + this.tokenEmitter.on('access_token', (event) => { + if ('token' in event) { + this.accessToken = event.token + } + }) + this.retryCount = 0 + } + + stopPoller() { + clearTimeout(this.pollerTimer) + } + + async pollerLoop() { + let timeUntilRefreshInMs = 25 + let response: HTTPResponse + + try { + response = await this.requestAccessToken() + } catch (err) { + // Error without a status code - likely networking, retry + return this.handleTransientError({ error: err }) + } + + if (!isValidCustomResponse(response)) { + return this.handleInvalidCustomResponse() + } + + const headers = convertHeaders(response.headers) + if (headers['date']) { + this.updateClockSkew(Date.parse(headers['date'])) + } + + // Handle status codes! + if (response.status === 200) { + try { + const body = await response.text() + const token = JSON.parse(body) + + if (!isAccessToken(token)) { + throw new Error( + 'Response did not contain a valid access_token and expires_in' + ) + } + + // Success, we have a token! + token.expires_at = Math.round(Date.now() / 1000) + token.expires_in + this.tokenEmitter.emit('access_token', { token }) + + // Reset our failure count + this.retryCount = 0 + // Refresh the token after half the expiry time passes + timeUntilRefreshInMs = (token.expires_in / 2) * 1000 + return this.queueNextPoll(timeUntilRefreshInMs) + } catch (err) { + // Something went really wrong with the body, lets surface an error and try again? + return this.handleTransientError({ error: err, forceEmitError: true }) + } + } else if (response.status === 429) { + // Rate limited, wait for the reset time + return await this.handleRateLimited( + response, + headers, + timeUntilRefreshInMs + ) + } else if ([400, 401, 415].includes(response.status)) { + // Unrecoverable errors, stops the poller + return this.handleUnrecoverableErrors(response) + } else { + return this.handleTransientError({ + error: new Error(`[${response.status}] ${response.statusText}`), + }) + } + } + + private handleTransientError({ + error, + forceEmitError, + }: { + error: unknown + forceEmitError?: boolean + }) { + this.incrementRetries({ error, forceEmitError }) + + const timeUntilRefreshInMs = backoff({ + attempt: this.retryCount, + minTimeout: 25, + maxTimeout: 1000, + }) + this.queueNextPoll(timeUntilRefreshInMs) + } + + private handleInvalidCustomResponse() { + this.tokenEmitter.emit('access_token', { + error: new Error('HTTPClient does not implement response.text method'), + }) + } + + private async handleRateLimited( + response: HTTPResponse, + headers: Record, + timeUntilRefreshInMs: number + ) { + this.incrementRetries({ + error: new Error(`[${response.status}] ${response.statusText}`), + }) + + if (headers['x-ratelimit-reset']) { + const rateLimitResetTimestamp = parseInt(headers['x-ratelimit-reset'], 10) + if (isFinite(rateLimitResetTimestamp)) { + timeUntilRefreshInMs = + rateLimitResetTimestamp - Date.now() + this.clockSkewInSeconds * 1000 + } else { + timeUntilRefreshInMs = 5 * 1000 + } + // We want subsequent calls to get_token to be able to interrupt our + // Timeout when it's waiting for e.g. a long normal expiration, but + // not when we're waiting for a rate limit reset. Sleep instead. + await sleep(timeUntilRefreshInMs) + timeUntilRefreshInMs = 0 + } + + this.queueNextPoll(timeUntilRefreshInMs) + } + + private handleUnrecoverableErrors(response: HTTPResponse) { + this.retryCount = 0 + this.tokenEmitter.emit('access_token', { + error: new Error(`[${response.status}] ${response.statusText}`), + }) + this.stopPoller() + } + + private updateClockSkew(dateInMs: number) { + this.clockSkewInSeconds = (Date.now() - dateInMs) / 1000 + } + + private incrementRetries({ + error, + forceEmitError, + }: { + error: unknown + forceEmitError?: boolean + }) { + this.retryCount++ + if (forceEmitError || this.retryCount % this.maxRetries === 0) { + this.retryCount = 0 + this.tokenEmitter.emit('access_token', { error: error }) + } + } + + private queueNextPoll(timeUntilRefreshInMs: number) { + this.pollerTimer = setTimeout(() => this.pollerLoop(), timeUntilRefreshInMs) + if (this.pollerTimer.unref) { + this.pollerTimer.unref() + } + } + + /** + * Solely responsible for building the HTTP request and calling the token service. + */ + private async requestAccessToken(): Promise { + // Set issued at time to 5 seconds in the past to account for clock skew + const ISSUED_AT_BUFFER_IN_SECONDS = 5 + const MAX_EXPIRY_IN_SECONDS = 60 + // Final expiry time takes into account the issued at time, so need to subtract IAT buffer + const EXPIRY_IN_SECONDS = + MAX_EXPIRY_IN_SECONDS - ISSUED_AT_BUFFER_IN_SECONDS + const jti = uuid() + const currentUTCInSeconds = + Math.round(Date.now() / 1000) - this.clockSkewInSeconds + const jwtBody = { + iss: this.clientId, + sub: this.clientId, + aud: this.authServer, + iat: currentUTCInSeconds - ISSUED_AT_BUFFER_IN_SECONDS, + exp: currentUTCInSeconds + EXPIRY_IN_SECONDS, + jti, + } + + const key = await importPKCS8(this.clientKey, 'RS256') + const signedJwt = await new SignJWT(jwtBody) + .setProtectedHeader({ alg: this.alg, kid: this.keyId, typ: 'JWT' }) + .sign(key) + + const requestBody = `grant_type=${this.grantType}&client_assertion_type=${this.clientAssertionType}&client_assertion=${signedJwt}&scope=${this.scope}` + const accessTokenEndpoint = `${this.authServer}/token` + + const requestOptions: HTTPClientRequest = { + method: 'POST', + url: accessTokenEndpoint, + body: requestBody, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + httpRequestTimeout: 10000, + } + return this.httpClient.makeRequest(requestOptions) + } + + async getAccessToken(): Promise { + // Use the cached token if it is still valid, otherwise wait for a new token. + if (this.isValidToken(this.accessToken)) { + return this.accessToken + } + + // stop poller first in order to make sure that it's not sleeping if we need a token immediately + // Otherwise it could be hours before the expiration time passes normally + this.stopPoller() + + // startPoller needs to be called somewhere, either lazily when a token is first requested, or at instantiation. + // Doing it lazily currently + this.pollerLoop().catch(() => {}) + + return new Promise((resolve, reject) => { + this.tokenEmitter.once('access_token', (event) => { + if ('token' in event) { + resolve(event.token) + } else { + reject(event.error) + } + }) + }) + } + + clearToken() { + this.accessToken = undefined + } + + isValidToken(token?: AccessToken): token is AccessToken { + return ( + typeof token !== 'undefined' && + token !== null && + token.expires_in < Date.now() / 1000 + ) + } +} diff --git a/packages/node/src/lib/types.ts b/packages/node/src/lib/types.ts new file mode 100644 index 000000000..110d05480 --- /dev/null +++ b/packages/node/src/lib/types.ts @@ -0,0 +1,58 @@ +import { HTTPClient } from './http-client' +import { TokenManagerSettings } from './token-manager' + +export interface OAuthSettings { + /** + * The OAuth App ID from Access Management under Workspace Settings in the Segment Dashboard. + */ + clientId: string + /** + * The private key that matches the public key set in the OAuth app in the Segment Dashboard. + */ + clientKey: string + /** + * The ID for the matching public key as given in the Segment Dashboard after it is uploaded. + */ + keyId: string + /** + * The Authorization server. Defaults to https://oauth2.segment.io + * If your TAPI endpoint is not https://api.segment.io you will need to set this value. + * e.g. https://oauth2.eu1.segmentapis.com/ for TAPI endpoint https://events.eu1.segmentapis.com/ + */ + authServer?: string + /** + * The scope of permissions. Defaults to `tracking_api:write`. + * Must match a scope from the OAuth app settings in the Segment Dashboard. + */ + scope?: string + /** + * Custom number of retries before a recoverable error is reported. + * Defaults to the custom value set in the Analytics settings, or 3 if unset + */ + maxRetries?: number + /** + * Custom HTTP Client implementation. + * Defaults to the custom value set in the Analytics settings, or uses the default fetch client. + * Note: This would only be need to be set in a complex environment that may have different access + * rules for the TAPI and Auth endpoints. + */ + httpClient?: HTTPClient +} + +export type AccessToken = { + access_token: string + expires_in: number + expires_at?: number +} + +export interface TokenManager { + pollerLoop(): Promise + stopPoller(): void + getAccessToken(): Promise + clearToken(): void + isValidToken(token?: AccessToken): token is AccessToken +} + +export interface TokenManagerConstructor { + new (settings: TokenManagerSettings): TokenManager +} diff --git a/packages/node/src/plugins/segmentio/__tests__/methods.test.ts b/packages/node/src/plugins/segmentio/__tests__/methods.test.ts index f1ed369b3..2818318bc 100644 --- a/packages/node/src/plugins/segmentio/__tests__/methods.test.ts +++ b/packages/node/src/plugins/segmentio/__tests__/methods.test.ts @@ -3,7 +3,7 @@ import { createSuccess } from '../../../__tests__/test-helpers/factories' import { createConfiguredNodePlugin } from '../index' import { PublisherProps } from '../publisher' import { Context } from '../../../app/context' -import { Emitter } from '@segment/analytics-core' +import { Emitter } from '@segment/analytics-generic-utils' import { assertHTTPRequestOptions, httpClientOptionsBodyMatcher, @@ -12,13 +12,14 @@ import { TestFetchClient } from '../../../__tests__/test-helpers/create-test-ana let emitter: Emitter const testClient = new TestFetchClient() -const fetcher = jest.spyOn(testClient, 'makeRequest') +const makeReqSpy = jest.spyOn(testClient, 'makeRequest') +const getLastRequest = () => makeReqSpy.mock.lastCall![0] const createTestNodePlugin = (props: Partial = {}) => createConfiguredNodePlugin( { maxRetries: 3, - maxEventsInBatch: 1, + flushAt: 1, flushInterval: 1000, writeKey: '', httpClient: testClient, @@ -27,16 +28,15 @@ const createTestNodePlugin = (props: Partial = {}) => emitter ) -const validateFetcherInputs = (...contexts: Context[]) => { - const [request] = fetcher.mock.lastCall - return assertHTTPRequestOptions(request, contexts) +const validateMakeReqInputs = (...contexts: Context[]) => { + return assertHTTPRequestOptions(getLastRequest(), contexts) } const eventFactory = new NodeEventFactory() beforeEach(() => { emitter = new Emitter() - fetcher.mockReturnValue(createSuccess()) + makeReqSpy.mockReturnValue(createSuccess()) jest.useFakeTimers() }) @@ -46,14 +46,13 @@ test('alias', async () => { const event = eventFactory.alias('to', 'from') const context = new Context(event) - fetcher.mockReturnValueOnce(createSuccess()) + makeReqSpy.mockReturnValueOnce(createSuccess()) await segmentPlugin.alias(context) - expect(fetcher).toHaveBeenCalledTimes(1) - validateFetcherInputs(context) + expect(makeReqSpy).toHaveBeenCalledTimes(1) + validateMakeReqInputs(context) - const [request] = fetcher.mock.lastCall - const data = request.data + const data = JSON.parse(getLastRequest().body) expect(data.batch).toHaveLength(1) expect(data.batch[0]).toEqual({ @@ -76,14 +75,13 @@ test('group', async () => { ) const context = new Context(event) - fetcher.mockReturnValueOnce(createSuccess()) + makeReqSpy.mockReturnValueOnce(createSuccess()) await segmentPlugin.group(context) - expect(fetcher).toHaveBeenCalledTimes(1) - validateFetcherInputs(context) + expect(makeReqSpy).toHaveBeenCalledTimes(1) + validateMakeReqInputs(context) - const [request] = fetcher.mock.lastCall - const data = request.data + const data = JSON.parse(getLastRequest().body) expect(data.batch).toHaveLength(1) expect(data.batch[0]).toEqual({ @@ -105,14 +103,14 @@ test('identify', async () => { }) const context = new Context(event) - fetcher.mockReturnValueOnce(createSuccess()) + makeReqSpy.mockReturnValueOnce(createSuccess()) await segmentPlugin.identify(context) - expect(fetcher).toHaveBeenCalledTimes(1) - validateFetcherInputs(context) + expect(makeReqSpy).toHaveBeenCalledTimes(1) + validateMakeReqInputs(context) + + const data = JSON.parse(getLastRequest().body) - const [request] = fetcher.mock.lastCall - const data = request.data expect(data.batch).toHaveLength(1) expect(data.batch[0]).toEqual({ ...httpClientOptionsBodyMatcher, @@ -135,14 +133,13 @@ test('page', async () => { ) const context = new Context(event) - fetcher.mockReturnValueOnce(createSuccess()) + makeReqSpy.mockReturnValueOnce(createSuccess()) await segmentPlugin.page(context) - expect(fetcher).toHaveBeenCalledTimes(1) - validateFetcherInputs(context) + expect(makeReqSpy).toHaveBeenCalledTimes(1) + validateMakeReqInputs(context) - const [request] = fetcher.mock.lastCall - const data = request.data + const data = JSON.parse(getLastRequest().body) expect(data.batch).toHaveLength(1) expect(data.batch[0]).toEqual({ @@ -169,14 +166,13 @@ test('screen', async () => { ) const context = new Context(event) - fetcher.mockReturnValueOnce(createSuccess()) + makeReqSpy.mockReturnValueOnce(createSuccess()) await segmentPlugin.screen(context) - expect(fetcher).toHaveBeenCalledTimes(1) - validateFetcherInputs(context) + expect(makeReqSpy).toHaveBeenCalledTimes(1) + validateMakeReqInputs(context) - const [request] = fetcher.mock.lastCall - const data = request.data + const data = JSON.parse(getLastRequest().body) expect(data.batch).toHaveLength(1) expect(data.batch[0]).toEqual({ @@ -201,14 +197,13 @@ test('track', async () => { ) const context = new Context(event) - fetcher.mockReturnValueOnce(createSuccess()) + makeReqSpy.mockReturnValueOnce(createSuccess()) await segmentPlugin.screen(context) - expect(fetcher).toHaveBeenCalledTimes(1) - validateFetcherInputs(context) + expect(makeReqSpy).toHaveBeenCalledTimes(1) + validateMakeReqInputs(context) - const [request] = fetcher.mock.lastCall - const data = request.data + const data = JSON.parse(getLastRequest().body) expect(data.batch).toHaveLength(1) expect(data.batch[0]).toEqual({ diff --git a/packages/node/src/plugins/segmentio/__tests__/publisher.test.ts b/packages/node/src/plugins/segmentio/__tests__/publisher.test.ts index c95462976..fb8364bb2 100644 --- a/packages/node/src/plugins/segmentio/__tests__/publisher.test.ts +++ b/packages/node/src/plugins/segmentio/__tests__/publisher.test.ts @@ -1,4 +1,4 @@ -import { Emitter } from '@segment/analytics-core' +import { Emitter } from '@segment/analytics-generic-utils' import { range } from 'lodash' import { createConfiguredNodePlugin } from '..' import { Context } from '../../../app/context' @@ -14,12 +14,13 @@ import { assertHTTPRequestOptions } from '../../../__tests__/test-helpers/assert let emitter: Emitter const testClient = new TestFetchClient() -const fetcher = jest.spyOn(testClient, 'makeRequest') +const makeReqSpy = jest.spyOn(testClient, 'makeRequest') +const getLastRequest = () => makeReqSpy.mock.lastCall![0] const createTestNodePlugin = (props: Partial = {}) => createConfiguredNodePlugin( { - maxEventsInBatch: 1, + flushAt: 1, httpClient: testClient, writeKey: '', flushInterval: 1000, @@ -29,23 +30,22 @@ const createTestNodePlugin = (props: Partial = {}) => emitter ) -const validateFetcherInputs = (...contexts: Context[]) => { - const [request] = fetcher.mock.lastCall - return assertHTTPRequestOptions(request, contexts) +const validateMakeReqInputs = (...contexts: Context[]) => { + return assertHTTPRequestOptions(getLastRequest(), contexts) } const eventFactory = new NodeEventFactory() beforeEach(() => { emitter = new Emitter() - fetcher.mockReturnValue(createSuccess()) + makeReqSpy.mockReturnValue(createSuccess()) jest.useFakeTimers() }) it('supports multiple events in a batch', async () => { const { plugin: segmentPlugin } = createTestNodePlugin({ maxRetries: 3, - maxEventsInBatch: 3, + flushAt: 3, }) // Create 3 events of mixed types to send. @@ -63,15 +63,15 @@ it('supports multiple events in a batch', async () => { } // Expect a single fetch call for all 3 events. - expect(fetcher).toHaveBeenCalledTimes(1) + expect(makeReqSpy).toHaveBeenCalledTimes(1) - validateFetcherInputs(...contexts) + validateMakeReqInputs(...contexts) }) it('supports waiting a max amount of time before sending', async () => { const { plugin: segmentPlugin } = createTestNodePlugin({ maxRetries: 3, - maxEventsInBatch: 3, + flushAt: 3, }) const context = new Context(eventFactory.alias('to', 'from')) @@ -80,13 +80,13 @@ it('supports waiting a max amount of time before sending', async () => { jest.advanceTimersByTime(500) - expect(fetcher).not.toHaveBeenCalled() + expect(makeReqSpy).not.toHaveBeenCalled() jest.advanceTimersByTime(500) // Expect a single fetch call for all 1 events. - expect(fetcher).toHaveBeenCalledTimes(1) - validateFetcherInputs(context) + expect(makeReqSpy).toHaveBeenCalledTimes(1) + validateMakeReqInputs(context) // Make sure we're returning the context in the resolved promise. const updatedContext = await pendingContext @@ -97,7 +97,7 @@ it('supports waiting a max amount of time before sending', async () => { it('sends as soon as batch fills up or max time is reached', async () => { const { plugin: segmentPlugin } = createTestNodePlugin({ maxRetries: 3, - maxEventsInBatch: 2, + flushAt: 2, }) const context = new Context(eventFactory.alias('to', 'from')) @@ -111,14 +111,14 @@ it('sends as soon as batch fills up or max time is reached', async () => { const pendingContexts = contexts.map((ctx) => segmentPlugin.alias(ctx)) // Should have seen 1 call due to 1 batch being filled. - expect(fetcher).toHaveBeenCalledTimes(1) - validateFetcherInputs(context, context) + expect(makeReqSpy).toHaveBeenCalledTimes(1) + validateMakeReqInputs(context, context) // 2nd batch is not full, so need to wait for the flushInterval to be reached before sending. jest.advanceTimersByTime(500) - expect(fetcher).toHaveBeenCalledTimes(1) + expect(makeReqSpy).toHaveBeenCalledTimes(1) jest.advanceTimersByTime(500) - expect(fetcher).toHaveBeenCalledTimes(2) + expect(makeReqSpy).toHaveBeenCalledTimes(2) // Make sure we're returning the context in the resolved promise. const updatedContexts = await Promise.all(pendingContexts) @@ -131,7 +131,7 @@ it('sends as soon as batch fills up or max time is reached', async () => { it('sends if batch will exceed max size in bytes when adding event', async () => { const { plugin: segmentPlugin } = createTestNodePlugin({ maxRetries: 3, - maxEventsInBatch: 20, + flushAt: 20, flushInterval: 100, }) @@ -153,9 +153,9 @@ it('sends if batch will exceed max size in bytes when adding event', async () => } const pendingContexts = contexts.map((ctx) => segmentPlugin.track(ctx)) - expect(fetcher).toHaveBeenCalledTimes(1) + expect(makeReqSpy).toHaveBeenCalledTimes(1) jest.advanceTimersByTime(100) - expect(fetcher).toHaveBeenCalledTimes(2) + expect(makeReqSpy).toHaveBeenCalledTimes(2) const updatedContexts = await Promise.all(pendingContexts) for (let i = 0; i < 16; i++) { @@ -185,78 +185,76 @@ describe('flushAfterClose', () => { ) const { plugin: segmentPlugin, publisher } = createTestNodePlugin({ - maxEventsInBatch: 20, + flushAt: 20, }) - publisher.flushAfterClose(3) + publisher.flush(3) void segmentPlugin.track(_createTrackCtx()) void segmentPlugin.track(_createTrackCtx()) - expect(fetcher).toHaveBeenCalledTimes(0) + expect(makeReqSpy).toHaveBeenCalledTimes(0) void segmentPlugin.track(_createTrackCtx()) - expect(fetcher).toBeCalledTimes(1) + expect(makeReqSpy).toBeCalledTimes(1) }) it('continues to flush on each event if batch size is 1', async () => { const { plugin: segmentPlugin, publisher } = createTestNodePlugin({ - maxEventsInBatch: 1, + flushAt: 1, }) - publisher.flushAfterClose(3) + publisher.flush(3) void segmentPlugin.track(_createTrackCtx()) void segmentPlugin.track(_createTrackCtx()) void segmentPlugin.track(_createTrackCtx()) - expect(fetcher).toBeCalledTimes(3) + expect(makeReqSpy).toBeCalledTimes(3) }) it('sends immediately once there are no pending items, even if pending events exceeds batch size', async () => { const { plugin: segmentPlugin, publisher } = createTestNodePlugin({ - maxEventsInBatch: 3, + flushAt: 3, }) - publisher.flushAfterClose(5) + publisher.flush(5) range(3).forEach(() => segmentPlugin.track(_createTrackCtx())) - expect(fetcher).toHaveBeenCalledTimes(1) + expect(makeReqSpy).toHaveBeenCalledTimes(1) range(2).forEach(() => segmentPlugin.track(_createTrackCtx())) - expect(fetcher).toHaveBeenCalledTimes(2) + expect(makeReqSpy).toHaveBeenCalledTimes(2) }) it('works if there are previous items in the batch', async () => { const { plugin: segmentPlugin, publisher } = createTestNodePlugin({ - maxEventsInBatch: 7, + flushAt: 7, }) range(3).forEach(() => segmentPlugin.track(_createTrackCtx())) // should not flush - expect(fetcher).toHaveBeenCalledTimes(0) - publisher.flushAfterClose(5) - expect(fetcher).toHaveBeenCalledTimes(0) + publisher.flush(5) range(2).forEach(() => segmentPlugin.track(_createTrackCtx())) - expect(fetcher).toHaveBeenCalledTimes(1) + expect(makeReqSpy).toHaveBeenCalledTimes(1) }) it('works if there are previous items in the batch AND pending items > batch size', async () => { const { plugin: segmentPlugin, publisher } = createTestNodePlugin({ - maxEventsInBatch: 7, + flushAt: 7, }) range(3).forEach(() => segmentPlugin.track(_createTrackCtx())) // should not flush - expect(fetcher).toHaveBeenCalledTimes(0) - publisher.flushAfterClose(10) - expect(fetcher).toHaveBeenCalledTimes(0) + expect(makeReqSpy).toHaveBeenCalledTimes(0) + publisher.flush(10) + expect(makeReqSpy).toHaveBeenCalledTimes(0) range(4).forEach(() => segmentPlugin.track(_createTrackCtx())) // batch is full, send. - expect(fetcher).toHaveBeenCalledTimes(1) + expect(makeReqSpy).toHaveBeenCalledTimes(1) range(2).forEach(() => segmentPlugin.track(_createTrackCtx())) - expect(fetcher).toBeCalledTimes(1) + expect(makeReqSpy).toBeCalledTimes(1) void segmentPlugin.track(_createTrackCtx()) // pending items limit has been reached, send. - expect(fetcher).toBeCalledTimes(2) + expect(makeReqSpy).toBeCalledTimes(2) }) }) describe('error handling', () => { it('excludes events that are too large', async () => { const { plugin: segmentPlugin } = createTestNodePlugin({ - maxEventsInBatch: 1, + flushAt: 1, }) const context = new Context( @@ -276,33 +274,33 @@ describe('error handling', () => { expect(updatedContext).toBe(context) expect(updatedContext.failedDelivery()).toBeTruthy() expect(updatedContext.failedDelivery()).toMatchInlineSnapshot(` - Object { + { "reason": [Error: Event exceeds maximum event size of 32 KB], } `) - expect(fetcher).not.toHaveBeenCalled() + expect(makeReqSpy).not.toHaveBeenCalled() }) it('does not retry 400 errors', async () => { - fetcher.mockReturnValue( + makeReqSpy.mockReturnValue( createError({ status: 400, statusText: 'Bad Request' }) ) const { plugin: segmentPlugin } = createTestNodePlugin({ - maxEventsInBatch: 1, + flushAt: 1, }) const context = new Context(eventFactory.alias('to', 'from')) const updatedContext = await segmentPlugin.alias(context) - expect(fetcher).toHaveBeenCalledTimes(1) - validateFetcherInputs(context) + expect(makeReqSpy).toHaveBeenCalledTimes(1) + validateMakeReqInputs(context) expect(updatedContext).toBe(context) expect(updatedContext.failedDelivery()).toBeTruthy() expect(updatedContext.failedDelivery()).toMatchInlineSnapshot(` - Object { + { "reason": [Error: [400] Bad Request], } `) @@ -316,11 +314,11 @@ describe('error handling', () => { // Jest kept timing out when using fake timers despite advancing time. jest.useRealTimers() - fetcher.mockReturnValue(createError(response)) + makeReqSpy.mockReturnValue(createError(response)) const { plugin: segmentPlugin } = createTestNodePlugin({ maxRetries: 2, - maxEventsInBatch: 1, + flushAt: 1, }) const context = new Context(eventFactory.alias('to', 'from')) @@ -328,8 +326,8 @@ describe('error handling', () => { const pendingContext = segmentPlugin.alias(context) const updatedContext = await pendingContext - expect(fetcher).toHaveBeenCalledTimes(3) - validateFetcherInputs(context) + expect(makeReqSpy).toHaveBeenCalledTimes(3) + validateMakeReqInputs(context) expect(updatedContext).toBe(context) expect(updatedContext.failedDelivery()).toBeTruthy() @@ -343,11 +341,11 @@ describe('error handling', () => { // Jest kept timing out when using fake timers despite advancing time. jest.useRealTimers() - fetcher.mockRejectedValue(new Error('Connection Error')) + makeReqSpy.mockRejectedValue(new Error('Connection Error')) const { plugin: segmentPlugin } = createTestNodePlugin({ maxRetries: 2, - maxEventsInBatch: 1, + flushAt: 1, }) const context = new Context(eventFactory.alias('my', 'from')) @@ -355,13 +353,13 @@ describe('error handling', () => { const pendingContext = segmentPlugin.alias(context) const updatedContext = await pendingContext - expect(fetcher).toHaveBeenCalledTimes(3) - validateFetcherInputs(context) + expect(makeReqSpy).toHaveBeenCalledTimes(3) + validateMakeReqInputs(context) expect(updatedContext).toBe(context) expect(updatedContext.failedDelivery()).toBeTruthy() expect(updatedContext.failedDelivery()).toMatchInlineSnapshot(` - Object { + { "reason": [Error: Connection Error], } `) @@ -371,10 +369,10 @@ describe('error handling', () => { // Jest kept timing out when using fake timers despite advancing time. jest.useRealTimers() - fetcher.mockRejectedValue(new Error('Connection Error')) + makeReqSpy.mockRejectedValue(new Error('Connection Error')) const { plugin: segmentPlugin } = createTestNodePlugin({ maxRetries: 0, - maxEventsInBatch: 1, + flushAt: 1, }) const fn = jest.fn() @@ -389,17 +387,17 @@ describe('error handling', () => { ) ) ) - expect(fetcher).toHaveBeenCalledTimes(1) + expect(makeReqSpy).toHaveBeenCalledTimes(1) }) }) describe('http_request emitter event', () => { it('should emit an http_request object', async () => { const { plugin: segmentPlugin } = createTestNodePlugin({ - maxEventsInBatch: 1, + flushAt: 1, }) - fetcher.mockReturnValueOnce(createSuccess()) + makeReqSpy.mockReturnValueOnce(createSuccess()) const fn = jest.fn() emitter.on('http_request', fn) const context = new Context( diff --git a/packages/node/src/plugins/segmentio/index.ts b/packages/node/src/plugins/segmentio/index.ts index 56c793b02..7ffed2feb 100644 --- a/packages/node/src/plugins/segmentio/index.ts +++ b/packages/node/src/plugins/segmentio/index.ts @@ -41,7 +41,7 @@ export function createNodePlugin(publisher: Publisher): SegmentNodePlugin { return { name: 'Segment.io', - type: 'after', + type: 'destination', version: '1.0.0', isLoaded: () => true, load: () => Promise.resolve(), diff --git a/packages/node/src/plugins/segmentio/publisher.ts b/packages/node/src/plugins/segmentio/publisher.ts index aa6f3f9f3..5c63f9ee8 100644 --- a/packages/node/src/plugins/segmentio/publisher.ts +++ b/packages/node/src/plugins/segmentio/publisher.ts @@ -1,11 +1,12 @@ import { backoff } from '@segment/analytics-core' import type { Context } from '../../app/context' import { tryCreateFormattedUrl } from '../../lib/create-url' -import { extractPromiseParts } from '../../lib/extract-promise-parts' +import { createDeferred } from '@segment/analytics-generic-utils' import { ContextBatch } from './context-batch' import { NodeEmitter } from '../../app/emitter' -import { b64encode } from '../../lib/base-64-encode' import { HTTPClient, HTTPClientRequest } from '../../lib/http-client' +import { OAuthSettings } from '../../lib/types' +import { TokenManager } from '../../lib/token-manager' function sleep(timeoutInMs: number): Promise { return new Promise((resolve) => setTimeout(resolve, timeoutInMs)) @@ -22,12 +23,13 @@ export interface PublisherProps { host?: string path?: string flushInterval: number - maxEventsInBatch: number + flushAt: number maxRetries: number writeKey: string httpRequestTimeout?: number disable?: boolean httpClient: HTTPClient + oauthSettings?: OAuthSettings } /** @@ -38,34 +40,36 @@ export class Publisher { private _batch?: ContextBatch private _flushInterval: number - private _maxEventsInBatch: number + private _flushAt: number private _maxRetries: number - private _auth: string private _url: string - private _closeAndFlushPendingItemsCount?: number + private _flushPendingItemsCount?: number private _httpRequestTimeout: number private _emitter: NodeEmitter private _disable: boolean private _httpClient: HTTPClient + private _writeKey: string + private _tokenManager: TokenManager | undefined + constructor( { host, path, maxRetries, - maxEventsInBatch, + flushAt, flushInterval, writeKey, httpRequestTimeout, httpClient, disable, + oauthSettings, }: PublisherProps, emitter: NodeEmitter ) { this._emitter = emitter this._maxRetries = maxRetries - this._maxEventsInBatch = Math.max(maxEventsInBatch, 1) + this._flushAt = Math.max(flushAt, 1) this._flushInterval = flushInterval - this._auth = b64encode(`${writeKey}:`) this._url = tryCreateFormattedUrl( host ?? 'https://api.segment.io', path ?? '/v1/batch' @@ -73,11 +77,20 @@ export class Publisher { this._httpRequestTimeout = httpRequestTimeout ?? 10000 this._disable = Boolean(disable) this._httpClient = httpClient + this._writeKey = writeKey + + if (oauthSettings) { + this._tokenManager = new TokenManager({ + ...oauthSettings, + httpClient: oauthSettings.httpClient ?? httpClient, + maxRetries: oauthSettings.maxRetries ?? maxRetries, + }) + } } private createBatch(): ContextBatch { this.pendingFlushTimeout && clearTimeout(this.pendingFlushTimeout) - const batch = new ContextBatch(this._maxEventsInBatch) + const batch = new ContextBatch(this._flushAt) this._batch = batch this.pendingFlushTimeout = setTimeout(() => { if (batch === this._batch) { @@ -96,13 +109,16 @@ export class Publisher { this._batch = undefined } - flushAfterClose(pendingItemsCount: number) { + flush(pendingItemsCount: number): void { if (!pendingItemsCount) { // if number of pending items is 0, there will never be anything else entering the batch, since the app is closed. + if (this._tokenManager) { + this._tokenManager.stopPoller() + } return } - this._closeAndFlushPendingItemsCount = pendingItemsCount + this._flushPendingItemsCount = pendingItemsCount // if batch is empty, there's nothing to flush, and when things come in, enqueue will handle them. if (!this._batch) return @@ -111,7 +127,14 @@ export class Publisher { // Any mismatch is because some globally pending items are in plugins. const isExpectingNoMoreItems = this._batch.length === pendingItemsCount if (isExpectingNoMoreItems) { - this.send(this._batch).catch(noop) + this.send(this._batch) + .catch(noop) + .finally(() => { + // stop poller so program can exit (). + if (this._tokenManager) { + this._tokenManager.stopPoller() + } + }) this.clearBatch() } } @@ -124,7 +147,7 @@ export class Publisher { enqueue(ctx: Context): Promise { const batch = this._batch ?? this.createBatch() - const { promise: ctxPromise, resolve } = extractPromiseParts() + const { promise: ctxPromise, resolve } = createDeferred() const pendingItem: PendingItem = { context: ctx, @@ -145,8 +168,8 @@ export class Publisher { const addStatus = batch.tryAdd(pendingItem) if (addStatus.success) { const isExpectingNoMoreItems = - batch.length === this._closeAndFlushPendingItemsCount - const isFull = batch.length === this._maxEventsInBatch + batch.length === this._flushPendingItemsCount + const isFull = batch.length === this._flushAt if (isFull || isExpectingNoMoreItems) { this.send(batch).catch(noop) this.clearBatch() @@ -166,7 +189,7 @@ export class Publisher { if (fbAddStatus.success) { const isExpectingNoMoreItems = - fallbackBatch.length === this._closeAndFlushPendingItemsCount + fallbackBatch.length === this._flushPendingItemsCount if (isExpectingNoMoreItems) { this.send(fallbackBatch).catch(noop) this.clearBatch() @@ -182,8 +205,8 @@ export class Publisher { } private async send(batch: ContextBatch) { - if (this._closeAndFlushPendingItemsCount) { - this._closeAndFlushPendingItemsCount -= batch.length + if (this._flushPendingItemsCount) { + this._flushPendingItemsCount -= batch.length } const events = batch.getEvents() const maxAttempts = this._maxRetries + 1 @@ -198,20 +221,34 @@ export class Publisher { return batch.resolveEvents() } + let authString = undefined + if (this._tokenManager) { + const token = await this._tokenManager.getAccessToken() + if (token && token.access_token) { + authString = `Bearer ${token.access_token}` + } + } + + const headers: Record = { + 'Content-Type': 'application/json', + 'User-Agent': 'analytics-node-next/latest', + ...(authString ? { Authorization: authString } : {}), + } + const request: HTTPClientRequest = { url: this._url, method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Basic ${this._auth}`, - 'User-Agent': 'analytics-node-next/latest', - }, - data: { batch: events, sentAt: new Date() }, + headers: headers, + body: JSON.stringify({ + batch: events, + writeKey: this._writeKey, + sentAt: new Date(), + }), httpRequestTimeout: this._httpRequestTimeout, } this._emitter.emit('http_request', { - body: request.data, + body: request.body, method: request.method, url: request.url, headers: request.headers, @@ -223,6 +260,17 @@ export class Publisher { // Successfully sent events, so exit! batch.resolveEvents() return + } else if ( + this._tokenManager && + (response.status === 400 || + response.status === 401 || + response.status === 403) + ) { + // Retry with a new OAuth token if we have OAuth data + this._tokenManager.clearToken() + failureReason = new Error( + `[${response.status}] ${response.statusText}` + ) } else if (response.status === 400) { // https://segment.com/docs/connections/sources/catalog/libraries/server/http-api/#max-request-size // Request either malformed or size exceeded - don't retry. diff --git a/packages/node/tsconfig.json b/packages/node/tsconfig.json index 8941846ae..e4aad78dd 100644 --- a/packages/node/tsconfig.json +++ b/packages/node/tsconfig.json @@ -3,8 +3,8 @@ "exclude": ["node_modules", "dist"], "compilerOptions": { "module": "ESNext", - "target": "es2020", // node 14 + "target": "es2022", // node 18 "moduleResolution": "node", - "lib": ["es2020"] // TODO: https://www.npmjs.com/package/@tsconfig/node14 + "lib": ["es2022"] // TODO: es2023 https://www.npmjs.com/package/@tsconfig/node18 } } diff --git a/playgrounds/README.md b/playgrounds/README.md new file mode 100644 index 000000000..9fa928b11 --- /dev/null +++ b/playgrounds/README.md @@ -0,0 +1,3 @@ +# Application Playground + +These applications are not meant to be vanilla examples. Please refer to the individual READMEs for more information. diff --git a/examples/with-next-js/.eslintrc.js b/playgrounds/next-playground/.eslintrc.js similarity index 100% rename from examples/with-next-js/.eslintrc.js rename to playgrounds/next-playground/.eslintrc.js diff --git a/examples/with-next-js/.gitignore b/playgrounds/next-playground/.gitignore similarity index 100% rename from examples/with-next-js/.gitignore rename to playgrounds/next-playground/.gitignore diff --git a/examples/with-next-js/.lintstagedrc.js b/playgrounds/next-playground/.lintstagedrc.js similarity index 99% rename from examples/with-next-js/.lintstagedrc.js rename to playgrounds/next-playground/.lintstagedrc.js index a564a902f..afa2433ba 100644 --- a/examples/with-next-js/.lintstagedrc.js +++ b/playgrounds/next-playground/.lintstagedrc.js @@ -8,4 +8,4 @@ const buildEslintCommand = (filenames) => module.exports = { '*.{js,jsx,ts,tsx}': [buildEslintCommand], -} \ No newline at end of file +} diff --git a/playgrounds/next-playground/README.md b/playgrounds/next-playground/README.md new file mode 100644 index 000000000..a625deb8b --- /dev/null +++ b/playgrounds/next-playground/README.md @@ -0,0 +1,13 @@ +### ⚠️ This is not a vanilla analytics.js-next.js example. +If you're looking for how to implement Analytics.js with Next.js, see: +- https://github.com/vercel/next.js/tree/canary/examples/with-segment-analytics +- https://github.com/vercel/next.js/tree/canary/examples/with-segment-analytics-pages-router + +### Getting Started +First, run the development server: + +```bash +yarn dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. diff --git a/examples/with-next-js/context/analytics.tsx b/playgrounds/next-playground/context/analytics.tsx similarity index 100% rename from examples/with-next-js/context/analytics.tsx rename to playgrounds/next-playground/context/analytics.tsx diff --git a/examples/with-next-js/next-env.d.ts b/playgrounds/next-playground/next-env.d.ts similarity index 100% rename from examples/with-next-js/next-env.d.ts rename to playgrounds/next-playground/next-env.d.ts diff --git a/examples/with-next-js/next.config.js b/playgrounds/next-playground/next.config.js similarity index 100% rename from examples/with-next-js/next.config.js rename to playgrounds/next-playground/next.config.js diff --git a/examples/with-next-js/package.json b/playgrounds/next-playground/package.json similarity index 84% rename from examples/with-next-js/package.json rename to playgrounds/next-playground/package.json index 918001df9..79a3f9583 100644 --- a/examples/with-next-js/package.json +++ b/playgrounds/next-playground/package.json @@ -1,11 +1,11 @@ { - "name": "@example/with-next-js", + "name": "@playground/next-playground", "version": "0.1.0", "private": true, "scripts": { - ".": "yarn run -T turbo run --filter=@example/with-next-js", + ".": "yarn run -T turbo run --filter=@playground/next-playground...", "concurrently": "yarn run -T concurrently", - "dev": "yarn partytown && yarn concurrently 'yarn run -T watch --filter=with-next-js' 'sleep 10 && next dev'", + "dev": "yarn partytown && yarn concurrently 'yarn run -T watch --filter=next-playground' 'sleep 10 && next dev'", "build": "yarn partytown && next build", "partytown": "partytown copylib public/~partytown", "start": "next start", diff --git a/examples/with-next-js/pages/_app.tsx b/playgrounds/next-playground/pages/_app.tsx similarity index 100% rename from examples/with-next-js/pages/_app.tsx rename to playgrounds/next-playground/pages/_app.tsx diff --git a/examples/with-next-js/pages/iframe/childPage.tsx b/playgrounds/next-playground/pages/iframe/childPage.tsx similarity index 100% rename from examples/with-next-js/pages/iframe/childPage.tsx rename to playgrounds/next-playground/pages/iframe/childPage.tsx diff --git a/examples/with-next-js/pages/iframe/index.tsx b/playgrounds/next-playground/pages/iframe/index.tsx similarity index 100% rename from examples/with-next-js/pages/iframe/index.tsx rename to playgrounds/next-playground/pages/iframe/index.tsx diff --git a/examples/with-next-js/pages/index.tsx b/playgrounds/next-playground/pages/index.tsx similarity index 100% rename from examples/with-next-js/pages/index.tsx rename to playgrounds/next-playground/pages/index.tsx diff --git a/examples/with-next-js/pages/partytown/index.tsx b/playgrounds/next-playground/pages/partytown/index.tsx similarity index 100% rename from examples/with-next-js/pages/partytown/index.tsx rename to playgrounds/next-playground/pages/partytown/index.tsx diff --git a/examples/with-next-js/pages/vanilla/index.tsx b/playgrounds/next-playground/pages/vanilla/index.tsx similarity index 100% rename from examples/with-next-js/pages/vanilla/index.tsx rename to playgrounds/next-playground/pages/vanilla/index.tsx diff --git a/examples/with-next-js/pages/vanilla/other-page.tsx b/playgrounds/next-playground/pages/vanilla/other-page.tsx similarity index 100% rename from examples/with-next-js/pages/vanilla/other-page.tsx rename to playgrounds/next-playground/pages/vanilla/other-page.tsx diff --git a/examples/with-next-js/public/demos/faulty-load.js b/playgrounds/next-playground/public/demos/faulty-load.js similarity index 100% rename from examples/with-next-js/public/demos/faulty-load.js rename to playgrounds/next-playground/public/demos/faulty-load.js diff --git a/examples/with-next-js/public/demos/faulty-middleware.js b/playgrounds/next-playground/public/demos/faulty-middleware.js similarity index 100% rename from examples/with-next-js/public/demos/faulty-middleware.js rename to playgrounds/next-playground/public/demos/faulty-middleware.js diff --git a/examples/with-next-js/public/demos/faulty-track.js b/playgrounds/next-playground/public/demos/faulty-track.js similarity index 100% rename from examples/with-next-js/public/demos/faulty-track.js rename to playgrounds/next-playground/public/demos/faulty-track.js diff --git a/examples/with-next-js/public/demos/identity.js b/playgrounds/next-playground/public/demos/identity.js similarity index 100% rename from examples/with-next-js/public/demos/identity.js rename to playgrounds/next-playground/public/demos/identity.js diff --git a/examples/with-next-js/public/demos/signals.js b/playgrounds/next-playground/public/demos/signals.js similarity index 100% rename from examples/with-next-js/public/demos/signals.js rename to playgrounds/next-playground/public/demos/signals.js diff --git a/examples/with-next-js/public/demos/tracking-plan.js b/playgrounds/next-playground/public/demos/tracking-plan.js similarity index 100% rename from examples/with-next-js/public/demos/tracking-plan.js rename to playgrounds/next-playground/public/demos/tracking-plan.js diff --git a/examples/with-next-js/public/fira-code.webfont/fira-code_regular.eot b/playgrounds/next-playground/public/fira-code.webfont/fira-code_regular.eot similarity index 100% rename from examples/with-next-js/public/fira-code.webfont/fira-code_regular.eot rename to playgrounds/next-playground/public/fira-code.webfont/fira-code_regular.eot diff --git a/examples/with-next-js/public/fira-code.webfont/fira-code_regular.svg b/playgrounds/next-playground/public/fira-code.webfont/fira-code_regular.svg similarity index 100% rename from examples/with-next-js/public/fira-code.webfont/fira-code_regular.svg rename to playgrounds/next-playground/public/fira-code.webfont/fira-code_regular.svg diff --git a/examples/with-next-js/public/fira-code.webfont/fira-code_regular.ttf b/playgrounds/next-playground/public/fira-code.webfont/fira-code_regular.ttf similarity index 100% rename from examples/with-next-js/public/fira-code.webfont/fira-code_regular.ttf rename to playgrounds/next-playground/public/fira-code.webfont/fira-code_regular.ttf diff --git a/examples/with-next-js/public/fira-code.webfont/fira-code_regular.woff b/playgrounds/next-playground/public/fira-code.webfont/fira-code_regular.woff similarity index 100% rename from examples/with-next-js/public/fira-code.webfont/fira-code_regular.woff rename to playgrounds/next-playground/public/fira-code.webfont/fira-code_regular.woff diff --git a/examples/with-next-js/public/fira-code.webfont/webfont.css b/playgrounds/next-playground/public/fira-code.webfont/webfont.css similarity index 100% rename from examples/with-next-js/public/fira-code.webfont/webfont.css rename to playgrounds/next-playground/public/fira-code.webfont/webfont.css diff --git a/examples/with-next-js/styles/dracula/avatar.css b/playgrounds/next-playground/styles/dracula/avatar.css similarity index 100% rename from examples/with-next-js/styles/dracula/avatar.css rename to playgrounds/next-playground/styles/dracula/avatar.css diff --git a/examples/with-next-js/styles/dracula/badge.css b/playgrounds/next-playground/styles/dracula/badge.css similarity index 100% rename from examples/with-next-js/styles/dracula/badge.css rename to playgrounds/next-playground/styles/dracula/badge.css diff --git a/examples/with-next-js/styles/dracula/button.css b/playgrounds/next-playground/styles/dracula/button.css similarity index 100% rename from examples/with-next-js/styles/dracula/button.css rename to playgrounds/next-playground/styles/dracula/button.css diff --git a/examples/with-next-js/styles/dracula/card.css b/playgrounds/next-playground/styles/dracula/card.css similarity index 100% rename from examples/with-next-js/styles/dracula/card.css rename to playgrounds/next-playground/styles/dracula/card.css diff --git a/examples/with-next-js/styles/dracula/colors.css b/playgrounds/next-playground/styles/dracula/colors.css similarity index 100% rename from examples/with-next-js/styles/dracula/colors.css rename to playgrounds/next-playground/styles/dracula/colors.css diff --git a/examples/with-next-js/styles/dracula/dracula-ui.css b/playgrounds/next-playground/styles/dracula/dracula-ui.css similarity index 100% rename from examples/with-next-js/styles/dracula/dracula-ui.css rename to playgrounds/next-playground/styles/dracula/dracula-ui.css diff --git a/examples/with-next-js/styles/dracula/input.css b/playgrounds/next-playground/styles/dracula/input.css similarity index 100% rename from examples/with-next-js/styles/dracula/input.css rename to playgrounds/next-playground/styles/dracula/input.css diff --git a/examples/with-next-js/styles/dracula/prism.css b/playgrounds/next-playground/styles/dracula/prism.css similarity index 100% rename from examples/with-next-js/styles/dracula/prism.css rename to playgrounds/next-playground/styles/dracula/prism.css diff --git a/examples/with-next-js/styles/dracula/radio-checkbox-switch.css b/playgrounds/next-playground/styles/dracula/radio-checkbox-switch.css similarity index 100% rename from examples/with-next-js/styles/dracula/radio-checkbox-switch.css rename to playgrounds/next-playground/styles/dracula/radio-checkbox-switch.css diff --git a/examples/with-next-js/styles/dracula/select.css b/playgrounds/next-playground/styles/dracula/select.css similarity index 100% rename from examples/with-next-js/styles/dracula/select.css rename to playgrounds/next-playground/styles/dracula/select.css diff --git a/examples/with-next-js/styles/dracula/sizes.css b/playgrounds/next-playground/styles/dracula/sizes.css similarity index 100% rename from examples/with-next-js/styles/dracula/sizes.css rename to playgrounds/next-playground/styles/dracula/sizes.css diff --git a/examples/with-next-js/styles/dracula/typography.css b/playgrounds/next-playground/styles/dracula/typography.css similarity index 100% rename from examples/with-next-js/styles/dracula/typography.css rename to playgrounds/next-playground/styles/dracula/typography.css diff --git a/examples/with-next-js/styles/globals.css b/playgrounds/next-playground/styles/globals.css similarity index 100% rename from examples/with-next-js/styles/globals.css rename to playgrounds/next-playground/styles/globals.css diff --git a/examples/with-next-js/styles/logs-table.css b/playgrounds/next-playground/styles/logs-table.css similarity index 100% rename from examples/with-next-js/styles/logs-table.css rename to playgrounds/next-playground/styles/logs-table.css diff --git a/examples/with-next-js/tsconfig.json b/playgrounds/next-playground/tsconfig.json similarity index 100% rename from examples/with-next-js/tsconfig.json rename to playgrounds/next-playground/tsconfig.json diff --git a/examples/with-next-js/utils/hooks/useConfig.ts b/playgrounds/next-playground/utils/hooks/useConfig.ts similarity index 100% rename from examples/with-next-js/utils/hooks/useConfig.ts rename to playgrounds/next-playground/utils/hooks/useConfig.ts diff --git a/examples/with-next-js/utils/hooks/useDidMountEffect.ts b/playgrounds/next-playground/utils/hooks/useDidMountEffect.ts similarity index 100% rename from examples/with-next-js/utils/hooks/useDidMountEffect.ts rename to playgrounds/next-playground/utils/hooks/useDidMountEffect.ts diff --git a/examples/with-next-js/utils/hooks/useLocalStorage.ts b/playgrounds/next-playground/utils/hooks/useLocalStorage.ts similarity index 100% rename from examples/with-next-js/utils/hooks/useLocalStorage.ts rename to playgrounds/next-playground/utils/hooks/useLocalStorage.ts diff --git a/examples/standalone-playground/README.md b/playgrounds/standalone-playground/README.md similarity index 100% rename from examples/standalone-playground/README.md rename to playgrounds/standalone-playground/README.md diff --git a/examples/standalone-playground/index.html b/playgrounds/standalone-playground/index.html similarity index 100% rename from examples/standalone-playground/index.html rename to playgrounds/standalone-playground/index.html diff --git a/examples/standalone-playground/package.json b/playgrounds/standalone-playground/package.json similarity index 78% rename from examples/standalone-playground/package.json rename to playgrounds/standalone-playground/package.json index cbe32e8b1..d2bce51d3 100644 --- a/examples/standalone-playground/package.json +++ b/playgrounds/standalone-playground/package.json @@ -1,11 +1,11 @@ { - "name": "@example/standalone-playground", + "name": "@playground/standalone-playground", "private": true, "installConfig": { "hoistingLimits": "workspaces" }, "scripts": { - ".": "yarn run -T turbo run --filter=@example/standalone-playground...", + ".": "yarn run -T turbo run --filter=@playground/standalone-playground...", "start": "yarn http-server . -o /pages", "dev": "yarn concurrently 'yarn . build && yarn start' 'sleep 10 && yarn . watch'", "concurrently": "yarn run -T concurrently" diff --git a/playgrounds/standalone-playground/pages/index-buffered-page-ctx.html b/playgrounds/standalone-playground/pages/index-buffered-page-ctx.html new file mode 100644 index 000000000..f973c900d --- /dev/null +++ b/playgrounds/standalone-playground/pages/index-buffered-page-ctx.html @@ -0,0 +1,233 @@ + + + + + +
+ + +
+ + + + + + + +

+ This is for testing that the page context is buffered when users make quick navigation changes. + When this page first loads, it should immediately add some query parameters (similuating a quick navigation change). + The page call should still have the correct campaign and page parameters. +

+
+ +
+ + +
+
+ +

+  

+
+  
+
+
+
+
diff --git a/playgrounds/standalone-playground/pages/index-consent-no-banner.html b/playgrounds/standalone-playground/pages/index-consent-no-banner.html
new file mode 100644
index 000000000..245b493ec
--- /dev/null
+++ b/playgrounds/standalone-playground/pages/index-consent-no-banner.html
@@ -0,0 +1,212 @@
+
+
+
+
+
+  
+  
+ + +
+ + + + + + + + + + + + + + + + + + +
+ +
+ + +
+
+

Consent Changed Event

+ +

Logs

+

+
+  
+  
+
+
+
diff --git a/examples/standalone-playground/pages/index-consent.html b/playgrounds/standalone-playground/pages/index-consent.html
similarity index 97%
rename from examples/standalone-playground/pages/index-consent.html
rename to playgrounds/standalone-playground/pages/index-consent.html
index c6a998fb6..64afa1da5 100644
--- a/examples/standalone-playground/pages/index-consent.html
+++ b/playgrounds/standalone-playground/pages/index-consent.html
@@ -94,10 +94,7 @@
               var t = document.createElement('script')
               t.type = 'text/javascript'
               t.async = !0
-              t.src =
-                'https://cdn.segment.com/analytics.js/v1/' +
-                writeKey +
-                '/analytics.min.js'
+              t.src = '/node_modules/@segment/analytics-next/dist/umd/standalone.js'
               var n = document.getElementsByTagName('script')[0]
               n.parentNode.insertBefore(t, n)
               analytics._loadOptions = e
diff --git a/examples/standalone-playground/pages/index-local-batched.html b/playgrounds/standalone-playground/pages/index-local-batched.html
similarity index 100%
rename from examples/standalone-playground/pages/index-local-batched.html
rename to playgrounds/standalone-playground/pages/index-local-batched.html
diff --git a/examples/standalone-playground/pages/index-local-csp.html b/playgrounds/standalone-playground/pages/index-local-csp.html
similarity index 100%
rename from examples/standalone-playground/pages/index-local-csp.html
rename to playgrounds/standalone-playground/pages/index-local-csp.html
diff --git a/examples/standalone-playground/pages/index-local-errors.html b/playgrounds/standalone-playground/pages/index-local-errors.html
similarity index 100%
rename from examples/standalone-playground/pages/index-local-errors.html
rename to playgrounds/standalone-playground/pages/index-local-errors.html
diff --git a/examples/standalone-playground/pages/index-local-track-link.html b/playgrounds/standalone-playground/pages/index-local-track-link.html
similarity index 100%
rename from examples/standalone-playground/pages/index-local-track-link.html
rename to playgrounds/standalone-playground/pages/index-local-track-link.html
diff --git a/examples/standalone-playground/pages/index-local.html b/playgrounds/standalone-playground/pages/index-local.html
similarity index 100%
rename from examples/standalone-playground/pages/index-local.html
rename to playgrounds/standalone-playground/pages/index-local.html
diff --git a/examples/standalone-playground/pages/index-remote.html b/playgrounds/standalone-playground/pages/index-remote.html
similarity index 100%
rename from examples/standalone-playground/pages/index-remote.html
rename to playgrounds/standalone-playground/pages/index-remote.html
diff --git a/examples/with-vite/.gitignore b/playgrounds/with-vite/.gitignore
similarity index 100%
rename from examples/with-vite/.gitignore
rename to playgrounds/with-vite/.gitignore
diff --git a/examples/with-vite/index.html b/playgrounds/with-vite/index.html
similarity index 100%
rename from examples/with-vite/index.html
rename to playgrounds/with-vite/index.html
diff --git a/examples/with-vite/package.json b/playgrounds/with-vite/package.json
similarity index 81%
rename from examples/with-vite/package.json
rename to playgrounds/with-vite/package.json
index 1eba4c5e5..d630f95f5 100644
--- a/examples/with-vite/package.json
+++ b/playgrounds/with-vite/package.json
@@ -1,8 +1,8 @@
 {
-  "name": "@example/with-vite",
+  "name": "@playground/with-vite",
   "private": true,
   "scripts": {
-    ".": "yarn run -T turbo run --filter=@example/with-vite",
+    ".": "yarn run -T turbo run --filter=@playground/with-vite",
     "concurrently": "yarn run -T concurrently",
     "dev": "yarn concurrently 'yarn run -T watch --filter=with-vite' 'sleep 10 && vite'",
     "build": "tsc && vite build",
@@ -18,6 +18,6 @@
     "@types/react-dom": "^18.0.0",
     "@vitejs/plugin-react": "^1.3.0",
     "typescript": "^4.7.0",
-    "vite": "^2.9.16"
+    "vite": "^2.9.17"
   }
 }
diff --git a/examples/with-vite/src/App.css b/playgrounds/with-vite/src/App.css
similarity index 100%
rename from examples/with-vite/src/App.css
rename to playgrounds/with-vite/src/App.css
diff --git a/examples/with-vite/src/App.tsx b/playgrounds/with-vite/src/App.tsx
similarity index 100%
rename from examples/with-vite/src/App.tsx
rename to playgrounds/with-vite/src/App.tsx
diff --git a/examples/with-vite/src/favicon.svg b/playgrounds/with-vite/src/favicon.svg
similarity index 100%
rename from examples/with-vite/src/favicon.svg
rename to playgrounds/with-vite/src/favicon.svg
diff --git a/examples/with-vite/src/index.css b/playgrounds/with-vite/src/index.css
similarity index 100%
rename from examples/with-vite/src/index.css
rename to playgrounds/with-vite/src/index.css
diff --git a/examples/with-vite/src/logo.svg b/playgrounds/with-vite/src/logo.svg
similarity index 100%
rename from examples/with-vite/src/logo.svg
rename to playgrounds/with-vite/src/logo.svg
diff --git a/examples/with-vite/src/main.tsx b/playgrounds/with-vite/src/main.tsx
similarity index 100%
rename from examples/with-vite/src/main.tsx
rename to playgrounds/with-vite/src/main.tsx
diff --git a/examples/with-vite/src/vite-env.d.ts b/playgrounds/with-vite/src/vite-env.d.ts
similarity index 100%
rename from examples/with-vite/src/vite-env.d.ts
rename to playgrounds/with-vite/src/vite-env.d.ts
diff --git a/examples/with-vite/tsconfig.json b/playgrounds/with-vite/tsconfig.json
similarity index 100%
rename from examples/with-vite/tsconfig.json
rename to playgrounds/with-vite/tsconfig.json
diff --git a/examples/with-vite/tsconfig.node.json b/playgrounds/with-vite/tsconfig.node.json
similarity index 100%
rename from examples/with-vite/tsconfig.node.json
rename to playgrounds/with-vite/tsconfig.node.json
diff --git a/examples/with-vite/vite.config.ts b/playgrounds/with-vite/vite.config.ts
similarity index 100%
rename from examples/with-vite/vite.config.ts
rename to playgrounds/with-vite/vite.config.ts
diff --git a/scripts/.eslintrc.js b/scripts/.eslintrc.js
new file mode 100644
index 000000000..bb2df24b6
--- /dev/null
+++ b/scripts/.eslintrc.js
@@ -0,0 +1,7 @@
+/** @type { import('eslint').Linter.Config } */
+module.exports = {
+  extends: ['../.eslintrc'],
+  env: {
+    node: true,
+  },
+}
diff --git a/scripts/.lintstagedrc.js b/scripts/.lintstagedrc.js
new file mode 100644
index 000000000..bc1f1c780
--- /dev/null
+++ b/scripts/.lintstagedrc.js
@@ -0,0 +1 @@
+module.exports = require("@internal/config").lintStagedConfig
diff --git a/scripts/create-release-from-tags/__tests__/index.test.ts b/scripts/create-release-from-tags/__tests__/index.test.ts
index 9d0841f06..edf19cdb9 100644
--- a/scripts/create-release-from-tags/__tests__/index.test.ts
+++ b/scripts/create-release-from-tags/__tests__/index.test.ts
@@ -1,4 +1,4 @@
-import { parseReleaseNotes } from '..'
+import { parseReleaseNotes, parseRawTags } from '..'
 import fs from 'fs'
 import path from 'path'
 
@@ -15,11 +15,11 @@ describe('parseReleaseNotes', () => {
       "
           ### Minor Changes
 
-          * [#606](https://github.com/segmentio/analytics-next/pull/606) [\\\\\`b9c6356\\\\\`](https://github.com/segmentio/analytics-next/commit/b9c6356b7d35ee8acb6ecbd1eebc468d18d63958) Thanks [@silesky] - foo!)
+          * [#606](https://github.com/segmentio/analytics-next/pull/606) [\\\`b9c6356\\\`](https://github.com/segmentio/analytics-next/commit/b9c6356b7d35ee8acb6ecbd1eebc468d18d63958) Thanks [@silesky] - foo!)
 
           ### Patch Changes
 
-          * [#404](https://github.com/segmentio/analytics-next/pull/404) [\\\\\`b9abc6\\\\\`](https://github.com/segmentio/analytics-next/commit/b9c6356b7d35ee8acb6ecbd1eebc468d18d63958) Thanks [@silesky] - bar!)
+          * [#404](https://github.com/segmentio/analytics-next/pull/404) [\\\`b9abc6\\\`](https://github.com/segmentio/analytics-next/commit/b9c6356b7d35ee8acb6ecbd1eebc468d18d63958) Thanks [@silesky] - bar!)
       "
     `)
   })
@@ -30,11 +30,137 @@ describe('parseReleaseNotes', () => {
       "
           ### Minor Changes
 
-          * [#606](https://github.com/segmentio/analytics-next/pull/606) [\\\\\`b9c6356\\\\\`](https://github.com/segmentio/analytics-next/commit/b9c6356b7d35ee8acb6ecbd1eebc468d18d63958) Thanks [@silesky] - foo!)
+          * [#606](https://github.com/segmentio/analytics-next/pull/606) [\\\`b9c6356\\\`](https://github.com/segmentio/analytics-next/commit/b9c6356b7d35ee8acb6ecbd1eebc468d18d63958) Thanks [@silesky] - foo!)
 
           ### Patch Changes
 
-          * [#404](https://github.com/segmentio/analytics-next/pull/404) [\\\\\`b9abc6\\\\\`](https://github.com/segmentio/analytics-next/commit/b9c6356b7d35ee8acb6ecbd1eebc468d18d63958) Thanks [@silesky] - bar!)"
+          * [#404](https://github.com/segmentio/analytics-next/pull/404) [\\\`b9abc6\\\`](https://github.com/segmentio/analytics-next/commit/b9c6356b7d35ee8acb6ecbd1eebc468d18d63958) Thanks [@silesky] - bar!)"
+    `)
+  })
+})
+
+describe('parseRawTags', () => {
+  test('should work if all are on a single line', () => {
+    const rawTags =
+      '@segment/analytics-next@2.1.1 @segment/analytics-foo@1.0.1 @segment/analytics-core@1.0.0'
+    const tags = parseRawTags(rawTags)
+    expect(tags).toMatchInlineSnapshot(`
+      [
+        {
+          "name": "@segment/analytics-next",
+          "raw": "@segment/analytics-next@2.1.1",
+          "versionNumber": "2.1.1",
+        },
+        {
+          "name": "@segment/analytics-foo",
+          "raw": "@segment/analytics-foo@1.0.1",
+          "versionNumber": "1.0.1",
+        },
+        {
+          "name": "@segment/analytics-core",
+          "raw": "@segment/analytics-core@1.0.0",
+          "versionNumber": "1.0.0",
+        },
+      ]
+    `)
+  })
+  test('should work if there are multiple columns', () => {
+    const rawTags = `
+    @segment/analytics-next@2.1.1  @segment/analytics-foo@1.0.1
+    @segment/analytics-core@1.0.0  @segment/analytics-bar@1.0.1
+    `
+    const tags = parseRawTags(rawTags)
+    expect(tags).toMatchInlineSnapshot(`
+      [
+        {
+          "name": "@segment/analytics-next",
+          "raw": "@segment/analytics-next@2.1.1",
+          "versionNumber": "2.1.1",
+        },
+        {
+          "name": "@segment/analytics-foo",
+          "raw": "@segment/analytics-foo@1.0.1",
+          "versionNumber": "1.0.1",
+        },
+        {
+          "name": "@segment/analytics-core",
+          "raw": "@segment/analytics-core@1.0.0",
+          "versionNumber": "1.0.0",
+        },
+        {
+          "name": "@segment/analytics-bar",
+          "raw": "@segment/analytics-bar@1.0.1",
+          "versionNumber": "1.0.1",
+        },
+      ]
+    `)
+  })
+  test('should work if there are many many columns', () => {
+    const rawTags = `
+    @segment/analytics-next@2.1.1  @segment/analytics-foo@1.0.1 @segment/analytics-bar@1.0.1
+    @segment/analytics-next@2.1.1  @segment/analytics-baz@1.0.1 @segment/analytics-foobar@1.0.1
+    @segment/analytics-core@1.0.0
+    `
+    const tags = parseRawTags(rawTags)
+    expect(tags).toMatchInlineSnapshot(`
+      [
+        {
+          "name": "@segment/analytics-next",
+          "raw": "@segment/analytics-next@2.1.1",
+          "versionNumber": "2.1.1",
+        },
+        {
+          "name": "@segment/analytics-foo",
+          "raw": "@segment/analytics-foo@1.0.1",
+          "versionNumber": "1.0.1",
+        },
+        {
+          "name": "@segment/analytics-bar",
+          "raw": "@segment/analytics-bar@1.0.1",
+          "versionNumber": "1.0.1",
+        },
+        {
+          "name": "@segment/analytics-next",
+          "raw": "@segment/analytics-next@2.1.1",
+          "versionNumber": "2.1.1",
+        },
+        {
+          "name": "@segment/analytics-baz",
+          "raw": "@segment/analytics-baz@1.0.1",
+          "versionNumber": "1.0.1",
+        },
+        {
+          "name": "@segment/analytics-foobar",
+          "raw": "@segment/analytics-foobar@1.0.1",
+          "versionNumber": "1.0.1",
+        },
+        {
+          "name": "@segment/analytics-core",
+          "raw": "@segment/analytics-core@1.0.0",
+          "versionNumber": "1.0.0",
+        },
+      ]
+    `)
+  })
+  test('should work if there is newline characters', () => {
+    const rawTags = `
+    @segment/analytics-next@2.1.1
+    @segment/analytics-core@1.0.0
+    `
+    const tags = parseRawTags(rawTags)
+    expect(tags).toMatchInlineSnapshot(`
+      [
+        {
+          "name": "@segment/analytics-next",
+          "raw": "@segment/analytics-next@2.1.1",
+          "versionNumber": "2.1.1",
+        },
+        {
+          "name": "@segment/analytics-core",
+          "raw": "@segment/analytics-core@1.0.0",
+          "versionNumber": "1.0.0",
+        },
+      ]
     `)
   })
 })
diff --git a/scripts/create-release-from-tags/index.ts b/scripts/create-release-from-tags/index.ts
index 33dbfffd9..094edeef5 100755
--- a/scripts/create-release-from-tags/index.ts
+++ b/scripts/create-release-from-tags/index.ts
@@ -3,6 +3,7 @@ import getPackages from 'get-monorepo-packages'
 import path from 'path'
 import fs from 'fs'
 import { exists } from '../utils/exists'
+import { yarnWorkspaceRootSync } from '@node-kit/yarn-workspace-root'
 
 export type Config = {
   isDryRun: boolean
@@ -25,7 +26,6 @@ export const getCurrentGitTags = async (): Promise => {
     'tag',
     '--points-at',
     'HEAD',
-    '--column',
   ])
   if (code !== 0) {
     throw new Error(stderr.toString())
@@ -34,10 +34,8 @@ export const getCurrentGitTags = async (): Promise => {
   return parseRawTags(stdout.toString())
 }
 
-export const getConfig = async ({
-  DRY_RUN,
-  TAGS,
-}: NodeJS.ProcessEnv): Promise => {
+export const getConfig = async (): Promise => {
+  const { DRY_RUN, TAGS } = process.env
   const isDryRun = Boolean(DRY_RUN)
   const tags = TAGS ? parseRawTags(TAGS) : await getCurrentGitTags()
 
@@ -50,10 +48,18 @@ export const getConfig = async ({
   }
 }
 
-const getChangelogPath = (packageName: string): string | undefined => {
-  const result = getPackages('.').find((p) =>
-    p.package.name.includes(packageName)
-  )
+const getRelativeWorkspaceRoot = (): string => {
+  const root = yarnWorkspaceRootSync()
+  if (!root) {
+    throw new Error('cannot get workspace root.')
+  }
+  return path.relative(process.cwd(), root)
+}
+
+const packages = getPackages(getRelativeWorkspaceRoot())
+
+export const getChangelogPath = (packageName: string): string | undefined => {
+  const result = packages.find((p) => p.package.name.includes(packageName))
   if (!result)
     throw new Error(`could not find package with name: ${packageName}.`)
 
@@ -116,7 +122,12 @@ const extractPartsFromTag = (rawTag: string): Tag | undefined => {
  * @param rawTags - string delimited list of tags (e.g. `@segment/analytics-next@2.1.1 @segment/analytics-core@1.0.0`)
  */
 export const parseRawTags = (rawTags: string): Tag[] => {
-  return rawTags.trim().split(' ').map(extractPartsFromTag).filter(exists)
+  return rawTags
+    .trim()
+    .replace(new RegExp('\\n', 'g'), ' ') // remove any newLine characters
+    .split(' ')
+    .map(extractPartsFromTag)
+    .filter(exists)
 }
 
 /**
@@ -187,6 +198,7 @@ export const createReleaseFromTags = async (config: Config) => {
   console.log('Processing tags:', config.tags, '\n')
 
   for (const tag of config.tags) {
+    console.log(`\n ---> Creating release for tag: ${tag.raw}`)
     await createGithubReleaseFromTag(tag, { dryRun: config.isDryRun })
   }
 }
diff --git a/scripts/create-release-from-tags/run.ts b/scripts/create-release-from-tags/run.ts
index 4e6d8a284..38b3e1f32 100644
--- a/scripts/create-release-from-tags/run.ts
+++ b/scripts/create-release-from-tags/run.ts
@@ -1,7 +1,7 @@
 import { createReleaseFromTags, getConfig } from '.'
 
 async function run() {
-  const config = await getConfig(process.env)
+  const config = await getConfig()
   return createReleaseFromTags(config)
 }
 
diff --git a/scripts/env.d.ts b/scripts/env.d.ts
new file mode 100644
index 000000000..ba47ceca8
--- /dev/null
+++ b/scripts/env.d.ts
@@ -0,0 +1,2 @@
+/// 
+/// 
diff --git a/scripts/jest.config.js b/scripts/jest.config.js
index 7b9c3420b..68ccd2e05 100644
--- a/scripts/jest.config.js
+++ b/scripts/jest.config.js
@@ -1,10 +1,3 @@
-module.exports = {
-  preset: 'ts-jest',
-  testEnvironment: 'node',
-  testMatch: ['**/?(*.)+(test).[jt]s?(x)'],
-  globals: {
-    'ts-jest': {
-      isolatedModules: true,
-    },
-  },
-}
+const { createJestTSConfig } = require('@internal/config')
+
+module.exports = createJestTSConfig(__dirname)
diff --git a/scripts/package.json b/scripts/package.json
new file mode 100644
index 000000000..9cf25a45d
--- /dev/null
+++ b/scripts/package.json
@@ -0,0 +1,21 @@
+{
+  "name": "@internal/scripts",
+  "version": "0.0.0",
+  "private": true,
+  "scripts": {
+    ".": "yarn run -T turbo run --filter=@internal/scripts",
+    "lint": "yarn concurrently 'yarn:eslint .' 'yarn:tsc --noEmit'",
+    "create-release-from-tags": "yarn ts-node-script --files create-release-from-tags/run.ts",
+    "test": "yarn jest",
+    "tsc": "yarn run -T tsc",
+    "eslint": "yarn run -T eslint",
+    "concurrently": "yarn run -T concurrently",
+    "jest": "yarn run -T jest"
+  },
+  "packageManager": "yarn@3.4.1",
+  "devDependencies": {
+    "@node-kit/yarn-workspace-root": "^3.2.0",
+    "@types/node": "^16",
+    "ts-node": "^10.8.0"
+  }
+}
diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json
new file mode 100644
index 000000000..ad868eb77
--- /dev/null
+++ b/scripts/tsconfig.json
@@ -0,0 +1,12 @@
+{
+  "extends": "../tsconfig.json",
+  "exclude": ["node_modules", "dist"],
+  "compilerOptions": {
+    "noEmit": true,
+    "resolveJsonModule": true,
+    "module": "esnext",
+    "target": "es6",
+    "moduleResolution": "node",
+    "lib": ["es2020"]
+  }
+}
diff --git a/turbo.json b/turbo.json
index aa1a3925b..35259dee4 100644
--- a/turbo.json
+++ b/turbo.json
@@ -5,9 +5,12 @@
       "dependsOn": ["^build"],
       "inputs": [
         "**/tsconfig*.json",
+        "**/babel.config*",
+        "**/webpack.config*",
         "**/*.ts",
         "**/*.tsx",
-        ":!**/__tests__/**"
+        "!**/__tests__/**",
+        "!src/**/test-helpers/**"
       ],
       "outputs": ["**/dist/**", ".next/**"]
     },
@@ -25,6 +28,12 @@
       "dependsOn": ["build"],
       "inputs": ["**/tsconfig*.json", "**/*.ts", "**/*.tsx", "**/*.js"],
       "outputs": []
+    },
+    "test:cloudflare-workers": {
+      "dependsOn": ["build"]
+    },
+    "test:perf-and-durability": {
+      "dependsOn": ["build"]
     }
   }
 }
diff --git a/yarn.lock b/yarn.lock
index 091772885..728e25638 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -106,6 +106,16 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@babel/code-frame@npm:^7.23.4":
+  version: 7.23.4
+  resolution: "@babel/code-frame@npm:7.23.4"
+  dependencies:
+    "@babel/highlight": ^7.23.4
+    chalk: ^2.4.2
+  checksum: 29999d08c3dbd803f3c296dae7f4f40af1f9e381d6bbc76e5a75327c4b8b023bcb2e209843d292f5d71c3b5c845df1da959d415ed862d6a68e0ad6c5c9622d37
+  languageName: node
+  linkType: hard
+
 "@babel/compat-data@npm:^7.17.10":
   version: 7.17.10
   resolution: "@babel/compat-data@npm:7.17.10"
@@ -144,25 +154,25 @@ __metadata:
   linkType: hard
 
 "@babel/core@npm:^7.17.10":
-  version: 7.18.5
-  resolution: "@babel/core@npm:7.18.5"
+  version: 7.23.3
+  resolution: "@babel/core@npm:7.23.3"
   dependencies:
-    "@ampproject/remapping": ^2.1.0
-    "@babel/code-frame": ^7.16.7
-    "@babel/generator": ^7.18.2
-    "@babel/helper-compilation-targets": ^7.18.2
-    "@babel/helper-module-transforms": ^7.18.0
-    "@babel/helpers": ^7.18.2
-    "@babel/parser": ^7.18.5
-    "@babel/template": ^7.16.7
-    "@babel/traverse": ^7.18.5
-    "@babel/types": ^7.18.4
-    convert-source-map: ^1.7.0
+    "@ampproject/remapping": ^2.2.0
+    "@babel/code-frame": ^7.22.13
+    "@babel/generator": ^7.23.3
+    "@babel/helper-compilation-targets": ^7.22.15
+    "@babel/helper-module-transforms": ^7.23.3
+    "@babel/helpers": ^7.23.2
+    "@babel/parser": ^7.23.3
+    "@babel/template": ^7.22.15
+    "@babel/traverse": ^7.23.3
+    "@babel/types": ^7.23.3
+    convert-source-map: ^2.0.0
     debug: ^4.1.0
     gensync: ^1.0.0-beta.2
-    json5: ^2.2.1
-    semver: ^6.3.0
-  checksum: e20c3d69a07eb564408d611b827c2f5db56f05f1ca7cb3046f3823a1cf6b13c032f02d4b8ffe1e4593699e86e0f25ca1aee6228486c1ebea48d21aaeb28e6718
+    json5: ^2.2.3
+    semver: ^6.3.1
+  checksum: d306c1fa68972f4e085e9e7ad165aee80eb801ef331f6f07808c86309f03534d638b82ad00a3bc08f4d3de4860ccd38512b2790a39e6acc2caf9ea21e526afe7
   languageName: node
   linkType: hard
 
@@ -247,12 +257,15 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@babel/helper-annotate-as-pure@npm:^7.16.7":
-  version: 7.16.7
-  resolution: "@babel/helper-annotate-as-pure@npm:7.16.7"
+"@babel/generator@npm:^7.23.3, @babel/generator@npm:^7.23.4":
+  version: 7.23.4
+  resolution: "@babel/generator@npm:7.23.4"
   dependencies:
-    "@babel/types": ^7.16.7
-  checksum: d235be963fed5d48a8a4cfabc41c3f03fad6a947810dbcab9cebed7f819811457e10d99b4b2e942ad71baa7ee8e3cd3f5f38a4e4685639ddfddb7528d9a07179
+    "@babel/types": ^7.23.4
+    "@jridgewell/gen-mapping": ^0.3.2
+    "@jridgewell/trace-mapping": ^0.3.17
+    jsesc: ^2.5.1
+  checksum: 7403717002584eaeb58559f4d0de19b79e924ef2735711278f7cb5206d081428bf3960578566d6fa4102b7b30800d44f70acffea5ecef83f0cb62361c2a23062
   languageName: node
   linkType: hard
 
@@ -368,6 +381,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@babel/helper-environment-visitor@npm:^7.22.20":
+  version: 7.22.20
+  resolution: "@babel/helper-environment-visitor@npm:7.22.20"
+  checksum: d80ee98ff66f41e233f36ca1921774c37e88a803b2f7dca3db7c057a5fea0473804db9fb6729e5dbfd07f4bed722d60f7852035c2c739382e84c335661590b69
+  languageName: node
+  linkType: hard
+
 "@babel/helper-environment-visitor@npm:^7.22.5":
   version: 7.22.5
   resolution: "@babel/helper-environment-visitor@npm:7.22.5"
@@ -395,6 +415,16 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@babel/helper-function-name@npm:^7.23.0":
+  version: 7.23.0
+  resolution: "@babel/helper-function-name@npm:7.23.0"
+  dependencies:
+    "@babel/template": ^7.22.15
+    "@babel/types": ^7.23.0
+  checksum: e44542257b2d4634a1f979244eb2a4ad8e6d75eb6761b4cfceb56b562f7db150d134bc538c8e6adca3783e3bc31be949071527aa8e3aab7867d1ad2d84a26e10
+  languageName: node
+  linkType: hard
+
 "@babel/helper-hoist-variables@npm:^7.16.7":
   version: 7.16.7
   resolution: "@babel/helper-hoist-variables@npm:7.16.7"
@@ -495,6 +525,21 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@babel/helper-module-transforms@npm:^7.23.3":
+  version: 7.23.3
+  resolution: "@babel/helper-module-transforms@npm:7.23.3"
+  dependencies:
+    "@babel/helper-environment-visitor": ^7.22.20
+    "@babel/helper-module-imports": ^7.22.15
+    "@babel/helper-simple-access": ^7.22.5
+    "@babel/helper-split-export-declaration": ^7.22.6
+    "@babel/helper-validator-identifier": ^7.22.20
+  peerDependencies:
+    "@babel/core": ^7.0.0
+  checksum: 5d0895cfba0e16ae16f3aa92fee108517023ad89a855289c4eb1d46f7aef4519adf8e6f971e1d55ac20c5461610e17213f1144097a8f932e768a9132e2278d71
+  languageName: node
+  linkType: hard
+
 "@babel/helper-optimise-call-expression@npm:^7.22.5":
   version: 7.22.5
   resolution: "@babel/helper-optimise-call-expression@npm:7.22.5"
@@ -518,7 +563,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@babel/helper-plugin-utils@npm:^7.16.7, @babel/helper-plugin-utils@npm:^7.17.12":
+"@babel/helper-plugin-utils@npm:^7.17.12":
   version: 7.17.12
   resolution: "@babel/helper-plugin-utils@npm:7.17.12"
   checksum: 4813cf0ddb0f143de032cb88d4207024a2334951db330f8216d6fa253ea320c02c9b2667429ef1a34b5e95d4cfbd085f6cb72d418999751c31d0baf2422cc61d
@@ -603,6 +648,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@babel/helper-string-parser@npm:^7.23.4":
+  version: 7.23.4
+  resolution: "@babel/helper-string-parser@npm:7.23.4"
+  checksum: c0641144cf1a7e7dc93f3d5f16d5327465b6cf5d036b48be61ecba41e1eece161b48f46b7f960951b67f8c3533ce506b16dece576baef4d8b3b49f8c65410f90
+  languageName: node
+  linkType: hard
+
 "@babel/helper-validator-identifier@npm:^7.10.4, @babel/helper-validator-identifier@npm:^7.14.5":
   version: 7.14.8
   resolution: "@babel/helper-validator-identifier@npm:7.14.8"
@@ -624,6 +676,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@babel/helper-validator-identifier@npm:^7.22.20":
+  version: 7.22.20
+  resolution: "@babel/helper-validator-identifier@npm:7.22.20"
+  checksum: 136412784d9428266bcdd4d91c32bcf9ff0e8d25534a9d94b044f77fe76bc50f941a90319b05aafd1ec04f7d127cd57a179a3716009ff7f3412ef835ada95bdc
+  languageName: node
+  linkType: hard
+
 "@babel/helper-validator-identifier@npm:^7.22.5":
   version: 7.22.5
   resolution: "@babel/helper-validator-identifier@npm:7.22.5"
@@ -696,6 +755,17 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@babel/helpers@npm:^7.23.2":
+  version: 7.23.4
+  resolution: "@babel/helpers@npm:7.23.4"
+  dependencies:
+    "@babel/template": ^7.22.15
+    "@babel/traverse": ^7.23.4
+    "@babel/types": ^7.23.4
+  checksum: 85677834f2698d0a468db59c062b011ebdd65fc12bab96eeaae64084d3ce3268427ce2dbc23c2db2ddb8a305c79ea223c2c9f7bbd1fb3f6d2fa5e978c0eb1cea
+  languageName: node
+  linkType: hard
+
 "@babel/highlight@npm:^7.10.4, @babel/highlight@npm:^7.14.5":
   version: 7.14.5
   resolution: "@babel/highlight@npm:7.14.5"
@@ -740,6 +810,17 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@babel/highlight@npm:^7.23.4":
+  version: 7.23.4
+  resolution: "@babel/highlight@npm:7.23.4"
+  dependencies:
+    "@babel/helper-validator-identifier": ^7.22.20
+    chalk: ^2.4.2
+    js-tokens: ^4.0.0
+  checksum: 643acecdc235f87d925979a979b539a5d7d1f31ae7db8d89047269082694122d11aa85351304c9c978ceeb6d250591ccadb06c366f358ccee08bb9c122476b89
+  languageName: node
+  linkType: hard
+
 "@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.10.4":
   version: 7.11.5
   resolution: "@babel/parser@npm:7.11.5"
@@ -758,15 +839,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@babel/parser@npm:^7.18.5":
-  version: 7.18.5
-  resolution: "@babel/parser@npm:7.18.5"
-  bin:
-    parser: ./bin/babel-parser.js
-  checksum: 4976349d8681af215fd5771bd5b74568cc95a2e8bf2afcf354bf46f73f3d6f08d54705f354b1d0012f914dd02a524b7d37c5c1204ccaafccb9db3c37dba96a9b
-  languageName: node
-  linkType: hard
-
 "@babel/parser@npm:^7.22.11, @babel/parser@npm:^7.22.5":
   version: 7.22.11
   resolution: "@babel/parser@npm:7.22.11"
@@ -785,6 +857,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@babel/parser@npm:^7.23.3, @babel/parser@npm:^7.23.4":
+  version: 7.23.4
+  resolution: "@babel/parser@npm:7.23.4"
+  bin:
+    parser: ./bin/babel-parser.js
+  checksum: 1d90e17d966085b8ea12f357ffcc76568969364481254f0ae3e7ed579e9421d31c7fd3876ccb3b215a5b2ada48251b0c2d0f21ba225ee194f0e18295b49085f2
+  languageName: node
+  linkType: hard
+
 "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@npm:^7.22.5":
   version: 7.22.5
   resolution: "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@npm:7.22.5"
@@ -939,25 +1020,25 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@babel/plugin-syntax-jsx@npm:^7.17.12":
-  version: 7.17.12
-  resolution: "@babel/plugin-syntax-jsx@npm:7.17.12"
+"@babel/plugin-syntax-jsx@npm:^7.22.5":
+  version: 7.22.5
+  resolution: "@babel/plugin-syntax-jsx@npm:7.22.5"
   dependencies:
-    "@babel/helper-plugin-utils": ^7.17.12
+    "@babel/helper-plugin-utils": ^7.22.5
   peerDependencies:
     "@babel/core": ^7.0.0-0
-  checksum: 6acd0bbca8c3e0100ad61f3b7d0b0111cd241a0710b120b298c4aa0e07be02eccbcca61ede1e7678ade1783a0979f20305b62263df6767fa3fbf658670d82af5
+  checksum: 8829d30c2617ab31393d99cec2978e41f014f4ac6f01a1cecf4c4dd8320c3ec12fdc3ce121126b2d8d32f6887e99ca1a0bad53dedb1e6ad165640b92b24980ce
   languageName: node
   linkType: hard
 
-"@babel/plugin-syntax-jsx@npm:^7.22.5":
-  version: 7.22.5
-  resolution: "@babel/plugin-syntax-jsx@npm:7.22.5"
+"@babel/plugin-syntax-jsx@npm:^7.23.3, @babel/plugin-syntax-jsx@npm:^7.7.2":
+  version: 7.23.3
+  resolution: "@babel/plugin-syntax-jsx@npm:7.23.3"
   dependencies:
     "@babel/helper-plugin-utils": ^7.22.5
   peerDependencies:
     "@babel/core": ^7.0.0-0
-  checksum: 8829d30c2617ab31393d99cec2978e41f014f4ac6f01a1cecf4c4dd8320c3ec12fdc3ce121126b2d8d32f6887e99ca1a0bad53dedb1e6ad165640b92b24980ce
+  checksum: 89037694314a74e7f0e7a9c8d3793af5bf6b23d80950c29b360db1c66859d67f60711ea437e70ad6b5b4b29affe17eababda841b6c01107c2b638e0493bafb4e
   languageName: node
   linkType: hard
 
@@ -1549,50 +1630,50 @@ __metadata:
   linkType: hard
 
 "@babel/plugin-transform-react-jsx-development@npm:^7.16.7":
-  version: 7.16.7
-  resolution: "@babel/plugin-transform-react-jsx-development@npm:7.16.7"
+  version: 7.22.5
+  resolution: "@babel/plugin-transform-react-jsx-development@npm:7.22.5"
   dependencies:
-    "@babel/plugin-transform-react-jsx": ^7.16.7
+    "@babel/plugin-transform-react-jsx": ^7.22.5
   peerDependencies:
     "@babel/core": ^7.0.0-0
-  checksum: 697c71cb0ac9647a9b8c6f1aca99767cf06197f6c0b5d1f2e0c01f641e0706a380779f06836fdb941d3aa171f868091270fbe9fcfbfbcc2a24df5e60e04545e8
+  checksum: 36bc3ff0b96bb0ef4723070a50cfdf2e72cfd903a59eba448f9fe92fea47574d6f22efd99364413719e1f3fb3c51b6c9b2990b87af088f8486a84b2a5f9e4560
   languageName: node
   linkType: hard
 
 "@babel/plugin-transform-react-jsx-self@npm:^7.16.7":
-  version: 7.17.12
-  resolution: "@babel/plugin-transform-react-jsx-self@npm:7.17.12"
+  version: 7.23.3
+  resolution: "@babel/plugin-transform-react-jsx-self@npm:7.23.3"
   dependencies:
-    "@babel/helper-plugin-utils": ^7.17.12
+    "@babel/helper-plugin-utils": ^7.22.5
   peerDependencies:
     "@babel/core": ^7.0.0-0
-  checksum: 63a376abf90d1ecbfd9661c07f22330c60cddd35aee1ead0bbb9d6698a2ef055088e85be857c2a1d519d77247ceb4c297f690e55ef6b0c6c472cd4f6483f2211
+  checksum: 882bf56bc932d015c2d83214133939ddcf342e5bcafa21f1a93b19f2e052145115e1e0351730897fd66e5f67cad7875b8a8d81ceb12b6e2a886ad0102cb4eb1f
   languageName: node
   linkType: hard
 
 "@babel/plugin-transform-react-jsx-source@npm:^7.16.7":
-  version: 7.16.7
-  resolution: "@babel/plugin-transform-react-jsx-source@npm:7.16.7"
+  version: 7.23.3
+  resolution: "@babel/plugin-transform-react-jsx-source@npm:7.23.3"
   dependencies:
-    "@babel/helper-plugin-utils": ^7.16.7
+    "@babel/helper-plugin-utils": ^7.22.5
   peerDependencies:
     "@babel/core": ^7.0.0-0
-  checksum: 722147fd37d8b5343ab88f611f0e05dd1e298ac981ec74797751689d4a3ed35a09af1d62dc81bf78efee922d8962aa0840a4fcf07f030434139e41012ade851d
+  checksum: 92287fb797e522d99bdc77eaa573ce79ff0ad9f1cf4e7df374645e28e51dce0adad129f6f075430b129b5bac8dad843f65021970e12e992d6d6671f0d65bb1e0
   languageName: node
   linkType: hard
 
-"@babel/plugin-transform-react-jsx@npm:^7.16.7, @babel/plugin-transform-react-jsx@npm:^7.17.3":
-  version: 7.17.12
-  resolution: "@babel/plugin-transform-react-jsx@npm:7.17.12"
+"@babel/plugin-transform-react-jsx@npm:^7.17.3, @babel/plugin-transform-react-jsx@npm:^7.22.5":
+  version: 7.23.4
+  resolution: "@babel/plugin-transform-react-jsx@npm:7.23.4"
   dependencies:
-    "@babel/helper-annotate-as-pure": ^7.16.7
-    "@babel/helper-module-imports": ^7.16.7
-    "@babel/helper-plugin-utils": ^7.17.12
-    "@babel/plugin-syntax-jsx": ^7.17.12
-    "@babel/types": ^7.17.12
+    "@babel/helper-annotate-as-pure": ^7.22.5
+    "@babel/helper-module-imports": ^7.22.15
+    "@babel/helper-plugin-utils": ^7.22.5
+    "@babel/plugin-syntax-jsx": ^7.23.3
+    "@babel/types": ^7.23.4
   peerDependencies:
     "@babel/core": ^7.0.0-0
-  checksum: 02e9974d14821173bb8e84db4bdfccd546bfdbf445d91d6345f953591f16306cf5741861d72e0d0910f3ffa7d4084fafed99cedf736e7ba8bed0cf64320c2ea6
+  checksum: d8b8c52e8e22e833bf77c8d1a53b0a57d1fd52ba9596a319d572de79446a8ed9d95521035bc1175c1589d1a6a34600d2e678fa81d81bac8fac121137097f1f0a
   languageName: node
   linkType: hard
 
@@ -1861,22 +1942,12 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@babel/runtime-corejs3@npm:^7.10.2":
-  version: 7.18.3
-  resolution: "@babel/runtime-corejs3@npm:7.18.3"
+"@babel/runtime@npm:^7.10.1, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.20.0, @babel/runtime@npm:^7.20.7, @babel/runtime@npm:^7.23.2":
+  version: 7.23.4
+  resolution: "@babel/runtime@npm:7.23.4"
   dependencies:
-    core-js-pure: ^3.20.2
-    regenerator-runtime: ^0.13.4
-  checksum: 50319e107e4c3dc6662404daf1079ab1ecd1cb232577bf50e645c5051fa8977f6ce48a44aa1d158ce2beaec76a43df4fc404999bf4f03c66719c3f8b8fe50a4e
-  languageName: node
-  linkType: hard
-
-"@babel/runtime@npm:^7.10.1, @babel/runtime@npm:^7.10.2, @babel/runtime@npm:^7.16.3, @babel/runtime@npm:^7.18.3":
-  version: 7.18.3
-  resolution: "@babel/runtime@npm:7.18.3"
-  dependencies:
-    regenerator-runtime: ^0.13.4
-  checksum: db8526226aa02cfa35a5a7ac1a34b5f303c62a1f000c7db48cb06c6290e616483e5036ab3c4e7a84d0f3be6d4e2148d5fe5cec9564bf955f505c3e764b83d7f1
+    regenerator-runtime: ^0.14.0
+  checksum: 8eb6a6b2367f7d60e7f7dd83f477cc2e2fdb169e5460694d7614ce5c730e83324bcf29251b70940068e757ad1ee56ff8073a372260d90cad55f18a825caf97cd
   languageName: node
   linkType: hard
 
@@ -1942,7 +2013,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@babel/traverse@npm:^7.18.0, @babel/traverse@npm:^7.18.2, @babel/traverse@npm:^7.7.2":
+"@babel/traverse@npm:^7.18.0, @babel/traverse@npm:^7.18.2":
   version: 7.18.2
   resolution: "@babel/traverse@npm:7.18.2"
   dependencies:
@@ -1960,24 +2031,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@babel/traverse@npm:^7.18.5":
-  version: 7.18.5
-  resolution: "@babel/traverse@npm:7.18.5"
-  dependencies:
-    "@babel/code-frame": ^7.16.7
-    "@babel/generator": ^7.18.2
-    "@babel/helper-environment-visitor": ^7.18.2
-    "@babel/helper-function-name": ^7.17.9
-    "@babel/helper-hoist-variables": ^7.16.7
-    "@babel/helper-split-export-declaration": ^7.16.7
-    "@babel/parser": ^7.18.5
-    "@babel/types": ^7.18.4
-    debug: ^4.1.0
-    globals: ^11.1.0
-  checksum: cc0470c880e15a748ca3424665c65836dba450fd0331fb28f9d30aa42acd06387b6321996517ab1761213f781fe8d657e2c3ad67c34afcb766d50653b393810f
-  languageName: node
-  linkType: hard
-
 "@babel/traverse@npm:^7.22.11":
   version: 7.22.11
   resolution: "@babel/traverse@npm:7.22.11"
@@ -2014,6 +2067,24 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@babel/traverse@npm:^7.23.3, @babel/traverse@npm:^7.23.4":
+  version: 7.23.4
+  resolution: "@babel/traverse@npm:7.23.4"
+  dependencies:
+    "@babel/code-frame": ^7.23.4
+    "@babel/generator": ^7.23.4
+    "@babel/helper-environment-visitor": ^7.22.20
+    "@babel/helper-function-name": ^7.23.0
+    "@babel/helper-hoist-variables": ^7.22.5
+    "@babel/helper-split-export-declaration": ^7.22.6
+    "@babel/parser": ^7.23.4
+    "@babel/types": ^7.23.4
+    debug: ^4.1.0
+    globals: ^11.1.0
+  checksum: e8c9cd92cfd6fec9cf3969604edea5a58c2d55275b88b9de06f0d94de43b64b04d57168554b617159d62c840a8700e6d4c7954d2e6ed69cfb918202ac01561e9
+  languageName: node
+  linkType: hard
+
 "@babel/types@npm:^7.0.0, @babel/types@npm:^7.10.4, @babel/types@npm:^7.3.0, @babel/types@npm:^7.3.3":
   version: 7.11.5
   resolution: "@babel/types@npm:7.11.5"
@@ -2025,7 +2096,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@babel/types@npm:^7.16.7, @babel/types@npm:^7.17.0, @babel/types@npm:^7.17.12, @babel/types@npm:^7.18.0, @babel/types@npm:^7.18.2, @babel/types@npm:^7.18.4, @babel/types@npm:^7.8.3":
+"@babel/types@npm:^7.16.7, @babel/types@npm:^7.17.0, @babel/types@npm:^7.18.0, @babel/types@npm:^7.18.2, @babel/types@npm:^7.8.3":
   version: 7.18.4
   resolution: "@babel/types@npm:7.18.4"
   dependencies:
@@ -2057,6 +2128,17 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@babel/types@npm:^7.23.0, @babel/types@npm:^7.23.3, @babel/types@npm:^7.23.4":
+  version: 7.23.4
+  resolution: "@babel/types@npm:7.23.4"
+  dependencies:
+    "@babel/helper-string-parser": ^7.23.4
+    "@babel/helper-validator-identifier": ^7.22.20
+    to-fast-properties: ^2.0.0
+  checksum: 8a1ab20da663d202b1c090fdef4b157d3c7d8cb1cf60ea548f887d7b674935371409804d6cba52f870c22ced7685fcb41b0578d3edde720990de00cbb328da54
+  languageName: node
+  linkType: hard
+
 "@bcoe/v8-coverage@npm:^0.2.3":
   version: 0.2.3
   resolution: "@bcoe/v8-coverage@npm:0.2.3"
@@ -2079,11 +2161,11 @@ __metadata:
   linkType: hard
 
 "@builder.io/partytown@npm:^0.7.4":
-  version: 0.7.4
-  resolution: "@builder.io/partytown@npm:0.7.4"
+  version: 0.7.6
+  resolution: "@builder.io/partytown@npm:0.7.6"
   bin:
     partytown: bin/partytown.cjs
-  checksum: b731f89a909343e7e12e9569f8d3ca24a6ac0ba266dadff2f8061d1a9e4b2a38c3fa6d28a870bffe04f7cc17a1e90d248437b967b9e3e35a8cb29b859f3c0161
+  checksum: c469575e0f0389e4cf0a39f6248c14db1d87fe98d67f0bb38e9d97ce1f957eb32c8441bd2aae1f663a36a49567fbb7e1f2dd06b82a91848936cd5dedf5d7d5b9
   languageName: node
   linkType: hard
 
@@ -2343,6 +2425,57 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@cloudflare/kv-asset-handler@npm:^0.2.0":
+  version: 0.2.0
+  resolution: "@cloudflare/kv-asset-handler@npm:0.2.0"
+  dependencies:
+    mime: ^3.0.0
+  checksum: bc6a02a9c80be6de90e46454ef4de09301e68726eaa4835de0e30216e50fffcc5612274a17dfb455916cf3418f0cb25fefd2b561a9d2282f4cc10d40527f0acb
+  languageName: node
+  linkType: hard
+
+"@cloudflare/workerd-darwin-64@npm:1.20231002.0":
+  version: 1.20231002.0
+  resolution: "@cloudflare/workerd-darwin-64@npm:1.20231002.0"
+  conditions: os=darwin & cpu=x64
+  languageName: node
+  linkType: hard
+
+"@cloudflare/workerd-darwin-arm64@npm:1.20231002.0":
+  version: 1.20231002.0
+  resolution: "@cloudflare/workerd-darwin-arm64@npm:1.20231002.0"
+  conditions: os=darwin & cpu=arm64
+  languageName: node
+  linkType: hard
+
+"@cloudflare/workerd-linux-64@npm:1.20231002.0":
+  version: 1.20231002.0
+  resolution: "@cloudflare/workerd-linux-64@npm:1.20231002.0"
+  conditions: os=linux & cpu=x64
+  languageName: node
+  linkType: hard
+
+"@cloudflare/workerd-linux-arm64@npm:1.20231002.0":
+  version: 1.20231002.0
+  resolution: "@cloudflare/workerd-linux-arm64@npm:1.20231002.0"
+  conditions: os=linux & cpu=arm64
+  languageName: node
+  linkType: hard
+
+"@cloudflare/workerd-windows-64@npm:1.20231002.0":
+  version: 1.20231002.0
+  resolution: "@cloudflare/workerd-windows-64@npm:1.20231002.0"
+  conditions: os=win32 & cpu=x64
+  languageName: node
+  linkType: hard
+
+"@cloudflare/workers-types@npm:^4.20231002.0":
+  version: 4.20231002.0
+  resolution: "@cloudflare/workers-types@npm:4.20231002.0"
+  checksum: 066f4ef24286948de0165be9b22b842867330fcbaba520a60bae01a1ce85617364829fe50be7480f12a3fe4dc41463a9a53a8722b473493c61e0fc57dd7b21e9
+  languageName: node
+  linkType: hard
+
 "@colors/colors@npm:1.5.0":
   version: 1.5.0
   resolution: "@colors/colors@npm:1.5.0"
@@ -2366,6 +2499,188 @@ __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: f83eeaa382680b26a3b1cf6c396450332c41d2dc0f9fd935d3f4bacf5412bef7383d2aeb4246a858781435b7c005a570dadc81051f8a038f1ef2111f17d3d8b0
+  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: ^4.0.0
+    rollup-plugin-node-polyfills: ^0.2.1
+  peerDependencies:
+    esbuild: "*"
+  checksum: 73c247a7559c68b7df080ab08dd3d0b0ab44b934840a4933df9626357b7183a9a5d8cf4ffa9c744f1bad8d7131bce0fde14a23203f7b262f9f14f7b3485bfdb1
+  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-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-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/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-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/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-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/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-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-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-loong64@npm:0.14.54":
+  version: 0.14.54
+  resolution: "@esbuild/linux-loong64@npm:0.14.54"
+  conditions: os=linux & cpu=loong64
+  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-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-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-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-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-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/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/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/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/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-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-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
+
 "@eslint-community/eslint-utils@npm:^4.2.0":
   version: 4.4.0
   resolution: "@eslint-community/eslint-utils@npm:4.4.0"
@@ -2394,53 +2709,12 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@example/standalone-playground@workspace:examples/standalone-playground":
-  version: 0.0.0-use.local
-  resolution: "@example/standalone-playground@workspace:examples/standalone-playground"
-  dependencies:
-    "@segment/analytics-consent-wrapper-onetrust": "workspace:^"
-    "@segment/analytics-next": "workspace:^"
-    http-server: 14.1.1
-  languageName: unknown
-  linkType: soft
-
-"@example/with-next-js@workspace:examples/with-next-js":
-  version: 0.0.0-use.local
-  resolution: "@example/with-next-js@workspace:examples/with-next-js"
-  dependencies:
-    "@builder.io/partytown": ^0.7.4
-    "@next/bundle-analyzer": ^12.1.5
-    "@segment/analytics-next": "workspace:^"
-    "@types/faker": ^5.1.2
-    "@types/react": ^17.0.37
-    eslint-config-next: ^12.1.6
-    faker: ^5.1.0
-    lodash: ^4.17.21
-    next: ^12.1.0
-    prismjs: ^1.27.0
-    rc-table: ^7.10.0
-    react: ^17.0.2
-    react-dom: ^17.0.2
-    react-json-tree: ^0.13.0
-    react-simple-code-editor: ^0.11.0
-    source-map-loader: ^3.0.1
-  languageName: unknown
-  linkType: soft
-
-"@example/with-vite@workspace:examples/with-vite":
-  version: 0.0.0-use.local
-  resolution: "@example/with-vite@workspace:examples/with-vite"
-  dependencies:
-    "@segment/analytics-next": "workspace:^"
-    "@types/react": ^18.0.0
-    "@types/react-dom": ^18.0.0
-    "@vitejs/plugin-react": ^1.3.0
-    react: ^18.0.0
-    react-dom: ^18.0.0
-    typescript: ^4.7.0
-    vite: ^2.9.16
-  languageName: unknown
-  linkType: soft
+"@fastify/busboy@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "@fastify/busboy@npm:2.0.0"
+  checksum: 41879937ce1dee6421ef9cd4da53239830617e1f0bb7a0e843940772cd72827205d05e518af6adabe6e1ea19301285fff432b9d11bad01a531e698bea95c781b
+  languageName: node
+  linkType: hard
 
 "@gar/promisify@npm:^1.1.3":
   version: 1.1.3
@@ -2507,6 +2781,7 @@ __metadata:
     "@types/circular-dependency-plugin": ^5
     babel-loader: ^8.0.0
     circular-dependency-plugin: ^5.2.2
+    ecma-version-validator-webpack-plugin: ^1.2.1
     terser-webpack-plugin: ^5.1.4
     webpack: ^5.76.0
     webpack-cli: ^4.8.0
@@ -2558,6 +2833,8 @@ __metadata:
   version: 0.0.0-use.local
   resolution: "@internal/node-integration-tests@workspace:packages/node-integration-tests"
   dependencies:
+    "@cloudflare/workers-types": ^4.20231002.0
+    "@internal/config": "workspace:^"
     "@internal/test-helpers": "workspace:^"
     "@segment/analytics-node": "workspace:^"
     "@types/analytics-node": ^3.1.9
@@ -2565,6 +2842,17 @@ __metadata:
     "@types/node": ^16
     analytics-node: ^6.2.0
     autocannon: ^7.10.0
+    wrangler: ^3.11.0
+  languageName: unknown
+  linkType: soft
+
+"@internal/scripts@workspace:scripts":
+  version: 0.0.0-use.local
+  resolution: "@internal/scripts@workspace:scripts"
+  dependencies:
+    "@node-kit/yarn-workspace-root": ^3.2.0
+    "@types/node": ^16
+    ts-node: ^10.8.0
   languageName: unknown
   linkType: soft
 
@@ -2611,51 +2899,50 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@jest/console@npm:^28.1.1":
-  version: 28.1.1
-  resolution: "@jest/console@npm:28.1.1"
+"@jest/console@npm:^29.7.0":
+  version: 29.7.0
+  resolution: "@jest/console@npm:29.7.0"
   dependencies:
-    "@jest/types": ^28.1.1
+    "@jest/types": ^29.6.3
     "@types/node": "*"
     chalk: ^4.0.0
-    jest-message-util: ^28.1.1
-    jest-util: ^28.1.1
+    jest-message-util: ^29.7.0
+    jest-util: ^29.7.0
     slash: ^3.0.0
-  checksum: ddf3b9e9b003a99d6686ecd89c263fda8f81303277f64cca6e434106fa3556c456df6023cdba962851df16880e044bfbae264daa5f67f7ac28712144b5f1007e
+  checksum: 0e3624e32c5a8e7361e889db70b170876401b7d70f509a2538c31d5cd50deb0c1ae4b92dc63fe18a0902e0a48c590c21d53787a0df41a52b34fa7cab96c384d6
   languageName: node
   linkType: hard
 
-"@jest/core@npm:^28.1.1":
-  version: 28.1.1
-  resolution: "@jest/core@npm:28.1.1"
+"@jest/core@npm:^29.7.0":
+  version: 29.7.0
+  resolution: "@jest/core@npm:29.7.0"
   dependencies:
-    "@jest/console": ^28.1.1
-    "@jest/reporters": ^28.1.1
-    "@jest/test-result": ^28.1.1
-    "@jest/transform": ^28.1.1
-    "@jest/types": ^28.1.1
+    "@jest/console": ^29.7.0
+    "@jest/reporters": ^29.7.0
+    "@jest/test-result": ^29.7.0
+    "@jest/transform": ^29.7.0
+    "@jest/types": ^29.6.3
     "@types/node": "*"
     ansi-escapes: ^4.2.1
     chalk: ^4.0.0
     ci-info: ^3.2.0
     exit: ^0.1.2
     graceful-fs: ^4.2.9
-    jest-changed-files: ^28.0.2
-    jest-config: ^28.1.1
-    jest-haste-map: ^28.1.1
-    jest-message-util: ^28.1.1
-    jest-regex-util: ^28.0.2
-    jest-resolve: ^28.1.1
-    jest-resolve-dependencies: ^28.1.1
-    jest-runner: ^28.1.1
-    jest-runtime: ^28.1.1
-    jest-snapshot: ^28.1.1
-    jest-util: ^28.1.1
-    jest-validate: ^28.1.1
-    jest-watcher: ^28.1.1
+    jest-changed-files: ^29.7.0
+    jest-config: ^29.7.0
+    jest-haste-map: ^29.7.0
+    jest-message-util: ^29.7.0
+    jest-regex-util: ^29.6.3
+    jest-resolve: ^29.7.0
+    jest-resolve-dependencies: ^29.7.0
+    jest-runner: ^29.7.0
+    jest-runtime: ^29.7.0
+    jest-snapshot: ^29.7.0
+    jest-util: ^29.7.0
+    jest-validate: ^29.7.0
+    jest-watcher: ^29.7.0
     micromatch: ^4.0.4
-    pretty-format: ^28.1.1
-    rimraf: ^3.0.0
+    pretty-format: ^29.7.0
     slash: ^3.0.0
     strip-ansi: ^6.0.0
   peerDependencies:
@@ -2663,7 +2950,7 @@ __metadata:
   peerDependenciesMeta:
     node-notifier:
       optional: true
-  checksum: fd4361f77b4f3a600374733c537474fac86d3df42f2a47ee1f66594d4fc8391be66cd501bbf85d9b4c35a7229feeb31f4a04cf353c49a38f3069a4383ac5d8bf
+  checksum: af759c9781cfc914553320446ce4e47775ae42779e73621c438feb1e4231a5d4862f84b1d8565926f2d1aab29b3ec3dcfdc84db28608bdf5f29867124ebcfc0d
   languageName: node
   linkType: hard
 
@@ -2679,12 +2966,15 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@jest/expect-utils@npm:^28.1.1":
-  version: 28.1.1
-  resolution: "@jest/expect-utils@npm:28.1.1"
+"@jest/environment@npm:^29.7.0":
+  version: 29.7.0
+  resolution: "@jest/environment@npm:29.7.0"
   dependencies:
-    jest-get-type: ^28.0.2
-  checksum: 46a2ad754b10bc649c36a5914f887bea33a43bb868946508892a73f1da99065b17167dc3c0e3e299c7cea82c6be1e9d816986e120d7ae3e1be511f64cfc1d3d3
+    "@jest/fake-timers": ^29.7.0
+    "@jest/types": ^29.6.3
+    "@types/node": "*"
+    jest-mock: ^29.7.0
+  checksum: 6fb398143b2543d4b9b8d1c6dbce83fa5247f84f550330604be744e24c2bd2178bb893657d62d1b97cf2f24baf85c450223f8237cccb71192c36a38ea2272934
   languageName: node
   linkType: hard
 
@@ -2697,13 +2987,22 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@jest/expect@npm:^28.1.1":
-  version: 28.1.1
-  resolution: "@jest/expect@npm:28.1.1"
+"@jest/expect-utils@npm:^29.7.0":
+  version: 29.7.0
+  resolution: "@jest/expect-utils@npm:29.7.0"
+  dependencies:
+    jest-get-type: ^29.6.3
+  checksum: 75eb177f3d00b6331bcaa057e07c0ccb0733a1d0a1943e1d8db346779039cb7f103789f16e502f888a3096fb58c2300c38d1f3748b36a7fa762eb6f6d1b160ed
+  languageName: node
+  linkType: hard
+
+"@jest/expect@npm:^29.7.0":
+  version: 29.7.0
+  resolution: "@jest/expect@npm:29.7.0"
   dependencies:
-    expect: ^28.1.1
-    jest-snapshot: ^28.1.1
-  checksum: c43fddaf597c1f6701eb84873e736e89f0f7baa0f42ac7dc1d1ff95efee9744bfae860fd26911e16f07155ff886da04c369b8ee19e361ff0661af823f43ebd63
+    expect: ^29.7.0
+    jest-snapshot: ^29.7.0
+  checksum: a01cb85fd9401bab3370618f4b9013b90c93536562222d920e702a0b575d239d74cecfe98010aaec7ad464f67cf534a353d92d181646a4b792acaa7e912ae55e
   languageName: node
   linkType: hard
 
@@ -2721,27 +3020,42 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@jest/globals@npm:^28.1.1":
-  version: 28.1.1
-  resolution: "@jest/globals@npm:28.1.1"
+"@jest/fake-timers@npm:^29.7.0":
+  version: 29.7.0
+  resolution: "@jest/fake-timers@npm:29.7.0"
   dependencies:
-    "@jest/environment": ^28.1.1
-    "@jest/expect": ^28.1.1
-    "@jest/types": ^28.1.1
-  checksum: fb8f2c985e21488d0c833de7c3ffd60848ee0f03c3294a6410aaee21d4f14f552fc2a026a2517566b6c57354669ad502f0f13694861a7949840750646da88dd0
+    "@jest/types": ^29.6.3
+    "@sinonjs/fake-timers": ^10.0.2
+    "@types/node": "*"
+    jest-message-util: ^29.7.0
+    jest-mock: ^29.7.0
+    jest-util: ^29.7.0
+  checksum: caf2bbd11f71c9241b458d1b5a66cbe95debc5a15d96442444b5d5c7ba774f523c76627c6931cca5e10e76f0d08761f6f1f01a608898f4751a0eee54fc3d8d00
   languageName: node
   linkType: hard
 
-"@jest/reporters@npm:^28.1.1":
-  version: 28.1.1
-  resolution: "@jest/reporters@npm:28.1.1"
+"@jest/globals@npm:^29.7.0":
+  version: 29.7.0
+  resolution: "@jest/globals@npm:29.7.0"
+  dependencies:
+    "@jest/environment": ^29.7.0
+    "@jest/expect": ^29.7.0
+    "@jest/types": ^29.6.3
+    jest-mock: ^29.7.0
+  checksum: 97dbb9459135693ad3a422e65ca1c250f03d82b2a77f6207e7fa0edd2c9d2015fbe4346f3dc9ebff1678b9d8da74754d4d440b7837497f8927059c0642a22123
+  languageName: node
+  linkType: hard
+
+"@jest/reporters@npm:^29.7.0":
+  version: 29.7.0
+  resolution: "@jest/reporters@npm:29.7.0"
   dependencies:
     "@bcoe/v8-coverage": ^0.2.3
-    "@jest/console": ^28.1.1
-    "@jest/test-result": ^28.1.1
-    "@jest/transform": ^28.1.1
-    "@jest/types": ^28.1.1
-    "@jridgewell/trace-mapping": ^0.3.7
+    "@jest/console": ^29.7.0
+    "@jest/test-result": ^29.7.0
+    "@jest/transform": ^29.7.0
+    "@jest/types": ^29.6.3
+    "@jridgewell/trace-mapping": ^0.3.18
     "@types/node": "*"
     chalk: ^4.0.0
     collect-v8-coverage: ^1.0.0
@@ -2749,24 +3063,23 @@ __metadata:
     glob: ^7.1.3
     graceful-fs: ^4.2.9
     istanbul-lib-coverage: ^3.0.0
-    istanbul-lib-instrument: ^5.1.0
+    istanbul-lib-instrument: ^6.0.0
     istanbul-lib-report: ^3.0.0
     istanbul-lib-source-maps: ^4.0.0
     istanbul-reports: ^3.1.3
-    jest-message-util: ^28.1.1
-    jest-util: ^28.1.1
-    jest-worker: ^28.1.1
+    jest-message-util: ^29.7.0
+    jest-util: ^29.7.0
+    jest-worker: ^29.7.0
     slash: ^3.0.0
     string-length: ^4.0.1
     strip-ansi: ^6.0.0
-    terminal-link: ^2.0.0
-    v8-to-istanbul: ^9.0.0
+    v8-to-istanbul: ^9.0.1
   peerDependencies:
     node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0
   peerDependenciesMeta:
     node-notifier:
       optional: true
-  checksum: 8ad68d4a93fa9d998eb7f97e7955c86b652ce13ad7d80d0d999cefe898a6a1c753aea77ab65d3957b55d4dd0a877593895a124b55f692958a9e41a51d7b354ee
+  checksum: 7eadabd62cc344f629024b8a268ecc8367dba756152b761bdcb7b7e570a3864fc51b2a9810cd310d85e0a0173ef002ba4528d5ea0329fbf66ee2a3ada9c40455
   languageName: node
   linkType: hard
 
@@ -2788,61 +3101,70 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@jest/source-map@npm:^28.0.2":
-  version: 28.0.2
-  resolution: "@jest/source-map@npm:28.0.2"
+"@jest/schemas@npm:^29.6.3":
+  version: 29.6.3
+  resolution: "@jest/schemas@npm:29.6.3"
   dependencies:
-    "@jridgewell/trace-mapping": ^0.3.7
+    "@sinclair/typebox": ^0.27.8
+  checksum: 910040425f0fc93cd13e68c750b7885590b8839066dfa0cd78e7def07bbb708ad869381f725945d66f2284de5663bbecf63e8fdd856e2ae6e261ba30b1687e93
+  languageName: node
+  linkType: hard
+
+"@jest/source-map@npm:^29.6.3":
+  version: 29.6.3
+  resolution: "@jest/source-map@npm:29.6.3"
+  dependencies:
+    "@jridgewell/trace-mapping": ^0.3.18
     callsites: ^3.0.0
     graceful-fs: ^4.2.9
-  checksum: 427195be85c28517e7e6b29fb38448a371750a1e4f4003e4c33ee0b35bbb72229c80482d444a827aa230f688a0b72c0c858ebd11425a686103c13d6cc61c8da1
+  checksum: bcc5a8697d471396c0003b0bfa09722c3cd879ad697eb9c431e6164e2ea7008238a01a07193dfe3cbb48b1d258eb7251f6efcea36f64e1ebc464ea3c03ae2deb
   languageName: node
   linkType: hard
 
-"@jest/test-result@npm:^28.1.1":
-  version: 28.1.1
-  resolution: "@jest/test-result@npm:28.1.1"
+"@jest/test-result@npm:^29.7.0":
+  version: 29.7.0
+  resolution: "@jest/test-result@npm:29.7.0"
   dependencies:
-    "@jest/console": ^28.1.1
-    "@jest/types": ^28.1.1
+    "@jest/console": ^29.7.0
+    "@jest/types": ^29.6.3
     "@types/istanbul-lib-coverage": ^2.0.0
     collect-v8-coverage: ^1.0.0
-  checksum: 8812db2649a09ed423ccb33cf76162a996fc781156a489d4fd86e22615b523d72ca026c68b3699a1ea1ea274146234e09db636c49d7ea2516e0e1bb229f3013d
+  checksum: 67b6317d526e335212e5da0e768e3b8ab8a53df110361b80761353ad23b6aea4432b7c5665bdeb87658ea373b90fb1afe02ed3611ef6c858c7fba377505057fa
   languageName: node
   linkType: hard
 
-"@jest/test-sequencer@npm:^28.1.1":
-  version: 28.1.1
-  resolution: "@jest/test-sequencer@npm:28.1.1"
+"@jest/test-sequencer@npm:^29.7.0":
+  version: 29.7.0
+  resolution: "@jest/test-sequencer@npm:29.7.0"
   dependencies:
-    "@jest/test-result": ^28.1.1
+    "@jest/test-result": ^29.7.0
     graceful-fs: ^4.2.9
-    jest-haste-map: ^28.1.1
+    jest-haste-map: ^29.7.0
     slash: ^3.0.0
-  checksum: acfa3b7ff18478aaa9ac54d6013f951e1be2133a09ea5ca6b248eb80340e5cac71420f1357ef87d2780cb2adb2411fbacbbffcb6ac7f93a0b24cc76be5a42afa
+  checksum: 73f43599017946be85c0b6357993b038f875b796e2f0950487a82f4ebcb115fa12131932dd9904026b4ad8be131fe6e28bd8d0aa93b1563705185f9804bff8bd
   languageName: node
   linkType: hard
 
-"@jest/transform@npm:^28.1.1":
-  version: 28.1.1
-  resolution: "@jest/transform@npm:28.1.1"
+"@jest/transform@npm:^29.7.0":
+  version: 29.7.0
+  resolution: "@jest/transform@npm:29.7.0"
   dependencies:
     "@babel/core": ^7.11.6
-    "@jest/types": ^28.1.1
-    "@jridgewell/trace-mapping": ^0.3.7
+    "@jest/types": ^29.6.3
+    "@jridgewell/trace-mapping": ^0.3.18
     babel-plugin-istanbul: ^6.1.1
     chalk: ^4.0.0
-    convert-source-map: ^1.4.0
-    fast-json-stable-stringify: ^2.0.0
+    convert-source-map: ^2.0.0
+    fast-json-stable-stringify: ^2.1.0
     graceful-fs: ^4.2.9
-    jest-haste-map: ^28.1.1
-    jest-regex-util: ^28.0.2
-    jest-util: ^28.1.1
+    jest-haste-map: ^29.7.0
+    jest-regex-util: ^29.6.3
+    jest-util: ^29.7.0
     micromatch: ^4.0.4
     pirates: ^4.0.4
     slash: ^3.0.0
-    write-file-atomic: ^4.0.1
-  checksum: 24bac4cba40f7b27de7a9082be1586e235848c74f6509e87ca3eaeaa548573215d0e6e68f515cdf10cacdc8364d0df4b5760f4c608a267a82f9c290eb40f360d
+    write-file-atomic: ^4.0.2
+  checksum: 0f8ac9f413903b3cb6d240102db848f2a354f63971ab885833799a9964999dd51c388162106a807f810071f864302cdd8e3f0c241c29ce02d85a36f18f3f40ab
   languageName: node
   linkType: hard
 
@@ -2874,6 +3196,20 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@jest/types@npm:^29.6.3":
+  version: 29.6.3
+  resolution: "@jest/types@npm:29.6.3"
+  dependencies:
+    "@jest/schemas": ^29.6.3
+    "@types/istanbul-lib-coverage": ^2.0.0
+    "@types/istanbul-reports": ^3.0.0
+    "@types/node": "*"
+    "@types/yargs": ^17.0.8
+    chalk: ^4.0.0
+  checksum: a0bcf15dbb0eca6bdd8ce61a3fb055349d40268622a7670a3b2eb3c3dbafe9eb26af59938366d520b86907b9505b0f9b29b85cec11579a9e580694b87cd90fcc
+  languageName: node
+  linkType: hard
+
 "@jridgewell/gen-mapping@npm:^0.1.0":
   version: 0.1.1
   resolution: "@jridgewell/gen-mapping@npm:0.1.1"
@@ -2978,6 +3314,16 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@jridgewell/trace-mapping@npm:^0.3.12, @jridgewell/trace-mapping@npm:^0.3.18":
+  version: 0.3.20
+  resolution: "@jridgewell/trace-mapping@npm:0.3.20"
+  dependencies:
+    "@jridgewell/resolve-uri": ^3.1.0
+    "@jridgewell/sourcemap-codec": ^1.4.14
+  checksum: cd1a7353135f385909468ff0cf20bdd37e59f2ee49a13a966dedf921943e222082c583ade2b579ff6cd0d8faafcb5461f253e1bf2a9f48fec439211fdbe788f5
+  languageName: node
+  linkType: hard
+
 "@jridgewell/trace-mapping@npm:^0.3.17":
   version: 0.3.19
   resolution: "@jridgewell/trace-mapping@npm:0.3.19"
@@ -3140,110 +3486,117 @@ __metadata:
   linkType: hard
 
 "@next/bundle-analyzer@npm:^12.1.5":
-  version: 12.1.6
-  resolution: "@next/bundle-analyzer@npm:12.1.6"
+  version: 12.3.4
+  resolution: "@next/bundle-analyzer@npm:12.3.4"
   dependencies:
     webpack-bundle-analyzer: 4.3.0
-  checksum: cf37be49d45d706aea95df489656341bec64783e567067d15036b25330d7a69204987b2c402277f201b9bf943de588323b120fd8096bb3d6846a054bbb2cdc7e
+  checksum: 611cc07194a5cdd4aa0d1db5bae2de807cb2388d2c623f8d7ab8d581f8d01ec1510bd73ae3977334f7957278540ec0e2b2ebd48f9476235bd7b65055c560dc44
   languageName: node
   linkType: hard
 
-"@next/env@npm:12.1.6":
-  version: 12.1.6
-  resolution: "@next/env@npm:12.1.6"
-  checksum: e6a4f189f0d653d13dc7ad510f6c9d2cf690bfd9e07c554bd501b840f8dabc3da5adcab874b0bc01aab86c3647cff4fb84692e3c3b28125af26f0b05cd4c7fcf
+"@next/env@npm:12.3.4":
+  version: 12.3.4
+  resolution: "@next/env@npm:12.3.4"
+  checksum: daa3fc11efd1344c503eab41311a0e503ba7fd08607eeb3dc571036a6211eb37959cc4ed48b71dcc411cc214e7623ffd02411080aad3e09dc6a1192d5b256e60
   languageName: node
   linkType: hard
 
-"@next/eslint-plugin-next@npm:12.1.6":
-  version: 12.1.6
-  resolution: "@next/eslint-plugin-next@npm:12.1.6"
+"@next/eslint-plugin-next@npm:12.3.4":
+  version: 12.3.4
+  resolution: "@next/eslint-plugin-next@npm:12.3.4"
   dependencies:
     glob: 7.1.7
-  checksum: 33dcaf71f299d3c8a0744cad512369f92d7a355f3c0d57f2496e888e4242080c49226ec2c59ba2efac04b3a1df51c36019b853b4177df082ca4621a1713a2229
+  checksum: e4ae97062f3efe8f70904cf0da296ab501a2924423273352d01b18d8ffff1eb2e9a65c47dd6f9cfa0d696eada272486a3f519b2786918d0a9ab735b93f5ce4b3
   languageName: node
   linkType: hard
 
-"@next/swc-android-arm-eabi@npm:12.1.6":
-  version: 12.1.6
-  resolution: "@next/swc-android-arm-eabi@npm:12.1.6"
+"@next/swc-android-arm-eabi@npm:12.3.4":
+  version: 12.3.4
+  resolution: "@next/swc-android-arm-eabi@npm:12.3.4"
   conditions: os=android & cpu=arm
   languageName: node
   linkType: hard
 
-"@next/swc-android-arm64@npm:12.1.6":
-  version: 12.1.6
-  resolution: "@next/swc-android-arm64@npm:12.1.6"
+"@next/swc-android-arm64@npm:12.3.4":
+  version: 12.3.4
+  resolution: "@next/swc-android-arm64@npm:12.3.4"
   conditions: os=android & cpu=arm64
   languageName: node
   linkType: hard
 
-"@next/swc-darwin-arm64@npm:12.1.6":
-  version: 12.1.6
-  resolution: "@next/swc-darwin-arm64@npm:12.1.6"
+"@next/swc-darwin-arm64@npm:12.3.4":
+  version: 12.3.4
+  resolution: "@next/swc-darwin-arm64@npm:12.3.4"
   conditions: os=darwin & cpu=arm64
   languageName: node
   linkType: hard
 
-"@next/swc-darwin-x64@npm:12.1.6":
-  version: 12.1.6
-  resolution: "@next/swc-darwin-x64@npm:12.1.6"
+"@next/swc-darwin-x64@npm:12.3.4":
+  version: 12.3.4
+  resolution: "@next/swc-darwin-x64@npm:12.3.4"
   conditions: os=darwin & cpu=x64
   languageName: node
   linkType: hard
 
-"@next/swc-linux-arm-gnueabihf@npm:12.1.6":
-  version: 12.1.6
-  resolution: "@next/swc-linux-arm-gnueabihf@npm:12.1.6"
+"@next/swc-freebsd-x64@npm:12.3.4":
+  version: 12.3.4
+  resolution: "@next/swc-freebsd-x64@npm:12.3.4"
+  conditions: os=freebsd & cpu=x64
+  languageName: node
+  linkType: hard
+
+"@next/swc-linux-arm-gnueabihf@npm:12.3.4":
+  version: 12.3.4
+  resolution: "@next/swc-linux-arm-gnueabihf@npm:12.3.4"
   conditions: os=linux & cpu=arm
   languageName: node
   linkType: hard
 
-"@next/swc-linux-arm64-gnu@npm:12.1.6":
-  version: 12.1.6
-  resolution: "@next/swc-linux-arm64-gnu@npm:12.1.6"
+"@next/swc-linux-arm64-gnu@npm:12.3.4":
+  version: 12.3.4
+  resolution: "@next/swc-linux-arm64-gnu@npm:12.3.4"
   conditions: os=linux & cpu=arm64 & libc=glibc
   languageName: node
   linkType: hard
 
-"@next/swc-linux-arm64-musl@npm:12.1.6":
-  version: 12.1.6
-  resolution: "@next/swc-linux-arm64-musl@npm:12.1.6"
+"@next/swc-linux-arm64-musl@npm:12.3.4":
+  version: 12.3.4
+  resolution: "@next/swc-linux-arm64-musl@npm:12.3.4"
   conditions: os=linux & cpu=arm64 & libc=musl
   languageName: node
   linkType: hard
 
-"@next/swc-linux-x64-gnu@npm:12.1.6":
-  version: 12.1.6
-  resolution: "@next/swc-linux-x64-gnu@npm:12.1.6"
+"@next/swc-linux-x64-gnu@npm:12.3.4":
+  version: 12.3.4
+  resolution: "@next/swc-linux-x64-gnu@npm:12.3.4"
   conditions: os=linux & cpu=x64 & libc=glibc
   languageName: node
   linkType: hard
 
-"@next/swc-linux-x64-musl@npm:12.1.6":
-  version: 12.1.6
-  resolution: "@next/swc-linux-x64-musl@npm:12.1.6"
+"@next/swc-linux-x64-musl@npm:12.3.4":
+  version: 12.3.4
+  resolution: "@next/swc-linux-x64-musl@npm:12.3.4"
   conditions: os=linux & cpu=x64 & libc=musl
   languageName: node
   linkType: hard
 
-"@next/swc-win32-arm64-msvc@npm:12.1.6":
-  version: 12.1.6
-  resolution: "@next/swc-win32-arm64-msvc@npm:12.1.6"
+"@next/swc-win32-arm64-msvc@npm:12.3.4":
+  version: 12.3.4
+  resolution: "@next/swc-win32-arm64-msvc@npm:12.3.4"
   conditions: os=win32 & cpu=arm64
   languageName: node
   linkType: hard
 
-"@next/swc-win32-ia32-msvc@npm:12.1.6":
-  version: 12.1.6
-  resolution: "@next/swc-win32-ia32-msvc@npm:12.1.6"
+"@next/swc-win32-ia32-msvc@npm:12.3.4":
+  version: 12.3.4
+  resolution: "@next/swc-win32-ia32-msvc@npm:12.3.4"
   conditions: os=win32 & cpu=ia32
   languageName: node
   linkType: hard
 
-"@next/swc-win32-x64-msvc@npm:12.1.6":
-  version: 12.1.6
-  resolution: "@next/swc-win32-x64-msvc@npm:12.1.6"
+"@next/swc-win32-x64-msvc@npm:12.3.4":
+  version: 12.3.4
+  resolution: "@next/swc-win32-x64-msvc@npm:12.3.4"
   conditions: os=win32 & cpu=x64
   languageName: node
   linkType: hard
@@ -3255,6 +3608,24 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@node-kit/extra.fs@npm:3.2.0":
+  version: 3.2.0
+  resolution: "@node-kit/extra.fs@npm:3.2.0"
+  checksum: 48781d37ddd45f544774c17fccf31e1bfe648a16354cf8b20b28f0315798d977336a50c2a4cbb421fd9016792a0860cb2254e7450885324e7ace08903176b58b
+  languageName: node
+  linkType: hard
+
+"@node-kit/yarn-workspace-root@npm:^3.2.0":
+  version: 3.2.0
+  resolution: "@node-kit/yarn-workspace-root@npm:3.2.0"
+  dependencies:
+    "@node-kit/extra.fs": 3.2.0
+    find-up: ^5.0.0
+    micromatch: ^4.0.5
+  checksum: 18eca9649017f1b419a230909c319d57fe26400d3074685bb89946be30b3eb6670594dc7bb20d1a4d83cb4b991acf9818026b214fb879717f5ca0290ed934c3e
+  languageName: node
+  linkType: hard
+
 "@nodelib/fs.scandir@npm:2.1.5":
   version: 2.1.5
   resolution: "@nodelib/fs.scandir@npm:2.1.5"
@@ -3302,12 +3673,12 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@npmcli/promise-spawn@npm:^3.0.0":
-  version: 3.0.0
-  resolution: "@npmcli/promise-spawn@npm:3.0.0"
+"@npmcli/promise-spawn@npm:^7.0.0":
+  version: 7.0.0
+  resolution: "@npmcli/promise-spawn@npm:7.0.0"
   dependencies:
-    infer-owner: ^1.0.4
-  checksum: 3454465a2731cea5875ba51f80873e2205e5bd878c31517286b0ede4ea931c7bf3de895382287e906d03710fff6f9e44186bd0eee068ce578901c5d3b58e7692
+    which: ^4.0.0
+  checksum: 22a8c4fd4ef2729cf75d13b0b294e8c695e08bdb2143e951288056656091fc5281e8baf330c97a6bc803e6fc09489028bf80dcd787972597ef9fda9a9349fc0f
   languageName: node
   linkType: hard
 
@@ -3318,6 +3689,54 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@playground/next-playground@workspace:playgrounds/next-playground":
+  version: 0.0.0-use.local
+  resolution: "@playground/next-playground@workspace:playgrounds/next-playground"
+  dependencies:
+    "@builder.io/partytown": ^0.7.4
+    "@next/bundle-analyzer": ^12.1.5
+    "@segment/analytics-next": "workspace:^"
+    "@types/faker": ^5.1.2
+    "@types/react": ^17.0.37
+    eslint-config-next: ^12.1.6
+    faker: ^5.1.0
+    lodash: ^4.17.21
+    next: ^12.1.0
+    prismjs: ^1.27.0
+    rc-table: ^7.10.0
+    react: ^17.0.2
+    react-dom: ^17.0.2
+    react-json-tree: ^0.13.0
+    react-simple-code-editor: ^0.11.0
+    source-map-loader: ^3.0.1
+  languageName: unknown
+  linkType: soft
+
+"@playground/standalone-playground@workspace:playgrounds/standalone-playground":
+  version: 0.0.0-use.local
+  resolution: "@playground/standalone-playground@workspace:playgrounds/standalone-playground"
+  dependencies:
+    "@segment/analytics-consent-wrapper-onetrust": "workspace:^"
+    "@segment/analytics-next": "workspace:^"
+    http-server: 14.1.1
+  languageName: unknown
+  linkType: soft
+
+"@playground/with-vite@workspace:playgrounds/with-vite":
+  version: 0.0.0-use.local
+  resolution: "@playground/with-vite@workspace:playgrounds/with-vite"
+  dependencies:
+    "@segment/analytics-next": "workspace:^"
+    "@types/react": ^18.0.0
+    "@types/react-dom": ^18.0.0
+    "@vitejs/plugin-react": ^1.3.0
+    react: ^18.0.0
+    react-dom: ^18.0.0
+    typescript: ^4.7.0
+    vite: ^2.9.17
+  languageName: unknown
+  linkType: soft
+
 "@playwright/test@npm:^1.28.1":
   version: 1.28.1
   resolution: "@playwright/test@npm:1.28.1"
@@ -3400,6 +3819,19 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@rc-component/context@npm:^1.4.0":
+  version: 1.4.0
+  resolution: "@rc-component/context@npm:1.4.0"
+  dependencies:
+    "@babel/runtime": ^7.10.1
+    rc-util: ^5.27.0
+  peerDependencies:
+    react: ">=16.9.0"
+    react-dom: ">=16.9.0"
+  checksum: 3771237de1e82a453cfff7b5f0ca0dcc370a2838be8ecbfe172c26dec2e94dc2354a8b3061deaff7e633e418fc1b70ce3d10d770603f12dc477fe03f2ada7059
+  languageName: node
+  linkType: hard
+
 "@rollup/pluginutils@npm:^4.2.1":
   version: 4.2.1
   resolution: "@rollup/pluginutils@npm:4.2.1"
@@ -3411,9 +3843,9 @@ __metadata:
   linkType: hard
 
 "@rushstack/eslint-patch@npm:^1.1.3":
-  version: 1.1.3
-  resolution: "@rushstack/eslint-patch@npm:1.1.3"
-  checksum: 53752d1e34e45a91b30a016b837c33054fcbd0a295c0312b0812dab78289ea680d7c0c3f19c1f885f49764d416727747133765ff5bfce31a9c4cc93c7a56ebe1
+  version: 1.6.0
+  resolution: "@rushstack/eslint-patch@npm:1.6.0"
+  checksum: 9fbc39e6070508139ac9ded5cc223780315a1e65ccb7612dd3dff07a0957fa9985a2b049bb5cae21d7eeed44ed315e2868b8755941500dc64ed9932c5760c80d
   languageName: node
   linkType: hard
 
@@ -3470,13 +3902,14 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@segment/analytics-consent-tools@0.2.0, @segment/analytics-consent-tools@workspace:^, @segment/analytics-consent-tools@workspace:packages/consent/consent-tools":
+"@segment/analytics-consent-tools@1.2.0, @segment/analytics-consent-tools@workspace:^, @segment/analytics-consent-tools@workspace:packages/consent/consent-tools":
   version: 0.0.0-use.local
   resolution: "@segment/analytics-consent-tools@workspace:packages/consent/consent-tools"
   dependencies:
     "@internal/config": "workspace:^"
     "@internal/test-helpers": "workspace:^"
     "@segment/analytics-next": "workspace:^"
+    tslib: ^2.4.1
   peerDependencies:
     "@segment/analytics-next": ">=1.53.1"
   peerDependenciesMeta:
@@ -3491,7 +3924,8 @@ __metadata:
   dependencies:
     "@internal/config-webpack": "workspace:^"
     "@internal/test-helpers": "workspace:^"
-    "@segment/analytics-consent-tools": 0.2.0
+    "@segment/analytics-consent-tools": 1.2.0
+    tslib: ^2.4.1
   peerDependencies:
     "@segment/analytics-next": ">=1.53.1"
   peerDependenciesMeta:
@@ -3505,11 +3939,20 @@ __metadata:
   resolution: "@segment/analytics-core@workspace:packages/core"
   dependencies:
     "@lukeed/uuid": ^2.0.0
+    "@segment/analytics-generic-utils": 1.2.0
     dset: ^3.1.2
     tslib: ^2.4.1
   languageName: unknown
   linkType: soft
 
+"@segment/analytics-generic-utils@1.2.0, @segment/analytics-generic-utils@workspace:packages/generic-utils":
+  version: 0.0.0-use.local
+  resolution: "@segment/analytics-generic-utils@workspace:packages/generic-utils"
+  dependencies:
+    tslib: ^2.4.1
+  languageName: unknown
+  linkType: soft
+
 "@segment/analytics-next@workspace:*, @segment/analytics-next@workspace:packages/browser":
   version: 0.0.0-use.local
   resolution: "@segment/analytics-next@workspace:packages/browser"
@@ -3517,7 +3960,8 @@ __metadata:
     "@internal/config": 0.0.0
     "@lukeed/uuid": ^2.0.0
     "@segment/analytics-browser-actions-braze": ^1.3.0
-    "@segment/analytics-core": 1.3.1
+    "@segment/analytics-core": 1.5.0
+    "@segment/analytics-generic-utils": 1.2.0
     "@segment/analytics.js-integration": ^3.3.3
     "@segment/analytics.js-integration-amplitude": ^3.3.3
     "@segment/analytics.js-video-plugins": ^0.2.1
@@ -3573,10 +4017,12 @@ __metadata:
   dependencies:
     "@internal/config": 0.0.0
     "@lukeed/uuid": ^2.0.0
-    "@segment/analytics-core": 1.3.1
+    "@segment/analytics-core": 1.5.0
+    "@segment/analytics-generic-utils": 1.2.0
     "@types/node": ^16
-    axios: ^1.4.0
+    axios: ^1.6.0
     buffer: ^6.0.3
+    jose: ^5.1.0
     node-fetch: ^2.6.7
     tslib: ^2.4.1
   languageName: unknown
@@ -3848,6 +4294,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@sinclair/typebox@npm:^0.27.8":
+  version: 0.27.8
+  resolution: "@sinclair/typebox@npm:0.27.8"
+  checksum: 00bd7362a3439021aa1ea51b0e0d0a0e8ca1351a3d54c606b115fdcc49b51b16db6e5f43b4fe7a28c38688523e22a94d49dd31168868b655f0d4d50f032d07a1
+  languageName: node
+  linkType: hard
+
 "@sindresorhus/is@npm:^0.14.0":
   version: 0.14.0
   resolution: "@sindresorhus/is@npm:0.14.0"
@@ -3871,6 +4324,24 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@sinonjs/commons@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "@sinonjs/commons@npm:3.0.0"
+  dependencies:
+    type-detect: 4.0.8
+  checksum: b4b5b73d4df4560fb8c0c7b38c7ad4aeabedd362f3373859d804c988c725889cde33550e4bcc7cd316a30f5152a2d1d43db71b6d0c38f5feef71fd8d016763f8
+  languageName: node
+  linkType: hard
+
+"@sinonjs/fake-timers@npm:^10.0.2":
+  version: 10.3.0
+  resolution: "@sinonjs/fake-timers@npm:10.3.0"
+  dependencies:
+    "@sinonjs/commons": ^3.0.0
+  checksum: 614d30cb4d5201550c940945d44c9e0b6d64a888ff2cd5b357f95ad6721070d6b8839cd10e15b76bf5e14af0bcc1d8f9ec00d49a46318f1f669a4bec1d7f3148
+  languageName: node
+  linkType: hard
+
 "@sinonjs/fake-timers@npm:^9.1.1":
   version: 9.1.2
   resolution: "@sinonjs/fake-timers@npm:9.1.2"
@@ -4918,6 +5389,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@swc/helpers@npm:0.4.11":
+  version: 0.4.11
+  resolution: "@swc/helpers@npm:0.4.11"
+  dependencies:
+    tslib: ^2.4.0
+  checksum: 736857d524b41a8a4db81094e9b027f554004e0fa3e86325d85bdb38f7e6459ce022db079edb6c61ba0f46fe8583b3e663e95f7acbd13e51b8da6c34e45bba2e
+  languageName: node
+  linkType: hard
+
 "@szmarczak/http-timer@npm:^1.1.2":
   version: 1.1.2
   resolution: "@szmarczak/http-timer@npm:1.1.2"
@@ -5043,9 +5523,9 @@ __metadata:
   linkType: hard
 
 "@types/base16@npm:^1.0.2":
-  version: 1.0.2
-  resolution: "@types/base16@npm:1.0.2"
-  checksum: 5bc587d45066dae6fb14a4a7f72e57044fa1a8057eed86d4005f9e38a0c38b83009828ac3072e6e9e4be1cf5d5032849690adf8cd51efd545fa281bee5cf0fa5
+  version: 1.0.5
+  resolution: "@types/base16@npm:1.0.5"
+  checksum: caa391944e9af95d395ad8a489ad7e6bc3b358f73f876215313e8a2f97a18d109d8ab3d6d92bd473d9a1c782639a82921a3c30124922f2492082b550a2e9f6a6
   languageName: node
   linkType: hard
 
@@ -5284,13 +5764,13 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@types/jest@npm:^28.1.1":
-  version: 28.1.1
-  resolution: "@types/jest@npm:28.1.1"
+"@types/jest@npm:^29.5.11":
+  version: 29.5.11
+  resolution: "@types/jest@npm:29.5.11"
   dependencies:
-    jest-matcher-utils: ^27.0.0
-    pretty-format: ^27.0.0
-  checksum: 0a8b045a7b660372decc807c390d3f99a2b12bb1659a1cd593afe04557f4b7c235b0576a5e35b1577710d20e42759d3d8755eb8bed6edc8733f47007e75a5509
+    expect: ^29.0.0
+    pretty-format: ^29.0.0
+  checksum: f892a06ec9f0afa9a61cd7fa316ec614e21d4df1ad301b5a837787e046fcb40dfdf7f264a55e813ac6b9b633cb9d366bd5b8d1cea725e84102477b366df23fdd
   languageName: node
   linkType: hard
 
@@ -5366,9 +5846,9 @@ __metadata:
   linkType: hard
 
 "@types/lodash@npm:^4.14.178":
-  version: 4.14.182
-  resolution: "@types/lodash@npm:4.14.182"
-  checksum: 7dd137aa9dbabd632408bd37009d984655164fa1ecc3f2b6eb94afe35bf0a5852cbab6183148d883e9c73a958b7fec9a9bcf7c8e45d41195add6a18c34958209
+  version: 4.14.202
+  resolution: "@types/lodash@npm:4.14.202"
+  checksum: a91acf3564a568c6f199912f3eb2c76c99c5a0d7e219394294213b3f2d54f672619f0fde4da22b29dc5d4c31457cd799acc2e5cb6bd90f9af04a1578483b6ff7
   languageName: node
   linkType: hard
 
@@ -5466,17 +5946,10 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@types/prettier@npm:^2.1.5":
-  version: 2.6.3
-  resolution: "@types/prettier@npm:2.6.3"
-  checksum: e1836699ca189fff6d2a73dc22e028b6a6f693ed1180d5998ac29fa197caf8f85aa92cb38db642e4a370e616b451cb5722ad2395dab11c78e025a1455f37d1f0
-  languageName: node
-  linkType: hard
-
 "@types/prop-types@npm:*, @types/prop-types@npm:^15.7.3":
-  version: 15.7.5
-  resolution: "@types/prop-types@npm:15.7.5"
-  checksum: 5b43b8b15415e1f298243165f1d44390403bb2bd42e662bca3b5b5633fdd39c938e91b7fce3a9483699db0f7a715d08cef220c121f723a634972fdf596aec980
+  version: 15.7.11
+  resolution: "@types/prop-types@npm:15.7.11"
+  checksum: 7519ff11d06fbf6b275029fe03fff9ec377b4cb6e864cac34d87d7146c7f5a7560fd164bdc1d2dbe00b60c43713631251af1fd3d34d46c69cd354602bc0c7c54
   languageName: node
   linkType: hard
 
@@ -5495,33 +5968,33 @@ __metadata:
   linkType: hard
 
 "@types/react-dom@npm:^18.0.0":
-  version: 18.0.5
-  resolution: "@types/react-dom@npm:18.0.5"
+  version: 18.2.17
+  resolution: "@types/react-dom@npm:18.2.17"
   dependencies:
     "@types/react": "*"
-  checksum: cd48b81950f499b52a3f0c08261f00046f9b7c96699fa249c9664e257e820daf6ecac815cd1028cebc9d105094adc39d047d1efd79214394b8b2d515574c0787
+  checksum: 7a4e704ed4be6e0c3ccd8a22ff69386fe548304bf4db090513f42e059ff4c65f7a427790320051524d6578a2e4c9667bb7a80a4c989b72361c019fbe851d9385
   languageName: node
   linkType: hard
 
 "@types/react@npm:*, @types/react@npm:^18.0.0":
-  version: 18.0.14
-  resolution: "@types/react@npm:18.0.14"
+  version: 18.2.39
+  resolution: "@types/react@npm:18.2.39"
   dependencies:
     "@types/prop-types": "*"
     "@types/scheduler": "*"
     csstype: ^3.0.2
-  checksum: 608eb57a383eedc54c79949673e5e8314f6b0c61542bff58721c8c47a18c23e2832e77c656050c2c2c004b62cf25582136c7c56fe1b6263a285c065fae31dbcf
+  checksum: 9bcb1f1f060f1bf8f4730fb1c7772d0323a6e707f274efee3b976c40d92af4677df4d88e9135faaacf34e13e02f92ef24eb7d0cbcf7fb75c1883f5623ccb19f4
   languageName: node
   linkType: hard
 
 "@types/react@npm:^17.0.37":
-  version: 17.0.45
-  resolution: "@types/react@npm:17.0.45"
+  version: 17.0.71
+  resolution: "@types/react@npm:17.0.71"
   dependencies:
     "@types/prop-types": "*"
     "@types/scheduler": "*"
     csstype: ^3.0.2
-  checksum: 3cc13a02824c13f6fa4807a83abd065ac1d9943359e76bd995cc7cd2b4148c1176ebd54a30a9f4eb8a0f141ff359d712876f256c4fee707e4290607ef8410b3e
+  checksum: c72dbebdced882fa39de867b0179ed91259331172458d69250ff30fdb3c61e3d1f3373dacca3771c3de4b19162fd65758179252b17961729213496a016b918d7
   languageName: node
   linkType: hard
 
@@ -5542,9 +6015,9 @@ __metadata:
   linkType: hard
 
 "@types/scheduler@npm:*":
-  version: 0.16.2
-  resolution: "@types/scheduler@npm:0.16.2"
-  checksum: b6b4dcfeae6deba2e06a70941860fb1435730576d3689225a421280b7742318d1548b3d22c1f66ab68e414f346a9542f29240bc955b6332c5b11e561077583bc
+  version: 0.16.8
+  resolution: "@types/scheduler@npm:0.16.8"
+  checksum: 6c091b096daa490093bf30dd7947cd28e5b2cd612ec93448432b33f724b162587fed9309a0acc104d97b69b1d49a0f3fc755a62282054d62975d53d7fd13472d
   languageName: node
   linkType: hard
 
@@ -6601,7 +7074,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"acorn-walk@npm:^8.1.1":
+"acorn-walk@npm:^8.1.1, acorn-walk@npm:^8.2.0":
   version: 8.2.0
   resolution: "acorn-walk@npm:8.2.0"
   checksum: 1715e76c01dd7b2d4ca472f9c58968516a4899378a63ad5b6c2d668bba8da21a71976c14ec5f5b75f887b6317c4ae0b897ab141c831d741dc76024d8745f1ad1
@@ -6644,7 +7117,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"acorn@npm:^8.8.2":
+"acorn@npm:^8.8.0, acorn@npm:^8.8.2":
   version: 8.10.0
   resolution: "acorn@npm:8.10.0"
   bin:
@@ -6787,9 +7260,9 @@ __metadata:
   dependencies:
     "@changesets/changelog-github": ^0.4.5
     "@changesets/cli": ^2.23.2
-    "@npmcli/promise-spawn": ^3.0.0
+    "@npmcli/promise-spawn": ^7.0.0
     "@types/express": 4
-    "@types/jest": ^28.1.1
+    "@types/jest": ^29.5.11
     "@types/lodash": ^4
     "@types/node-fetch": ^2.6.2
     "@typescript-eslint/eslint-plugin": ^5.21.0
@@ -6803,15 +7276,15 @@ __metadata:
     express: ^4.18.2
     get-monorepo-packages: ^1.2.0
     husky: ^8.0.0
-    jest: ^28.1.0
+    jest: ^29.7.0
     lint-staged: ^13.0.0
     lodash: ^4.17.21
     nock: ^13.3.0
     node-gyp: ^9.0.0
     prettier: ^2.6.2
-    ts-jest: ^28.0.4
+    ts-jest: ^29.1.1
     ts-node: ^10.8.0
-    turbo: ^1.3.1
+    turbo: ^1.10.14
     typescript: ^4.7.0
     webpack: ^5.76.0
     webpack-dev-server: ^4.15.1
@@ -7090,17 +7563,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"aria-query@npm:^4.2.2":
-  version: 4.2.2
-  resolution: "aria-query@npm:4.2.2"
-  dependencies:
-    "@babel/runtime": ^7.10.2
-    "@babel/runtime-corejs3": ^7.10.2
-  checksum: 38401a9a400f26f3dcc24b84997461a16b32869a9893d323602bed8da40a8bcc0243b8d2880e942249a1496cea7a7de769e93d21c0baa439f01e1ee936fed665
-  languageName: node
-  linkType: hard
-
-"aria-query@npm:^5.0.0":
+"aria-query@npm:^5.0.0, aria-query@npm:^5.3.0":
   version: 5.3.0
   resolution: "aria-query@npm:5.3.0"
   dependencies:
@@ -7140,19 +7603,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"array-includes@npm:^3.1.4, array-includes@npm:^3.1.5":
-  version: 3.1.5
-  resolution: "array-includes@npm:3.1.5"
-  dependencies:
-    call-bind: ^1.0.2
-    define-properties: ^1.1.4
-    es-abstract: ^1.19.5
-    get-intrinsic: ^1.1.1
-    is-string: ^1.0.7
-  checksum: f6f24d834179604656b7bec3e047251d5cc87e9e87fab7c175c61af48e80e75acd296017abcde21fb52292ab6a2a449ab2ee37213ee48c8709f004d75983f9c5
-  languageName: node
-  linkType: hard
-
 "array-includes@npm:^3.1.6":
   version: 3.1.6
   resolution: "array-includes@npm:3.1.6"
@@ -7166,6 +7616,19 @@ __metadata:
   languageName: node
   linkType: hard
 
+"array-includes@npm:^3.1.7":
+  version: 3.1.7
+  resolution: "array-includes@npm:3.1.7"
+  dependencies:
+    call-bind: ^1.0.2
+    define-properties: ^1.2.0
+    es-abstract: ^1.22.1
+    get-intrinsic: ^1.2.1
+    is-string: ^1.0.7
+  checksum: 06f9e4598fac12a919f7c59a3f04f010ea07f0b7f0585465ed12ef528a60e45f374e79d1bddbb34cdd4338357d00023ddbd0ac18b0be36964f5e726e8965d7fc
+  languageName: node
+  linkType: hard
+
 "array-union@npm:^1.0.1":
   version: 1.0.2
   resolution: "array-union@npm:1.0.2"
@@ -7189,7 +7652,20 @@ __metadata:
   languageName: node
   linkType: hard
 
-"array.prototype.flat@npm:^1.2.3, array.prototype.flat@npm:^1.2.5":
+"array.prototype.findlastindex@npm:^1.2.3":
+  version: 1.2.3
+  resolution: "array.prototype.findlastindex@npm:1.2.3"
+  dependencies:
+    call-bind: ^1.0.2
+    define-properties: ^1.2.0
+    es-abstract: ^1.22.1
+    es-shim-unscopables: ^1.0.0
+    get-intrinsic: ^1.2.1
+  checksum: 31f35d7b370c84db56484618132041a9af401b338f51899c2e78ef7690fbba5909ee7ca3c59a7192085b328cc0c68c6fd1f6d1553db01a689a589ae510f3966e
+  languageName: node
+  linkType: hard
+
+"array.prototype.flat@npm:^1.2.3":
   version: 1.3.0
   resolution: "array.prototype.flat@npm:1.3.0"
   dependencies:
@@ -7213,15 +7689,15 @@ __metadata:
   languageName: node
   linkType: hard
 
-"array.prototype.flatmap@npm:^1.3.0":
-  version: 1.3.0
-  resolution: "array.prototype.flatmap@npm:1.3.0"
+"array.prototype.flat@npm:^1.3.2":
+  version: 1.3.2
+  resolution: "array.prototype.flat@npm:1.3.2"
   dependencies:
     call-bind: ^1.0.2
-    define-properties: ^1.1.3
-    es-abstract: ^1.19.2
+    define-properties: ^1.2.0
+    es-abstract: ^1.22.1
     es-shim-unscopables: ^1.0.0
-  checksum: 818538f39409c4045d874be85df0dbd195e1446b14d22f95bdcfefea44ae77db44e42dcd89a559254ec5a7c8b338cfc986cc6d641e3472f9a5326b21eb2976a2
+  checksum: 5d6b4bf102065fb3f43764bfff6feb3295d372ce89591e6005df3d0ce388527a9f03c909af6f2a973969a4d178ab232ffc9236654149173e0e187ec3a1a6b87b
   languageName: node
   linkType: hard
 
@@ -7237,6 +7713,46 @@ __metadata:
   languageName: node
   linkType: hard
 
+"array.prototype.flatmap@npm:^1.3.2":
+  version: 1.3.2
+  resolution: "array.prototype.flatmap@npm:1.3.2"
+  dependencies:
+    call-bind: ^1.0.2
+    define-properties: ^1.2.0
+    es-abstract: ^1.22.1
+    es-shim-unscopables: ^1.0.0
+  checksum: ce09fe21dc0bcd4f30271f8144083aa8c13d4639074d6c8dc82054b847c7fc9a0c97f857491f4da19d4003e507172a78f4bcd12903098adac8b9cd374f734be3
+  languageName: node
+  linkType: hard
+
+"array.prototype.tosorted@npm:^1.1.1":
+  version: 1.1.2
+  resolution: "array.prototype.tosorted@npm:1.1.2"
+  dependencies:
+    call-bind: ^1.0.2
+    define-properties: ^1.2.0
+    es-abstract: ^1.22.1
+    es-shim-unscopables: ^1.0.0
+    get-intrinsic: ^1.2.1
+  checksum: 3607a7d6b117f0ffa6f4012457b7af0d47d38cf05e01d50e09682fd2fb782a66093a5e5fbbdbad77c8c824794a9d892a51844041641f719ad41e3a974f0764de
+  languageName: node
+  linkType: hard
+
+"arraybuffer.prototype.slice@npm:^1.0.2":
+  version: 1.0.2
+  resolution: "arraybuffer.prototype.slice@npm:1.0.2"
+  dependencies:
+    array-buffer-byte-length: ^1.0.0
+    call-bind: ^1.0.2
+    define-properties: ^1.2.0
+    es-abstract: ^1.22.1
+    get-intrinsic: ^1.2.1
+    is-array-buffer: ^3.0.2
+    is-shared-array-buffer: ^1.0.2
+  checksum: c200faf437786f5b2c80d4564ff5481c886a16dee642ef02abdc7306c7edd523d1f01d1dd12b769c7eb42ac9bc53874510db19a92a2c035c0f6696172aafa5d3
+  languageName: node
+  linkType: hard
+
 "arrify@npm:^1.0.1":
   version: 1.0.1
   resolution: "arrify@npm:1.0.1"
@@ -7244,10 +7760,19 @@ __metadata:
   languageName: node
   linkType: hard
 
-"ast-types-flow@npm:^0.0.7":
-  version: 0.0.7
-  resolution: "ast-types-flow@npm:0.0.7"
-  checksum: a26dcc2182ffee111cad7c471759b0bda22d3b7ebacf27c348b22c55f16896b18ab0a4d03b85b4020dce7f3e634b8f00b593888f622915096ea1927fa51866c4
+"as-table@npm:^1.0.36":
+  version: 1.0.55
+  resolution: "as-table@npm:1.0.55"
+  dependencies:
+    printable-characters: ^1.0.42
+  checksum: 341c99d9e99a702c315b3f0744d49b4764b26ef7ddd32bafb9e1706626560c0e599100521fc1b17f640e804bd0503ce70b2ba519c023da6edf06bdd9086dccd9
+  languageName: node
+  linkType: hard
+
+"ast-types-flow@npm:^0.0.8":
+  version: 0.0.8
+  resolution: "ast-types-flow@npm:0.0.8"
+  checksum: 0a64706609a179233aac23817837abab614f3548c252a2d3d79ea1e10c74aa28a0846e11f466cf72771b6ed8713abc094dcf8c40c3ec4207da163efa525a94a8
   languageName: node
   linkType: hard
 
@@ -7290,6 +7815,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"asynciterator.prototype@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "asynciterator.prototype@npm:1.0.0"
+  dependencies:
+    has-symbols: ^1.0.3
+  checksum: e8ebfd9493ac651cf9b4165e9d64030b3da1d17181bb1963627b59e240cdaf021d9b59d44b827dc1dde4e22387ec04c2d0f8720cf58a1c282e34e40cc12721b3
+  languageName: node
+  linkType: hard
+
 "asynckit@npm:^0.4.0":
   version: 0.4.0
   resolution: "asynckit@npm:0.4.0"
@@ -7375,10 +7909,10 @@ __metadata:
   languageName: node
   linkType: hard
 
-"axe-core@npm:^4.3.5":
-  version: 4.4.2
-  resolution: "axe-core@npm:4.4.2"
-  checksum: 93fbb36c5ac8ab5e67e49678a6f7be0dc799a9f560edd95cca1f0a8183def8c50205972366b9941a3ea2b20224a1fe230e6d87ef38cb6db70472ed1b694febd1
+"axe-core@npm:=4.7.0":
+  version: 4.7.0
+  resolution: "axe-core@npm:4.7.0"
+  checksum: f086bcab42be1761ba2b0b127dec350087f4c3a853bba8dd58f69d898cefaac31a1561da23146f6f3c07954c76171d1f2ce460e555e052d2b02cd79af628fa4a
   languageName: node
   linkType: hard
 
@@ -7410,21 +7944,23 @@ __metadata:
   languageName: node
   linkType: hard
 
-"axios@npm:^1.4.0":
-  version: 1.4.0
-  resolution: "axios@npm:1.4.0"
+"axios@npm:^1.6.0":
+  version: 1.6.1
+  resolution: "axios@npm:1.6.1"
   dependencies:
     follow-redirects: ^1.15.0
     form-data: ^4.0.0
     proxy-from-env: ^1.1.0
-  checksum: 7fb6a4313bae7f45e89d62c70a800913c303df653f19eafec88e56cea2e3821066b8409bc68be1930ecca80e861c52aa787659df0ffec6ad4d451c7816b9386b
+  checksum: 573f03f59b7487d54551b16f5e155d1d130ad4864ed32d1da93d522b78a57123b34e3bde37f822a65ee297e79f1db840f9ad6514addff50d3cbf5caeed39e8dc
   languageName: node
   linkType: hard
 
-"axobject-query@npm:^2.2.0":
-  version: 2.2.0
-  resolution: "axobject-query@npm:2.2.0"
-  checksum: 96b8c7d807ca525f41ad9b286186e2089b561ba63a6d36c3e7d73dc08150714660995c7ad19cda05784458446a0793b45246db45894631e13853f48c1aa3117f
+"axobject-query@npm:^3.2.1":
+  version: 3.2.1
+  resolution: "axobject-query@npm:3.2.1"
+  dependencies:
+    dequal: ^2.0.3
+  checksum: a94047e702b57c91680e6a952ec4a1aaa2cfd0d80ead76bc8c954202980d8c51968a6ea18b4d8010e8e2cf95676533d8022a8ebba9abc1dfe25686721df26fd2
   languageName: node
   linkType: hard
 
@@ -7444,20 +7980,20 @@ __metadata:
   languageName: node
   linkType: hard
 
-"babel-jest@npm:^28.1.1":
-  version: 28.1.1
-  resolution: "babel-jest@npm:28.1.1"
+"babel-jest@npm:^29.7.0":
+  version: 29.7.0
+  resolution: "babel-jest@npm:29.7.0"
   dependencies:
-    "@jest/transform": ^28.1.1
+    "@jest/transform": ^29.7.0
     "@types/babel__core": ^7.1.14
     babel-plugin-istanbul: ^6.1.1
-    babel-preset-jest: ^28.1.1
+    babel-preset-jest: ^29.6.3
     chalk: ^4.0.0
     graceful-fs: ^4.2.9
     slash: ^3.0.0
   peerDependencies:
     "@babel/core": ^7.8.0
-  checksum: 9c7c7f600685d51873bf1faee223a8720d73c0cc6d551dcf0cabd452cd5295d17adcef4c3f9baa1dba22d4c057bc4519bed096a1bb3e24cb2d066ba67b8f615a
+  checksum: ee6f8e0495afee07cac5e4ee167be705c711a8cc8a737e05a587a131fdae2b3c8f9aa55dfd4d9c03009ac2d27f2de63d8ba96d3e8460da4d00e8af19ef9a83f7
   languageName: node
   linkType: hard
 
@@ -7489,15 +8025,15 @@ __metadata:
   languageName: node
   linkType: hard
 
-"babel-plugin-jest-hoist@npm:^28.1.1":
-  version: 28.1.1
-  resolution: "babel-plugin-jest-hoist@npm:28.1.1"
+"babel-plugin-jest-hoist@npm:^29.6.3":
+  version: 29.6.3
+  resolution: "babel-plugin-jest-hoist@npm:29.6.3"
   dependencies:
     "@babel/template": ^7.3.3
     "@babel/types": ^7.3.3
     "@types/babel__core": ^7.1.14
     "@types/babel__traverse": ^7.0.6
-  checksum: 5fb9ad012e4613e7d321b61a875371dd10e171ef3df2e9c87be25fda62c3c7ad759821e40a9da18f611054727309c38f10e3502583f697312cb9cd1e92616756
+  checksum: 51250f22815a7318f17214a9d44650ba89551e6d4f47a2dc259128428324b52f5a73979d010cefd921fd5a720d8c1d55ad74ff601cd94c7bd44d5f6292fde2d1
   languageName: node
   linkType: hard
 
@@ -7559,15 +8095,15 @@ __metadata:
   languageName: node
   linkType: hard
 
-"babel-preset-jest@npm:^28.1.1":
-  version: 28.1.1
-  resolution: "babel-preset-jest@npm:28.1.1"
+"babel-preset-jest@npm:^29.6.3":
+  version: 29.6.3
+  resolution: "babel-preset-jest@npm:29.6.3"
   dependencies:
-    babel-plugin-jest-hoist: ^28.1.1
+    babel-plugin-jest-hoist: ^29.6.3
     babel-preset-current-node-syntax: ^1.0.0
   peerDependencies:
     "@babel/core": ^7.0.0
-  checksum: c581a81967aa30eba71a5a5a28eca2cc082901f3e6823c17e5b4ef7ba10f1347494a8e77d785b09ba7e86d3f902f2e13f5b75854d2af7bf9b489924629a87bad
+  checksum: aa4ff2a8a728d9d698ed521e3461a109a1e66202b13d3494e41eea30729a5e7cc03b3a2d56c594423a135429c37bf63a9fa8b0b9ce275298be3095a88c69f6fb
   languageName: node
   linkType: hard
 
@@ -7673,6 +8209,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"blake3-wasm@npm:^2.1.5":
+  version: 2.1.5
+  resolution: "blake3-wasm@npm:2.1.5"
+  checksum: 5088e929c722b52b9c28701c1760ab850a963692056a417b894c943030e3267f12138ae6409e79069b8d7d0401a411426147e8d812b65a49e303fa432af18871
+  languageName: node
+  linkType: hard
+
 "bluebird@npm:~3.4.1":
   version: 3.4.7
   resolution: "bluebird@npm:3.4.7"
@@ -8016,6 +8559,17 @@ __metadata:
   languageName: node
   linkType: hard
 
+"call-bind@npm:^1.0.4, call-bind@npm:^1.0.5":
+  version: 1.0.5
+  resolution: "call-bind@npm:1.0.5"
+  dependencies:
+    function-bind: ^1.1.2
+    get-intrinsic: ^1.2.1
+    set-function-length: ^1.1.1
+  checksum: 449e83ecbd4ba48e7eaac5af26fea3b50f8f6072202c2dd7c5a6e7a6308f2421abe5e13a3bbd55221087f76320c5e09f25a8fdad1bab2b77c68ae74d92234ea5
+  languageName: node
+  linkType: hard
+
 "callsites@npm:^3.0.0":
   version: 3.1.0
   resolution: "callsites@npm:3.1.0"
@@ -8096,13 +8650,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"caniuse-lite@npm:^1.0.30001332":
-  version: 1.0.30001346
-  resolution: "caniuse-lite@npm:1.0.30001346"
-  checksum: 951590454ffa4e2e7b772558dc593cd08604b44c83741e1188166298f54c34387f4bf34f5141a35de4a43028c012484240ad15c896e48bf4eac70dd7076a4449
-  languageName: node
-  linkType: hard
-
 "caniuse-lite@npm:^1.0.30001349":
   version: 1.0.30001352
   resolution: "caniuse-lite@npm:1.0.30001352"
@@ -8110,6 +8657,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"caniuse-lite@npm:^1.0.30001406":
+  version: 1.0.30001565
+  resolution: "caniuse-lite@npm:1.0.30001565"
+  checksum: 7621f358d0e1158557430a111ca5506008ae0b2c796039ef53aeebf4e2ba15e5241cb89def21ea3a633b6a609273085835b44a522165d871fa44067cdf29cccd
+  languageName: node
+  linkType: hard
+
 "caniuse-lite@npm:^1.0.30001517":
   version: 1.0.30001523
   resolution: "caniuse-lite@npm:1.0.30001523"
@@ -8117,6 +8671,16 @@ __metadata:
   languageName: node
   linkType: hard
 
+"capnp-ts@npm:^0.7.0":
+  version: 0.7.0
+  resolution: "capnp-ts@npm:0.7.0"
+  dependencies:
+    debug: ^4.3.1
+    tslib: ^2.2.0
+  checksum: 9ab495a887c5d5fd56afa3cc930733cd8c6c0743c52c2e79c46675eb5c7753e5578f71348628a4b3d9f03e5269bc71f811e58af18d0b0557607c4ee56189cfbb
+  languageName: node
+  linkType: hard
+
 "chainsaw@npm:~0.1.0":
   version: 0.1.0
   resolution: "chainsaw@npm:0.1.0"
@@ -8349,10 +8913,10 @@ __metadata:
   languageName: node
   linkType: hard
 
-"classnames@npm:^2.2.1, classnames@npm:^2.2.5":
-  version: 2.3.1
-  resolution: "classnames@npm:2.3.1"
-  checksum: 14db8889d56c267a591f08b0834989fe542d47fac659af5a539e110cc4266694e8de86e4e3bbd271157dbd831361310a8293e0167141e80b0f03a0f175c80960
+"classnames@npm:^2.2.1, classnames@npm:^2.2.5, classnames@npm:^2.2.6":
+  version: 2.3.2
+  resolution: "classnames@npm:2.3.2"
+  checksum: 2c62199789618d95545c872787137262e741f9db13328e216b093eea91c85ef2bfb152c1f9e63027204e2559a006a92eb74147d46c800a9f96297ae1d9f96f4e
   languageName: node
   linkType: hard
 
@@ -8829,7 +9393,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.7.0":
   version: 1.7.0
   resolution: "convert-source-map@npm:1.7.0"
   dependencies:
@@ -8838,6 +9402,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"convert-source-map@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "convert-source-map@npm:2.0.0"
+  checksum: 63ae9933be5a2b8d4509daca5124e20c14d023c820258e484e32dc324d34c2754e71297c94a05784064ad27615037ef677e3f0c00469fb55f409d2bb21261035
+  languageName: node
+  linkType: hard
+
 "cookie-signature@npm:1.0.6":
   version: 1.0.6
   resolution: "cookie-signature@npm:1.0.6"
@@ -8852,7 +9423,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"cookie@npm:0.5.0":
+"cookie@npm:0.5.0, cookie@npm:^0.5.0":
   version: 0.5.0
   resolution: "cookie@npm:0.5.0"
   checksum: 1f4bd2ca5765f8c9689a7e8954183f5332139eb72b6ff783d8947032ec1fdf43109852c178e21a953a30c0dd42257828185be01b49d1eb1a67fd054ca588a180
@@ -8875,13 +9446,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"core-js-pure@npm:^3.20.2":
-  version: 3.22.8
-  resolution: "core-js-pure@npm:3.22.8"
-  checksum: 007d2374b6dca116cc2d57da44605be9bd84906022e385da25008a010e8a8d33bfbfde1b3f9dbb999aa270ff0dec57dbac62b080fd2a60dea39ea3b9cfdf763f
-  languageName: node
-  linkType: hard
-
 "core-util-is@npm:~1.0.0":
   version: 1.0.3
   resolution: "core-util-is@npm:1.0.3"
@@ -8925,6 +9489,23 @@ __metadata:
   languageName: node
   linkType: hard
 
+"create-jest@npm:^29.7.0":
+  version: 29.7.0
+  resolution: "create-jest@npm:29.7.0"
+  dependencies:
+    "@jest/types": ^29.6.3
+    chalk: ^4.0.0
+    exit: ^0.1.2
+    graceful-fs: ^4.2.9
+    jest-config: ^29.7.0
+    jest-util: ^29.7.0
+    prompts: ^2.0.1
+  bin:
+    create-jest: bin/create-jest.js
+  checksum: 1427d49458adcd88547ef6fa39041e1fe9033a661293aa8d2c3aa1b4967cb5bf4f0c00436c7a61816558f28ba2ba81a94d5c962e8022ea9a883978fc8e1f2945
+  languageName: node
+  linkType: hard
+
 "create-require@npm:^1.1.0":
   version: 1.1.1
   resolution: "create-require@npm:1.1.1"
@@ -9075,9 +9656,9 @@ __metadata:
   linkType: hard
 
 "csstype@npm:^3.0.10, csstype@npm:^3.0.2":
-  version: 3.1.0
-  resolution: "csstype@npm:3.1.0"
-  checksum: 644e986cefab86525f0b674a06889cfdbb1f117e5b7d1ce0fc55b0423ecc58807a1ea42ecc75c4f18999d14fc42d1d255f84662a45003a52bb5840e977eb2ffd
+  version: 3.1.2
+  resolution: "csstype@npm:3.1.2"
+  checksum: e1a52e6c25c1314d6beef5168da704ab29c5186b877c07d822bd0806717d9a265e8493a2e35ca7e68d0f5d472d43fac1cdce70fd79fd0853dff81f3028d857b5
   languageName: node
   linkType: hard
 
@@ -9133,13 +9714,20 @@ __metadata:
   languageName: node
   linkType: hard
 
-"damerau-levenshtein@npm:^1.0.7":
+"damerau-levenshtein@npm:^1.0.8":
   version: 1.0.8
   resolution: "damerau-levenshtein@npm:1.0.8"
   checksum: d240b7757544460ae0586a341a53110ab0a61126570ef2d8c731e3eab3f0cb6e488e2609e6a69b46727635de49be20b071688698744417ff1b6c1d7ccd03e0de
   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: 152bec5e77513ee253a7c686700a1723246f582dad8b614e8eaaaba7fa45a15c8671ae4b8f4843f4f3a002dae1d3e7a20f852f7d7bdc8b4c15cfe7adfdfb07f8
+  languageName: node
+  linkType: hard
+
 "data-uri-to-buffer@npm:^4.0.0":
   version: 4.0.1
   resolution: "data-uri-to-buffer@npm:4.0.1"
@@ -9207,7 +9795,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"debug@npm:4.3.4, debug@npm:^4.3.2, debug@npm:^4.3.3, debug@npm:^4.3.4":
+"debug@npm:4.3.4, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.3, debug@npm:^4.3.4":
   version: 4.3.4
   resolution: "debug@npm:4.3.4"
   dependencies:
@@ -9293,10 +9881,15 @@ __metadata:
   languageName: node
   linkType: hard
 
-"dedent@npm:^0.7.0":
-  version: 0.7.0
-  resolution: "dedent@npm:0.7.0"
-  checksum: 87de191050d9a40dd70cad01159a0bcf05ecb59750951242070b6abf9569088684880d00ba92a955b4058804f16eeaf91d604f283929b4f614d181cd7ae633d2
+"dedent@npm:^1.0.0":
+  version: 1.5.1
+  resolution: "dedent@npm:1.5.1"
+  peerDependencies:
+    babel-plugin-macros: ^3.1.0
+  peerDependenciesMeta:
+    babel-plugin-macros:
+      optional: true
+  checksum: c3c300a14edf1bdf5a873f9e4b22e839d62490bc5c8d6169c1f15858a1a76733d06a9a56930e963d677a2ceeca4b6b0894cc5ea2f501aa382ca5b92af3413c2a
   languageName: node
   linkType: hard
 
@@ -9367,6 +9960,17 @@ __metadata:
   languageName: node
   linkType: hard
 
+"define-data-property@npm:^1.0.1, define-data-property@npm:^1.1.1":
+  version: 1.1.1
+  resolution: "define-data-property@npm:1.1.1"
+  dependencies:
+    get-intrinsic: ^1.2.1
+    gopd: ^1.0.1
+    has-property-descriptors: ^1.0.0
+  checksum: a29855ad3f0630ea82e3c5012c812efa6ca3078d5c2aa8df06b5f597c1cde6f7254692df41945851d903e05a1668607b6d34e778f402b9ff9ffb38111f1a3f0d
+  languageName: node
+  linkType: hard
+
 "define-lazy-prop@npm:^2.0.0":
   version: 2.0.0
   resolution: "define-lazy-prop@npm:2.0.0"
@@ -9384,6 +9988,17 @@ __metadata:
   languageName: node
   linkType: hard
 
+"define-properties@npm:^1.2.0, define-properties@npm:^1.2.1":
+  version: 1.2.1
+  resolution: "define-properties@npm:1.2.1"
+  dependencies:
+    define-data-property: ^1.0.1
+    has-property-descriptors: ^1.0.0
+    object-keys: ^1.1.1
+  checksum: b4ccd00597dd46cb2d4a379398f5b19fca84a16f3374e2249201992f36b30f6835949a9429669ee6b41b6e837205a163eadd745e472069e70dfc10f03e5fcc12
+  languageName: node
+  linkType: hard
+
 "degenerator@npm:^5.0.0":
   version: 5.0.1
   resolution: "degenerator@npm:5.0.1"
@@ -9515,20 +10130,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"diff-sequences@npm:^27.5.1":
-  version: 27.5.1
-  resolution: "diff-sequences@npm:27.5.1"
-  checksum: a00db5554c9da7da225db2d2638d85f8e41124eccbd56cbaefb3b276dcbb1c1c2ad851c32defe2055a54a4806f030656cbf6638105fd6ce97bb87b90b32a33ca
-  languageName: node
-  linkType: hard
-
-"diff-sequences@npm:^28.1.1":
-  version: 28.1.1
-  resolution: "diff-sequences@npm:28.1.1"
-  checksum: e2529036505567c7ca5a2dea86b6bcd1ca0e3ae63bf8ebf529b8a99cfa915bbf194b7021dc1c57361a4017a6d95578d4ceb29fabc3232a4f4cb866a2726c7690
-  languageName: node
-  linkType: hard
-
 "diff-sequences@npm:^29.4.3":
   version: 29.4.3
   resolution: "diff-sequences@npm:29.4.3"
@@ -9536,6 +10137,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"diff-sequences@npm:^29.6.3":
+  version: 29.6.3
+  resolution: "diff-sequences@npm:29.6.3"
+  checksum: f4914158e1f2276343d98ff5b31fc004e7304f5470bf0f1adb2ac6955d85a531a6458d33e87667f98f6ae52ebd3891bb47d420bb48a5bd8b7a27ee25b20e33aa
+  languageName: node
+  linkType: hard
+
 "diff@npm:5.0.0":
   version: 5.0.0
   resolution: "diff@npm:5.0.0"
@@ -9707,6 +10315,17 @@ __metadata:
   languageName: node
   linkType: hard
 
+"ecma-version-validator-webpack-plugin@npm:^1.2.1":
+  version: 1.2.1
+  resolution: "ecma-version-validator-webpack-plugin@npm:1.2.1"
+  dependencies:
+    acorn: ^8.7.0
+  peerDependencies:
+    webpack: ^4.40.0 || ^5.0.0
+  checksum: 9a0f6d9beb600f1af054f65a52790c3784d5d4c19cb10b9ae7446682613349ade952b355cca0e60b45616e66ce9b1914d183a909ce136bb05b47f364dd6bfabf
+  languageName: node
+  linkType: hard
+
 "edge-paths@npm:^3.0.5":
   version: 3.0.5
   resolution: "edge-paths@npm:3.0.5"
@@ -9772,10 +10391,10 @@ __metadata:
   languageName: node
   linkType: hard
 
-"emittery@npm:^0.10.2":
-  version: 0.10.2
-  resolution: "emittery@npm:0.10.2"
-  checksum: ee3e21788b043b90885b18ea756ec3105c1cedc50b29709c92b01e239c7e55345d4bb6d3aef4ddbaf528eef448a40b3bb831bad9ee0fc9c25cbf1367ab1ab5ac
+"emittery@npm:^0.13.1":
+  version: 0.13.1
+  resolution: "emittery@npm:0.13.1"
+  checksum: 2b089ab6306f38feaabf4f6f02792f9ec85fc054fda79f44f6790e61bbf6bc4e1616afb9b232e0c5ec5289a8a452f79bfa6d905a6fd64e94b49981f0934001c6
   languageName: node
   linkType: hard
 
@@ -9906,7 +10525,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"es-abstract@npm:^1.19.0, es-abstract@npm:^1.19.1, es-abstract@npm:^1.19.2, es-abstract@npm:^1.19.5":
+"es-abstract@npm:^1.19.0, es-abstract@npm:^1.19.2, es-abstract@npm:^1.19.5":
   version: 1.20.1
   resolution: "es-abstract@npm:1.20.1"
   dependencies:
@@ -9979,6 +10598,75 @@ __metadata:
   languageName: node
   linkType: hard
 
+"es-abstract@npm:^1.22.1":
+  version: 1.22.3
+  resolution: "es-abstract@npm:1.22.3"
+  dependencies:
+    array-buffer-byte-length: ^1.0.0
+    arraybuffer.prototype.slice: ^1.0.2
+    available-typed-arrays: ^1.0.5
+    call-bind: ^1.0.5
+    es-set-tostringtag: ^2.0.1
+    es-to-primitive: ^1.2.1
+    function.prototype.name: ^1.1.6
+    get-intrinsic: ^1.2.2
+    get-symbol-description: ^1.0.0
+    globalthis: ^1.0.3
+    gopd: ^1.0.1
+    has-property-descriptors: ^1.0.0
+    has-proto: ^1.0.1
+    has-symbols: ^1.0.3
+    hasown: ^2.0.0
+    internal-slot: ^1.0.5
+    is-array-buffer: ^3.0.2
+    is-callable: ^1.2.7
+    is-negative-zero: ^2.0.2
+    is-regex: ^1.1.4
+    is-shared-array-buffer: ^1.0.2
+    is-string: ^1.0.7
+    is-typed-array: ^1.1.12
+    is-weakref: ^1.0.2
+    object-inspect: ^1.13.1
+    object-keys: ^1.1.1
+    object.assign: ^4.1.4
+    regexp.prototype.flags: ^1.5.1
+    safe-array-concat: ^1.0.1
+    safe-regex-test: ^1.0.0
+    string.prototype.trim: ^1.2.8
+    string.prototype.trimend: ^1.0.7
+    string.prototype.trimstart: ^1.0.7
+    typed-array-buffer: ^1.0.0
+    typed-array-byte-length: ^1.0.0
+    typed-array-byte-offset: ^1.0.0
+    typed-array-length: ^1.0.4
+    unbox-primitive: ^1.0.2
+    which-typed-array: ^1.1.13
+  checksum: b1bdc962856836f6e72be10b58dc128282bdf33771c7a38ae90419d920fc3b36cc5d2b70a222ad8016e3fc322c367bf4e9e89fc2bc79b7e933c05b218e83d79a
+  languageName: node
+  linkType: hard
+
+"es-iterator-helpers@npm:^1.0.12, es-iterator-helpers@npm:^1.0.15":
+  version: 1.0.15
+  resolution: "es-iterator-helpers@npm:1.0.15"
+  dependencies:
+    asynciterator.prototype: ^1.0.0
+    call-bind: ^1.0.2
+    define-properties: ^1.2.1
+    es-abstract: ^1.22.1
+    es-set-tostringtag: ^2.0.1
+    function-bind: ^1.1.1
+    get-intrinsic: ^1.2.1
+    globalthis: ^1.0.3
+    has-property-descriptors: ^1.0.0
+    has-proto: ^1.0.1
+    has-symbols: ^1.0.3
+    internal-slot: ^1.0.5
+    iterator.prototype: ^1.1.2
+    safe-array-concat: ^1.0.1
+  checksum: 50081ae5c549efe62e5c1d244df0194b40b075f7897fc2116b7e1aa437eb3c41f946d2afda18c33f9b31266ec544765932542765af839f76fa6d7b7855d1e0e1
+  languageName: node
+  linkType: hard
+
 "es-module-lexer@npm:^0.9.0":
   version: 0.9.3
   resolution: "es-module-lexer@npm:0.9.3"
@@ -10024,171 +10712,251 @@ __metadata:
   languageName: node
   linkType: hard
 
-"esbuild-android-64@npm:0.14.47":
-  version: 0.14.47
-  resolution: "esbuild-android-64@npm:0.14.47"
+"esbuild-android-64@npm:0.14.54":
+  version: 0.14.54
+  resolution: "esbuild-android-64@npm:0.14.54"
   conditions: os=android & cpu=x64
   languageName: node
   linkType: hard
 
-"esbuild-android-arm64@npm:0.14.47":
-  version: 0.14.47
-  resolution: "esbuild-android-arm64@npm:0.14.47"
+"esbuild-android-arm64@npm:0.14.54":
+  version: 0.14.54
+  resolution: "esbuild-android-arm64@npm:0.14.54"
   conditions: os=android & cpu=arm64
   languageName: node
   linkType: hard
 
-"esbuild-darwin-64@npm:0.14.47":
-  version: 0.14.47
-  resolution: "esbuild-darwin-64@npm:0.14.47"
+"esbuild-darwin-64@npm:0.14.54":
+  version: 0.14.54
+  resolution: "esbuild-darwin-64@npm:0.14.54"
   conditions: os=darwin & cpu=x64
   languageName: node
   linkType: hard
 
-"esbuild-darwin-arm64@npm:0.14.47":
-  version: 0.14.47
-  resolution: "esbuild-darwin-arm64@npm:0.14.47"
+"esbuild-darwin-arm64@npm:0.14.54":
+  version: 0.14.54
+  resolution: "esbuild-darwin-arm64@npm:0.14.54"
   conditions: os=darwin & cpu=arm64
   languageName: node
   linkType: hard
 
-"esbuild-freebsd-64@npm:0.14.47":
-  version: 0.14.47
-  resolution: "esbuild-freebsd-64@npm:0.14.47"
+"esbuild-freebsd-64@npm:0.14.54":
+  version: 0.14.54
+  resolution: "esbuild-freebsd-64@npm:0.14.54"
   conditions: os=freebsd & cpu=x64
   languageName: node
   linkType: hard
 
-"esbuild-freebsd-arm64@npm:0.14.47":
-  version: 0.14.47
-  resolution: "esbuild-freebsd-arm64@npm:0.14.47"
+"esbuild-freebsd-arm64@npm:0.14.54":
+  version: 0.14.54
+  resolution: "esbuild-freebsd-arm64@npm:0.14.54"
   conditions: os=freebsd & cpu=arm64
   languageName: node
   linkType: hard
 
-"esbuild-linux-32@npm:0.14.47":
-  version: 0.14.47
-  resolution: "esbuild-linux-32@npm:0.14.47"
+"esbuild-linux-32@npm:0.14.54":
+  version: 0.14.54
+  resolution: "esbuild-linux-32@npm:0.14.54"
   conditions: os=linux & cpu=ia32
   languageName: node
   linkType: hard
 
-"esbuild-linux-64@npm:0.14.47":
-  version: 0.14.47
-  resolution: "esbuild-linux-64@npm:0.14.47"
+"esbuild-linux-64@npm:0.14.54":
+  version: 0.14.54
+  resolution: "esbuild-linux-64@npm:0.14.54"
   conditions: os=linux & cpu=x64
   languageName: node
   linkType: hard
 
-"esbuild-linux-arm64@npm:0.14.47":
-  version: 0.14.47
-  resolution: "esbuild-linux-arm64@npm:0.14.47"
+"esbuild-linux-arm64@npm:0.14.54":
+  version: 0.14.54
+  resolution: "esbuild-linux-arm64@npm:0.14.54"
   conditions: os=linux & cpu=arm64
   languageName: node
   linkType: hard
 
-"esbuild-linux-arm@npm:0.14.47":
-  version: 0.14.47
-  resolution: "esbuild-linux-arm@npm:0.14.47"
+"esbuild-linux-arm@npm:0.14.54":
+  version: 0.14.54
+  resolution: "esbuild-linux-arm@npm:0.14.54"
   conditions: os=linux & cpu=arm
   languageName: node
   linkType: hard
 
-"esbuild-linux-mips64le@npm:0.14.47":
-  version: 0.14.47
-  resolution: "esbuild-linux-mips64le@npm:0.14.47"
+"esbuild-linux-mips64le@npm:0.14.54":
+  version: 0.14.54
+  resolution: "esbuild-linux-mips64le@npm:0.14.54"
   conditions: os=linux & cpu=mips64el
   languageName: node
   linkType: hard
 
-"esbuild-linux-ppc64le@npm:0.14.47":
-  version: 0.14.47
-  resolution: "esbuild-linux-ppc64le@npm:0.14.47"
+"esbuild-linux-ppc64le@npm:0.14.54":
+  version: 0.14.54
+  resolution: "esbuild-linux-ppc64le@npm:0.14.54"
   conditions: os=linux & cpu=ppc64
   languageName: node
   linkType: hard
 
-"esbuild-linux-riscv64@npm:0.14.47":
-  version: 0.14.47
-  resolution: "esbuild-linux-riscv64@npm:0.14.47"
+"esbuild-linux-riscv64@npm:0.14.54":
+  version: 0.14.54
+  resolution: "esbuild-linux-riscv64@npm:0.14.54"
   conditions: os=linux & cpu=riscv64
   languageName: node
   linkType: hard
 
-"esbuild-linux-s390x@npm:0.14.47":
-  version: 0.14.47
-  resolution: "esbuild-linux-s390x@npm:0.14.47"
+"esbuild-linux-s390x@npm:0.14.54":
+  version: 0.14.54
+  resolution: "esbuild-linux-s390x@npm:0.14.54"
   conditions: os=linux & cpu=s390x
   languageName: node
   linkType: hard
 
-"esbuild-netbsd-64@npm:0.14.47":
-  version: 0.14.47
-  resolution: "esbuild-netbsd-64@npm:0.14.47"
+"esbuild-netbsd-64@npm:0.14.54":
+  version: 0.14.54
+  resolution: "esbuild-netbsd-64@npm:0.14.54"
   conditions: os=netbsd & cpu=x64
   languageName: node
   linkType: hard
 
-"esbuild-openbsd-64@npm:0.14.47":
-  version: 0.14.47
-  resolution: "esbuild-openbsd-64@npm:0.14.47"
+"esbuild-openbsd-64@npm:0.14.54":
+  version: 0.14.54
+  resolution: "esbuild-openbsd-64@npm:0.14.54"
   conditions: os=openbsd & cpu=x64
   languageName: node
   linkType: hard
 
-"esbuild-sunos-64@npm:0.14.47":
-  version: 0.14.47
-  resolution: "esbuild-sunos-64@npm:0.14.47"
+"esbuild-sunos-64@npm:0.14.54":
+  version: 0.14.54
+  resolution: "esbuild-sunos-64@npm:0.14.54"
   conditions: os=sunos & cpu=x64
   languageName: node
   linkType: hard
 
-"esbuild-windows-32@npm:0.14.47":
-  version: 0.14.47
-  resolution: "esbuild-windows-32@npm:0.14.47"
+"esbuild-windows-32@npm:0.14.54":
+  version: 0.14.54
+  resolution: "esbuild-windows-32@npm:0.14.54"
   conditions: os=win32 & cpu=ia32
   languageName: node
   linkType: hard
 
-"esbuild-windows-64@npm:0.14.47":
-  version: 0.14.47
-  resolution: "esbuild-windows-64@npm:0.14.47"
+"esbuild-windows-64@npm:0.14.54":
+  version: 0.14.54
+  resolution: "esbuild-windows-64@npm:0.14.54"
   conditions: os=win32 & cpu=x64
   languageName: node
   linkType: hard
 
-"esbuild-windows-arm64@npm:0.14.47":
-  version: 0.14.47
-  resolution: "esbuild-windows-arm64@npm:0.14.47"
+"esbuild-windows-arm64@npm:0.14.54":
+  version: 0.14.54
+  resolution: "esbuild-windows-arm64@npm:0.14.54"
   conditions: os=win32 & cpu=arm64
   languageName: node
   linkType: hard
 
+"esbuild@npm:0.17.19":
+  version: 0.17.19
+  resolution: "esbuild@npm:0.17.19"
+  dependencies:
+    "@esbuild/android-arm": 0.17.19
+    "@esbuild/android-arm64": 0.17.19
+    "@esbuild/android-x64": 0.17.19
+    "@esbuild/darwin-arm64": 0.17.19
+    "@esbuild/darwin-x64": 0.17.19
+    "@esbuild/freebsd-arm64": 0.17.19
+    "@esbuild/freebsd-x64": 0.17.19
+    "@esbuild/linux-arm": 0.17.19
+    "@esbuild/linux-arm64": 0.17.19
+    "@esbuild/linux-ia32": 0.17.19
+    "@esbuild/linux-loong64": 0.17.19
+    "@esbuild/linux-mips64el": 0.17.19
+    "@esbuild/linux-ppc64": 0.17.19
+    "@esbuild/linux-riscv64": 0.17.19
+    "@esbuild/linux-s390x": 0.17.19
+    "@esbuild/linux-x64": 0.17.19
+    "@esbuild/netbsd-x64": 0.17.19
+    "@esbuild/openbsd-x64": 0.17.19
+    "@esbuild/sunos-x64": 0.17.19
+    "@esbuild/win32-arm64": 0.17.19
+    "@esbuild/win32-ia32": 0.17.19
+    "@esbuild/win32-x64": 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: ac11b1a5a6008e4e37ccffbd6c2c054746fc58d0ed4a2f9ee643bd030cfcea9a33a235087bc777def8420f2eaafb3486e76adb7bdb7241a9143b43a69a10afd8
+  languageName: node
+  linkType: hard
+
 "esbuild@npm:^0.14.27":
-  version: 0.14.47
-  resolution: "esbuild@npm:0.14.47"
-  dependencies:
-    esbuild-android-64: 0.14.47
-    esbuild-android-arm64: 0.14.47
-    esbuild-darwin-64: 0.14.47
-    esbuild-darwin-arm64: 0.14.47
-    esbuild-freebsd-64: 0.14.47
-    esbuild-freebsd-arm64: 0.14.47
-    esbuild-linux-32: 0.14.47
-    esbuild-linux-64: 0.14.47
-    esbuild-linux-arm: 0.14.47
-    esbuild-linux-arm64: 0.14.47
-    esbuild-linux-mips64le: 0.14.47
-    esbuild-linux-ppc64le: 0.14.47
-    esbuild-linux-riscv64: 0.14.47
-    esbuild-linux-s390x: 0.14.47
-    esbuild-netbsd-64: 0.14.47
-    esbuild-openbsd-64: 0.14.47
-    esbuild-sunos-64: 0.14.47
-    esbuild-windows-32: 0.14.47
-    esbuild-windows-64: 0.14.47
-    esbuild-windows-arm64: 0.14.47
+  version: 0.14.54
+  resolution: "esbuild@npm:0.14.54"
+  dependencies:
+    "@esbuild/linux-loong64": 0.14.54
+    esbuild-android-64: 0.14.54
+    esbuild-android-arm64: 0.14.54
+    esbuild-darwin-64: 0.14.54
+    esbuild-darwin-arm64: 0.14.54
+    esbuild-freebsd-64: 0.14.54
+    esbuild-freebsd-arm64: 0.14.54
+    esbuild-linux-32: 0.14.54
+    esbuild-linux-64: 0.14.54
+    esbuild-linux-arm: 0.14.54
+    esbuild-linux-arm64: 0.14.54
+    esbuild-linux-mips64le: 0.14.54
+    esbuild-linux-ppc64le: 0.14.54
+    esbuild-linux-riscv64: 0.14.54
+    esbuild-linux-s390x: 0.14.54
+    esbuild-netbsd-64: 0.14.54
+    esbuild-openbsd-64: 0.14.54
+    esbuild-sunos-64: 0.14.54
+    esbuild-windows-32: 0.14.54
+    esbuild-windows-64: 0.14.54
+    esbuild-windows-arm64: 0.14.54
   dependenciesMeta:
+    "@esbuild/linux-loong64":
+      optional: true
     esbuild-android-64:
       optional: true
     esbuild-android-arm64:
@@ -10231,7 +10999,7 @@ __metadata:
       optional: true
   bin:
     esbuild: bin/esbuild
-  checksum: 77a8bff8c3fe52dc9d2823448843b0f53c9a9f3701e3637a54e396270c9ca04cc46a4b08ef86cbaa8d202854e02c790f61683bfa75ebff540b1e24414f536e91
+  checksum: 49e360b1185c797f5ca3a7f5f0a75121494d97ddf691f65ed1796e6257d318f928342a97f559bb8eced6a90cf604dd22db4a30e0dbbf15edd9dbf22459b639af
   languageName: node
   linkType: hard
 
@@ -10322,26 +11090,25 @@ __metadata:
   linkType: hard
 
 "eslint-config-next@npm:^12.1.6":
-  version: 12.1.6
-  resolution: "eslint-config-next@npm:12.1.6"
+  version: 12.3.4
+  resolution: "eslint-config-next@npm:12.3.4"
   dependencies:
-    "@next/eslint-plugin-next": 12.1.6
+    "@next/eslint-plugin-next": 12.3.4
     "@rushstack/eslint-patch": ^1.1.3
     "@typescript-eslint/parser": ^5.21.0
     eslint-import-resolver-node: ^0.3.6
     eslint-import-resolver-typescript: ^2.7.1
     eslint-plugin-import: ^2.26.0
     eslint-plugin-jsx-a11y: ^6.5.1
-    eslint-plugin-react: ^7.29.4
+    eslint-plugin-react: ^7.31.7
     eslint-plugin-react-hooks: ^4.5.0
   peerDependencies:
     eslint: ^7.23.0 || ^8.0.0
-    next: ">=10.2.0"
     typescript: ">=3.3.1"
   peerDependenciesMeta:
     typescript:
       optional: true
-  checksum: b3ba9e8f598b4018002aaaa64dc0e8a8b5994be992839a8b1b0a7f7ab0e4cbe006a30fd787524baab7c1e979dc6b41d4cdcbe239be181635413d987be7f25b6f
+  checksum: 53cd24d7b764fe382812a5e76571083fe59e892ac88ac5ccddf171e261f5a3ea36cb1c34283f97569c97a4bae51ece5252d5aa71fd130d31ada94310dc4147ee
   languageName: node
   linkType: hard
 
@@ -10356,13 +11123,14 @@ __metadata:
   languageName: node
   linkType: hard
 
-"eslint-import-resolver-node@npm:^0.3.6":
-  version: 0.3.6
-  resolution: "eslint-import-resolver-node@npm:0.3.6"
+"eslint-import-resolver-node@npm:^0.3.6, eslint-import-resolver-node@npm:^0.3.9":
+  version: 0.3.9
+  resolution: "eslint-import-resolver-node@npm:0.3.9"
   dependencies:
     debug: ^3.2.7
-    resolve: ^1.20.0
-  checksum: 6266733af1e112970e855a5bcc2d2058fb5ae16ad2a6d400705a86b29552b36131ffc5581b744c23d550de844206fb55e9193691619ee4dbf225c4bde526b1c8
+    is-core-module: ^2.13.0
+    resolve: ^1.22.4
+  checksum: 439b91271236b452d478d0522a44482e8c8540bf9df9bd744062ebb89ab45727a3acd03366a6ba2bdbcde8f9f718bab7fe8db64688aca75acf37e04eafd25e22
   languageName: node
   linkType: hard
 
@@ -10393,17 +11161,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"eslint-module-utils@npm:^2.7.3":
-  version: 2.7.3
-  resolution: "eslint-module-utils@npm:2.7.3"
-  dependencies:
-    debug: ^3.2.7
-    find-up: ^2.1.0
-  checksum: 77048263f309167a1e6a1e1b896bfb5ddd1d3859b2e2abbd9c32c432aee13d610d46e6820b1ca81b37fba437cf423a404bc6649be64ace9148a3062d1886a678
-  languageName: node
-  linkType: hard
-
-"eslint-module-utils@npm:^2.7.4":
+"eslint-module-utils@npm:^2.7.4, eslint-module-utils@npm:^2.8.0":
   version: 2.8.0
   resolution: "eslint-module-utils@npm:2.8.0"
   dependencies:
@@ -10416,25 +11174,29 @@ __metadata:
   linkType: hard
 
 "eslint-plugin-import@npm:^2.26.0":
-  version: 2.26.0
-  resolution: "eslint-plugin-import@npm:2.26.0"
+  version: 2.29.0
+  resolution: "eslint-plugin-import@npm:2.29.0"
   dependencies:
-    array-includes: ^3.1.4
-    array.prototype.flat: ^1.2.5
-    debug: ^2.6.9
+    array-includes: ^3.1.7
+    array.prototype.findlastindex: ^1.2.3
+    array.prototype.flat: ^1.3.2
+    array.prototype.flatmap: ^1.3.2
+    debug: ^3.2.7
     doctrine: ^2.1.0
-    eslint-import-resolver-node: ^0.3.6
-    eslint-module-utils: ^2.7.3
-    has: ^1.0.3
-    is-core-module: ^2.8.1
+    eslint-import-resolver-node: ^0.3.9
+    eslint-module-utils: ^2.8.0
+    hasown: ^2.0.0
+    is-core-module: ^2.13.1
     is-glob: ^4.0.3
     minimatch: ^3.1.2
-    object.values: ^1.1.5
-    resolve: ^1.22.0
-    tsconfig-paths: ^3.14.1
+    object.fromentries: ^2.0.7
+    object.groupby: ^1.0.1
+    object.values: ^1.1.7
+    semver: ^6.3.1
+    tsconfig-paths: ^3.14.2
   peerDependencies:
     eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8
-  checksum: 0bf77ad80339554481eafa2b1967449e1f816b94c7a6f9614ce33fb4083c4e6c050f10d241dd50b4975d47922880a34de1e42ea9d8e6fd663ebb768baa67e655
+  checksum: 19ee541fb95eb7a796f3daebe42387b8d8262bbbcc4fd8a6e92f63a12035f3d2c6cb8bc0b6a70864fa14b1b50ed6b8e6eed5833e625e16cb6bb98b665beff269
   languageName: node
   linkType: hard
 
@@ -10482,24 +11244,28 @@ __metadata:
   linkType: hard
 
 "eslint-plugin-jsx-a11y@npm:^6.5.1":
-  version: 6.5.1
-  resolution: "eslint-plugin-jsx-a11y@npm:6.5.1"
-  dependencies:
-    "@babel/runtime": ^7.16.3
-    aria-query: ^4.2.2
-    array-includes: ^3.1.4
-    ast-types-flow: ^0.0.7
-    axe-core: ^4.3.5
-    axobject-query: ^2.2.0
-    damerau-levenshtein: ^1.0.7
+  version: 6.8.0
+  resolution: "eslint-plugin-jsx-a11y@npm:6.8.0"
+  dependencies:
+    "@babel/runtime": ^7.23.2
+    aria-query: ^5.3.0
+    array-includes: ^3.1.7
+    array.prototype.flatmap: ^1.3.2
+    ast-types-flow: ^0.0.8
+    axe-core: =4.7.0
+    axobject-query: ^3.2.1
+    damerau-levenshtein: ^1.0.8
     emoji-regex: ^9.2.2
-    has: ^1.0.3
-    jsx-ast-utils: ^3.2.1
-    language-tags: ^1.0.5
-    minimatch: ^3.0.4
+    es-iterator-helpers: ^1.0.15
+    hasown: ^2.0.0
+    jsx-ast-utils: ^3.3.5
+    language-tags: ^1.0.9
+    minimatch: ^3.1.2
+    object.entries: ^1.1.7
+    object.fromentries: ^2.0.7
   peerDependencies:
     eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8
-  checksum: 311ab993ed982d0cc7cb0ba02fbc4b36c4a94e9434f31e97f13c4d67e8ecb8aec36baecfd759ff70498846e7e11d7a197eb04c39ad64934baf3354712fd0bc9d
+  checksum: 3dec00e2a3089c4c61ac062e4196a70985fb7eda1fd67fe035363d92578debde92fdb8ed2e472321fc0d71e75f4a1e8888c6a3218c14dd93c8e8d19eb6f51554
   languageName: node
   linkType: hard
 
@@ -10519,35 +11285,37 @@ __metadata:
   linkType: hard
 
 "eslint-plugin-react-hooks@npm:^4.5.0":
-  version: 4.5.0
-  resolution: "eslint-plugin-react-hooks@npm:4.5.0"
+  version: 4.6.0
+  resolution: "eslint-plugin-react-hooks@npm:4.6.0"
   peerDependencies:
     eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0
-  checksum: 0389377de635dd9b769f6f52e2c9e6ab857a0cdfecc3734c95ce81676a752e781bb5c44fd180e01953a03a77278323d90729776438815557b069ceb988ab1f9f
+  checksum: 23001801f14c1d16bf0a837ca7970d9dd94e7b560384b41db378b49b6e32dc43d6e2790de1bd737a652a86f81a08d6a91f402525061b47719328f586a57e86c3
   languageName: node
   linkType: hard
 
-"eslint-plugin-react@npm:^7.29.4":
-  version: 7.30.0
-  resolution: "eslint-plugin-react@npm:7.30.0"
+"eslint-plugin-react@npm:^7.31.7":
+  version: 7.33.2
+  resolution: "eslint-plugin-react@npm:7.33.2"
   dependencies:
-    array-includes: ^3.1.5
-    array.prototype.flatmap: ^1.3.0
+    array-includes: ^3.1.6
+    array.prototype.flatmap: ^1.3.1
+    array.prototype.tosorted: ^1.1.1
     doctrine: ^2.1.0
+    es-iterator-helpers: ^1.0.12
     estraverse: ^5.3.0
     jsx-ast-utils: ^2.4.1 || ^3.0.0
     minimatch: ^3.1.2
-    object.entries: ^1.1.5
-    object.fromentries: ^2.0.5
-    object.hasown: ^1.1.1
-    object.values: ^1.1.5
+    object.entries: ^1.1.6
+    object.fromentries: ^2.0.6
+    object.hasown: ^1.1.2
+    object.values: ^1.1.6
     prop-types: ^15.8.1
-    resolve: ^2.0.0-next.3
-    semver: ^6.3.0
-    string.prototype.matchall: ^4.0.7
+    resolve: ^2.0.0-next.4
+    semver: ^6.3.1
+    string.prototype.matchall: ^4.0.8
   peerDependencies:
     eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8
-  checksum: 729b7682a0fe6eab068171c159503ac57120ecc7b20067e76300b08879745c16a687e2033378ab45d9a3182da8844d06197a89081be83e1eb21fcceb76e79214
+  checksum: b4c3d76390b0ae6b6f9fed78170604cc2c04b48e6778a637db339e8e3911ec9ef22510b0ae77c429698151d0f1b245f282177f384105b6830e7b29b9c9b26610
   languageName: node
   linkType: hard
 
@@ -10716,6 +11484,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"estree-walker@npm:^0.6.1":
+  version: 0.6.1
+  resolution: "estree-walker@npm:0.6.1"
+  checksum: 9d6f82a4921f11eec18f8089fb3cce6e53bcf45a8e545c42a2674d02d055fb30f25f90495f8be60803df6c39680c80dcee7f944526867eb7aa1fc9254883b23d
+  languageName: node
+  linkType: hard
+
 "estree-walker@npm:^2.0.1":
   version: 2.0.2
   resolution: "estree-walker@npm:2.0.2"
@@ -10833,6 +11608,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"exit-hook@npm:^2.2.1":
+  version: 2.2.1
+  resolution: "exit-hook@npm:2.2.1"
+  checksum: 1aa8359b6c5590a012d6cadf9cd337d227291bfcaa8970dc585d73dffef0582af34ed8ac56f6164f8979979fb417cff1eb49f03cdfd782f9332a30c773f0ada0
+  languageName: node
+  linkType: hard
+
 "exit@npm:^0.1.2":
   version: 0.1.2
   resolution: "exit@npm:0.1.2"
@@ -10866,16 +11648,16 @@ __metadata:
   languageName: node
   linkType: hard
 
-"expect@npm:^28.1.1":
-  version: 28.1.1
-  resolution: "expect@npm:28.1.1"
+"expect@npm:^29.0.0, expect@npm:^29.7.0":
+  version: 29.7.0
+  resolution: "expect@npm:29.7.0"
   dependencies:
-    "@jest/expect-utils": ^28.1.1
-    jest-get-type: ^28.0.2
-    jest-matcher-utils: ^28.1.1
-    jest-message-util: ^28.1.1
-    jest-util: ^28.1.1
-  checksum: 6e557b681f4cfb0bf61efad50c5787cc6eb4596a3c299be69adc83fcad0265b5f329b997c2bb7ec92290e609681485616e51e16301a7f0ba3c57139b337c9351
+    "@jest/expect-utils": ^29.7.0
+    jest-get-type: ^29.6.3
+    jest-matcher-utils: ^29.7.0
+    jest-message-util: ^29.7.0
+    jest-util: ^29.7.0
+  checksum: 9257f10288e149b81254a0fda8ffe8d54a7061cd61d7515779998b012579d2b8c22354b0eb901daf0145f347403da582f75f359f4810c007182ad3fb318b5c0c
   languageName: node
   linkType: hard
 
@@ -11034,7 +11816,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"fast-json-stable-stringify@npm:2.x, fast-json-stable-stringify@npm:^2.0.0":
+"fast-json-stable-stringify@npm:2.x, fast-json-stable-stringify@npm:^2.0.0, fast-json-stable-stringify@npm:^2.1.0":
   version: 2.1.0
   resolution: "fast-json-stable-stringify@npm:2.1.0"
   checksum: b191531e36c607977e5b1c47811158733c34ccb3bfde92c44798929e9b4154884378536d26ad90dfecd32e1ffc09c545d23535ad91b3161a27ddbb8ebe0cbecb
@@ -11232,15 +12014,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"find-up@npm:^2.1.0":
-  version: 2.1.0
-  resolution: "find-up@npm:2.1.0"
-  dependencies:
-    locate-path: ^2.0.0
-  checksum: 43284fe4da09f89011f08e3c32cd38401e786b19226ea440b75386c1b12a4cb738c94969808d53a84f564ede22f732c8409e3cfc3f7fb5b5c32378ad0bbf28bd
-  languageName: node
-  linkType: hard
-
 "find-up@npm:^4.0.0, find-up@npm:^4.1.0":
   version: 4.1.0
   resolution: "find-up@npm:4.1.0"
@@ -11297,23 +12070,13 @@ __metadata:
   languageName: node
   linkType: hard
 
-"follow-redirects@npm:^1.0.0, follow-redirects@npm:^1.14.9, follow-redirects@npm:^1.15.0":
-  version: 1.15.2
-  resolution: "follow-redirects@npm:1.15.2"
-  peerDependenciesMeta:
-    debug:
-      optional: true
-  checksum: faa66059b66358ba65c234c2f2a37fcec029dc22775f35d9ad6abac56003268baf41e55f9ee645957b32c7d9f62baf1f0b906e68267276f54ec4b4c597c2b190
-  languageName: node
-  linkType: hard
-
-"follow-redirects@npm:^1.14.7":
-  version: 1.15.1
-  resolution: "follow-redirects@npm:1.15.1"
+"follow-redirects@npm:^1.0.0, follow-redirects@npm:^1.14.7, follow-redirects@npm:^1.14.9, follow-redirects@npm:^1.15.0":
+  version: 1.15.4
+  resolution: "follow-redirects@npm:1.15.4"
   peerDependenciesMeta:
     debug:
       optional: true
-  checksum: 6aa4e3e3cdfa3b9314801a1cd192ba756a53479d9d8cca65bf4db3a3e8834e62139245cd2f9566147c8dfe2efff1700d3e6aefd103de4004a7b99985e71dd533
+  checksum: e178d1deff8b23d5d24ec3f7a94cde6e47d74d0dc649c35fc9857041267c12ec5d44650a0c5597ef83056ada9ea6ca0c30e7c4f97dbf07d035086be9e6a5b7b6
   languageName: node
   linkType: hard
 
@@ -11504,6 +12267,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"function-bind@npm:^1.1.2":
+  version: 1.1.2
+  resolution: "function-bind@npm:1.1.2"
+  checksum: 2b0ff4ce708d99715ad14a6d1f894e2a83242e4a52ccfcefaee5e40050562e5f6dafc1adbb4ce2d4ab47279a45dc736ab91ea5042d843c3c092820dfe032efb1
+  languageName: node
+  linkType: hard
+
 "function.prototype.name@npm:^1.1.5":
   version: 1.1.5
   resolution: "function.prototype.name@npm:1.1.5"
@@ -11516,6 +12286,18 @@ __metadata:
   languageName: node
   linkType: hard
 
+"function.prototype.name@npm:^1.1.6":
+  version: 1.1.6
+  resolution: "function.prototype.name@npm:1.1.6"
+  dependencies:
+    call-bind: ^1.0.2
+    define-properties: ^1.2.0
+    es-abstract: ^1.22.1
+    functions-have-names: ^1.2.3
+  checksum: 7a3f9bd98adab09a07f6e1f03da03d3f7c26abbdeaeee15223f6c04a9fb5674792bdf5e689dac19b97ac71de6aad2027ba3048a9b883aa1b3173eed6ab07f479
+  languageName: node
+  linkType: hard
+
 "functional-red-black-tree@npm:^1.0.1":
   version: 1.0.1
   resolution: "functional-red-black-tree@npm:1.0.1"
@@ -11523,7 +12305,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"functions-have-names@npm:^1.2.2":
+"functions-have-names@npm:^1.2.2, functions-have-names@npm:^1.2.3":
   version: 1.2.3
   resolution: "functions-have-names@npm:1.2.3"
   checksum: c3f1f5ba20f4e962efb71344ce0a40722163e85bee2101ce25f88214e78182d2d2476aa85ef37950c579eb6cf6ee811c17b3101bb84004bb75655f3e33f3fdb5
@@ -11609,6 +12391,18 @@ __metadata:
   languageName: node
   linkType: hard
 
+"get-intrinsic@npm:^1.2.1, get-intrinsic@npm:^1.2.2":
+  version: 1.2.2
+  resolution: "get-intrinsic@npm:1.2.2"
+  dependencies:
+    function-bind: ^1.1.2
+    has-proto: ^1.0.1
+    has-symbols: ^1.0.3
+    hasown: ^2.0.0
+  checksum: 447ff0724df26829908dc033b62732359596fcf66027bc131ab37984afb33842d9cd458fd6cecadfe7eac22fd8a54b349799ed334cf2726025c921c7250e7417
+  languageName: node
+  linkType: hard
+
 "get-monorepo-packages@npm:^1.2.0":
   version: 1.2.0
   resolution: "get-monorepo-packages@npm:1.2.0"
@@ -11633,6 +12427,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: ^2.0.0
+    source-map: ^0.6.1
+  checksum: c73368fee709594ba38682ec1a96872aac6f7d766396019611d3d2358b68186a7847765a773ea0db088c42781126cc6bc09e4b87f263951c74dae5dcea50ad42
+  languageName: node
+  linkType: hard
+
 "get-stdin@npm:^4.0.1":
   version: 4.0.1
   resolution: "get-stdin@npm:4.0.1"
@@ -12127,6 +12931,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"hasown@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "hasown@npm:2.0.0"
+  dependencies:
+    function-bind: ^1.1.2
+  checksum: 6151c75ca12554565098641c98a40f4cc86b85b0fd5b6fe92360967e4605a4f9610f7757260b4e8098dd1c2ce7f4b095f2006fe72a570e3b6d2d28de0298c176
+  languageName: node
+  linkType: hard
+
 "hdr-histogram-js@npm:^3.0.0":
   version: 3.0.0
   resolution: "hdr-histogram-js@npm:3.0.0"
@@ -12741,6 +13554,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"is-async-function@npm:^2.0.0":
+  version: 2.0.0
+  resolution: "is-async-function@npm:2.0.0"
+  dependencies:
+    has-tostringtag: ^1.0.0
+  checksum: e3471d95e6c014bf37cad8a93f2f4b6aac962178e0a5041e8903147166964fdc1c5c1d2ef87e86d77322c370ca18f2ea004fa7420581fa747bcaf7c223069dbd
+  languageName: node
+  linkType: hard
+
 "is-bigint@npm:^1.0.1":
   version: 1.0.4
   resolution: "is-bigint@npm:1.0.4"
@@ -12830,6 +13652,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"is-core-module@npm:^2.13.1":
+  version: 2.13.1
+  resolution: "is-core-module@npm:2.13.1"
+  dependencies:
+    hasown: ^2.0.0
+  checksum: 256559ee8a9488af90e4bad16f5583c6d59e92f0742e9e8bb4331e758521ee86b810b93bae44f390766ffbc518a0488b18d9dab7da9a5ff997d499efc9403f7c
+  languageName: node
+  linkType: hard
+
 "is-core-module@npm:^2.2.0":
   version: 2.3.0
   resolution: "is-core-module@npm:2.3.0"
@@ -12857,7 +13688,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"is-date-object@npm:^1.0.1":
+"is-date-object@npm:^1.0.1, is-date-object@npm:^1.0.5":
   version: 1.0.5
   resolution: "is-date-object@npm:1.0.5"
   dependencies:
@@ -12898,6 +13729,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"is-finalizationregistry@npm:^1.0.2":
+  version: 1.0.2
+  resolution: "is-finalizationregistry@npm:1.0.2"
+  dependencies:
+    call-bind: ^1.0.2
+  checksum: 4f243a8e06228cd45bdab8608d2cb7abfc20f6f0189c8ac21ea8d603f1f196eabd531ce0bb8e08cbab047e9845ef2c191a3761c9a17ad5cabf8b35499c4ad35d
+  languageName: node
+  linkType: hard
+
 "is-finite@npm:^1.0.0":
   version: 1.1.0
   resolution: "is-finite@npm:1.1.0"
@@ -12926,6 +13766,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"is-generator-function@npm:^1.0.10":
+  version: 1.0.10
+  resolution: "is-generator-function@npm:1.0.10"
+  dependencies:
+    has-tostringtag: ^1.0.0
+  checksum: d54644e7dbaccef15ceb1e5d91d680eb5068c9ee9f9eb0a9e04173eb5542c9b51b5ab52c5537f5703e48d5fddfd376817c1ca07a84a407b7115b769d4bdde72b
+  languageName: node
+  linkType: hard
+
 "is-glob@npm:^4.0.0, is-glob@npm:^4.0.1, is-glob@npm:~4.0.1":
   version: 4.0.1
   resolution: "is-glob@npm:4.0.1"
@@ -12968,6 +13817,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"is-map@npm:^2.0.1":
+  version: 2.0.2
+  resolution: "is-map@npm:2.0.2"
+  checksum: ace3d0ecd667bbdefdb1852de601268f67f2db725624b1958f279316e13fecb8fa7df91fd60f690d7417b4ec180712f5a7ee967008e27c65cfd475cc84337728
+  languageName: node
+  linkType: hard
+
 "is-negative-zero@npm:^2.0.2":
   version: 2.0.2
   resolution: "is-negative-zero@npm:2.0.2"
@@ -13073,6 +13929,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"is-set@npm:^2.0.1":
+  version: 2.0.2
+  resolution: "is-set@npm:2.0.2"
+  checksum: b64343faf45e9387b97a6fd32be632ee7b269bd8183701f3b3f5b71a7cf00d04450ed8669d0bd08753e08b968beda96fca73a10fd0ff56a32603f64deba55a57
+  languageName: node
+  linkType: hard
+
 "is-shared-array-buffer@npm:^1.0.2":
   version: 1.0.2
   resolution: "is-shared-array-buffer@npm:1.0.2"
@@ -13136,6 +13999,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"is-typed-array@npm:^1.1.12":
+  version: 1.1.12
+  resolution: "is-typed-array@npm:1.1.12"
+  dependencies:
+    which-typed-array: ^1.1.11
+  checksum: 4c89c4a3be07186caddadf92197b17fda663a9d259ea0d44a85f171558270d36059d1c386d34a12cba22dfade5aba497ce22778e866adc9406098c8fc4771796
+  languageName: node
+  linkType: hard
+
 "is-typedarray@npm:^1.0.0":
   version: 1.0.0
   resolution: "is-typedarray@npm:1.0.0"
@@ -13164,6 +14036,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"is-weakmap@npm:^2.0.1":
+  version: 2.0.1
+  resolution: "is-weakmap@npm:2.0.1"
+  checksum: 1222bb7e90c32bdb949226e66d26cb7bce12e1e28e3e1b40bfa6b390ba3e08192a8664a703dff2a00a84825f4e022f9cd58c4599ff9981ab72b1d69479f4f7f6
+  languageName: node
+  linkType: hard
+
 "is-weakref@npm:^1.0.2":
   version: 1.0.2
   resolution: "is-weakref@npm:1.0.2"
@@ -13173,6 +14052,16 @@ __metadata:
   languageName: node
   linkType: hard
 
+"is-weakset@npm:^2.0.1":
+  version: 2.0.2
+  resolution: "is-weakset@npm:2.0.2"
+  dependencies:
+    call-bind: ^1.0.2
+    get-intrinsic: ^1.1.1
+  checksum: 5d8698d1fa599a0635d7ca85be9c26d547b317ed8fd83fc75f03efbe75d50001b5eececb1e9971de85fcde84f69ae6f8346bc92d20d55d46201d328e4c74a367
+  languageName: node
+  linkType: hard
+
 "is-windows@npm:^0.2.0":
   version: 0.2.0
   resolution: "is-windows@npm:0.2.0"
@@ -13224,6 +14113,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"isarray@npm:^2.0.5":
+  version: 2.0.5
+  resolution: "isarray@npm:2.0.5"
+  checksum: bd5bbe4104438c4196ba58a54650116007fa0262eccef13a4c55b2e09a5b36b59f1e75b9fcc49883dd9d4953892e6fc007eef9e9155648ceea036e184b0f930a
+  languageName: node
+  linkType: hard
+
 "isexe@npm:^2.0.0":
   version: 2.0.0
   resolution: "isexe@npm:2.0.0"
@@ -13259,7 +14155,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"istanbul-lib-instrument@npm:^5.0.4, istanbul-lib-instrument@npm:^5.1.0":
+"istanbul-lib-instrument@npm:^5.0.4":
   version: 5.2.0
   resolution: "istanbul-lib-instrument@npm:5.2.0"
   dependencies:
@@ -13272,6 +14168,19 @@ __metadata:
   languageName: node
   linkType: hard
 
+"istanbul-lib-instrument@npm:^6.0.0":
+  version: 6.0.1
+  resolution: "istanbul-lib-instrument@npm:6.0.1"
+  dependencies:
+    "@babel/core": ^7.12.3
+    "@babel/parser": ^7.14.7
+    "@istanbuljs/schema": ^0.1.2
+    istanbul-lib-coverage: ^3.2.0
+    semver: ^7.5.4
+  checksum: fb23472e739cfc9b027cefcd7d551d5e7ca7ff2817ae5150fab99fe42786a7f7b56a29a2aa8309c37092e18297b8003f9c274f50ca4360949094d17fbac81472
+  languageName: node
+  linkType: hard
+
 "istanbul-lib-report@npm:^3.0.0":
   version: 3.0.0
   resolution: "istanbul-lib-report@npm:3.0.0"
@@ -13314,6 +14223,19 @@ __metadata:
   languageName: node
   linkType: hard
 
+"iterator.prototype@npm:^1.1.2":
+  version: 1.1.2
+  resolution: "iterator.prototype@npm:1.1.2"
+  dependencies:
+    define-properties: ^1.2.1
+    get-intrinsic: ^1.2.1
+    has-symbols: ^1.0.3
+    reflect.getprototypeof: ^1.0.4
+    set-function-name: ^2.0.1
+  checksum: d8a507e2ccdc2ce762e8a1d3f4438c5669160ac72b88b648e59a688eec6bc4e64b22338e74000518418d9e693faf2a092d2af21b9ec7dbf7763b037a54701168
+  languageName: node
+  linkType: hard
+
 "jackspeak@npm:^2.0.3":
   version: 2.2.1
   resolution: "jackspeak@npm:2.2.1"
@@ -13341,58 +14263,59 @@ __metadata:
   languageName: node
   linkType: hard
 
-"jest-changed-files@npm:^28.0.2":
-  version: 28.0.2
-  resolution: "jest-changed-files@npm:28.0.2"
+"jest-changed-files@npm:^29.7.0":
+  version: 29.7.0
+  resolution: "jest-changed-files@npm:29.7.0"
   dependencies:
     execa: ^5.0.0
-    throat: ^6.0.1
-  checksum: 389d4de4b26de3d2c6e23783ef4e23f827a9a79cfebd2db7c6ff74727198814469ee1e1a89f0e6d28a94e3c632ec45b044c2400a0793b8591e18d07b4b421784
+    jest-util: ^29.7.0
+    p-limit: ^3.1.0
+  checksum: 963e203893c396c5dfc75e00a49426688efea7361b0f0e040035809cecd2d46b3c01c02be2d9e8d38b1138357d2de7719ea5b5be21f66c10f2e9685a5a73bb99
   languageName: node
   linkType: hard
 
-"jest-circus@npm:^28.1.1":
-  version: 28.1.1
-  resolution: "jest-circus@npm:28.1.1"
+"jest-circus@npm:^29.7.0":
+  version: 29.7.0
+  resolution: "jest-circus@npm:29.7.0"
   dependencies:
-    "@jest/environment": ^28.1.1
-    "@jest/expect": ^28.1.1
-    "@jest/test-result": ^28.1.1
-    "@jest/types": ^28.1.1
+    "@jest/environment": ^29.7.0
+    "@jest/expect": ^29.7.0
+    "@jest/test-result": ^29.7.0
+    "@jest/types": ^29.6.3
     "@types/node": "*"
     chalk: ^4.0.0
     co: ^4.6.0
-    dedent: ^0.7.0
+    dedent: ^1.0.0
     is-generator-fn: ^2.0.0
-    jest-each: ^28.1.1
-    jest-matcher-utils: ^28.1.1
-    jest-message-util: ^28.1.1
-    jest-runtime: ^28.1.1
-    jest-snapshot: ^28.1.1
-    jest-util: ^28.1.1
-    pretty-format: ^28.1.1
+    jest-each: ^29.7.0
+    jest-matcher-utils: ^29.7.0
+    jest-message-util: ^29.7.0
+    jest-runtime: ^29.7.0
+    jest-snapshot: ^29.7.0
+    jest-util: ^29.7.0
+    p-limit: ^3.1.0
+    pretty-format: ^29.7.0
+    pure-rand: ^6.0.0
     slash: ^3.0.0
     stack-utils: ^2.0.3
-    throat: ^6.0.1
-  checksum: 8fcca59012715034a731a3e072b295427f640b38ea6c3ba6c01cd6725a26e53bd02c93857573a298b5538b5f8b891d4083ef01230b1ff0a221ad2b653f7df7f5
+  checksum: 349437148924a5a109c9b8aad6d393a9591b4dac1918fc97d81b7fc515bc905af9918495055071404af1fab4e48e4b04ac3593477b1d5dcf48c4e71b527c70a7
   languageName: node
   linkType: hard
 
-"jest-cli@npm:^28.1.1":
-  version: 28.1.1
-  resolution: "jest-cli@npm:28.1.1"
+"jest-cli@npm:^29.7.0":
+  version: 29.7.0
+  resolution: "jest-cli@npm:29.7.0"
   dependencies:
-    "@jest/core": ^28.1.1
-    "@jest/test-result": ^28.1.1
-    "@jest/types": ^28.1.1
+    "@jest/core": ^29.7.0
+    "@jest/test-result": ^29.7.0
+    "@jest/types": ^29.6.3
     chalk: ^4.0.0
+    create-jest: ^29.7.0
     exit: ^0.1.2
-    graceful-fs: ^4.2.9
     import-local: ^3.0.2
-    jest-config: ^28.1.1
-    jest-util: ^28.1.1
-    jest-validate: ^28.1.1
-    prompts: ^2.0.1
+    jest-config: ^29.7.0
+    jest-util: ^29.7.0
+    jest-validate: ^29.7.0
     yargs: ^17.3.1
   peerDependencies:
     node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0
@@ -13401,34 +14324,34 @@ __metadata:
       optional: true
   bin:
     jest: bin/jest.js
-  checksum: fce96f2f0cccba2de549b615a73a30f4c4aaadbaa2e292d3cc57222526335872bda657a1f3fa3c69fc081bee79abfce9fbf58ebb027ad89bcc34cd395717deb4
+  checksum: 664901277a3f5007ea4870632ed6e7889db9da35b2434e7cb488443e6bf5513889b344b7fddf15112135495b9875892b156faeb2d7391ddb9e2a849dcb7b6c36
   languageName: node
   linkType: hard
 
-"jest-config@npm:^28.1.1":
-  version: 28.1.1
-  resolution: "jest-config@npm:28.1.1"
+"jest-config@npm:^29.7.0":
+  version: 29.7.0
+  resolution: "jest-config@npm:29.7.0"
   dependencies:
     "@babel/core": ^7.11.6
-    "@jest/test-sequencer": ^28.1.1
-    "@jest/types": ^28.1.1
-    babel-jest: ^28.1.1
+    "@jest/test-sequencer": ^29.7.0
+    "@jest/types": ^29.6.3
+    babel-jest: ^29.7.0
     chalk: ^4.0.0
     ci-info: ^3.2.0
     deepmerge: ^4.2.2
     glob: ^7.1.3
     graceful-fs: ^4.2.9
-    jest-circus: ^28.1.1
-    jest-environment-node: ^28.1.1
-    jest-get-type: ^28.0.2
-    jest-regex-util: ^28.0.2
-    jest-resolve: ^28.1.1
-    jest-runner: ^28.1.1
-    jest-util: ^28.1.1
-    jest-validate: ^28.1.1
+    jest-circus: ^29.7.0
+    jest-environment-node: ^29.7.0
+    jest-get-type: ^29.6.3
+    jest-regex-util: ^29.6.3
+    jest-resolve: ^29.7.0
+    jest-runner: ^29.7.0
+    jest-util: ^29.7.0
+    jest-validate: ^29.7.0
     micromatch: ^4.0.4
     parse-json: ^5.2.0
-    pretty-format: ^28.1.1
+    pretty-format: ^29.7.0
     slash: ^3.0.0
     strip-json-comments: ^3.1.1
   peerDependencies:
@@ -13439,7 +14362,7 @@ __metadata:
       optional: true
     ts-node:
       optional: true
-  checksum: 8ce9f6b8f6b416f77294cad18deb4b720f19277dea6c6ffea63c25fc6a2dd7ef70c686d6405487ee8ea088801e1885b37a3cee2fbbf823064f37faf245cac347
+  checksum: 4cabf8f894c180cac80b7df1038912a3fc88f96f2622de33832f4b3314f83e22b08fb751da570c0ab2b7988f21604bdabade95e3c0c041068ac578c085cf7dff
   languageName: node
   linkType: hard
 
@@ -13458,30 +14381,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"jest-diff@npm:^27.5.1":
-  version: 27.5.1
-  resolution: "jest-diff@npm:27.5.1"
-  dependencies:
-    chalk: ^4.0.0
-    diff-sequences: ^27.5.1
-    jest-get-type: ^27.5.1
-    pretty-format: ^27.5.1
-  checksum: 8be27c1e1ee57b2bb2bef9c0b233c19621b4c43d53a3c26e2c00a4e805eb4ea11fe1694a06a9fb0e80ffdcfdc0d2b1cb0b85920b3f5c892327ecd1e7bd96b865
-  languageName: node
-  linkType: hard
-
-"jest-diff@npm:^28.1.1":
-  version: 28.1.1
-  resolution: "jest-diff@npm:28.1.1"
-  dependencies:
-    chalk: ^4.0.0
-    diff-sequences: ^28.1.1
-    jest-get-type: ^28.0.2
-    pretty-format: ^28.1.1
-  checksum: d9e0355880bee8728f7615ac0f03c66dcd4e93113935cca056a5f5a2f20ac2c7812aca6ad68e79bd1b11f2428748bd9123e6b1c7e51c93b4da3dfa5a875339f7
-  languageName: node
-  linkType: hard
-
 "jest-diff@npm:^29.5.0":
   version: 29.5.0
   resolution: "jest-diff@npm:29.5.0"
@@ -13494,25 +14393,37 @@ __metadata:
   languageName: node
   linkType: hard
 
-"jest-docblock@npm:^28.1.1":
-  version: 28.1.1
-  resolution: "jest-docblock@npm:28.1.1"
+"jest-diff@npm:^29.7.0":
+  version: 29.7.0
+  resolution: "jest-diff@npm:29.7.0"
+  dependencies:
+    chalk: ^4.0.0
+    diff-sequences: ^29.6.3
+    jest-get-type: ^29.6.3
+    pretty-format: ^29.7.0
+  checksum: 08e24a9dd43bfba1ef07a6374e5af138f53137b79ec3d5cc71a2303515335898888fa5409959172e1e05de966c9e714368d15e8994b0af7441f0721ee8e1bb77
+  languageName: node
+  linkType: hard
+
+"jest-docblock@npm:^29.7.0":
+  version: 29.7.0
+  resolution: "jest-docblock@npm:29.7.0"
   dependencies:
     detect-newline: ^3.0.0
-  checksum: 22fca68d988ecb2933bc65f448facdca85fc71b4bd0a188ea09a5ae1b0cc3a049a2a6ec7e7eaa2542c1d5cb5e5145e420a3df4fa280f5070f486c44da1d36151
+  checksum: 66390c3e9451f8d96c5da62f577a1dad701180cfa9b071c5025acab2f94d7a3efc2515cfa1654ebe707213241541ce9c5530232cdc8017c91ed64eea1bd3b192
   languageName: node
   linkType: hard
 
-"jest-each@npm:^28.1.1":
-  version: 28.1.1
-  resolution: "jest-each@npm:28.1.1"
+"jest-each@npm:^29.7.0":
+  version: 29.7.0
+  resolution: "jest-each@npm:29.7.0"
   dependencies:
-    "@jest/types": ^28.1.1
+    "@jest/types": ^29.6.3
     chalk: ^4.0.0
-    jest-get-type: ^28.0.2
-    jest-util: ^28.1.1
-    pretty-format: ^28.1.1
-  checksum: 91965603f898d5e29150995333f5b193aa37f36b232fc9ffd1be546236e7e47f5df4eca1f25ee45eb549e0866f4769d6a8045591703454b505d18e9fe2b18572
+    jest-get-type: ^29.6.3
+    jest-util: ^29.7.0
+    pretty-format: ^29.7.0
+  checksum: e88f99f0184000fc8813f2a0aa79e29deeb63700a3b9b7928b8a418d7d93cd24933608591dbbdea732b473eb2021c72991b5cc51a17966842841c6e28e6f691c
   languageName: node
   linkType: hard
 
@@ -13532,31 +14443,17 @@ __metadata:
   languageName: node
   linkType: hard
 
-"jest-environment-node@npm:^28.1.1":
-  version: 28.1.1
-  resolution: "jest-environment-node@npm:28.1.1"
+"jest-environment-node@npm:^29.7.0":
+  version: 29.7.0
+  resolution: "jest-environment-node@npm:29.7.0"
   dependencies:
-    "@jest/environment": ^28.1.1
-    "@jest/fake-timers": ^28.1.1
-    "@jest/types": ^28.1.1
+    "@jest/environment": ^29.7.0
+    "@jest/fake-timers": ^29.7.0
+    "@jest/types": ^29.6.3
     "@types/node": "*"
-    jest-mock: ^28.1.1
-    jest-util: ^28.1.1
-  checksum: fe6fec178a8e5275daba1aeead61981511f050e4d68d67d348a756276ea3e844237b09e56ad450638d6c442c15a6057878f0167e43355c46d11920c10878a0d4
-  languageName: node
-  linkType: hard
-
-"jest-get-type@npm:^27.5.1":
-  version: 27.5.1
-  resolution: "jest-get-type@npm:27.5.1"
-  checksum: 63064ab70195c21007d897c1157bf88ff94a790824a10f8c890392e7d17eda9c3900513cb291ca1c8d5722cad79169764e9a1279f7c8a9c4cd6e9109ff04bbc0
-  languageName: node
-  linkType: hard
-
-"jest-get-type@npm:^28.0.2":
-  version: 28.0.2
-  resolution: "jest-get-type@npm:28.0.2"
-  checksum: 5281d7c89bc8156605f6d15784f45074f4548501195c26e9b188742768f72d40948252d13230ea905b5349038865a1a8eeff0e614cc530ff289dfc41fe843abd
+    jest-mock: ^29.7.0
+    jest-util: ^29.7.0
+  checksum: 501a9966292cbe0ca3f40057a37587cb6def25e1e0c5e39ac6c650fe78d3c70a2428304341d084ac0cced5041483acef41c477abac47e9a290d5545fd2f15646
   languageName: node
   linkType: hard
 
@@ -13567,60 +14464,43 @@ __metadata:
   languageName: node
   linkType: hard
 
-"jest-haste-map@npm:^28.1.1":
-  version: 28.1.1
-  resolution: "jest-haste-map@npm:28.1.1"
+"jest-get-type@npm:^29.6.3":
+  version: 29.6.3
+  resolution: "jest-get-type@npm:29.6.3"
+  checksum: 88ac9102d4679d768accae29f1e75f592b760b44277df288ad76ce5bf038c3f5ce3719dea8aa0f035dac30e9eb034b848ce716b9183ad7cc222d029f03e92205
+  languageName: node
+  linkType: hard
+
+"jest-haste-map@npm:^29.7.0":
+  version: 29.7.0
+  resolution: "jest-haste-map@npm:29.7.0"
   dependencies:
-    "@jest/types": ^28.1.1
+    "@jest/types": ^29.6.3
     "@types/graceful-fs": ^4.1.3
     "@types/node": "*"
     anymatch: ^3.0.3
     fb-watchman: ^2.0.0
     fsevents: ^2.3.2
     graceful-fs: ^4.2.9
-    jest-regex-util: ^28.0.2
-    jest-util: ^28.1.1
-    jest-worker: ^28.1.1
+    jest-regex-util: ^29.6.3
+    jest-util: ^29.7.0
+    jest-worker: ^29.7.0
     micromatch: ^4.0.4
     walker: ^1.0.8
   dependenciesMeta:
     fsevents:
       optional: true
-  checksum: db31a2a83906277d96b79017742c433c1573b322d061632a011fb1e184cf6f151f94134da09da7366e4477e8716f280efa676b4cc04a8544c13ce466a44102e8
-  languageName: node
-  linkType: hard
-
-"jest-leak-detector@npm:^28.1.1":
-  version: 28.1.1
-  resolution: "jest-leak-detector@npm:28.1.1"
-  dependencies:
-    jest-get-type: ^28.0.2
-    pretty-format: ^28.1.1
-  checksum: 379a15ad7bed4f6d11414cc0131a5a592ac9c0b12a5933c522b292209a325b12a852e2330144fb59c82420a89712e46f2c244a881722473e241ad1c487fc476d
-  languageName: node
-  linkType: hard
-
-"jest-matcher-utils@npm:^27.0.0":
-  version: 27.5.1
-  resolution: "jest-matcher-utils@npm:27.5.1"
-  dependencies:
-    chalk: ^4.0.0
-    jest-diff: ^27.5.1
-    jest-get-type: ^27.5.1
-    pretty-format: ^27.5.1
-  checksum: bb2135fc48889ff3fe73888f6cc7168ddab9de28b51b3148f820c89fdfd2effdcad005f18be67d0b9be80eda208ad47290f62f03d0a33f848db2dd0273c8217a
+  checksum: c2c8f2d3e792a963940fbdfa563ce14ef9e14d4d86da645b96d3cd346b8d35c5ce0b992ee08593939b5f718cf0a1f5a90011a056548a1dbf58397d4356786f01
   languageName: node
   linkType: hard
 
-"jest-matcher-utils@npm:^28.1.1":
-  version: 28.1.1
-  resolution: "jest-matcher-utils@npm:28.1.1"
+"jest-leak-detector@npm:^29.7.0":
+  version: 29.7.0
+  resolution: "jest-leak-detector@npm:29.7.0"
   dependencies:
-    chalk: ^4.0.0
-    jest-diff: ^28.1.1
-    jest-get-type: ^28.0.2
-    pretty-format: ^28.1.1
-  checksum: cb73ccd347638cd761ef7e0b606fbd71c115bd8febe29413f7b105fff6855d4356b8094c6b72393c5457db253b9c163498f188f25f9b6308c39c510e4c2886ee
+    jest-get-type: ^29.6.3
+    pretty-format: ^29.7.0
+  checksum: e3950e3ddd71e1d0c22924c51a300a1c2db6cf69ec1e51f95ccf424bcc070f78664813bef7aed4b16b96dfbdeea53fe358f8aeaaea84346ae15c3735758f1605
   languageName: node
   linkType: hard
 
@@ -13636,6 +14516,18 @@ __metadata:
   languageName: node
   linkType: hard
 
+"jest-matcher-utils@npm:^29.7.0":
+  version: 29.7.0
+  resolution: "jest-matcher-utils@npm:29.7.0"
+  dependencies:
+    chalk: ^4.0.0
+    jest-diff: ^29.7.0
+    jest-get-type: ^29.6.3
+    pretty-format: ^29.7.0
+  checksum: d7259e5f995d915e8a37a8fd494cb7d6af24cd2a287b200f831717ba0d015190375f9f5dc35393b8ba2aae9b2ebd60984635269c7f8cff7d85b077543b7744cd
+  languageName: node
+  linkType: hard
+
 "jest-message-util@npm:^28.1.1":
   version: 28.1.1
   resolution: "jest-message-util@npm:28.1.1"
@@ -13670,6 +14562,23 @@ __metadata:
   languageName: node
   linkType: hard
 
+"jest-message-util@npm:^29.7.0":
+  version: 29.7.0
+  resolution: "jest-message-util@npm:29.7.0"
+  dependencies:
+    "@babel/code-frame": ^7.12.13
+    "@jest/types": ^29.6.3
+    "@types/stack-utils": ^2.0.0
+    chalk: ^4.0.0
+    graceful-fs: ^4.2.9
+    micromatch: ^4.0.4
+    pretty-format: ^29.7.0
+    slash: ^3.0.0
+    stack-utils: ^2.0.3
+  checksum: a9d025b1c6726a2ff17d54cc694de088b0489456c69106be6b615db7a51b7beb66788bea7a59991a019d924fbf20f67d085a445aedb9a4d6760363f4d7d09930
+  languageName: node
+  linkType: hard
+
 "jest-mock@npm:^28.1.1":
   version: 28.1.1
   resolution: "jest-mock@npm:28.1.1"
@@ -13680,6 +14589,17 @@ __metadata:
   languageName: node
   linkType: hard
 
+"jest-mock@npm:^29.7.0":
+  version: 29.7.0
+  resolution: "jest-mock@npm:29.7.0"
+  dependencies:
+    "@jest/types": ^29.6.3
+    "@types/node": "*"
+    jest-util: ^29.7.0
+  checksum: 81ba9b68689a60be1482212878973700347cb72833c5e5af09895882b9eb5c4e02843a1bbdf23f94c52d42708bab53a30c45a3482952c9eec173d1eaac5b86c5
+  languageName: node
+  linkType: hard
+
 "jest-pnp-resolver@npm:^1.2.2":
   version: 1.2.2
   resolution: "jest-pnp-resolver@npm:1.2.2"
@@ -13692,131 +14612,128 @@ __metadata:
   languageName: node
   linkType: hard
 
-"jest-regex-util@npm:^28.0.2":
-  version: 28.0.2
-  resolution: "jest-regex-util@npm:28.0.2"
-  checksum: 0ea8c5c82ec88bc85e273c0ec82e0c0f35f7a1e2d055070e50f0cc2a2177f848eec55f73e37ae0d045c3db5014c42b2f90ac62c1ab3fdb354d2abd66a9e08add
+"jest-regex-util@npm:^29.6.3":
+  version: 29.6.3
+  resolution: "jest-regex-util@npm:29.6.3"
+  checksum: 0518beeb9bf1228261695e54f0feaad3606df26a19764bc19541e0fc6e2a3737191904607fb72f3f2ce85d9c16b28df79b7b1ec9443aa08c3ef0e9efda6f8f2a
   languageName: node
   linkType: hard
 
-"jest-resolve-dependencies@npm:^28.1.1":
-  version: 28.1.1
-  resolution: "jest-resolve-dependencies@npm:28.1.1"
+"jest-resolve-dependencies@npm:^29.7.0":
+  version: 29.7.0
+  resolution: "jest-resolve-dependencies@npm:29.7.0"
   dependencies:
-    jest-regex-util: ^28.0.2
-    jest-snapshot: ^28.1.1
-  checksum: d1d5db627f650872018656381fd7c3d10d6331aa7d28701ebc04748daea8cc5ec010ce6a662cceca478f3bb9e5940c5e768d6c76690f120224b2b5f36347eda5
+    jest-regex-util: ^29.6.3
+    jest-snapshot: ^29.7.0
+  checksum: aeb75d8150aaae60ca2bb345a0d198f23496494677cd6aefa26fc005faf354061f073982175daaf32b4b9d86b26ca928586344516e3e6969aa614cb13b883984
   languageName: node
   linkType: hard
 
-"jest-resolve@npm:^28.1.1":
-  version: 28.1.1
-  resolution: "jest-resolve@npm:28.1.1"
+"jest-resolve@npm:^29.7.0":
+  version: 29.7.0
+  resolution: "jest-resolve@npm:29.7.0"
   dependencies:
     chalk: ^4.0.0
     graceful-fs: ^4.2.9
-    jest-haste-map: ^28.1.1
+    jest-haste-map: ^29.7.0
     jest-pnp-resolver: ^1.2.2
-    jest-util: ^28.1.1
-    jest-validate: ^28.1.1
+    jest-util: ^29.7.0
+    jest-validate: ^29.7.0
     resolve: ^1.20.0
-    resolve.exports: ^1.1.0
+    resolve.exports: ^2.0.0
     slash: ^3.0.0
-  checksum: cda5c472fe5b50b91696d90d5c3a72d0f5ff188ecad18816b4085fbac0bad53c0a9abff94c3bf41c7ced24256cf8e34f0b03f1c9e05464e8efcd0f03560d6699
+  checksum: 0ca218e10731aa17920526ec39deaec59ab9b966237905ffc4545444481112cd422f01581230eceb7e82d86f44a543d520a71391ec66e1b4ef1a578bd5c73487
   languageName: node
   linkType: hard
 
-"jest-runner@npm:^28.1.1":
-  version: 28.1.1
-  resolution: "jest-runner@npm:28.1.1"
-  dependencies:
-    "@jest/console": ^28.1.1
-    "@jest/environment": ^28.1.1
-    "@jest/test-result": ^28.1.1
-    "@jest/transform": ^28.1.1
-    "@jest/types": ^28.1.1
+"jest-runner@npm:^29.7.0":
+  version: 29.7.0
+  resolution: "jest-runner@npm:29.7.0"
+  dependencies:
+    "@jest/console": ^29.7.0
+    "@jest/environment": ^29.7.0
+    "@jest/test-result": ^29.7.0
+    "@jest/transform": ^29.7.0
+    "@jest/types": ^29.6.3
     "@types/node": "*"
     chalk: ^4.0.0
-    emittery: ^0.10.2
+    emittery: ^0.13.1
     graceful-fs: ^4.2.9
-    jest-docblock: ^28.1.1
-    jest-environment-node: ^28.1.1
-    jest-haste-map: ^28.1.1
-    jest-leak-detector: ^28.1.1
-    jest-message-util: ^28.1.1
-    jest-resolve: ^28.1.1
-    jest-runtime: ^28.1.1
-    jest-util: ^28.1.1
-    jest-watcher: ^28.1.1
-    jest-worker: ^28.1.1
+    jest-docblock: ^29.7.0
+    jest-environment-node: ^29.7.0
+    jest-haste-map: ^29.7.0
+    jest-leak-detector: ^29.7.0
+    jest-message-util: ^29.7.0
+    jest-resolve: ^29.7.0
+    jest-runtime: ^29.7.0
+    jest-util: ^29.7.0
+    jest-watcher: ^29.7.0
+    jest-worker: ^29.7.0
+    p-limit: ^3.1.0
     source-map-support: 0.5.13
-    throat: ^6.0.1
-  checksum: f2659154340d083cd12b1ed75a0aaa6ff2d055996e96148e250655363bb309266be226d2eeb4d1faf451df1f372ff2f02223665e0595db66c6d7c6016a700a8e
+  checksum: f0405778ea64812bf9b5c50b598850d94ccf95d7ba21f090c64827b41decd680ee19fcbb494007cdd7f5d0d8906bfc9eceddd8fa583e753e736ecd462d4682fb
   languageName: node
   linkType: hard
 
-"jest-runtime@npm:^28.1.1":
-  version: 28.1.1
-  resolution: "jest-runtime@npm:28.1.1"
+"jest-runtime@npm:^29.7.0":
+  version: 29.7.0
+  resolution: "jest-runtime@npm:29.7.0"
   dependencies:
-    "@jest/environment": ^28.1.1
-    "@jest/fake-timers": ^28.1.1
-    "@jest/globals": ^28.1.1
-    "@jest/source-map": ^28.0.2
-    "@jest/test-result": ^28.1.1
-    "@jest/transform": ^28.1.1
-    "@jest/types": ^28.1.1
+    "@jest/environment": ^29.7.0
+    "@jest/fake-timers": ^29.7.0
+    "@jest/globals": ^29.7.0
+    "@jest/source-map": ^29.6.3
+    "@jest/test-result": ^29.7.0
+    "@jest/transform": ^29.7.0
+    "@jest/types": ^29.6.3
+    "@types/node": "*"
     chalk: ^4.0.0
     cjs-module-lexer: ^1.0.0
     collect-v8-coverage: ^1.0.0
-    execa: ^5.0.0
     glob: ^7.1.3
     graceful-fs: ^4.2.9
-    jest-haste-map: ^28.1.1
-    jest-message-util: ^28.1.1
-    jest-mock: ^28.1.1
-    jest-regex-util: ^28.0.2
-    jest-resolve: ^28.1.1
-    jest-snapshot: ^28.1.1
-    jest-util: ^28.1.1
+    jest-haste-map: ^29.7.0
+    jest-message-util: ^29.7.0
+    jest-mock: ^29.7.0
+    jest-regex-util: ^29.6.3
+    jest-resolve: ^29.7.0
+    jest-snapshot: ^29.7.0
+    jest-util: ^29.7.0
     slash: ^3.0.0
     strip-bom: ^4.0.0
-  checksum: 3600e3c1be4c4fe86ead9e874cf0342fab0445bf016a44705a8c00721be1d69c2d7b5fd1b14f1e63764719db1a86d9d9eca44dde3dd27e44ecea1b39345c5c57
+  checksum: d19f113d013e80691e07047f68e1e3448ef024ff2c6b586ce4f90cd7d4c62a2cd1d460110491019719f3c59bfebe16f0e201ed005ef9f80e2cf798c374eed54e
   languageName: node
   linkType: hard
 
-"jest-snapshot@npm:^28.1.1":
-  version: 28.1.1
-  resolution: "jest-snapshot@npm:28.1.1"
+"jest-snapshot@npm:^29.7.0":
+  version: 29.7.0
+  resolution: "jest-snapshot@npm:29.7.0"
   dependencies:
     "@babel/core": ^7.11.6
     "@babel/generator": ^7.7.2
+    "@babel/plugin-syntax-jsx": ^7.7.2
     "@babel/plugin-syntax-typescript": ^7.7.2
-    "@babel/traverse": ^7.7.2
     "@babel/types": ^7.3.3
-    "@jest/expect-utils": ^28.1.1
-    "@jest/transform": ^28.1.1
-    "@jest/types": ^28.1.1
-    "@types/babel__traverse": ^7.0.6
-    "@types/prettier": ^2.1.5
+    "@jest/expect-utils": ^29.7.0
+    "@jest/transform": ^29.7.0
+    "@jest/types": ^29.6.3
     babel-preset-current-node-syntax: ^1.0.0
     chalk: ^4.0.0
-    expect: ^28.1.1
+    expect: ^29.7.0
     graceful-fs: ^4.2.9
-    jest-diff: ^28.1.1
-    jest-get-type: ^28.0.2
-    jest-haste-map: ^28.1.1
-    jest-matcher-utils: ^28.1.1
-    jest-message-util: ^28.1.1
-    jest-util: ^28.1.1
+    jest-diff: ^29.7.0
+    jest-get-type: ^29.6.3
+    jest-matcher-utils: ^29.7.0
+    jest-message-util: ^29.7.0
+    jest-util: ^29.7.0
     natural-compare: ^1.4.0
-    pretty-format: ^28.1.1
-    semver: ^7.3.5
-  checksum: b540e8755f973526db2a7837814361fe6754eec33eaa2e23f2eed11ae1c083763a47283789f58c461e32a30ee5cc2a3c106ce096ffde412f5d4929c546250a7a
+    pretty-format: ^29.7.0
+    semver: ^7.5.3
+  checksum: 86821c3ad0b6899521ce75ee1ae7b01b17e6dfeff9166f2cf17f012e0c5d8c798f30f9e4f8f7f5bed01ea7b55a6bc159f5eda778311162cbfa48785447c237ad
   languageName: node
   linkType: hard
 
-"jest-util@npm:^28.0.0, jest-util@npm:^28.1.1":
+"jest-util@npm:^28.1.1":
   version: 28.1.1
   resolution: "jest-util@npm:28.1.1"
   dependencies:
@@ -13830,6 +14747,20 @@ __metadata:
   languageName: node
   linkType: hard
 
+"jest-util@npm:^29.0.0, jest-util@npm:^29.7.0":
+  version: 29.7.0
+  resolution: "jest-util@npm:29.7.0"
+  dependencies:
+    "@jest/types": ^29.6.3
+    "@types/node": "*"
+    chalk: ^4.0.0
+    ci-info: ^3.2.0
+    graceful-fs: ^4.2.9
+    picomatch: ^2.2.3
+  checksum: 042ab4980f4ccd4d50226e01e5c7376a8556b472442ca6091a8f102488c0f22e6e8b89ea874111d2328a2080083bf3225c86f3788c52af0bd0345a00eb57a3ca
+  languageName: node
+  linkType: hard
+
 "jest-util@npm:^29.5.0":
   version: 29.5.0
   resolution: "jest-util@npm:29.5.0"
@@ -13844,33 +14775,33 @@ __metadata:
   languageName: node
   linkType: hard
 
-"jest-validate@npm:^28.1.1":
-  version: 28.1.1
-  resolution: "jest-validate@npm:28.1.1"
+"jest-validate@npm:^29.7.0":
+  version: 29.7.0
+  resolution: "jest-validate@npm:29.7.0"
   dependencies:
-    "@jest/types": ^28.1.1
+    "@jest/types": ^29.6.3
     camelcase: ^6.2.0
     chalk: ^4.0.0
-    jest-get-type: ^28.0.2
+    jest-get-type: ^29.6.3
     leven: ^3.1.0
-    pretty-format: ^28.1.1
-  checksum: 7bb5427d9b5ef4efc218aaf1f2a4282ebcc66458a6c40aa9fd2914aab967d3157352fb37ea46c83c1bc640ccf997ca3edee4d7aa109dccc02a7c821bac192104
+    pretty-format: ^29.7.0
+  checksum: 191fcdc980f8a0de4dbdd879fa276435d00eb157a48683af7b3b1b98b0f7d9de7ffe12689b617779097ff1ed77601b9f7126b0871bba4f776e222c40f62e9dae
   languageName: node
   linkType: hard
 
-"jest-watcher@npm:^28.1.1":
-  version: 28.1.1
-  resolution: "jest-watcher@npm:28.1.1"
+"jest-watcher@npm:^29.7.0":
+  version: 29.7.0
+  resolution: "jest-watcher@npm:29.7.0"
   dependencies:
-    "@jest/test-result": ^28.1.1
-    "@jest/types": ^28.1.1
+    "@jest/test-result": ^29.7.0
+    "@jest/types": ^29.6.3
     "@types/node": "*"
     ansi-escapes: ^4.2.1
     chalk: ^4.0.0
-    emittery: ^0.10.2
-    jest-util: ^28.1.1
+    emittery: ^0.13.1
+    jest-util: ^29.7.0
     string-length: ^4.0.1
-  checksum: 60ee90a3b760db2bc57173a0f3fc44f3162491e1ca4cf6a0e99d40bea3825e2a20c47c3ba13ebcbaea09cd2e4fe338c41841a972d9fe49ed7bbf3f34d2734ebd
+  checksum: 67e6e7fe695416deff96b93a14a561a6db69389a0667e9489f24485bb85e5b54e12f3b2ba511ec0b777eca1e727235b073e3ebcdd473d68888650489f88df92f
   languageName: node
   linkType: hard
 
@@ -13896,25 +14827,26 @@ __metadata:
   languageName: node
   linkType: hard
 
-"jest-worker@npm:^28.1.1":
-  version: 28.1.1
-  resolution: "jest-worker@npm:28.1.1"
+"jest-worker@npm:^29.7.0":
+  version: 29.7.0
+  resolution: "jest-worker@npm:29.7.0"
   dependencies:
     "@types/node": "*"
+    jest-util: ^29.7.0
     merge-stream: ^2.0.0
     supports-color: ^8.0.0
-  checksum: 28519c43b4007e60a3756d27f1e7884192ee9161b6a9587383a64b6535f820cc4868e351a67775e0feada41465f48ccf323a8db34ae87e15a512ddac5d1424b2
+  checksum: 30fff60af49675273644d408b650fc2eb4b5dcafc5a0a455f238322a8f9d8a98d847baca9d51ff197b6747f54c7901daa2287799230b856a0f48287d131f8c13
   languageName: node
   linkType: hard
 
-"jest@npm:^28.1.0":
-  version: 28.1.1
-  resolution: "jest@npm:28.1.1"
+"jest@npm:^29.7.0":
+  version: 29.7.0
+  resolution: "jest@npm:29.7.0"
   dependencies:
-    "@jest/core": ^28.1.1
-    "@jest/types": ^28.1.1
+    "@jest/core": ^29.7.0
+    "@jest/types": ^29.6.3
     import-local: ^3.0.2
-    jest-cli: ^28.1.1
+    jest-cli: ^29.7.0
   peerDependencies:
     node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0
   peerDependenciesMeta:
@@ -13922,7 +14854,7 @@ __metadata:
       optional: true
   bin:
     jest: bin/jest.js
-  checksum: 398a143d9ef1a78e2ba516a09b6343cb926bf20e29ad400141dd3bd57e964195b82817a60eb8745ba9006fcd7c028ceda5108e3c426fa4e29877f28d87cf88a3
+  checksum: 17ca8d67504a7dbb1998cf3c3077ec9031ba3eb512da8d71cb91bcabb2b8995c4e4b292b740cb9bf1cbff5ce3e110b3f7c777b0cefb6f41ab05445f248d0ee0b
   languageName: node
   linkType: hard
 
@@ -13953,6 +14885,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"jose@npm:^5.1.0":
+  version: 5.1.0
+  resolution: "jose@npm:5.1.0"
+  checksum: 62e907e953fd83f869cf4ce2e08359f5678bf99a6a4cc50faf7997b4694bc5cd32f43b79c3ff86f0e5385346b465a408166ccd1bf4237ae8c705ee82c733acc0
+  languageName: node
+  linkType: hard
+
 "jpeg-js@npm:^0.4.1, jpeg-js@npm:^0.4.3":
   version: 0.4.4
   resolution: "jpeg-js@npm:0.4.4"
@@ -14151,7 +15090,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"json5@npm:^1.0.1":
+"json5@npm:^1.0.1, json5@npm:^1.0.2":
   version: 1.0.2
   resolution: "json5@npm:1.0.2"
   dependencies:
@@ -14205,13 +15144,15 @@ __metadata:
   languageName: node
   linkType: hard
 
-"jsx-ast-utils@npm:^2.4.1 || ^3.0.0, jsx-ast-utils@npm:^3.2.1":
-  version: 3.3.0
-  resolution: "jsx-ast-utils@npm:3.3.0"
+"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: ^3.1.4
-    object.assign: ^4.1.2
-  checksum: e3c0667e8979c70600fb0456b19f0ec194994c953678ac2772a819d8d5740df2ed751e49e4f1db7869bf63251585a93b18acd42ef02269fe41cb23941d0d4950
+    array-includes: ^3.1.6
+    array.prototype.flat: ^1.3.1
+    object.assign: ^4.1.4
+    object.values: ^1.1.6
+  checksum: f4b05fa4d7b5234230c905cfa88d36dc8a58a6666975a3891429b1a8cdc8a140bca76c297225cb7a499fad25a2c052ac93934449a2c31a44fc9edd06c773780a
   languageName: node
   linkType: hard
 
@@ -14261,19 +15202,19 @@ __metadata:
   languageName: node
   linkType: hard
 
-"language-subtag-registry@npm:~0.3.2":
-  version: 0.3.21
-  resolution: "language-subtag-registry@npm:0.3.21"
-  checksum: 5f794525a5bfcefeea155a681af1c03365b60e115b688952a53c6e0b9532b09163f57f1fcb69d6150e0e805ec0350644a4cb35da98f4902562915be9f89572a1
+"language-subtag-registry@npm:^0.3.20":
+  version: 0.3.22
+  resolution: "language-subtag-registry@npm:0.3.22"
+  checksum: 8ab70a7e0e055fe977ac16ea4c261faec7205ac43db5e806f72e5b59606939a3b972c4bd1e10e323b35d6ffa97c3e1c4c99f6553069dad2dfdd22020fa3eb56a
   languageName: node
   linkType: hard
 
-"language-tags@npm:^1.0.5":
-  version: 1.0.5
-  resolution: "language-tags@npm:1.0.5"
+"language-tags@npm:^1.0.9":
+  version: 1.0.9
+  resolution: "language-tags@npm:1.0.9"
   dependencies:
-    language-subtag-registry: ~0.3.2
-  checksum: c81b5d8b9f5f9cfd06ee71ada6ddfe1cf83044dd5eeefcd1e420ad491944da8957688db4a0a9bc562df4afdc2783425cbbdfd152c01d93179cf86888903123cf
+    language-subtag-registry: ^0.3.20
+  checksum: 57c530796dc7179914dee71bc94f3747fd694612480241d0453a063777265dfe3a951037f7acb48f456bf167d6eb419d4c00263745326b3ba1cdcf4657070e78
   languageName: node
   linkType: hard
 
@@ -14596,16 +15537,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"locate-path@npm:^2.0.0":
-  version: 2.0.0
-  resolution: "locate-path@npm:2.0.0"
-  dependencies:
-    p-locate: ^2.0.0
-    path-exists: ^3.0.0
-  checksum: 02d581edbbbb0fa292e28d96b7de36b5b62c2fa8b5a7e82638ebb33afa74284acf022d3b1e9ae10e3ffb7658fbc49163fcd5e76e7d1baaa7801c3e05a81da755
-  languageName: node
-  linkType: hard
-
 "locate-path@npm:^5.0.0":
   version: 5.0.0
   resolution: "locate-path@npm:5.0.0"
@@ -14928,6 +15859,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"magic-string@npm:^0.25.3":
+  version: 0.25.9
+  resolution: "magic-string@npm:0.25.9"
+  dependencies:
+    sourcemap-codec: ^1.4.8
+  checksum: 9a0e55a15c7303fc360f9572a71cffba1f61451bc92c5602b1206c9d17f492403bf96f946dfce7483e66822d6b74607262e24392e87b0ac27b786e69a40e9b1a
+  languageName: node
+  linkType: hard
+
 "make-dir@npm:^2.1.0":
   version: 2.1.0
   resolution: "make-dir@npm:2.1.0"
@@ -15249,6 +16189,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: f43f9b7bfa64534e6b05bd6062961681aeb406a5b53673b53b683f27fcc4e739989941836a355eef831f4478923651ecc739f4a5f6e20a76487b432bfd4db928
+  languageName: node
+  linkType: hard
+
 "mimic-fn@npm:^2.1.0":
   version: 2.1.0
   resolution: "mimic-fn@npm:2.1.0"
@@ -15291,6 +16240,26 @@ __metadata:
   languageName: node
   linkType: hard
 
+"miniflare@npm:3.20231002.1":
+  version: 3.20231002.1
+  resolution: "miniflare@npm:3.20231002.1"
+  dependencies:
+    acorn: ^8.8.0
+    acorn-walk: ^8.2.0
+    capnp-ts: ^0.7.0
+    exit-hook: ^2.2.1
+    glob-to-regexp: ^0.4.1
+    source-map-support: 0.5.21
+    stoppable: ^1.1.0
+    undici: ^5.22.1
+    workerd: 1.20231002.0
+    ws: ^8.11.0
+    youch: ^3.2.2
+    zod: ^3.20.6
+  checksum: 96105f99fc0e34f248c301279dc955fca1b12c0c2808a15954552b75df558c9d9964454cd295d37eeb92a4b564239e4912fa6b0e84359a86a62ab5f1e3399aea
+  languageName: node
+  linkType: hard
+
 "minimalistic-assert@npm:^1.0.0":
   version: 1.0.1
   resolution: "minimalistic-assert@npm:1.0.1"
@@ -15580,6 +16549,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: 928fcb63e3aa44a562bfe9b59ba202cccbe40a46da50be6f0dd831b495be1dd7e38ca4657f0ecab2c1a89dc7bccba0885eab7ee7c1b215830da765758c7e0506
+  languageName: node
+  linkType: hard
+
 "mute-stream@npm:1.0.0":
   version: 1.0.0
   resolution: "mute-stream@npm:1.0.0"
@@ -15603,7 +16581,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"nanoid@npm:^3.1.30, nanoid@npm:^3.2.0, nanoid@npm:^3.3.2, nanoid@npm:^3.3.4":
+"nanoid@npm:^3.2.0, nanoid@npm:^3.3.2":
   version: 3.3.4
   resolution: "nanoid@npm:3.3.4"
   bin:
@@ -15612,6 +16590,24 @@ __metadata:
   languageName: node
   linkType: hard
 
+"nanoid@npm:^3.3.3":
+  version: 3.3.6
+  resolution: "nanoid@npm:3.3.6"
+  bin:
+    nanoid: bin/nanoid.cjs
+  checksum: 7d0eda657002738aa5206107bd0580aead6c95c460ef1bdd0b1a87a9c7ae6277ac2e9b945306aaa5b32c6dcb7feaf462d0f552e7f8b5718abfc6ead5c94a71b3
+  languageName: node
+  linkType: hard
+
+"nanoid@npm:^3.3.4, nanoid@npm:^3.3.6":
+  version: 3.3.7
+  resolution: "nanoid@npm:3.3.7"
+  bin:
+    nanoid: bin/nanoid.cjs
+  checksum: d36c427e530713e4ac6567d488b489a36582ef89da1d6d4e3b87eded11eb10d7042a877958c6f104929809b2ab0bafa17652b076cdf84324aa75b30b722204f2
+  languageName: node
+  linkType: hard
+
 "nanospinner@npm:^1.0.0":
   version: 1.1.0
   resolution: "nanospinner@npm:1.1.0"
@@ -15666,25 +16662,28 @@ __metadata:
   linkType: hard
 
 "next@npm:^12.1.0":
-  version: 12.1.6
-  resolution: "next@npm:12.1.6"
-  dependencies:
-    "@next/env": 12.1.6
-    "@next/swc-android-arm-eabi": 12.1.6
-    "@next/swc-android-arm64": 12.1.6
-    "@next/swc-darwin-arm64": 12.1.6
-    "@next/swc-darwin-x64": 12.1.6
-    "@next/swc-linux-arm-gnueabihf": 12.1.6
-    "@next/swc-linux-arm64-gnu": 12.1.6
-    "@next/swc-linux-arm64-musl": 12.1.6
-    "@next/swc-linux-x64-gnu": 12.1.6
-    "@next/swc-linux-x64-musl": 12.1.6
-    "@next/swc-win32-arm64-msvc": 12.1.6
-    "@next/swc-win32-ia32-msvc": 12.1.6
-    "@next/swc-win32-x64-msvc": 12.1.6
-    caniuse-lite: ^1.0.30001332
-    postcss: 8.4.5
-    styled-jsx: 5.0.2
+  version: 12.3.4
+  resolution: "next@npm:12.3.4"
+  dependencies:
+    "@next/env": 12.3.4
+    "@next/swc-android-arm-eabi": 12.3.4
+    "@next/swc-android-arm64": 12.3.4
+    "@next/swc-darwin-arm64": 12.3.4
+    "@next/swc-darwin-x64": 12.3.4
+    "@next/swc-freebsd-x64": 12.3.4
+    "@next/swc-linux-arm-gnueabihf": 12.3.4
+    "@next/swc-linux-arm64-gnu": 12.3.4
+    "@next/swc-linux-arm64-musl": 12.3.4
+    "@next/swc-linux-x64-gnu": 12.3.4
+    "@next/swc-linux-x64-musl": 12.3.4
+    "@next/swc-win32-arm64-msvc": 12.3.4
+    "@next/swc-win32-ia32-msvc": 12.3.4
+    "@next/swc-win32-x64-msvc": 12.3.4
+    "@swc/helpers": 0.4.11
+    caniuse-lite: ^1.0.30001406
+    postcss: 8.4.14
+    styled-jsx: 5.0.7
+    use-sync-external-store: 1.2.0
   peerDependencies:
     fibers: ">= 3.1.0"
     node-sass: ^6.0.0 || ^7.0.0
@@ -15700,6 +16699,8 @@ __metadata:
       optional: true
     "@next/swc-darwin-x64":
       optional: true
+    "@next/swc-freebsd-x64":
+      optional: true
     "@next/swc-linux-arm-gnueabihf":
       optional: true
     "@next/swc-linux-arm64-gnu":
@@ -15725,7 +16726,7 @@ __metadata:
       optional: true
   bin:
     next: dist/bin/next
-  checksum: 670d544fd47670c29681d10824e6da625e9d4a048e564c8d9cb80d37f33c9ff9b5ca0a53e6d84d8d618b1fe7c9bb4e6b45040cb7e57a5c46b232a8f914425dc1
+  checksum: d96fc4f5bcd5a630d74111519f4820dcbd75dddf16c6d00d2167bd3cb8d74965d46d83c8e5ec301bf999013c7d96f1bfff9424f0221317d68b594c4d01f5825e
   languageName: node
   linkType: hard
 
@@ -15989,6 +16990,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"object-inspect@npm:^1.13.1":
+  version: 1.13.1
+  resolution: "object-inspect@npm:1.13.1"
+  checksum: 7d9fa9221de3311dcb5c7c307ee5dc011cdd31dc43624b7c184b3840514e118e05ef0002be5388304c416c0eb592feb46e983db12577fc47e47d5752fbbfb61f
+  languageName: node
+  linkType: hard
+
 "object-keys@npm:^1.1.1":
   version: 1.1.1
   resolution: "object-keys@npm:1.1.1"
@@ -16020,46 +17028,47 @@ __metadata:
   languageName: node
   linkType: hard
 
-"object.entries@npm:^1.1.5":
-  version: 1.1.5
-  resolution: "object.entries@npm:1.1.5"
+"object.entries@npm:^1.1.6, object.entries@npm:^1.1.7":
+  version: 1.1.7
+  resolution: "object.entries@npm:1.1.7"
   dependencies:
     call-bind: ^1.0.2
-    define-properties: ^1.1.3
-    es-abstract: ^1.19.1
-  checksum: d658696f74fd222060d8428d2a9fda2ce736b700cb06f6bdf4a16a1892d145afb746f453502b2fa55d1dca8ead6f14ddbcf66c545df45adadea757a6c4cd86c7
+    define-properties: ^1.2.0
+    es-abstract: ^1.22.1
+  checksum: da287d434e7e32989586cd734382364ba826a2527f2bc82e6acbf9f9bfafa35d51018b66ec02543ffdfa2a5ba4af2b6f1ca6e588c65030cb4fd9c67d6ced594c
   languageName: node
   linkType: hard
 
-"object.fromentries@npm:^2.0.5":
-  version: 2.0.5
-  resolution: "object.fromentries@npm:2.0.5"
+"object.fromentries@npm:^2.0.6, object.fromentries@npm:^2.0.7":
+  version: 2.0.7
+  resolution: "object.fromentries@npm:2.0.7"
   dependencies:
     call-bind: ^1.0.2
-    define-properties: ^1.1.3
-    es-abstract: ^1.19.1
-  checksum: 61a0b565ded97b76df9e30b569729866e1824cce902f98e90bb106e84f378aea20163366f66dc75c9000e2aad2ed0caf65c6f530cb2abc4c0c0f6c982102db4b
+    define-properties: ^1.2.0
+    es-abstract: ^1.22.1
+  checksum: 7341ce246e248b39a431b87a9ddd331ff52a454deb79afebc95609f94b1f8238966cf21f52188f2a353f0fdf83294f32f1ebf1f7826aae915ebad21fd0678065
   languageName: node
   linkType: hard
 
-"object.hasown@npm:^1.1.1":
-  version: 1.1.1
-  resolution: "object.hasown@npm:1.1.1"
+"object.groupby@npm:^1.0.1":
+  version: 1.0.1
+  resolution: "object.groupby@npm:1.0.1"
   dependencies:
-    define-properties: ^1.1.4
-    es-abstract: ^1.19.5
-  checksum: d8ed4907ce57f48b93e3b53c418fd6787bf226a51e8d698c91e39b78e80fe5b124cb6282f6a9d5be21cf9e2c7829ab10206dcc6112b7748860eefe641880c793
+    call-bind: ^1.0.2
+    define-properties: ^1.2.0
+    es-abstract: ^1.22.1
+    get-intrinsic: ^1.2.1
+  checksum: d7959d6eaaba358b1608066fc67ac97f23ce6f573dc8fc661f68c52be165266fcb02937076aedb0e42722fdda0bdc0bbf74778196ac04868178888e9fd3b78b5
   languageName: node
   linkType: hard
 
-"object.values@npm:^1.1.5":
-  version: 1.1.5
-  resolution: "object.values@npm:1.1.5"
+"object.hasown@npm:^1.1.2":
+  version: 1.1.3
+  resolution: "object.hasown@npm:1.1.3"
   dependencies:
-    call-bind: ^1.0.2
-    define-properties: ^1.1.3
-    es-abstract: ^1.19.1
-  checksum: 0f17e99741ebfbd0fa55ce942f6184743d3070c61bd39221afc929c8422c4907618c8da694c6915bc04a83ab3224260c779ba37fc07bb668bdc5f33b66a902a4
+    define-properties: ^1.2.0
+    es-abstract: ^1.22.1
+  checksum: 76bc17356f6124542fb47e5d0e78d531eafa4bba3fc2d6fc4b1a8ce8b6878912366c0d99f37ce5c84ada8fd79df7aa6ea1214fddf721f43e093ad2df51f27da1
   languageName: node
   linkType: hard
 
@@ -16074,6 +17083,17 @@ __metadata:
   languageName: node
   linkType: hard
 
+"object.values@npm:^1.1.7":
+  version: 1.1.7
+  resolution: "object.values@npm:1.1.7"
+  dependencies:
+    call-bind: ^1.0.2
+    define-properties: ^1.2.0
+    es-abstract: ^1.22.1
+  checksum: f3e4ae4f21eb1cc7cebb6ce036d4c67b36e1c750428d7b7623c56a0db90edced63d08af8a316d81dfb7c41a3a5fa81b05b7cc9426e98d7da986b1682460f0777
+  languageName: node
+  linkType: hard
+
 "obuf@npm:^1.0.0, obuf@npm:^1.1.2":
   version: 1.1.2
   resolution: "obuf@npm:1.1.2"
@@ -16276,15 +17296,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"p-limit@npm:^1.1.0":
-  version: 1.3.0
-  resolution: "p-limit@npm:1.3.0"
-  dependencies:
-    p-try: ^1.0.0
-  checksum: 281c1c0b8c82e1ac9f81acd72a2e35d402bf572e09721ce5520164e9de07d8274451378a3470707179ad13240535558f4b277f02405ad752e08c7d5b0d54fbfd
-  languageName: node
-  linkType: hard
-
 "p-limit@npm:^2.2.0":
   version: 2.3.0
   resolution: "p-limit@npm:2.3.0"
@@ -16312,15 +17323,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"p-locate@npm:^2.0.0":
-  version: 2.0.0
-  resolution: "p-locate@npm:2.0.0"
-  dependencies:
-    p-limit: ^1.1.0
-  checksum: e2dceb9b49b96d5513d90f715780f6f4972f46987dc32a0e18bc6c3fc74a1a5d73ec5f81b1398af5e58b99ea1ad03fd41e9181c01fa81b4af2833958696e3081
-  languageName: node
-  linkType: hard
-
 "p-locate@npm:^4.1.0":
   version: 4.1.0
   resolution: "p-locate@npm:4.1.0"
@@ -16374,13 +17376,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"p-try@npm:^1.0.0":
-  version: 1.0.0
-  resolution: "p-try@npm:1.0.0"
-  checksum: 3b5303f77eb7722144154288bfd96f799f8ff3e2b2b39330efe38db5dd359e4fb27012464cd85cb0a76e9b7edd1b443568cb3192c22e7cffc34989df0bafd605
-  languageName: node
-  linkType: hard
-
 "p-try@npm:^2.0.0":
   version: 2.2.0
   resolution: "p-try@npm:2.2.0"
@@ -16531,13 +17526,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"path-exists@npm:^3.0.0":
-  version: 3.0.0
-  resolution: "path-exists@npm:3.0.0"
-  checksum: 96e92643aa34b4b28d0de1cd2eba52a1c5313a90c6542d03f62750d82480e20bfa62bc865d5cfc6165f5fcd5aeb0851043c40a39be5989646f223300021bae0a
-  languageName: node
-  linkType: hard
-
 "path-exists@npm:^4.0.0":
   version: 4.0.0
   resolution: "path-exists@npm:4.0.0"
@@ -16611,6 +17599,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"path-to-regexp@npm:^6.2.0":
+  version: 6.2.1
+  resolution: "path-to-regexp@npm:6.2.1"
+  checksum: f0227af8284ea13300f4293ba111e3635142f976d4197f14d5ad1f124aebd9118783dd2e5f1fe16f7273743cc3dbeddfb7493f237bb27c10fdae07020cc9b698
+  languageName: node
+  linkType: hard
+
 "path-type@npm:^1.0.0":
   version: 1.1.0
   resolution: "path-type@npm:1.1.0"
@@ -16759,25 +17754,25 @@ __metadata:
   languageName: node
   linkType: hard
 
-"postcss@npm:8.4.5":
-  version: 8.4.5
-  resolution: "postcss@npm:8.4.5"
+"postcss@npm:8.4.14":
+  version: 8.4.14
+  resolution: "postcss@npm:8.4.14"
   dependencies:
-    nanoid: ^3.1.30
+    nanoid: ^3.3.4
     picocolors: ^1.0.0
-    source-map-js: ^1.0.1
-  checksum: b78abdd89c10f7b48f4bdcd376104a19d6e9c7495ab521729bdb3df315af6c211360e9f06887ad3bc0ab0f61a04b91d68ea11462997c79cced58b9ccd66fac07
+    source-map-js: ^1.0.2
+  checksum: fe58766ff32e4becf65a7d57678995cfd239df6deed2fe0557f038b47c94e4132e7e5f68b5aa820c13adfec32e523b693efaeb65798efb995ce49ccd83953816
   languageName: node
   linkType: hard
 
 "postcss@npm:^8.4.13":
-  version: 8.4.14
-  resolution: "postcss@npm:8.4.14"
+  version: 8.4.31
+  resolution: "postcss@npm:8.4.31"
   dependencies:
-    nanoid: ^3.3.4
+    nanoid: ^3.3.6
     picocolors: ^1.0.0
     source-map-js: ^1.0.2
-  checksum: fe58766ff32e4becf65a7d57678995cfd239df6deed2fe0557f038b47c94e4132e7e5f68b5aa820c13adfec32e523b693efaeb65798efb995ce49ccd83953816
+  checksum: 1d8611341b073143ad90486fcdfeab49edd243377b1f51834dc4f6d028e82ce5190e4f11bb2633276864503654fb7cab28e67abdc0fbf9d1f88cad4a0ff0beea
   languageName: node
   linkType: hard
 
@@ -16848,17 +17843,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"pretty-format@npm:^27.0.0, pretty-format@npm:^27.5.1":
-  version: 27.5.1
-  resolution: "pretty-format@npm:27.5.1"
-  dependencies:
-    ansi-regex: ^5.0.1
-    ansi-styles: ^5.0.0
-    react-is: ^17.0.1
-  checksum: cf610cffcb793885d16f184a62162f2dd0df31642d9a18edf4ca298e909a8fe80bdbf556d5c9573992c102ce8bf948691da91bf9739bee0ffb6e79c8a8a6e088
-  languageName: node
-  linkType: hard
-
 "pretty-format@npm:^28.1.1":
   version: 28.1.1
   resolution: "pretty-format@npm:28.1.1"
@@ -16871,6 +17855,17 @@ __metadata:
   languageName: node
   linkType: hard
 
+"pretty-format@npm:^29.0.0, pretty-format@npm:^29.7.0":
+  version: 29.7.0
+  resolution: "pretty-format@npm:29.7.0"
+  dependencies:
+    "@jest/schemas": ^29.6.3
+    ansi-styles: ^5.0.0
+    react-is: ^18.0.0
+  checksum: 032c1602383e71e9c0c02a01bbd25d6759d60e9c7cf21937dde8357aa753da348fcec5def5d1002c9678a8524d5fe099ad98861286550ef44de8808cc61e43b6
+  languageName: node
+  linkType: hard
+
 "pretty-format@npm:^29.5.0":
   version: 29.5.0
   resolution: "pretty-format@npm:29.5.0"
@@ -16891,10 +17886,17 @@ __metadata:
   languageName: node
   linkType: hard
 
+"printable-characters@npm:^1.0.42":
+  version: 1.0.42
+  resolution: "printable-characters@npm:1.0.42"
+  checksum: 2724aa02919d7085933af0f8f904bd0de67a6b53834f2e5b98fc7aa3650e20755c805e8c85bcf96c09f678cb16a58b55640dd3a2da843195fce06b1ccb0c8ce4
+  languageName: node
+  linkType: hard
+
 "prismjs@npm:^1.27.0":
-  version: 1.28.0
-  resolution: "prismjs@npm:1.28.0"
-  checksum: bde93fb2beb45b7243219fc53855f59ee54b3fa179f315e8f9d66244d756ef984462e10561bbdc6713d3d7e051852472d7c284f5794a8791eeaefea2fb910b16
+  version: 1.29.0
+  resolution: "prismjs@npm:1.29.0"
+  checksum: 007a8869d4456ff8049dc59404e32d5666a07d99c3b0e30a18bd3b7676dfa07d1daae9d0f407f20983865fd8da56de91d09cb08e6aa61f5bc420a27c0beeaf93
   languageName: node
   linkType: hard
 
@@ -17126,6 +18128,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"pure-rand@npm:^6.0.0":
+  version: 6.0.4
+  resolution: "pure-rand@npm:6.0.4"
+  checksum: e1c4e69f8bf7303e5252756d67c3c7551385cd34d94a1f511fe099727ccbab74c898c03a06d4c4a24a89b51858781057b83ebbfe740d984240cdc04fead36068
+  languageName: node
+  linkType: hard
+
 "qs@npm:6.11.0, qs@npm:^6.4.0":
   version: 6.11.0
   resolution: "qs@npm:6.11.0"
@@ -17227,48 +18236,63 @@ __metadata:
   languageName: node
   linkType: hard
 
-"rc-resize-observer@npm:^1.1.0":
-  version: 1.2.0
-  resolution: "rc-resize-observer@npm:1.2.0"
+"rc-resize-observer@npm:^1.0.0, rc-resize-observer@npm:^1.1.0":
+  version: 1.4.0
+  resolution: "rc-resize-observer@npm:1.4.0"
   dependencies:
-    "@babel/runtime": ^7.10.1
+    "@babel/runtime": ^7.20.7
     classnames: ^2.2.1
-    rc-util: ^5.15.0
+    rc-util: ^5.38.0
     resize-observer-polyfill: ^1.5.1
   peerDependencies:
     react: ">=16.9.0"
     react-dom: ">=16.9.0"
-  checksum: cb338ee405c6df3d072754ad2fc29c19fe90cc9264331c02b7e23cb85d75f8ad984352fa8e0ff48f339439f548613b8960992e3050754290f2e651ed71909489
+  checksum: e6ee24fd887ea440b07e0326c3fc60b240274fa43ea87cf8f86ca9e0741a2c817e47a182f336b00d7246b4fd21b3536f4d3aacd7f0db5ae673f106630cd348ba
   languageName: node
   linkType: hard
 
 "rc-table@npm:^7.10.0":
-  version: 7.24.2
-  resolution: "rc-table@npm:7.24.2"
+  version: 7.36.0
+  resolution: "rc-table@npm:7.36.0"
   dependencies:
     "@babel/runtime": ^7.10.1
+    "@rc-component/context": ^1.4.0
     classnames: ^2.2.5
     rc-resize-observer: ^1.1.0
-    rc-util: ^5.14.0
-    shallowequal: ^1.1.0
+    rc-util: ^5.37.0
+    rc-virtual-list: ^3.11.1
   peerDependencies:
     react: ">=16.9.0"
     react-dom: ">=16.9.0"
-  checksum: a9402da364442f8a84bf13e5638a1cee6fce79df6cc301beec8e234a64a127eab4ec7d055e11e0274205c5014893deacbfdc9288594c578179c3e46ffc1b8478
+  checksum: 4db1fbd348bd2ebde767f87e047abd07c60a2ddd054f74bf3193a6bf789714512c5aca36e946ee7491d08b202b625a652c7ac9f48d213f034816a6ad6d8dcffe
   languageName: node
   linkType: hard
 
-"rc-util@npm:^5.14.0, rc-util@npm:^5.15.0":
-  version: 5.21.5
-  resolution: "rc-util@npm:5.21.5"
+"rc-util@npm:^5.27.0, rc-util@npm:^5.36.0, rc-util@npm:^5.37.0, rc-util@npm:^5.38.0":
+  version: 5.38.1
+  resolution: "rc-util@npm:5.38.1"
   dependencies:
     "@babel/runtime": ^7.18.3
-    react-is: ^16.12.0
-    shallowequal: ^1.1.0
+    react-is: ^18.2.0
   peerDependencies:
     react: ">=16.9.0"
     react-dom: ">=16.9.0"
-  checksum: 39434002f228381b6a6dbb135a1c099b2e5c5f63aba4616cee1ed9b1436cdc8011d78055b06cc169d44b1a79c42d40ecd3d2f6123a61043510aa2a9de48e0e72
+  checksum: 40d0411fb5d6b0a187e718ff16c18f3d68eae3d7e4def43a9a9b2690b89cfce639077a69d683aa01302f8132394dd633baf76b07e5a3b8438fb706b1abb31937
+  languageName: node
+  linkType: hard
+
+"rc-virtual-list@npm:^3.11.1":
+  version: 3.11.3
+  resolution: "rc-virtual-list@npm:3.11.3"
+  dependencies:
+    "@babel/runtime": ^7.20.0
+    classnames: ^2.2.6
+    rc-resize-observer: ^1.0.0
+    rc-util: ^5.36.0
+  peerDependencies:
+    react: "*"
+    react-dom: "*"
+  checksum: 488661f158de37ace5ed0d7543fe4ed19e0145cc59f3b842f9c1ff5dfda687240620ba59bb44ec9425c5703c8ac9683449b3012722ca7da5e0a585ce2104629b
   languageName: node
   linkType: hard
 
@@ -17325,20 +18349,13 @@ __metadata:
   languageName: node
   linkType: hard
 
-"react-is@npm:^16.12.0, react-is@npm:^16.13.1":
+"react-is@npm:^16.13.1":
   version: 16.13.1
   resolution: "react-is@npm:16.13.1"
   checksum: f7a19ac3496de32ca9ae12aa030f00f14a3d45374f1ceca0af707c831b2a6098ef0d6bdae51bd437b0a306d7f01d4677fcc8de7c0d331eb47ad0f46130e53c5f
   languageName: node
   linkType: hard
 
-"react-is@npm:^17.0.1":
-  version: 17.0.1
-  resolution: "react-is@npm:17.0.1"
-  checksum: 5e6945a286367894d11b24f41a0065607ba62bdac0df0b567294b2e299c037e3641434e66f9be30536b8c47f7ad94d44e633feb2ba25959c2c42423844e6c2f1
-  languageName: node
-  linkType: hard
-
 "react-is@npm:^18.0.0":
   version: 18.1.0
   resolution: "react-is@npm:18.1.0"
@@ -17346,6 +18363,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"react-is@npm:^18.2.0":
+  version: 18.2.0
+  resolution: "react-is@npm:18.2.0"
+  checksum: e72d0ba81b5922759e4aff17e0252bd29988f9642ed817f56b25a3e217e13eea8a7f2322af99a06edb779da12d5d636e9fda473d620df9a3da0df2a74141d53e
+  languageName: node
+  linkType: hard
+
 "react-json-tree@npm:^0.13.0":
   version: 0.13.0
   resolution: "react-json-tree@npm:0.13.0"
@@ -17368,12 +18392,12 @@ __metadata:
   linkType: hard
 
 "react-simple-code-editor@npm:^0.11.0":
-  version: 0.11.2
-  resolution: "react-simple-code-editor@npm:0.11.2"
+  version: 0.11.3
+  resolution: "react-simple-code-editor@npm:0.11.3"
   peerDependencies:
     react: "*"
     react-dom: "*"
-  checksum: 219e1d8972678a99f01097f6545a7d050825c8803a6e6b4d5257cf4ff123af95c9a409e8beba28d091507f92cb903ec1eccfc2b861fb4a11cfbeb0dcf48439be
+  checksum: 15a03bfd7fef4efd73f0c25136e2eb253990ec9878199083853d8bcfad2246ac9750edfb64183c9dc33ad27d38f5e0e12052005c2ef7c80cbb6b1d4a9a4da511
   languageName: node
   linkType: hard
 
@@ -17591,6 +18615,20 @@ __metadata:
   languageName: node
   linkType: hard
 
+"reflect.getprototypeof@npm:^1.0.4":
+  version: 1.0.4
+  resolution: "reflect.getprototypeof@npm:1.0.4"
+  dependencies:
+    call-bind: ^1.0.2
+    define-properties: ^1.2.0
+    es-abstract: ^1.22.1
+    get-intrinsic: ^1.2.1
+    globalthis: ^1.0.3
+    which-builtin-type: ^1.1.3
+  checksum: 16e2361988dbdd23274b53fb2b1b9cefeab876c3941a2543b4cadac6f989e3db3957b07a44aac46cfceb3e06e2871785ec2aac992d824f76292f3b5ee87f66f2
+  languageName: node
+  linkType: hard
+
 "regenerate-unicode-properties@npm:^10.1.0":
   version: 10.1.0
   resolution: "regenerate-unicode-properties@npm:10.1.0"
@@ -17630,7 +18668,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"regexp.prototype.flags@npm:^1.4.1, regexp.prototype.flags@npm:^1.4.3":
+"regexp.prototype.flags@npm:^1.4.3":
   version: 1.4.3
   resolution: "regexp.prototype.flags@npm:1.4.3"
   dependencies:
@@ -17641,6 +18679,17 @@ __metadata:
   languageName: node
   linkType: hard
 
+"regexp.prototype.flags@npm:^1.5.0, regexp.prototype.flags@npm:^1.5.1":
+  version: 1.5.1
+  resolution: "regexp.prototype.flags@npm:1.5.1"
+  dependencies:
+    call-bind: ^1.0.2
+    define-properties: ^1.2.0
+    set-function-name: ^2.0.0
+  checksum: 869edff00288442f8d7fa4c9327f91d85f3b3acf8cbbef9ea7a220345cf23e9241b6def9263d2c1ebcf3a316b0aa52ad26a43a84aa02baca3381717b3e307f47
+  languageName: node
+  linkType: hard
+
 "regexpp@npm:^3.2.0":
   version: 3.2.0
   resolution: "regexpp@npm:3.2.0"
@@ -17789,14 +18838,14 @@ __metadata:
   languageName: node
   linkType: hard
 
-"resolve.exports@npm:^1.1.0":
-  version: 1.1.0
-  resolution: "resolve.exports@npm:1.1.0"
-  checksum: 52865af8edb088f6c7759a328584a5de6b226754f004b742523adcfe398cfbc4559515104bc2ae87b8e78b1e4de46c9baec400b3fb1f7d517b86d2d48a098a2d
+"resolve.exports@npm:^2.0.0":
+  version: 2.0.2
+  resolution: "resolve.exports@npm:2.0.2"
+  checksum: 1c7778ca1b86a94f8ab4055d196c7d87d1874b96df4d7c3e67bbf793140f0717fd506dcafd62785b079cd6086b9264424ad634fb904409764c3509c3df1653f2
   languageName: node
   linkType: hard
 
-"resolve@npm:^1.1.7, resolve@npm:^1.22.0":
+"resolve@npm:^1.1.7":
   version: 1.22.0
   resolution: "resolve@npm:1.22.0"
   dependencies:
@@ -17832,6 +18881,19 @@ __metadata:
   languageName: node
   linkType: hard
 
+"resolve@npm:^1.22.0, resolve@npm:^1.22.4":
+  version: 1.22.8
+  resolution: "resolve@npm:1.22.8"
+  dependencies:
+    is-core-module: ^2.13.0
+    path-parse: ^1.0.7
+    supports-preserve-symlinks-flag: ^1.0.0
+  bin:
+    resolve: bin/resolve
+  checksum: f8a26958aa572c9b064562750b52131a37c29d072478ea32e129063e2da7f83e31f7f11e7087a18225a8561cfe8d2f0df9dbea7c9d331a897571c0a2527dbb4c
+  languageName: node
+  linkType: hard
+
 "resolve@npm:^1.22.1":
   version: 1.22.3
   resolution: "resolve@npm:1.22.3"
@@ -17845,17 +18907,20 @@ __metadata:
   languageName: node
   linkType: hard
 
-"resolve@npm:^2.0.0-next.3":
-  version: 2.0.0-next.3
-  resolution: "resolve@npm:2.0.0-next.3"
+"resolve@npm:^2.0.0-next.4":
+  version: 2.0.0-next.5
+  resolution: "resolve@npm:2.0.0-next.5"
   dependencies:
-    is-core-module: ^2.2.0
-    path-parse: ^1.0.6
-  checksum: f34b3b93ada77d64a6d590c06a83e198f3a827624c4ec972260905fa6c4d612164fbf0200d16d2beefea4ad1755b001f4a9a1293d8fc2322a8f7d6bf692c4ff5
+    is-core-module: ^2.13.0
+    path-parse: ^1.0.7
+    supports-preserve-symlinks-flag: ^1.0.0
+  bin:
+    resolve: bin/resolve
+  checksum: a73ac69a1c4bd34c56b213d91f5b17ce390688fdb4a1a96ed3025cc7e08e7bfb90b3a06fcce461780cb0b589c958afcb0080ab802c71c01a7ecc8c64feafc89f
   languageName: node
   linkType: hard
 
-"resolve@patch:resolve@^1.1.7#~builtin, resolve@patch:resolve@^1.22.0#~builtin":
+"resolve@patch:resolve@^1.1.7#~builtin":
   version: 1.22.0
   resolution: "resolve@patch:resolve@npm%3A1.22.0#~builtin::version=1.22.0&hash=c3c19d"
   dependencies:
@@ -17891,6 +18956,19 @@ __metadata:
   languageName: node
   linkType: hard
 
+"resolve@patch:resolve@^1.22.0#~builtin, resolve@patch:resolve@^1.22.4#~builtin":
+  version: 1.22.8
+  resolution: "resolve@patch:resolve@npm%3A1.22.8#~builtin::version=1.22.8&hash=c3c19d"
+  dependencies:
+    is-core-module: ^2.13.0
+    path-parse: ^1.0.7
+    supports-preserve-symlinks-flag: ^1.0.0
+  bin:
+    resolve: bin/resolve
+  checksum: 5479b7d431cacd5185f8db64bfcb7286ae5e31eb299f4c4f404ad8aa6098b77599563ac4257cb2c37a42f59dfc06a1bec2bcf283bb448f319e37f0feb9a09847
+  languageName: node
+  linkType: hard
+
 "resolve@patch:resolve@^1.22.1#~builtin":
   version: 1.22.3
   resolution: "resolve@patch:resolve@npm%3A1.22.3#~builtin::version=1.22.3&hash=c3c19d"
@@ -17904,13 +18982,16 @@ __metadata:
   languageName: node
   linkType: hard
 
-"resolve@patch:resolve@^2.0.0-next.3#~builtin":
-  version: 2.0.0-next.3
-  resolution: "resolve@patch:resolve@npm%3A2.0.0-next.3#~builtin::version=2.0.0-next.3&hash=c3c19d"
+"resolve@patch:resolve@^2.0.0-next.4#~builtin":
+  version: 2.0.0-next.5
+  resolution: "resolve@patch:resolve@npm%3A2.0.0-next.5#~builtin::version=2.0.0-next.5&hash=c3c19d"
   dependencies:
-    is-core-module: ^2.2.0
-    path-parse: ^1.0.6
-  checksum: 21684b4d99a4877337cdbd5484311c811b3e8910edb5d868eec85c6e6550b0f570d911f9a384f9e176172d6713f2715bd0b0887fa512cb8c6aeece018de6a9f8
+    is-core-module: ^2.13.0
+    path-parse: ^1.0.7
+    supports-preserve-symlinks-flag: ^1.0.0
+  bin:
+    resolve: bin/resolve
+  checksum: 064d09c1808d0c51b3d90b5d27e198e6d0c5dad0eb57065fd40803d6a20553e5398b07f76739d69cbabc12547058bec6b32106ea66622375fb0d7e8fca6a846c
   languageName: node
   linkType: hard
 
@@ -18004,7 +19085,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"rimraf@npm:3.0.2, rimraf@npm:^3.0.0, rimraf@npm:^3.0.2":
+"rimraf@npm:3.0.2, rimraf@npm:^3.0.2":
   version: 3.0.2
   resolution: "rimraf@npm:3.0.2"
   dependencies:
@@ -18029,6 +19110,35 @@ __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: ^0.6.1
+    magic-string: ^0.25.3
+    rollup-pluginutils: ^2.8.1
+  checksum: a014972c80fe34b8c8154056fa2533a8440066a31de831e3793fc21b15d108d92c22d8f7f472397bd5783d7c5e04d8cbf112fb72c5a26e997726e4eb090edad1
+  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: ^3.0.0
+  checksum: e84645212c443aca3cfae2ba69f01c6d8c5c250f0bf651416b69a4572b60aae9da7cdd687de3ab9b903f7a1ab96b06b71f0c4927d1b02a37485360d2b563937b
+  languageName: node
+  linkType: hard
+
+"rollup-pluginutils@npm:^2.8.1":
+  version: 2.8.2
+  resolution: "rollup-pluginutils@npm:2.8.2"
+  dependencies:
+    estree-walker: ^0.6.1
+  checksum: 339fdf866d8f4ff6e408fa274c0525412f7edb01dc46b5ccda51f575b7e0d20ad72965773376fb5db95a77a7fcfcab97bf841ec08dbadf5d6b08af02b7a2cf5e
+  languageName: node
+  linkType: hard
+
 "rollup@npm:>=2.59.0 <2.78.0":
   version: 2.77.3
   resolution: "rollup@npm:2.77.3"
@@ -18093,6 +19203,18 @@ __metadata:
   languageName: node
   linkType: hard
 
+"safe-array-concat@npm:^1.0.1":
+  version: 1.0.1
+  resolution: "safe-array-concat@npm:1.0.1"
+  dependencies:
+    call-bind: ^1.0.2
+    get-intrinsic: ^1.2.1
+    has-symbols: ^1.0.3
+    isarray: ^2.0.5
+  checksum: 001ecf1d8af398251cbfabaf30ed66e3855127fbceee178179524b24160b49d15442f94ed6c0db0b2e796da76bb05b73bf3cc241490ec9c2b741b41d33058581
+  languageName: node
+  linkType: hard
+
 "safe-buffer@npm:5.1.2, safe-buffer@npm:~5.1.0, safe-buffer@npm:~5.1.1":
   version: 5.1.2
   resolution: "safe-buffer@npm:5.1.2"
@@ -18260,7 +19382,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"selfsigned@npm:^2.1.1":
+"selfsigned@npm:^2.0.1, selfsigned@npm:^2.1.1":
   version: 2.1.1
   resolution: "selfsigned@npm:2.1.1"
   dependencies:
@@ -18294,18 +19416,7 @@ __metadata:
     lru-cache: ^6.0.0
   bin:
     semver: bin/semver.js
-  checksum: 5eafe6102bea2a7439897c1856362e31cc348ccf96efd455c8b5bc2c61e6f7e7b8250dc26b8828c1d76a56f818a7ee907a36ae9fb37a599d3d24609207001d60
-  languageName: node
-  linkType: hard
-
-"semver@npm:7.x, semver@npm:^7.3.2, semver@npm:^7.3.4, semver@npm:^7.3.5, semver@npm:^7.3.7":
-  version: 7.5.4
-  resolution: "semver@npm:7.5.4"
-  dependencies:
-    lru-cache: ^6.0.0
-  bin:
-    semver: bin/semver.js
-  checksum: 12d8ad952fa353b0995bf180cdac205a4068b759a140e5d3c608317098b3575ac2f1e09182206bf2eb26120e1c0ed8fb92c48c592f6099680de56bb071423ca3
+  checksum: 5eafe6102bea2a7439897c1856362e31cc348ccf96efd455c8b5bc2c61e6f7e7b8250dc26b8828c1d76a56f818a7ee907a36ae9fb37a599d3d24609207001d60
   languageName: node
   linkType: hard
 
@@ -18327,6 +19438,17 @@ __metadata:
   languageName: node
   linkType: hard
 
+"semver@npm:^7.3.2, semver@npm:^7.3.4, semver@npm:^7.3.5, semver@npm:^7.3.7, semver@npm:^7.5.3, semver@npm:^7.5.4":
+  version: 7.5.4
+  resolution: "semver@npm:7.5.4"
+  dependencies:
+    lru-cache: ^6.0.0
+  bin:
+    semver: bin/semver.js
+  checksum: 12d8ad952fa353b0995bf180cdac205a4068b759a140e5d3c608317098b3575ac2f1e09182206bf2eb26120e1c0ed8fb92c48c592f6099680de56bb071423ca3
+  languageName: node
+  linkType: hard
+
 "send@npm:0.18.0":
   version: 0.18.0
   resolution: "send@npm:0.18.0"
@@ -18434,6 +19556,29 @@ __metadata:
   languageName: node
   linkType: hard
 
+"set-function-length@npm:^1.1.1":
+  version: 1.1.1
+  resolution: "set-function-length@npm:1.1.1"
+  dependencies:
+    define-data-property: ^1.1.1
+    get-intrinsic: ^1.2.1
+    gopd: ^1.0.1
+    has-property-descriptors: ^1.0.0
+  checksum: c131d7569cd7e110cafdfbfbb0557249b538477624dfac4fc18c376d879672fa52563b74029ca01f8f4583a8acb35bb1e873d573a24edb80d978a7ee607c6e06
+  languageName: node
+  linkType: hard
+
+"set-function-name@npm:^2.0.0, set-function-name@npm:^2.0.1":
+  version: 2.0.1
+  resolution: "set-function-name@npm:2.0.1"
+  dependencies:
+    define-data-property: ^1.0.1
+    functions-have-names: ^1.2.3
+    has-property-descriptors: ^1.0.0
+  checksum: 4975d17d90c40168eee2c7c9c59d023429f0a1690a89d75656306481ece0c3c1fb1ebcc0150ea546d1913e35fbd037bace91372c69e543e51fc5d1f31a9fa126
+  languageName: node
+  linkType: hard
+
 "setimmediate@npm:~1.0.4":
   version: 1.0.5
   resolution: "setimmediate@npm:1.0.5"
@@ -18464,13 +19609,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"shallowequal@npm:^1.1.0":
-  version: 1.1.0
-  resolution: "shallowequal@npm:1.1.0"
-  checksum: f4c1de0837f106d2dbbfd5d0720a5d059d1c66b42b580965c8f06bb1db684be8783538b684092648c981294bf817869f743a066538771dbecb293df78f765e00
-  languageName: node
-  linkType: hard
-
 "shebang-command@npm:^1.2.0":
   version: 1.2.0
   resolution: "shebang-command@npm:1.2.0"
@@ -18731,15 +19869,15 @@ __metadata:
   linkType: hard
 
 "source-map-loader@npm:^3.0.1":
-  version: 3.0.1
-  resolution: "source-map-loader@npm:3.0.1"
+  version: 3.0.2
+  resolution: "source-map-loader@npm:3.0.2"
   dependencies:
     abab: ^2.0.5
     iconv-lite: ^0.6.3
     source-map-js: ^1.0.1
   peerDependencies:
     webpack: ^5.0.0
-  checksum: 6ff27ba9335307e64edaab8fb8f87aa82a88d7efb12260732f7e3649c3fffe8bd3f77b6970c39c0bdd5e3a9b2a5ed8f11ac805bea90a6c99f186aa52033e53e0
+  checksum: d5a4e2ab190c93ae5cba68c247fbaa9fd560333c91060602b634c399a8a4b3205b8c07714c3bcdb0a11c6cc5476c06256bd8e824e71fbbb7981e8fad5cba4a00
   languageName: node
   linkType: hard
 
@@ -18753,7 +19891,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"source-map-support@npm:~0.5.20":
+"source-map-support@npm:0.5.21, source-map-support@npm:~0.5.20":
   version: 0.5.21
   resolution: "source-map-support@npm:0.5.21"
   dependencies:
@@ -18763,13 +19901,20 @@ __metadata:
   languageName: node
   linkType: hard
 
-"source-map@npm:^0.6.0, source-map@npm:^0.6.1, source-map@npm:~0.6.1":
+"source-map@npm:0.6.1, source-map@npm:^0.6.0, source-map@npm:^0.6.1, source-map@npm:~0.6.1":
   version: 0.6.1
   resolution: "source-map@npm:0.6.1"
   checksum: 59ce8640cf3f3124f64ac289012c2b8bd377c238e316fb323ea22fbfe83da07d81e000071d7242cad7a23cd91c7de98e4df8830ec3f133cb6133a5f6e9f67bc2
   languageName: node
   linkType: hard
 
+"sourcemap-codec@npm:^1.4.8":
+  version: 1.4.8
+  resolution: "sourcemap-codec@npm:1.4.8"
+  checksum: b57981c05611afef31605732b598ccf65124a9fcb03b833532659ac4d29ac0f7bfacbc0d6c5a28a03e84c7510e7e556d758d0bb57786e214660016fb94279316
+  languageName: node
+  linkType: hard
+
 "spark-md5@npm:^3.0.1":
   version: 3.0.1
   resolution: "spark-md5@npm:3.0.1"
@@ -18939,6 +20084,16 @@ __metadata:
   languageName: node
   linkType: hard
 
+"stacktracey@npm:^2.1.8":
+  version: 2.1.8
+  resolution: "stacktracey@npm:2.1.8"
+  dependencies:
+    as-table: ^1.0.36
+    get-source: ^2.0.12
+  checksum: abd8316b4e120884108f5a47b2f61abdcaeaa118afd95f3c48317cb057fff43d697450ba00de3f9fe7fee61ee72644ccda4db990a8e4553706644f7c17522eab
+  languageName: node
+  linkType: hard
+
 "statuses@npm:2.0.1":
   version: 2.0.1
   resolution: "statuses@npm:2.0.1"
@@ -18953,6 +20108,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"stoppable@npm:^1.1.0":
+  version: 1.1.0
+  resolution: "stoppable@npm:1.1.0"
+  checksum: 63104fcbdece130bc4906fd982061e763d2ef48065ed1ab29895e5ad00552c625f8a4c50c9cd2e3bfa805c8a2c3bfdda0f07c5ae39694bd2d5cb0bee1618d1e9
+  languageName: node
+  linkType: hard
+
 "stream-buffers@npm:^3.0.2":
   version: 3.0.2
   resolution: "stream-buffers@npm:3.0.2"
@@ -19029,19 +20191,20 @@ __metadata:
   languageName: node
   linkType: hard
 
-"string.prototype.matchall@npm:^4.0.7":
-  version: 4.0.7
-  resolution: "string.prototype.matchall@npm:4.0.7"
+"string.prototype.matchall@npm:^4.0.8":
+  version: 4.0.10
+  resolution: "string.prototype.matchall@npm:4.0.10"
   dependencies:
     call-bind: ^1.0.2
-    define-properties: ^1.1.3
-    es-abstract: ^1.19.1
-    get-intrinsic: ^1.1.1
+    define-properties: ^1.2.0
+    es-abstract: ^1.22.1
+    get-intrinsic: ^1.2.1
     has-symbols: ^1.0.3
-    internal-slot: ^1.0.3
-    regexp.prototype.flags: ^1.4.1
+    internal-slot: ^1.0.5
+    regexp.prototype.flags: ^1.5.0
+    set-function-name: ^2.0.0
     side-channel: ^1.0.4
-  checksum: fc09f3ccbfb325de0472bcc87a6be0598a7499e0b4a31db5789676155b15754a4cc4bb83924f15fc9ed48934dac7366ee52c8b9bd160bed6fd072c93b489e75c
+  checksum: 3c78bdeff39360c8e435d7c4c6ea19f454aa7a63eda95fa6fadc3a5b984446a2f9f2c02d5c94171ce22268a573524263fbd0c8edbe3ce2e9890d7cc036cdc3ed
   languageName: node
   linkType: hard
 
@@ -19056,6 +20219,17 @@ __metadata:
   languageName: node
   linkType: hard
 
+"string.prototype.trim@npm:^1.2.8":
+  version: 1.2.8
+  resolution: "string.prototype.trim@npm:1.2.8"
+  dependencies:
+    call-bind: ^1.0.2
+    define-properties: ^1.2.0
+    es-abstract: ^1.22.1
+  checksum: 49eb1a862a53aba73c3fb6c2a53f5463173cb1f4512374b623bcd6b43ad49dd559a06fb5789bdec771a40fc4d2a564411c0a75d35fb27e76bbe738c211ecff07
+  languageName: node
+  linkType: hard
+
 "string.prototype.trimend@npm:^1.0.5":
   version: 1.0.5
   resolution: "string.prototype.trimend@npm:1.0.5"
@@ -19078,6 +20252,17 @@ __metadata:
   languageName: node
   linkType: hard
 
+"string.prototype.trimend@npm:^1.0.7":
+  version: 1.0.7
+  resolution: "string.prototype.trimend@npm:1.0.7"
+  dependencies:
+    call-bind: ^1.0.2
+    define-properties: ^1.2.0
+    es-abstract: ^1.22.1
+  checksum: 2375516272fd1ba75992f4c4aa88a7b5f3c7a9ca308d963bcd5645adf689eba6f8a04ebab80c33e30ec0aefc6554181a3a8416015c38da0aa118e60ec896310c
+  languageName: node
+  linkType: hard
+
 "string.prototype.trimstart@npm:^1.0.5":
   version: 1.0.5
   resolution: "string.prototype.trimstart@npm:1.0.5"
@@ -19100,6 +20285,17 @@ __metadata:
   languageName: node
   linkType: hard
 
+"string.prototype.trimstart@npm:^1.0.7":
+  version: 1.0.7
+  resolution: "string.prototype.trimstart@npm:1.0.7"
+  dependencies:
+    call-bind: ^1.0.2
+    define-properties: ^1.2.0
+    es-abstract: ^1.22.1
+  checksum: 13d0c2cb0d5ff9e926fa0bec559158b062eed2b68cd5be777ffba782c96b2b492944e47057274e064549b94dd27cf81f48b27a31fee8af5b574cff253e7eb613
+  languageName: node
+  linkType: hard
+
 "string_decoder@npm:^1.1.1":
   version: 1.3.0
   resolution: "string_decoder@npm:1.3.0"
@@ -19234,9 +20430,9 @@ __metadata:
   languageName: node
   linkType: hard
 
-"styled-jsx@npm:5.0.2":
-  version: 5.0.2
-  resolution: "styled-jsx@npm:5.0.2"
+"styled-jsx@npm:5.0.7":
+  version: 5.0.7
+  resolution: "styled-jsx@npm:5.0.7"
   peerDependencies:
     react: ">= 16.8.0 || 17.x.x || ^18.0.0-0"
   peerDependenciesMeta:
@@ -19244,7 +20440,7 @@ __metadata:
       optional: true
     babel-plugin-macros:
       optional: true
-  checksum: 86d55819ebeabd283a574d2f44f7d3f8fa6b8c28fa41687ece161bf1e910e04965611618921d8f5cd33dc6dae1033b926a70421ae5ea045440a9861edc3e0d87
+  checksum: 61959993915f4b1662a682dbbefb3512de9399cf6901969bcadd26ba5441d2b5ca5c1021b233bbd573da2541b41efb45d56c6f618dbc8d88a381ebc62461fefe
   languageName: node
   linkType: hard
 
@@ -19296,7 +20492,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"supports-color@npm:^7.0.0, supports-color@npm:^7.1.0":
+"supports-color@npm:^7.1.0":
   version: 7.2.0
   resolution: "supports-color@npm:7.2.0"
   dependencies:
@@ -19305,16 +20501,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"supports-hyperlinks@npm:^2.0.0":
-  version: 2.1.0
-  resolution: "supports-hyperlinks@npm:2.1.0"
-  dependencies:
-    has-flag: ^4.0.0
-    supports-color: ^7.0.0
-  checksum: e4f430c870a258c9854b8bd7f166a9c1e76e3b851da84d4399d6a8f1d4a485e4ec36c16455dde80acf06c86e7c0a6df76ed22b6a4644a6ae3eced8616b3f21b5
-  languageName: node
-  linkType: hard
-
 "supports-preserve-symlinks-flag@npm:^1.0.0":
   version: 1.0.0
   resolution: "supports-preserve-symlinks-flag@npm:1.0.0"
@@ -19404,16 +20590,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"terminal-link@npm:^2.0.0":
-  version: 2.1.1
-  resolution: "terminal-link@npm:2.1.1"
-  dependencies:
-    ansi-escapes: ^4.2.1
-    supports-hyperlinks: ^2.0.0
-  checksum: ce3d2cd3a438c4a9453947aa664581519173ea40e77e2534d08c088ee6dda449eabdbe0a76d2a516b8b73c33262fedd10d5270ccf7576ae316e3db170ce6562f
-  languageName: node
-  linkType: hard
-
 "terser-webpack-plugin@npm:^5.1.3":
   version: 5.3.3
   resolution: "terser-webpack-plugin@npm:5.3.3"
@@ -19534,13 +20710,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"throat@npm:^6.0.1":
-  version: 6.0.1
-  resolution: "throat@npm:6.0.1"
-  checksum: 782d4171ee4e3cf947483ed2ff1af3e17cc4354c693b9d339284f61f99fbc401d171e0b0d2db3295bb7d447630333e9319c174ebd7ef315c6fb791db9675369c
-  languageName: node
-  linkType: hard
-
 "through@npm:^2.3.6, through@npm:^2.3.8":
   version: 2.3.8
   resolution: "through@npm:2.3.8"
@@ -19707,33 +20876,36 @@ __metadata:
   languageName: node
   linkType: hard
 
-"ts-jest@npm:^28.0.4":
-  version: 28.0.4
-  resolution: "ts-jest@npm:28.0.4"
+"ts-jest@npm:^29.1.1":
+  version: 29.1.1
+  resolution: "ts-jest@npm:29.1.1"
   dependencies:
     bs-logger: 0.x
     fast-json-stable-stringify: 2.x
-    jest-util: ^28.0.0
-    json5: ^2.2.1
+    jest-util: ^29.0.0
+    json5: ^2.2.3
     lodash.memoize: 4.x
     make-error: 1.x
-    semver: 7.x
-    yargs-parser: ^20.x
+    semver: ^7.5.3
+    yargs-parser: ^21.0.1
   peerDependencies:
     "@babel/core": ">=7.0.0-beta.0 <8"
-    babel-jest: ^28.0.0
-    jest: ^28.0.0
-    typescript: ">=4.3"
+    "@jest/types": ^29.0.0
+    babel-jest: ^29.0.0
+    jest: ^29.0.0
+    typescript: ">=4.3 <6"
   peerDependenciesMeta:
     "@babel/core":
       optional: true
+    "@jest/types":
+      optional: true
     babel-jest:
       optional: true
     esbuild:
       optional: true
   bin:
     ts-jest: cli.js
-  checksum: 21028e1917f60f086e0af6d057039f31385ca0f5b85736dc19bdd670ccbb5675c7ecde2eb30a5d01fcccdc7a59054498db0c4419306fc5fab0a596e3cf001023
+  checksum: a8c9e284ed4f819526749f6e4dc6421ec666f20ab44d31b0f02b4ed979975f7580b18aea4813172d43e39b29464a71899f8893dd29b06b4a351a3af8ba47b402
   languageName: node
   linkType: hard
 
@@ -19802,6 +20974,18 @@ __metadata:
   languageName: node
   linkType: hard
 
+"tsconfig-paths@npm:^3.14.2":
+  version: 3.14.2
+  resolution: "tsconfig-paths@npm:3.14.2"
+  dependencies:
+    "@types/json5": ^0.0.29
+    json5: ^1.0.2
+    minimist: ^1.2.6
+    strip-bom: ^3.0.0
+  checksum: a6162eaa1aed680537f93621b82399c7856afd10ec299867b13a0675e981acac4e0ec00896860480efc59fc10fd0b16fdc928c0b885865b52be62cadac692447
+  languageName: node
+  linkType: hard
+
 "tslib@npm:^1.8.1, tslib@npm:^1.9.0":
   version: 1.13.0
   resolution: "tslib@npm:1.13.0"
@@ -19816,7 +21000,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"tslib@npm:^2.0.1":
+"tslib@npm:^2.0.1, tslib@npm:^2.2.0, tslib@npm:^2.4.0":
   version: 2.6.2
   resolution: "tslib@npm:2.6.2"
   checksum: 329ea56123005922f39642318e3d1f0f8265d1e7fcb92c633e0809521da75eeaca28d2cf96d7248229deb40e5c19adf408259f4b9640afd20d13aecc1430f3ad
@@ -19865,154 +21049,74 @@ __metadata:
   languageName: node
   linkType: hard
 
-"turbo-android-arm64@npm:1.3.1":
-  version: 1.3.1
-  resolution: "turbo-android-arm64@npm:1.3.1"
-  conditions: os=android & cpu=arm64
-  languageName: node
-  linkType: hard
-
-"turbo-darwin-64@npm:1.3.1":
-  version: 1.3.1
-  resolution: "turbo-darwin-64@npm:1.3.1"
+"turbo-darwin-64@npm:1.10.14":
+  version: 1.10.14
+  resolution: "turbo-darwin-64@npm:1.10.14"
   conditions: os=darwin & cpu=x64
   languageName: node
   linkType: hard
 
-"turbo-darwin-arm64@npm:1.3.1":
-  version: 1.3.1
-  resolution: "turbo-darwin-arm64@npm:1.3.1"
+"turbo-darwin-arm64@npm:1.10.14":
+  version: 1.10.14
+  resolution: "turbo-darwin-arm64@npm:1.10.14"
   conditions: os=darwin & cpu=arm64
   languageName: node
   linkType: hard
 
-"turbo-freebsd-64@npm:1.3.1":
-  version: 1.3.1
-  resolution: "turbo-freebsd-64@npm:1.3.1"
-  conditions: os=freebsd & cpu=x64
-  languageName: node
-  linkType: hard
-
-"turbo-freebsd-arm64@npm:1.3.1":
-  version: 1.3.1
-  resolution: "turbo-freebsd-arm64@npm:1.3.1"
-  conditions: os=freebsd & cpu=arm64
-  languageName: node
-  linkType: hard
-
-"turbo-linux-32@npm:1.3.1":
-  version: 1.3.1
-  resolution: "turbo-linux-32@npm:1.3.1"
-  conditions: os=linux & cpu=ia32
-  languageName: node
-  linkType: hard
-
-"turbo-linux-64@npm:1.3.1":
-  version: 1.3.1
-  resolution: "turbo-linux-64@npm:1.3.1"
+"turbo-linux-64@npm:1.10.14":
+  version: 1.10.14
+  resolution: "turbo-linux-64@npm:1.10.14"
   conditions: os=linux & cpu=x64
   languageName: node
   linkType: hard
 
-"turbo-linux-arm64@npm:1.3.1":
-  version: 1.3.1
-  resolution: "turbo-linux-arm64@npm:1.3.1"
+"turbo-linux-arm64@npm:1.10.14":
+  version: 1.10.14
+  resolution: "turbo-linux-arm64@npm:1.10.14"
   conditions: os=linux & cpu=arm64
   languageName: node
   linkType: hard
 
-"turbo-linux-arm@npm:1.3.1":
-  version: 1.3.1
-  resolution: "turbo-linux-arm@npm:1.3.1"
-  conditions: os=linux & cpu=arm
-  languageName: node
-  linkType: hard
-
-"turbo-linux-mips64le@npm:1.3.1":
-  version: 1.3.1
-  resolution: "turbo-linux-mips64le@npm:1.3.1"
-  conditions: os=linux & cpu=mips64el
-  languageName: node
-  linkType: hard
-
-"turbo-linux-ppc64le@npm:1.3.1":
-  version: 1.3.1
-  resolution: "turbo-linux-ppc64le@npm:1.3.1"
-  conditions: os=linux & cpu=ppc64
-  languageName: node
-  linkType: hard
-
-"turbo-windows-32@npm:1.3.1":
-  version: 1.3.1
-  resolution: "turbo-windows-32@npm:1.3.1"
-  conditions: os=win32 & cpu=ia32
-  languageName: node
-  linkType: hard
-
-"turbo-windows-64@npm:1.3.1":
-  version: 1.3.1
-  resolution: "turbo-windows-64@npm:1.3.1"
+"turbo-windows-64@npm:1.10.14":
+  version: 1.10.14
+  resolution: "turbo-windows-64@npm:1.10.14"
   conditions: os=win32 & cpu=x64
   languageName: node
   linkType: hard
 
-"turbo-windows-arm64@npm:1.3.1":
-  version: 1.3.1
-  resolution: "turbo-windows-arm64@npm:1.3.1"
+"turbo-windows-arm64@npm:1.10.14":
+  version: 1.10.14
+  resolution: "turbo-windows-arm64@npm:1.10.14"
   conditions: os=win32 & cpu=arm64
   languageName: node
   linkType: hard
 
-"turbo@npm:^1.3.1":
-  version: 1.3.1
-  resolution: "turbo@npm:1.3.1"
-  dependencies:
-    turbo-android-arm64: 1.3.1
-    turbo-darwin-64: 1.3.1
-    turbo-darwin-arm64: 1.3.1
-    turbo-freebsd-64: 1.3.1
-    turbo-freebsd-arm64: 1.3.1
-    turbo-linux-32: 1.3.1
-    turbo-linux-64: 1.3.1
-    turbo-linux-arm: 1.3.1
-    turbo-linux-arm64: 1.3.1
-    turbo-linux-mips64le: 1.3.1
-    turbo-linux-ppc64le: 1.3.1
-    turbo-windows-32: 1.3.1
-    turbo-windows-64: 1.3.1
-    turbo-windows-arm64: 1.3.1
+"turbo@npm:^1.10.14":
+  version: 1.10.14
+  resolution: "turbo@npm:1.10.14"
+  dependencies:
+    turbo-darwin-64: 1.10.14
+    turbo-darwin-arm64: 1.10.14
+    turbo-linux-64: 1.10.14
+    turbo-linux-arm64: 1.10.14
+    turbo-windows-64: 1.10.14
+    turbo-windows-arm64: 1.10.14
   dependenciesMeta:
-    turbo-android-arm64:
-      optional: true
     turbo-darwin-64:
       optional: true
     turbo-darwin-arm64:
       optional: true
-    turbo-freebsd-64:
-      optional: true
-    turbo-freebsd-arm64:
-      optional: true
-    turbo-linux-32:
-      optional: true
     turbo-linux-64:
       optional: true
-    turbo-linux-arm:
-      optional: true
     turbo-linux-arm64:
       optional: true
-    turbo-linux-mips64le:
-      optional: true
-    turbo-linux-ppc64le:
-      optional: true
-    turbo-windows-32:
-      optional: true
     turbo-windows-64:
       optional: true
     turbo-windows-arm64:
       optional: true
   bin:
     turbo: bin/turbo
-  checksum: fced49081f2c64aaf93a2499edb057bb05d30bd2074b9652610f939c9037869501fcc50e7ad05a48cec4a8b9d344cc1c4ea505edd45fe2f9639fb138fa55182b
+  checksum: 219d245bb5cc32a9f76b136b81e86e179228d93a44cab4df3e3d487a55dd2688b5b85f4d585b66568ac53166145352399dd2d7ed0cd47f1aae63d08beb814ebb
   languageName: node
   linkType: hard
 
@@ -20128,6 +21232,42 @@ __metadata:
   languageName: node
   linkType: hard
 
+"typed-array-buffer@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "typed-array-buffer@npm:1.0.0"
+  dependencies:
+    call-bind: ^1.0.2
+    get-intrinsic: ^1.2.1
+    is-typed-array: ^1.1.10
+  checksum: 3e0281c79b2a40cd97fe715db803884301993f4e8c18e8d79d75fd18f796e8cd203310fec8c7fdb5e6c09bedf0af4f6ab8b75eb3d3a85da69328f28a80456bd3
+  languageName: node
+  linkType: hard
+
+"typed-array-byte-length@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "typed-array-byte-length@npm:1.0.0"
+  dependencies:
+    call-bind: ^1.0.2
+    for-each: ^0.3.3
+    has-proto: ^1.0.1
+    is-typed-array: ^1.1.10
+  checksum: b03db16458322b263d87a702ff25388293f1356326c8a678d7515767ef563ef80e1e67ce648b821ec13178dd628eb2afdc19f97001ceae7a31acf674c849af94
+  languageName: node
+  linkType: hard
+
+"typed-array-byte-offset@npm:^1.0.0":
+  version: 1.0.0
+  resolution: "typed-array-byte-offset@npm:1.0.0"
+  dependencies:
+    available-typed-arrays: ^1.0.5
+    call-bind: ^1.0.2
+    for-each: ^0.3.3
+    has-proto: ^1.0.1
+    is-typed-array: ^1.1.10
+  checksum: 04f6f02d0e9a948a95fbfe0d5a70b002191fae0b8fe0fe3130a9b2336f043daf7a3dda56a31333c35a067a97e13f539949ab261ca0f3692c41603a46a94e960b
+  languageName: node
+  linkType: hard
+
 "typed-array-length@npm:^1.0.4":
   version: 1.0.4
   resolution: "typed-array-length@npm:1.0.4"
@@ -20197,6 +21337,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"undici@npm:^5.22.1":
+  version: 5.25.4
+  resolution: "undici@npm:5.25.4"
+  dependencies:
+    "@fastify/busboy": ^2.0.0
+  checksum: 654da161687de893127a685be61a19cb5bae42f4595c316ebf633929d871ac3bcd33edcb74156cea90655dfcd100bfe9b53a4f4749d52fc6ad2232f49a6ca8ab
+  languageName: node
+  linkType: hard
+
 "unfetch@npm:^3.1.1":
   version: 3.1.2
   resolution: "unfetch@npm:3.1.2"
@@ -20387,6 +21536,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"use-sync-external-store@npm:1.2.0":
+  version: 1.2.0
+  resolution: "use-sync-external-store@npm:1.2.0"
+  peerDependencies:
+    react: ^16.8.0 || ^17.0.0 || ^18.0.0
+  checksum: 5c639e0f8da3521d605f59ce5be9e094ca772bd44a4ce7322b055a6f58eeed8dda3c94cabd90c7a41fb6fa852210092008afe48f7038792fd47501f33299116a
+  languageName: node
+  linkType: hard
+
 "userhome@npm:1.0.0":
   version: 1.0.0
   resolution: "userhome@npm:1.0.0"
@@ -20463,14 +21621,14 @@ __metadata:
   languageName: node
   linkType: hard
 
-"v8-to-istanbul@npm:^9.0.0":
-  version: 9.0.0
-  resolution: "v8-to-istanbul@npm:9.0.0"
+"v8-to-istanbul@npm:^9.0.1":
+  version: 9.2.0
+  resolution: "v8-to-istanbul@npm:9.2.0"
   dependencies:
-    "@jridgewell/trace-mapping": ^0.3.7
+    "@jridgewell/trace-mapping": ^0.3.12
     "@types/istanbul-lib-coverage": ^2.0.1
-    convert-source-map: ^1.6.0
-  checksum: d8ed2c39ba657dfd851a3c7b3f2b87e5b96c9face806ecfe5b627abe53b0c86f264f51425c591e451405b739e3f8a6728da59670f081790990710e813d8d3440
+    convert-source-map: ^2.0.0
+  checksum: 31ef98c6a31b1dab6be024cf914f235408cd4c0dc56a5c744a5eea1a9e019ba279e1b6f90d695b78c3186feed391ed492380ccf095009e2eb91f3d058f0b4491
   languageName: node
   linkType: hard
 
@@ -20491,9 +21649,9 @@ __metadata:
   languageName: node
   linkType: hard
 
-"vite@npm:^2.9.16":
-  version: 2.9.16
-  resolution: "vite@npm:2.9.16"
+"vite@npm:^2.9.17":
+  version: 2.9.17
+  resolution: "vite@npm:2.9.17"
   dependencies:
     esbuild: ^0.14.27
     fsevents: ~2.3.2
@@ -20516,7 +21674,7 @@ __metadata:
       optional: true
   bin:
     vite: bin/vite.js
-  checksum: b09d899861d9266ce67bf123f4b3c9adf46bd6f9dad186237e69e08b5862d7fb38984e8503abe622f6396b1b5b6f182617292ce71e9237e1ac3c0a771d611b5a
+  checksum: a9c3e15d0191984d0a297aed8e4309c9264c3e4dc095c54f8f28fd3abe00c45d893187f253bbb91db5dea7dc35bbcde2902b04114077d6805789b339f978193c
   languageName: node
   linkType: hard
 
@@ -21088,6 +22246,38 @@ __metadata:
   languageName: node
   linkType: hard
 
+"which-builtin-type@npm:^1.1.3":
+  version: 1.1.3
+  resolution: "which-builtin-type@npm:1.1.3"
+  dependencies:
+    function.prototype.name: ^1.1.5
+    has-tostringtag: ^1.0.0
+    is-async-function: ^2.0.0
+    is-date-object: ^1.0.5
+    is-finalizationregistry: ^1.0.2
+    is-generator-function: ^1.0.10
+    is-regex: ^1.1.4
+    is-weakref: ^1.0.2
+    isarray: ^2.0.5
+    which-boxed-primitive: ^1.0.2
+    which-collection: ^1.0.1
+    which-typed-array: ^1.1.9
+  checksum: 43730f7d8660ff9e33d1d3f9f9451c4784265ee7bf222babc35e61674a11a08e1c2925019d6c03154fcaaca4541df43abe35d2720843b9b4cbcebdcc31408f36
+  languageName: node
+  linkType: hard
+
+"which-collection@npm:^1.0.1":
+  version: 1.0.1
+  resolution: "which-collection@npm:1.0.1"
+  dependencies:
+    is-map: ^2.0.1
+    is-set: ^2.0.1
+    is-weakmap: ^2.0.1
+    is-weakset: ^2.0.1
+  checksum: c815bbd163107ef9cb84f135e6f34453eaf4cca994e7ba85ddb0d27cea724c623fae2a473ceccfd5549c53cc65a5d82692de418166df3f858e1e5dc60818581c
+  languageName: node
+  linkType: hard
+
 "which-module@npm:^2.0.0":
   version: 2.0.0
   resolution: "which-module@npm:2.0.0"
@@ -21105,6 +22295,19 @@ __metadata:
   languageName: node
   linkType: hard
 
+"which-typed-array@npm:^1.1.11, which-typed-array@npm:^1.1.13":
+  version: 1.1.13
+  resolution: "which-typed-array@npm:1.1.13"
+  dependencies:
+    available-typed-arrays: ^1.0.5
+    call-bind: ^1.0.4
+    for-each: ^0.3.3
+    gopd: ^1.0.1
+    has-tostringtag: ^1.0.0
+  checksum: 3828a0d5d72c800e369d447e54c7620742a4cc0c9baf1b5e8c17e9b6ff90d8d861a3a6dd4800f1953dbf80e5e5cec954a289e5b4a223e3bee4aeb1f8c5f33309
+  languageName: node
+  linkType: hard
+
 "which-typed-array@npm:^1.1.9":
   version: 1.1.9
   resolution: "which-typed-array@npm:1.1.9"
@@ -21195,6 +22398,32 @@ __metadata:
   languageName: node
   linkType: hard
 
+"workerd@npm:1.20231002.0":
+  version: 1.20231002.0
+  resolution: "workerd@npm:1.20231002.0"
+  dependencies:
+    "@cloudflare/workerd-darwin-64": 1.20231002.0
+    "@cloudflare/workerd-darwin-arm64": 1.20231002.0
+    "@cloudflare/workerd-linux-64": 1.20231002.0
+    "@cloudflare/workerd-linux-arm64": 1.20231002.0
+    "@cloudflare/workerd-windows-64": 1.20231002.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: e51c2e33a12f742ba8c04810ff309e65f9cb739fd5701f5502e50deb4c82ad85052d0e0bf3679956272013c5b024ae9165d35149730e7cbbcbfb28aea6cdafa0
+  languageName: node
+  linkType: hard
+
 "workerpool@npm:6.2.1":
   version: 6.2.1
   resolution: "workerpool@npm:6.2.1"
@@ -21202,6 +22431,34 @@ __metadata:
   languageName: node
   linkType: hard
 
+"wrangler@npm:^3.11.0":
+  version: 3.11.0
+  resolution: "wrangler@npm:3.11.0"
+  dependencies:
+    "@cloudflare/kv-asset-handler": ^0.2.0
+    "@esbuild-plugins/node-globals-polyfill": ^0.2.3
+    "@esbuild-plugins/node-modules-polyfill": ^0.2.2
+    blake3-wasm: ^2.1.5
+    chokidar: ^3.5.3
+    esbuild: 0.17.19
+    fsevents: ~2.3.2
+    miniflare: 3.20231002.1
+    nanoid: ^3.3.3
+    path-to-regexp: ^6.2.0
+    selfsigned: ^2.0.1
+    source-map: 0.6.1
+    source-map-support: 0.5.21
+    xxhash-wasm: ^1.0.1
+  dependenciesMeta:
+    fsevents:
+      optional: true
+  bin:
+    wrangler: bin/wrangler.js
+    wrangler2: bin/wrangler.js
+  checksum: 0799ab07640ada55adad1b45e5f2ebd04717c8701b2888d0e27fc21d9140680b256e676b5bef247964799ff392449e2ec2b387635ca968c1f97b8da928231385
+  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"
@@ -21254,13 +22511,13 @@ __metadata:
   languageName: node
   linkType: hard
 
-"write-file-atomic@npm:^4.0.1":
-  version: 4.0.1
-  resolution: "write-file-atomic@npm:4.0.1"
+"write-file-atomic@npm:^4.0.2":
+  version: 4.0.2
+  resolution: "write-file-atomic@npm:4.0.2"
   dependencies:
     imurmurhash: ^0.1.4
     signal-exit: ^3.0.7
-  checksum: 8f780232533ca6223c63c9b9c01c4386ca8c625ebe5017a9ed17d037aec19462ae17109e0aa155bff5966ee4ae7a27b67a99f55caf3f32ffd84155e9da3929fc
+  checksum: 5da60bd4eeeb935eec97ead3df6e28e5917a6bd317478e4a85a5285e8480b8ed96032bbcc6ecd07b236142a24f3ca871c924ec4a6575e623ec1b11bf8c1c253c
   languageName: node
   linkType: hard
 
@@ -21309,6 +22566,21 @@ __metadata:
   languageName: node
   linkType: hard
 
+"ws@npm:^8.11.0":
+  version: 8.14.2
+  resolution: "ws@npm:8.14.2"
+  peerDependencies:
+    bufferutil: ^4.0.1
+    utf-8-validate: ">=5.0.2"
+  peerDependenciesMeta:
+    bufferutil:
+      optional: true
+    utf-8-validate:
+      optional: true
+  checksum: 3ca0dad26e8cc6515ff392b622a1467430814c463b3368b0258e33696b1d4bed7510bc7030f7b72838b9fdeb8dbd8839cbf808367d6aae2e1d668ce741d4308b
+  languageName: node
+  linkType: hard
+
 "xdg-basedir@npm:^4.0.0":
   version: 4.0.0
   resolution: "xdg-basedir@npm:4.0.0"
@@ -21347,6 +22619,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"xxhash-wasm@npm:^1.0.1":
+  version: 1.0.2
+  resolution: "xxhash-wasm@npm:1.0.2"
+  checksum: 11fec6e6196e37ad96cc958b7a4477dc30caf5b4da889a02a84f6f663ab8cd3c9be6ae405e66f0af0404301f27c39375191c5254f0409a793020e2093afd1409
+  languageName: node
+  linkType: hard
+
 "y18n@npm:^4.0.0":
   version: 4.0.3
   resolution: "y18n@npm:4.0.3"
@@ -21406,7 +22685,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"yargs-parser@npm:^20.2.2, yargs-parser@npm:^20.2.4, yargs-parser@npm:^20.x":
+"yargs-parser@npm:^20.2.2, yargs-parser@npm:^20.2.4":
   version: 20.2.9
   resolution: "yargs-parser@npm:20.2.9"
   checksum: 8bb69015f2b0ff9e17b2c8e6bfe224ab463dd00ca211eece72a4cd8a906224d2703fb8a326d36fdd0e68701e201b2a60ed7cf81ce0fd9b3799f9fe7745977ae3
@@ -21420,7 +22699,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"yargs-parser@npm:^21.1.1":
+"yargs-parser@npm:^21.0.1, yargs-parser@npm:^21.1.1":
   version: 21.1.1
   resolution: "yargs-parser@npm:21.1.1"
   checksum: ed2d96a616a9e3e1cc7d204c62ecc61f7aaab633dcbfab2c6df50f7f87b393993fe6640d017759fe112d0cb1e0119f2b4150a87305cc873fd90831c6a58ccf1c
@@ -21578,6 +22857,17 @@ __metadata:
   languageName: node
   linkType: hard
 
+"youch@npm:^3.2.2":
+  version: 3.3.2
+  resolution: "youch@npm:3.3.2"
+  dependencies:
+    cookie: ^0.5.0
+    mustache: ^4.2.0
+    stacktracey: ^2.1.8
+  checksum: ffe1370279972401c5c3b296fe24c319e2a43370737ae46058bbbd6739a1efb3bdd2e52dbebb28e9049ac21fd570f7934033bc8dd98cb11019bffba6ab8df3c9
+  languageName: node
+  linkType: hard
+
 "zip-stream@npm:^4.1.0":
   version: 4.1.0
   resolution: "zip-stream@npm:4.1.0"
@@ -21599,3 +22889,10 @@ __metadata:
   checksum: 116cee5a2c1ecce7aa440b665470653f58ef56670c6aafa1b5491c9f9335992352145502af5fa865ac82f46336905e37fb7cbc649c2be72e2152c6b91802995c
   languageName: node
   linkType: hard
+
+"zod@npm:^3.20.6":
+  version: 3.22.4
+  resolution: "zod@npm:3.22.4"
+  checksum: 80bfd7f8039b24fddeb0718a2ec7c02aa9856e4838d6aa4864335a047b6b37a3273b191ef335bf0b2002e5c514ef261ffcda5a589fb084a48c336ffc4cdbab7f
+  languageName: node
+  linkType: hard