diff --git a/.eslintrc.js b/.eslintrc.js index af5ecdab2..f6659e123 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -47,6 +47,38 @@ module.exports = { 'jest/no-focused-tests': 'error', 'jest/no-identical-title': 'error', 'jest/valid-expect': 'error', + 'no-underscore-dangle': ['error', { allowAfterThis: true }], + '@typescript-eslint/naming-convention': [ + 'error', + { + selector: ['method'], + format: ['camelCase'], + leadingUnderscore: 'forbid', + }, + { + selector: ['method'], + format: ['camelCase'], + modifiers: ['private'], + leadingUnderscore: 'require', + }, + { + selector: ['classProperty', 'parameterProperty'], + format: ['camelCase'], + leadingUnderscore: 'forbid', + }, + { + selector: ['classProperty', 'parameterProperty'], + modifiers: ['static'], + format: ['PascalCase'], + leadingUnderscore: 'forbid', + }, + { + selector: ['classProperty', 'parameterProperty'], + modifiers: ['private'], + format: ['camelCase'], + leadingUnderscore: 'require', + }, + ], }, globals: { BigInt: 'readonly', diff --git a/.github/workflows/browser.yml b/.github/workflows/browser.yml index da12b51e5..1442c4a56 100644 --- a/.github/workflows/browser.yml +++ b/.github/workflows/browser.yml @@ -12,6 +12,8 @@ on: jobs: build-test-browser: + permissions: + pull-requests: write runs-on: ubuntu-latest strategy: @@ -31,3 +33,12 @@ jobs: with: workspace_name: '@launchdarkly/js-client-sdk' workspace_path: packages/sdk/browser + - name: Check package size + if: github.event_name == 'pull_request' && matrix.version == '21' + uses: ./actions/package-size + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + target_file: 'packages/sdk/browser/dist/index.js' + package_name: '@launchdarkly/js-client-sdk' + pr_number: ${{ github.event.number }} + size_limit: 21000 diff --git a/.github/workflows/common.yml b/.github/workflows/common.yml index f2dc03e64..a64766e4c 100644 --- a/.github/workflows/common.yml +++ b/.github/workflows/common.yml @@ -25,3 +25,12 @@ jobs: with: workspace_name: '@launchdarkly/js-sdk-common' workspace_path: packages/shared/common + - name: Check package size + if: github.event_name == 'pull_request' + uses: ./actions/package-size + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + target_file: 'packages/shared/common/dist/esm/index.mjs' + package_name: '@launchdarkly/js-sdk-common' + pr_number: ${{ github.event.number }} + size_limit: 21000 diff --git a/.github/workflows/mocks.yml b/.github/workflows/mocks.yml deleted file mode 100644 index 6a5b5d6b7..000000000 --- a/.github/workflows/mocks.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: shared/mocks - -on: - push: - branches: [main, 'feat/**'] - paths-ignore: - - '**.md' #Do not need to run CI for markdown changes. - pull_request: - branches: [main, 'feat/**'] - paths-ignore: - - '**.md' - -jobs: - build-test-mocks: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - - id: shared - name: Shared CI Steps - uses: ./actions/ci - with: - workspace_name: '@launchdarkly/private-js-mocks' - workspace_path: packages/shared/mocks - should_build_docs: false diff --git a/.github/workflows/react-native-detox.yml b/.github/workflows/react-native-detox.yml new file mode 100644 index 000000000..1b491d0de --- /dev/null +++ b/.github/workflows/react-native-detox.yml @@ -0,0 +1,94 @@ +name: sdk/react-native/example + +# The example builds independently of react-native because of the duration of the build. +# We limit it to only build under specific circumstances. +# Additionally this does allow for scheduled builds of just the example, to handle changes in expo, +# should they be desired. + +on: + push: + branches: [main, 'feat/**'] + paths-ignore: + - '**.md' #Do not need to run CI for markdown changes. + pull_request: + branches: [main, 'feat/**'] + paths: + - 'packages/shared/common/**' + - 'packages/shared/sdk-client/**' + - 'packages/sdk/react-native/**' + +jobs: + detox-android: + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + defaults: + run: + working-directory: packages/sdk/react-native/example + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + + - name: Install deps + run: yarn workspaces focus + - name: Build + run: yarn workspaces foreach -pR --topological-dev --from 'react-native-example' run build + + - uses: ./actions/release-secrets + name: 'Get mobile key' + with: + aws_assume_role: ${{ vars.AWS_ROLE_ARN_EXAMPLES }} + ssm_parameter_pairs: '/sdk/common/hello-apps/mobile-key = MOBILE_KEY, + /sdk/common/hello-apps/boolean-flag-key = LAUNCHDARKLY_FLAG_KEY' + + - name: Create .env file. + run: echo "MOBILE_KEY=$MOBILE_KEY" > .env + + - name: Enable KVM group perms (for performance) + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Expo Prebuild + run: npx expo prebuild + + # Java setup is after checkout and expo prebuild so that it can locate the + # gradle configuration. + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + cache: 'gradle' + + - name: Detox build + run: yarn detox build --configuration android.emu.release + + - name: Get android emulator device name + id: device + run: node -e "console.log('AVD_NAME=' + require('./.detoxrc').devices.emulator.device.avdName)" >> $GITHUB_OUTPUT + + - name: Make space for the emulator. + uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be + with: + android: false # We need android. + + - name: Detox test + uses: reactivecircus/android-emulator-runner@f0d1ed2dcad93c7479e8b2f2226c83af54494915 + with: + api-level: 31 + arch: x86_64 + avd-name: ${{ steps.device.outputs.AVD_NAME }} + working-directory: packages/sdk/react-native/example + script: yarn detox test --configuration android.emu.release --headless --record-logs all + + - name: Upload artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: detox-artifacts + path: packages/sdk/react-native/example/artifacts diff --git a/.github/workflows/react-native.yml b/.github/workflows/react-native.yml index a9eb6945e..de88b8e1e 100644 --- a/.github/workflows/react-native.yml +++ b/.github/workflows/react-native.yml @@ -22,65 +22,3 @@ jobs: with: workspace_name: '@launchdarkly/react-native-client-sdk' workspace_path: packages/sdk/react-native - detox-ios: - # TODO: disable detox for now because it's unstable. - if: false - # macos-latest uses macos-12 and we need macos-14 to get xcode 15. - # https://github.com/actions/runner-images/blob/main/README.md - runs-on: macos-14 - permissions: - id-token: write - contents: read - defaults: - run: - working-directory: packages/sdk/react-native/example - steps: - - uses: actions/checkout@v4 - - name: Setup Node.js - uses: actions/setup-node@v4 - - name: Install deps - run: yarn workspaces focus - - name: Build - run: yarn workspaces foreach -pR --topological-dev --from 'react-native-example' run build - - name: Install macOS dependencies - run: | - brew tap wix/brew - brew install applesimutils - env: - HOMEBREW_NO_AUTO_UPDATE: 1 - HOMEBREW_NO_INSTALL_CLEANUP: 1 - - - name: Cache Detox build - id: cache-detox-build - uses: actions/cache@v4 - with: - path: ios/build - key: ${{ runner.os }}-detox-build - restore-keys: | - ${{ runner.os }}-detox-build - - - name: Detox rebuild framework cache - run: yarn detox rebuild-framework-cache - - - uses: ./actions/release-secrets - name: 'Get mobile key' - with: - aws_assume_role: ${{ vars.AWS_ROLE_ARN }} - ssm_parameter_pairs: '/sdk/detox/mobile-key = MOBILE_KEY' - - - name: Set mobile key - run: echo "MOBILE_KEY=$MOBILE_KEY" > .env - - - name: Expo prebuild - # HACK: Deleting ios/.xcode.env.local is needed to solve an xcode build issue with rn 0.73 - # https://github.com/facebook/react-native/issues/42112#issuecomment-1884536225 - run: | - export NO_FLIPPER=1 - yarn expo-prebuild - rm -rf ./ios/.xcode.env.local - - - name: Detox build - run: yarn detox build --configuration ios.sim.release - - - name: Detox test - run: yarn detox test --configuration ios.sim.release --cleanup --headless --record-logs all --take-screenshots failing diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 99a941690..57a260d96 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -24,6 +24,7 @@ jobs: package-node-server-sdk-otel-release: ${{ steps.release.outputs['packages/telemetry/node-server-sdk-otel--release_created'] }} package-tooling-jest-release: ${{ steps.release.outputs['packages/tooling/jest--release_created'] }} package-react-universal-release: ${{ steps.release.outputs['packages/sdk/react-universal--release_created'] }} + package-browser-released: ${{ steps.release.outputs['packages/sdk/browser--release_created'] }} steps: - uses: googleapis/release-please-action@v4 id: release @@ -52,11 +53,11 @@ jobs: release-sdk-client: runs-on: ubuntu-latest - needs: ['release-please'] + needs: ['release-please', 'release-common'] permissions: id-token: write contents: write - if: ${{ needs.release-please.outputs.package-sdk-client-released == 'true'}} + if: ${{ always() && !failure() && !cancelled() && needs.release-please.outputs.package-sdk-client-released == 'true'}} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -72,11 +73,11 @@ jobs: release-sdk-server: runs-on: ubuntu-latest - needs: ['release-please'] + needs: ['release-please', 'release-common'] permissions: id-token: write contents: write - if: ${{ needs.release-please.outputs.package-sdk-server-released == 'true'}} + if: ${{ always() && !failure() && !cancelled() && needs.release-please.outputs.package-sdk-server-released == 'true'}} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -92,11 +93,11 @@ jobs: release-sdk-server-edge: runs-on: ubuntu-latest - needs: ['release-please'] + needs: ['release-please', 'release-sdk-server'] permissions: id-token: write contents: write - if: ${{ needs.release-please.outputs.package-sdk-server-edge-released == 'true'}} + if: ${{ always() && !failure() && !cancelled() && needs.release-please.outputs.package-sdk-server-edge-released == 'true'}} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -112,11 +113,11 @@ jobs: release-akamai-edgeworker-sdk: runs-on: ubuntu-latest - needs: ['release-please'] + needs: ['release-please', 'release-sdk-server'] permissions: id-token: write contents: write - if: ${{ needs.release-please.outputs.package-akamai-edgeworker-sdk-released == 'true'}} + if: ${{ always() && !failure() && !cancelled() && needs.release-please.outputs.package-akamai-edgeworker-sdk-released == 'true'}} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -132,11 +133,11 @@ jobs: release-cloudflare: runs-on: ubuntu-latest - needs: ['release-please'] + needs: ['release-please', 'release-sdk-server-edge'] permissions: id-token: write contents: write - if: ${{ needs.release-please.outputs.package-cloudflare-released == 'true'}} + if: ${{ always() && !failure() && !cancelled() && needs.release-please.outputs.package-cloudflare-released == 'true'}} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -152,11 +153,11 @@ jobs: release-react-native: runs-on: ubuntu-latest - needs: ['release-please'] + needs: ['release-please', 'release-sdk-client'] permissions: id-token: write contents: write - if: ${{ needs.release-please.outputs.package-react-native-released == 'true'}} + if: ${{ always() && !failure() && !cancelled() && needs.release-please.outputs.package-react-native-released == 'true'}} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -170,13 +171,33 @@ jobs: workspace_path: packages/sdk/react-native aws_assume_role: ${{ vars.AWS_ROLE_ARN }} + release-browser: + runs-on: ubuntu-latest + needs: ['release-please', 'release-sdk-client'] + permissions: + id-token: write + contents: write + if: ${{ always() && !failure() && !cancelled() && needs.release-please.outputs.package-browser-released == 'true'}} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20.x + registry-url: 'https://registry.npmjs.org' + - id: release-react-native + name: Full release of packages/sdk/browser + uses: ./actions/full-release + with: + workspace_path: packages/sdk/browser + aws_assume_role: ${{ vars.AWS_ROLE_ARN }} + release-server-node: runs-on: ubuntu-latest - needs: ['release-please'] + needs: ['release-please', 'release-sdk-server'] permissions: id-token: write contents: write - if: ${{ needs.release-please.outputs.package-server-node-released == 'true'}} + if: ${{ always() && !failure() && !cancelled() && needs.release-please.outputs.package-server-node-released == 'true'}} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -192,11 +213,11 @@ jobs: release-vercel: runs-on: ubuntu-latest - needs: ['release-please'] + needs: ['release-please', 'release-sdk-server-edge'] permissions: id-token: write contents: write - if: ${{ needs.release-please.outputs.package-vercel-released == 'true'}} + if: ${{ always() && !failure() && !cancelled() && needs.release-please.outputs.package-vercel-released == 'true'}} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -212,11 +233,11 @@ jobs: release-akamai-base: runs-on: ubuntu-latest - needs: ['release-please'] + needs: ['release-please', 'release-akamai-edgeworker-sdk'] permissions: id-token: write contents: write - if: ${{ needs.release-please.outputs.package-akamai-base-released == 'true'}} + if: ${{ always() && !failure() && !cancelled() && needs.release-please.outputs.package-akamai-base-released == 'true'}} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -232,11 +253,11 @@ jobs: release-akamai-edgekv: runs-on: ubuntu-latest - needs: ['release-please'] + needs: ['release-please', 'release-akamai-edgeworker-sdk'] permissions: id-token: write contents: write - if: ${{ needs.release-please.outputs.package-akamai-edgekv-released == 'true'}} + if: ${{ always() && !failure() && !cancelled() && needs.release-please.outputs.package-akamai-edgekv-released == 'true'}} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -252,11 +273,11 @@ jobs: release-node-server-sdk-redis: runs-on: ubuntu-latest - needs: ['release-please'] + needs: ['release-please', 'release-server-node'] permissions: id-token: write contents: write - if: ${{ needs.release-please.outputs.package-node-server-sdk-redis-release == 'true' }} + if: ${{ always() && !failure() && !cancelled() && needs.release-please.outputs.package-node-server-sdk-redis-release == 'true' }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -276,11 +297,11 @@ jobs: release-node-server-sdk-dynamodb: runs-on: ubuntu-latest - needs: ['release-please'] + needs: ['release-please', 'release-server-node'] permissions: id-token: write contents: write - if: ${{ needs.release-please.outputs.package-node-server-sdk-dynamodb-release == 'true' }} + if: ${{ always() && !failure() && !cancelled() && needs.release-please.outputs.package-node-server-sdk-dynamodb-release == 'true' }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -298,11 +319,11 @@ jobs: release-node-server-sdk-otel: runs-on: ubuntu-latest - needs: ['release-please'] + needs: ['release-please', 'release-server-node'] permissions: id-token: write contents: write - if: ${{ needs.release-please.outputs.package-node-server-sdk-otel-release == 'true' }} + if: ${{ always() && !failure() && !cancelled() && needs.release-please.outputs.package-node-server-sdk-otel-release == 'true' }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -339,11 +360,11 @@ jobs: release-tooling-react-universal: runs-on: ubuntu-latest - needs: ['release-please'] + needs: ['release-please', 'release-server-node', 'release-sdk-client'] permissions: id-token: write contents: write - if: false #${{ needs.release-please.outputs.package-react-universal-release == 'true' }} + if: false #${{ always() && !failure() && !cancelled() && needs.release-please.outputs.package-react-universal-release == 'true' }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 diff --git a/.github/workflows/sdk-client.yml b/.github/workflows/sdk-client.yml index 9ac1c9ebe..2c2fbaa7b 100644 --- a/.github/workflows/sdk-client.yml +++ b/.github/workflows/sdk-client.yml @@ -22,3 +22,12 @@ jobs: with: workspace_name: '@launchdarkly/js-client-sdk-common' workspace_path: packages/shared/sdk-client + - name: Check package size + if: github.event_name == 'pull_request' + uses: ./actions/package-size + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + target_file: 'packages/shared/sdk-client/dist/esm/index.mjs' + package_name: '@launchdarkly/js-client-sdk-common' + pr_number: ${{ github.event.number }} + size_limit: 20000 diff --git a/.gitignore b/.gitignore index 37ff832f4..2f28d3160 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ yarn-error.log .vscode dump.rdb .wrangler +stats.html diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 9c5bbad92..5bd68a8f8 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,16 +1,17 @@ { - "packages/shared/common": "2.7.0", - "packages/shared/sdk-server": "2.5.0", - "packages/sdk/server-node": "9.5.2", - "packages/sdk/cloudflare": "2.5.11", - "packages/shared/sdk-server-edge": "2.3.7", - "packages/sdk/vercel": "1.3.14", - "packages/sdk/akamai-base": "2.1.13", - "packages/sdk/akamai-edgekv": "1.1.13", - "packages/shared/akamai-edgeworker-sdk": "1.1.13", - "packages/store/node-server-sdk-dynamodb": "6.1.19", - "packages/store/node-server-sdk-redis": "4.1.19", - "packages/shared/sdk-client": "1.6.0", - "packages/sdk/react-native": "10.6.0", - "packages/telemetry/node-server-sdk-otel": "1.0.11" + "packages/shared/common": "2.11.0", + "packages/shared/sdk-server": "2.9.0", + "packages/sdk/server-node": "9.7.0", + "packages/sdk/cloudflare": "2.6.0", + "packages/shared/sdk-server-edge": "2.5.0", + "packages/sdk/vercel": "1.3.19", + "packages/sdk/akamai-base": "2.1.18", + "packages/sdk/akamai-edgekv": "1.2.0", + "packages/shared/akamai-edgeworker-sdk": "1.3.0", + "packages/store/node-server-sdk-dynamodb": "6.2.0", + "packages/store/node-server-sdk-redis": "4.2.0", + "packages/shared/sdk-client": "1.11.0", + "packages/sdk/react-native": "10.9.1", + "packages/telemetry/node-server-sdk-otel": "1.1.0", + "packages/sdk/browser": "0.2.0" } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3cde4e2eb..dbcde575e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,14 +27,20 @@ To install project dependencies, from the project root directory: yarn ``` -### Testing +### Build -To run all unit tests: +To build all projects, from the root directory: ``` -yarn test +yarn build ``` +### Testing + +Unit tests should be implemented in a `__tests__` folder in the root of the package. The directory structure inside of `__tests__` should mirror that of the source directory. + +Each package has its own testing requirements and tests should be only ran for single projects. + To run the SDK contract test suite (see [`contract-tests/README.md`](./contract-tests/README.md)): The SDK contract test suite will run the Node.js Server version of the SDK. diff --git a/actions/package-size/action.yml b/actions/package-size/action.yml new file mode 100644 index 000000000..ba4af8902 --- /dev/null +++ b/actions/package-size/action.yml @@ -0,0 +1,69 @@ +name: Package Size Action +description: Checks that a compressed package is less than a certain size and also comments on the PR. +inputs: + github_token: + description: 'Github token with permission to write PR comments' + required: true + target_file: + description: 'Path to the JavaScript file to check' + required: true + package_name: + description: 'The name of the package' + required: true + pr_number: + description: 'The PR number' + required: true + size_limit: + description: 'The maximum size of the library' + required: true +runs: + using: composite + steps: + - name: Install Brotli + shell: bash + if: github.event_name == 'pull_request' + run: sudo apt-get update && sudo apt-get install brotli + - name: Get package size + shell: bash + run: | + brotli ${{ inputs.target_file }} + export PACK_SIZE=$(stat -c %s ${{ inputs.target_file }}.br) + echo "PACK_SIZE=$PACK_SIZE" >> $GITHUB_ENV + + - name: Find Size Comment + # Only do commenting on non-forks. A fork does not have permissions for comments. + if: github.event.pull_request.head.repo.full_name == github.repository + uses: peter-evans/find-comment@3eae4d37986fb5a8592848f6a574fdf654e61f9e + id: fc + with: + issue-number: ${{ inputs.pr_number }} + comment-author: 'github-actions[bot]' + body-includes: '${{ inputs.package_name }} size report' + + - name: Create comment + if: steps.fc.outputs.comment-id == '' && github.event.pull_request.head.repo.full_name == github.repository + uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 + with: + issue-number: ${{ inputs.pr_number }} + body: | + ${{ inputs.package_name }} size report + This is the brotli compressed size of the ESM build. + Size: ${{ env.PACK_SIZE }} bytes + Size limit: ${{ inputs.size_limit }} + + - name: Update comment + if: steps.fc.outputs.comment-id != '' && github.event.pull_request.head.repo.full_name == github.repository + uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 + with: + comment-id: ${{ steps.fc.outputs.comment-id }} + edit-mode: replace + body: | + ${{ inputs.package_name }} size report + This is the brotli compressed size of the ESM build. + Size: ${{ env.PACK_SIZE }} bytes + Size limit: ${{ inputs.size_limit }} + + - name: Check package size limit + shell: bash + run: | + [ $PACK_SIZE -le ${{ inputs.size_limit }} ] || exit 1 diff --git a/contract-tests/index.js b/contract-tests/index.js index e3716a367..761a431dd 100644 --- a/contract-tests/index.js +++ b/contract-tests/index.js @@ -38,6 +38,7 @@ app.get('/', (req, res) => { 'anonymous-redaction', 'evaluation-hooks', 'wrapper', + 'client-prereq-events', ], }); }); diff --git a/jest.config.js b/jest.config.js index c9b229c3d..58df77ebc 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,7 +1,7 @@ module.exports = { transform: { '^.+\\.ts?$': 'ts-jest' }, testMatch: ['**/__tests__/**/*test.ts?(x)'], - testEnvironment: 'node', + testEnvironment: 'jsdom', moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], collectCoverageFrom: [ 'packages/sdk/server-node/src/**/*.ts', diff --git a/package.json b/package.json index 00a094160..0786114ca 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,6 @@ "name": "@launchdarkly/js-core", "workspaces": [ "packages/shared/common", - "packages/shared/mocks", "packages/shared/sdk-client", "packages/shared/sdk-server", "packages/shared/sdk-server-edge", @@ -23,7 +22,10 @@ "packages/store/node-server-sdk-dynamodb", "packages/telemetry/node-server-sdk-otel", "packages/tooling/jest", + "packages/tooling/jest/example/react-native-example", "packages/sdk/browser", + "packages/sdk/browser/contract-tests/entity", + "packages/sdk/browser/contract-tests/adapter", "packages/sdk/ai" ], "private": true, diff --git a/packages/sdk/ai/src/LDAIConfigTracker.ts b/packages/sdk/ai/src/LDAIConfigTracker.ts deleted file mode 100644 index 564902bd6..000000000 --- a/packages/sdk/ai/src/LDAIConfigTracker.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { LDClient, LDContext } from '@launchdarkly/node-server-sdk'; - -import { - BedrockTokenUsage, - FeedbackKind, - OpenAITokenUsage, - TokenUsage, - UnderscoreTokenUsage, -} from './api/metrics'; - -export class LDAIConfigTracker { - private ldClient: LDClient; - private variationId: string; - private configKey: string; - private context: LDContext; - - constructor(ldClient: LDClient, configKey: string, variationId: string, context: LDContext) { - this.ldClient = ldClient; - this.variationId = variationId; - this.configKey = configKey; - this.context = context; - } - - private getTrackData() { - return { - variationId: this.variationId, - configKey: this.configKey, - }; - } - - trackDuration(duration: number): void { - this.ldClient.track('$ld:ai:duration:total', this.context, this.getTrackData(), duration); - } - - async trackDurationOf(func: Function, ...args: any[]): Promise { - const startTime = Date.now(); - const result = await func(...args); - const endTime = Date.now(); - const duration = endTime - startTime; // duration in milliseconds - this.trackDuration(duration); - return result; - } - - trackError(error: number): void { - this.ldClient.track('$ld:ai:error', this.context, this.getTrackData(), error); - } - - trackFeedback(feedback: { kind: FeedbackKind }): void { - if (feedback.kind === FeedbackKind.Positive) { - this.ldClient.track('$ld:ai:feedback:user:positive', this.context, this.getTrackData(), 1); - } else if (feedback.kind === FeedbackKind.Negative) { - this.ldClient.track('$ld:ai:feedback:user:negative', this.context, this.getTrackData(), 1); - } - } - - trackGeneration(generation: number): void { - this.ldClient.track('$ld:ai:generation', this.context, this.getTrackData(), generation); - } - - async trackOpenAI(func: Function, ...args: any[]): Promise { - const result = await this.trackDurationOf(func, ...args); - this.trackGeneration(1); - if (result.usage) { - this.trackTokens(new OpenAITokenUsage(result.usage)); - } - return result; - } - - async trackBedrockConverse(res: any): Promise { - if (res.$metadata?.httpStatusCode === 200) { - this.trackGeneration(1); - } else if (res.$metadata?.httpStatusCode >= 400) { - this.trackError(res.$metadata.httpStatusCode); - } - if (res.metrics) { - this.trackDuration(res.metrics.latencyMs); - } - if (res.usage) { - this.trackTokens(new BedrockTokenUsage(res.usage)); - } - return res; - } - - trackTokens(tokens: TokenUsage | UnderscoreTokenUsage | BedrockTokenUsage): void { - const tokenMetrics = tokens.toMetrics(); - if (tokenMetrics.total > 0) { - this.ldClient.track( - '$ld:ai:tokens:total', - this.context, - this.getTrackData(), - tokenMetrics.total, - ); - } - if (tokenMetrics.input > 0) { - this.ldClient.track( - '$ld:ai:tokens:input', - this.context, - this.getTrackData(), - tokenMetrics.input, - ); - } - if (tokenMetrics.output > 0) { - this.ldClient.track( - '$ld:ai:tokens:output', - this.context, - this.getTrackData(), - tokenMetrics.output, - ); - } - } -} diff --git a/packages/sdk/ai/src/LDAIConfigTrackerImpl.ts b/packages/sdk/ai/src/LDAIConfigTrackerImpl.ts new file mode 100644 index 000000000..bbb5b5286 --- /dev/null +++ b/packages/sdk/ai/src/LDAIConfigTrackerImpl.ts @@ -0,0 +1,100 @@ +import { LDClient, LDContext } from '@launchdarkly/node-server-sdk'; + +import { LDAIConfigTracker } from './api/config'; +import { createBedrockTokenUsage, FeedbackKind, TokenUsage } from './api/metrics'; +import { createOpenAiUsage } from './api/metrics/OpenAiUsage'; + +export class LDAIConfigTrackerImpl implements LDAIConfigTracker { + private _ldClient: LDClient; + private _variationId: string; + private _configKey: string; + private _context: LDContext; + + constructor(ldClient: LDClient, configKey: string, versionId: string, context: LDContext) { + this._ldClient = ldClient; + this._variationId = versionId; + this._configKey = configKey; + this._context = context; + } + + private _getTrackData(): { variationId: string; configKey: string } { + return { + variationId: this._variationId, + configKey: this._configKey, + }; + } + + trackDuration(duration: number): void { + this._ldClient.track('$ld:ai:duration:total', this._context, this._getTrackData(), duration); + } + + async trackDurationOf(func: (...args: any[]) => Promise, ...args: any[]): Promise { + const startTime = Date.now(); + const result = await func(...args); + const endTime = Date.now(); + const duration = endTime - startTime; // duration in milliseconds + this.trackDuration(duration); + return result; + } + + trackError(error: number): void { + this._ldClient.track('$ld:ai:error', this._context, this._getTrackData(), error); + } + + trackFeedback(feedback: { kind: FeedbackKind }): void { + if (feedback.kind === FeedbackKind.Positive) { + this._ldClient.track('$ld:ai:feedback:user:positive', this._context, this._getTrackData(), 1); + } else if (feedback.kind === FeedbackKind.Negative) { + this._ldClient.track('$ld:ai:feedback:user:negative', this._context, this._getTrackData(), 1); + } + } + + trackGeneration(generation: number): void { + this._ldClient.track('$ld:ai:generation', this._context, this._getTrackData(), generation); + } + + async trackOpenAI(func: (...args: any[]) => Promise, ...args: any[]): Promise { + const result = await this.trackDurationOf(func, ...args); + this.trackGeneration(1); + if (result.usage) { + this.trackTokens(createOpenAiUsage(result.usage)); + } + return result; + } + + async trackBedrockConverse(res: { + $metadata?: { httpStatusCode: number }; + metrics?: { latencyMs: number }; + usage?: { + inputTokens: number; + outputTokens: number; + totalTokens: number; + }; + }): Promise { + if (res.$metadata?.httpStatusCode === 200) { + this.trackGeneration(1); + } else if (res.$metadata?.httpStatusCode && res.$metadata.httpStatusCode >= 400) { + this.trackError(res.$metadata.httpStatusCode); + } + if (res.metrics) { + this.trackDuration(res.metrics.latencyMs); + } + if (res.usage) { + this.trackTokens(createBedrockTokenUsage(res.usage)); + } + return res; + } + + trackTokens(tokens: TokenUsage): void { + const trackData = this._getTrackData(); + if (tokens.total > 0) { + this._ldClient.track('$ld:ai:tokens:total', this._context, trackData, tokens.total); + } + if (tokens.input > 0) { + this._ldClient.track('$ld:ai:tokens:input', this._context, trackData, tokens.input); + } + if (tokens.output > 0) { + this._ldClient.track('$ld:ai:tokens:output', this._context, trackData, tokens.output); + } + } +} diff --git a/packages/sdk/ai/src/api/config/LDAIConfigTracker.ts b/packages/sdk/ai/src/api/config/LDAIConfigTracker.ts index 6c7dd7c27..a32e2dccd 100644 --- a/packages/sdk/ai/src/api/config/LDAIConfigTracker.ts +++ b/packages/sdk/ai/src/api/config/LDAIConfigTracker.ts @@ -1,12 +1,12 @@ -import { BedrockTokenUsage, FeedbackKind, TokenUsage, UnderscoreTokenUsage } from '../metrics'; +import { FeedbackKind, TokenUsage } from '../metrics'; export interface LDAIConfigTracker { trackDuration: (duration: number) => void; - trackTokens: (tokens: TokenUsage | UnderscoreTokenUsage | BedrockTokenUsage) => void; + trackTokens: (tokens: TokenUsage) => void; trackError: (error: number) => void; trackGeneration: (generation: number) => void; trackFeedback: (feedback: { kind: FeedbackKind }) => void; - trackDurationOf: (func: Function, ...args: any[]) => any; - trackOpenAI: (func: Function, ...args: any[]) => any; + trackDurationOf: (func: (...args: any[]) => Promise, ...args: any[]) => Promise; + trackOpenAI: (func: (...args: any[]) => Promise, ...args: any[]) => any; trackBedrockConverse: (res: any) => any; } diff --git a/packages/sdk/ai/src/api/index.ts b/packages/sdk/ai/src/api/index.ts new file mode 100644 index 000000000..71dfe2bf2 --- /dev/null +++ b/packages/sdk/ai/src/api/index.ts @@ -0,0 +1,2 @@ +export * from './config'; +export * from './metrics'; diff --git a/packages/sdk/ai/src/api/metrics/BedrockTokenUsage.ts b/packages/sdk/ai/src/api/metrics/BedrockTokenUsage.ts index a0c44ec4f..629f9043c 100644 --- a/packages/sdk/ai/src/api/metrics/BedrockTokenUsage.ts +++ b/packages/sdk/ai/src/api/metrics/BedrockTokenUsage.ts @@ -1,19 +1,13 @@ -export class BedrockTokenUsage { +import { TokenUsage } from './TokenUsage'; + +export function createBedrockTokenUsage(data: { totalTokens: number; inputTokens: number; outputTokens: number; - - constructor(data: any) { - this.totalTokens = data.totalTokens || 0; - this.inputTokens = data.inputTokens || 0; - this.outputTokens = data.outputTokens || 0; - } - - toMetrics() { - return { - total: this.totalTokens, - input: this.inputTokens, - output: this.outputTokens, - }; - } +}): TokenUsage { + return { + total: data.totalTokens || 0, + input: data.inputTokens || 0, + output: data.outputTokens || 0, + }; } diff --git a/packages/sdk/ai/src/api/metrics/OpenAiUsage.ts b/packages/sdk/ai/src/api/metrics/OpenAiUsage.ts new file mode 100644 index 000000000..ea4800bc0 --- /dev/null +++ b/packages/sdk/ai/src/api/metrics/OpenAiUsage.ts @@ -0,0 +1,9 @@ +import { TokenUsage } from './TokenUsage'; + +export function createOpenAiUsage(data: any): TokenUsage { + return { + total: data.total_tokens ?? 0, + input: data.prompt_token ?? 0, + output: data.completion_token ?? 0, + }; +} diff --git a/packages/sdk/ai/src/api/metrics/TokenUsage.ts b/packages/sdk/ai/src/api/metrics/TokenUsage.ts index ed098f7ae..7d31ab3b9 100644 --- a/packages/sdk/ai/src/api/metrics/TokenUsage.ts +++ b/packages/sdk/ai/src/api/metrics/TokenUsage.ts @@ -1,19 +1,5 @@ -export class TokenUsage { - totalTokens: number; - promptTokens: number; - completionTokens: number; - - constructor(data: any) { - this.totalTokens = data.total_tokens || 0; - this.promptTokens = data.prompt_tokens || 0; - this.completionTokens = data.completion_tokens || 0; - } - - toMetrics() { - return { - total: this.totalTokens, - input: this.promptTokens, - output: this.completionTokens, - }; - } +export interface TokenUsage { + total: number; + input: number; + output: number; } diff --git a/packages/sdk/ai/src/api/metrics/UnderScoreTokenUsage.ts b/packages/sdk/ai/src/api/metrics/UnderScoreTokenUsage.ts new file mode 100644 index 000000000..5d9ed2fc7 --- /dev/null +++ b/packages/sdk/ai/src/api/metrics/UnderScoreTokenUsage.ts @@ -0,0 +1,9 @@ +import { TokenUsage } from './TokenUsage'; + +export function createUnderscoreTokenUsage(data: any): TokenUsage { + return { + total: data.total_tokens || 0, + input: data.prompt_tokens || 0, + output: data.completion_tokens || 0, + }; +} diff --git a/packages/sdk/ai/src/api/metrics/UnderscoreTokenUsage.ts b/packages/sdk/ai/src/api/metrics/UnderscoreTokenUsage.ts deleted file mode 100644 index ec5b81803..000000000 --- a/packages/sdk/ai/src/api/metrics/UnderscoreTokenUsage.ts +++ /dev/null @@ -1,19 +0,0 @@ -export class UnderscoreTokenUsage { - total_tokens: number; - prompt_tokens: number; - completion_tokens: number; - - constructor(data: any) { - this.total_tokens = data.total_tokens || 0; - this.prompt_tokens = data.prompt_tokens || 0; - this.completion_tokens = data.completion_tokens || 0; - } - - toMetrics() { - return { - total: this.total_tokens, - input: this.prompt_tokens, - output: this.completion_tokens, - }; - } -} diff --git a/packages/sdk/ai/src/index.ts b/packages/sdk/ai/src/index.ts index c77ac522c..219beda04 100644 --- a/packages/sdk/ai/src/index.ts +++ b/packages/sdk/ai/src/index.ts @@ -3,15 +3,12 @@ import Mustache from 'mustache'; import { LDClient, LDContext } from '@launchdarkly/node-server-sdk'; import { LDAIConfig } from './api/config'; -import { LDAIConfigTracker } from './LDAIConfigTracker'; - -export class AIClient { - private ldClient: LDClient; - - constructor(ldClient: LDClient) { - this.ldClient = ldClient; - } +import { LDAIConfigTrackerImpl } from './LDAIConfigTrackerImpl'; +/** + * Interface for performing AI operations using LaunchDarkly. + */ +export interface AIClient { /** * Parses and interpolates a template string with the provided variables. * @@ -19,9 +16,7 @@ export class AIClient { * @param variables - An object containing the variables to be used for interpolation. * @returns The interpolated string. */ - interpolateTemplate(template: string, variables: Record): string { - return Mustache.render(template, variables, undefined, { escape: (item: any) => item }); - } + interpolateTemplate(template: string, variables: Record): string; /** * Retrieves and processes a prompt template based on the provided key, LaunchDarkly context, and variables. @@ -36,37 +31,56 @@ export class AIClient { * @example * ``` * const key = "welcome_prompt"; - * const context = new LDContext(...); - * const variables = new Record([["username", "John"]]); - * const defaultValue = {}}; + * const context = {...}; + * const variables = {username: 'john}; + * const defaultValue = {}; * * const result = modelConfig(key, context, defaultValue, variables); * // Output: * { - * modelId: "gpt-4o", - * temperature: 0.2, - * maxTokens: 4096, - * userDefinedKey: "myValue", - * prompt: [ - * { - * role: "system", - * content: "You are an amazing GPT." - * }, - * { - * role: "user", - * content: "Explain how you're an amazing GPT." - * } - * ] + * modelId: "gpt-4o", + * temperature: 0.2, + * maxTokens: 4096, + * userDefinedKey: "myValue", + * prompt: [ + * { + * role: "system", + * content: "You are an amazing GPT." + * }, + * { + * role: "user", + * content: "Explain how you're an amazing GPT." + * } + * ] * } * ``` */ - async modelConfig( + modelConfig( + key: string, + context: LDContext, + defaultValue: T, + variables?: Record, + ): Promise; +} + +export class AIClientImpl implements AIClient { + private _ldClient: LDClient; + + constructor(ldClient: LDClient) { + this._ldClient = ldClient; + } + + interpolateTemplate(template: string, variables: Record): string { + return Mustache.render(template, variables, undefined, { escape: (item: any) => item }); + } + + async modelConfig( key: string, context: LDContext, - defaultValue: string, + defaultValue: T, variables?: Record, - ): Promise { - const detail = await this.ldClient.variation(key, context, defaultValue); + ): Promise { + const detail = await this._ldClient.variation(key, context, defaultValue); const allVariables = { ldctx: context, ...variables }; @@ -77,11 +91,12 @@ export class AIClient { return { config: detail.value, - tracker: new LDAIConfigTracker( - this.ldClient, + // eslint-disable-next-line no-underscore-dangle + tracker: new LDAIConfigTrackerImpl( + this._ldClient, key, - // eslint-disable-next-line @typescript-eslint/dot-notation - detail.value['_ldMeta'].variationId, + // eslint-disable-next-line no-underscore-dangle + detail.value._ldMeta.variationId, context, ), noConfiguration: Object.keys(detail).length === 0, @@ -89,9 +104,13 @@ export class AIClient { } } +/** + * Initialize a new AI client. This client will be used to perform any AI operations. + * @param ldClient The base LaunchDarkly client. + * @returns A new AI client. + */ export function init(ldClient: LDClient): AIClient { - return new AIClient(ldClient); + return new AIClientImpl(ldClient); } -export * from './api/config/LDAIConfigTracker'; -export * from './api/metrics'; +export * from './api'; diff --git a/packages/sdk/ai/src/trackUtils.ts b/packages/sdk/ai/src/trackUtils.ts deleted file mode 100644 index bace5e0c3..000000000 --- a/packages/sdk/ai/src/trackUtils.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { BedrockTokenUsage, TokenMetrics, TokenUsage, UnderscoreTokenUsage } from './api/metrics'; - -export function usageToTokenMetrics( - usage: TokenUsage | UnderscoreTokenUsage | BedrockTokenUsage, -): TokenMetrics { - if ('inputTokens' in usage && 'outputTokens' in usage) { - // Bedrock usage - return { - total: usage.totalTokens, - input: usage.inputTokens, - output: usage.outputTokens, - }; - } - - // OpenAI usage (both camelCase and snake_case) - return { - total: 'total_tokens' in usage ? usage.total_tokens! : (usage as TokenUsage).totalTokens ?? 0, - input: - 'prompt_tokens' in usage ? usage.prompt_tokens! : (usage as TokenUsage).promptTokens ?? 0, - output: - 'completion_tokens' in usage - ? usage.completion_tokens! - : (usage as TokenUsage).completionTokens ?? 0, - }; -} diff --git a/packages/sdk/ai/tsconfig.eslint.json b/packages/sdk/ai/tsconfig.eslint.json index 0d1ecf8cf..058048d4e 100644 --- a/packages/sdk/ai/tsconfig.eslint.json +++ b/packages/sdk/ai/tsconfig.eslint.json @@ -2,4 +2,4 @@ "extends": "./tsconfig.json", "include": ["/**/*.ts"], "exclude": ["node_modules"] - } +} diff --git a/packages/sdk/ai/tsconfig.json b/packages/sdk/ai/tsconfig.json index a91e23333..7306c5b0c 100644 --- a/packages/sdk/ai/tsconfig.json +++ b/packages/sdk/ai/tsconfig.json @@ -1,12 +1,34 @@ { "compilerOptions": { - "target": "ES2017", - "module": "commonjs", + "allowSyntheticDefaultImports": true, "declaration": true, - "outDir": "./dist", + "declarationMap": true, + "lib": ["ES2017", "dom"], + "module": "ESNext", + "moduleResolution": "node", + "noImplicitOverride": true, + "resolveJsonModule": true, + "rootDir": ".", + "outDir": "dist", + "skipLibCheck": true, + "sourceMap": false, "strict": true, - "esModuleInterop": true + "stripInternal": true, + "target": "ES2017", + "types": ["node", "jest"], + "allowJs": true }, "include": ["src"], - "exclude": ["node_modules", "**/*.test.ts"] -} \ No newline at end of file + "exclude": [ + "vite.config.ts", + "__tests__", + "dist", + "docs", + "example", + "node_modules", + "babel.config.js", + "setup-jest.js", + "rollup.config.js", + "**/*.test.ts*" + ] +} diff --git a/packages/sdk/akamai-base/CHANGELOG.md b/packages/sdk/akamai-base/CHANGELOG.md index 226b4455a..4f8584eb7 100644 --- a/packages/sdk/akamai-base/CHANGELOG.md +++ b/packages/sdk/akamai-base/CHANGELOG.md @@ -30,6 +30,56 @@ All notable changes to the LaunchDarkly SDK for Akamai Workers will be documente * @launchdarkly/akamai-edgeworker-sdk-common bumped from ^1.1.1 to ^1.1.2 * @launchdarkly/js-server-sdk-common bumped from ^2.2.1 to ^2.2.2 +## [2.1.18](https://github.com/launchdarkly/js-core/compare/akamai-server-base-sdk-v2.1.17...akamai-server-base-sdk-v2.1.18) (2024-10-17) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/akamai-edgeworker-sdk-common bumped from ^1.2.1 to ^1.3.0 + * @launchdarkly/js-server-sdk-common bumped from ^2.8.0 to ^2.9.0 + +## [2.1.17](https://github.com/launchdarkly/js-core/compare/akamai-server-base-sdk-v2.1.16...akamai-server-base-sdk-v2.1.17) (2024-10-09) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/akamai-edgeworker-sdk-common bumped from ^1.2.0 to ^1.2.1 + * @launchdarkly/js-server-sdk-common bumped from ^2.7.0 to ^2.8.0 + +## [2.1.16](https://github.com/launchdarkly/js-core/compare/akamai-server-base-sdk-v2.1.15...akamai-server-base-sdk-v2.1.16) (2024-09-26) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/akamai-edgeworker-sdk-common bumped from ^1.1.15 to ^1.2.0 + * @launchdarkly/js-server-sdk-common bumped from ^2.6.1 to ^2.7.0 + +## [2.1.15](https://github.com/launchdarkly/js-core/compare/akamai-server-base-sdk-v2.1.14...akamai-server-base-sdk-v2.1.15) (2024-09-05) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/akamai-edgeworker-sdk-common bumped from ^1.1.14 to ^1.1.15 + * @launchdarkly/js-server-sdk-common bumped from ^2.6.0 to ^2.6.1 + +## [2.1.14](https://github.com/launchdarkly/js-core/compare/akamai-server-base-sdk-v2.1.13...akamai-server-base-sdk-v2.1.14) (2024-09-03) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/akamai-edgeworker-sdk-common bumped from ^1.1.13 to ^1.1.14 + * @launchdarkly/js-server-sdk-common bumped from ^2.5.0 to ^2.6.0 + ## [2.1.13](https://github.com/launchdarkly/js-core/compare/akamai-server-base-sdk-v2.1.12...akamai-server-base-sdk-v2.1.13) (2024-08-28) diff --git a/packages/sdk/akamai-base/src/__tests__/index.test.ts b/packages/sdk/akamai-base/__tests__/index.test.ts similarity index 98% rename from packages/sdk/akamai-base/src/__tests__/index.test.ts rename to packages/sdk/akamai-base/__tests__/index.test.ts index f70caae05..e62833ea2 100644 --- a/packages/sdk/akamai-base/src/__tests__/index.test.ts +++ b/packages/sdk/akamai-base/__tests__/index.test.ts @@ -1,4 +1,4 @@ -import { EdgeProvider, init } from '../index'; +import { EdgeProvider, init } from '../src/index'; import * as testData from './testData.json'; const sdkKey = 'test-sdk-key'; diff --git a/packages/sdk/akamai-base/src/__tests__/testData.json b/packages/sdk/akamai-base/__tests__/testData.json similarity index 100% rename from packages/sdk/akamai-base/src/__tests__/testData.json rename to packages/sdk/akamai-base/__tests__/testData.json diff --git a/packages/sdk/akamai-base/package.json b/packages/sdk/akamai-base/package.json index 6cf34b1a0..f2710ec98 100644 --- a/packages/sdk/akamai-base/package.json +++ b/packages/sdk/akamai-base/package.json @@ -1,6 +1,6 @@ { "name": "@launchdarkly/akamai-server-base-sdk", - "version": "2.1.13", + "version": "2.1.18", "description": "Akamai LaunchDarkly EdgeWorker SDK", "homepage": "https://github.com/launchdarkly/js-core/tree/main/packages/sdk/akamai-base", "repository": { @@ -73,7 +73,7 @@ "typescript": "5.1.6" }, "dependencies": { - "@launchdarkly/akamai-edgeworker-sdk-common": "^1.1.13", - "@launchdarkly/js-server-sdk-common": "^2.5.0" + "@launchdarkly/akamai-edgeworker-sdk-common": "^1.3.0", + "@launchdarkly/js-server-sdk-common": "^2.9.0" } } diff --git a/packages/sdk/akamai-edgekv/CHANGELOG.md b/packages/sdk/akamai-edgekv/CHANGELOG.md index 046a70890..ad36aea1f 100644 --- a/packages/sdk/akamai-edgekv/CHANGELOG.md +++ b/packages/sdk/akamai-edgekv/CHANGELOG.md @@ -31,6 +31,61 @@ All notable changes to the LaunchDarkly SDK for Akamai Workers will be documente * @launchdarkly/akamai-edgeworker-sdk-common bumped from ^1.1.1 to ^1.1.2 * @launchdarkly/js-server-sdk-common bumped from ^2.2.1 to ^2.2.2 +## [1.2.0](https://github.com/launchdarkly/js-core/compare/akamai-server-edgekv-sdk-v1.1.17...akamai-server-edgekv-sdk-v1.2.0) (2024-10-17) + + +### Features + +* Apply private property naming standard. Mangle browser private properties. ([#620](https://github.com/launchdarkly/js-core/issues/620)) ([3e6d404](https://github.com/launchdarkly/js-core/commit/3e6d404ae665c5cc7e5a1394a59c8f2c9d5d682a)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/akamai-edgeworker-sdk-common bumped from ^1.2.1 to ^1.3.0 + * @launchdarkly/js-server-sdk-common bumped from ^2.8.0 to ^2.9.0 + +## [1.1.17](https://github.com/launchdarkly/js-core/compare/akamai-server-edgekv-sdk-v1.1.16...akamai-server-edgekv-sdk-v1.1.17) (2024-10-09) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/akamai-edgeworker-sdk-common bumped from ^1.2.0 to ^1.2.1 + * @launchdarkly/js-server-sdk-common bumped from ^2.7.0 to ^2.8.0 + +## [1.1.16](https://github.com/launchdarkly/js-core/compare/akamai-server-edgekv-sdk-v1.1.15...akamai-server-edgekv-sdk-v1.1.16) (2024-09-26) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/akamai-edgeworker-sdk-common bumped from ^1.1.15 to ^1.2.0 + * @launchdarkly/js-server-sdk-common bumped from ^2.6.1 to ^2.7.0 + +## [1.1.15](https://github.com/launchdarkly/js-core/compare/akamai-server-edgekv-sdk-v1.1.14...akamai-server-edgekv-sdk-v1.1.15) (2024-09-05) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/akamai-edgeworker-sdk-common bumped from ^1.1.14 to ^1.1.15 + * @launchdarkly/js-server-sdk-common bumped from ^2.6.0 to ^2.6.1 + +## [1.1.14](https://github.com/launchdarkly/js-core/compare/akamai-server-edgekv-sdk-v1.1.13...akamai-server-edgekv-sdk-v1.1.14) (2024-09-03) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/akamai-edgeworker-sdk-common bumped from ^1.1.13 to ^1.1.14 + * @launchdarkly/js-server-sdk-common bumped from ^2.5.0 to ^2.6.0 + ## [1.1.13](https://github.com/launchdarkly/js-core/compare/akamai-server-edgekv-sdk-v1.1.12...akamai-server-edgekv-sdk-v1.1.13) (2024-08-28) diff --git a/packages/sdk/akamai-edgekv/src/__tests__/edgekv/edgeKVProvider.test.ts b/packages/sdk/akamai-edgekv/__tests__/edgekv/edgeKVProvider.test.ts similarity index 85% rename from packages/sdk/akamai-edgekv/src/__tests__/edgekv/edgeKVProvider.test.ts rename to packages/sdk/akamai-edgekv/__tests__/edgekv/edgeKVProvider.test.ts index 13d61d3d8..b97366dd2 100644 --- a/packages/sdk/akamai-edgekv/src/__tests__/edgekv/edgeKVProvider.test.ts +++ b/packages/sdk/akamai-edgekv/__tests__/edgekv/edgeKVProvider.test.ts @@ -1,7 +1,7 @@ -import { EdgeKV } from '../../edgekv/edgekv'; -import EdgeKVProvider from '../../edgekv/edgeKVProvider'; +import { EdgeKV } from '../../src/edgekv/edgekv'; +import EdgeKVProvider from '../../src/edgekv/edgeKVProvider'; -jest.mock('../../edgekv/edgekv', () => ({ +jest.mock('../../src/edgekv/edgekv', () => ({ EdgeKV: jest.fn(), })); diff --git a/packages/sdk/akamai-edgekv/src/__tests__/index.test.ts b/packages/sdk/akamai-edgekv/__tests__/index.test.ts similarity index 96% rename from packages/sdk/akamai-edgekv/src/__tests__/index.test.ts rename to packages/sdk/akamai-edgekv/__tests__/index.test.ts index 778a37e2a..90fd9e924 100644 --- a/packages/sdk/akamai-edgekv/src/__tests__/index.test.ts +++ b/packages/sdk/akamai-edgekv/__tests__/index.test.ts @@ -1,8 +1,8 @@ -import EdgeKVProvider from '../edgekv/edgeKVProvider'; -import { init as initWithEdgeKV, LDClient, LDContext } from '../index'; +import EdgeKVProvider from '../src/edgekv/edgeKVProvider'; +import { init as initWithEdgeKV, LDClient, LDContext } from '../src/index'; import * as testData from './testData.json'; -jest.mock('../edgekv/edgekv', () => ({ +jest.mock('../src/edgekv/edgekv', () => ({ EdgeKV: jest.fn(), })); diff --git a/packages/sdk/akamai-edgekv/src/__tests__/testData.json b/packages/sdk/akamai-edgekv/__tests__/testData.json similarity index 100% rename from packages/sdk/akamai-edgekv/src/__tests__/testData.json rename to packages/sdk/akamai-edgekv/__tests__/testData.json diff --git a/packages/sdk/akamai-edgekv/package.json b/packages/sdk/akamai-edgekv/package.json index 46041012d..cad645b56 100644 --- a/packages/sdk/akamai-edgekv/package.json +++ b/packages/sdk/akamai-edgekv/package.json @@ -1,6 +1,6 @@ { "name": "@launchdarkly/akamai-server-edgekv-sdk", - "version": "1.1.13", + "version": "1.2.0", "description": "Akamai LaunchDarkly EdgeWorker SDK for EdgeKV feature store", "homepage": "https://github.com/launchdarkly/js-core/tree/main/packages/sdk/akamai-edgekv", "repository": { @@ -73,7 +73,7 @@ "typescript": "5.1.6" }, "dependencies": { - "@launchdarkly/akamai-edgeworker-sdk-common": "^1.1.13", - "@launchdarkly/js-server-sdk-common": "^2.5.0" + "@launchdarkly/akamai-edgeworker-sdk-common": "^1.3.0", + "@launchdarkly/js-server-sdk-common": "^2.9.0" } } diff --git a/packages/sdk/akamai-edgekv/src/edgekv/edgeKVProvider.ts b/packages/sdk/akamai-edgekv/src/edgekv/edgeKVProvider.ts index 97c8806f0..66a1f17ed 100644 --- a/packages/sdk/akamai-edgekv/src/edgekv/edgeKVProvider.ts +++ b/packages/sdk/akamai-edgekv/src/edgekv/edgeKVProvider.ts @@ -8,15 +8,15 @@ type EdgeKVProviderParams = { }; export default class EdgeKVProvider implements EdgeProvider { - private edgeKv: EdgeKV; + private _edgeKv: EdgeKV; constructor({ namespace, group }: EdgeKVProviderParams) { - this.edgeKv = new EdgeKV({ namespace, group } as any); + this._edgeKv = new EdgeKV({ namespace, group } as any); } async get(rootKey: string): Promise { try { - return await this.edgeKv.getText({ item: rootKey } as any); + return await this._edgeKv.getText({ item: rootKey } as any); } catch (e) { /* empty */ } diff --git a/packages/sdk/browser/CHANGELOG.md b/packages/sdk/browser/CHANGELOG.md new file mode 100644 index 000000000..da48540d7 --- /dev/null +++ b/packages/sdk/browser/CHANGELOG.md @@ -0,0 +1,71 @@ +# Changelog + +## [0.2.0](https://github.com/launchdarkly/js-core/compare/js-client-sdk-v0.1.0...js-client-sdk-v0.2.0) (2024-10-29) + + +### Features + +* Add a module for increased backward compatibility. ([#637](https://github.com/launchdarkly/js-core/issues/637)) ([44a2237](https://github.com/launchdarkly/js-core/commit/44a223730fed10fbd75e8de7c87c63570774fe96)) +* Refine CJS/ESM build configuration for browser SDK. ([#640](https://github.com/launchdarkly/js-core/issues/640)) ([ec4377c](https://github.com/launchdarkly/js-core/commit/ec4377cc2afc62455aba769c20f3831cccd50250)) +* Vendor escapeStringRegexp to simplify builds. ([48cac54](https://github.com/launchdarkly/js-core/commit/48cac546f6d36a6b70f3b1f7cb72d1dcff2b50ba)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-client-sdk-common bumped from 1.10.0 to 1.11.0 + +## [0.1.0](https://github.com/launchdarkly/js-core/compare/js-client-sdk-v0.0.1...js-client-sdk-v0.1.0) (2024-10-17) + + +### Features + +* Add prerequisite information to server-side allFlagsState. ([8c84e01](https://github.com/launchdarkly/js-core/commit/8c84e0149a5621c6fcb95f2cfdbd6112f3540191)) +* Add support for client-side prerequisite events. ([8c84e01](https://github.com/launchdarkly/js-core/commit/8c84e0149a5621c6fcb95f2cfdbd6112f3540191)) +* Add support for inspectors. ([#625](https://github.com/launchdarkly/js-core/issues/625)) ([a986478](https://github.com/launchdarkly/js-core/commit/a986478ed8e39d0f529ca6adec0a09b484421390)) +* Add support for prerequisite details to evaluation detail. ([8c84e01](https://github.com/launchdarkly/js-core/commit/8c84e0149a5621c6fcb95f2cfdbd6112f3540191)) +* adds ping stream support ([#624](https://github.com/launchdarkly/js-core/issues/624)) ([dee53af](https://github.com/launchdarkly/js-core/commit/dee53af9312b74a70b748d49b2d2911d65333cf3)) +* Apply private property naming standard. Mangle browser private properties. ([#620](https://github.com/launchdarkly/js-core/issues/620)) ([3e6d404](https://github.com/launchdarkly/js-core/commit/3e6d404ae665c5cc7e5a1394a59c8f2c9d5d682a)) + + +### Bug Fixes + +* Do not mangle _meta. ([#622](https://github.com/launchdarkly/js-core/issues/622)) ([f6fc40b](https://github.com/launchdarkly/js-core/commit/f6fc40bc518d175d18d556b8c22e3c924ed25bbd)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-client-sdk-common bumped from 1.9.0 to 1.10.0 + +## 0.0.1 (2024-10-10) + + +### Features + +* Add basic secure mode support for browser SDK. ([#598](https://github.com/launchdarkly/js-core/issues/598)) ([3389983](https://github.com/launchdarkly/js-core/commit/33899830781affbe986f3bb9df35e5c908884f99)) +* Add bootstrap support. ([#600](https://github.com/launchdarkly/js-core/issues/600)) ([4e5dbee](https://github.com/launchdarkly/js-core/commit/4e5dbee48d6bb236b5febd872c910e809058a012)) +* Add browser info. ([#576](https://github.com/launchdarkly/js-core/issues/576)) ([a2f4398](https://github.com/launchdarkly/js-core/commit/a2f439813171527e05f5863afbda3fcb93fedb47)) +* Add ESM support for common and common-client (rollup) ([#604](https://github.com/launchdarkly/js-core/issues/604)) ([8cd0cdc](https://github.com/launchdarkly/js-core/commit/8cd0cdce988f606b1efdf6bfd19484f6607db2e5)) +* Add support for browser contract tests. ([#582](https://github.com/launchdarkly/js-core/issues/582)) ([38f081e](https://github.com/launchdarkly/js-core/commit/38f081ebf04c68123cf83addefbcbfec692cac16)) +* Add support for hooks. ([#605](https://github.com/launchdarkly/js-core/issues/605)) ([04d347b](https://github.com/launchdarkly/js-core/commit/04d347b25e01015134a2545be22bfd8b1d1e85cc)) +* Add support for js-client-sdk style initialization. ([53f5bb8](https://github.com/launchdarkly/js-core/commit/53f5bb89754ff05405d481a959e75742fbd0d0a9)) +* Add support for localStorage for the browser platform. ([#566](https://github.com/launchdarkly/js-core/issues/566)) ([4792391](https://github.com/launchdarkly/js-core/commit/4792391d1ae06f5d5afc7f7ab56608df6b1909c4)) +* Add URLs for custom events and URL filtering. ([#587](https://github.com/launchdarkly/js-core/issues/587)) ([7131e69](https://github.com/launchdarkly/js-core/commit/7131e6905f19cc10a1374aae5e74cec66c7fd6de)) +* Add visibility handling to allow proactive event flushing. ([#607](https://github.com/launchdarkly/js-core/issues/607)) ([819a311](https://github.com/launchdarkly/js-core/commit/819a311db6f56e323bb84c925789ad4bd19ae4ba)) +* adds datasource status to sdk-client ([#590](https://github.com/launchdarkly/js-core/issues/590)) ([6f26204](https://github.com/launchdarkly/js-core/commit/6f262045b76836e5d2f5ccc2be433094993fcdbb)) +* Adds support for REPORT. ([#575](https://github.com/launchdarkly/js-core/issues/575)) ([916b724](https://github.com/launchdarkly/js-core/commit/916b72409b63abdf350e70cca41331c4204b6e95)) +* Browser-SDK Automatically start streaming based on event handlers. ([#592](https://github.com/launchdarkly/js-core/issues/592)) ([f2e5cbf](https://github.com/launchdarkly/js-core/commit/f2e5cbf1d0b3ae39a95881fecdcbefc11e9d0363)) +* Implement browser crypto and encoding. ([#574](https://github.com/launchdarkly/js-core/issues/574)) ([e763e5d](https://github.com/launchdarkly/js-core/commit/e763e5d2e53329c0f86b93544af85ca7a94e7936)) +* Implement goals for client-side SDKs. ([#585](https://github.com/launchdarkly/js-core/issues/585)) ([fd38a8f](https://github.com/launchdarkly/js-core/commit/fd38a8fa8560dad0c6721c2eaeed2f3f5c674900)) +* Implement support for browser requests. ([#578](https://github.com/launchdarkly/js-core/issues/578)) ([887548a](https://github.com/launchdarkly/js-core/commit/887548a29e22a618d44a6941c175f33402e331bf)) +* Refactor data source connection handling. ([53f5bb8](https://github.com/launchdarkly/js-core/commit/53f5bb89754ff05405d481a959e75742fbd0d0a9)) +* Scaffold browser client. ([#579](https://github.com/launchdarkly/js-core/issues/579)) ([0848ab7](https://github.com/launchdarkly/js-core/commit/0848ab790903f8fcdc717de6c426e4948abe51c4)) + + +### Bug Fixes + +* Ensure browser contract tests run during top-level build. ([#589](https://github.com/launchdarkly/js-core/issues/589)) ([7dfb14d](https://github.com/launchdarkly/js-core/commit/7dfb14de1757b66d9f876b25d4c1262e8f8b70c9)) +* Ensure client logger is always wrapped in a safe logger. ([#599](https://github.com/launchdarkly/js-core/issues/599)) ([980e4da](https://github.com/launchdarkly/js-core/commit/980e4daaf32864e18f14b1e5e28e308dff0ae94f)) diff --git a/packages/sdk/browser/README.md b/packages/sdk/browser/README.md index d171ca1d9..6202f2b1e 100644 --- a/packages/sdk/browser/README.md +++ b/packages/sdk/browser/README.md @@ -8,8 +8,12 @@ [![NPM][browser-sdk-dt-badge]][browser-sdk-npm-link] --> +# ⛔️⛔️⛔️⛔️ + > [!CAUTION] -> This library is a beta version and should not be considered ready for production use while this message is visible. +> This library is a alpha version and should not be considered ready for production use while this message is visible. + +# ☝️☝️☝️☝️☝️☝️ + +`; diff --git a/packages/sdk/browser/contract-tests/entity/src/makeLogger.ts b/packages/sdk/browser/contract-tests/entity/src/makeLogger.ts new file mode 100644 index 000000000..a8cf9f165 --- /dev/null +++ b/packages/sdk/browser/contract-tests/entity/src/makeLogger.ts @@ -0,0 +1,22 @@ +import { LDLogger } from '@launchdarkly/js-client-sdk'; + +export function makeLogger(tag: string): LDLogger { + return { + debug(message: any, ...args: any[]) { + // eslint-disable-next-line no-console + console.debug(`${new Date().toISOString()} [${tag}]: ${message}`, ...args); + }, + info(message: any, ...args: any[]) { + // eslint-disable-next-line no-console + console.info(`${new Date().toISOString()} [${tag}]: ${message}`, ...args); + }, + warn(message: any, ...args: any[]) { + // eslint-disable-next-line no-console + console.warn(`${new Date().toISOString()} [${tag}]: ${message}`, ...args); + }, + error(message: any, ...args: any[]) { + // eslint-disable-next-line no-console + console.error(`${new Date().toISOString()} [${tag}]: ${message}`, ...args); + }, + }; +} diff --git a/packages/sdk/browser/contract-tests/entity/src/style.css b/packages/sdk/browser/contract-tests/entity/src/style.css new file mode 100644 index 000000000..f9c735024 --- /dev/null +++ b/packages/sdk/browser/contract-tests/entity/src/style.css @@ -0,0 +1,96 @@ +:root { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +#app { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.vanilla:hover { + filter: drop-shadow(0 0 2em #3178c6aa); +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} diff --git a/packages/sdk/browser/contract-tests/entity/src/typescript.svg b/packages/sdk/browser/contract-tests/entity/src/typescript.svg new file mode 100644 index 000000000..d91c910cc --- /dev/null +++ b/packages/sdk/browser/contract-tests/entity/src/typescript.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/sdk/browser/contract-tests/entity/src/vite-env.d.ts b/packages/sdk/browser/contract-tests/entity/src/vite-env.d.ts new file mode 100644 index 000000000..11f02fe2a --- /dev/null +++ b/packages/sdk/browser/contract-tests/entity/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/sdk/browser/contract-tests/entity/tsconfig.eslint.json b/packages/sdk/browser/contract-tests/entity/tsconfig.eslint.json new file mode 100644 index 000000000..56c9b3830 --- /dev/null +++ b/packages/sdk/browser/contract-tests/entity/tsconfig.eslint.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/packages/sdk/browser/contract-tests/entity/tsconfig.json b/packages/sdk/browser/contract-tests/entity/tsconfig.json new file mode 100644 index 000000000..0511b9f0e --- /dev/null +++ b/packages/sdk/browser/contract-tests/entity/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/packages/sdk/browser/contract-tests/entity/tsconfig.ref.json b/packages/sdk/browser/contract-tests/entity/tsconfig.ref.json new file mode 100644 index 000000000..34a1cb607 --- /dev/null +++ b/packages/sdk/browser/contract-tests/entity/tsconfig.ref.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*", "package.json"], + "compilerOptions": { + "composite": true + } +} diff --git a/packages/sdk/browser/contract-tests/run-test-service.sh b/packages/sdk/browser/contract-tests/run-test-service.sh new file mode 100755 index 000000000..44ad8774a --- /dev/null +++ b/packages/sdk/browser/contract-tests/run-test-service.sh @@ -0,0 +1 @@ +yarn workspace browser-contract-test-adapter run start & yarn workspace browser-contract-test-service run start && kill $! diff --git a/packages/sdk/browser/contract-tests/suppressions.txt b/packages/sdk/browser/contract-tests/suppressions.txt new file mode 100644 index 000000000..30a7f7365 --- /dev/null +++ b/packages/sdk/browser/contract-tests/suppressions.txt @@ -0,0 +1,6 @@ +streaming/requests/method and headers/REPORT/http +streaming/requests/URL path is computed correctly/no environment filter/base URI has no trailing slash/REPORT +streaming/requests/URL path is computed correctly/no environment filter/base URI has a trailing slash/REPORT +streaming/requests/context properties/single kind minimal/REPORT +streaming/requests/context properties/single kind with all attributes/REPORT +streaming/requests/context properties/multi-kind/REPORT diff --git a/packages/sdk/browser/jest.config.json b/packages/sdk/browser/jest.config.json new file mode 100644 index 000000000..6d2e223cd --- /dev/null +++ b/packages/sdk/browser/jest.config.json @@ -0,0 +1,16 @@ +{ + "verbose": true, + "testEnvironment": "jest-environment-jsdom", + "testPathIgnorePatterns": ["./dist", "./src"], + "testMatch": ["**.test.ts"], + "setupFiles": ["./setup-jest.js"], + "transform": { + "^.+\\.ts$": [ + "ts-jest", + { + "tsConfig": "tsconfig.test.json" + } + ], + "^.+.tsx?$": ["ts-jest", {}] + } +} diff --git a/packages/sdk/browser/package.json b/packages/sdk/browser/package.json index 066309d7b..8f66ed8d7 100644 --- a/packages/sdk/browser/package.json +++ b/packages/sdk/browser/package.json @@ -1,6 +1,6 @@ { "name": "@launchdarkly/js-client-sdk", - "version": "0.0.0", + "version": "0.2.0", "description": "LaunchDarkly SDK for JavaScript in Browsers", "homepage": "https://github.com/launchdarkly/js-core/tree/main/packages/sdk/browser", "repository": { @@ -16,30 +16,49 @@ "feature management", "sdk" ], + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", "exports": { - "types": "./dist/src/index.d.ts", - "require": "./dist/index.cjs.js", - "import": "./dist/index.es.js" + ".": { + "require": { + "types": "./dist/index.d.cts", + "require": "./dist/index.cjs" + }, + "import": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "./compat": { + "require": { + "types": "./dist/compat.d.cts", + "require": "./dist/compat.cjs" + }, + "import": { + "types": "./dist/compat.d.ts", + "import": "./dist/compat.js" + } + } }, - "type": "module", "files": [ "dist" ], "scripts": { "clean": "rimraf dist", - "build": "vite build", + "build": "tsup", "lint": "eslint . --ext .ts,.tsx", "prettier": "prettier --write '**/*.@(js|ts|tsx|json|css)' --ignore-path ../../../.prettierignore", - "test": "jest", + "test": "npx jest --runInBand", "coverage": "yarn test --coverage", "check": "yarn prettier && yarn lint && yarn build && yarn test" }, "dependencies": { - "@launchdarkly/js-client-sdk-common": "1.5.0" + "@launchdarkly/js-client-sdk-common": "1.11.0" }, "devDependencies": { - "@launchdarkly/private-js-mocks": "0.0.1", - "@testing-library/react": "^14.1.2", + "@jest/globals": "^29.7.0", "@trivago/prettier-plugin-sort-imports": "^4.1.1", "@types/jest": "^29.5.11", "@typescript-eslint/eslint-plugin": "^6.20.0", @@ -52,12 +71,12 @@ "eslint-plugin-jest": "^27.6.3", "eslint-plugin-prettier": "^5.0.0", "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", "prettier": "^3.0.0", "rimraf": "^5.0.5", "ts-jest": "^29.1.1", + "tsup": "^8.3.5", "typedoc": "0.25.0", - "typescript": "^5.5.3", - "vite": "^5.4.1", - "vite-plugin-dts": "^4.0.3" + "typescript": "^5.5.3" } } diff --git a/packages/sdk/browser/setup-jest.js b/packages/sdk/browser/setup-jest.js new file mode 100644 index 000000000..e17ac62cb --- /dev/null +++ b/packages/sdk/browser/setup-jest.js @@ -0,0 +1,24 @@ +const { TextEncoder, TextDecoder } = require('node:util'); +const crypto = require('node:crypto'); + +global.TextEncoder = TextEncoder; + +Object.assign(window, { TextDecoder, TextEncoder }); + +// Based on: +// https://stackoverflow.com/a/71750830 + +Object.defineProperty(global.self, 'crypto', { + value: { + getRandomValues: (arr) => crypto.randomBytes(arr.length), + subtle: { + digest: (algorithm, data) => { + return new Promise((resolve) => + resolve( + crypto.createHash(algorithm.toLowerCase().replace('-', '')).update(data).digest(), + ), + ); + }, + }, + }, +}); diff --git a/packages/sdk/browser/src/BrowserApi.ts b/packages/sdk/browser/src/BrowserApi.ts new file mode 100644 index 000000000..a9c233b7a --- /dev/null +++ b/packages/sdk/browser/src/BrowserApi.ts @@ -0,0 +1,118 @@ +/** + * All access to browser specific APIs should be limited to this file. + * Care should be taken to ensure that any given method will work in the service worker API. So if + * something isn't available in the service worker API attempt to provide reasonable defaults. + */ + +export function isDocument() { + return typeof document !== undefined; +} + +export function isWindow() { + return typeof window !== undefined; +} + +/** + * Register an event handler on the document. If there is no document, such as when running in + * a service worker, then no operation is performed. + * + * @param type The event type to register a handler for. + * @param listener The handler to register. + * @param options Event registration options. + * @returns a function which unregisters the handler. + */ +export function addDocumentEventListener( + type: string, + listener: (this: Document, ev: Event) => any, + options?: boolean | AddEventListenerOptions, +): () => void { + if (isDocument()) { + document.addEventListener(type, listener, options); + return () => { + document.removeEventListener(type, listener, options); + }; + } + // No document, so no need to unregister anything. + return () => {}; +} + +/** + * Register an event handler on the window. If there is no window, such as when running in + * a service worker, then no operation is performed. + * + * @param type The event type to register a handler for. + * @param listener The handler to register. + * @param options Event registration options. + * @returns a function which unregisters the handler. + */ +export function addWindowEventListener( + type: string, + listener: (this: Document, ev: Event) => any, + options?: boolean | AddEventListenerOptions, +): () => void { + if (isDocument()) { + window.addEventListener(type, listener, options); + return () => { + window.removeEventListener(type, listener, options); + }; + } + // No document, so no need to unregister anything. + return () => {}; +} + +/** + * For non-window code this will always be an empty string. + */ +export function getHref(): string { + if (isWindow()) { + return window.location.href; + } + return ''; +} + +/** + * For non-window code this will always be an empty string. + */ +export function getLocationSearch(): string { + if (isWindow()) { + return window.location.search; + } + return ''; +} + +/** + * For non-window code this will always be an empty string. + */ +export function getLocationHash(): string { + if (isWindow()) { + return window.location.hash; + } + return ''; +} + +export function getCrypto(): Crypto { + if (typeof crypto !== undefined) { + return crypto; + } + // This would indicate running in an environment that doesn't have window.crypto or self.crypto. + throw Error('Access to a web crypto API is required'); +} + +/** + * Get the visibility state. For non-documents this will always be 'invisible'. + * + * @returns The document visibility. + */ +export function getVisibility(): string { + if (isDocument()) { + return document.visibilityState; + } + return 'visibile'; +} + +export function querySelectorAll(selector: string): NodeListOf | undefined { + if (isDocument()) { + return document.querySelectorAll(selector); + } + return undefined; +} diff --git a/packages/sdk/browser/src/BrowserClient.ts b/packages/sdk/browser/src/BrowserClient.ts new file mode 100644 index 000000000..0ef3490e9 --- /dev/null +++ b/packages/sdk/browser/src/BrowserClient.ts @@ -0,0 +1,265 @@ +import { + AutoEnvAttributes, + base64UrlEncode, + BasicLogger, + LDClient as CommonClient, + Configuration, + Encoding, + FlagManager, + internal, + LDClientImpl, + LDContext, + LDEmitter, + LDEmitterEventName, + LDHeaders, + Platform, +} from '@launchdarkly/js-client-sdk-common'; + +import { getHref } from './BrowserApi'; +import BrowserDataManager from './BrowserDataManager'; +import { BrowserIdentifyOptions as LDIdentifyOptions } from './BrowserIdentifyOptions'; +import { registerStateDetection } from './BrowserStateDetector'; +import GoalManager from './goals/GoalManager'; +import { Goal, isClick } from './goals/Goals'; +import validateOptions, { BrowserOptions, filterToBaseOptions } from './options'; +import BrowserPlatform from './platform/BrowserPlatform'; + +/** + * + * The LaunchDarkly SDK client object. + * + * Applications should configure the client at page load time and reuse the same instance. + * + * For more information, see the [SDK Reference Guide](https://docs.launchdarkly.com/sdk/client-side/javascript). + */ +export type LDClient = Omit< + CommonClient, + 'setConnectionMode' | 'getConnectionMode' | 'getOffline' | 'identify' +> & { + /** + * @ignore + * Implementation Note: We are not supporting dynamically setting the connection mode on the LDClient. + * Implementation Note: The SDK does not support offline mode. Instead bootstrap data can be used. + * Implementation Note: The browser SDK has different identify options, so omits the base implementation + * from the interface. + */ + + /** + * Specifies whether or not to open a streaming connection to LaunchDarkly for live flag updates. + * + * If this is true, the client will always attempt to maintain a streaming connection; if false, + * it never will. If you leave the value undefined (the default), the client will open a streaming + * connection if you subscribe to `"change"` or `"change:flag-key"` events (see {@link LDClient.on}). + * + * This can also be set as the `streaming` property of {@link LDOptions}. + */ + setStreaming(streaming?: boolean): void; + + /** + * Identifies a context to LaunchDarkly. + * + * Unlike the server-side SDKs, the client-side JavaScript SDKs maintain a current context state, + * which is set when you call `identify()`. + * + * Changing the current context also causes all feature flag values to be reloaded. Until that has + * finished, calls to {@link variation} will still return flag values for the previous context. You can + * await the Promise to determine when the new flag values are available. + * + * @param context + * The LDContext object. + * @param identifyOptions + * Optional configuration. Please see {@link LDIdentifyOptions}. + * @returns + * A Promise which resolves when the flag values for the specified + * context are available. It rejects when: + * + * 1. The context is unspecified or has no key. + * + * 2. The identify timeout is exceeded. In client SDKs this defaults to 5s. + * You can customize this timeout with {@link LDIdentifyOptions | identifyOptions}. + * + * 3. A network error is encountered during initialization. + * + * @ignore Implementation Note: Browser implementation has different options. + */ + identify(context: LDContext, identifyOptions?: LDIdentifyOptions): Promise; +}; + +export class BrowserClient extends LDClientImpl implements LDClient { + private readonly _goalManager?: GoalManager; + + constructor( + clientSideId: string, + autoEnvAttributes: AutoEnvAttributes, + options: BrowserOptions = {}, + overridePlatform?: Platform, + ) { + const { logger: customLogger, debug } = options; + // Overrides the default logger from the common implementation. + const logger = + customLogger ?? + new BasicLogger({ + destination: { + // eslint-disable-next-line no-console + debug: console.debug, + // eslint-disable-next-line no-console + info: console.info, + // eslint-disable-next-line no-console + warn: console.warn, + // eslint-disable-next-line no-console + error: console.error, + }, + level: debug ? 'debug' : 'info', + }); + + // TODO: Use the already-configured baseUri from the SDK config. SDK-560 + const baseUrl = options.baseUri ?? 'https://clientsdk.launchdarkly.com'; + + const platform = overridePlatform ?? new BrowserPlatform(logger); + const validatedBrowserOptions = validateOptions(options, logger); + const { eventUrlTransformer } = validatedBrowserOptions; + super( + clientSideId, + autoEnvAttributes, + platform, + filterToBaseOptions({ ...options, logger }), + ( + flagManager: FlagManager, + configuration: Configuration, + baseHeaders: LDHeaders, + emitter: LDEmitter, + diagnosticsManager?: internal.DiagnosticsManager, + ) => + new BrowserDataManager( + platform, + flagManager, + clientSideId, + configuration, + validatedBrowserOptions, + () => ({ + pathGet(encoding: Encoding, _plainContextString: string): string { + return `/sdk/evalx/${clientSideId}/contexts/${base64UrlEncode(_plainContextString, encoding)}`; + }, + pathReport(_encoding: Encoding, _plainContextString: string): string { + return `/sdk/evalx/${clientSideId}/context`; + }, + pathPing(_encoding: Encoding, _plainContextString: string): string { + // Note: if you are seeing this error, it is a coding error. This DataSourcePaths implementation is for polling endpoints. /ping is not currently + // used in a polling situation. It is probably the case that this was called by streaming logic erroneously. + throw new Error('Ping for polling unsupported.'); + }, + }), + () => ({ + pathGet(encoding: Encoding, _plainContextString: string): string { + return `/eval/${clientSideId}/${base64UrlEncode(_plainContextString, encoding)}`; + }, + pathReport(_encoding: Encoding, _plainContextString: string): string { + return `/eval/${clientSideId}`; + }, + pathPing(_encoding: Encoding, _plainContextString: string): string { + return `/ping/${clientSideId}`; + }, + }), + baseHeaders, + emitter, + diagnosticsManager, + ), + { + analyticsEventPath: `/events/bulk/${clientSideId}`, + diagnosticEventPath: `/events/diagnostic/${clientSideId}`, + includeAuthorizationHeader: false, + highTimeoutThreshold: 5, + userAgentHeaderName: 'x-launchdarkly-user-agent', + trackEventModifier: (event: internal.InputCustomEvent) => + new internal.InputCustomEvent( + event.context, + event.key, + event.data, + event.metricValue, + event.samplingRatio, + eventUrlTransformer(getHref()), + ), + }, + ); + + this.setEventSendingEnabled(true, false); + + if (validatedBrowserOptions.fetchGoals) { + this._goalManager = new GoalManager( + clientSideId, + platform.requests, + baseUrl, + (err) => { + // TODO: May need to emit. SDK-561 + logger.error(err.message); + }, + (url: string, goal: Goal) => { + const context = this.getInternalContext(); + if (!context) { + return; + } + const transformedUrl = eventUrlTransformer(url); + if (isClick(goal)) { + this.sendEvent({ + kind: 'click', + url: transformedUrl, + samplingRatio: 1, + key: goal.key, + creationDate: Date.now(), + context, + selector: goal.selector, + }); + } else { + this.sendEvent({ + kind: 'pageview', + url: transformedUrl, + samplingRatio: 1, + key: goal.key, + creationDate: Date.now(), + context, + }); + } + }, + ); + + // This is intentionally not awaited. If we want to add a "goalsready" event, or + // "waitForGoalsReady", then we would make an async immediately invoked function expression + // which emits the event, and assign its promise to a member. The "waitForGoalsReady" function + // would return that promise. + this._goalManager.initialize(); + + if (validatedBrowserOptions.automaticBackgroundHandling) { + registerStateDetection(() => this.flush()); + } + } + } + + override async identify(context: LDContext, identifyOptions?: LDIdentifyOptions): Promise { + await super.identify(context, identifyOptions); + this._goalManager?.startTracking(); + } + + setStreaming(streaming?: boolean): void { + // With FDv2 we may want to consider if we support connection mode directly. + // Maybe with an extension to connection mode for 'automatic'. + const browserDataManager = this.dataManager as BrowserDataManager; + browserDataManager.setForcedStreaming(streaming); + } + + private _updateAutomaticStreamingState() { + const browserDataManager = this.dataManager as BrowserDataManager; + // This will need changed if support for listening to individual flag change + // events it added. + browserDataManager.setAutomaticStreamingState(!!this.emitter.listenerCount('change')); + } + + override on(eventName: LDEmitterEventName, listener: Function): void { + super.on(eventName, listener); + this._updateAutomaticStreamingState(); + } + + override off(eventName: LDEmitterEventName, listener: Function): void { + super.off(eventName, listener); + this._updateAutomaticStreamingState(); + } +} diff --git a/packages/sdk/browser/src/BrowserDataManager.ts b/packages/sdk/browser/src/BrowserDataManager.ts new file mode 100644 index 000000000..00f777f5e --- /dev/null +++ b/packages/sdk/browser/src/BrowserDataManager.ts @@ -0,0 +1,235 @@ +import { + BaseDataManager, + Configuration, + Context, + DataSourceErrorKind, + DataSourcePaths, + DataSourceState, + FlagManager, + internal, + LDEmitter, + LDHeaders, + LDIdentifyOptions, + makeRequestor, + Platform, +} from '@launchdarkly/js-client-sdk-common'; + +import { readFlagsFromBootstrap } from './bootstrap'; +import { BrowserIdentifyOptions } from './BrowserIdentifyOptions'; +import { ValidatedOptions } from './options'; + +const logTag = '[BrowserDataManager]'; + +export default class BrowserDataManager extends BaseDataManager { + // If streaming is forced on or off, then we follow that setting. + // Otherwise we automatically manage streaming state. + private _forcedStreaming?: boolean = undefined; + private _automaticStreamingState: boolean = false; + private _secureModeHash?: string; + + // +-----------+-----------+---------------+ + // | forced | automatic | state | + // +-----------+-----------+---------------+ + // | true | false | streaming | + // | true | true | streaming | + // | false | true | not streaming | + // | false | false | not streaming | + // | undefined | true | streaming | + // | undefined | false | not streaming | + // +-----------+-----------+---------------+ + + constructor( + platform: Platform, + flagManager: FlagManager, + credential: string, + config: Configuration, + private readonly _browserConfig: ValidatedOptions, + getPollingPaths: () => DataSourcePaths, + getStreamingPaths: () => DataSourcePaths, + baseHeaders: LDHeaders, + emitter: LDEmitter, + diagnosticsManager?: internal.DiagnosticsManager, + ) { + super( + platform, + flagManager, + credential, + config, + getPollingPaths, + getStreamingPaths, + baseHeaders, + emitter, + diagnosticsManager, + ); + this._forcedStreaming = _browserConfig.streaming; + } + + private _debugLog(message: any, ...args: any[]) { + this.logger.debug(`${logTag} ${message}`, ...args); + } + + override async identify( + identifyResolve: () => void, + identifyReject: (err: Error) => void, + context: Context, + identifyOptions?: LDIdentifyOptions, + ): Promise { + this.context = context; + const browserIdentifyOptions = identifyOptions as BrowserIdentifyOptions | undefined; + if (browserIdentifyOptions?.hash) { + this.setConnectionParams({ + queryParameters: [{ key: 'h', value: browserIdentifyOptions.hash }], + }); + } else { + this.setConnectionParams(); + } + this._secureModeHash = browserIdentifyOptions?.hash; + + if (browserIdentifyOptions?.bootstrap) { + this._finishIdentifyFromBootstrap(context, browserIdentifyOptions.bootstrap, identifyResolve); + } else { + if (await this.flagManager.loadCached(context)) { + this._debugLog('Identify - Flags loaded from cache. Continuing to initialize via a poll.'); + } + + await this._finishIdentifyFromPoll(context, identifyResolve, identifyReject); + } + this._updateStreamingState(); + } + + private async _finishIdentifyFromPoll( + context: Context, + identifyResolve: () => void, + identifyReject: (err: Error) => void, + ) { + try { + this.dataSourceStatusManager.requestStateUpdate(DataSourceState.Initializing); + + const plainContextString = JSON.stringify(Context.toLDContext(context)); + const pollingRequestor = makeRequestor( + plainContextString, + this.config.serviceEndpoints, + this.getPollingPaths(), + this.platform.requests, + this.platform.encoding!, + this.baseHeaders, + [], + this.config.withReasons, + this.config.useReport, + this._secureModeHash, + ); + + const payload = await pollingRequestor.requestPayload(); + try { + const listeners = this.createStreamListeners(context, identifyResolve); + const putListener = listeners.get('put'); + putListener!.processJson(putListener!.deserializeData(payload)); + } catch (e: any) { + this.dataSourceStatusManager.reportError( + DataSourceErrorKind.InvalidData, + e.message ?? 'Could not parse poll response', + ); + } + } catch (e: any) { + this.dataSourceStatusManager.reportError( + DataSourceErrorKind.NetworkError, + e.message ?? 'unexpected network error', + e.status, + ); + identifyReject(e); + } + } + + private _finishIdentifyFromBootstrap( + context: Context, + bootstrap: unknown, + identifyResolve: () => void, + ) { + this.flagManager.setBootstrap(context, readFlagsFromBootstrap(this.logger, bootstrap)); + this._debugLog('Identify - Initialization completed from bootstrap'); + identifyResolve(); + } + + setForcedStreaming(streaming?: boolean) { + this._forcedStreaming = streaming; + this._updateStreamingState(); + } + + setAutomaticStreamingState(streaming: boolean) { + this._automaticStreamingState = streaming; + this._updateStreamingState(); + } + + private _updateStreamingState() { + const shouldBeStreaming = + this._forcedStreaming || + (this._automaticStreamingState && this._forcedStreaming === undefined); + + this._debugLog( + `Updating streaming state. forced(${this._forcedStreaming}) automatic(${this._automaticStreamingState})`, + ); + + if (shouldBeStreaming) { + this._startDataSource(); + } else { + this._stopDataSource(); + } + } + + private _stopDataSource() { + if (this.updateProcessor) { + this._debugLog('Stopping update processor.'); + } + this.updateProcessor?.close(); + this.updateProcessor = undefined; + } + + private _startDataSource() { + if (this.updateProcessor) { + this._debugLog('Update processor already active. Not changing state.'); + return; + } + + if (!this.context) { + this._debugLog('Context not set, not starting update processor.'); + return; + } + + this._debugLog('Starting update processor.'); + this._setupConnection(this.context); + } + + private _setupConnection( + context: Context, + identifyResolve?: () => void, + identifyReject?: (err: Error) => void, + ) { + const rawContext = Context.toLDContext(context)!; + + this.updateProcessor?.close(); + + const plainContextString = JSON.stringify(Context.toLDContext(context)); + const pollingRequestor = makeRequestor( + plainContextString, + this.config.serviceEndpoints, + this.getPollingPaths(), + this.platform.requests, + this.platform.encoding!, + this.baseHeaders, + [], + this.config.withReasons, + this.config.useReport, + this._secureModeHash, + ); + + this.createStreamingProcessor( + rawContext, + context, + pollingRequestor, + identifyResolve, + identifyReject, + ); + + this.updateProcessor!.start(); + } +} diff --git a/packages/sdk/browser/src/BrowserIdentifyOptions.ts b/packages/sdk/browser/src/BrowserIdentifyOptions.ts new file mode 100644 index 000000000..231b49905 --- /dev/null +++ b/packages/sdk/browser/src/BrowserIdentifyOptions.ts @@ -0,0 +1,24 @@ +import { LDIdentifyOptions } from '@launchdarkly/js-client-sdk-common'; + +export interface BrowserIdentifyOptions extends Omit { + /** + * The signed context key if you are using [Secure Mode] + * (https://docs.launchdarkly.com/sdk/features/secure-mode#configuring-secure-mode-in-the-javascript-client-side-sdk). + */ + hash?: string; + + /** + * The initial set of flags to use until the remote set is retrieved. + * + * Bootstrap data can be generated by server SDKs. When bootstrap data is provided the SDK the + * identification operation will complete without waiting for any values from LaunchDarkly and + * the variation calls can be used immediately. + * + * If streaming is activated, either it is configured to always be used, or is activated + * via setStreaming, or via the addition of change handlers, then a streaming connection will + * subsequently be established. + * + * For more information, see the [SDK Reference Guide](https://docs.launchdarkly.com/sdk/features/bootstrapping#javascript). + */ + bootstrap?: unknown; +} diff --git a/packages/sdk/browser/src/BrowserStateDetector.ts b/packages/sdk/browser/src/BrowserStateDetector.ts new file mode 100644 index 000000000..f554a8420 --- /dev/null +++ b/packages/sdk/browser/src/BrowserStateDetector.ts @@ -0,0 +1,27 @@ +import { addDocumentEventListener, addWindowEventListener, getVisibility } from './BrowserApi'; + +export function registerStateDetection(requestFlush: () => void): () => void { + // When the visibility of the page changes to hidden we want to flush any pending events. + // + // This is handled with visibility, instead of beforeunload/unload + // because those events are not compatible with the bfcache and are unlikely + // to be called in many situations. For more information see: https://developer.chrome.com/blog/page-lifecycle-api/ + // + // Redundancy is included by using both the visibilitychange handler as well as + // pagehide, because different browsers, and versions have different bugs with each. + // This also may provide more opportunity for the events to get flushed. + // + const handleVisibilityChange = () => { + if (getVisibility() === 'hidden') { + requestFlush(); + } + }; + + const removeDocListener = addDocumentEventListener('visibilitychange', handleVisibilityChange); + const removeWindowListener = addWindowEventListener('pagehide', requestFlush); + + return () => { + removeDocListener(); + removeWindowListener(); + }; +} diff --git a/packages/sdk/browser/src/bootstrap.ts b/packages/sdk/browser/src/bootstrap.ts new file mode 100644 index 000000000..009986364 --- /dev/null +++ b/packages/sdk/browser/src/bootstrap.ts @@ -0,0 +1,47 @@ +import { Flag, ItemDescriptor, LDLogger } from '@launchdarkly/js-client-sdk-common'; + +export function readFlagsFromBootstrap( + logger: LDLogger, + data: any, +): { [key: string]: ItemDescriptor } { + // If the bootstrap data came from an older server-side SDK, we'll have just a map of keys to values. + // Newer SDKs that have an allFlagsState method will provide an extra "$flagsState" key that contains + // the rest of the metadata we want. We do it this way for backward compatibility with older JS SDKs. + const keys = Object.keys(data); + const metadataKey = '$flagsState'; + const validKey = '$valid'; + const metadata = data[metadataKey]; + if (!metadata && keys.length) { + logger.warn( + 'LaunchDarkly client was initialized with bootstrap data that did not include flag' + + ' metadata. Events may not be sent correctly.', + ); + } + if (data[validKey] === false) { + logger.warn( + 'LaunchDarkly bootstrap data is not available because the back end could not read the flags.', + ); + } + const ret: { [key: string]: ItemDescriptor } = {}; + keys.forEach((key) => { + if (key !== metadataKey && key !== validKey) { + let flag: Flag; + if (metadata && metadata[key]) { + flag = { + value: data[key], + ...metadata[key], + }; + } else { + flag = { + value: data[key], + version: 0, + }; + } + ret[key] = { + version: flag.version, + flag, + }; + } + }); + return ret; +} diff --git a/packages/sdk/browser/src/compat/LDClientCompat.ts b/packages/sdk/browser/src/compat/LDClientCompat.ts new file mode 100644 index 000000000..670bc4130 --- /dev/null +++ b/packages/sdk/browser/src/compat/LDClientCompat.ts @@ -0,0 +1,142 @@ +import { LDContext, LDFlagSet } from '@launchdarkly/js-client-sdk-common'; + +import { LDClient as LDCLientBrowser } from '../BrowserClient'; + +/** + * Compatibility interface. This interface extends the base LDCLient interface with functions + * that improve backwards compatibility. + * + * If starting a new project please import the root package instead of `/compat`. + * + * In the `launchdarkly-js-client-sdk@3.x` package a number of functions had the return typings + * incorrect. Any function which optionally returned a promise based on a callback had incorrect + * typings. Those have been corrected in this implementation. + */ +export interface LDClient extends Omit { + /** + * Identifies a context to LaunchDarkly. + * + * Unlike the server-side SDKs, the client-side JavaScript SDKs maintain a current context state, + * which is set at initialization time. You only need to call `identify()` if the context has changed + * since then. + * + * Changing the current context also causes all feature flag values to be reloaded. Until that has + * finished, calls to {@link variation} will still return flag values for the previous context. You can + * use a callback or a Promise to determine when the new flag values are available. + * + * @param context + * The context properties. Must contain at least the `key` property. + * @param hash + * The signed context key if you are using [Secure Mode](https://docs.launchdarkly.com/sdk/features/secure-mode#configuring-secure-mode-in-the-javascript-client-side-sdk). + * @param onDone + * A function which will be called as soon as the flag values for the new context are available, + * with two parameters: an error value (if any), and an {@link LDFlagSet} containing the new values + * (which can also be obtained by calling {@link variation}). If the callback is omitted, you will + * receive a Promise instead. + * @returns + * If you provided a callback, then nothing. Otherwise, a Promise which resolve once the flag + * values for the new context are available, providing an {@link LDFlagSet} containing the new values + * (which can also be obtained by calling {@link variation}). + */ + identify( + context: LDContext, + hash?: string, + onDone?: (err: Error | null, flags: LDFlagSet | null) => void, + ): Promise | undefined; + + /** + * Returns a Promise that tracks the client's initialization state. + * + * The Promise will be resolved if the client successfully initializes, or rejected if client + * initialization has irrevocably failed (for instance, if it detects that the SDK key is invalid). + * + * ``` + * // using async/await + * try { + * await client.waitForInitialization(5); + * doSomethingWithSuccessfullyInitializedClient(); + * } catch (err) { + * doSomethingForFailedStartup(err); + * } + * ``` + * + * It is important that you handle the rejection case; otherwise it will become an unhandled Promise + * rejection, which is a serious error on some platforms. The Promise is not created unless you + * request it, so if you never call `waitForInitialization()` then you do not have to worry about + * unhandled rejections. + * + * Note that you can also use event listeners ({@link on}) for the same purpose: the event `"initialized"` + * indicates success, and `"failed"` indicates failure. + * + * @param timeout + * The amount of time, in seconds, to wait for initialization before rejecting the promise. + * Using a large timeout is not recommended. If you use a large timeout and await it, then + * any network delays will cause your application to wait a long time before + * continuing execution. + * + * If no timeout is specified, then the returned promise will only be resolved when the client + * successfully initializes or initialization fails. + * + * @returns + * A Promise that will be resolved if the client initializes successfully, or rejected if it + * fails or the specified timeout elapses. + */ + waitForInitialization(timeout?: number): Promise; + + /** + * Returns a Promise that tracks the client's initialization state. + * + * The returned Promise will be resolved once the client has either successfully initialized + * or failed to initialize (e.g. due to an invalid environment key or a server error). It will + * never be rejected. + * + * ``` + * // using async/await + * await client.waitUntilReady(); + * doSomethingWithClient(); + * ``` + * + * If you want to distinguish between these success and failure conditions, use + * {@link waitForInitialization} instead. + * + * If you prefer to use event listeners ({@link on}) rather than Promises, you can listen on the + * client for a `"ready"` event, which will be fired in either case. + * + * @returns + * A Promise that will be resolved once the client is no longer trying to initialize. + * @deprecated Please use {@link waitForInitialization} instead. This method will always + * cause a warning to be logged because it is implemented via waitForInitialization. + */ + waitUntilReady(): Promise; + + /** + * Shuts down the client and releases its resources, after delivering any pending analytics + * events. + * + * @param onDone + * A function which will be called when the operation completes. If omitted, you + * will receive a Promise instead. + * + * @returns + * If you provided a callback, then nothing. Otherwise, a Promise which resolves once + * closing is finished. It will never be rejected. + */ + close(onDone?: () => void): Promise | undefined; + + /** + * Flushes all pending analytics events. + * + * Normally, batches of events are delivered in the background at intervals determined by the + * `flushInterval` property of {@link LDOptions}. Calling `flush()` triggers an immediate delivery. + * + * @param onDone + * A function which will be called when the flush completes. If omitted, you + * will receive a Promise instead. + * + * @returns + * If you provided a callback, then nothing. Otherwise, a Promise which resolves once + * flushing is finished. Note that the Promise will be rejected if the HTTP request + * fails, so be sure to attach a rejection handler to it. + */ + flush(onDone?: () => void): Promise | undefined; +} diff --git a/packages/sdk/browser/src/compat/LDClientCompatImpl.ts b/packages/sdk/browser/src/compat/LDClientCompatImpl.ts new file mode 100644 index 000000000..9014b94a4 --- /dev/null +++ b/packages/sdk/browser/src/compat/LDClientCompatImpl.ts @@ -0,0 +1,242 @@ +// TODO may or may not need this. +import { + AutoEnvAttributes, + cancelableTimedPromise, + Hook, + LDContext, + LDEvaluationDetail, + LDEvaluationDetailTyped, + LDFlagSet, + LDFlagValue, + LDLogger, + LDTimeoutError, +} from '@launchdarkly/js-client-sdk-common'; + +import { BrowserClient } from '../BrowserClient'; +import { LDClient } from './LDClientCompat'; +import { LDOptions } from './LDCompatOptions'; +import LDEmitterCompat, { CompatEventName } from './LDEmitterCompat'; +import { wrapPromiseCallback } from './wrapPromiseCallback'; + +export default class LDClientCompatImpl implements LDClient { + private _client: BrowserClient; + public readonly logger: LDLogger; + + private _initResolve?: () => void; + + private _initReject?: (err: Error) => void; + + private _rejectionReason: Error | undefined; + + private _initializedPromise?: Promise; + + private _initState: 'success' | 'failure' | 'initializing' = 'initializing'; + + private _emitter: LDEmitterCompat; + + constructor(envKey: string, context: LDContext, options?: LDOptions) { + const bootstrap = options?.bootstrap; + const hash = options?.hash; + + const cleanedOptions = { ...options }; + delete cleanedOptions.bootstrap; + delete cleanedOptions.hash; + this._client = new BrowserClient(envKey, AutoEnvAttributes.Disabled, options); + this._emitter = new LDEmitterCompat(this._client); + this.logger = this._client.logger; + this._initIdentify(context, bootstrap, hash); + } + + private async _initIdentify( + context: LDContext, + bootstrap?: LDFlagSet, + hash?: string, + ): Promise { + try { + await this._client.identify(context, { noTimeout: true, bootstrap, hash }); + this._initState = 'success'; + this._initResolve?.(); + this._emitter.emit('initialized'); + } catch (err) { + this._rejectionReason = err as Error; + this._initState = 'failure'; + this._initReject?.(err as Error); + this._emitter.emit('failed', err); + } + // Ready will always be emitted in addition to either 'initialized' or 'failed'. + this._emitter.emit('ready'); + } + + allFlags(): LDFlagSet { + return this._client.allFlags(); + } + + boolVariation(key: string, defaultValue: boolean): boolean { + return this._client.boolVariation(key, defaultValue); + } + + boolVariationDetail(key: string, defaultValue: boolean): LDEvaluationDetailTyped { + return this._client.boolVariationDetail(key, defaultValue); + } + + jsonVariation(key: string, defaultValue: unknown): unknown { + return this._client.jsonVariation(key, defaultValue); + } + + jsonVariationDetail(key: string, defaultValue: unknown): LDEvaluationDetailTyped { + return this._client.jsonVariationDetail(key, defaultValue); + } + + numberVariation(key: string, defaultValue: number): number { + return this._client.numberVariation(key, defaultValue); + } + + numberVariationDetail(key: string, defaultValue: number): LDEvaluationDetailTyped { + return this._client.numberVariationDetail(key, defaultValue); + } + + off(key: CompatEventName, callback: (...args: any[]) => void): void { + this._emitter.off(key, callback); + } + + on(key: CompatEventName, callback: (...args: any[]) => void): void { + this._emitter.on(key, callback); + } + + stringVariation(key: string, defaultValue: string): string { + return this._client.stringVariation(key, defaultValue); + } + + stringVariationDetail(key: string, defaultValue: string): LDEvaluationDetailTyped { + return this._client.stringVariationDetail(key, defaultValue); + } + + track(key: string, data?: any, metricValue?: number): void { + this._client.track(key, data, metricValue); + } + + variation(key: string, defaultValue?: LDFlagValue) { + return this._client.variation(key, defaultValue); + } + + variationDetail(key: string, defaultValue?: LDFlagValue): LDEvaluationDetail { + return this._client.variationDetail(key, defaultValue); + } + + addHook(hook: Hook): void { + this._client.addHook(hook); + } + + setStreaming(streaming?: boolean): void { + this._client.setStreaming(streaming); + } + + identify( + context: LDContext, + hash?: string, + onDone?: (err: Error | null, flags: LDFlagSet | null) => void, + ): Promise | undefined { + return wrapPromiseCallback( + this._client.identify(context, { hash }).then(() => this.allFlags()), + onDone, + ) as Promise | undefined; + // The typing here is a little funny. The wrapPromiseCallback can technically return + // `Promise`, but in the case where it would resolve to undefined we are not + // actually using the promise, because it means a callback was specified. + } + + close(onDone?: () => void): Promise | undefined { + return wrapPromiseCallback(this._client.close().then(), onDone); + } + + flush(onDone?: () => void): Promise | undefined { + // The .then() is to strip the return value making a void promise. + return wrapPromiseCallback( + this._client.flush().then(() => undefined), + onDone, + ); + } + + getContext(): LDContext | undefined { + return this._client.getContext(); + } + + waitForInitialization(timeout?: number): Promise { + // An initialization promise is only created if someone is going to use that promise. + // If we always created an initialization promise, and there was no call waitForInitialization + // by the time the promise was rejected, then that would result in an unhandled promise + // rejection. + + // It waitForInitialization was previously called, then we can use that promise even if it has + // been resolved or rejected. + if (this._initializedPromise) { + return this._promiseWithTimeout(this._initializedPromise, timeout); + } + + switch (this._initState) { + case 'success': + return Promise.resolve(); + case 'failure': + return Promise.reject(this._rejectionReason); + case 'initializing': + // Continue with the existing logic for creating and handling the promise + break; + default: + throw new Error( + 'Unexpected initialization state. This represents an implementation error in the SDK.', + ); + } + + if (timeout === undefined) { + this.logger?.warn( + 'The waitForInitialization function was called without a timeout specified.' + + ' In a future version a default timeout will be applied.', + ); + } + + if (!this._initializedPromise) { + this._initializedPromise = new Promise((resolve, reject) => { + this._initResolve = resolve; + this._initReject = reject; + }); + } + + return this._promiseWithTimeout(this._initializedPromise, timeout); + } + + async waitUntilReady(): Promise { + try { + await this.waitForInitialization(); + } catch { + // We do not care about the error. + } + } + + /** + * Apply a timeout promise to a base promise. This is for use with waitForInitialization. + * Currently it returns a LDClient. In the future it should return a status. + * + * The client isn't always the expected type of the consumer. It returns an LDClient interface + * which is less capable than, for example, the node client interface. + * + * @param basePromise The promise to race against a timeout. + * @param timeout The timeout in seconds. + * @param logger A logger to log when the timeout expires. + * @returns + */ + private _promiseWithTimeout(basePromise: Promise, timeout?: number): Promise { + if (timeout) { + const cancelableTimeout = cancelableTimedPromise(timeout, 'waitForInitialization'); + return Promise.race([ + basePromise.then(() => cancelableTimeout.cancel()), + cancelableTimeout.promise, + ]).catch((reason) => { + if (reason instanceof LDTimeoutError) { + this.logger?.error(reason.message); + } + throw reason; + }); + } + return basePromise; + } +} diff --git a/packages/sdk/browser/src/compat/LDCompatOptions.ts b/packages/sdk/browser/src/compat/LDCompatOptions.ts new file mode 100644 index 000000000..95235f5b0 --- /dev/null +++ b/packages/sdk/browser/src/compat/LDCompatOptions.ts @@ -0,0 +1,19 @@ +import { LDFlagSet } from '@launchdarkly/js-client-sdk-common'; + +import { BrowserOptions } from '../options'; + +export interface LDOptions extends BrowserOptions { + /** + * The initial set of flags to use until the remote set is retrieved. + * + * For more information, refer to the + * [SDK Reference Guide](https://docs.launchdarkly.com/sdk/features/bootstrapping#javascript). + */ + bootstrap?: LDFlagSet; + + /** + * The signed canonical context key, for the initial context, if you are using + * [Secure Mode](https://docs.launchdarkly.com/sdk/features/secure-mode#configuring-secure-mode-in-the-javascript-client-side-sdk). + */ + hash?: string; +} diff --git a/packages/sdk/browser/src/compat/LDEmitterCompat.ts b/packages/sdk/browser/src/compat/LDEmitterCompat.ts new file mode 100644 index 000000000..7684b1d46 --- /dev/null +++ b/packages/sdk/browser/src/compat/LDEmitterCompat.ts @@ -0,0 +1,79 @@ +import { LDEmitterEventName } from '@launchdarkly/js-client-sdk-common'; + +import { LDClient } from '../BrowserClient'; + +type CompatOnlyEvents = 'ready' | 'failed' | 'initialized'; +export type CompatEventName = LDEmitterEventName | CompatOnlyEvents; + +const COMPAT_EVENTS: string[] = ['ready', 'failed', 'initialized']; + +export default class LDEmitterCompat { + private _listeners: Map = new Map(); + + constructor(private readonly _client: LDClient) {} + + on(name: CompatEventName, listener: Function) { + if (COMPAT_EVENTS.includes(name)) { + if (!this._listeners.has(name)) { + this._listeners.set(name, [listener]); + } else { + this._listeners.get(name)?.push(listener); + } + } else { + this._client.on(name, listener as (...args: any[]) => void); + } + } + + /** + * Unsubscribe one or all events. + * + * @param name + * @param listener Optional. If unspecified, all listeners for the event will be removed. + */ + off(name: CompatEventName, listener?: Function) { + if (COMPAT_EVENTS.includes(name)) { + const existingListeners = this._listeners.get(name); + if (!existingListeners) { + return; + } + + if (listener) { + const updated = existingListeners.filter((fn) => fn !== listener); + if (updated.length === 0) { + this._listeners.delete(name); + } else { + this._listeners.set(name, updated); + } + return; + } + + // listener was not specified, so remove them all for that event + this._listeners.delete(name); + } else { + this._client.off(name, listener as (...args: any[]) => void); + } + } + + private _invokeListener(listener: Function, name: CompatEventName, ...detail: any[]) { + try { + listener(...detail); + } catch (err) { + this._client.logger.error( + `Encountered error invoking handler for "${name}", detail: "${err}"`, + ); + } + } + + emit(name: CompatEventName, ...detail: any[]) { + const listeners = this._listeners.get(name); + listeners?.forEach((listener) => this._invokeListener(listener, name, ...detail)); + } + + eventNames(): string[] { + return [...this._listeners.keys()]; + } + + listenerCount(name: CompatEventName): number { + return this._listeners.get(name)?.length ?? 0; + } +} diff --git a/packages/sdk/browser/src/compat/index.ts b/packages/sdk/browser/src/compat/index.ts new file mode 100644 index 000000000..73b0052a8 --- /dev/null +++ b/packages/sdk/browser/src/compat/index.ts @@ -0,0 +1,98 @@ +/** + * This module provides a compatibility layer which emulates the interface used + * in the launchdarkly-js-client 3.x package. + * + * Some code changes may still be required, for example {@link LDOptions} removes + * support for some previously available options. + */ +import { + basicLogger, + EvaluationSeriesContext, + EvaluationSeriesData, + Hook, + HookMetadata, + IdentifySeriesContext, + IdentifySeriesData, + IdentifySeriesResult, + IdentifySeriesStatus, + LDContext, + LDContextCommon, + LDContextMeta, + LDEvaluationDetail, + LDEvaluationDetailTyped, + LDEvaluationReason, + LDFlagSet, + LDIdentifyOptions, + LDLogger, + LDLogLevel, + LDMultiKindContext, + LDOptions, + LDSingleKindContext, +} from '..'; +import { LDClient } from './LDClientCompat'; +import LDClientCompatImpl from './LDClientCompatImpl'; + +export type { + LDClient, + LDFlagSet, + LDContext, + LDContextCommon, + LDContextMeta, + LDMultiKindContext, + LDSingleKindContext, + LDLogLevel, + LDLogger, + LDOptions, + LDEvaluationDetail, + LDEvaluationDetailTyped, + LDEvaluationReason, + LDIdentifyOptions, + Hook, + HookMetadata, + EvaluationSeriesContext, + EvaluationSeriesData, + IdentifySeriesContext, + IdentifySeriesData, + IdentifySeriesResult, + IdentifySeriesStatus, + basicLogger, +}; + +/** + * Creates an instance of the LaunchDarkly client. This version of initialization is for + * improved backwards compatibility. In general the `initialize` function from the root packge + * should be used instead of the one in the `/compat` module. + * + * The client will begin attempting to connect to LaunchDarkly as soon as it is created. To + * determine when it is ready to use, call {@link LDClient.waitForInitialization}, or register an + * event listener for the `"ready"` event using {@link LDClient.on}. + * + * Example: + * import { initialize } from '@launchdarkly/js-client-sdk/compat'; + * const client = initialize(envKey, context, options); + * + * Note: The `compat` module minimizes compatibility breaks, but not all functionality is directly + * equivalent to the previous version. + * + * LDOptions are where the primary differences are. By default the new SDK implementation will + * generally use localStorage to cache flags. This can be disabled by setting the + * `maxCachedContexts` to 0. + * + * This does allow combinations that were not possible before. For insance an initial context + * could be identified using bootstrap, and a second context without bootstrap, and the second + * context could cache flags in local storage. For more control the primary module can be used + * instead of this `compat` module (for instance bootstrap can be provided per identify call in + * the primary module). + * + * @param envKey + * The environment ID. + * @param context + * The initial context properties. These can be changed later with {@link LDClient.identify}. + * @param options + * Optional configuration settings. + * @return + * The new client instance. + */ +export function initialize(envKey: string, context: LDContext, options?: LDOptions): LDClient { + return new LDClientCompatImpl(envKey, context, options); +} diff --git a/packages/sdk/browser/src/compat/wrapPromiseCallback.ts b/packages/sdk/browser/src/compat/wrapPromiseCallback.ts new file mode 100644 index 000000000..efcbc13be --- /dev/null +++ b/packages/sdk/browser/src/compat/wrapPromiseCallback.ts @@ -0,0 +1,40 @@ +/** + * Wrap a promise to invoke an optional callback upon resolution or rejection. + * + * This function assumes the callback follows the Node.js callback type: (err, value) => void + * + * If a callback is provided: + * - if the promise is resolved, invoke the callback with (null, value) + * - if the promise is rejected, invoke the callback with (error, null) + * + * @param {Promise} promise + * @param {Function} callback + * @returns Promise | undefined + */ +export function wrapPromiseCallback( + promise: Promise, + callback?: (err: Error | null, res: T | null) => void, +): Promise | undefined { + const ret = promise.then( + (value) => { + if (callback) { + setTimeout(() => { + callback(null, value); + }, 0); + } + return value; + }, + (error) => { + if (callback) { + setTimeout(() => { + callback(error, null); + }, 0); + } else { + return Promise.reject(error); + } + return undefined; + }, + ); + + return !callback ? ret : undefined; +} diff --git a/packages/sdk/browser/src/goals/GoalManager.ts b/packages/sdk/browser/src/goals/GoalManager.ts new file mode 100644 index 000000000..1c44022b8 --- /dev/null +++ b/packages/sdk/browser/src/goals/GoalManager.ts @@ -0,0 +1,69 @@ +import { LDUnexpectedResponseError, Requests } from '@launchdarkly/js-client-sdk-common'; + +import { getHref } from '../BrowserApi'; +import { Goal } from './Goals'; +import GoalTracker from './GoalTracker'; +import { DefaultLocationWatcher, LocationWatcher } from './LocationWatcher'; + +export default class GoalManager { + private _goals?: Goal[] = []; + private _url: string; + private _watcher?: LocationWatcher; + private _tracker?: GoalTracker; + private _isTracking = false; + + constructor( + credential: string, + private readonly _requests: Requests, + baseUrl: string, + private readonly _reportError: (err: Error) => void, + private readonly _reportGoal: (url: string, goal: Goal) => void, + locationWatcherFactory: (cb: () => void) => LocationWatcher = (cb) => + new DefaultLocationWatcher(cb), + ) { + // TODO: Generate URL in a better way. + this._url = `${baseUrl}/sdk/goals/${credential}`; + + this._watcher = locationWatcherFactory(() => { + this._createTracker(); + }); + } + + public async initialize(): Promise { + await this._fetchGoals(); + // If tracking has been started before goal fetching completes, we need to + // create the tracker so it can start watching for events. + this._createTracker(); + } + + public startTracking() { + this._isTracking = true; + this._createTracker(); + } + + private _createTracker() { + if (!this._isTracking) { + return; + } + this._tracker?.close(); + if (this._goals && this._goals.length) { + this._tracker = new GoalTracker(this._goals, (goal) => { + this._reportGoal(getHref(), goal); + }); + } + } + + private async _fetchGoals(): Promise { + try { + const res = await this._requests.fetch(this._url); + this._goals = await res.json(); + } catch (err) { + this._reportError(new LDUnexpectedResponseError(`Encountered error fetching goals: ${err}`)); + } + } + + close(): void { + this._watcher?.close(); + this._tracker?.close(); + } +} diff --git a/packages/sdk/browser/src/goals/GoalTracker.ts b/packages/sdk/browser/src/goals/GoalTracker.ts new file mode 100644 index 000000000..bb5365d0e --- /dev/null +++ b/packages/sdk/browser/src/goals/GoalTracker.ts @@ -0,0 +1,104 @@ +import { + addDocumentEventListener, + getHref, + getLocationHash, + getLocationSearch, + querySelectorAll, +} from '../BrowserApi'; +import escapeStringRegexp from '../vendor/escapeStringRegexp'; +import { ClickGoal, Goal, Matcher } from './Goals'; + +type EventHandler = (goal: Goal) => void; + +export function matchesUrl(matcher: Matcher, href: string, search: string, hash: string) { + /** + * Hash fragments are included when they include forward slashes to allow for applications that + * use path-like hashes. (http://example.com/url/path#/additional/path) + * + * When they do not include a forward slash they are considered anchors and are not included + * in matching. + */ + const keepHash = (matcher.kind === 'substring' || matcher.kind === 'regex') && hash.includes('/'); + // For most matching purposes we want the "canonical" URL, which in this context means the + // excluding the query parameters and hash (unless the hash is path-like). + const canonicalUrl = (keepHash ? href : href.replace(hash, '')).replace(search, ''); + + switch (matcher.kind) { + case 'exact': + return new RegExp(`^${escapeStringRegexp(matcher.url)}/?$`).test(href); + case 'canonical': + return new RegExp(`^${escapeStringRegexp(matcher.url)}/?$`).test(canonicalUrl); + case 'substring': + return new RegExp(`.*${escapeStringRegexp(matcher.substring)}.*$`).test(canonicalUrl); + case 'regex': + return new RegExp(matcher.pattern).test(canonicalUrl); + default: + return false; + } +} + +function findGoalsForClick(event: Event, clickGoals: ClickGoal[]) { + const matches: ClickGoal[] = []; + + clickGoals.forEach((goal) => { + let target: Node | null = event.target as Node; + const { selector } = goal; + const elements = querySelectorAll(selector); + + // Traverse from the target of the event up the page hierarchy. + // If there are no element that match the selector, then no need to check anything. + while (target && elements?.length) { + // The elements are a NodeList, so it doesn't have the array functions. For performance we + // do not convert it to an array. + for (let elementIndex = 0; elementIndex < elements.length; elementIndex += 1) { + if (target === elements[elementIndex]) { + matches.push(goal); + // The same element should not be in the list multiple times. + // Multiple objects in the hierarchy can match the selector, so we don't break the outer + // loop. + break; + } + } + target = target.parentNode as Node; + } + }); + + return matches; +} + +/** + * Tracks the goals on an individual "page" (combination of route, query params, and hash). + */ +export default class GoalTracker { + private _cleanup?: () => void; + constructor(goals: Goal[], onEvent: EventHandler) { + const goalsMatchingUrl = goals.filter((goal) => + goal.urls?.some((matcher) => + matchesUrl(matcher, getHref(), getLocationSearch(), getLocationHash()), + ), + ); + + const pageviewGoals = goalsMatchingUrl.filter((goal) => goal.kind === 'pageview'); + const clickGoals = goalsMatchingUrl.filter((goal) => goal.kind === 'click') as ClickGoal[]; + + pageviewGoals.forEach((event) => onEvent(event)); + + if (clickGoals.length) { + // Click handler is not a member function in order to avoid having to bind it for the event + // handler and then track a reference to that bound handler. + const clickHandler = (event: Event) => { + findGoalsForClick(event, clickGoals).forEach((clickGoal) => { + onEvent(clickGoal); + }); + }; + this._cleanup = addDocumentEventListener('click', clickHandler); + } + } + + /** + * Close the tracker which stops listening to any events. + */ + close() { + this._cleanup?.(); + } +} diff --git a/packages/sdk/browser/src/goals/Goals.ts b/packages/sdk/browser/src/goals/Goals.ts new file mode 100644 index 000000000..6b74a43dc --- /dev/null +++ b/packages/sdk/browser/src/goals/Goals.ts @@ -0,0 +1,44 @@ +export type GoalKind = 'click' | 'pageview'; + +export type MatcherKind = 'exact' | 'canonical' | 'substring' | 'regex'; + +export interface ExactMatcher { + kind: 'exact'; + url: string; +} + +export interface SubstringMatcher { + kind: 'substring'; + substring: string; +} + +export interface CanonicalMatcher { + kind: 'canonical'; + url: string; +} + +export interface RegexMatcher { + kind: 'regex'; + pattern: string; +} + +export type Matcher = ExactMatcher | SubstringMatcher | CanonicalMatcher | RegexMatcher; + +export interface PageViewGoal { + key: string; + kind: 'pageview'; + urls?: Matcher[]; +} + +export interface ClickGoal { + key: string; + kind: 'click'; + urls?: Matcher[]; + selector: string; +} + +export type Goal = PageViewGoal | ClickGoal; + +export function isClick(goal: Goal): goal is ClickGoal { + return goal.kind === 'click'; +} diff --git a/packages/sdk/browser/src/goals/LocationWatcher.ts b/packages/sdk/browser/src/goals/LocationWatcher.ts new file mode 100644 index 000000000..729d7af28 --- /dev/null +++ b/packages/sdk/browser/src/goals/LocationWatcher.ts @@ -0,0 +1,62 @@ +import { addWindowEventListener, getHref } from '../BrowserApi'; + +export const LOCATION_WATCHER_INTERVAL_MS = 300; + +// Using any for the timer handle because the type is not the same for all +// platforms and we only need to use it opaquely. +export type IntervalHandle = any; + +export interface LocationWatcher { + close(): void; +} + +/** + * Watches the browser URL and detects changes. + * + * This is used to detect URL changes for generating pageview events. + * + * @internal + */ +export class DefaultLocationWatcher { + private _previousLocation?: string; + private _watcherHandle: IntervalHandle; + private _cleanupListeners?: () => void; + + /** + * @param callback Callback that is executed whenever a URL change is detected. + */ + constructor(callback: () => void) { + this._previousLocation = getHref(); + const checkUrl = () => { + const currentLocation = getHref(); + + if (currentLocation !== this._previousLocation) { + this._previousLocation = currentLocation; + callback(); + } + }; + /** The location is watched via polling and popstate events because it is possible to miss + * navigation at certain points with just popstate. It is also to miss events with polling + * because they can happen within the polling interval. + * Details on when popstate is called: + * https://developer.mozilla.org/en-US/docs/Web/API/Window/popstate_event#when_popstate_is_sent + */ + this._watcherHandle = setInterval(checkUrl, LOCATION_WATCHER_INTERVAL_MS); + + const removeListener = addWindowEventListener('popstate', checkUrl); + + this._cleanupListeners = () => { + removeListener(); + }; + } + + /** + * Stop watching for location changes. + */ + close(): void { + if (this._watcherHandle) { + clearInterval(this._watcherHandle); + } + this._cleanupListeners?.(); + } +} diff --git a/packages/sdk/browser/src/index.ts b/packages/sdk/browser/src/index.ts index a31204dbd..dbd867a42 100644 --- a/packages/sdk/browser/src/index.ts +++ b/packages/sdk/browser/src/index.ts @@ -1,4 +1,141 @@ -export function Hello() { - // eslint-disable-next-line no-console - console.log('HELLO'); +/** + * This is the API reference for the LaunchDarkly Client-Side SDK for JavaScript. + * + * This SDK is intended for use in browser environments. + * + * In typical usage, you will call {@link initialize} once at startup time to obtain an instance of + * {@link LDClient}, which provides access to all of the SDK's functionality. + * + * For more information, see the SDK reference guide. + * + * @packageDocumentation + */ +import { + AutoEnvAttributes, + BasicLogger, + BasicLoggerOptions, + EvaluationSeriesContext, + EvaluationSeriesData, + Hook, + HookMetadata, + IdentifySeriesContext, + IdentifySeriesData, + IdentifySeriesResult, + IdentifySeriesStatus, + LDContext, + LDContextCommon, + LDContextMeta, + LDEvaluationDetail, + LDEvaluationDetailTyped, + LDEvaluationReason, + LDFlagSet, + LDInspection, + LDLogger, + LDLogLevel, + LDMultiKindContext, + LDSingleKindContext, +} from '@launchdarkly/js-client-sdk-common'; + +// The exported LDClient and LDOptions are the browser specific implementations. +// These shadow the common implementations. +import { BrowserClient, LDClient } from './BrowserClient'; +import { BrowserIdentifyOptions as LDIdentifyOptions } from './BrowserIdentifyOptions'; +import { BrowserOptions as LDOptions } from './options'; + +export type { + LDClient, + LDFlagSet, + LDContext, + LDContextCommon, + LDContextMeta, + LDMultiKindContext, + LDSingleKindContext, + LDLogLevel, + LDLogger, + LDOptions, + LDEvaluationDetail, + LDEvaluationDetailTyped, + LDEvaluationReason, + LDIdentifyOptions, + Hook, + HookMetadata, + EvaluationSeriesContext, + EvaluationSeriesData, + IdentifySeriesContext, + IdentifySeriesData, + IdentifySeriesResult, + IdentifySeriesStatus, + LDInspection, +}; + +/** + * Creates an instance of the LaunchDarkly client. + * + * Usage: + * ``` + * import { initialize } from 'launchdarkly-js-client-sdk'; + * const client = initialize(envKey, context, options); + * ``` + * + * @param clientSideId + * The client-side id, also known as the environment ID. + * @param options + * Optional configuration settings. + * @return + * The new client instance. + */ +export function initialize(clientSideId: string, options?: LDOptions): LDClient { + // AutoEnvAttributes are not supported yet in the browser SDK. + return new BrowserClient(clientSideId, AutoEnvAttributes.Disabled, options); +} + +/** + * Provides a simple {@link LDLogger} implementation. + * + * This logging implementation uses a simple format that includes only the log level + * and the message text. By default the output is written to `console.error`. + * + * To use the logger created by this function, put it into {@link LDOptions.logger}. If + * you do not set {@link LDOptions.logger} to anything, the SDK uses a default logger + * that will log "info" level and higher priorty messages and it will log messages to + * console.info, console.warn, and console.error. + * + * @param options Configuration for the logger. If no options are specified, the + * logger uses `{ level: 'info' }`. + * + * @example + * This example shows how to use `basicLogger` in your SDK options to enable console + * logging only at `warn` and `error` levels. + * ```javascript + * const ldOptions = { + * logger: basicLogger({ level: 'warn' }), + * }; + * ``` + * + * @example + * This example shows how to use `basicLogger` in your SDK options to cause all + * log output to go to `console.log` + * ```javascript + * const ldOptions = { + * logger: basicLogger({ destination: console.log }), + * }; + * ``` + * + * * @example + * The configuration also allows you to control the destination for each log level. + * ```javascript + * const ldOptions = { + * logger: basicLogger({ + * destination: { + * debug: console.debug, + * info: console.info, + * warn: console.warn, + * error:console.error + * } + * }), + * }; + * ``` + */ +export function basicLogger(options: BasicLoggerOptions): LDLogger { + return new BasicLogger(options); } diff --git a/packages/sdk/browser/src/options.ts b/packages/sdk/browser/src/options.ts new file mode 100644 index 000000000..3cb4e3568 --- /dev/null +++ b/packages/sdk/browser/src/options.ts @@ -0,0 +1,106 @@ +import { + LDLogger, + LDOptions as LDOptionsBase, + OptionMessages, + TypeValidator, + TypeValidators, +} from '@launchdarkly/js-client-sdk-common'; + +const DEFAULT_FLUSH_INTERVAL_SECONDS = 2; + +/** + * Initialization options for the LaunchDarkly browser SDK. + */ +export interface BrowserOptions extends Omit { + /** + * Whether the client should make a request to LaunchDarkly for Experimentation metrics (goals). + * + * This is true by default, meaning that this request will be made on every page load. + * Set it to false if you are not using Experimentation and want to skip the request. + */ + fetchGoals?: boolean; + + /** + * A function which, if present, can change the URL in analytics events to something other + * than the actual browser URL. It will be called with the current browser URL as a parameter, + * and returns the value that should be stored in the event's `url` property. + */ + eventUrlTransformer?: (url: string) => string; + + /** + * Whether or not to open a streaming connection to LaunchDarkly for live flag updates. + * + * If this is true, the client will always attempt to maintain a streaming connection; if false, + * it never will. If you leave the value undefined (the default), the client will open a streaming + * connection if you subscribe to `"change"` or `"change:flag-key"` events. + * + * This is equivalent to calling `client.setStreaming()` with the same value. + */ + streaming?: boolean; + + /** + * Determines if the SDK responds to entering different visibility states to handle tasks such as + * flushing events. + * + * This is true by default. Generally speaking the SDK will be able to most reliably delivery + * events with this setting on. + * + * It may be useful to disable for environments where not all window/document objects are + * available, such as when running the SDK in a browser extension. + */ + automaticBackgroundHandling?: boolean; +} + +export interface ValidatedOptions { + fetchGoals: boolean; + eventUrlTransformer: (url: string) => string; + streaming?: boolean; + automaticBackgroundHandling?: boolean; +} + +const optDefaults = { + fetchGoals: true, + eventUrlTransformer: (url: string) => url, + streaming: undefined, +}; + +const validators: { [Property in keyof BrowserOptions]: TypeValidator | undefined } = { + fetchGoals: TypeValidators.Boolean, + eventUrlTransformer: TypeValidators.Function, + streaming: TypeValidators.Boolean, +}; + +export function filterToBaseOptions(opts: BrowserOptions): LDOptionsBase { + const baseOptions: LDOptionsBase = { ...opts }; + + // Remove any browser specific configuration keys so we don't get warnings from + // the base implementation for unknown configuration. + Object.keys(optDefaults).forEach((key) => { + delete (baseOptions as any)[key]; + }); + return baseOptions; +} + +function applyBrowserDefaults(opts: BrowserOptions) { + // eslint-disable-next-line no-param-reassign + opts.flushInterval ??= DEFAULT_FLUSH_INTERVAL_SECONDS; +} + +export default function validateOptions(opts: BrowserOptions, logger: LDLogger): ValidatedOptions { + const output: ValidatedOptions = { ...optDefaults }; + applyBrowserDefaults(output); + + Object.entries(validators).forEach((entry) => { + const [key, validator] = entry as [keyof BrowserOptions, TypeValidator]; + const value = opts[key]; + if (value !== undefined) { + if (validator.is(value)) { + output[key as keyof ValidatedOptions] = value as any; + } else { + logger.warn(OptionMessages.wrongOptionType(key, validator.getType(), typeof value)); + } + } + }); + + return output; +} diff --git a/packages/sdk/browser/src/platform/Backoff.ts b/packages/sdk/browser/src/platform/Backoff.ts new file mode 100644 index 000000000..ce0e931ee --- /dev/null +++ b/packages/sdk/browser/src/platform/Backoff.ts @@ -0,0 +1,76 @@ +const MAX_RETRY_DELAY = 30 * 1000; // Maximum retry delay 30 seconds. +const JITTER_RATIO = 0.5; // Delay should be 50%-100% of calculated time. + +/** + * Implements exponential backoff and jitter. This class tracks successful connections and failures + * and produces a retry delay. + * + * It does not start any timers or directly control a connection. + * + * The backoff follows an exponential backoff scheme with 50% jitter starting at + * initialRetryDelayMillis and capping at MAX_RETRY_DELAY. If RESET_INTERVAL has elapsed after a + * success, without an intervening faulure, then the backoff is reset to initialRetryDelayMillis. + */ +export default class Backoff { + private _retryCount: number = 0; + private _activeSince?: number; + private _initialRetryDelayMillis: number; + /** + * The exponent at which the backoff delay will exceed the maximum. + * Beyond this limit the backoff can be set to the max. + */ + private readonly _maxExponent: number; + + constructor( + initialRetryDelayMillis: number, + private readonly _retryResetIntervalMillis: number, + private readonly _random = Math.random, + ) { + // Initial retry delay cannot be 0. + this._initialRetryDelayMillis = Math.max(1, initialRetryDelayMillis); + this._maxExponent = Math.ceil(Math.log2(MAX_RETRY_DELAY / this._initialRetryDelayMillis)); + } + + private _backoff(): number { + const exponent = Math.min(this._retryCount, this._maxExponent); + const delay = this._initialRetryDelayMillis * 2 ** exponent; + return Math.min(delay, MAX_RETRY_DELAY); + } + + private _jitter(computedDelayMillis: number): number { + return computedDelayMillis - Math.trunc(this._random() * JITTER_RATIO * computedDelayMillis); + } + + /** + * This function should be called when a connection attempt is successful. + * + * @param timeStampMs The time of the success. Used primarily for testing, when not provided + * the current time is used. + */ + success(timeStampMs: number = Date.now()): void { + this._activeSince = timeStampMs; + } + + /** + * This function should be called when a connection fails. It returns the a delay, in + * milliseconds, after which a reconnection attempt should be made. + * + * @param timeStampMs The time of the success. Used primarily for testing, when not provided + * the current time is used. + * @returns The delay before the next connection attempt. + */ + fail(timeStampMs: number = Date.now()): number { + // If the last successful connection was active for more than the RESET_INTERVAL, then we + // return to the initial retry delay. + if ( + this._activeSince !== undefined && + timeStampMs - this._activeSince > this._retryResetIntervalMillis + ) { + this._retryCount = 0; + } + this._activeSince = undefined; + const delay = this._jitter(this._backoff()); + this._retryCount += 1; + return delay; + } +} diff --git a/packages/sdk/browser/src/platform/BrowserCrypto.ts b/packages/sdk/browser/src/platform/BrowserCrypto.ts new file mode 100644 index 000000000..29695655c --- /dev/null +++ b/packages/sdk/browser/src/platform/BrowserCrypto.ts @@ -0,0 +1,15 @@ +import { Crypto } from '@launchdarkly/js-client-sdk-common'; + +import { getCrypto } from '../BrowserApi'; +import BrowserHasher from './BrowserHasher'; +import randomUuidV4 from './randomUuidV4'; + +export default class BrowserCrypto implements Crypto { + createHash(algorithm: string): BrowserHasher { + return new BrowserHasher(getCrypto(), algorithm); + } + + randomUUID(): string { + return randomUuidV4(); + } +} diff --git a/packages/sdk/browser/src/platform/BrowserEncoding.ts b/packages/sdk/browser/src/platform/BrowserEncoding.ts new file mode 100644 index 000000000..f673c1327 --- /dev/null +++ b/packages/sdk/browser/src/platform/BrowserEncoding.ts @@ -0,0 +1,18 @@ +import { Encoding } from '@launchdarkly/js-client-sdk-common'; + +function bytesToBase64(bytes: Uint8Array) { + const binString = Array.from(bytes, (byte) => String.fromCodePoint(byte)).join(''); + return btoa(binString); +} + +/** + * Implementation Note: This btoa handles unicode characters, which the base btoa in the browser + * does not. + * Background: https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem + */ + +export default class BrowserEncoding implements Encoding { + btoa(data: string): string { + return bytesToBase64(new TextEncoder().encode(data)); + } +} diff --git a/packages/sdk/browser/src/platform/BrowserHasher.ts b/packages/sdk/browser/src/platform/BrowserHasher.ts new file mode 100644 index 000000000..7f1107ec8 --- /dev/null +++ b/packages/sdk/browser/src/platform/BrowserHasher.ts @@ -0,0 +1,44 @@ +import { Hasher } from '@launchdarkly/js-client-sdk-common'; + +export default class BrowserHasher implements Hasher { + private _data: string[] = []; + private _algorithm: string; + constructor( + private readonly _webcrypto: Crypto, + algorithm: string, + ) { + switch (algorithm) { + case 'sha1': + this._algorithm = 'SHA-1'; + break; + case 'sha256': + this._algorithm = 'SHA-256'; + break; + default: + throw new Error(`Algorithm is not supported ${algorithm}`); + } + } + + async asyncDigest(encoding: string): Promise { + const combinedData = this._data.join(''); + const encoded = new TextEncoder().encode(combinedData); + const digestedBuffer = await this._webcrypto.subtle.digest(this._algorithm, encoded); + switch (encoding) { + case 'base64': + return btoa(String.fromCharCode(...new Uint8Array(digestedBuffer))); + case 'hex': + // Convert the buffer to an array of uint8 values, then convert each of those to hex. + // The map function on a Uint8Array directly only maps to other Uint8Arrays. + return [...new Uint8Array(digestedBuffer)] + .map((val) => val.toString(16).padStart(2, '0')) + .join(''); + default: + throw new Error(`Encoding is not supported ${encoding}`); + } + } + + update(data: string): Hasher { + this._data.push(data); + return this as Hasher; + } +} diff --git a/packages/sdk/browser/src/platform/BrowserInfo.ts b/packages/sdk/browser/src/platform/BrowserInfo.ts new file mode 100644 index 000000000..3a2c064fb --- /dev/null +++ b/packages/sdk/browser/src/platform/BrowserInfo.ts @@ -0,0 +1,16 @@ +import { Info, PlatformData, SdkData } from '@launchdarkly/js-client-sdk-common'; + +export default class BrowserInfo implements Info { + platformData(): PlatformData { + return { + name: 'JS', // Name maintained from previous 3.x implementation. + }; + } + sdkData(): SdkData { + return { + name: '@launchdarkly/js-client-sdk', + version: '0.0.0', // x-release-please-version + userAgentBase: 'JSClient', + }; + } +} diff --git a/packages/sdk/browser/src/platform/BrowserPlatform.ts b/packages/sdk/browser/src/platform/BrowserPlatform.ts new file mode 100644 index 000000000..33b5b1024 --- /dev/null +++ b/packages/sdk/browser/src/platform/BrowserPlatform.ts @@ -0,0 +1,30 @@ +import { + Crypto, + Encoding, + Info, + LDLogger, + Platform, + Requests, + Storage, +} from '@launchdarkly/js-client-sdk-common'; + +import BrowserCrypto from './BrowserCrypto'; +import BrowserEncoding from './BrowserEncoding'; +import BrowserInfo from './BrowserInfo'; +import BrowserRequests from './BrowserRequests'; +import LocalStorage, { isLocalStorageSupported } from './LocalStorage'; + +export default class BrowserPlatform implements Platform { + encoding: Encoding = new BrowserEncoding(); + info: Info = new BrowserInfo(); + // fileSystem?: Filesystem; + crypto: Crypto = new BrowserCrypto(); + requests: Requests = new BrowserRequests(); + storage?: Storage; + + constructor(logger: LDLogger) { + if (isLocalStorageSupported()) { + this.storage = new LocalStorage(logger); + } + } +} diff --git a/packages/sdk/browser/src/platform/BrowserRequests.ts b/packages/sdk/browser/src/platform/BrowserRequests.ts new file mode 100644 index 000000000..5e7346784 --- /dev/null +++ b/packages/sdk/browser/src/platform/BrowserRequests.ts @@ -0,0 +1,29 @@ +import { + EventSourceCapabilities, + EventSourceInitDict, + EventSource as LDEventSource, + Options, + Requests, + Response, +} from '@launchdarkly/js-client-sdk-common'; + +import DefaultBrowserEventSource from './DefaultBrowserEventSource'; + +export default class BrowserRequests implements Requests { + fetch(url: string, options?: Options): Promise { + // @ts-ignore + return fetch(url, options); + } + + createEventSource(url: string, eventSourceInitDict: EventSourceInitDict): LDEventSource { + return new DefaultBrowserEventSource(url, eventSourceInitDict); + } + + getEventSourceCapabilities(): EventSourceCapabilities { + return { + customMethod: false, + readTimeout: false, + headers: false, + }; + } +} diff --git a/packages/sdk/browser/src/platform/DefaultBrowserEventSource.ts b/packages/sdk/browser/src/platform/DefaultBrowserEventSource.ts new file mode 100644 index 000000000..3ecdeb3a1 --- /dev/null +++ b/packages/sdk/browser/src/platform/DefaultBrowserEventSource.ts @@ -0,0 +1,106 @@ +import { + EventListener, + EventName, + EventSourceInitDict, + HttpErrorResponse, + EventSource as LDEventSource, +} from '@launchdarkly/js-client-sdk-common'; + +import Backoff from './Backoff'; + +/** + * Implementation Notes: + * + * This event source does not support a read-timeout. + * This event source does not support customized verbs. + * This event source does not support headers. + */ + +/** + * Browser event source implementation which extends the built-in event + * source with additional reconnection logic. + */ +export default class DefaultBrowserEventSource implements LDEventSource { + private _es?: EventSource; + private _backoff: Backoff; + private _errorFilter: (err: HttpErrorResponse) => boolean; + + // The type of the handle can be platform specific and we treat is opaquely. + private _reconnectTimeoutHandle?: any; + + private _listeners: Record = {}; + + constructor( + private readonly _url: string, + options: EventSourceInitDict, + ) { + this._backoff = new Backoff(options.initialRetryDelayMillis, options.retryResetIntervalMillis); + this._errorFilter = options.errorFilter; + this._openConnection(); + } + + onclose: (() => void) | undefined; + + onerror: ((err?: HttpErrorResponse) => void) | undefined; + + onopen: (() => void) | undefined; + + onretrying: ((e: { delayMillis: number }) => void) | undefined; + + private _openConnection() { + this._es = new EventSource(this._url); + this._es.onopen = () => { + this._backoff.success(); + this.onopen?.(); + }; + // The error could be from a polyfill, or from the browser event source, so we are loose on the + // typing. + this._es.onerror = (err: any) => { + this._handleError(err); + this.onerror?.(err); + }; + Object.entries(this._listeners).forEach(([eventName, listeners]) => { + listeners.forEach((listener) => { + this._es?.addEventListener(eventName, listener); + }); + }); + } + + addEventListener(type: EventName, listener: EventListener): void { + this._listeners[type] ??= []; + this._listeners[type].push(listener); + this._es?.addEventListener(type, listener); + } + + close(): void { + // Ensure any pending retry attempts are not done. + clearTimeout(this._reconnectTimeoutHandle); + this._reconnectTimeoutHandle = undefined; + + // Close the event source and notify any listeners. + this._es?.close(); + this.onclose?.(); + } + + private _tryConnect(delayMs: number) { + this.onretrying?.({ delayMillis: delayMs }); + this._reconnectTimeoutHandle = setTimeout(() => { + this._openConnection(); + }, delayMs); + } + + private _handleError(err: any): void { + this.close(); + + // The event source may not produce a status. But the LaunchDarkly + // polyfill can. If we can get the status, then we should stop retrying + // on certain error codes. + if (err.status && typeof err.status === 'number' && !this._errorFilter(err)) { + // If we encounter an unrecoverable condition, then we do not want to + // retry anymore. + return; + } + + this._tryConnect(this._backoff.fail()); + } +} diff --git a/packages/sdk/browser/src/platform/LocalStorage.ts b/packages/sdk/browser/src/platform/LocalStorage.ts new file mode 100644 index 000000000..88748d70c --- /dev/null +++ b/packages/sdk/browser/src/platform/LocalStorage.ts @@ -0,0 +1,42 @@ +import type { LDLogger, Storage } from '@launchdarkly/js-client-sdk-common'; + +export function isLocalStorageSupported() { + // Checking a symbol using typeof is safe, but directly accessing a symbol + // which is not defined would be an error. + return typeof localStorage !== 'undefined'; +} + +/** + * Implementation of Storage using localStorage for the browser. + * + * The Storage API is async, and localStorage is synchronous. This is fine, + * and none of the methods need to internally await their operations. + */ +export default class PlatformStorage implements Storage { + constructor(private readonly _logger?: LDLogger) {} + async clear(key: string): Promise { + try { + localStorage.removeItem(key); + } catch (error) { + this._logger?.error(`Error clearing key from localStorage: ${key}, reason: ${error}`); + } + } + + async get(key: string): Promise { + try { + const value = localStorage.getItem(key); + return value ?? null; + } catch (error) { + this._logger?.error(`Error getting key from localStorage: ${key}, reason: ${error}`); + return null; + } + } + + async set(key: string, value: string): Promise { + try { + localStorage.setItem(key, value); + } catch (error) { + this._logger?.error(`Error setting key in localStorage: ${key}, reason: ${error}`); + } + } +} diff --git a/packages/sdk/browser/src/platform/randomUuidV4.ts b/packages/sdk/browser/src/platform/randomUuidV4.ts new file mode 100644 index 000000000..0659d58f7 --- /dev/null +++ b/packages/sdk/browser/src/platform/randomUuidV4.ts @@ -0,0 +1,99 @@ +// The implementation in this file generates UUIDs in v4 format and is suitable +// for use as a UUID in LaunchDarkly events. It is not a rigorous implementation. + +// It uses crypto.randomUUID when available. +// If crypto.randomUUID is not available, then it uses random values and forms +// the UUID itself. +// When possible it uses crypto.getRandomValues, but it can use Math.random +// if crypto.getRandomValues is not available. + +// UUIDv4 Struct definition. +// https://www.rfc-archive.org/getrfc.php?rfc=4122 +// Appendix A. Appendix A - Sample Implementation +const timeLow = { + start: 0, + end: 3, +}; +const timeMid = { + start: 4, + end: 5, +}; +const timeHiAndVersion = { + start: 6, + end: 7, +}; +const clockSeqHiAndReserved = { + start: 8, + end: 8, +}; +const clockSeqLow = { + start: 9, + end: 9, +}; +const nodes = { + start: 10, + end: 15, +}; + +function getRandom128bit(): number[] { + if (crypto && crypto.getRandomValues) { + const typedArray = new Uint8Array(16); + crypto.getRandomValues(typedArray); + return [...typedArray.values()]; + } + const values = []; + for (let index = 0; index < 16; index += 1) { + // Math.random is 0-1 with inclusive min and exclusive max. + values.push(Math.floor(Math.random() * 256)); + } + return values; +} + +function hex(bytes: number[], range: { start: number; end: number }): string { + let strVal = ''; + for (let index = range.start; index <= range.end; index += 1) { + strVal += bytes[index].toString(16).padStart(2, '0'); + } + return strVal; +} + +/** + * Given a list of 16 random bytes generate a UUID in v4 format. + * + * Note: The input bytes are modified to conform to the requirements of UUID v4. + * + * @param bytes A list of 16 bytes. + * @returns A UUID v4 string. + */ +export function formatDataAsUuidV4(bytes: number[]): string { + // https://www.rfc-archive.org/getrfc.php?rfc=4122 + // 4.4. Algorithms for Creating a UUID from Truly Random or + // Pseudo-Random Numbers + + // Set the two most significant bits (bits 6 and 7) of the clock_seq_hi_and_reserved to zero and + // one, respectively. + // eslint-disable-next-line no-bitwise, no-param-reassign + bytes[clockSeqHiAndReserved.start] = (bytes[clockSeqHiAndReserved.start] | 0x80) & 0xbf; + // Set the four most significant bits (bits 12 through 15) of the time_hi_and_version field to + // the 4-bit version number from Section 4.1.3. + // eslint-disable-next-line no-bitwise, no-param-reassign + bytes[timeHiAndVersion.start] = (bytes[timeHiAndVersion.start] & 0x0f) | 0x40; + + return ( + `${hex(bytes, timeLow)}-${hex(bytes, timeMid)}-${hex(bytes, timeHiAndVersion)}-` + + `${hex(bytes, clockSeqHiAndReserved)}${hex(bytes, clockSeqLow)}-${hex(bytes, nodes)}` + ); +} + +export function fallbackUuidV4(): string { + const bytes = getRandom128bit(); + return formatDataAsUuidV4(bytes); +} + +export default function randomUuidV4(): string { + if (typeof crypto !== undefined && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + + return fallbackUuidV4(); +} diff --git a/packages/sdk/browser/src/vendor/escapeStringRegexp.ts b/packages/sdk/browser/src/vendor/escapeStringRegexp.ts new file mode 100644 index 000000000..228c0e30b --- /dev/null +++ b/packages/sdk/browser/src/vendor/escapeStringRegexp.ts @@ -0,0 +1,34 @@ +// From here: https://github.com/sindresorhus/escape-string-regexp + +// This is vendored to reduce the complexity of the built and test setup. +// The NPM package for escape-string-regexp is ESM only, and that introduces +// complexity to the jest configuration which works best/easiest with CJS. + +/** + * MIT License + * + * Copyright (c) Sindre Sorhus (https://sindresorhus.com) + * + * 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. + * + */ + +/** + * Escape regular expression especial characters. + * + * @param string The regular expression to escape. + * @returns The escaped expression. + */ +export default function escapeStringRegexp(string: string) { + if (typeof string !== 'string') { + throw new TypeError('Expected a string'); + } + + // Escape characters with special meaning either inside or outside character sets. + // Use a simple backslash escape when it’s always valid, and a `\xnn` escape when the simpler form would be disallowed by Unicode patterns’ stricter grammar. + return string.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&').replace(/-/g, '\\x2d'); +} diff --git a/packages/sdk/browser/tsconfig.json b/packages/sdk/browser/tsconfig.json index b1c92fdd9..7306c5b0c 100644 --- a/packages/sdk/browser/tsconfig.json +++ b/packages/sdk/browser/tsconfig.json @@ -3,23 +3,22 @@ "allowSyntheticDefaultImports": true, "declaration": true, "declarationMap": true, - "jsx": "react-jsx", - "lib": ["es6", "dom"], - "module": "ES6", + "lib": ["ES2017", "dom"], + "module": "ESNext", "moduleResolution": "node", "noImplicitOverride": true, "resolveJsonModule": true, - // Uses "." so it can load package.json. "rootDir": ".", + "outDir": "dist", "skipLibCheck": true, - // enables importers to jump to source - "sourceMap": true, + "sourceMap": false, "strict": true, "stripInternal": true, "target": "ES2017", "types": ["node", "jest"], "allowJs": true }, + "include": ["src"], "exclude": [ "vite.config.ts", "__tests__", @@ -28,8 +27,8 @@ "example", "node_modules", "babel.config.js", - "jest.config.ts", - "jestSetupFile.ts", + "setup-jest.js", + "rollup.config.js", "**/*.test.ts*" ] } diff --git a/packages/sdk/browser/tsconfig.test.json b/packages/sdk/browser/tsconfig.test.json index 2c617dcaa..6087e302d 100644 --- a/packages/sdk/browser/tsconfig.test.json +++ b/packages/sdk/browser/tsconfig.test.json @@ -1,14 +1,27 @@ { "compilerOptions": { - "esModuleInterop": true, - "jsx": "react-jsx", - "lib": ["es6", "dom"], - "module": "ES6", - "moduleResolution": "node", - "resolveJsonModule": true, - "rootDir": ".", + "rootDir": "src", + "outDir": "dist", + "lib": ["es6", "DOM"], + "module": "CommonJS", "strict": true, - "types": ["jest", "node"] + "noImplicitOverride": true, + "sourceMap": true, + "declaration": true, + "declarationMap": true, + "stripInternal": true }, - "exclude": ["dist", "node_modules", "__tests__", "example"] + "exclude": [ + "vite.config.ts", + "__tests__", + "dist", + "docs", + "example", + "node_modules", + "contract-tests", + "babel.config.js", + "jest.config.js", + "jestSetupFile.ts", + "**/*.test.ts*" + ] } diff --git a/packages/sdk/browser/tsup.config.ts b/packages/sdk/browser/tsup.config.ts new file mode 100644 index 000000000..56a856d87 --- /dev/null +++ b/packages/sdk/browser/tsup.config.ts @@ -0,0 +1,27 @@ +// It is a dev dependency and the linter doesn't understand. +// eslint-disable-next-line import/no-extraneous-dependencies +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: { + index: 'src/index.ts', + compat: 'src/compat/index.ts', + }, + minify: true, + format: ['esm', 'cjs'], + splitting: false, + sourcemap: false, + clean: true, + noExternal: ['@launchdarkly/js-sdk-common', '@launchdarkly/js-client-sdk-common'], + dts: true, + metafile: true, + esbuildOptions(opts) { + // This would normally be `^_(?!meta|_)`, but go doesn't support negative look-ahead assertions, + // so we need to craft something that works without it. + // So start of line followed by a character that isn't followed by m or underscore, but we + // want other things that do start with m, so we need to progressively handle more characters + // of meta with exclusions. + // eslint-disable-next-line no-param-reassign + opts.mangleProps = /^_([^m|_]|m[^e]|me[^t]|met[^a])/; + }, +}); diff --git a/packages/sdk/browser/vite.config.ts b/packages/sdk/browser/vite.config.ts deleted file mode 100644 index 74378ce66..000000000 --- a/packages/sdk/browser/vite.config.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* eslint-disable import/no-extraneous-dependencies */ -// This file intentionally uses dev dependencies as it is a build file. -import { resolve } from 'path'; -import { defineConfig } from 'vite'; -import dts from 'vite-plugin-dts'; - -export default defineConfig({ - plugins: [dts()], - build: { - lib: { - entry: resolve(__dirname, 'src/index.ts'), - name: '@launchdarkly/js-client-sdk', - fileName: (format) => `index.${format}.js`, - formats: ['cjs', 'es'], - }, - rollupOptions: {}, - }, -}); diff --git a/packages/sdk/cloudflare/CHANGELOG.md b/packages/sdk/cloudflare/CHANGELOG.md index cc54ba6cd..7f6c33ed9 100644 --- a/packages/sdk/cloudflare/CHANGELOG.md +++ b/packages/sdk/cloudflare/CHANGELOG.md @@ -21,6 +21,56 @@ All notable changes to the LaunchDarkly SDK for Cloudflare Workers will be docum * devDependencies * @launchdarkly/js-server-sdk-common-edge bumped from 2.2.1 to 2.2.2 +## [2.6.0](https://github.com/launchdarkly/js-core/compare/cloudflare-server-sdk-v2.5.15...cloudflare-server-sdk-v2.6.0) (2024-10-17) + + +### Features + +* Apply private property naming standard. Mangle browser private properties. ([#620](https://github.com/launchdarkly/js-core/issues/620)) ([3e6d404](https://github.com/launchdarkly/js-core/commit/3e6d404ae665c5cc7e5a1394a59c8f2c9d5d682a)) + + +### Dependencies + +* The following workspace dependencies were updated + * devDependencies + * @launchdarkly/js-server-sdk-common-edge bumped from 2.4.1 to 2.5.0 + +## [2.5.15](https://github.com/launchdarkly/js-core/compare/cloudflare-server-sdk-v2.5.14...cloudflare-server-sdk-v2.5.15) (2024-10-09) + + +### Dependencies + +* The following workspace dependencies were updated + * devDependencies + * @launchdarkly/js-server-sdk-common-edge bumped from 2.4.0 to 2.4.1 + +## [2.5.14](https://github.com/launchdarkly/js-core/compare/cloudflare-server-sdk-v2.5.13...cloudflare-server-sdk-v2.5.14) (2024-09-26) + + +### Dependencies + +* The following workspace dependencies were updated + * devDependencies + * @launchdarkly/js-server-sdk-common-edge bumped from 2.3.9 to 2.4.0 + +## [2.5.13](https://github.com/launchdarkly/js-core/compare/cloudflare-server-sdk-v2.5.12...cloudflare-server-sdk-v2.5.13) (2024-09-05) + + +### Dependencies + +* The following workspace dependencies were updated + * devDependencies + * @launchdarkly/js-server-sdk-common-edge bumped from 2.3.8 to 2.3.9 + +## [2.5.12](https://github.com/launchdarkly/js-core/compare/cloudflare-server-sdk-v2.5.11...cloudflare-server-sdk-v2.5.12) (2024-09-03) + + +### Dependencies + +* The following workspace dependencies were updated + * devDependencies + * @launchdarkly/js-server-sdk-common-edge bumped from 2.3.7 to 2.3.8 + ## [2.5.11](https://github.com/launchdarkly/js-core/compare/cloudflare-server-sdk-v2.5.10...cloudflare-server-sdk-v2.5.11) (2024-08-28) diff --git a/packages/sdk/cloudflare/src/index.test.ts b/packages/sdk/cloudflare/__tests__/index.test.ts similarity index 98% rename from packages/sdk/cloudflare/src/index.test.ts rename to packages/sdk/cloudflare/__tests__/index.test.ts index 747bc75e1..941438c0a 100644 --- a/packages/sdk/cloudflare/src/index.test.ts +++ b/packages/sdk/cloudflare/__tests__/index.test.ts @@ -3,7 +3,7 @@ import { Miniflare } from 'miniflare'; import { LDClient, LDContext } from '@launchdarkly/js-server-sdk-common-edge'; -import { init } from './index'; +import { init } from '../src/index'; import * as allFlagsSegments from './testData.json'; const mf = new Miniflare({ diff --git a/packages/sdk/cloudflare/src/testData.json b/packages/sdk/cloudflare/__tests__/testData.json similarity index 100% rename from packages/sdk/cloudflare/src/testData.json rename to packages/sdk/cloudflare/__tests__/testData.json diff --git a/packages/sdk/cloudflare/jsr.json b/packages/sdk/cloudflare/jsr.json index 1646c941f..62b0c6d0b 100644 --- a/packages/sdk/cloudflare/jsr.json +++ b/packages/sdk/cloudflare/jsr.json @@ -1,9 +1,17 @@ { "name": "@launchdarkly/cloudflare-server-sdk", - "version": "2.5.11", + "version": "2.6.0", "exports": "./src/index.ts", "publish": { - "include": ["LICENSE", "README.md", "package.json", "jsr.json", "src/**/*.ts"], - "exclude": ["src/**/*.test.ts"] + "include": [ + "LICENSE", + "README.md", + "package.json", + "jsr.json", + "src/**/*.ts" + ], + "exclude": [ + "src/**/*.test.ts" + ] } } diff --git a/packages/sdk/cloudflare/package.json b/packages/sdk/cloudflare/package.json index b3293f996..d760763f5 100644 --- a/packages/sdk/cloudflare/package.json +++ b/packages/sdk/cloudflare/package.json @@ -1,6 +1,6 @@ { "name": "@launchdarkly/cloudflare-server-sdk", - "version": "2.5.11", + "version": "2.6.0", "description": "Cloudflare LaunchDarkly SDK", "homepage": "https://github.com/launchdarkly/js-core/tree/main/packages/sdk/cloudflare", "repository": { @@ -44,7 +44,7 @@ "crypto-js": "^4.1.1" }, "devDependencies": { - "@launchdarkly/js-server-sdk-common-edge": "2.3.7", + "@launchdarkly/js-server-sdk-common-edge": "2.5.0", "@rollup/plugin-commonjs": "^25.0.4", "@rollup/plugin-json": "^6.0.0", "@rollup/plugin-node-resolve": "^15.2.1", diff --git a/packages/sdk/cloudflare/src/createPlatformInfo.ts b/packages/sdk/cloudflare/src/createPlatformInfo.ts index 0976b985e..f123051a7 100644 --- a/packages/sdk/cloudflare/src/createPlatformInfo.ts +++ b/packages/sdk/cloudflare/src/createPlatformInfo.ts @@ -1,7 +1,7 @@ import type { Info, PlatformData, SdkData } from '@launchdarkly/js-server-sdk-common-edge'; const name = '@launchdarkly/cloudflare-server-sdk'; -const version = '2.5.11'; // x-release-please-version +const version = '2.6.0'; // x-release-please-version class CloudflarePlatformInfo implements Info { platformData(): PlatformData { diff --git a/packages/sdk/react-native/CHANGELOG.md b/packages/sdk/react-native/CHANGELOG.md index 90ce7ada2..43a0f2705 100644 --- a/packages/sdk/react-native/CHANGELOG.md +++ b/packages/sdk/react-native/CHANGELOG.md @@ -1,5 +1,76 @@ # Changelog +## [10.9.1](https://github.com/launchdarkly/js-core/compare/react-native-client-sdk-v10.9.0...react-native-client-sdk-v10.9.1) (2024-10-29) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-client-sdk-common bumped from 1.10.0 to 1.11.0 + +## [10.9.0](https://github.com/launchdarkly/js-core/compare/react-native-client-sdk-v10.8.0...react-native-client-sdk-v10.9.0) (2024-10-17) + + +### Features + +* Add support for inspectors. ([#625](https://github.com/launchdarkly/js-core/issues/625)) ([a986478](https://github.com/launchdarkly/js-core/commit/a986478ed8e39d0f529ca6adec0a09b484421390)) +* adds ping stream support ([#624](https://github.com/launchdarkly/js-core/issues/624)) ([dee53af](https://github.com/launchdarkly/js-core/commit/dee53af9312b74a70b748d49b2d2911d65333cf3)) +* Apply private property naming standard. Mangle browser private properties. ([#620](https://github.com/launchdarkly/js-core/issues/620)) ([3e6d404](https://github.com/launchdarkly/js-core/commit/3e6d404ae665c5cc7e5a1394a59c8f2c9d5d682a)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-client-sdk-common bumped from 1.9.0 to 1.10.0 + +## [10.8.0](https://github.com/launchdarkly/js-core/compare/react-native-client-sdk-v10.7.0...react-native-client-sdk-v10.8.0) (2024-10-09) + + +### Features + +* Add support for hooks. ([#605](https://github.com/launchdarkly/js-core/issues/605)) ([04d347b](https://github.com/launchdarkly/js-core/commit/04d347b25e01015134a2545be22bfd8b1d1e85cc)) + + +### Bug Fixes + +* Ensure client logger is always wrapped in a safe logger. ([#599](https://github.com/launchdarkly/js-core/issues/599)) ([980e4da](https://github.com/launchdarkly/js-core/commit/980e4daaf32864e18f14b1e5e28e308dff0ae94f)) +* Fix base64 encoding of unicode characters. ([#613](https://github.com/launchdarkly/js-core/issues/613)) ([35ec8d1](https://github.com/launchdarkly/js-core/commit/35ec8d1ecc07ddb68f4d02b19e1f238f7ff14df7)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-client-sdk-common bumped from 1.8.0 to 1.9.0 + +## [10.7.0](https://github.com/launchdarkly/js-core/compare/react-native-client-sdk-v10.6.1...react-native-client-sdk-v10.7.0) (2024-09-26) + + +### Features + +* Add support for conditional event source capabilities. ([#577](https://github.com/launchdarkly/js-core/issues/577)) ([fe82500](https://github.com/launchdarkly/js-core/commit/fe82500f28cf8d8311502098aa6cc2e73932064e)) +* Add support for js-client-sdk style initialization. ([53f5bb8](https://github.com/launchdarkly/js-core/commit/53f5bb89754ff05405d481a959e75742fbd0d0a9)) +* Adds support for REPORT. ([#575](https://github.com/launchdarkly/js-core/issues/575)) ([916b724](https://github.com/launchdarkly/js-core/commit/916b72409b63abdf350e70cca41331c4204b6e95)) +* Refactor data source connection handling. ([53f5bb8](https://github.com/launchdarkly/js-core/commit/53f5bb89754ff05405d481a959e75742fbd0d0a9)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-client-sdk-common bumped from 1.7.0 to 1.8.0 + +## [10.6.1](https://github.com/launchdarkly/js-core/compare/react-native-client-sdk-v10.6.0...react-native-client-sdk-v10.6.1) (2024-09-03) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-client-sdk-common bumped from 1.6.0 to 1.7.0 + ## [10.6.0](https://github.com/launchdarkly/js-core/compare/react-native-client-sdk-v10.5.1...react-native-client-sdk-v10.6.0) (2024-08-28) diff --git a/packages/sdk/react-native/__tests__/MobileDataManager.test.ts b/packages/sdk/react-native/__tests__/MobileDataManager.test.ts new file mode 100644 index 000000000..27ac0de48 --- /dev/null +++ b/packages/sdk/react-native/__tests__/MobileDataManager.test.ts @@ -0,0 +1,302 @@ +import { + ApplicationTags, + base64UrlEncode, + Configuration, + Context, + Encoding, + FlagManager, + internal, + LDEmitter, + LDHeaders, + LDIdentifyOptions, + LDLogger, + Platform, + Response, + ServiceEndpoints, +} from '@launchdarkly/js-client-sdk-common'; + +import MobileDataManager from '../src/MobileDataManager'; +import { ValidatedOptions } from '../src/options'; +import PlatformCrypto from '../src/platform/crypto'; +import PlatformEncoding from '../src/platform/PlatformEncoding'; +import PlatformInfo from '../src/platform/PlatformInfo'; +import PlatformStorage from '../src/platform/PlatformStorage'; + +function mockResponse(value: string, statusCode: number) { + const response: Response = { + headers: { + get: jest.fn(), + keys: jest.fn(), + values: jest.fn(), + entries: jest.fn(), + has: jest.fn(), + }, + status: statusCode, + text: () => Promise.resolve(value), + json: () => Promise.resolve(JSON.parse(value)), + }; + return Promise.resolve(response); +} + +function mockFetch(value: string, statusCode: number = 200) { + const f = jest.fn(); + f.mockResolvedValue(mockResponse(value, statusCode)); + return f; +} + +describe('given a MobileDataManager with mocked dependencies', () => { + let platform: jest.Mocked; + let flagManager: jest.Mocked; + let config: Configuration; + let rnConfig: ValidatedOptions; + let baseHeaders: LDHeaders; + let emitter: jest.Mocked; + let diagnosticsManager: jest.Mocked; + let mobileDataManager: MobileDataManager; + let logger: LDLogger; + beforeEach(() => { + logger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }; + config = { + logger, + maxCachedContexts: 5, + capacity: 100, + diagnosticRecordingInterval: 1000, + flushInterval: 1000, + streamInitialReconnectDelay: 1000, + allAttributesPrivate: false, + debug: true, + diagnosticOptOut: false, + sendEvents: false, + sendLDHeaders: true, + useReport: false, + withReasons: true, + privateAttributes: [], + tags: new ApplicationTags({}), + serviceEndpoints: new ServiceEndpoints('', ''), + pollInterval: 1000, + userAgentHeaderName: 'user-agent', + trackEventModifier: (event) => event, + hooks: [], + inspectors: [], + }; + const mockedFetch = mockFetch('{"flagA": true}', 200); + platform = { + crypto: new PlatformCrypto(), + info: new PlatformInfo(config.logger), + requests: { + createEventSource: jest.fn((streamUri: string = '', options: any = {}) => ({ + streamUri, + options, + onclose: jest.fn(), + addEventListener: jest.fn(), + close: jest.fn(), + })), + fetch: mockedFetch, + getEventSourceCapabilities: jest.fn(), + }, + storage: new PlatformStorage(config.logger), + encoding: new PlatformEncoding(), + } as unknown as jest.Mocked; + + flagManager = { + loadCached: jest.fn(), + get: jest.fn(), + getAll: jest.fn(), + init: jest.fn(), + upsert: jest.fn(), + on: jest.fn(), + off: jest.fn(), + } as unknown as jest.Mocked; + + rnConfig = { initialConnectionMode: 'streaming' } as ValidatedOptions; + baseHeaders = {}; + emitter = { + emit: jest.fn(), + } as unknown as jest.Mocked; + diagnosticsManager = {} as unknown as jest.Mocked; + + mobileDataManager = new MobileDataManager( + platform, + flagManager, + 'test-credential', + config, + rnConfig, + () => ({ + pathGet(encoding: Encoding, _plainContextString: string): string { + return `/msdk/evalx/contexts/${base64UrlEncode(_plainContextString, encoding)}`; + }, + pathReport(_encoding: Encoding, _plainContextString: string): string { + return `/msdk/evalx/context`; + }, + pathPing(_encoding: Encoding, _plainContextString: string): string { + // Note: if you are seeing this error, it is a coding error. This DataSourcePaths implementation is for polling endpoints. /ping is not currently + // used in a polling situation. It is probably the case that this was called by streaming logic erroneously. + throw new Error('Ping for polling unsupported.'); + }, + }), + () => ({ + pathGet(encoding: Encoding, _plainContextString: string): string { + return `/meval/${base64UrlEncode(_plainContextString, encoding)}`; + }, + pathReport(_encoding: Encoding, _plainContextString: string): string { + return `/meval`; + }, + pathPing(_encoding: Encoding, _plainContextString: string): string { + return `/mping`; + }, + }), + baseHeaders, + emitter, + diagnosticsManager, + ); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should initialize with the correct initial connection mode', () => { + expect(mobileDataManager.getConnectionMode()).toBe('streaming'); + }); + + it('should set and get connection mode', async () => { + await mobileDataManager.setConnectionMode('polling'); + expect(mobileDataManager.getConnectionMode()).toBe('polling'); + + await mobileDataManager.setConnectionMode('streaming'); + expect(mobileDataManager.getConnectionMode()).toBe('streaming'); + + await mobileDataManager.setConnectionMode('offline'); + expect(mobileDataManager.getConnectionMode()).toBe('offline'); + }); + + it('should log when connection mode remains the same', async () => { + const initialMode = mobileDataManager.getConnectionMode(); + await mobileDataManager.setConnectionMode(initialMode); + expect(logger.debug).toHaveBeenCalledWith( + `[MobileDataManager] setConnectionMode ignored. Mode is already '${initialMode}'.`, + ); + expect(mobileDataManager.getConnectionMode()).toBe(initialMode); + }); + + it('uses streaming when the connection mode is streaming', async () => { + mobileDataManager.setConnectionMode('streaming'); + const context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); + const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false }; + const identifyResolve = jest.fn(); + const identifyReject = jest.fn(); + + await mobileDataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + + expect(platform.requests.createEventSource).toHaveBeenCalled(); + expect(platform.requests.fetch).not.toHaveBeenCalled(); + }); + + it('uses polling when the connection mode is polling', async () => { + mobileDataManager.setConnectionMode('polling'); + const context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); + const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false }; + const identifyResolve = jest.fn(); + const identifyReject = jest.fn(); + + await mobileDataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + + expect(platform.requests.createEventSource).not.toHaveBeenCalled(); + expect(platform.requests.fetch).toHaveBeenCalled(); + }); + + it('makes no connection when offline', async () => { + mobileDataManager.setConnectionMode('offline'); + const context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); + const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false }; + const identifyResolve = jest.fn(); + const identifyReject = jest.fn(); + + await mobileDataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + + expect(platform.requests.createEventSource).not.toHaveBeenCalled(); + expect(platform.requests.fetch).not.toHaveBeenCalled(); + }); + + it('should load cached flags and resolve the identify', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); + const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false }; + const identifyResolve = jest.fn(); + const identifyReject = jest.fn(); + + flagManager.loadCached.mockResolvedValue(true); + + await mobileDataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + + expect(logger.debug).toHaveBeenCalledWith( + '[MobileDataManager] Identify completing with cached flags', + ); + + expect(flagManager.loadCached).toHaveBeenCalledWith(context); + expect(identifyResolve).toHaveBeenCalled(); + }); + + it('should log that it loaded cached values, but is waiting for the network result', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); + const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: true }; + const identifyResolve = jest.fn(); + const identifyReject = jest.fn(); + + flagManager.loadCached.mockResolvedValue(true); + + await mobileDataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + + expect(logger.debug).toHaveBeenCalledWith( + '[MobileDataManager] Identify - Flags loaded from cache, but identify was requested with "waitForNetworkResults"', + ); + + expect(flagManager.loadCached).toHaveBeenCalledWith(context); + expect(identifyResolve).not.toHaveBeenCalled(); + expect(identifyReject).not.toHaveBeenCalled(); + }); + + it('should handle offline identify without cache', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); + const identifyOptions: LDIdentifyOptions = {}; + const identifyResolve = jest.fn(); + const identifyReject = jest.fn(); + + await mobileDataManager.setConnectionMode('offline'); + flagManager.loadCached.mockResolvedValue(false); + + await mobileDataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + + expect(logger.debug).toHaveBeenCalledWith( + '[MobileDataManager] Offline identify - no cached flags, using defaults or already loaded flags.', + ); + + expect(flagManager.loadCached).toHaveBeenCalledWith(context); + expect(identifyResolve).toHaveBeenCalled(); + expect(identifyReject).not.toHaveBeenCalled(); + }); + + it('should handle offline identify with cache', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); + const identifyOptions: LDIdentifyOptions = {}; + const identifyResolve = jest.fn(); + const identifyReject = jest.fn(); + + await mobileDataManager.setConnectionMode('offline'); + flagManager.loadCached.mockResolvedValue(true); + + await mobileDataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + + expect(logger.debug).toHaveBeenCalledWith( + '[MobileDataManager] Offline identify - using cached flags.', + ); + + expect(flagManager.loadCached).toHaveBeenCalledWith(context); + expect(identifyResolve).toHaveBeenCalled(); + expect(identifyReject).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/sdk/react-native/src/ReactNativeLDClient.storage.test.ts b/packages/sdk/react-native/__tests__/ReactNativeLDClient.storage.test.ts similarity index 93% rename from packages/sdk/react-native/src/ReactNativeLDClient.storage.test.ts rename to packages/sdk/react-native/__tests__/ReactNativeLDClient.storage.test.ts index 26b6dd7c4..53bde5c12 100644 --- a/packages/sdk/react-native/src/ReactNativeLDClient.storage.test.ts +++ b/packages/sdk/react-native/__tests__/ReactNativeLDClient.storage.test.ts @@ -1,6 +1,6 @@ import { AutoEnvAttributes, LDLogger } from '@launchdarkly/js-client-sdk-common'; -import ReactNativeLDClient from './ReactNativeLDClient'; +import ReactNativeLDClient from '../src/ReactNativeLDClient'; it('uses custom storage', async () => { // This test just validates that the custom storage instance is being called. diff --git a/packages/sdk/react-native/src/ReactNativeLDClient.test.ts b/packages/sdk/react-native/__tests__/ReactNativeLDClient.test.ts similarity index 92% rename from packages/sdk/react-native/src/ReactNativeLDClient.test.ts rename to packages/sdk/react-native/__tests__/ReactNativeLDClient.test.ts index 8f79acc41..56ddebcbf 100644 --- a/packages/sdk/react-native/src/ReactNativeLDClient.test.ts +++ b/packages/sdk/react-native/__tests__/ReactNativeLDClient.test.ts @@ -1,11 +1,11 @@ import { AutoEnvAttributes, LDLogger, Response } from '@launchdarkly/js-client-sdk-common'; -import createPlatform from './platform'; -import PlatformCrypto from './platform/crypto'; -import PlatformEncoding from './platform/PlatformEncoding'; -import PlatformInfo from './platform/PlatformInfo'; -import PlatformStorage from './platform/PlatformStorage'; -import ReactNativeLDClient from './ReactNativeLDClient'; +import createPlatform from '../src/platform'; +import PlatformCrypto from '../src/platform/crypto'; +import PlatformEncoding from '../src/platform/PlatformEncoding'; +import PlatformInfo from '../src/platform/PlatformInfo'; +import PlatformStorage from '../src/platform/PlatformStorage'; +import ReactNativeLDClient from '../src/ReactNativeLDClient'; function mockResponse(value: string, statusCode: number) { const response: Response = { @@ -34,7 +34,7 @@ function mockFetch(value: string, statusCode: number = 200) { return f; } -jest.mock('./platform', () => ({ +jest.mock('../src/platform', () => ({ __esModule: true, default: jest.fn((logger: LDLogger) => ({ crypto: new PlatformCrypto(), @@ -42,6 +42,7 @@ jest.mock('./platform', () => ({ requests: { createEventSource: jest.fn(), fetch: jest.fn(), + getEventSourceCapabilities: jest.fn(), }, encoding: new PlatformEncoding(), storage: new PlatformStorage(logger), @@ -70,6 +71,7 @@ it('uses correct default diagnostic url', () => { requests: { createEventSource: jest.fn(), fetch: mockedFetch, + getEventSourceCapabilities: jest.fn(), }, encoding: new PlatformEncoding(), storage: new PlatformStorage(logger), @@ -97,6 +99,7 @@ it('uses correct default analytics event url', async () => { requests: { createEventSource: createMockEventSource, fetch: mockedFetch, + getEventSourceCapabilities: jest.fn(), }, encoding: new PlatformEncoding(), storage: new PlatformStorage(logger), @@ -128,6 +131,7 @@ it('uses correct default polling url', async () => { requests: { createEventSource: jest.fn(), fetch: mockedFetch, + getEventSourceCapabilities: jest.fn(), }, encoding: new PlatformEncoding(), storage: new PlatformStorage(logger), @@ -158,6 +162,7 @@ it('uses correct default streaming url', (done) => { requests: { createEventSource: mockedCreateEventSource, fetch: jest.fn(), + getEventSourceCapabilities: jest.fn(), }, encoding: new PlatformEncoding(), storage: new PlatformStorage(logger), @@ -197,6 +202,7 @@ it('includes authorization header for polling', async () => { requests: { createEventSource: jest.fn(), fetch: mockedFetch, + getEventSourceCapabilities: jest.fn(), }, encoding: new PlatformEncoding(), storage: new PlatformStorage(logger), @@ -231,6 +237,7 @@ it('includes authorization header for streaming', (done) => { requests: { createEventSource: mockedCreateEventSource, fetch: jest.fn(), + getEventSourceCapabilities: jest.fn(), }, encoding: new PlatformEncoding(), storage: new PlatformStorage(logger), diff --git a/packages/sdk/react-native/src/fromExternal/react-native-sse/EventSource.test.ts b/packages/sdk/react-native/__tests__/fromExternal/react-native-sse/EventSource.test.ts similarity index 83% rename from packages/sdk/react-native/src/fromExternal/react-native-sse/EventSource.test.ts rename to packages/sdk/react-native/__tests__/fromExternal/react-native-sse/EventSource.test.ts index 268215b51..2073d1a0f 100644 --- a/packages/sdk/react-native/src/fromExternal/react-native-sse/EventSource.test.ts +++ b/packages/sdk/react-native/__tests__/fromExternal/react-native-sse/EventSource.test.ts @@ -1,12 +1,19 @@ -import { type EventName } from '@launchdarkly/js-client-sdk-common'; -import { createLogger } from '@launchdarkly/private-js-mocks'; +import { type EventName, LDLogger } from '@launchdarkly/js-client-sdk-common'; -import EventSource, { backoff, jitter } from './EventSource'; +import EventSource, { + backoff, + jitter, +} from '../../../src/fromExternal/react-native-sse/EventSource'; -let logger: ReturnType; +let logger: LDLogger; beforeEach(() => { - logger = createLogger(); + logger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }; }); describe('EventSource', () => { @@ -71,12 +78,12 @@ describe('EventSource', () => { test('getNextRetryDelay', () => { // @ts-ignore - const delay0 = eventSource.getNextRetryDelay(); + const delay0 = eventSource._getNextRetryDelay(); // @ts-ignore - const delay1 = eventSource.getNextRetryDelay(); + const delay1 = eventSource._getNextRetryDelay(); // @ts-ignore - expect(eventSource.retryCount).toEqual(2); + expect(eventSource._retryCount).toEqual(2); expect(delay0).toEqual(556); expect(delay1).toEqual(1001); }); @@ -95,7 +102,7 @@ describe('EventSource', () => { jest.runAllTimers(); // This forces it to reconnect. // @ts-ignore - eventSource.tryConnect(); + eventSource._tryConnect(); jest.runAllTimers(); expect(logger.debug).toHaveBeenNthCalledWith( diff --git a/packages/sdk/react-native/src/options.test.ts b/packages/sdk/react-native/__tests__/options.test.ts similarity index 96% rename from packages/sdk/react-native/src/options.test.ts rename to packages/sdk/react-native/__tests__/options.test.ts index 91f179a02..e219d28aa 100644 --- a/packages/sdk/react-native/src/options.test.ts +++ b/packages/sdk/react-native/__tests__/options.test.ts @@ -1,7 +1,7 @@ import { LDLogger } from '@launchdarkly/js-client-sdk-common'; -import validateOptions, { filterToBaseOptions } from './options'; -import { RNStorage } from './RNOptions'; +import validateOptions, { filterToBaseOptions } from '../src/options'; +import { RNStorage } from '../src/RNOptions'; it('logs no warnings when all configuration is valid', () => { const logger: LDLogger = { diff --git a/packages/sdk/react-native/src/platform/ConnectionManager.test.ts b/packages/sdk/react-native/__tests__/platform/ConnectionManager.test.ts similarity index 99% rename from packages/sdk/react-native/src/platform/ConnectionManager.test.ts rename to packages/sdk/react-native/__tests__/platform/ConnectionManager.test.ts index 2ce2db3d2..9bc8e20c1 100644 --- a/packages/sdk/react-native/src/platform/ConnectionManager.test.ts +++ b/packages/sdk/react-native/__tests__/platform/ConnectionManager.test.ts @@ -6,7 +6,7 @@ import { ConnectionManager, NetworkState, StateDetector, -} from './ConnectionManager'; +} from '../../src/platform/ConnectionManager'; function mockDestination(): ConnectionDestination { return { diff --git a/packages/sdk/react-native/__tests__/platform/PlatformEncoding.test.ts b/packages/sdk/react-native/__tests__/platform/PlatformEncoding.test.ts new file mode 100644 index 000000000..1b3ed3f80 --- /dev/null +++ b/packages/sdk/react-native/__tests__/platform/PlatformEncoding.test.ts @@ -0,0 +1,13 @@ +import PlatformEncoding from '../../src/platform/PlatformEncoding'; + +it('can base64 a basic ASCII string', () => { + const encoding = new PlatformEncoding(); + expect(encoding.btoa('toaster')).toEqual('dG9hc3Rlcg=='); +}); + +it('can base64 a unicode string containing multi-byte character', () => { + const encoding = new PlatformEncoding(); + expect(encoding.btoa('✇⽊❽⾵⊚▴ⶊ↺➹≈⋟⚥⤅⊈ⲏⷨ⾭Ⲗ⑲▯ⶋₐℛ⬎⿌🦄')).toEqual( + '4pyH4r2K4p294r614oqa4pa04raK4oa64p654omI4ouf4pql4qSF4oqI4rKP4reo4r6t4rKW4pGy4pav4raL4oKQ4oSb4qyO4r+M8J+mhA==', + ); +}); diff --git a/packages/sdk/react-native/src/platform/crypto/PlatformHasher.test.ts b/packages/sdk/react-native/__tests__/platform/crypto/PlatformHasher.test.ts similarity index 96% rename from packages/sdk/react-native/src/platform/crypto/PlatformHasher.test.ts rename to packages/sdk/react-native/__tests__/platform/crypto/PlatformHasher.test.ts index 38135983c..ae416add2 100644 --- a/packages/sdk/react-native/src/platform/crypto/PlatformHasher.test.ts +++ b/packages/sdk/react-native/__tests__/platform/crypto/PlatformHasher.test.ts @@ -1,4 +1,4 @@ -import PlatformHasher from './PlatformHasher'; +import PlatformHasher from '../../../src/platform/crypto/PlatformHasher'; /** * The links below are different from js-sha256 and are useful to verify the diff --git a/packages/sdk/react-native/src/provider/LDProvider.test.tsx b/packages/sdk/react-native/__tests__/provider/LDProvider.test.tsx similarity index 84% rename from packages/sdk/react-native/src/provider/LDProvider.test.tsx rename to packages/sdk/react-native/__tests__/provider/LDProvider.test.tsx index 4d5d9a5db..fb208b947 100644 --- a/packages/sdk/react-native/src/provider/LDProvider.test.tsx +++ b/packages/sdk/react-native/__tests__/provider/LDProvider.test.tsx @@ -2,13 +2,13 @@ import { render } from '@testing-library/react'; import { AutoEnvAttributes, LDContext, LDOptions } from '@launchdarkly/js-client-sdk-common'; -import { useLDClient } from '../hooks'; -import ReactNativeLDClient from '../ReactNativeLDClient'; -import LDProvider from './LDProvider'; -import setupListeners from './setupListeners'; +import { useLDClient } from '../../src/hooks'; +import LDProvider from '../../src/provider/LDProvider'; +import setupListeners from '../../src/provider/setupListeners'; +import ReactNativeLDClient from '../../src/ReactNativeLDClient'; -jest.mock('../ReactNativeLDClient'); -jest.mock('./setupListeners'); +jest.mock('../../src/ReactNativeLDClient'); +jest.mock('../../src/provider/setupListeners'); const TestApp = () => { const ldClient = useLDClient(); diff --git a/packages/sdk/react-native/src/provider/setupListeners.test.ts b/packages/sdk/react-native/__tests__/provider/setupListeners.test.ts similarity index 82% rename from packages/sdk/react-native/src/provider/setupListeners.test.ts rename to packages/sdk/react-native/__tests__/provider/setupListeners.test.ts index 4d6878a29..98a45a990 100644 --- a/packages/sdk/react-native/src/provider/setupListeners.test.ts +++ b/packages/sdk/react-native/__tests__/provider/setupListeners.test.ts @@ -1,11 +1,11 @@ import { AutoEnvAttributes } from '@launchdarkly/js-client-sdk-common'; -import ReactNativeLDClient from '../ReactNativeLDClient'; -import setupListeners from './setupListeners'; +import setupListeners from '../../src/provider/setupListeners'; +import ReactNativeLDClient from '../../src/ReactNativeLDClient'; import resetAllMocks = jest.resetAllMocks; -jest.mock('../ReactNativeLDClient'); +jest.mock('../../src/ReactNativeLDClient'); describe('setupListeners', () => { let ldc: ReactNativeLDClient; diff --git a/packages/sdk/react-native/example/.detoxrc.js b/packages/sdk/react-native/example/.detoxrc.js index d6b62c9e2..3dc2ceb12 100644 --- a/packages/sdk/react-native/example/.detoxrc.js +++ b/packages/sdk/react-native/example/.detoxrc.js @@ -50,7 +50,7 @@ module.exports = { emulator: { type: 'android.emulator', device: { - avdName: 'Pixel_3a_API_33_arm64-v8a', + avdName: 'Pixel_4_API_30', }, }, }, diff --git a/packages/sdk/react-native/example/.gitignore b/packages/sdk/react-native/example/.gitignore index 877c0f430..017aa78aa 100644 --- a/packages/sdk/react-native/example/.gitignore +++ b/packages/sdk/react-native/example/.gitignore @@ -39,3 +39,6 @@ ios android !yarn.lock + +# detox +artifacts diff --git a/packages/sdk/react-native/example/app.json b/packages/sdk/react-native/example/app.json index 681b269d3..fd5b4ed4d 100644 --- a/packages/sdk/react-native/example/app.json +++ b/packages/sdk/react-native/example/app.json @@ -25,6 +25,7 @@ }, "web": { "favicon": "./assets/favicon.png" - } + }, + "plugins": ["@config-plugins/detox"] } } diff --git a/packages/sdk/react-native/example/e2e/starter.test.ts b/packages/sdk/react-native/example/e2e/starter.test.ts index af4f80c74..bd6461249 100644 --- a/packages/sdk/react-native/example/e2e/starter.test.ts +++ b/packages/sdk/react-native/example/e2e/starter.test.ts @@ -1,42 +1,37 @@ import { by, device, element, expect, waitFor } from 'detox'; -describe('Example', () => { +describe('given the example application', () => { beforeAll(async () => { await device.launchApp({ newInstance: true, launchArgs: { - detoxURLBlacklistRegex: '\\("^https://clientstream.launchdarkly.com/meval"\\)', + // Detox will wait for HTTP requests to complete. This prevents detox from waiting for + // requests matching this URL to complete. + detoxURLBlacklistRegex: '\\("^https://clientstream.launchdarkly.com/meval.*"\\)', }, }); }); - // For speed, all tests are sequential and dependent. - // beforeEach(async () => { - // await device.reloadReactNative(); - // }); - - afterAll(async () => { - await device.terminateApp(); - }); - - test('app loads and renders correctly', async () => { + it('loads and renders correctly with default values', async () => { await expect(element(by.text(/welcome to launchdarkly/i))).toBeVisible(); - await expect(element(by.text(/my-boolean-flag-1: false/i))).toBeVisible(); + await expect(element(by.text(/sample-feature: false/i))).toBeVisible(); }); - test('identify', async () => { - await element(by.id('userKey')).typeText('test-user'); + it('can identify and evaluate with non-default values', async () => { + const featureFlagKey = process.env.LAUNCHDARKLY_FLAG_KEY ?? 'sample-feature'; + await element(by.id('userKey')).typeText('example-user-key'); + await element(by.id('flagKey')).replaceText(featureFlagKey); await element(by.text(/identify/i)).tap(); - await waitFor(element(by.text(/my-boolean-flag-1: true/i))) + await waitFor(element(by.text(new RegExp(`${featureFlagKey}: true`)))) .toBeVisible() .withTimeout(2000); }); - test('variation', async () => { - await element(by.id('flagKey')).replaceText('my-boolean-flag-2'); + it('can set a flag and has defaults for a non-existent flag', async () => { + await element(by.id('flagKey')).replaceText('not-found-flag'); - await waitFor(element(by.text(/my-boolean-flag-2: true/i))) + await waitFor(element(by.text(/not-found-flag: false/i))) .toBeVisible() .withTimeout(2000); }); diff --git a/packages/sdk/react-native/example/package.json b/packages/sdk/react-native/example/package.json index 35ee7790b..a79b3d473 100644 --- a/packages/sdk/react-native/example/package.json +++ b/packages/sdk/react-native/example/package.json @@ -31,6 +31,7 @@ }, "devDependencies": { "@babel/core": "^7.20.0", + "@config-plugins/detox": "^8.0.0", "@types/detox": "^18.1.0", "@types/jest": "^29.5.11", "@types/node": "^20.10.5", diff --git a/packages/sdk/react-native/example/src/welcome.tsx b/packages/sdk/react-native/example/src/welcome.tsx index 4a76d4d29..f726fe865 100644 --- a/packages/sdk/react-native/example/src/welcome.tsx +++ b/packages/sdk/react-native/example/src/welcome.tsx @@ -5,7 +5,7 @@ import { ConnectionMode } from '@launchdarkly/js-client-sdk-common'; import { useBoolVariation, useLDClient } from '@launchdarkly/react-native-client-sdk'; export default function Welcome() { - const [flagKey, setFlagKey] = useState('my-boolean-flag-1'); + const [flagKey, setFlagKey] = useState('sample-feature'); const [userKey, setUserKey] = useState(''); const flagValue = useBoolVariation(flagKey, false); const ldc = useLDClient(); diff --git a/packages/sdk/react-native/package.json b/packages/sdk/react-native/package.json index f58e9ddfe..667998dbb 100644 --- a/packages/sdk/react-native/package.json +++ b/packages/sdk/react-native/package.json @@ -1,6 +1,6 @@ { "name": "@launchdarkly/react-native-client-sdk", - "version": "10.6.0", + "version": "10.9.1", "description": "React Native LaunchDarkly SDK", "homepage": "https://github.com/launchdarkly/js-core/tree/main/packages/sdk/react-native", "repository": { @@ -34,21 +34,18 @@ "prettier": "prettier --write '**/*.@(js|ts|tsx|json|css)' --ignore-path ../../../.prettierignore", "test": "jest", "coverage": "yarn test --coverage", - "check": "yarn prettier && yarn lint && yarn build && yarn test", - "android": "yarn && yarn ./example && yarn build && (cd example/ && yarn android-release)", - "ios": "yarn && yarn ./example && yarn build && (cd example/ && yarn ios-go)" + "check": "yarn prettier && yarn lint && yarn build && yarn test" }, "peerDependencies": { "react": "*", "react-native": "*" }, "dependencies": { - "@launchdarkly/js-client-sdk-common": "1.6.0", + "@launchdarkly/js-client-sdk-common": "1.11.0", "@react-native-async-storage/async-storage": "^1.21.0", "base64-js": "^1.5.1" }, "devDependencies": { - "@launchdarkly/private-js-mocks": "0.0.1", "@testing-library/react": "^14.1.2", "@trivago/prettier-plugin-sort-imports": "^4.1.1", "@types/jest": "^29.5.11", diff --git a/packages/sdk/react-native/src/MobileDataManager.ts b/packages/sdk/react-native/src/MobileDataManager.ts new file mode 100644 index 000000000..bf50fdea0 --- /dev/null +++ b/packages/sdk/react-native/src/MobileDataManager.ts @@ -0,0 +1,174 @@ +import { + BaseDataManager, + Configuration, + ConnectionMode, + Context, + DataSourcePaths, + FlagManager, + internal, + LDEmitter, + LDHeaders, + LDIdentifyOptions, + makeRequestor, + Platform, +} from '@launchdarkly/js-client-sdk-common'; + +import { ValidatedOptions } from './options'; + +const logTag = '[MobileDataManager]'; + +export default class MobileDataManager extends BaseDataManager { + // Not implemented yet. + protected networkAvailable: boolean = true; + protected connectionMode: ConnectionMode = 'streaming'; + + constructor( + platform: Platform, + flagManager: FlagManager, + credential: string, + config: Configuration, + private readonly _rnConfig: ValidatedOptions, + getPollingPaths: () => DataSourcePaths, + getStreamingPaths: () => DataSourcePaths, + baseHeaders: LDHeaders, + emitter: LDEmitter, + diagnosticsManager?: internal.DiagnosticsManager, + ) { + super( + platform, + flagManager, + credential, + config, + getPollingPaths, + getStreamingPaths, + baseHeaders, + emitter, + diagnosticsManager, + ); + this.connectionMode = _rnConfig.initialConnectionMode; + } + + private _debugLog(message: any, ...args: any[]) { + this.logger.debug(`${logTag} ${message}`, ...args); + } + + override async identify( + identifyResolve: () => void, + identifyReject: (err: Error) => void, + context: Context, + identifyOptions?: LDIdentifyOptions, + ): Promise { + this.context = context; + const offline = this.connectionMode === 'offline'; + // In offline mode we do not support waiting for results. + const waitForNetworkResults = !!identifyOptions?.waitForNetworkResults && !offline; + + const loadedFromCache = await this.flagManager.loadCached(context); + if (loadedFromCache && !waitForNetworkResults) { + this._debugLog('Identify completing with cached flags'); + identifyResolve(); + } + if (loadedFromCache && waitForNetworkResults) { + this._debugLog( + 'Identify - Flags loaded from cache, but identify was requested with "waitForNetworkResults"', + ); + } + + if (this.connectionMode === 'offline') { + if (loadedFromCache) { + this._debugLog('Offline identify - using cached flags.'); + } else { + this._debugLog( + 'Offline identify - no cached flags, using defaults or already loaded flags.', + ); + identifyResolve(); + } + } else { + // Context has been validated in LDClientImpl.identify + this._setupConnection(context, identifyResolve, identifyReject); + } + } + + private _setupConnection( + context: Context, + identifyResolve?: () => void, + identifyReject?: (err: Error) => void, + ) { + const rawContext = Context.toLDContext(context)!; + + const plainContextString = JSON.stringify(Context.toLDContext(context)); + const requestor = makeRequestor( + plainContextString, + this.config.serviceEndpoints, + this.getPollingPaths(), + this.platform.requests, + this.platform.encoding!, + this.baseHeaders, + [], + this.config.useReport, + this.config.withReasons, + ); + + this.updateProcessor?.close(); + switch (this.connectionMode) { + case 'streaming': + this.createStreamingProcessor( + rawContext, + context, + requestor, + identifyResolve, + identifyReject, + ); + break; + case 'polling': + this.createPollingProcessor( + rawContext, + context, + requestor, + identifyResolve, + identifyReject, + ); + break; + default: + break; + } + this.updateProcessor!.start(); + } + + setNetworkAvailability(available: boolean): void { + this.networkAvailable = available; + } + + async setConnectionMode(mode: ConnectionMode): Promise { + if (this.connectionMode === mode) { + this._debugLog(`setConnectionMode ignored. Mode is already '${mode}'.`); + return; + } + + this.connectionMode = mode; + this._debugLog(`setConnectionMode ${mode}.`); + + switch (mode) { + case 'offline': + this.updateProcessor?.close(); + break; + case 'polling': + case 'streaming': + if (this.context) { + // identify will start the update processor + this._setupConnection(this.context); + } + + break; + default: + this.logger.warn( + `Unknown ConnectionMode: ${mode}. Only 'offline', 'streaming', and 'polling' are supported.`, + ); + break; + } + } + + getConnectionMode(): ConnectionMode { + return this.connectionMode; + } +} diff --git a/packages/sdk/react-native/src/RNOptions.ts b/packages/sdk/react-native/src/RNOptions.ts index c48c22e3a..ba6264440 100644 --- a/packages/sdk/react-native/src/RNOptions.ts +++ b/packages/sdk/react-native/src/RNOptions.ts @@ -1,4 +1,4 @@ -import { LDOptions } from '@launchdarkly/js-client-sdk-common'; +import { ConnectionMode, LDOptions } from '@launchdarkly/js-client-sdk-common'; /** * Interface for providing custom storage implementations for react Native. @@ -95,6 +95,16 @@ export interface RNSpecificOptions { * Defaults to @react-native-async-storage/async-storage. */ readonly storage?: RNStorage; + + /** + * Sets the mode to use for connections when the SDK is initialized. + * + * @remarks + * Possible values are offline, streaming, or polling. See {@link ConnectionMode} for more information. + * + * @defaultValue streaming. + */ + initialConnectionMode?: ConnectionMode; } export default interface RNOptions extends LDOptions, RNSpecificOptions {} diff --git a/packages/sdk/react-native/src/RNStateDetector.ts b/packages/sdk/react-native/src/RNStateDetector.ts index 2a9cf3c05..7694c475c 100644 --- a/packages/sdk/react-native/src/RNStateDetector.ts +++ b/packages/sdk/react-native/src/RNStateDetector.ts @@ -21,28 +21,28 @@ function translateAppState(state: AppStateStatus): ApplicationState { * @internal */ export default class RNStateDetector implements StateDetector { - private applicationStateListener?: (state: ApplicationState) => void; - private networkStateListener?: (state: NetworkState) => void; + private _applicationStateListener?: (state: ApplicationState) => void; + private _networkStateListener?: (state: NetworkState) => void; constructor() { AppState.addEventListener('change', (state: AppStateStatus) => { - this.applicationStateListener?.(translateAppState(state)); + this._applicationStateListener?.(translateAppState(state)); }); } setApplicationStateListener(fn: (state: ApplicationState) => void): void { - this.applicationStateListener = fn; + this._applicationStateListener = fn; // When you listen provide the current state immediately. - this.applicationStateListener(translateAppState(AppState.currentState)); + this._applicationStateListener(translateAppState(AppState.currentState)); } setNetworkStateListener(fn: (state: NetworkState) => void): void { - this.networkStateListener = fn; + this._networkStateListener = fn; // Not implemented. } stopListening(): void { - this.applicationStateListener = undefined; - this.networkStateListener = undefined; + this._applicationStateListener = undefined; + this._networkStateListener = undefined; } } diff --git a/packages/sdk/react-native/src/ReactNativeLDClient.ts b/packages/sdk/react-native/src/ReactNativeLDClient.ts index 44558a5cb..4fcd1b677 100644 --- a/packages/sdk/react-native/src/ReactNativeLDClient.ts +++ b/packages/sdk/react-native/src/ReactNativeLDClient.ts @@ -3,12 +3,17 @@ import { AutoEnvAttributes, base64UrlEncode, BasicLogger, + type Configuration, ConnectionMode, + Encoding, + FlagManager, internal, LDClientImpl, - type LDContext, + LDEmitter, + LDHeaders, } from '@launchdarkly/js-client-sdk-common'; +import MobileDataManager from './MobileDataManager'; import validateOptions, { filterToBaseOptions } from './options'; import createPlatform from './platform'; import { ConnectionDestination, ConnectionManager } from './platform/ConnectionManager'; @@ -29,7 +34,7 @@ import RNStateDetector from './RNStateDetector'; * ``` */ export default class ReactNativeLDClient extends LDClientImpl { - private connectionManager: ConnectionManager; + private _connectionManager: ConnectionManager; /** * Creates an instance of the LaunchDarkly client. * @@ -57,18 +62,63 @@ export default class ReactNativeLDClient extends LDClientImpl { }; const validatedRnOptions = validateOptions(options, logger); + const platform = createPlatform(logger, validatedRnOptions.storage); super( sdkKey, autoEnvAttributes, - createPlatform(logger, validatedRnOptions.storage), + platform, { ...filterToBaseOptions(options), logger }, + ( + flagManager: FlagManager, + configuration: Configuration, + baseHeaders: LDHeaders, + emitter: LDEmitter, + diagnosticsManager?: internal.DiagnosticsManager, + ) => + new MobileDataManager( + platform, + flagManager, + sdkKey, + configuration, + validatedRnOptions, + () => ({ + pathGet(encoding: Encoding, _plainContextString: string): string { + return `/msdk/evalx/contexts/${base64UrlEncode(_plainContextString, encoding)}`; + }, + pathReport(_encoding: Encoding, _plainContextString: string): string { + return `/msdk/evalx/context`; + }, + pathPing(_encoding: Encoding, _plainContextString: string): string { + // Note: if you are seeing this error, it is a coding error. This DataSourcePaths implementation is for polling endpoints. /ping is not currently + // used in a polling situation. It is probably the case that this was called by streaming logic erroneously. + throw new Error('Ping for polling unsupported.'); + }, + }), + () => ({ + pathGet(encoding: Encoding, _plainContextString: string): string { + return `/meval/${base64UrlEncode(_plainContextString, encoding)}`; + }, + pathReport(_encoding: Encoding, _plainContextString: string): string { + return `/meval`; + }, + pathPing(_encoding: Encoding, _plainContextString: string): string { + return `/mping`; + }, + }), + baseHeaders, + emitter, + diagnosticsManager, + ), internalOptions, ); + this.setEventSendingEnabled(!this.isOffline(), false); + + const dataManager = this.dataManager as MobileDataManager; const destination: ConnectionDestination = { setNetworkAvailability: (available: boolean) => { - this.setNetworkAvailability(available); + dataManager.setNetworkAvailability(available); }, setEventSendingEnabled: (enabled: boolean, flush: boolean) => { this.setEventSendingEnabled(enabled, flush); @@ -76,12 +126,12 @@ export default class ReactNativeLDClient extends LDClientImpl { setConnectionMode: async (mode: ConnectionMode) => { // Pass the connection mode to the base implementation. // The RN implementation will pass the connection mode through the connection manager. - this.baseSetConnectionMode(mode); + dataManager.setConnectionMode(mode); }, }; const initialConnectionMode = options.initialConnectionMode ?? 'streaming'; - this.connectionManager = new ConnectionManager( + this._connectionManager = new ConnectionManager( logger, { initialConnectionMode, @@ -94,28 +144,24 @@ export default class ReactNativeLDClient extends LDClientImpl { ); } - private baseSetConnectionMode(mode: ConnectionMode) { - // Jest had problems with calls to super from nested arrow functions, so this method proxies the call. - super.setConnectionMode(mode); - } - - private encodeContext(context: LDContext) { - return base64UrlEncode(JSON.stringify(context), this.platform.encoding!); - } - - override createStreamUriPath(context: LDContext) { - return `/meval/${this.encodeContext(context)}`; + async setConnectionMode(mode: ConnectionMode): Promise { + // Set the connection mode before setting offline, in case there is any mode transition work + // such as flushing on entering the background. + this._connectionManager.setConnectionMode(mode); + // For now the data source connection and the event processing state are connected. + this._connectionManager.setOffline(mode === 'offline'); } - override createPollUriPath(context: LDContext): string { - return `/msdk/evalx/contexts/${this.encodeContext(context)}`; + /** + * Gets the SDK connection mode. + */ + getConnectionMode(): ConnectionMode { + const dataManager = this.dataManager as MobileDataManager; + return dataManager.getConnectionMode(); } - override async setConnectionMode(mode: ConnectionMode): Promise { - // Set the connection mode before setting offline, in case there is any mode transition work - // such as flushing on entering the background. - this.connectionManager.setConnectionMode(mode); - // For now the data source connection and the event processing state are connected. - this.connectionManager.setOffline(mode === 'offline'); + isOffline() { + const dataManager = this.dataManager as MobileDataManager; + return dataManager.getConnectionMode() === 'offline'; } } diff --git a/packages/sdk/react-native/src/fromExternal/react-native-sse/EventSource.ts b/packages/sdk/react-native/src/fromExternal/react-native-sse/EventSource.ts index 710f3257c..9a5d87dc2 100644 --- a/packages/sdk/react-native/src/fromExternal/react-native-sse/EventSource.ts +++ b/packages/sdk/react-native/src/fromExternal/react-native-sse/EventSource.ts @@ -39,29 +39,29 @@ export default class EventSource { OPEN = 1; CLOSED = 2; - private lastEventId: undefined | string; - private lastIndexProcessed = 0; - private eventType: undefined | EventType; - private status = this.CONNECTING; - private eventHandlers: any = { + private _lastEventId: undefined | string; + private _lastIndexProcessed = 0; + private _eventType: undefined | EventType; + private _status = this.CONNECTING; + private _eventHandlers: any = { open: [], message: [], error: [], close: [], }; - private method: string; - private timeout: number; - private withCredentials: boolean; - private headers: Record; - private body: any; - private url: string; - private xhr: XMLHttpRequest = new XMLHttpRequest(); - private connectTimer: any; - private retryAndHandleError?: (err: any) => boolean; - private initialRetryDelayMillis: number = 1000; - private retryCount: number = 0; - private logger?: any; + private _method: string; + private _timeout: number; + private _withCredentials: boolean; + private _headers: Record; + private _body: any; + private _url: string; + private _xhr: XMLHttpRequest = new XMLHttpRequest(); + private _connectTimer: any; + private _retryAndHandleError?: (err: any) => boolean; + private _initialRetryDelayMillis: number = 1000; + private _retryCount: number = 0; + private _logger?: any; constructor(url: string, options?: EventSourceOptions) { const opts = { @@ -69,163 +69,163 @@ export default class EventSource { ...options, }; - this.url = url; - this.method = opts.method!; - this.timeout = opts.timeout!; - this.withCredentials = opts.withCredentials!; - this.headers = opts.headers!; - this.body = opts.body; - this.retryAndHandleError = opts.retryAndHandleError; - this.initialRetryDelayMillis = opts.initialRetryDelayMillis!; - this.logger = opts.logger; - - this.tryConnect(true); + this._url = url; + this._method = opts.method!; + this._timeout = opts.timeout!; + this._withCredentials = opts.withCredentials!; + this._headers = opts.headers!; + this._body = opts.body; + this._retryAndHandleError = opts.retryAndHandleError; + this._initialRetryDelayMillis = opts.initialRetryDelayMillis!; + this._logger = opts.logger; + + this._tryConnect(true); } - private getNextRetryDelay() { - const delay = jitter(backoff(this.initialRetryDelayMillis, this.retryCount)); - this.retryCount += 1; + private _getNextRetryDelay() { + const delay = jitter(backoff(this._initialRetryDelayMillis, this._retryCount)); + this._retryCount += 1; return delay; } - private tryConnect(initialConnection: boolean = false) { - let delay = initialConnection ? 0 : this.getNextRetryDelay(); + private _tryConnect(initialConnection: boolean = false) { + let delay = initialConnection ? 0 : this._getNextRetryDelay(); if (initialConnection) { - this.logger?.debug(`[EventSource] opening new connection.`); + this._logger?.debug(`[EventSource] opening new connection.`); } else { - this.logger?.debug(`[EventSource] Will open new connection in ${delay} ms.`); + this._logger?.debug(`[EventSource] Will open new connection in ${delay} ms.`); this.dispatch('retry', { type: 'retry', delayMillis: delay }); } - this.connectTimer = setTimeout(() => { + this._connectTimer = setTimeout(() => { if (!initialConnection) { this.close(); } - this.open(); + this._open(); }, delay); } - private open() { + private _open() { try { - this.lastIndexProcessed = 0; - this.status = this.CONNECTING; - this.xhr.open(this.method, this.url, true); + this._lastIndexProcessed = 0; + this._status = this.CONNECTING; + this._xhr.open(this._method, this._url, true); - if (this.withCredentials) { - this.xhr.withCredentials = true; + if (this._withCredentials) { + this._xhr.withCredentials = true; } - this.xhr.setRequestHeader('Accept', 'text/event-stream'); - this.xhr.setRequestHeader('Cache-Control', 'no-cache'); - this.xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest'); + this._xhr.setRequestHeader('Accept', 'text/event-stream'); + this._xhr.setRequestHeader('Cache-Control', 'no-cache'); + this._xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest'); - if (this.headers) { - Object.entries(this.headers).forEach(([key, value]) => { - this.xhr.setRequestHeader(key, value); + if (this._headers) { + Object.entries(this._headers).forEach(([key, value]) => { + this._xhr.setRequestHeader(key, value); }); } - if (typeof this.lastEventId !== 'undefined') { - this.xhr.setRequestHeader('Last-Event-ID', this.lastEventId); + if (typeof this._lastEventId !== 'undefined') { + this._xhr.setRequestHeader('Last-Event-ID', this._lastEventId); } - this.xhr.timeout = this.timeout; + this._xhr.timeout = this._timeout; - this.xhr.onreadystatechange = () => { - if (this.status === this.CLOSED) { + this._xhr.onreadystatechange = () => { + if (this._status === this.CLOSED) { return; } - this.logger?.debug( + this._logger?.debug( `[EventSource][onreadystatechange] ReadyState: ${ - XMLReadyStateMap[this.xhr.readyState] || 'Unknown' - }(${this.xhr.readyState}), status: ${this.xhr.status}`, + XMLReadyStateMap[this._xhr.readyState] || 'Unknown' + }(${this._xhr.readyState}), status: ${this._xhr.status}`, ); if ( - this.xhr.readyState !== XMLHttpRequest.DONE && - this.xhr.readyState !== XMLHttpRequest.LOADING + this._xhr.readyState !== XMLHttpRequest.DONE && + this._xhr.readyState !== XMLHttpRequest.LOADING ) { return; } - if (this.xhr.status >= 200 && this.xhr.status < 400) { - if (this.status === this.CONNECTING) { - this.retryCount = 0; - this.status = this.OPEN; + if (this._xhr.status >= 200 && this._xhr.status < 400) { + if (this._status === this.CONNECTING) { + this._retryCount = 0; + this._status = this.OPEN; this.dispatch('open', { type: 'open' }); - this.logger?.debug('[EventSource][onreadystatechange][OPEN] Connection opened.'); + this._logger?.debug('[EventSource][onreadystatechange][OPEN] Connection opened.'); } // retry from server gets set here - this.handleEvent(this.xhr.responseText || ''); + this._handleEvent(this._xhr.responseText || ''); - if (this.xhr.readyState === XMLHttpRequest.DONE) { - this.logger?.debug('[EventSource][onreadystatechange][DONE] Operation done.'); - this.tryConnect(); + if (this._xhr.readyState === XMLHttpRequest.DONE) { + this._logger?.debug('[EventSource][onreadystatechange][DONE] Operation done.'); + this._tryConnect(); } } else { - this.status = this.ERROR; + this._status = this.ERROR; this.dispatch('error', { type: 'error', - message: this.xhr.responseText, - xhrStatus: this.xhr.status, - xhrState: this.xhr.readyState, + message: this._xhr.responseText, + xhrStatus: this._xhr.status, + xhrState: this._xhr.readyState, }); - if (this.xhr.readyState === XMLHttpRequest.DONE) { - this.logger?.debug('[EventSource][onreadystatechange][ERROR] Response status error.'); + if (this._xhr.readyState === XMLHttpRequest.DONE) { + this._logger?.debug('[EventSource][onreadystatechange][ERROR] Response status error.'); - if (!this.retryAndHandleError) { + if (!this._retryAndHandleError) { // by default just try and reconnect if there's an error. - this.tryConnect(); + this._tryConnect(); } else { // custom retry logic taking into account status codes. - const shouldRetry = this.retryAndHandleError({ - status: this.xhr.status, - message: this.xhr.responseText, + const shouldRetry = this._retryAndHandleError({ + status: this._xhr.status, + message: this._xhr.responseText, }); if (shouldRetry) { - this.tryConnect(); + this._tryConnect(); } } } } }; - this.xhr.onerror = () => { - if (this.status === this.CLOSED) { + this._xhr.onerror = () => { + if (this._status === this.CLOSED) { return; } - this.status = this.ERROR; + this._status = this.ERROR; this.dispatch('error', { type: 'error', - message: this.xhr.responseText, - xhrStatus: this.xhr.status, - xhrState: this.xhr.readyState, + message: this._xhr.responseText, + xhrStatus: this._xhr.status, + xhrState: this._xhr.readyState, }); }; - if (this.body) { - this.xhr.send(this.body); + if (this._body) { + this._xhr.send(this._body); } else { - this.xhr.send(); + this._xhr.send(); } - if (this.timeout > 0) { + if (this._timeout > 0) { setTimeout(() => { - if (this.xhr.readyState === XMLHttpRequest.LOADING) { + if (this._xhr.readyState === XMLHttpRequest.LOADING) { this.dispatch('error', { type: 'timeout' }); this.close(); } - }, this.timeout); + }, this._timeout); } } catch (e: any) { - this.status = this.ERROR; + this._status = this.ERROR; this.dispatch('error', { type: 'exception', message: e.message, @@ -234,12 +234,12 @@ export default class EventSource { } } - private handleEvent(response: string) { - const parts = response.slice(this.lastIndexProcessed).split('\n'); + private _handleEvent(response: string) { + const parts = response.slice(this._lastIndexProcessed).split('\n'); const indexOfDoubleNewline = response.lastIndexOf('\n\n'); if (indexOfDoubleNewline !== -1) { - this.lastIndexProcessed = indexOfDoubleNewline + 2; + this._lastIndexProcessed = indexOfDoubleNewline + 2; } let data = []; @@ -250,7 +250,7 @@ export default class EventSource { for (let i = 0; i < parts.length; i++) { line = parts[i].replace(/^(\s|\u00A0)+|(\s|\u00A0)+$/g, ''); if (line.indexOf('event') === 0) { - this.eventType = line.replace(/event:?\s*/, '') as EventType; + this._eventType = line.replace(/event:?\s*/, '') as EventType; } else if (line.indexOf('retry') === 0) { retry = parseInt(line.replace(/retry:?\s*/, ''), 10); if (!Number.isNaN(retry)) { @@ -260,62 +260,62 @@ export default class EventSource { } else if (line.indexOf('data') === 0) { data.push(line.replace(/data:?\s*/, '')); } else if (line.indexOf('id:') === 0) { - this.lastEventId = line.replace(/id:?\s*/, ''); + this._lastEventId = line.replace(/id:?\s*/, ''); } else if (line.indexOf('id') === 0) { - this.lastEventId = undefined; + this._lastEventId = undefined; } else if (line === '') { if (data.length > 0) { - const eventType = this.eventType || 'message'; + const eventType = this._eventType || 'message'; const event: any = { type: eventType, data: data.join('\n'), - url: this.url, - lastEventId: this.lastEventId, + url: this._url, + lastEventId: this._lastEventId, }; this.dispatch(eventType, event); data = []; - this.eventType = undefined; + this._eventType = undefined; } } } } addEventListener>(type: T, listener: EventSourceListener): void { - if (this.eventHandlers[type] === undefined) { - this.eventHandlers[type] = []; + if (this._eventHandlers[type] === undefined) { + this._eventHandlers[type] = []; } - this.eventHandlers[type].push(listener); + this._eventHandlers[type].push(listener); } removeEventListener>(type: T, listener: EventSourceListener): void { - if (this.eventHandlers[type] !== undefined) { - this.eventHandlers[type] = this.eventHandlers[type].filter( + if (this._eventHandlers[type] !== undefined) { + this._eventHandlers[type] = this._eventHandlers[type].filter( (handler: EventSourceListener) => handler !== listener, ); } } removeAllEventListeners>(type?: T) { - const availableTypes = Object.keys(this.eventHandlers); + const availableTypes = Object.keys(this._eventHandlers); if (type === undefined) { availableTypes.forEach((eventType) => { - this.eventHandlers[eventType] = []; + this._eventHandlers[eventType] = []; }); } else { if (!availableTypes.includes(type)) { throw Error(`[EventSource] '${type}' type is not supported event type.`); } - this.eventHandlers[type] = []; + this._eventHandlers[type] = []; } } dispatch>(type: T, data: EventSourceEvent) { - this.eventHandlers[type]?.forEach((handler: EventSourceListener) => handler(data)); + this._eventHandlers[type]?.forEach((handler: EventSourceListener) => handler(data)); switch (type) { case 'open': @@ -325,7 +325,7 @@ export default class EventSource { this.onclose(); break; case 'error': - this.logger?.debug(`[EventSource][dispatch][ERROR]: ${JSON.stringify(data)}`); + this._logger?.debug(`[EventSource][dispatch][ERROR]: ${JSON.stringify(data)}`); this.onerror(data); break; case 'retry': @@ -337,17 +337,17 @@ export default class EventSource { } close() { - this.status = this.CLOSED; - clearTimeout(this.connectTimer); - if (this.xhr) { - this.xhr.abort(); + this._status = this.CLOSED; + clearTimeout(this._connectTimer); + if (this._xhr) { + this._xhr.abort(); } this.dispatch('close', { type: 'close' }); } getStatus() { - return this.status; + return this._status; } onopen() {} diff --git a/packages/sdk/react-native/src/options.ts b/packages/sdk/react-native/src/options.ts index ffc2b57e3..e981b4a31 100644 --- a/packages/sdk/react-native/src/options.ts +++ b/packages/sdk/react-native/src/options.ts @@ -1,4 +1,5 @@ import { + ConnectionMode, LDLogger, LDOptions, OptionMessages, @@ -8,18 +9,29 @@ import { import RNOptions, { RNStorage } from './RNOptions'; +class ConnectionModeValidator implements TypeValidator { + is(u: unknown): u is ConnectionMode { + return u === 'offline' || u === 'streaming' || u === 'polling'; + } + getType(): string { + return 'ConnectionMode (offline | streaming | polling)'; + } +} + export interface ValidatedOptions { runInBackground: boolean; automaticNetworkHandling: boolean; automaticBackgroundHandling: boolean; storage?: RNStorage; + initialConnectionMode: ConnectionMode; } -const optDefaults = { +const optDefaults: ValidatedOptions = { runInBackground: false, automaticNetworkHandling: true, automaticBackgroundHandling: true, storage: undefined, + initialConnectionMode: 'streaming', }; const validators: { [Property in keyof RNOptions]: TypeValidator | undefined } = { @@ -27,6 +39,7 @@ const validators: { [Property in keyof RNOptions]: TypeValidator | undefined } = automaticNetworkHandling: TypeValidators.Boolean, automaticBackgroundHandling: TypeValidators.Boolean, storage: TypeValidators.Object, + initialConnectionMode: new ConnectionModeValidator(), }; export function filterToBaseOptions(opts: RNOptions): LDOptions { @@ -48,6 +61,7 @@ export default function validateOptions(opts: RNOptions, logger: LDLogger): Vali const value = opts[key]; if (value !== undefined) { if (validator.is(value)) { + // @ts-ignore The type inference has some problems here. output[key as keyof ValidatedOptions] = value as any; } else { logger.warn(OptionMessages.wrongOptionType(key, validator.getType(), typeof value)); diff --git a/packages/sdk/react-native/src/platform/ConnectionManager.ts b/packages/sdk/react-native/src/platform/ConnectionManager.ts index 8f39f76f0..4463b824b 100644 --- a/packages/sdk/react-native/src/platform/ConnectionManager.ts +++ b/packages/sdk/react-native/src/platform/ConnectionManager.ts @@ -76,61 +76,61 @@ export interface ConnectionManagerConfig { * @internal */ export class ConnectionManager { - private applicationState: ApplicationState = ApplicationState.Foreground; - private networkState: NetworkState = NetworkState.Available; - private offline: boolean = false; - private currentConnectionMode: ConnectionMode; + private _applicationState: ApplicationState = ApplicationState.Foreground; + private _networkState: NetworkState = NetworkState.Available; + private _offline: boolean = false; + private _currentConnectionMode: ConnectionMode; constructor( - private readonly logger: LDLogger, - private readonly config: ConnectionManagerConfig, - private readonly destination: ConnectionDestination, - private readonly detector: StateDetector, + private readonly _logger: LDLogger, + private readonly _config: ConnectionManagerConfig, + private readonly _destination: ConnectionDestination, + private readonly _detector: StateDetector, ) { - this.currentConnectionMode = config.initialConnectionMode; - if (config.automaticBackgroundHandling) { - detector.setApplicationStateListener((state) => { - this.applicationState = state; - this.handleState(); + this._currentConnectionMode = _config.initialConnectionMode; + if (_config.automaticBackgroundHandling) { + _detector.setApplicationStateListener((state) => { + this._applicationState = state; + this._handleState(); }); } - if (config.automaticNetworkHandling) { - detector.setNetworkStateListener((state) => { - this.networkState = state; - this.handleState(); + if (_config.automaticNetworkHandling) { + _detector.setNetworkStateListener((state) => { + this._networkState = state; + this._handleState(); }); } } public setOffline(offline: boolean): void { - this.offline = offline; - this.handleState(); + this._offline = offline; + this._handleState(); } public setConnectionMode(mode: ConnectionMode) { - this.currentConnectionMode = mode; - this.handleState(); + this._currentConnectionMode = mode; + this._handleState(); } public close() { - this.detector.stopListening(); + this._detector.stopListening(); } - private handleState(): void { - this.logger.debug(`Handling state: ${this.applicationState}:${this.networkState}`); + private _handleState(): void { + this._logger.debug(`Handling state: ${this._applicationState}:${this._networkState}`); - switch (this.networkState) { + switch (this._networkState) { case NetworkState.Unavailable: - this.destination.setNetworkAvailability(false); + this._destination.setNetworkAvailability(false); break; case NetworkState.Available: - this.destination.setNetworkAvailability(true); - switch (this.applicationState) { + this._destination.setNetworkAvailability(true); + switch (this._applicationState) { case ApplicationState.Foreground: - this.setForegroundAvailable(); + this._setForegroundAvailable(); break; case ApplicationState.Background: - this.setBackgroundAvailable(); + this._setBackgroundAvailable(); break; default: break; @@ -141,25 +141,25 @@ export class ConnectionManager { } } - private setForegroundAvailable(): void { - if (this.offline) { - this.destination.setConnectionMode('offline'); + private _setForegroundAvailable(): void { + if (this._offline) { + this._destination.setConnectionMode('offline'); // Don't attempt to flush. If the user wants to flush when entering offline // mode, then they can do that directly. - this.destination.setEventSendingEnabled(false, false); + this._destination.setEventSendingEnabled(false, false); return; } // Currently the foreground mode will always be whatever the last active // connection mode was. - this.destination.setConnectionMode(this.currentConnectionMode); - this.destination.setEventSendingEnabled(true, false); + this._destination.setConnectionMode(this._currentConnectionMode); + this._destination.setEventSendingEnabled(true, false); } - private setBackgroundAvailable(): void { - if (!this.config.runInBackground) { - this.destination.setConnectionMode('offline'); - this.destination.setEventSendingEnabled(false, true); + private _setBackgroundAvailable(): void { + if (!this._config.runInBackground) { + this._destination.setConnectionMode('offline'); + this._destination.setEventSendingEnabled(false, true); return; } @@ -167,6 +167,6 @@ export class ConnectionManager { // If connections in the background are allowed, then use the same mode // as is configured for the foreground. - this.setForegroundAvailable(); + this._setForegroundAvailable(); } } diff --git a/packages/sdk/react-native/src/platform/PlatformInfo.ts b/packages/sdk/react-native/src/platform/PlatformInfo.ts index ef68b4480..ee84d4909 100644 --- a/packages/sdk/react-native/src/platform/PlatformInfo.ts +++ b/packages/sdk/react-native/src/platform/PlatformInfo.ts @@ -4,7 +4,7 @@ import { name, version } from '../../package.json'; import { ldApplication, ldDevice } from './autoEnv'; export default class PlatformInfo implements Info { - constructor(private readonly logger: LDLogger) {} + constructor(private readonly _logger: LDLogger) {} platformData(): PlatformData { const data = { @@ -13,7 +13,7 @@ export default class PlatformInfo implements Info { ld_device: ldDevice, }; - this.logger.debug(`platformData: ${JSON.stringify(data, null, 2)}`); + this._logger.debug(`platformData: ${JSON.stringify(data, null, 2)}`); return data; } @@ -24,7 +24,7 @@ export default class PlatformInfo implements Info { userAgentBase: 'ReactNativeClient', }; - this.logger.debug(`sdkData: ${JSON.stringify(data, null, 2)}`); + this._logger.debug(`sdkData: ${JSON.stringify(data, null, 2)}`); return data; } } diff --git a/packages/sdk/react-native/src/platform/PlatformRequests.ts b/packages/sdk/react-native/src/platform/PlatformRequests.ts index 5b345d295..4cbe4f6da 100644 --- a/packages/sdk/react-native/src/platform/PlatformRequests.ts +++ b/packages/sdk/react-native/src/platform/PlatformRequests.ts @@ -1,6 +1,7 @@ import type { EventName, EventSource, + EventSourceCapabilities, EventSourceInitDict, LDLogger, Options, @@ -11,16 +12,26 @@ import type { import RNEventSource from '../fromExternal/react-native-sse'; export default class PlatformRequests implements Requests { - constructor(private readonly logger: LDLogger) {} + constructor(private readonly _logger: LDLogger) {} createEventSource(url: string, eventSourceInitDict: EventSourceInitDict): EventSource { return new RNEventSource(url, { + method: eventSourceInitDict.method ?? 'GET', headers: eventSourceInitDict.headers, + body: eventSourceInitDict.body, retryAndHandleError: eventSourceInitDict.errorFilter, - logger: this.logger, + logger: this._logger, }); } + getEventSourceCapabilities(): EventSourceCapabilities { + return { + readTimeout: false, + headers: true, + customMethod: true, + }; + } + fetch(url: string, options?: Options): Promise { // @ts-ignore return fetch(url, options); diff --git a/packages/sdk/react-native/src/platform/PlatformStorage.ts b/packages/sdk/react-native/src/platform/PlatformStorage.ts index 33e003c82..9460bdf37 100644 --- a/packages/sdk/react-native/src/platform/PlatformStorage.ts +++ b/packages/sdk/react-native/src/platform/PlatformStorage.ts @@ -3,7 +3,7 @@ import type { LDLogger, Storage } from '@launchdarkly/js-client-sdk-common'; import AsyncStorage from './ConditionalAsyncStorage'; export default class PlatformStorage implements Storage { - constructor(private readonly logger: LDLogger) {} + constructor(private readonly _logger: LDLogger) {} async clear(key: string): Promise { await AsyncStorage.removeItem(key); } @@ -13,7 +13,7 @@ export default class PlatformStorage implements Storage { const value = await AsyncStorage.getItem(key); return value ?? null; } catch (error) { - this.logger.debug(`Error getting AsyncStorage key: ${key}, error: ${error}`); + this._logger.debug(`Error getting AsyncStorage key: ${key}, error: ${error}`); return null; } } @@ -22,7 +22,7 @@ export default class PlatformStorage implements Storage { try { await AsyncStorage.setItem(key, value); } catch (error) { - this.logger.debug(`Error saving AsyncStorage key: ${key}, value: ${value}, error: ${error}`); + this._logger.debug(`Error saving AsyncStorage key: ${key}, value: ${value}, error: ${error}`); } } } diff --git a/packages/sdk/react-native/src/platform/crypto/PlatformHasher.ts b/packages/sdk/react-native/src/platform/crypto/PlatformHasher.ts index 081af2624..a1bdd6f7d 100644 --- a/packages/sdk/react-native/src/platform/crypto/PlatformHasher.ts +++ b/packages/sdk/react-native/src/platform/crypto/PlatformHasher.ts @@ -5,12 +5,12 @@ import { base64FromByteArray } from '../../polyfills'; import { SupportedHashAlgorithm, SupportedOutputEncoding } from './types'; export default class PlatformHasher implements LDHasher { - private hasher: Hasher; + private _hasher: Hasher; constructor(algorithm: SupportedHashAlgorithm, hmacKey?: string) { switch (algorithm) { case 'sha256': - this.hasher = hmacKey ? sha256.hmac.create(hmacKey) : sha256.create(); + this._hasher = hmacKey ? sha256.hmac.create(hmacKey) : sha256.create(); break; default: throw new Error(`Unsupported hash algorithm: ${algorithm}. Only sha256 is supported.`); @@ -20,9 +20,9 @@ export default class PlatformHasher implements LDHasher { digest(encoding: SupportedOutputEncoding): string { switch (encoding) { case 'base64': - return base64FromByteArray(new Uint8Array(this.hasher.arrayBuffer())); + return base64FromByteArray(new Uint8Array(this._hasher.arrayBuffer())); case 'hex': - return this.hasher.hex(); + return this._hasher.hex(); default: throw new Error( `unsupported output encoding: ${encoding}. Only base64 and hex are supported.`, @@ -31,7 +31,7 @@ export default class PlatformHasher implements LDHasher { } update(data: string): this { - this.hasher.update(data); + this._hasher.update(data); return this; } } diff --git a/packages/sdk/react-native/src/polyfills/btoa.ts b/packages/sdk/react-native/src/polyfills/btoa.ts index d9b29bfac..4b5952388 100644 --- a/packages/sdk/react-native/src/polyfills/btoa.ts +++ b/packages/sdk/react-native/src/polyfills/btoa.ts @@ -1,15 +1,10 @@ +/* eslint-disable no-bitwise */ import { fromByteArray } from 'base64-js'; -function convertToByteArray(s: string) { - const b = []; - for (let i = 0; i < s.length; i += 1) { - b.push(s.charCodeAt(i)); - } - return Uint8Array.from(b); -} +import toUtf8Array from './toUtf8Array'; export function btoa(s: string) { - return fromByteArray(convertToByteArray(s)); + return fromByteArray(toUtf8Array(s)); } export function base64FromByteArray(a: Uint8Array) { diff --git a/packages/sdk/react-native/src/polyfills/toUtf8Array.ts b/packages/sdk/react-native/src/polyfills/toUtf8Array.ts new file mode 100644 index 000000000..e58275435 --- /dev/null +++ b/packages/sdk/react-native/src/polyfills/toUtf8Array.ts @@ -0,0 +1,50 @@ +/* eslint-disable no-plusplus */ +/* eslint-disable no-bitwise */ + +// Originally from: https://github.com/google/closure-library/blob/a1f5a029c1b32eb4793a2d920aa52abc085e1bf7/closure/goog/crypt/crypt.js + +// Once React Native versions uniformly support TextEncoder this code can be removed. + +// Copyright 2008 The Closure Library Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS-IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export default function toUtf8Array(str: string): Uint8Array { + const out: number[] = []; + let p = 0; + for (let i = 0; i < str.length; i += 1) { + let c = str.charCodeAt(i); + if (c < 128) { + out[p++] = c; + } else if (c < 2048) { + out[p++] = (c >> 6) | 192; + out[p++] = (c & 63) | 128; + } else if ( + (c & 0xfc00) === 0xd800 && + i + 1 < str.length && + (str.charCodeAt(i + 1) & 0xfc00) === 0xdc00 + ) { + // Surrogate Pair + c = 0x10000 + ((c & 0x03ff) << 10) + (str.charCodeAt(++i) & 0x03ff); + out[p++] = (c >> 18) | 240; + out[p++] = ((c >> 12) & 63) | 128; + out[p++] = ((c >> 6) & 63) | 128; + out[p++] = (c & 63) | 128; + } else { + out[p++] = (c >> 12) | 224; + out[p++] = ((c >> 6) & 63) | 128; + out[p++] = (c & 63) | 128; + } + } + return Uint8Array.from(out); +} diff --git a/packages/sdk/react-universal/src/index.test.ts b/packages/sdk/react-universal/__tests__/index.test.ts similarity index 100% rename from packages/sdk/react-universal/src/index.test.ts rename to packages/sdk/react-universal/__tests__/index.test.ts diff --git a/packages/sdk/react-universal/src/ldClientRsc.ts b/packages/sdk/react-universal/src/ldClientRsc.ts index 8d74de47e..7fd333839 100644 --- a/packages/sdk/react-universal/src/ldClientRsc.ts +++ b/packages/sdk/react-universal/src/ldClientRsc.ts @@ -17,33 +17,33 @@ type PartialJSSdk = Omit, 'variationDetail'>; */ export class LDClientRsc implements PartialJSSdk { constructor( - private readonly ldContext: LDContext, - private readonly bootstrap: LDFlagSet, + private readonly _ldContext: LDContext, + private readonly _bootstrap: LDFlagSet, ) {} allFlags(): LDFlagSet { - return this.bootstrap; + return this._bootstrap; } getContext(): LDContext { - return this.ldContext; + return this._ldContext; } variation(key: string, defaultValue?: LDFlagValue): LDFlagValue { if (isServer) { // On the server during ssr, call variation for analytics purposes. - global.nodeSdk.variation(key, this.ldContext, defaultValue).then(/* ignore */); + global.nodeSdk.variation(key, this._ldContext, defaultValue).then(/* ignore */); } - return this.bootstrap[key] ?? defaultValue; + return this._bootstrap[key] ?? defaultValue; } variationDetail(key: string, defaultValue?: LDFlagValue): LDEvaluationDetail { if (isServer) { // On the server during ssr, call variation for analytics purposes. - global.nodeSdk.variationDetail(key, this.ldContext, defaultValue).then(/* ignore */); + global.nodeSdk.variationDetail(key, this._ldContext, defaultValue).then(/* ignore */); } - const { reason, variation: variationIndex } = this.bootstrap.$flagsState[key]; - return { value: this.bootstrap[key], reason, variationIndex }; + const { reason, variation: variationIndex } = this._bootstrap.$flagsState[key]; + return { value: this._bootstrap[key], reason, variationIndex }; } } diff --git a/packages/sdk/server-node/CHANGELOG.md b/packages/sdk/server-node/CHANGELOG.md index 37a0e2de3..c931ece1f 100644 --- a/packages/sdk/server-node/CHANGELOG.md +++ b/packages/sdk/server-node/CHANGELOG.md @@ -2,6 +2,61 @@ All notable changes to `@launchdarkly/node-server-sdk` will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org). +## [9.7.0](https://github.com/launchdarkly/js-core/compare/node-server-sdk-v9.6.1...node-server-sdk-v9.7.0) (2024-10-17) + + +### Features + +* Apply private property naming standard. Mangle browser private properties. ([#620](https://github.com/launchdarkly/js-core/issues/620)) ([3e6d404](https://github.com/launchdarkly/js-core/commit/3e6d404ae665c5cc7e5a1394a59c8f2c9d5d682a)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common bumped from 2.8.0 to 2.9.0 + +## [9.6.1](https://github.com/launchdarkly/js-core/compare/node-server-sdk-v9.6.0...node-server-sdk-v9.6.1) (2024-10-09) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common bumped from 2.7.0 to 2.8.0 + +## [9.6.0](https://github.com/launchdarkly/js-core/compare/node-server-sdk-v9.5.4...node-server-sdk-v9.6.0) (2024-09-26) + + +### Features + +* Add support for conditional event source capabilities. ([#577](https://github.com/launchdarkly/js-core/issues/577)) ([fe82500](https://github.com/launchdarkly/js-core/commit/fe82500f28cf8d8311502098aa6cc2e73932064e)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common bumped from 2.6.1 to 2.7.0 + +## [9.5.4](https://github.com/launchdarkly/js-core/compare/node-server-sdk-v9.5.3...node-server-sdk-v9.5.4) (2024-09-05) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common bumped from 2.6.0 to 2.6.1 + +## [9.5.3](https://github.com/launchdarkly/js-core/compare/node-server-sdk-v9.5.2...node-server-sdk-v9.5.3) (2024-09-03) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common bumped from 2.5.0 to 2.6.0 + ## [9.5.2](https://github.com/launchdarkly/js-core/compare/node-server-sdk-v9.5.1...node-server-sdk-v9.5.2) (2024-08-28) diff --git a/packages/sdk/server-node/__tests__/LDClientNode.listeners.test.ts b/packages/sdk/server-node/__tests__/LDClientNode.listeners.test.ts index 182488b15..2a8fd8cce 100644 --- a/packages/sdk/server-node/__tests__/LDClientNode.listeners.test.ts +++ b/packages/sdk/server-node/__tests__/LDClientNode.listeners.test.ts @@ -1,15 +1,18 @@ -import { integrations } from '@launchdarkly/js-server-sdk-common'; -import { createLogger } from '@launchdarkly/private-js-mocks'; +import { integrations, LDLogger } from '@launchdarkly/js-server-sdk-common'; import { Context, LDClient } from '../src'; import LDClientNode from '../src/LDClientNode'; -let logger: ReturnType; +let logger: LDLogger; beforeEach(() => { - logger = createLogger(); + logger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }; }); - describe('given an LDClient with test data', () => { let client: LDClient; let td: integrations.TestData; diff --git a/packages/sdk/server-node/__tests__/LDClientNode.test.ts b/packages/sdk/server-node/__tests__/LDClientNode.test.ts index 28d8cb1d6..8de453297 100644 --- a/packages/sdk/server-node/__tests__/LDClientNode.test.ts +++ b/packages/sdk/server-node/__tests__/LDClientNode.test.ts @@ -1,12 +1,16 @@ -import { LDContext } from '@launchdarkly/js-server-sdk-common'; -import { createLogger } from '@launchdarkly/private-js-mocks'; +import { LDContext, LDLogger } from '@launchdarkly/js-server-sdk-common'; import { init } from '../src'; -let logger: ReturnType; +let logger: LDLogger; beforeEach(() => { - logger = createLogger(); + logger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }; }); it('fires ready event in offline mode', (done) => { diff --git a/packages/sdk/server-node/__tests__/LDClientNode.tls.test.ts b/packages/sdk/server-node/__tests__/LDClientNode.tls.test.ts index 2034b49f4..11454bb43 100644 --- a/packages/sdk/server-node/__tests__/LDClientNode.tls.test.ts +++ b/packages/sdk/server-node/__tests__/LDClientNode.tls.test.ts @@ -6,15 +6,18 @@ import { TestHttpServer, } from 'launchdarkly-js-test-helpers'; -import { createLogger } from '@launchdarkly/private-js-mocks'; - -import { LDClient } from '../src'; +import { LDClient, LDLogger } from '../src'; import LDClientNode from '../src/LDClientNode'; -let logger: ReturnType; +let logger: LDLogger; beforeEach(() => { - logger = createLogger(); + logger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }; }); describe('When using a TLS connection', () => { diff --git a/packages/sdk/server-node/package.json b/packages/sdk/server-node/package.json index 4c2971c30..a5bd06afe 100644 --- a/packages/sdk/server-node/package.json +++ b/packages/sdk/server-node/package.json @@ -1,6 +1,6 @@ { "name": "@launchdarkly/node-server-sdk", - "version": "9.5.2", + "version": "9.7.0", "description": "LaunchDarkly Server-Side SDK for Node.js", "homepage": "https://github.com/launchdarkly/js-core/tree/main/packages/sdk/server-node", "repository": { @@ -45,12 +45,11 @@ }, "license": "Apache-2.0", "dependencies": { - "@launchdarkly/js-server-sdk-common": "2.5.0", + "@launchdarkly/js-server-sdk-common": "2.9.0", "https-proxy-agent": "^5.0.1", "launchdarkly-eventsource": "2.0.3" }, "devDependencies": { - "@launchdarkly/private-js-mocks": "0.0.1", "@trivago/prettier-plugin-sort-imports": "^4.1.1", "@types/jest": "^29.4.0", "@typescript-eslint/eslint-plugin": "^6.20.0", diff --git a/packages/sdk/server-node/src/BigSegmentsStoreStatusProviderNode.ts b/packages/sdk/server-node/src/BigSegmentsStoreStatusProviderNode.ts index 52e1c3acf..c397863db 100644 --- a/packages/sdk/server-node/src/BigSegmentsStoreStatusProviderNode.ts +++ b/packages/sdk/server-node/src/BigSegmentsStoreStatusProviderNode.ts @@ -10,18 +10,18 @@ import { Emits } from './Emits'; class BigSegmentStoreStatusProviderNode implements interfaces.BigSegmentStoreStatusProvider { emitter: EventEmitter = new EventEmitter(); - constructor(private readonly provider: BigSegmentStoreStatusProviderImpl) { - this.provider.setListener((status: interfaces.BigSegmentStoreStatus) => { + constructor(private readonly _provider: BigSegmentStoreStatusProviderImpl) { + this._provider.setListener((status: interfaces.BigSegmentStoreStatus) => { this.dispatch('change', status); }); } getStatus(): interfaces.BigSegmentStoreStatus | undefined { - return this.provider.getStatus(); + return this._provider.getStatus(); } requireStatus(): Promise { - return this.provider.requireStatus(); + return this._provider.requireStatus(); } dispatch(eventType: string, status: interfaces.BigSegmentStoreStatus) { diff --git a/packages/sdk/server-node/src/platform/HeaderWrapper.ts b/packages/sdk/server-node/src/platform/HeaderWrapper.ts index fc84250fc..166d73854 100644 --- a/packages/sdk/server-node/src/platform/HeaderWrapper.ts +++ b/packages/sdk/server-node/src/platform/HeaderWrapper.ts @@ -7,14 +7,14 @@ import { platform } from '@launchdarkly/js-server-sdk-common'; * @internal */ export default class HeaderWrapper implements platform.Headers { - private headers: http.IncomingHttpHeaders; + private _headers: http.IncomingHttpHeaders; constructor(headers: http.IncomingHttpHeaders) { - this.headers = headers; + this._headers = headers; } - private headerVal(name: string) { - const val = this.headers[name]; + private _headerVal(name: string) { + const val = this._headers[name]; if (val === undefined || val === null) { return null; } @@ -25,11 +25,11 @@ export default class HeaderWrapper implements platform.Headers { } get(name: string): string | null { - return this.headerVal(name); + return this._headerVal(name); } keys(): Iterable { - return Object.keys(this.headers); + return Object.keys(this._headers); } // We want to use generators here for the simplicity of maintaining @@ -55,6 +55,6 @@ export default class HeaderWrapper implements platform.Headers { } has(name: string): boolean { - return Object.prototype.hasOwnProperty.call(this.headers, name); + return Object.prototype.hasOwnProperty.call(this._headers, name); } } diff --git a/packages/sdk/server-node/src/platform/NodeInfo.ts b/packages/sdk/server-node/src/platform/NodeInfo.ts index 1494d8362..3f30355b3 100644 --- a/packages/sdk/server-node/src/platform/NodeInfo.ts +++ b/packages/sdk/server-node/src/platform/NodeInfo.ts @@ -19,7 +19,7 @@ function processPlatformName(name: string): string { } export default class NodeInfo implements platform.Info { - constructor(private readonly config: { wrapperName?: string; wrapperVersion?: string }) {} + constructor(private readonly _config: { wrapperName?: string; wrapperVersion?: string }) {} platformData(): platform.PlatformData { return { os: { @@ -39,8 +39,8 @@ export default class NodeInfo implements platform.Info { name: packageJson.name, version: packageJson.version, userAgentBase: 'NodeJSClient', - wrapperName: this.config.wrapperName, - wrapperVersion: this.config.wrapperVersion, + wrapperName: this._config.wrapperName, + wrapperVersion: this._config.wrapperVersion, }; } } diff --git a/packages/sdk/server-node/src/platform/NodeRequests.ts b/packages/sdk/server-node/src/platform/NodeRequests.ts index c95118519..8c4b48755 100644 --- a/packages/sdk/server-node/src/platform/NodeRequests.ts +++ b/packages/sdk/server-node/src/platform/NodeRequests.ts @@ -7,6 +7,7 @@ import { HttpsProxyAgentOptions } from 'https-proxy-agent'; import { EventSource as LDEventSource } from 'launchdarkly-eventsource'; import { + EventSourceCapabilities, LDLogger, LDProxyOptions, LDTLSOptions, @@ -92,18 +93,18 @@ function createAgent( } export default class NodeRequests implements platform.Requests { - private agent: https.Agent | http.Agent | undefined; + private _agent: https.Agent | http.Agent | undefined; - private tlsOptions: LDTLSOptions | undefined; + private _tlsOptions: LDTLSOptions | undefined; - private hasProxy: boolean = false; + private _hasProxy: boolean = false; - private hasProxyAuth: boolean = false; + private _hasProxyAuth: boolean = false; constructor(tlsOptions?: LDTLSOptions, proxyOptions?: LDProxyOptions, logger?: LDLogger) { - this.agent = createAgent(tlsOptions, proxyOptions, logger); - this.hasProxy = !!proxyOptions; - this.hasProxyAuth = !!proxyOptions?.auth; + this._agent = createAgent(tlsOptions, proxyOptions, logger); + this._hasProxy = !!proxyOptions; + this._hasProxyAuth = !!proxyOptions?.auth; } fetch(url: string, options: platform.Options = {}): Promise { @@ -127,7 +128,7 @@ export default class NodeRequests implements platform.Requests { timeout: options.timeout, headers, method: options.method, - agent: this.agent, + agent: this._agent, }, (res) => resolve(new NodeResponse(res)), ); @@ -150,19 +151,27 @@ export default class NodeRequests implements platform.Requests { ): platform.EventSource { const expandedOptions = { ...eventSourceInitDict, - agent: this.agent, - tlsParams: this.tlsOptions, + agent: this._agent, + tlsParams: this._tlsOptions, maxBackoffMillis: 30 * 1000, jitterRatio: 0.5, }; return new LDEventSource(url, expandedOptions); } + getEventSourceCapabilities(): EventSourceCapabilities { + return { + readTimeout: true, + headers: true, + customMethod: true, + }; + } + usingProxy(): boolean { - return this.hasProxy; + return this._hasProxy; } usingProxyAuth(): boolean { - return this.hasProxyAuth; + return this._hasProxyAuth; } } diff --git a/packages/sdk/server-node/src/platform/NodeResponse.ts b/packages/sdk/server-node/src/platform/NodeResponse.ts index 2f765b23f..1309d276f 100644 --- a/packages/sdk/server-node/src/platform/NodeResponse.ts +++ b/packages/sdk/server-node/src/platform/NodeResponse.ts @@ -57,7 +57,7 @@ export default class NodeResponse implements platform.Response { }); } - private async wrappedWait(): Promise { + private async _wrappedWait(): Promise { this.listened = true; if (this.rejection) { throw this.rejection; @@ -66,11 +66,11 @@ export default class NodeResponse implements platform.Response { } text(): Promise { - return this.wrappedWait(); + return this._wrappedWait(); } async json(): Promise { - const stringValue = await this.wrappedWait(); + const stringValue = await this._wrappedWait(); return JSON.parse(stringValue); } } diff --git a/packages/sdk/vercel/CHANGELOG.md b/packages/sdk/vercel/CHANGELOG.md index 42aff3854..8275fe3ac 100644 --- a/packages/sdk/vercel/CHANGELOG.md +++ b/packages/sdk/vercel/CHANGELOG.md @@ -20,6 +20,51 @@ All notable changes to the LaunchDarkly SDK for Vercel Edge Config will be docum * dependencies * @launchdarkly/js-server-sdk-common-edge bumped from 2.2.1 to 2.2.2 +## [1.3.19](https://github.com/launchdarkly/js-core/compare/vercel-server-sdk-v1.3.18...vercel-server-sdk-v1.3.19) (2024-10-17) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common-edge bumped from 2.4.1 to 2.5.0 + +## [1.3.18](https://github.com/launchdarkly/js-core/compare/vercel-server-sdk-v1.3.17...vercel-server-sdk-v1.3.18) (2024-10-09) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common-edge bumped from 2.4.0 to 2.4.1 + +## [1.3.17](https://github.com/launchdarkly/js-core/compare/vercel-server-sdk-v1.3.16...vercel-server-sdk-v1.3.17) (2024-09-26) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common-edge bumped from 2.3.9 to 2.4.0 + +## [1.3.16](https://github.com/launchdarkly/js-core/compare/vercel-server-sdk-v1.3.15...vercel-server-sdk-v1.3.16) (2024-09-05) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common-edge bumped from 2.3.8 to 2.3.9 + +## [1.3.15](https://github.com/launchdarkly/js-core/compare/vercel-server-sdk-v1.3.14...vercel-server-sdk-v1.3.15) (2024-09-03) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common-edge bumped from 2.3.7 to 2.3.8 + ## [1.3.14](https://github.com/launchdarkly/js-core/compare/vercel-server-sdk-v1.3.13...vercel-server-sdk-v1.3.14) (2024-08-28) diff --git a/packages/sdk/vercel/src/createPlatformInfo.test.ts b/packages/sdk/vercel/__tests__/createPlatformInfo.test.ts similarity index 88% rename from packages/sdk/vercel/src/createPlatformInfo.test.ts rename to packages/sdk/vercel/__tests__/createPlatformInfo.test.ts index 6db8f24f9..5378dee11 100644 --- a/packages/sdk/vercel/src/createPlatformInfo.test.ts +++ b/packages/sdk/vercel/__tests__/createPlatformInfo.test.ts @@ -1,4 +1,4 @@ -import createPlatformInfo from './createPlatformInfo'; +import createPlatformInfo from '../src/createPlatformInfo'; const packageJson = require('../package.json'); diff --git a/packages/sdk/vercel/src/index.test.ts b/packages/sdk/vercel/__tests__/index.test.ts similarity index 98% rename from packages/sdk/vercel/src/index.test.ts rename to packages/sdk/vercel/__tests__/index.test.ts index 5c631f7f0..d95d2b107 100644 --- a/packages/sdk/vercel/src/index.test.ts +++ b/packages/sdk/vercel/__tests__/index.test.ts @@ -1,6 +1,6 @@ import { LDClient, LDContext } from '@launchdarkly/js-server-sdk-common-edge'; -import { init } from './index'; +import { init } from '../src/index'; import mockEdgeConfigClient from './utils/mockEdgeConfigClient'; import * as testData from './utils/testData.json'; diff --git a/packages/sdk/vercel/src/utils/mockEdgeConfigClient.ts b/packages/sdk/vercel/__tests__/utils/mockEdgeConfigClient.ts similarity index 100% rename from packages/sdk/vercel/src/utils/mockEdgeConfigClient.ts rename to packages/sdk/vercel/__tests__/utils/mockEdgeConfigClient.ts diff --git a/packages/sdk/vercel/src/utils/testData.json b/packages/sdk/vercel/__tests__/utils/testData.json similarity index 100% rename from packages/sdk/vercel/src/utils/testData.json rename to packages/sdk/vercel/__tests__/utils/testData.json diff --git a/packages/sdk/vercel/examples/complete/package.json b/packages/sdk/vercel/examples/complete/package.json index edcff2d5e..6e157f878 100644 --- a/packages/sdk/vercel/examples/complete/package.json +++ b/packages/sdk/vercel/examples/complete/package.json @@ -14,7 +14,7 @@ "@vercel/edge-config": "^1.1.0", "launchdarkly-js-client-sdk": "^3.1.3", "launchdarkly-react-client-sdk": "^3.0.6", - "next": "14.1.2", + "next": "14.2.10", "react": "18.2.0", "react-dom": "18.2.0" }, diff --git a/packages/sdk/vercel/examples/route-handler/package.json b/packages/sdk/vercel/examples/route-handler/package.json index 10dc82498..656ddd7ea 100644 --- a/packages/sdk/vercel/examples/route-handler/package.json +++ b/packages/sdk/vercel/examples/route-handler/package.json @@ -11,7 +11,7 @@ "dependencies": { "@launchdarkly/vercel-server-sdk": "workspace:^", "@vercel/edge-config": "^1.1.0", - "next": "14.1.2", + "next": "14.2.10", "react": "^18.2.0", "react-dom": "18.2.0" }, diff --git a/packages/sdk/vercel/package.json b/packages/sdk/vercel/package.json index f6352dce0..01c9b8b9d 100644 --- a/packages/sdk/vercel/package.json +++ b/packages/sdk/vercel/package.json @@ -1,6 +1,6 @@ { "name": "@launchdarkly/vercel-server-sdk", - "version": "1.3.14", + "version": "1.3.19", "description": "LaunchDarkly Server-Side SDK for Vercel Edge", "homepage": "https://github.com/launchdarkly/js-core/tree/main/packages/sdk/vercel", "repository": { @@ -36,7 +36,7 @@ "check": "yarn prettier && yarn lint && yarn build && yarn test" }, "dependencies": { - "@launchdarkly/js-server-sdk-common-edge": "2.3.7", + "@launchdarkly/js-server-sdk-common-edge": "2.5.0", "@vercel/edge-config": "^1.1.0", "crypto-js": "^4.1.1" }, diff --git a/packages/shared/akamai-edgeworker-sdk/CHANGELOG.md b/packages/shared/akamai-edgeworker-sdk/CHANGELOG.md index 97d379b83..e6406e891 100644 --- a/packages/shared/akamai-edgeworker-sdk/CHANGELOG.md +++ b/packages/shared/akamai-edgeworker-sdk/CHANGELOG.md @@ -86,6 +86,61 @@ All notable changes to the LaunchDarkly SDK for Akamai Workers will be documente * dependencies * @launchdarkly/js-server-sdk-common bumped from ^2.2.1 to ^2.2.2 +## [1.3.0](https://github.com/launchdarkly/js-core/compare/akamai-edgeworker-sdk-common-v1.2.1...akamai-edgeworker-sdk-common-v1.3.0) (2024-10-17) + + +### Features + +* Apply private property naming standard. Mangle browser private properties. ([#620](https://github.com/launchdarkly/js-core/issues/620)) ([3e6d404](https://github.com/launchdarkly/js-core/commit/3e6d404ae665c5cc7e5a1394a59c8f2c9d5d682a)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common bumped from ^2.8.0 to ^2.9.0 + +## [1.2.1](https://github.com/launchdarkly/js-core/compare/akamai-edgeworker-sdk-common-v1.2.0...akamai-edgeworker-sdk-common-v1.2.1) (2024-10-09) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common bumped from ^2.7.0 to ^2.8.0 + +## [1.2.0](https://github.com/launchdarkly/js-core/compare/akamai-edgeworker-sdk-common-v1.1.15...akamai-edgeworker-sdk-common-v1.2.0) (2024-09-26) + + +### Features + +* Add support for conditional event source capabilities. ([#577](https://github.com/launchdarkly/js-core/issues/577)) ([fe82500](https://github.com/launchdarkly/js-core/commit/fe82500f28cf8d8311502098aa6cc2e73932064e)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common bumped from ^2.6.1 to ^2.7.0 + +## [1.1.15](https://github.com/launchdarkly/js-core/compare/akamai-edgeworker-sdk-common-v1.1.14...akamai-edgeworker-sdk-common-v1.1.15) (2024-09-05) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common bumped from ^2.6.0 to ^2.6.1 + +## [1.1.14](https://github.com/launchdarkly/js-core/compare/akamai-edgeworker-sdk-common-v1.1.13...akamai-edgeworker-sdk-common-v1.1.14) (2024-09-03) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common bumped from ^2.5.0 to ^2.6.0 + ## [1.1.13](https://github.com/launchdarkly/js-core/compare/akamai-edgeworker-sdk-common-v1.1.12...akamai-edgeworker-sdk-common-v1.1.13) (2024-08-28) diff --git a/packages/shared/akamai-edgeworker-sdk/src/__tests__/featureStore/index.test.ts b/packages/shared/akamai-edgeworker-sdk/__tests__/featureStore/index.test.ts similarity index 98% rename from packages/shared/akamai-edgeworker-sdk/src/__tests__/featureStore/index.test.ts rename to packages/shared/akamai-edgeworker-sdk/__tests__/featureStore/index.test.ts index e692e3b71..ce243c6ed 100644 --- a/packages/shared/akamai-edgeworker-sdk/src/__tests__/featureStore/index.test.ts +++ b/packages/shared/akamai-edgeworker-sdk/__tests__/featureStore/index.test.ts @@ -1,6 +1,6 @@ import { AsyncStoreFacade, LDFeatureStore } from '@launchdarkly/js-server-sdk-common'; -import { EdgeFeatureStore, EdgeProvider } from '../../featureStore'; +import { EdgeFeatureStore, EdgeProvider } from '../../src/featureStore'; import * as testData from '../testData.json'; describe('EdgeFeatureStore', () => { diff --git a/packages/shared/akamai-edgeworker-sdk/src/__tests__/index.test.ts b/packages/shared/akamai-edgeworker-sdk/__tests__/index.test.ts similarity index 99% rename from packages/shared/akamai-edgeworker-sdk/src/__tests__/index.test.ts rename to packages/shared/akamai-edgeworker-sdk/__tests__/index.test.ts index aa8f16fe9..9b0264f82 100644 --- a/packages/shared/akamai-edgeworker-sdk/src/__tests__/index.test.ts +++ b/packages/shared/akamai-edgeworker-sdk/__tests__/index.test.ts @@ -1,4 +1,4 @@ -import { EdgeProvider, init, LDLogger, LDMultiKindContext, LDSingleKindContext } from '../..'; +import { EdgeProvider, init, LDLogger, LDMultiKindContext, LDSingleKindContext } from '../dist'; import * as testData from './testData.json'; const createClient = (sdkKey: string, mockLogger: LDLogger, mockEdgeProvider: EdgeProvider) => diff --git a/packages/shared/akamai-edgeworker-sdk/src/__tests__/platform/info/index.test.ts b/packages/shared/akamai-edgeworker-sdk/__tests__/platform/info/index.test.ts similarity index 84% rename from packages/shared/akamai-edgeworker-sdk/src/__tests__/platform/info/index.test.ts rename to packages/shared/akamai-edgeworker-sdk/__tests__/platform/info/index.test.ts index 9890146d6..7e6ef0abc 100644 --- a/packages/shared/akamai-edgeworker-sdk/src/__tests__/platform/info/index.test.ts +++ b/packages/shared/akamai-edgeworker-sdk/__tests__/platform/info/index.test.ts @@ -1,6 +1,6 @@ -import createPlatformInfo from '../../../platform/info'; +import createPlatformInfo from '../../../src/platform/info'; -const packageJson = require('../../../../package.json'); +const packageJson = require('../../../package.json'); describe('Akamai Platform Info', () => { const { name, version } = packageJson; diff --git a/packages/shared/akamai-edgeworker-sdk/src/__tests__/platform/requests.test.ts b/packages/shared/akamai-edgeworker-sdk/__tests__/platform/requests.test.ts similarity index 95% rename from packages/shared/akamai-edgeworker-sdk/src/__tests__/platform/requests.test.ts rename to packages/shared/akamai-edgeworker-sdk/__tests__/platform/requests.test.ts index e96e1ecb3..e70836b07 100644 --- a/packages/shared/akamai-edgeworker-sdk/src/__tests__/platform/requests.test.ts +++ b/packages/shared/akamai-edgeworker-sdk/__tests__/platform/requests.test.ts @@ -1,6 +1,6 @@ import { EventSourceInitDict } from '@launchdarkly/js-server-sdk-common'; -import EdgeRequests from '../../platform/requests'; +import EdgeRequests from '../../src/platform/requests'; const TEXT_RESPONSE = ''; const JSON_RESPONSE = {}; diff --git a/packages/shared/akamai-edgeworker-sdk/src/__tests__/testData.json b/packages/shared/akamai-edgeworker-sdk/__tests__/testData.json similarity index 100% rename from packages/shared/akamai-edgeworker-sdk/src/__tests__/testData.json rename to packages/shared/akamai-edgeworker-sdk/__tests__/testData.json diff --git a/packages/shared/akamai-edgeworker-sdk/src/__tests__/utils/createCallbacks.test.ts b/packages/shared/akamai-edgeworker-sdk/__tests__/utils/createCallbacks.test.ts similarity index 89% rename from packages/shared/akamai-edgeworker-sdk/src/__tests__/utils/createCallbacks.test.ts rename to packages/shared/akamai-edgeworker-sdk/__tests__/utils/createCallbacks.test.ts index 818fe553d..ef5b7963a 100644 --- a/packages/shared/akamai-edgeworker-sdk/src/__tests__/utils/createCallbacks.test.ts +++ b/packages/shared/akamai-edgeworker-sdk/__tests__/utils/createCallbacks.test.ts @@ -1,4 +1,4 @@ -import { createCallbacks } from '../../utils/createCallbacks'; +import { createCallbacks } from '../../src/utils/createCallbacks'; describe('create callback', () => { it('creates valid callbacks', () => { diff --git a/packages/shared/akamai-edgeworker-sdk/src/__tests__/utils/createOptions.test.ts b/packages/shared/akamai-edgeworker-sdk/__tests__/utils/createOptions.test.ts similarity index 87% rename from packages/shared/akamai-edgeworker-sdk/src/__tests__/utils/createOptions.test.ts rename to packages/shared/akamai-edgeworker-sdk/__tests__/utils/createOptions.test.ts index 66895ab38..735a2c9f3 100644 --- a/packages/shared/akamai-edgeworker-sdk/src/__tests__/utils/createOptions.test.ts +++ b/packages/shared/akamai-edgeworker-sdk/__tests__/utils/createOptions.test.ts @@ -1,4 +1,4 @@ -import { createOptions, defaultOptions } from '../../utils/createOptions'; +import { createOptions, defaultOptions } from '../../src/utils/createOptions'; describe('create options', () => { it('returns default options', () => { diff --git a/packages/shared/akamai-edgeworker-sdk/src/__tests__/utils/validateOptions.test.ts b/packages/shared/akamai-edgeworker-sdk/__tests__/utils/validateOptions.test.ts similarity index 94% rename from packages/shared/akamai-edgeworker-sdk/src/__tests__/utils/validateOptions.test.ts rename to packages/shared/akamai-edgeworker-sdk/__tests__/utils/validateOptions.test.ts index 22615875c..4126a9fe8 100644 --- a/packages/shared/akamai-edgeworker-sdk/src/__tests__/utils/validateOptions.test.ts +++ b/packages/shared/akamai-edgeworker-sdk/__tests__/utils/validateOptions.test.ts @@ -1,7 +1,7 @@ import { BasicLogger } from '@launchdarkly/js-server-sdk-common'; -import { EdgeFeatureStore } from '../../featureStore'; -import { LDOptionsInternal, validateOptions } from '../../utils/validateOptions'; +import { EdgeFeatureStore } from '../../src/featureStore'; +import { LDOptionsInternal, validateOptions } from '../../src/utils/validateOptions'; const SDK_KEY = 'test-key'; diff --git a/packages/shared/akamai-edgeworker-sdk/package.json b/packages/shared/akamai-edgeworker-sdk/package.json index 3e7b3bcc4..114f0e99f 100644 --- a/packages/shared/akamai-edgeworker-sdk/package.json +++ b/packages/shared/akamai-edgeworker-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@launchdarkly/akamai-edgeworker-sdk-common", - "version": "1.1.13", + "version": "1.3.0", "homepage": "https://github.com/launchdarkly/js-core/tree/main/packages/shared/akamai-edge-sdk", "repository": { "type": "git", @@ -55,7 +55,7 @@ "typescript": "5.1.6" }, "dependencies": { - "@launchdarkly/js-server-sdk-common": "^2.5.0", + "@launchdarkly/js-server-sdk-common": "^2.9.0", "crypto-js": "^4.1.1" } } diff --git a/packages/shared/akamai-edgeworker-sdk/src/api/LDClient.ts b/packages/shared/akamai-edgeworker-sdk/src/api/LDClient.ts index 891ae0aaf..a34fc73a3 100644 --- a/packages/shared/akamai-edgeworker-sdk/src/api/LDClient.ts +++ b/packages/shared/akamai-edgeworker-sdk/src/api/LDClient.ts @@ -20,7 +20,7 @@ export interface CustomLDOptions extends LDOptions {} * The LaunchDarkly Akamai SDK edge client object. */ class LDClient extends LDClientImpl { - private cacheableStoreProvider!: CacheableStoreProvider; + private _cacheableStoreProvider!: CacheableStoreProvider; // sdkKey is only used to query featureStore, not to initialize with LD servers constructor( @@ -31,7 +31,7 @@ class LDClient extends LDClientImpl { ) { const finalOptions = createOptions(options); super(sdkKey, platform, finalOptions, createCallbacks(finalOptions.logger)); - this.cacheableStoreProvider = storeProvider; + this._cacheableStoreProvider = storeProvider; } override waitForInitialization(): Promise { @@ -46,7 +46,7 @@ class LDClient extends LDClientImpl { defaultValue: LDFlagValue, callback?: (err: any, res: LDFlagValue) => void, ): Promise { - await this.cacheableStoreProvider.prefetchPayloadFromOriginStore(); + await this._cacheableStoreProvider.prefetchPayloadFromOriginStore(); return super.variation(key, context, defaultValue, callback); } @@ -56,7 +56,7 @@ class LDClient extends LDClientImpl { defaultValue: LDFlagValue, callback?: (err: any, res: LDEvaluationDetail) => void, ): Promise { - await this.cacheableStoreProvider.prefetchPayloadFromOriginStore(); + await this._cacheableStoreProvider.prefetchPayloadFromOriginStore(); return super.variationDetail(key, context, defaultValue, callback); } @@ -65,7 +65,7 @@ class LDClient extends LDClientImpl { options?: LDFlagsStateOptions, callback?: (err: Error | null, res: LDFlagsState) => void, ): Promise { - await this.cacheableStoreProvider.prefetchPayloadFromOriginStore(); + await this._cacheableStoreProvider.prefetchPayloadFromOriginStore(); return super.allFlagsState(context, options, callback); } } diff --git a/packages/shared/akamai-edgeworker-sdk/src/featureStore/cacheableStoreProvider.ts b/packages/shared/akamai-edgeworker-sdk/src/featureStore/cacheableStoreProvider.ts index 7dc345cec..11aecdf65 100644 --- a/packages/shared/akamai-edgeworker-sdk/src/featureStore/cacheableStoreProvider.ts +++ b/packages/shared/akamai-edgeworker-sdk/src/featureStore/cacheableStoreProvider.ts @@ -8,8 +8,8 @@ export default class CacheableStoreProvider implements EdgeProvider { cache: string | null | undefined; constructor( - private readonly edgeProvider: EdgeProvider, - private readonly rootKey: string, + private readonly _edgeProvider: EdgeProvider, + private readonly _rootKey: string, ) {} /** @@ -19,7 +19,7 @@ export default class CacheableStoreProvider implements EdgeProvider { */ async get(rootKey: string): Promise { if (!this.cache) { - this.cache = await this.edgeProvider.get(rootKey); + this.cache = await this._edgeProvider.get(rootKey); } return this.cache; @@ -34,6 +34,6 @@ export default class CacheableStoreProvider implements EdgeProvider { */ async prefetchPayloadFromOriginStore(rootKey?: string): Promise { this.cache = undefined; // clear the cache so that new data can be fetched from the origin - return this.get(rootKey || this.rootKey); + return this.get(rootKey || this._rootKey); } } diff --git a/packages/shared/akamai-edgeworker-sdk/src/featureStore/index.ts b/packages/shared/akamai-edgeworker-sdk/src/featureStore/index.ts index 812ae1dfa..18960e1f7 100644 --- a/packages/shared/akamai-edgeworker-sdk/src/featureStore/index.ts +++ b/packages/shared/akamai-edgeworker-sdk/src/featureStore/index.ts @@ -20,15 +20,15 @@ export interface EdgeProvider { export const buildRootKey = (sdkKey: string) => `LD-Env-${sdkKey}`; export class EdgeFeatureStore implements LDFeatureStore { - private readonly rootKey: string; + private readonly _rootKey: string; constructor( - private readonly edgeProvider: EdgeProvider, - private readonly sdkKey: string, - private readonly description: string, - private logger: LDLogger, + private readonly _edgeProvider: EdgeProvider, + private readonly _sdkKey: string, + private readonly _description: string, + private _logger: LDLogger, ) { - this.rootKey = buildRootKey(this.sdkKey); + this._rootKey = buildRootKey(this._sdkKey); } async get( @@ -38,13 +38,13 @@ export class EdgeFeatureStore implements LDFeatureStore { ): Promise { const { namespace } = kind; const kindKey = namespace === 'features' ? 'flags' : namespace; - this.logger.debug(`Requesting ${dataKey} from ${this.rootKey}.${kindKey}`); + this._logger.debug(`Requesting ${dataKey} from ${this._rootKey}.${kindKey}`); try { - const i = await this.edgeProvider.get(this.rootKey); + const i = await this._edgeProvider.get(this._rootKey); if (!i) { - throw new Error(`${this.rootKey}.${kindKey} is not found in KV.`); + throw new Error(`${this._rootKey}.${kindKey} is not found in KV.`); } const item = deserializePoll(i); @@ -63,7 +63,7 @@ export class EdgeFeatureStore implements LDFeatureStore { callback(null); } } catch (err) { - this.logger.error(err); + this._logger.error(err); callback(null); } } @@ -71,11 +71,11 @@ export class EdgeFeatureStore implements LDFeatureStore { async all(kind: DataKind, callback: (res: LDFeatureStoreKindData) => void = noop): Promise { const { namespace } = kind; const kindKey = namespace === 'features' ? 'flags' : namespace; - this.logger.debug(`Requesting all from ${this.rootKey}.${kindKey}`); + this._logger.debug(`Requesting all from ${this._rootKey}.${kindKey}`); try { - const i = await this.edgeProvider.get(this.rootKey); + const i = await this._edgeProvider.get(this._rootKey); if (!i) { - throw new Error(`${this.rootKey}.${kindKey} is not found in KV.`); + throw new Error(`${this._rootKey}.${kindKey} is not found in KV.`); } const item = deserializePoll(i); @@ -94,15 +94,15 @@ export class EdgeFeatureStore implements LDFeatureStore { throw new Error(`Unsupported DataKind: ${namespace}`); } } catch (err) { - this.logger.error(err); + this._logger.error(err); callback({}); } } async initialized(callback: (isInitialized: boolean) => void = noop): Promise { - const config = await this.edgeProvider.get(this.rootKey); + const config = await this._edgeProvider.get(this._rootKey); const result = config !== null; - this.logger.debug(`Is ${this.rootKey} initialized? ${result}`); + this._logger.debug(`Is ${this._rootKey} initialized? ${result}`); callback(result); } @@ -111,7 +111,7 @@ export class EdgeFeatureStore implements LDFeatureStore { } getDescription(): string { - return this.description; + return this._description; } // unused diff --git a/packages/shared/akamai-edgeworker-sdk/src/platform/crypto/cryptoJSHasher.ts b/packages/shared/akamai-edgeworker-sdk/src/platform/crypto/cryptoJSHasher.ts index 8d5cab22a..a75f13abd 100644 --- a/packages/shared/akamai-edgeworker-sdk/src/platform/crypto/cryptoJSHasher.ts +++ b/packages/shared/akamai-edgeworker-sdk/src/platform/crypto/cryptoJSHasher.ts @@ -7,7 +7,7 @@ import { Hasher as LDHasher } from '@launchdarkly/js-server-sdk-common'; import { SupportedHashAlgorithm, SupportedOutputEncoding } from './types'; export default class CryptoJSHasher implements LDHasher { - private cryptoJSHasher; + private _cryptoJSHasher; constructor(algorithm: SupportedHashAlgorithm) { let algo; @@ -23,11 +23,11 @@ export default class CryptoJSHasher implements LDHasher { throw new Error('unsupported hash algorithm. Only sha1 and sha256 are supported.'); } - this.cryptoJSHasher = algo.create(); + this._cryptoJSHasher = algo.create(); } digest(encoding: SupportedOutputEncoding): string { - const result = this.cryptoJSHasher.finalize(); + const result = this._cryptoJSHasher.finalize(); let enc; switch (encoding) { @@ -45,7 +45,7 @@ export default class CryptoJSHasher implements LDHasher { } update(data: string): this { - this.cryptoJSHasher.update(data); + this._cryptoJSHasher.update(data); return this; } } diff --git a/packages/shared/akamai-edgeworker-sdk/src/platform/crypto/cryptoJSHmac.ts b/packages/shared/akamai-edgeworker-sdk/src/platform/crypto/cryptoJSHmac.ts index 050a84bde..bc23f04d8 100644 --- a/packages/shared/akamai-edgeworker-sdk/src/platform/crypto/cryptoJSHmac.ts +++ b/packages/shared/akamai-edgeworker-sdk/src/platform/crypto/cryptoJSHmac.ts @@ -5,7 +5,7 @@ import { Hmac as LDHmac } from '@launchdarkly/js-server-sdk-common'; import { SupportedHashAlgorithm, SupportedOutputEncoding } from './types'; export default class CryptoJSHmac implements LDHmac { - private CryptoJSHmac; + private _cryptoJSHmac; constructor(algorithm: SupportedHashAlgorithm, key: string) { let algo; @@ -21,11 +21,11 @@ export default class CryptoJSHmac implements LDHmac { throw new Error('unsupported hash algorithm. Only sha1 and sha256 are supported.'); } - this.CryptoJSHmac = CryptoAlgo.HMAC.create(algo, key); + this._cryptoJSHmac = CryptoAlgo.HMAC.create(algo, key); } digest(encoding: SupportedOutputEncoding): string { - const result = this.CryptoJSHmac.finalize(); + const result = this._cryptoJSHmac.finalize(); if (encoding === 'base64') { return result.toString(CryptoJS.enc.Base64); @@ -39,7 +39,7 @@ export default class CryptoJSHmac implements LDHmac { } update(data: string): this { - this.CryptoJSHmac.update(data); + this._cryptoJSHmac.update(data); return this; } } diff --git a/packages/shared/akamai-edgeworker-sdk/src/platform/info/index.ts b/packages/shared/akamai-edgeworker-sdk/src/platform/info/index.ts index 557b312cb..c2b88218e 100644 --- a/packages/shared/akamai-edgeworker-sdk/src/platform/info/index.ts +++ b/packages/shared/akamai-edgeworker-sdk/src/platform/info/index.ts @@ -2,21 +2,21 @@ import type { Info, PlatformData, SdkData } from '@launchdarkly/js-server-sdk-co class AkamaiPlatformInfo implements Info { constructor( - private platformName: string, - private sdkName: string, - private sdkVersion: string, + private _platformName: string, + private _sdkName: string, + private _sdkVersion: string, ) {} platformData(): PlatformData { return { - name: this.platformName, + name: this._platformName, }; } sdkData(): SdkData { return { - name: this.sdkName, - version: this.sdkVersion, + name: this._sdkName, + version: this._sdkVersion, userAgentBase: 'AkamaiEdgeSDK', }; } diff --git a/packages/shared/akamai-edgeworker-sdk/src/platform/requests.ts b/packages/shared/akamai-edgeworker-sdk/src/platform/requests.ts index 5a6b728b0..69cbeeb5e 100644 --- a/packages/shared/akamai-edgeworker-sdk/src/platform/requests.ts +++ b/packages/shared/akamai-edgeworker-sdk/src/platform/requests.ts @@ -2,6 +2,7 @@ import { Headers, NullEventSource } from '@launchdarkly/js-server-sdk-common'; import type { EventSource, + EventSourceCapabilities, EventSourceInitDict, Options, Requests, @@ -41,4 +42,12 @@ export default class EdgeRequests implements Requests { createEventSource(url: string, eventSourceInitDict: EventSourceInitDict): EventSource { return new NullEventSource(url, eventSourceInitDict); } + + getEventSourceCapabilities(): EventSourceCapabilities { + return { + readTimeout: false, + headers: false, + customMethod: false, + }; + } } diff --git a/packages/shared/common/CHANGELOG.md b/packages/shared/common/CHANGELOG.md index fe71f0766..778d337ca 100644 --- a/packages/shared/common/CHANGELOG.md +++ b/packages/shared/common/CHANGELOG.md @@ -2,6 +2,51 @@ All notable changes to `@launchdarkly/js-sdk-common` will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org). +## [2.11.0](https://github.com/launchdarkly/js-core/compare/js-sdk-common-v2.10.0...js-sdk-common-v2.11.0) (2024-10-17) + + +### Features + +* Apply private property naming standard. Mangle browser private properties. ([#620](https://github.com/launchdarkly/js-core/issues/620)) ([3e6d404](https://github.com/launchdarkly/js-core/commit/3e6d404ae665c5cc7e5a1394a59c8f2c9d5d682a)) + + +### Bug Fixes + +* Update sdk-client rollup configuration to match common ([#630](https://github.com/launchdarkly/js-core/issues/630)) ([e061811](https://github.com/launchdarkly/js-core/commit/e06181158d29824ff0131a88988c84cd4a32f6c0)) + +## [2.10.0](https://github.com/launchdarkly/js-core/compare/js-sdk-common-v2.9.0...js-sdk-common-v2.10.0) (2024-10-09) + + +### Features + +* Add ESM support for common and common-client (rollup) ([#604](https://github.com/launchdarkly/js-core/issues/604)) ([8cd0cdc](https://github.com/launchdarkly/js-core/commit/8cd0cdce988f606b1efdf6bfd19484f6607db2e5)) +* Add visibility handling to allow proactive event flushing. ([#607](https://github.com/launchdarkly/js-core/issues/607)) ([819a311](https://github.com/launchdarkly/js-core/commit/819a311db6f56e323bb84c925789ad4bd19ae4ba)) +* adds datasource status to sdk-client ([#590](https://github.com/launchdarkly/js-core/issues/590)) ([6f26204](https://github.com/launchdarkly/js-core/commit/6f262045b76836e5d2f5ccc2be433094993fcdbb)) + +## [2.9.0](https://github.com/launchdarkly/js-core/compare/js-sdk-common-v2.8.0...js-sdk-common-v2.9.0) (2024-09-26) + + +### Features + +* Add platform support for async hashing. ([#573](https://github.com/launchdarkly/js-core/issues/573)) ([9248035](https://github.com/launchdarkly/js-core/commit/9248035a88fba1c7375c5df22ef6b4a80a867983)) +* Add support for conditional event source capabilities. ([#577](https://github.com/launchdarkly/js-core/issues/577)) ([fe82500](https://github.com/launchdarkly/js-core/commit/fe82500f28cf8d8311502098aa6cc2e73932064e)) +* Add URLs for custom events and URL filtering. ([#587](https://github.com/launchdarkly/js-core/issues/587)) ([7131e69](https://github.com/launchdarkly/js-core/commit/7131e6905f19cc10a1374aae5e74cec66c7fd6de)) +* Adds support for REPORT. ([#575](https://github.com/launchdarkly/js-core/issues/575)) ([916b724](https://github.com/launchdarkly/js-core/commit/916b72409b63abdf350e70cca41331c4204b6e95)) +* Allow using custom user-agent name. ([#580](https://github.com/launchdarkly/js-core/issues/580)) ([ed5a206](https://github.com/launchdarkly/js-core/commit/ed5a206c86f496942664dd73f6f8a7c602a1de28)) +* Implement goals for client-side SDKs. ([#585](https://github.com/launchdarkly/js-core/issues/585)) ([fd38a8f](https://github.com/launchdarkly/js-core/commit/fd38a8fa8560dad0c6721c2eaeed2f3f5c674900)) + + +### Bug Fixes + +* Multi-kind context containing only 1 kind conveted incorrectly. ([#594](https://github.com/launchdarkly/js-core/issues/594)) ([b6ff2a6](https://github.com/launchdarkly/js-core/commit/b6ff2a67db9f9a24da4a45ad88fa7f2a22fb635d)) + +## [2.8.0](https://github.com/launchdarkly/js-core/compare/js-sdk-common-v2.7.0...js-sdk-common-v2.8.0) (2024-09-03) + + +### Features + +* Add support for Payload Filtering ([#551](https://github.com/launchdarkly/js-core/issues/551)) ([6f44383](https://github.com/launchdarkly/js-core/commit/6f4438323baed802d8f951ac82494e6cfa9932c5)) + ## [2.7.0](https://github.com/launchdarkly/js-core/compare/js-sdk-common-v2.6.0...js-sdk-common-v2.7.0) (2024-08-28) diff --git a/packages/shared/common/__tests__/ContextFilter.test.ts b/packages/shared/common/__tests__/ContextFilter.test.ts index 3f1d7c0b1..17cd827cf 100644 --- a/packages/shared/common/__tests__/ContextFilter.test.ts +++ b/packages/shared/common/__tests__/ContextFilter.test.ts @@ -416,6 +416,23 @@ describe('when handling mult-kind contexts', () => { }, }; + const multiWithSingleContext = Context.fromLDContext({ + kind: 'multi', + user: { + key: 'abc', + name: 'alphabet', + letters: ['a', 'b', 'c'], + order: 3, + object: { + a: 'a', + b: 'b', + }, + _meta: { + privateAttributes: ['letters', '/object/b'], + }, + }, + }); + it('it should remove attributes from all contexts when all attributes are private.', () => { const uf = new ContextFilter(true, []); expect(uf.filter(orgAndUserContext)).toEqual(orgAndUserContextAllPrivate); @@ -430,4 +447,20 @@ describe('when handling mult-kind contexts', () => { const uf = new ContextFilter(false, [new AttributeReference('name', true)]); expect(uf.filter(orgAndUserContext)).toEqual(orgAndUserGlobalNamePrivate); }); + + it('should produce event with valid single context', () => { + const uf = new ContextFilter(false, []); + expect(uf.filter(multiWithSingleContext)).toEqual({ + kind: 'user', + _meta: { + redactedAttributes: ['/object/b', 'letters'], + }, + key: 'abc', + name: 'alphabet', + object: { + a: 'a', + }, + order: 3, + }); + }); }); diff --git a/packages/shared/mocks/src/contextDeduplicator.ts b/packages/shared/common/__tests__/contextDeduplicator.ts similarity index 82% rename from packages/shared/mocks/src/contextDeduplicator.ts rename to packages/shared/common/__tests__/contextDeduplicator.ts index b04d0d277..0c4ff2b8f 100644 --- a/packages/shared/mocks/src/contextDeduplicator.ts +++ b/packages/shared/common/__tests__/contextDeduplicator.ts @@ -1,4 +1,5 @@ -import type { Context, subsystem } from '@common'; +import { subsystem } from '../src/api'; +import Context from '../src/Context'; export default class ContextDeduplicator implements subsystem.LDContextDeduplicator { flushInterval?: number | undefined = 0.1; diff --git a/packages/shared/mocks/src/platform.ts b/packages/shared/common/__tests__/createBasicPlatform.ts similarity index 89% rename from packages/shared/mocks/src/platform.ts rename to packages/shared/common/__tests__/createBasicPlatform.ts index ef138ebd5..6b64d40dd 100644 --- a/packages/shared/mocks/src/platform.ts +++ b/packages/shared/common/__tests__/createBasicPlatform.ts @@ -1,6 +1,5 @@ -import type { PlatformData, SdkData } from '@common'; - -import { setupCrypto } from './crypto'; +import { PlatformData, SdkData } from '../src/api'; +import { setupCrypto } from './setupCrypto'; const setupInfo = () => ({ platformData: jest.fn( @@ -49,6 +48,7 @@ export const createBasicPlatform = () => ({ requests: { fetch: jest.fn(), createEventSource: jest.fn(), + getEventSourceCapabilities: jest.fn(), }, storage: { get: jest.fn(), diff --git a/packages/shared/common/src/internal/diagnostics/DiagnosticsManager.test.ts b/packages/shared/common/__tests__/internal/diagnostics/DiagnosticsManager.test.ts similarity index 95% rename from packages/shared/common/src/internal/diagnostics/DiagnosticsManager.test.ts rename to packages/shared/common/__tests__/internal/diagnostics/DiagnosticsManager.test.ts index c73d02007..6cfa7b6a8 100644 --- a/packages/shared/common/src/internal/diagnostics/DiagnosticsManager.test.ts +++ b/packages/shared/common/__tests__/internal/diagnostics/DiagnosticsManager.test.ts @@ -1,6 +1,5 @@ -import { createBasicPlatform } from '@launchdarkly/private-js-mocks'; - -import DiagnosticsManager from './DiagnosticsManager'; +import DiagnosticsManager from '../../../src/internal/diagnostics/DiagnosticsManager'; +import { createBasicPlatform } from '../../createBasicPlatform'; describe('given a diagnostics manager', () => { const dateNowString = '2023-08-10'; diff --git a/packages/shared/common/__tests__/internal/events/EventProcessor.test.ts b/packages/shared/common/__tests__/internal/events/EventProcessor.test.ts index d32f78c66..82de97679 100644 --- a/packages/shared/common/__tests__/internal/events/EventProcessor.test.ts +++ b/packages/shared/common/__tests__/internal/events/EventProcessor.test.ts @@ -1,10 +1,5 @@ -import { - ContextDeduplicator, - createBasicPlatform, - createLogger, -} from '@launchdarkly/private-js-mocks'; - import { LDContextCommon, LDMultiKindContext } from '../../../src/api/context'; +import { LDLogger } from '../../../src/api/logging/LDLogger'; import { LDContextDeduplicator, LDDeliveryStatus, LDEventType } from '../../../src/api/subsystem'; import Context from '../../../src/Context'; import { EventProcessor, InputIdentifyEvent } from '../../../src/internal'; @@ -13,13 +8,20 @@ import shouldSample from '../../../src/internal/events/sampling'; import BasicLogger from '../../../src/logging/BasicLogger'; import format from '../../../src/logging/format'; import ClientContext from '../../../src/options/ClientContext'; +import ContextDeduplicator from '../../contextDeduplicator'; +import { createBasicPlatform } from '../../createBasicPlatform'; let mockPlatform: ReturnType; let clientContext: ClientContext; -let logger: ReturnType; +let logger: LDLogger; beforeEach(() => { - logger = createLogger(); + logger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }; mockPlatform = createBasicPlatform(); clientContext = { basicConfiguration: { @@ -131,7 +133,12 @@ describe('given an event processor', () => { }), ); contextDeduplicator = new ContextDeduplicator(); - eventProcessor = new EventProcessor(eventProcessorConfig, clientContext, contextDeduplicator); + eventProcessor = new EventProcessor( + eventProcessorConfig, + clientContext, + {}, + contextDeduplicator, + ); }); afterEach(() => { @@ -788,6 +795,7 @@ describe('given an event processor', () => { eventProcessor = new EventProcessor( eventProcessorConfig, clientContextWithDebug, + {}, contextDeduplicator, ); diff --git a/packages/shared/common/src/internal/events/EventSender.test.ts b/packages/shared/common/__tests__/internal/events/EventSender.test.ts similarity index 81% rename from packages/shared/common/src/internal/events/EventSender.test.ts rename to packages/shared/common/__tests__/internal/events/EventSender.test.ts index 5e0e348e2..63a6130dc 100644 --- a/packages/shared/common/src/internal/events/EventSender.test.ts +++ b/packages/shared/common/__tests__/internal/events/EventSender.test.ts @@ -1,21 +1,20 @@ -import { createBasicPlatform } from '@launchdarkly/private-js-mocks'; - -import { Info, PlatformData, SdkData } from '../../api'; -import { LDDeliveryStatus, LDEventSenderResult, LDEventType } from '../../api/subsystem'; -import { ApplicationTags, ClientContext } from '../../options'; -import EventSender from './EventSender'; +import { Info, PlatformData, SdkData } from '../../../src/api'; +import { LDDeliveryStatus, LDEventSenderResult, LDEventType } from '../../../src/api/subsystem'; +import EventSender from '../../../src/internal/events/EventSender'; +import { ApplicationTags, ClientContext } from '../../../src/options'; +import { createBasicPlatform } from '../../createBasicPlatform'; let mockPlatform: ReturnType; +function runWithTimers(fn: () => Promise) { + const promise = fn(); + return jest.runAllTimersAsync().then(() => promise); +} + beforeEach(() => { mockPlatform = createBasicPlatform(); }); -jest.mock('../../utils', () => { - const actual = jest.requireActual('../../utils'); - return { ...actual, sleep: jest.fn() }; -}); - const basicConfig = { tags: new ApplicationTags({ application: { id: 'testApplication1', version: '1.0.0' } }), serviceEndpoints: { @@ -112,11 +111,16 @@ describe('given an event sender', () => { eventSender = new EventSender( new ClientContext('sdk-key', basicConfig, { ...mockPlatform, info }), + { + authorization: 'sdk-key', + 'user-agent': 'TestUserAgent/2.0.2', + 'x-launchdarkly-tags': 'application-id/testApplication1 application-version/1.0.0', + 'x-launchdarkly-wrapper': 'Rapper/1.2.3', + }, ); - eventSenderResult = await eventSender.sendEventData( - LDEventType.AnalyticsEvents, - testEventData1, + eventSenderResult = await runWithTimers(() => + eventSender.sendEventData(LDEventType.AnalyticsEvents, testEventData1), ); }); @@ -131,14 +135,14 @@ describe('given an event sender', () => { body: JSON.stringify(testEventData1), headers: analyticsHeaders(uuid), method: 'POST', + keepalive: true, }); }); it('includes the payload', async () => { const { status: status1 } = eventSenderResult; - const { status: status2 } = await eventSender.sendEventData( - LDEventType.DiagnosticEvent, - testEventData2, + const { status: status2 } = await runWithTimers(() => + eventSender.sendEventData(LDEventType.DiagnosticEvent, testEventData2), ); expect(status1).toEqual(LDDeliveryStatus.Succeeded); @@ -148,6 +152,7 @@ describe('given an event sender', () => { body: JSON.stringify(testEventData1), headers: analyticsHeaders(uuid), method: 'POST', + keepalive: true, }); expect(mockFetch).toHaveBeenNthCalledWith( 2, @@ -156,13 +161,16 @@ describe('given an event sender', () => { body: JSON.stringify(testEventData2), headers: diagnosticHeaders, method: 'POST', + keepalive: true, }, ); }); it('sends a unique payload for analytics events', async () => { // send the same request again to assert unique uuids - await eventSender.sendEventData(LDEventType.AnalyticsEvents, testEventData1); + await runWithTimers(() => + eventSender.sendEventData(LDEventType.AnalyticsEvents, testEventData1), + ); expect(mockFetch).toHaveBeenCalledTimes(2); expect(mockFetch).toHaveBeenNthCalledWith( @@ -184,9 +192,8 @@ describe('given an event sender', () => { describe.each([400, 408, 429, 503])('given recoverable errors', (responseStatusCode) => { beforeEach(async () => { setupMockFetch(responseStatusCode); - eventSenderResult = await eventSender.sendEventData( - LDEventType.AnalyticsEvents, - testEventData1, + eventSenderResult = await runWithTimers(() => + eventSender.sendEventData(LDEventType.AnalyticsEvents, testEventData1), ); }); @@ -204,9 +211,8 @@ describe('given an event sender', () => { it('given a result for too large of a payload', async () => { setupMockFetch(413); - eventSenderResult = await eventSender.sendEventData( - LDEventType.AnalyticsEvents, - testEventData1, + eventSenderResult = await runWithTimers(() => + eventSender.sendEventData(LDEventType.AnalyticsEvents, testEventData1), ); const errorMessage = `Received error 413 for event posting - giving up permanently`; @@ -222,9 +228,8 @@ describe('given an event sender', () => { describe.each([401, 403])('given unrecoverable errors', (responseStatusCode) => { beforeEach(async () => { setupMockFetch(responseStatusCode); - eventSenderResult = await eventSender.sendEventData( - LDEventType.AnalyticsEvents, - testEventData1, + eventSenderResult = await runWithTimers(() => + eventSender.sendEventData(LDEventType.AnalyticsEvents, testEventData1), ); }); diff --git a/packages/shared/common/src/internal/stream/StreamingProcessor.test.ts b/packages/shared/common/__tests__/internal/stream/StreamingProcessor.test.ts similarity index 88% rename from packages/shared/common/src/internal/stream/StreamingProcessor.test.ts rename to packages/shared/common/__tests__/internal/stream/StreamingProcessor.test.ts index e91b93e16..293e35124 100644 --- a/packages/shared/common/src/internal/stream/StreamingProcessor.test.ts +++ b/packages/shared/common/__tests__/internal/stream/StreamingProcessor.test.ts @@ -1,13 +1,13 @@ -import { createBasicPlatform, createLogger } from '@launchdarkly/private-js-mocks'; +import { EventName, Info, LDLogger, ProcessStreamResponse } from '../../../src/api'; +import { LDStreamProcessor } from '../../../src/api/subsystem'; +import { DataSourceErrorKind } from '../../../src/datasource/DataSourceErrorKinds'; +import { LDStreamingError } from '../../../src/datasource/errors'; +import { DiagnosticsManager } from '../../../src/internal/diagnostics'; +import StreamingProcessor from '../../../src/internal/stream/StreamingProcessor'; +import { defaultHeaders } from '../../../src/utils'; +import { createBasicPlatform } from '../../createBasicPlatform'; -import { EventName, Info, LDLogger, ProcessStreamResponse } from '../../api'; -import { LDStreamProcessor } from '../../api/subsystem'; -import { LDStreamingError } from '../../errors'; -import { defaultHeaders } from '../../utils'; -import { DiagnosticsManager } from '../diagnostics'; -import StreamingProcessor from './StreamingProcessor'; - -let logger: ReturnType; +let logger: LDLogger; const serviceEndpoints = { events: '', @@ -43,7 +43,12 @@ let basicPlatform: any; beforeEach(() => { basicPlatform = createBasicPlatform(); - logger = createLogger(); + logger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }; }); const createMockEventSource = (streamUri: string = '', options: any = {}) => ({ @@ -101,7 +106,6 @@ describe('given a stream processor with mock event source', () => { diagnosticsManager = new DiagnosticsManager(sdkKey, basicPlatform, {}); streamingProcessor = new StreamingProcessor( - sdkKey, { basicConfiguration: getBasicConfiguration(logger), platform: basicPlatform, @@ -109,6 +113,11 @@ describe('given a stream processor with mock event source', () => { '/all', [], listeners, + { + authorization: 'my-sdk-key', + 'user-agent': 'TestUserAgent/2.0.2', + 'x-launchdarkly-wrapper': 'Rapper/1.2.3', + }, diagnosticsManager, mockErrorHandler, ); @@ -137,7 +146,6 @@ describe('given a stream processor with mock event source', () => { it('sets streamInitialReconnectDelay correctly', () => { streamingProcessor = new StreamingProcessor( - sdkKey, { basicConfiguration: getBasicConfiguration(logger), platform: basicPlatform, @@ -145,6 +153,11 @@ describe('given a stream processor with mock event source', () => { '/all', [], listeners, + { + authorization: 'my-sdk-key', + 'user-agent': 'TestUserAgent/2.0.2', + 'x-launchdarkly-wrapper': 'Rapper/1.2.3', + }, diagnosticsManager, mockErrorHandler, 22, @@ -252,7 +265,7 @@ describe('given a stream processor with mock event source', () => { expect(willRetry).toBeFalsy(); expect(mockErrorHandler).toBeCalledWith( - new LDStreamingError(testError.message, testError.status), + new LDStreamingError(DataSourceErrorKind.Unknown, testError.message, testError.status), ); expect(logger.error).toBeCalledWith( expect.stringMatching(new RegExp(`${status}.*permanently`)), diff --git a/packages/shared/common/__tests__/logging/BasicLogger.test.ts b/packages/shared/common/__tests__/logging/BasicLogger.test.ts index 971d4a594..426c97c13 100644 --- a/packages/shared/common/__tests__/logging/BasicLogger.test.ts +++ b/packages/shared/common/__tests__/logging/BasicLogger.test.ts @@ -2,6 +2,10 @@ import { BasicLogger, LDLogLevel } from '../../src'; const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); +beforeEach(() => { + jest.clearAllMocks(); +}); + describe.each<[LDLogLevel, string[]]>([ [ 'debug', @@ -64,10 +68,6 @@ describe('given a logger with a custom name', () => { describe('given a default logger', () => { const logger = new BasicLogger({}); - beforeEach(() => { - jest.clearAllMocks(); - }); - it('logs to the console', () => { logger.warn('potato', 'bacon'); expect(spy).toHaveBeenCalledWith('potato', 'bacon'); @@ -81,10 +81,6 @@ describe('given a logger with a destination that throws', () => { }, }); - beforeEach(() => { - jest.clearAllMocks(); - }); - it('logs to the console instead of throwing', () => { logger.error('a'); expect(spy).toHaveBeenCalledWith('error: [LaunchDarkly] a'); @@ -94,10 +90,6 @@ describe('given a logger with a destination that throws', () => { describe('given a logger with a formatter that throws', () => { const strings: string[] = []; - beforeEach(() => { - jest.clearAllMocks(); - }); - const logger = new BasicLogger({ destination: (...args: any) => { strings.push(args.join(' ')); @@ -112,3 +104,102 @@ describe('given a logger with a formatter that throws', () => { expect(spy).toHaveBeenCalledTimes(0); }); }); + +it('dispatches logs correctly with multiple destinations', () => { + const debug = jest.fn(); + const info = jest.fn(); + const warn = jest.fn(); + const error = jest.fn(); + + const logger = new BasicLogger({ + destination: { + debug, + info, + warn, + error, + }, + level: 'debug', + }); + + logger.debug('toDebug'); + logger.info('toInfo'); + logger.warn('toWarn'); + logger.error('toError'); + + expect(debug).toHaveBeenCalledTimes(1); + expect(debug).toHaveBeenCalledWith('debug: [LaunchDarkly] toDebug'); + + expect(info).toHaveBeenCalledTimes(1); + expect(info).toHaveBeenCalledWith('info: [LaunchDarkly] toInfo'); + + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith('warn: [LaunchDarkly] toWarn'); + + expect(error).toHaveBeenCalledTimes(1); + expect(error).toHaveBeenCalledWith('error: [LaunchDarkly] toError'); +}); + +it('handles destinations which throw', () => { + const debug = jest.fn(() => { + throw new Error('bad'); + }); + const info = jest.fn(() => { + throw new Error('bad'); + }); + const warn = jest.fn(() => { + throw new Error('bad'); + }); + const error = jest.fn(() => { + throw new Error('bad'); + }); + + const logger = new BasicLogger({ + destination: { + debug, + info, + warn, + error, + }, + level: 'debug', + }); + + logger.debug('toDebug'); + logger.info('toInfo'); + logger.warn('toWarn'); + logger.error('toError'); + + expect(spy).toHaveBeenCalledTimes(4); + expect(spy).toHaveBeenCalledWith('debug: [LaunchDarkly] toDebug'); + expect(spy).toHaveBeenCalledWith('info: [LaunchDarkly] toInfo'); + expect(spy).toHaveBeenCalledWith('warn: [LaunchDarkly] toWarn'); + expect(spy).toHaveBeenCalledWith('error: [LaunchDarkly] toError'); +}); + +it('handles destinations which are not defined', () => { + const debug = jest.fn(); + const info = jest.fn(); + const logger = new BasicLogger({ + // @ts-ignore + destination: { + debug, + info, + }, + level: 'debug', + }); + + logger.debug('toDebug'); + logger.info('toInfo'); + logger.warn('toWarn'); + logger.error('toError'); + + expect(debug).toHaveBeenCalledTimes(1); + expect(debug).toHaveBeenCalledWith('debug: [LaunchDarkly] toDebug'); + + expect(info).toHaveBeenCalledTimes(1); + expect(info).toHaveBeenCalledWith('info: [LaunchDarkly] toInfo'); + + expect(spy).toHaveBeenCalledTimes(2); + + expect(spy).toHaveBeenCalledWith('toWarn'); + expect(spy).toHaveBeenCalledWith('toError'); +}); diff --git a/packages/shared/common/__tests__/options/ApplicationTags.test.ts b/packages/shared/common/__tests__/options/ApplicationTags.test.ts index 51ae58449..ffc51b3d9 100644 --- a/packages/shared/common/__tests__/options/ApplicationTags.test.ts +++ b/packages/shared/common/__tests__/options/ApplicationTags.test.ts @@ -1,10 +1,16 @@ -import { createLogger } from '@launchdarkly/private-js-mocks'; - import ApplicationTags from '../../src/options/ApplicationTags'; describe.each([ [ - { application: { id: 'is-valid', version: 'also-valid' }, logger: createLogger() }, + { + application: { id: 'is-valid', version: 'also-valid' }, + logger: { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }, + }, 'application-id/is-valid application-version/also-valid', [], ], @@ -16,36 +22,105 @@ describe.each([ name: 'test-app-1', versionName: 'test-version-1', }, - logger: createLogger(), + logger: { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }, }, 'application-id/is-valid application-name/test-app-1 application-version/also-valid application-version-name/test-version-1', [], ], - [{ application: { id: 'is-valid' }, logger: createLogger() }, 'application-id/is-valid', []], [ - { application: { version: 'also-valid' }, logger: createLogger() }, + { + application: { id: 'is-valid' }, + logger: { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }, + }, + 'application-id/is-valid', + [], + ], + [ + { + application: { version: 'also-valid' }, + logger: { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }, + }, 'application-version/also-valid', [], ], - [{ application: {}, logger: createLogger() }, undefined, []], - [{ logger: createLogger() }, undefined, []], + [ + { + application: {}, + logger: { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }, + }, + undefined, + [], + ], + [ + { + logger: { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }, + }, + undefined, + [], + ], [undefined, undefined, []], // Above ones are 'valid' cases. Below are invalid. [ - { application: { id: 'bad tag' }, logger: createLogger() }, + { + application: { id: 'bad tag' }, + logger: { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }, + }, undefined, [/Config option "application.id" must/], ], [ - { application: { id: 'bad tag', version: 'good-tag' }, logger: createLogger() }, + { + application: { id: 'bad tag', version: 'good-tag' }, + logger: { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }, + }, 'application-version/good-tag', [/Config option "application.id" must/], ], [ { application: { id: 'bad tag', version: 'good-tag', name: '', versionName: 'test-version-1' }, - logger: createLogger(), + logger: { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }, }, 'application-version/good-tag application-version-name/test-version-1', [/Config option "application.id" must/, /Config option "application.name" must/], @@ -58,7 +133,12 @@ describe.each([ name: 'invalid name', versionName: 'invalid version name', }, - logger: createLogger(), + logger: { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }, }, undefined, [ diff --git a/packages/shared/mocks/src/crypto.ts b/packages/shared/common/__tests__/setupCrypto.ts similarity index 91% rename from packages/shared/mocks/src/crypto.ts rename to packages/shared/common/__tests__/setupCrypto.ts index 8a7d9a625..72b2d13ef 100644 --- a/packages/shared/mocks/src/crypto.ts +++ b/packages/shared/common/__tests__/setupCrypto.ts @@ -1,4 +1,4 @@ -import type { Hasher } from '@common'; +import { Hasher } from '../src/api'; export const setupCrypto = () => { let counter = 0; diff --git a/packages/shared/common/jest.config.js b/packages/shared/common/jest.config.cjs similarity index 100% rename from packages/shared/common/jest.config.js rename to packages/shared/common/jest.config.cjs diff --git a/packages/shared/common/package.json b/packages/shared/common/package.json index 9e73d2a2b..45fc38146 100644 --- a/packages/shared/common/package.json +++ b/packages/shared/common/package.json @@ -1,9 +1,9 @@ { "name": "@launchdarkly/js-sdk-common", - "version": "2.7.0", - "type": "commonjs", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", + "version": "2.11.0", + "type": "module", + "main": "./dist/esm/index.mjs", + "types": "./dist/esm/index.d.ts", "homepage": "https://github.com/launchdarkly/js-core/tree/main/packages/shared/common", "repository": { "type": "git", @@ -18,11 +18,23 @@ "analytics", "client" ], + "exports": { + "require": { + "types": "./dist/cjs/index.d.ts", + "default": "./dist/cjs/index.cjs" + }, + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.mjs" + } + }, "scripts": { "test": "npx jest --ci", - "build-types": "npx tsc --declaration true --emitDeclarationOnly true --declarationDir dist", - "build": "npx tsc", - "clean": "npx tsc --build --clean", + "make-cjs-package-json": "echo '{\"type\":\"commonjs\"}' > dist/cjs/package.json", + "make-esm-package-json": "echo '{\"type\":\"module\"}' > dist/esm/package.json", + "make-package-jsons": "npm run make-cjs-package-json && npm run make-esm-package-json", + "build": "npx tsc --noEmit && rollup -c rollup.config.js && npm run make-package-jsons", + "clean": "rimraf dist", "lint": "npx eslint . --ext .ts", "lint:fix": "yarn run lint --fix", "prettier": "prettier --write 'src/*.@(js|ts|tsx|json)'", @@ -30,7 +42,11 @@ }, "license": "Apache-2.0", "devDependencies": { - "@launchdarkly/private-js-mocks": "0.0.1", + "@rollup/plugin-commonjs": "^25.0.0", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^15.0.2", + "@rollup/plugin-terser": "^0.4.3", + "@rollup/plugin-typescript": "^11.1.1", "@trivago/prettier-plugin-sort-imports": "^4.1.1", "@types/jest": "^29.4.0", "@typescript-eslint/eslint-plugin": "^6.20.0", @@ -45,7 +61,10 @@ "jest": "^29.5.0", "launchdarkly-js-test-helpers": "^2.2.0", "prettier": "^3.0.0", + "rimraf": "6.0.1", + "rollup": "^3.23.0", "ts-jest": "^29.0.5", + "tslib": "^2.7.0", "typedoc": "0.25.0", "typescript": "5.1.6" } diff --git a/packages/shared/common/rollup.config.js b/packages/shared/common/rollup.config.js new file mode 100644 index 000000000..5514151c2 --- /dev/null +++ b/packages/shared/common/rollup.config.js @@ -0,0 +1,41 @@ +import common from '@rollup/plugin-commonjs'; +import json from '@rollup/plugin-json'; +import typescript from '@rollup/plugin-typescript'; + +// The common library does not have a dependency resolution plugin as it should not have any +// dependencies. + +// This library is not minified as the final SDK package is responsible for minification. + +const getSharedConfig = (format, file) => ({ + input: 'src/index.ts', + output: [ + { + format: format, + sourcemap: true, + file: file, + }, + ], +}); + +export default [ + { + ...getSharedConfig('es', 'dist/esm/index.mjs'), + plugins: [ + typescript({ + module: 'esnext', + tsconfig: './tsconfig.json', + outputToFilesystem: true, + }), + common({ + transformMixedEsModules: true, + esmExternals: true, + }), + json(), + ], + }, + { + ...getSharedConfig('cjs', 'dist/cjs/index.cjs'), + plugins: [typescript({ tsconfig: './tsconfig.json', outputToFilesystem: true, }), common(), json()], + }, +]; diff --git a/packages/shared/common/src/AttributeReference.ts b/packages/shared/common/src/AttributeReference.ts index a84c03db9..497381831 100644 --- a/packages/shared/common/src/AttributeReference.ts +++ b/packages/shared/common/src/AttributeReference.ts @@ -43,9 +43,9 @@ export default class AttributeReference { /** * For use as invalid references when deserializing Flag/Segment data. */ - public static readonly invalidReference = new AttributeReference(''); + public static readonly InvalidReference = new AttributeReference(''); - private readonly components: string[]; + private readonly _components: string[]; /** * Take an attribute reference string, or literal string, and produce @@ -66,29 +66,29 @@ export default class AttributeReference { this.redactionName = refOrLiteral; if (refOrLiteral === '' || refOrLiteral === '/' || !validate(refOrLiteral)) { this.isValid = false; - this.components = []; + this._components = []; return; } if (isLiteral(refOrLiteral)) { - this.components = [refOrLiteral]; + this._components = [refOrLiteral]; } else if (refOrLiteral.indexOf('/', 1) < 0) { - this.components = [unescape(refOrLiteral.slice(1))]; + this._components = [unescape(refOrLiteral.slice(1))]; } else { - this.components = getComponents(refOrLiteral); + this._components = getComponents(refOrLiteral); } // The items inside of '_meta' are not intended to be addressable. // Excluding it as a valid reference means that we can make it non-addressable // without having to copy all the attributes out of the context object // provided by the user. - if (this.components[0] === '_meta') { + if (this._components[0] === '_meta') { this.isValid = false; } else { this.isValid = true; } } else { const literalVal = refOrLiteral; - this.components = [literalVal]; + this._components = [literalVal]; this.isValid = literalVal !== ''; // Literals which start with '/' need escaped to prevent ambiguity. this.redactionName = literalVal.startsWith('/') ? toRefString(literalVal) : literalVal; @@ -96,7 +96,7 @@ export default class AttributeReference { } public get(target: LDContextCommon) { - const { components, isValid } = this; + const { _components: components, isValid } = this; if (!isValid) { return undefined; } @@ -127,21 +127,21 @@ export default class AttributeReference { } public getComponent(depth: number) { - return this.components[depth]; + return this._components[depth]; } public get depth() { - return this.components.length; + return this._components.length; } public get isKind(): boolean { - return this.components.length === 1 && this.components[0] === 'kind'; + return this._components.length === 1 && this._components[0] === 'kind'; } public compare(other: AttributeReference) { return ( this.depth === other.depth && - this.components.every((value, index) => value === other.getComponent(index)) + this._components.every((value, index) => value === other.getComponent(index)) ); } } diff --git a/packages/shared/common/src/Context.ts b/packages/shared/common/src/Context.ts index 6bf677ac4..6b4a0bca7 100644 --- a/packages/shared/common/src/Context.ts +++ b/packages/shared/common/src/Context.ts @@ -158,17 +158,17 @@ function legacyToSingleKind(user: LDUser): LDSingleKindContext { * the type system. */ export default class Context { - private context?: LDContextCommon; + private _context?: LDContextCommon; - private isMulti: boolean = false; + private _isMulti: boolean = false; - private isUser: boolean = false; + private _isUser: boolean = false; - private wasLegacy: boolean = false; + private _wasLegacy: boolean = false; - private contexts: Record = {}; + private _contexts: Record = {}; - private privateAttributeReferences?: Record; + private _privateAttributeReferences?: Record; public readonly kind: string; @@ -180,7 +180,7 @@ export default class Context { public readonly message?: string; - static readonly userKind: string = DEFAULT_KIND; + static readonly UserKind: string = DEFAULT_KIND; /** * Contexts should be created using the static factory method {@link Context.fromLDContext}. @@ -195,11 +195,11 @@ export default class Context { this.message = message; } - private static contextForError(kind: string, message: string) { + private static _contextForError(kind: string, message: string) { return new Context(false, kind, message); } - private static getValueFromContext( + private static _getValueFromContext( reference: AttributeReference, context?: LDContextCommon, ): any { @@ -213,29 +213,29 @@ export default class Context { return reference.get(context); } - private contextForKind(kind: string): LDContextCommon | undefined { - if (this.isMulti) { - return this.contexts[kind]; + private _contextForKind(kind: string): LDContextCommon | undefined { + if (this._isMulti) { + return this._contexts[kind]; } if (this.kind === kind) { - return this.context; + return this._context; } return undefined; } - private static fromMultiKindContext(context: LDMultiKindContext): Context { + private static _fromMultiKindContext(context: LDMultiKindContext): Context { const kinds = Object.keys(context).filter((key) => key !== 'kind'); const kindsValid = kinds.every(validKind); if (!kinds.length) { - return Context.contextForError( + return Context._contextForError( 'multi', 'A multi-kind context must contain at least one kind', ); } if (!kindsValid) { - return Context.contextForError('multi', 'Context contains invalid kinds'); + return Context._contextForError('multi', 'Context contains invalid kinds'); } const privateAttributes: Record = {}; @@ -253,11 +253,11 @@ export default class Context { }, {}); if (!contextsAreObjects) { - return Context.contextForError('multi', 'Context contained contexts that were not objects'); + return Context._contextForError('multi', 'Context contained contexts that were not objects'); } if (!Object.values(contexts).every((part) => validKey(part.key))) { - return Context.contextForError('multi', 'Context contained invalid keys'); + return Context._contextForError('multi', 'Context contained invalid keys'); } // There was only a single kind in the multi-kind context. @@ -265,56 +265,56 @@ export default class Context { if (kinds.length === 1) { const kind = kinds[0]; const created = new Context(true, kind); - created.context = contexts[kind]; - created.privateAttributeReferences = privateAttributes; - created.isUser = kind === 'user'; + created._context = { ...contexts[kind], kind }; + created._privateAttributeReferences = privateAttributes; + created._isUser = kind === 'user'; return created; } const created = new Context(true, context.kind); - created.contexts = contexts; - created.privateAttributeReferences = privateAttributes; + created._contexts = contexts; + created._privateAttributeReferences = privateAttributes; - created.isMulti = true; + created._isMulti = true; return created; } - private static fromSingleKindContext(context: LDSingleKindContext): Context { + private static _fromSingleKindContext(context: LDSingleKindContext): Context { const { key, kind } = context; const kindValid = validKind(kind); const keyValid = validKey(key); if (!kindValid) { - return Context.contextForError(kind ?? 'unknown', 'The kind was not valid for the context'); + return Context._contextForError(kind ?? 'unknown', 'The kind was not valid for the context'); } if (!keyValid) { - return Context.contextForError(kind, 'The key for the context was not valid'); + return Context._contextForError(kind, 'The key for the context was not valid'); } // The JSON interfaces uses dangling _. // eslint-disable-next-line no-underscore-dangle const privateAttributeReferences = processPrivateAttributes(context._meta?.privateAttributes); const created = new Context(true, kind); - created.isUser = kind === 'user'; - created.context = context; - created.privateAttributeReferences = { + created._isUser = kind === 'user'; + created._context = context; + created._privateAttributeReferences = { [kind]: privateAttributeReferences, }; return created; } - private static fromLegacyUser(context: LDUser): Context { + private static _fromLegacyUser(context: LDUser): Context { const keyValid = context.key !== undefined && context.key !== null; // For legacy users we allow empty keys. if (!keyValid) { - return Context.contextForError('user', 'The key for the context was not valid'); + return Context._contextForError('user', 'The key for the context was not valid'); } const created = new Context(true, 'user'); - created.isUser = true; - created.wasLegacy = true; - created.context = legacyToSingleKind(context); - created.privateAttributeReferences = { + created._isUser = true; + created._wasLegacy = true; + created._context = legacyToSingleKind(context); + created._privateAttributeReferences = { user: processPrivateAttributes(context.privateAttributeNames, true), }; return created; @@ -328,19 +328,19 @@ export default class Context { */ public static fromLDContext(context: LDContext): Context { if (!context) { - return Context.contextForError('unknown', 'No context specified. Returning default value'); + return Context._contextForError('unknown', 'No context specified. Returning default value'); } if (isSingleKind(context)) { - return Context.fromSingleKindContext(context); + return Context._fromSingleKindContext(context); } if (isMultiKind(context)) { - return Context.fromMultiKindContext(context); + return Context._fromMultiKindContext(context); } if (isLegacyUser(context)) { - return Context.fromLegacyUser(context); + return Context._fromLegacyUser(context); } - return Context.contextForError('unknown', 'Context was not of a valid kind'); + return Context._contextForError('unknown', 'Context was not of a valid kind'); } /** @@ -354,7 +354,7 @@ export default class Context { } const contexts = context.getContexts(); - if (!context.isMulti) { + if (!context._isMulti) { return contexts[0][1]; } const result: LDMultiKindContext = { @@ -378,7 +378,7 @@ export default class Context { if (reference.isKind) { return this.kinds; } - return Context.getValueFromContext(reference, this.contextForKind(kind)); + return Context._getValueFromContext(reference, this._contextForKind(kind)); } /** @@ -387,38 +387,38 @@ export default class Context { * @returns The key for the specified kind, or undefined. */ public key(kind: string = DEFAULT_KIND): string | undefined { - return this.contextForKind(kind)?.key; + return this._contextForKind(kind)?.key; } /** * True if this is a multi-kind context. */ public get isMultiKind(): boolean { - return this.isMulti; + return this._isMulti; } /** * Get the canonical key for this context. */ public get canonicalKey(): string { - if (this.isUser) { - return this.context!.key; + if (this._isUser) { + return this._context!.key; } - if (this.isMulti) { - return Object.keys(this.contexts) + if (this._isMulti) { + return Object.keys(this._contexts) .sort() - .map((key) => `${key}:${encodeKey(this.contexts[key].key)}`) + .map((key) => `${key}:${encodeKey(this._contexts[key].key)}`) .join(':'); } - return `${this.kind}:${encodeKey(this.context!.key)}`; + return `${this.kind}:${encodeKey(this._context!.key)}`; } /** * Get the kinds of this context. */ public get kinds(): string[] { - if (this.isMulti) { - return Object.keys(this.contexts); + if (this._isMulti) { + return Object.keys(this._contexts); } return [this.kind]; } @@ -427,8 +427,8 @@ export default class Context { * Get the kinds, and their keys, for this context. */ public get kindsAndKeys(): Record { - if (this.isMulti) { - return Object.entries(this.contexts).reduce( + if (this._isMulti) { + return Object.entries(this._contexts).reduce( (acc: Record, [kind, context]) => { acc[kind] = context.key; return acc; @@ -436,7 +436,7 @@ export default class Context { {}, ); } - return { [this.kind]: this.context!.key }; + return { [this.kind]: this._context!.key }; } /** @@ -445,7 +445,7 @@ export default class Context { * @param kind */ public privateAttributes(kind: string): AttributeReference[] { - return this.privateAttributeReferences?.[kind] || []; + return this._privateAttributeReferences?.[kind] || []; } /** @@ -456,13 +456,13 @@ export default class Context { * The returned objects should not be modified. */ public getContexts(): [string, LDContextCommon][] { - if (this.isMulti) { - return Object.entries(this.contexts); + if (this._isMulti) { + return Object.entries(this._contexts); } - return [[this.kind, this.context!]]; + return [[this.kind, this._context!]]; } public get legacy(): boolean { - return this.wasLegacy; + return this._wasLegacy; } } diff --git a/packages/shared/common/src/ContextFilter.ts b/packages/shared/common/src/ContextFilter.ts index f0373618d..34f78a53e 100644 --- a/packages/shared/common/src/ContextFilter.ts +++ b/packages/shared/common/src/ContextFilter.ts @@ -94,14 +94,14 @@ function cloneWithRedactions(target: LDContextCommon, references: AttributeRefer export default class ContextFilter { constructor( - private readonly allAttributesPrivate: boolean, - private readonly privateAttributes: AttributeReference[], + private readonly _allAttributesPrivate: boolean, + private readonly _privateAttributes: AttributeReference[], ) {} filter(context: Context, redactAnonymousAttributes: boolean = false): any { const contexts = context.getContexts(); if (contexts.length === 1) { - return this.filterSingleKind( + return this._filterSingleKind( context, contexts[0][1], contexts[0][0], @@ -112,12 +112,17 @@ export default class ContextFilter { kind: 'multi', }; contexts.forEach(([kind, single]) => { - filteredMulti[kind] = this.filterSingleKind(context, single, kind, redactAnonymousAttributes); + filteredMulti[kind] = this._filterSingleKind( + context, + single, + kind, + redactAnonymousAttributes, + ); }); return filteredMulti; } - private getAttributesToFilter( + private _getAttributesToFilter( context: Context, single: LDContextCommon, kind: string, @@ -126,21 +131,21 @@ export default class ContextFilter { return ( redactAllAttributes ? Object.keys(single).map((k) => new AttributeReference(k, true)) - : [...this.privateAttributes, ...context.privateAttributes(kind)] + : [...this._privateAttributes, ...context.privateAttributes(kind)] ).filter((attr) => !protectedAttributes.some((protectedAttr) => protectedAttr.compare(attr))); } - private filterSingleKind( + private _filterSingleKind( context: Context, single: LDContextCommon, kind: string, redactAnonymousAttributes: boolean, ): any { const redactAllAttributes = - this.allAttributesPrivate || (redactAnonymousAttributes && single.anonymous === true); + this._allAttributesPrivate || (redactAnonymousAttributes && single.anonymous === true); const { cloned, excluded } = cloneWithRedactions( single, - this.getAttributesToFilter(context, single, kind, redactAllAttributes), + this._getAttributesToFilter(context, single, kind, redactAllAttributes), ); if (context.legacy) { diff --git a/packages/shared/common/src/api/logging/BasicLoggerOptions.ts b/packages/shared/common/src/api/logging/BasicLoggerOptions.ts index 7b983a359..cba75880c 100644 --- a/packages/shared/common/src/api/logging/BasicLoggerOptions.ts +++ b/packages/shared/common/src/api/logging/BasicLoggerOptions.ts @@ -21,18 +21,23 @@ export interface BasicLoggerOptions { name?: string; /** - * An optional function to use to print each log line. + * An optional function, or map of levels to functions, to use to print each log line. * - * If this is specified, `basicLogger` calls it to write each line of output. The + * If not specified, the default is `console.error`. + * + * If a function is specified, `basicLogger` calls it to write each line of output. The * argument is a fully formatted log line, not including a linefeed. The function * is only called for log levels that are enabled. * - * If not specified, the default is `console.error`. + * If a map is specified, then each entry will be used as the destination for the corresponding + * log level. Any level that is not specified will use the default of `console.error`. * * Setting this property to anything other than a function will cause SDK * initialization to fail. */ - destination?: (line: string) => void; + destination?: + | ((line: string) => void) + | Record<'debug' | 'info' | 'warn' | 'error', (line: string) => void>; /** * An optional formatter to use. The formatter should be compatible diff --git a/packages/shared/common/src/api/platform/Crypto.ts b/packages/shared/common/src/api/platform/Crypto.ts index 417fe03fb..984e2f1aa 100644 --- a/packages/shared/common/src/api/platform/Crypto.ts +++ b/packages/shared/common/src/api/platform/Crypto.ts @@ -7,7 +7,19 @@ */ export interface Hasher { update(data: string): Hasher; - digest(encoding: string): string; + /** + * Note: All server SDKs MUST implement synchronous digest. + * + * Server SDKs have high performance requirements for bucketing users. + */ + digest?(encoding: string): string; + + /** + * Note: Client-side SDKs MUST implement either synchronous or asynchronous digest. + * + * Client SDKs do not have high throughput hashing operations. + */ + asyncDigest?(encoding: string): Promise; } /** @@ -17,7 +29,7 @@ export interface Hasher { * * The has implementation must support digesting to 'hex'. */ -export interface Hmac extends Hasher { +export interface Hmac { update(data: string): Hasher; digest(encoding: string): string; } @@ -27,6 +39,9 @@ export interface Hmac extends Hasher { */ export interface Crypto { createHash(algorithm: string): Hasher; - createHmac(algorithm: string, key: string): Hmac; + /** + * Note: Server SDKs MUST implement createHmac. + */ + createHmac?(algorithm: string, key: string): Hmac; randomUUID(): string; } diff --git a/packages/shared/common/src/api/platform/EventSource.ts b/packages/shared/common/src/api/platform/EventSource.ts index a9214b015..55bef9bfe 100644 --- a/packages/shared/common/src/api/platform/EventSource.ts +++ b/packages/shared/common/src/api/platform/EventSource.ts @@ -18,8 +18,10 @@ export interface EventSource { } export interface EventSourceInitDict { - errorFilter: (err: HttpErrorResponse) => boolean; + method?: string; headers: { [key: string]: string | string[] }; + body?: string; + errorFilter: (err: HttpErrorResponse) => boolean; initialRetryDelayMillis: number; readTimeoutMillis: number; retryResetIntervalMillis: number; diff --git a/packages/shared/common/src/api/platform/Requests.ts b/packages/shared/common/src/api/platform/Requests.ts index 96168012b..8b0438d20 100644 --- a/packages/shared/common/src/api/platform/Requests.ts +++ b/packages/shared/common/src/api/platform/Requests.ts @@ -76,6 +76,36 @@ export interface Options { method?: string; body?: string; timeout?: number; + /** + * For use in browser environments. Platform support will be best effort for this field. + * https://developer.mozilla.org/en-US/docs/Web/API/RequestInit#keepalive + */ + keepalive?: boolean; +} + +export interface EventSourceCapabilities { + /** + * If true the event source supports read timeouts. A read timeout for an + * event source represents the maximum time between receiving any data. + * If you receive 1 byte, and then a period of time greater than the read + * time out elapses, and you do not receive a second byte, then that would + * cause the event source to timeout. + * + * It is not a timeout for the read of the entire body, which should be + * indefinite. + */ + readTimeout: boolean; + + /** + * If true the event source supports customized verbs POST/REPORT instead of + * only the default GET. + */ + customMethod: boolean; + + /** + * If true the event source supports setting HTTP headers. + */ + headers: boolean; } export interface Requests { @@ -83,6 +113,8 @@ export interface Requests { createEventSource(url: string, eventSourceInitDict: EventSourceInitDict): EventSource; + getEventSourceCapabilities(): EventSourceCapabilities; + /** * Returns true if a proxy is configured. */ diff --git a/packages/shared/common/src/datasource/DataSourceErrorKinds.ts b/packages/shared/common/src/datasource/DataSourceErrorKinds.ts new file mode 100644 index 000000000..f06043e8c --- /dev/null +++ b/packages/shared/common/src/datasource/DataSourceErrorKinds.ts @@ -0,0 +1,15 @@ +export enum DataSourceErrorKind { + /// An unexpected error, such as an uncaught exception, further + /// described by the error message. + Unknown = 'UNKNOWN', + + /// An I/O error such as a dropped connection. + NetworkError = 'NETWORK_ERROR', + + /// The LaunchDarkly service returned an HTTP response with an error + /// status, available in the status code. + ErrorResponse = 'ERROR_RESPONSE', + + /// The SDK received malformed data from the LaunchDarkly service. + InvalidData = 'INVALID_DATA', +} diff --git a/packages/shared/common/src/datasource/errors.ts b/packages/shared/common/src/datasource/errors.ts new file mode 100644 index 000000000..ef804f7f1 --- /dev/null +++ b/packages/shared/common/src/datasource/errors.ts @@ -0,0 +1,37 @@ +/* eslint-disable max-classes-per-file */ +import { DataSourceErrorKind } from './DataSourceErrorKinds'; + +export class LDFileDataSourceError extends Error { + constructor(message: string) { + super(message); + this.name = 'LaunchDarklyFileDataSourceError'; + } +} + +export class LDPollingError extends Error { + public readonly kind: DataSourceErrorKind; + public readonly status?: number; + public readonly recoverable: boolean; + + constructor(kind: DataSourceErrorKind, message: string, status?: number, recoverable = true) { + super(message); + this.kind = kind; + this.status = status; + this.name = 'LaunchDarklyPollingError'; + this.recoverable = recoverable; + } +} + +export class LDStreamingError extends Error { + public readonly kind: DataSourceErrorKind; + public readonly code?: number; + public readonly recoverable: boolean; + + constructor(kind: DataSourceErrorKind, message: string, code?: number, recoverable = true) { + super(message); + this.kind = kind; + this.code = code; + this.name = 'LaunchDarklyStreamingError'; + this.recoverable = recoverable; + } +} diff --git a/packages/shared/common/src/datasource/index.ts b/packages/shared/common/src/datasource/index.ts new file mode 100644 index 000000000..f888015fb --- /dev/null +++ b/packages/shared/common/src/datasource/index.ts @@ -0,0 +1,4 @@ +import { DataSourceErrorKind } from './DataSourceErrorKinds'; +import { LDFileDataSourceError, LDPollingError, LDStreamingError } from './errors'; + +export { DataSourceErrorKind, LDFileDataSourceError, LDPollingError, LDStreamingError }; diff --git a/packages/shared/common/src/errors.ts b/packages/shared/common/src/errors.ts index f85a2cc92..bfc672668 100644 --- a/packages/shared/common/src/errors.ts +++ b/packages/shared/common/src/errors.ts @@ -2,33 +2,6 @@ // more complex, then they could be independent files. /* eslint-disable max-classes-per-file */ -export class LDFileDataSourceError extends Error { - constructor(message: string) { - super(message); - this.name = 'LaunchDarklyFileDataSourceError'; - } -} - -export class LDPollingError extends Error { - public readonly status?: number; - - constructor(message: string, status?: number) { - super(message); - this.status = status; - this.name = 'LaunchDarklyPollingError'; - } -} - -export class LDStreamingError extends Error { - public readonly code?: number; - - constructor(message: string, code?: number) { - super(message); - this.code = code; - this.name = 'LaunchDarklyStreamingError'; - } -} - export class LDUnexpectedResponseError extends Error { constructor(message: string) { super(message); diff --git a/packages/shared/common/src/index.ts b/packages/shared/common/src/index.ts index 653cde18d..2d23590f0 100644 --- a/packages/shared/common/src/index.ts +++ b/packages/shared/common/src/index.ts @@ -1,6 +1,12 @@ import AttributeReference from './AttributeReference'; import Context from './Context'; import ContextFilter from './ContextFilter'; +import { + DataSourceErrorKind, + LDFileDataSourceError, + LDPollingError, + LDStreamingError, +} from './datasource'; export * from './api'; export * from './validators'; @@ -11,4 +17,12 @@ export * from './utils'; export * as internal from './internal'; export * from './errors'; -export { AttributeReference, Context, ContextFilter }; +export { + AttributeReference, + Context, + ContextFilter, + DataSourceErrorKind, + LDPollingError, + LDStreamingError, + LDFileDataSourceError, +}; diff --git a/packages/shared/common/src/internal/diagnostics/DiagnosticsManager.ts b/packages/shared/common/src/internal/diagnostics/DiagnosticsManager.ts index 4516b9f17..438adb39f 100644 --- a/packages/shared/common/src/internal/diagnostics/DiagnosticsManager.ts +++ b/packages/shared/common/src/internal/diagnostics/DiagnosticsManager.ts @@ -2,20 +2,20 @@ import { Platform } from '../../api'; import { DiagnosticId, DiagnosticInitEvent, DiagnosticStatsEvent, StreamInitData } from './types'; export default class DiagnosticsManager { - private readonly startTime: number; - private streamInits: StreamInitData[] = []; - private readonly id: DiagnosticId; - private dataSinceDate: number; + private readonly _startTime: number; + private _streamInits: StreamInitData[] = []; + private readonly _id: DiagnosticId; + private _dataSinceDate: number; constructor( sdkKey: string, - private readonly platform: Platform, - private readonly diagnosticInitConfig: any, + private readonly _platform: Platform, + private readonly _diagnosticInitConfig: any, ) { - this.startTime = Date.now(); - this.dataSinceDate = this.startTime; - this.id = { - diagnosticId: platform.crypto.randomUUID(), + this._startTime = Date.now(); + this._dataSinceDate = this._startTime; + this._id = { + diagnosticId: _platform.crypto.randomUUID(), sdkKeySuffix: sdkKey.length > 6 ? sdkKey.substring(sdkKey.length - 6) : sdkKey, }; } @@ -25,15 +25,15 @@ export default class DiagnosticsManager { * not be repeated during the lifetime of the SDK client. */ createInitEvent(): DiagnosticInitEvent { - const sdkData = this.platform.info.sdkData(); - const platformData = this.platform.info.platformData(); + const sdkData = this._platform.info.sdkData(); + const platformData = this._platform.info.platformData(); return { kind: 'diagnostic-init', - id: this.id, - creationDate: this.startTime, + id: this._id, + creationDate: this._startTime, sdk: sdkData, - configuration: this.diagnosticInitConfig, + configuration: this._diagnosticInitConfig, platform: { name: platformData.name, osArch: platformData.os?.arch, @@ -54,7 +54,7 @@ export default class DiagnosticsManager { */ recordStreamInit(timestamp: number, failed: boolean, durationMillis: number) { const item = { timestamp, failed, durationMillis }; - this.streamInits.push(item); + this._streamInits.push(item); } /** @@ -73,17 +73,17 @@ export default class DiagnosticsManager { const currentTime = Date.now(); const evt: DiagnosticStatsEvent = { kind: 'diagnostic', - id: this.id, + id: this._id, creationDate: currentTime, - dataSinceDate: this.dataSinceDate, + dataSinceDate: this._dataSinceDate, droppedEvents, deduplicatedUsers, eventsInLastBatch, - streamInits: this.streamInits, + streamInits: this._streamInits, }; - this.streamInits = []; - this.dataSinceDate = currentTime; + this._streamInits = []; + this._dataSinceDate = currentTime; return evt; } } diff --git a/packages/shared/common/src/internal/evaluation/EventFactoryBase.ts b/packages/shared/common/src/internal/evaluation/EventFactoryBase.ts index 0b877a400..cba0feff2 100644 --- a/packages/shared/common/src/internal/evaluation/EventFactoryBase.ts +++ b/packages/shared/common/src/internal/evaluation/EventFactoryBase.ts @@ -19,11 +19,11 @@ export type EvalEventArgs = { }; export default class EventFactoryBase { - constructor(private readonly withReasons: boolean) {} + constructor(private readonly _withReasons: boolean) {} evalEvent(e: EvalEventArgs): InputEvalEvent { return new InputEvalEvent( - this.withReasons, + this._withReasons, e.context, e.flagKey, e.value, @@ -33,7 +33,7 @@ export default class EventFactoryBase { e.variation ?? undefined, e.trackEvents || e.addExperimentData, e.prereqOfFlagKey, - this.withReasons || e.addExperimentData ? e.reason : undefined, + this._withReasons || e.addExperimentData ? e.reason : undefined, e.debugEventsUntilDate, e.excludeFromSummaries, e.samplingRatio, @@ -42,7 +42,7 @@ export default class EventFactoryBase { unknownFlagEvent(key: string, defVal: LDFlagValue, context: Context) { return new InputEvalEvent( - this.withReasons, + this._withReasons, context, key, defVal, diff --git a/packages/shared/common/src/internal/events/ClientMessages.ts b/packages/shared/common/src/internal/events/ClientMessages.ts index 99e5df43c..8564952da 100644 --- a/packages/shared/common/src/internal/events/ClientMessages.ts +++ b/packages/shared/common/src/internal/events/ClientMessages.ts @@ -2,7 +2,7 @@ * Messages for issues which can be encountered processing client requests. */ export default class ClientMessages { - static readonly missingContextKeyNoEvent = + static readonly MissingContextKeyNoEvent = 'Context was unspecified or had no key; event will not be sent'; static invalidMetricValue(badType: string) { diff --git a/packages/shared/common/src/internal/events/EventProcessor.ts b/packages/shared/common/src/internal/events/EventProcessor.ts index bda2fe7b8..76c7bf1ac 100644 --- a/packages/shared/common/src/internal/events/EventProcessor.ts +++ b/packages/shared/common/src/internal/events/EventProcessor.ts @@ -5,6 +5,7 @@ import LDEventProcessor from '../../api/subsystem/LDEventProcessor'; import AttributeReference from '../../AttributeReference'; import ContextFilter from '../../ContextFilter'; import { ClientContext } from '../../options'; +import { LDHeaders } from '../../utils'; import { DiagnosticsManager } from '../diagnostics'; import EventSender from './EventSender'; import EventSummarizer, { SummarizedFlagsEvent } from './EventSummarizer'; @@ -35,6 +36,7 @@ interface CustomOutputEvent { data?: any; metricValue?: number; samplingRatio?: number; + url?: string; } interface FeatureOutputEvent { @@ -56,6 +58,25 @@ interface IndexInputEvent extends Omit { kind: 'index'; } +interface ClickOutputEvent { + kind: 'click'; + key: string; + url: string; + creationDate: number; + contextKeys: Record; + selector: string; + samplingRatio?: number; +} + +interface PageviewOutputEvent { + kind: 'pageview'; + key: string; + url: string; + creationDate: number; + contextKeys: Record; + samplingRatio?: number; +} + /** * The event processor doesn't need to know anything about the shape of the * diagnostic events. @@ -84,39 +105,40 @@ export interface EventProcessorOptions { } export default class EventProcessor implements LDEventProcessor { - private eventSender: EventSender; - private summarizer = new EventSummarizer(); - private queue: OutputEvent[] = []; - private lastKnownPastTime = 0; - private droppedEvents = 0; - private deduplicatedUsers = 0; - private exceededCapacity = false; - private eventsInLastBatch = 0; - private shutdown = false; - private capacity: number; - private logger?: LDLogger; - private contextFilter: ContextFilter; + private _eventSender: EventSender; + private _summarizer = new EventSummarizer(); + private _queue: OutputEvent[] = []; + private _lastKnownPastTime = 0; + private _droppedEvents = 0; + private _deduplicatedUsers = 0; + private _exceededCapacity = false; + private _eventsInLastBatch = 0; + private _shutdown = false; + private _capacity: number; + private _logger?: LDLogger; + private _contextFilter: ContextFilter; // Using any here, because setInterval handles are not the same // between node and web. - private diagnosticsTimer: any; - private flushTimer: any; - private flushUsersTimer: any = null; + private _diagnosticsTimer: any; + private _flushTimer: any; + private _flushUsersTimer: any = null; constructor( - private readonly config: EventProcessorOptions, + private readonly _config: EventProcessorOptions, clientContext: ClientContext, - private readonly contextDeduplicator?: LDContextDeduplicator, - private readonly diagnosticsManager?: DiagnosticsManager, + baseHeaders: LDHeaders, + private readonly _contextDeduplicator?: LDContextDeduplicator, + private readonly _diagnosticsManager?: DiagnosticsManager, start: boolean = true, ) { - this.capacity = config.eventsCapacity; - this.logger = clientContext.basicConfiguration.logger; - this.eventSender = new EventSender(clientContext); + this._capacity = _config.eventsCapacity; + this._logger = clientContext.basicConfiguration.logger; + this._eventSender = new EventSender(clientContext, baseHeaders); - this.contextFilter = new ContextFilter( - config.allAttributesPrivate, - config.privateAttributes.map((ref) => new AttributeReference(ref)), + this._contextFilter = new ContextFilter( + _config.allAttributesPrivate, + _config.privateAttributes.map((ref) => new AttributeReference(ref)), ); if (start) { @@ -125,58 +147,58 @@ export default class EventProcessor implements LDEventProcessor { } start() { - if (this.contextDeduplicator?.flushInterval !== undefined) { - this.flushUsersTimer = setInterval(() => { - this.contextDeduplicator?.flush(); - }, this.contextDeduplicator.flushInterval * 1000); + if (this._contextDeduplicator?.flushInterval !== undefined) { + this._flushUsersTimer = setInterval(() => { + this._contextDeduplicator?.flush(); + }, this._contextDeduplicator.flushInterval * 1000); } - this.flushTimer = setInterval(async () => { + this._flushTimer = setInterval(async () => { try { await this.flush(); } catch (e) { // Log errors and swallow them - this.logger?.debug(`Flush failed: ${e}`); + this._logger?.debug(`Flush failed: ${e}`); } - }, this.config.flushInterval * 1000); + }, this._config.flushInterval * 1000); - if (this.diagnosticsManager) { - const initEvent = this.diagnosticsManager!.createInitEvent(); - this.postDiagnosticEvent(initEvent); + if (this._diagnosticsManager) { + const initEvent = this._diagnosticsManager!.createInitEvent(); + this._postDiagnosticEvent(initEvent); - this.diagnosticsTimer = setInterval(() => { - const statsEvent = this.diagnosticsManager!.createStatsEventAndReset( - this.droppedEvents, - this.deduplicatedUsers, - this.eventsInLastBatch, + this._diagnosticsTimer = setInterval(() => { + const statsEvent = this._diagnosticsManager!.createStatsEventAndReset( + this._droppedEvents, + this._deduplicatedUsers, + this._eventsInLastBatch, ); - this.droppedEvents = 0; - this.deduplicatedUsers = 0; + this._droppedEvents = 0; + this._deduplicatedUsers = 0; - this.postDiagnosticEvent(statsEvent); - }, this.config.diagnosticRecordingInterval * 1000); + this._postDiagnosticEvent(statsEvent); + }, this._config.diagnosticRecordingInterval * 1000); } - this.logger?.debug('Started EventProcessor.'); + this._logger?.debug('Started EventProcessor.'); } - private postDiagnosticEvent(event: DiagnosticEvent) { - this.eventSender.sendEventData(LDEventType.DiagnosticEvent, event); + private _postDiagnosticEvent(event: DiagnosticEvent) { + this._eventSender.sendEventData(LDEventType.DiagnosticEvent, event); } close() { - clearInterval(this.flushTimer); - if (this.flushUsersTimer) { - clearInterval(this.flushUsersTimer); + clearInterval(this._flushTimer); + if (this._flushUsersTimer) { + clearInterval(this._flushUsersTimer); } - if (this.diagnosticsTimer) { - clearInterval(this.diagnosticsTimer); + if (this._diagnosticsTimer) { + clearInterval(this._diagnosticsTimer); } } async flush(): Promise { - if (this.shutdown) { + if (this._shutdown) { throw new LDInvalidSDKKeyError( 'Events cannot be posted because a permanent error has been encountered. ' + 'This is most likely an invalid SDK key. The specific error information ' + @@ -184,10 +206,10 @@ export default class EventProcessor implements LDEventProcessor { ); } - const eventsToFlush = this.queue; - this.queue = []; - const summary = this.summarizer.getSummary(); - this.summarizer.clearSummary(); + const eventsToFlush = this._queue; + this._queue = []; + const summary = this._summarizer.getSummary(); + this._summarizer.clearSummary(); if (Object.keys(summary.features).length) { eventsToFlush.push(summary); @@ -197,13 +219,13 @@ export default class EventProcessor implements LDEventProcessor { return; } - this.eventsInLastBatch = eventsToFlush.length; - this.logger?.debug('Flushing %d events', eventsToFlush.length); - await this.tryPostingEvents(eventsToFlush); + this._eventsInLastBatch = eventsToFlush.length; + this._logger?.debug('Flushing %d events', eventsToFlush.length); + await this._tryPostingEvents(eventsToFlush); } sendEvent(inputEvent: InputEvent) { - if (this.shutdown) { + if (this._shutdown) { return; } @@ -218,34 +240,34 @@ export default class EventProcessor implements LDEventProcessor { if (migrationEvent.samplingRatio === 1) { delete migrationEvent.samplingRatio; } - this.enqueue(migrationEvent); + this._enqueue(migrationEvent); } return; } - this.summarizer.summarizeEvent(inputEvent); + this._summarizer.summarizeEvent(inputEvent); const isFeatureEvent = isFeature(inputEvent); const addFullEvent = (isFeatureEvent && inputEvent.trackEvents) || !isFeatureEvent; - const addDebugEvent = this.shouldDebugEvent(inputEvent); + const addDebugEvent = this._shouldDebugEvent(inputEvent); const isIdentifyEvent = isIdentify(inputEvent); - const shouldNotDeduplicate = this.contextDeduplicator?.processContext(inputEvent.context); + const shouldNotDeduplicate = this._contextDeduplicator?.processContext(inputEvent.context); // If there is no cache, then it will never be in the cache. if (!shouldNotDeduplicate) { if (!isIdentifyEvent) { - this.deduplicatedUsers += 1; + this._deduplicatedUsers += 1; } } const addIndexEvent = shouldNotDeduplicate && !isIdentifyEvent; if (addIndexEvent) { - this.enqueue( - this.makeOutputEvent( + this._enqueue( + this._makeOutputEvent( { kind: 'index', creationDate: inputEvent.creationDate, @@ -257,20 +279,20 @@ export default class EventProcessor implements LDEventProcessor { ); } if (addFullEvent && shouldSample(inputEvent.samplingRatio)) { - this.enqueue(this.makeOutputEvent(inputEvent, false)); + this._enqueue(this._makeOutputEvent(inputEvent, false)); } if (addDebugEvent && shouldSample(inputEvent.samplingRatio)) { - this.enqueue(this.makeOutputEvent(inputEvent, true)); + this._enqueue(this._makeOutputEvent(inputEvent, true)); } } - private makeOutputEvent(event: InputEvent | IndexInputEvent, debug: boolean): OutputEvent { + private _makeOutputEvent(event: InputEvent | IndexInputEvent, debug: boolean): OutputEvent { switch (event.kind) { case 'feature': { const out: FeatureOutputEvent = { kind: debug ? 'debug' : 'feature', creationDate: event.creationDate, - context: this.contextFilter.filter(event.context, !debug), + context: this._contextFilter.filter(event.context, !debug), key: event.key, value: event.value, default: event.default, @@ -297,7 +319,7 @@ export default class EventProcessor implements LDEventProcessor { const out: IdentifyOutputEvent = { kind: event.kind, creationDate: event.creationDate, - context: this.contextFilter.filter(event.context), + context: this._contextFilter.filter(event.context), }; if (event.samplingRatio !== 1) { out.samplingRatio = event.samplingRatio; @@ -323,6 +345,31 @@ export default class EventProcessor implements LDEventProcessor { out.metricValue = event.metricValue; } + if (event.url !== undefined) { + out.url = event.url; + } + + return out; + } + case 'click': { + const out: ClickOutputEvent = { + kind: 'click', + creationDate: event.creationDate, + contextKeys: event.context.kindsAndKeys, + key: event.key, + url: event.url, + selector: event.selector, + }; + return out; + } + case 'pageview': { + const out: PageviewOutputEvent = { + kind: 'pageview', + creationDate: event.creationDate, + contextKeys: event.context.kindsAndKeys, + key: event.key, + url: event.url, + }; return out; } default: @@ -331,38 +378,38 @@ export default class EventProcessor implements LDEventProcessor { } } - private enqueue(event: OutputEvent) { - if (this.queue.length < this.capacity) { - this.queue.push(event); - this.exceededCapacity = false; + private _enqueue(event: OutputEvent) { + if (this._queue.length < this._capacity) { + this._queue.push(event); + this._exceededCapacity = false; } else { - if (!this.exceededCapacity) { - this.exceededCapacity = true; - this.logger?.warn( + if (!this._exceededCapacity) { + this._exceededCapacity = true; + this._logger?.warn( 'Exceeded event queue capacity. Increase capacity to avoid dropping events.', ); } - this.droppedEvents += 1; + this._droppedEvents += 1; } } - private shouldDebugEvent(event: InputEvent) { + private _shouldDebugEvent(event: InputEvent) { return ( isFeature(event) && event.debugEventsUntilDate && - event.debugEventsUntilDate > this.lastKnownPastTime && + event.debugEventsUntilDate > this._lastKnownPastTime && event.debugEventsUntilDate > Date.now() ); } - private async tryPostingEvents(events: OutputEvent[] | OutputEvent): Promise { - const res = await this.eventSender.sendEventData(LDEventType.AnalyticsEvents, events); + private async _tryPostingEvents(events: OutputEvent[] | OutputEvent): Promise { + const res = await this._eventSender.sendEventData(LDEventType.AnalyticsEvents, events); if (res.status === LDDeliveryStatus.FailedAndMustShutDown) { - this.shutdown = true; + this._shutdown = true; } if (res.serverTime) { - this.lastKnownPastTime = res.serverTime; + this._lastKnownPastTime = res.serverTime; } if (res.error) { diff --git a/packages/shared/common/src/internal/events/EventSender.ts b/packages/shared/common/src/internal/events/EventSender.ts index afee52df1..dbb46a430 100644 --- a/packages/shared/common/src/internal/events/EventSender.ts +++ b/packages/shared/common/src/internal/events/EventSender.ts @@ -11,38 +11,36 @@ import { LDUnexpectedResponseError, } from '../../errors'; import { ClientContext, getEventsUri } from '../../options'; -import { defaultHeaders, httpErrorMessage, sleep } from '../../utils'; +import { httpErrorMessage, LDHeaders, sleep } from '../../utils'; export default class EventSender implements LDEventSender { - private crypto: Crypto; - private defaultHeaders: { + private _crypto: Crypto; + private _defaultHeaders: { [key: string]: string; }; - private diagnosticEventsUri: string; - private eventsUri: string; - private requests: Requests; + private _diagnosticEventsUri: string; + private _eventsUri: string; + private _requests: Requests; - constructor(clientContext: ClientContext) { + constructor(clientContext: ClientContext, baseHeaders: LDHeaders) { const { basicConfiguration, platform } = clientContext; const { - sdkKey, - serviceEndpoints: { analyticsEventPath, diagnosticEventPath, includeAuthorizationHeader }, - tags, + serviceEndpoints: { analyticsEventPath, diagnosticEventPath }, } = basicConfiguration; - const { crypto, info, requests } = platform; + const { crypto, requests } = platform; - this.defaultHeaders = defaultHeaders(sdkKey, info, tags, includeAuthorizationHeader); - this.eventsUri = getEventsUri(basicConfiguration.serviceEndpoints, analyticsEventPath, []); - this.diagnosticEventsUri = getEventsUri( + this._defaultHeaders = { ...baseHeaders }; + this._eventsUri = getEventsUri(basicConfiguration.serviceEndpoints, analyticsEventPath, []); + this._diagnosticEventsUri = getEventsUri( basicConfiguration.serviceEndpoints, diagnosticEventPath, [], ); - this.requests = requests; - this.crypto = crypto; + this._requests = requests; + this._crypto = crypto; } - private async tryPostingEvents( + private async _tryPostingEvents( events: any, uri: string, payloadId: string | undefined, @@ -53,7 +51,7 @@ export default class EventSender implements LDEventSender { }; const headers: Record = { - ...this.defaultHeaders, + ...this._defaultHeaders, 'content-type': 'application/json', }; @@ -63,10 +61,13 @@ export default class EventSender implements LDEventSender { } let error; try { - const { status, headers: resHeaders } = await this.requests.fetch(uri, { + const { status, headers: resHeaders } = await this._requests.fetch(uri, { headers, body: JSON.stringify(events), method: 'POST', + // When sending events from browser environments the request should be completed even + // if the user is navigating away from the page. + keepalive: true, }); const serverDate = Date.parse(resHeaders.get('date') || ''); @@ -109,13 +110,13 @@ export default class EventSender implements LDEventSender { // wait 1 second before retrying await sleep(); - return this.tryPostingEvents(events, this.eventsUri, payloadId, false); + return this._tryPostingEvents(events, this._eventsUri, payloadId, false); } async sendEventData(type: LDEventType, data: any): Promise { - const payloadId = type === LDEventType.AnalyticsEvents ? this.crypto.randomUUID() : undefined; - const uri = type === LDEventType.AnalyticsEvents ? this.eventsUri : this.diagnosticEventsUri; + const payloadId = type === LDEventType.AnalyticsEvents ? this._crypto.randomUUID() : undefined; + const uri = type === LDEventType.AnalyticsEvents ? this._eventsUri : this._diagnosticEventsUri; - return this.tryPostingEvents(data, uri, payloadId, true); + return this._tryPostingEvents(data, uri, payloadId, true); } } diff --git a/packages/shared/common/src/internal/events/EventSummarizer.ts b/packages/shared/common/src/internal/events/EventSummarizer.ts index 932a09f5f..2e34f2e67 100644 --- a/packages/shared/common/src/internal/events/EventSummarizer.ts +++ b/packages/shared/common/src/internal/events/EventSummarizer.ts @@ -43,29 +43,29 @@ export interface SummarizedFlagsEvent { * @internal */ export default class EventSummarizer { - private startDate = 0; + private _startDate = 0; - private endDate = 0; + private _endDate = 0; - private counters: Record = {}; + private _counters: Record = {}; - private contextKinds: Record> = {}; + private _contextKinds: Record> = {}; summarizeEvent(event: InputEvent) { if (isFeature(event) && !event.excludeFromSummaries) { const countKey = counterKey(event); - const counter = this.counters[countKey]; - let kinds = this.contextKinds[event.key]; + const counter = this._counters[countKey]; + let kinds = this._contextKinds[event.key]; if (!kinds) { kinds = new Set(); - this.contextKinds[event.key] = kinds; + this._contextKinds[event.key] = kinds; } event.context.kinds.forEach((kind) => kinds.add(kind)); if (counter) { counter.increment(); } else { - this.counters[countKey] = new SummaryCounter( + this._counters[countKey] = new SummaryCounter( 1, event.key, event.value, @@ -75,24 +75,24 @@ export default class EventSummarizer { ); } - if (this.startDate === 0 || event.creationDate < this.startDate) { - this.startDate = event.creationDate; + if (this._startDate === 0 || event.creationDate < this._startDate) { + this._startDate = event.creationDate; } - if (event.creationDate > this.endDate) { - this.endDate = event.creationDate; + if (event.creationDate > this._endDate) { + this._endDate = event.creationDate; } } } getSummary(): SummarizedFlagsEvent { - const features = Object.values(this.counters).reduce( + const features = Object.values(this._counters).reduce( (acc: Record, counter) => { let flagSummary = acc[counter.key]; if (!flagSummary) { flagSummary = { default: counter.default, counters: [], - contextKinds: [...this.contextKinds[counter.key]], + contextKinds: [...this._contextKinds[counter.key]], }; acc[counter.key] = flagSummary; } @@ -117,17 +117,17 @@ export default class EventSummarizer { ); return { - startDate: this.startDate, - endDate: this.endDate, + startDate: this._startDate, + endDate: this._endDate, features, kind: 'summary', }; } clearSummary() { - this.startDate = 0; - this.endDate = 0; - this.counters = {}; - this.contextKinds = {}; + this._startDate = 0; + this._endDate = 0; + this._counters = {}; + this._contextKinds = {}; } } diff --git a/packages/shared/common/src/internal/events/InputClickEvent.ts b/packages/shared/common/src/internal/events/InputClickEvent.ts new file mode 100644 index 000000000..a5812176d --- /dev/null +++ b/packages/shared/common/src/internal/events/InputClickEvent.ts @@ -0,0 +1,11 @@ +import Context from '../../Context'; + +export default interface InputClickEvent { + kind: 'click'; + samplingRatio: number; + key: string; + url: string; + creationDate: number; + context: Context; + selector: string; +} diff --git a/packages/shared/common/src/internal/events/InputCustomEvent.ts b/packages/shared/common/src/internal/events/InputCustomEvent.ts index 1c0c4a2b3..a8e29364f 100644 --- a/packages/shared/common/src/internal/events/InputCustomEvent.ts +++ b/packages/shared/common/src/internal/events/InputCustomEvent.ts @@ -13,6 +13,8 @@ export default class InputCustomEvent { // Currently custom events are not sampled, but this is here to make the handling // code more uniform. public readonly samplingRatio: number = 1, + // Browser SDKs can include a URL for custom events. + public readonly url?: string, ) { this.creationDate = Date.now(); this.context = context; diff --git a/packages/shared/common/src/internal/events/InputEvent.ts b/packages/shared/common/src/internal/events/InputEvent.ts index 2ffa15f51..9a1ab7e4c 100644 --- a/packages/shared/common/src/internal/events/InputEvent.ts +++ b/packages/shared/common/src/internal/events/InputEvent.ts @@ -1,7 +1,15 @@ +import InputClickEvent from './InputClickEvent'; import InputCustomEvent from './InputCustomEvent'; import InputEvalEvent from './InputEvalEvent'; import InputIdentifyEvent from './InputIdentifyEvent'; import InputMigrationEvent from './InputMigrationEvent'; +import InputPageViewEvent from './InputPageViewEvent'; -type InputEvent = InputEvalEvent | InputCustomEvent | InputIdentifyEvent | InputMigrationEvent; +type InputEvent = + | InputEvalEvent + | InputCustomEvent + | InputIdentifyEvent + | InputMigrationEvent + | InputClickEvent + | InputPageViewEvent; export default InputEvent; diff --git a/packages/shared/common/src/internal/events/InputPageViewEvent.ts b/packages/shared/common/src/internal/events/InputPageViewEvent.ts new file mode 100644 index 000000000..f01500742 --- /dev/null +++ b/packages/shared/common/src/internal/events/InputPageViewEvent.ts @@ -0,0 +1,10 @@ +import Context from '../../Context'; + +export default interface InputPageViewEvent { + kind: 'pageview'; + samplingRatio: number; + key: string; + url: string; + creationDate: number; + context: Context; +} diff --git a/packages/shared/common/src/internal/events/LDInternalOptions.ts b/packages/shared/common/src/internal/events/LDInternalOptions.ts index b852990a3..54f6b91c5 100644 --- a/packages/shared/common/src/internal/events/LDInternalOptions.ts +++ b/packages/shared/common/src/internal/events/LDInternalOptions.ts @@ -12,6 +12,7 @@ export type LDInternalOptions = { analyticsEventPath?: string; diagnosticEventPath?: string; includeAuthorizationHeader?: boolean; + userAgentHeaderName?: 'user-agent' | 'x-launchdarkly-user-agent'; /** * In seconds. Log a warning if identifyTimeout is greater than this value. diff --git a/packages/shared/common/src/internal/index.ts b/packages/shared/common/src/internal/index.ts index ae8a03362..eb2951786 100644 --- a/packages/shared/common/src/internal/index.ts +++ b/packages/shared/common/src/internal/index.ts @@ -1,5 +1,5 @@ +export * from './context'; export * from './diagnostics'; export * from './evaluation'; export * from './events'; export * from './stream'; -export * from './context'; diff --git a/packages/shared/common/src/internal/stream/StreamingProcessor.ts b/packages/shared/common/src/internal/stream/StreamingProcessor.ts index d9ccfaab4..5ffcc90cf 100644 --- a/packages/shared/common/src/internal/stream/StreamingProcessor.ts +++ b/packages/shared/common/src/internal/stream/StreamingProcessor.ts @@ -7,10 +7,11 @@ import { Requests, } from '../../api'; import { LDStreamProcessor } from '../../api/subsystem'; -import { LDStreamingError } from '../../errors'; +import { DataSourceErrorKind } from '../../datasource/DataSourceErrorKinds'; +import { LDStreamingError } from '../../datasource/errors'; import { ClientContext } from '../../options'; import { getStreamingUri } from '../../options/ServiceEndpoints'; -import { defaultHeaders, httpErrorMessage, shouldRetry } from '../../utils'; +import { httpErrorMessage, LDHeaders, shouldRetry } from '../../utils'; import { DiagnosticsManager } from '../diagnostics'; import { StreamingErrorHandler } from './types'; @@ -22,56 +23,59 @@ const reportJsonError = ( ) => { logger?.error(`Stream received invalid data in "${type}" message`); logger?.debug(`Invalid JSON follows: ${data}`); - errorHandler?.(new LDStreamingError('Malformed JSON data in event stream')); + errorHandler?.( + new LDStreamingError(DataSourceErrorKind.InvalidData, 'Malformed JSON data in event stream'), + ); }; +// TODO: SDK-156 - Move to Server SDK specific location class StreamingProcessor implements LDStreamProcessor { - private readonly headers: { [key: string]: string | string[] }; - private readonly streamUri: string; - private readonly logger?: LDLogger; + private readonly _headers: { [key: string]: string | string[] }; + private readonly _streamUri: string; + private readonly _logger?: LDLogger; - private eventSource?: EventSource; - private requests: Requests; - private connectionAttemptStartTime?: number; + private _eventSource?: EventSource; + private _requests: Requests; + private _connectionAttemptStartTime?: number; constructor( - sdkKey: string, clientContext: ClientContext, streamUriPath: string, parameters: { key: string; value: string }[], - private readonly listeners: Map, - private readonly diagnosticsManager?: DiagnosticsManager, - private readonly errorHandler?: StreamingErrorHandler, - private readonly streamInitialReconnectDelay = 1, + private readonly _listeners: Map, + baseHeaders: LDHeaders, + private readonly _diagnosticsManager?: DiagnosticsManager, + private readonly _errorHandler?: StreamingErrorHandler, + private readonly _streamInitialReconnectDelay = 1, ) { const { basicConfiguration, platform } = clientContext; - const { logger, tags } = basicConfiguration; - const { info, requests } = platform; + const { logger } = basicConfiguration; + const { requests } = platform; - this.headers = defaultHeaders(sdkKey, info, tags); - this.logger = logger; - this.requests = requests; - this.streamUri = getStreamingUri( + this._headers = { ...baseHeaders }; + this._logger = logger; + this._requests = requests; + this._streamUri = getStreamingUri( basicConfiguration.serviceEndpoints, streamUriPath, parameters, ); } - private logConnectionStarted() { - this.connectionAttemptStartTime = Date.now(); + private _logConnectionStarted() { + this._connectionAttemptStartTime = Date.now(); } - private logConnectionResult(success: boolean) { - if (this.connectionAttemptStartTime && this.diagnosticsManager) { - this.diagnosticsManager.recordStreamInit( - this.connectionAttemptStartTime, + private _logConnectionResult(success: boolean) { + if (this._connectionAttemptStartTime && this._diagnosticsManager) { + this._diagnosticsManager.recordStreamInit( + this._connectionAttemptStartTime, !success, - Date.now() - this.connectionAttemptStartTime, + Date.now() - this._connectionAttemptStartTime, ); } - this.connectionAttemptStartTime = undefined; + this._connectionAttemptStartTime = undefined; } /** @@ -83,35 +87,37 @@ class StreamingProcessor implements LDStreamProcessor { * * @private */ - private retryAndHandleError(err: HttpErrorResponse) { + private _retryAndHandleError(err: HttpErrorResponse) { if (!shouldRetry(err)) { - this.logConnectionResult(false); - this.errorHandler?.(new LDStreamingError(err.message, err.status)); - this.logger?.error(httpErrorMessage(err, 'streaming request')); + this._logConnectionResult(false); + this._errorHandler?.( + new LDStreamingError(DataSourceErrorKind.ErrorResponse, err.message, err.status), + ); + this._logger?.error(httpErrorMessage(err, 'streaming request')); return false; } - this.logger?.warn(httpErrorMessage(err, 'streaming request', 'will retry')); - this.logConnectionResult(false); - this.logConnectionStarted(); + this._logger?.warn(httpErrorMessage(err, 'streaming request', 'will retry')); + this._logConnectionResult(false); + this._logConnectionStarted(); return true; } start() { - this.logConnectionStarted(); + this._logConnectionStarted(); // TLS is handled by the platform implementation. - const eventSource = this.requests.createEventSource(this.streamUri, { - headers: this.headers, - errorFilter: (error: HttpErrorResponse) => this.retryAndHandleError(error), - initialRetryDelayMillis: 1000 * this.streamInitialReconnectDelay, + const eventSource = this._requests.createEventSource(this._streamUri, { + headers: this._headers, + errorFilter: (error: HttpErrorResponse) => this._retryAndHandleError(error), + initialRetryDelayMillis: 1000 * this._streamInitialReconnectDelay, readTimeoutMillis: 5 * 60 * 1000, retryResetIntervalMillis: 60 * 1000, }); - this.eventSource = eventSource; + this._eventSource = eventSource; eventSource.onclose = () => { - this.logger?.info('Closed LaunchDarkly stream connection'); + this._logger?.info('Closed LaunchDarkly stream connection'); }; eventSource.onerror = () => { @@ -119,37 +125,42 @@ class StreamingProcessor implements LDStreamProcessor { }; eventSource.onopen = () => { - this.logger?.info('Opened LaunchDarkly stream connection'); + this._logger?.info('Opened LaunchDarkly stream connection'); }; eventSource.onretrying = (e) => { - this.logger?.info(`Will retry stream connection in ${e.delayMillis} milliseconds`); + this._logger?.info(`Will retry stream connection in ${e.delayMillis} milliseconds`); }; - this.listeners.forEach(({ deserializeData, processJson }, eventName) => { + this._listeners.forEach(({ deserializeData, processJson }, eventName) => { eventSource.addEventListener(eventName, (event) => { - this.logger?.debug(`Received ${eventName} event`); + this._logger?.debug(`Received ${eventName} event`); if (event?.data) { - this.logConnectionResult(true); + this._logConnectionResult(true); const { data } = event; const dataJson = deserializeData(data); if (!dataJson) { - reportJsonError(eventName, data, this.logger, this.errorHandler); + reportJsonError(eventName, data, this._logger, this._errorHandler); return; } processJson(dataJson); } else { - this.errorHandler?.(new LDStreamingError('Unexpected payload from event stream')); + this._errorHandler?.( + new LDStreamingError( + DataSourceErrorKind.Unknown, + 'Unexpected payload from event stream', + ), + ); } }); }); } stop() { - this.eventSource?.close(); - this.eventSource = undefined; + this._eventSource?.close(); + this._eventSource = undefined; } close() { diff --git a/packages/shared/common/src/internal/stream/types.ts b/packages/shared/common/src/internal/stream/types.ts index a2c1c42d4..4b84650e6 100644 --- a/packages/shared/common/src/internal/stream/types.ts +++ b/packages/shared/common/src/internal/stream/types.ts @@ -1,3 +1,3 @@ -import { LDStreamingError } from '../../errors'; +import { LDStreamingError } from '../../datasource/errors'; export type StreamingErrorHandler = (err: LDStreamingError) => void; diff --git a/packages/shared/common/src/logging/BasicLogger.ts b/packages/shared/common/src/logging/BasicLogger.ts index f46f667e3..ffb6fccb9 100644 --- a/packages/shared/common/src/logging/BasicLogger.ts +++ b/packages/shared/common/src/logging/BasicLogger.ts @@ -1,15 +1,15 @@ -import { BasicLoggerOptions, LDLogger } from '../api'; +import { BasicLoggerOptions, LDLogger, LDLogLevel } from '../api'; import format from './format'; -const LogPriority = { - debug: 0, - info: 1, - warn: 2, - error: 3, - none: 4, -}; +enum LogPriority { + debug = 0, + info = 1, + warn = 2, + error = 3, + none = 4, +} -const LevelNames = ['debug', 'info', 'warn', 'error', 'none']; +const LEVEL_NAMES: LDLogLevel[] = ['debug', 'info', 'warn', 'error', 'none']; /** * A basic logger which handles filtering by level. @@ -23,13 +23,13 @@ const LevelNames = ['debug', 'info', 'warn', 'error', 'none']; * as well for performance. */ export default class BasicLogger implements LDLogger { - private logLevel: number; + private _logLevel: number; - private name: string; + private _name: string; - private destination?: (line: string) => void; + private _destinations?: Record void>; - private formatter?: (...args: any[]) => string; + private _formatter?: (...args: any[]) => string; /** * This should only be used as a default fallback and not as a convenient @@ -41,18 +41,32 @@ export default class BasicLogger implements LDLogger { } constructor(options: BasicLoggerOptions) { - this.logLevel = LogPriority[options.level ?? 'info'] ?? LogPriority.info; - this.name = options.name ?? 'LaunchDarkly'; - // eslint-disable-next-line no-console - this.destination = options.destination; - this.formatter = options.formatter; + this._logLevel = LogPriority[options.level ?? 'info'] ?? LogPriority.info; + this._name = options.name ?? 'LaunchDarkly'; + this._formatter = options.formatter; + if (typeof options.destination === 'object') { + this._destinations = { + [LogPriority.debug]: options.destination.debug, + [LogPriority.info]: options.destination.info, + [LogPriority.warn]: options.destination.warn, + [LogPriority.error]: options.destination.error, + }; + } else if (typeof options.destination === 'function') { + const { destination } = options; + this._destinations = { + [LogPriority.debug]: destination, + [LogPriority.info]: destination, + [LogPriority.warn]: destination, + [LogPriority.error]: destination, + }; + } } - private tryFormat(...args: any[]): string { + private _tryFormat(...args: any[]): string { try { - if (this.formatter) { + if (this._formatter) { // In case the provided formatter fails. - return this.formatter?.(...args); + return this._formatter?.(...args); } return format(...args); } catch { @@ -60,21 +74,22 @@ export default class BasicLogger implements LDLogger { } } - private tryWrite(msg: string) { + private _tryWrite(destination: (msg: string) => void, msg: string) { try { - this.destination!(msg); + destination(msg); } catch { // eslint-disable-next-line no-console console.error(msg); } } - private log(level: number, args: any[]) { - if (level >= this.logLevel) { - const prefix = `${LevelNames[level]}: [${this.name}]`; + private _log(level: number, args: any[]) { + if (level >= this._logLevel) { + const prefix = `${LEVEL_NAMES[level]}: [${this._name}]`; try { - if (this.destination) { - this.tryWrite(`${prefix} ${this.tryFormat(...args)}`); + const destination = this._destinations?.[level]; + if (destination) { + this._tryWrite(destination, `${prefix} ${this._tryFormat(...args)}`); } else { // `console.error` has its own formatter. // So we don't need to do anything. @@ -90,18 +105,18 @@ export default class BasicLogger implements LDLogger { } error(...args: any[]): void { - this.log(LogPriority.error, args); + this._log(LogPriority.error, args); } warn(...args: any[]): void { - this.log(LogPriority.warn, args); + this._log(LogPriority.warn, args); } info(...args: any[]): void { - this.log(LogPriority.info, args); + this._log(LogPriority.info, args); } debug(...args: any[]): void { - this.log(LogPriority.debug, args); + this._log(LogPriority.debug, args); } } diff --git a/packages/shared/common/src/logging/SafeLogger.ts b/packages/shared/common/src/logging/SafeLogger.ts index 8b7b84289..d51d09ca2 100644 --- a/packages/shared/common/src/logging/SafeLogger.ts +++ b/packages/shared/common/src/logging/SafeLogger.ts @@ -19,9 +19,9 @@ const loggerRequirements = { * checking for the presence of required methods at configuration time. */ export default class SafeLogger implements LDLogger { - private logger: LDLogger; + private _logger: LDLogger; - private fallback: LDLogger; + private _fallback: LDLogger; /** * Construct a safe logger with the specified logger. @@ -39,32 +39,32 @@ export default class SafeLogger implements LDLogger { // criteria since the SDK calls the logger during nearly all of its operations. } }); - this.logger = logger; - this.fallback = fallback; + this._logger = logger; + this._fallback = fallback; } - private log(level: 'error' | 'warn' | 'info' | 'debug', args: any[]) { + private _log(level: 'error' | 'warn' | 'info' | 'debug', args: any[]) { try { - this.logger[level](...args); + this._logger[level](...args); } catch { // If all else fails do not break. - this.fallback[level](...args); + this._fallback[level](...args); } } error(...args: any[]): void { - this.log('error', args); + this._log('error', args); } warn(...args: any[]): void { - this.log('warn', args); + this._log('warn', args); } info(...args: any[]): void { - this.log('info', args); + this._log('info', args); } debug(...args: any[]): void { - this.log('debug', args); + this._log('debug', args); } } diff --git a/packages/shared/common/src/logging/format.ts b/packages/shared/common/src/logging/format.ts index 84c60862c..d9440920a 100644 --- a/packages/shared/common/src/logging/format.ts +++ b/packages/shared/common/src/logging/format.ts @@ -97,6 +97,7 @@ const escapes: Record string> = { f: (val: any) => toFloat(val), j: (val: any) => tryStringify(val), o: (val: any) => tryStringify(val), + // eslint-disable-next-line @typescript-eslint/naming-convention O: (val: any) => tryStringify(val), c: () => '', }; diff --git a/packages/shared/common/src/options/ServiceEndpoints.ts b/packages/shared/common/src/options/ServiceEndpoints.ts index d0781b0a9..e0577b79b 100644 --- a/packages/shared/common/src/options/ServiceEndpoints.ts +++ b/packages/shared/common/src/options/ServiceEndpoints.ts @@ -10,6 +10,7 @@ function canonicalizePath(path: string): string { * Specifies the base service URIs used by SDK components. */ export default class ServiceEndpoints { + // eslint-disable-next-line @typescript-eslint/naming-convention public static DEFAULT_EVENTS = 'https://events.launchdarkly.com'; public readonly streaming: string; diff --git a/packages/shared/common/src/utils/http.ts b/packages/shared/common/src/utils/http.ts index 0a9885780..347c63f4e 100644 --- a/packages/shared/common/src/utils/http.ts +++ b/packages/shared/common/src/utils/http.ts @@ -4,7 +4,8 @@ import { ApplicationTags } from '../options'; export type LDHeaders = { authorization?: string; - 'user-agent': string; + 'user-agent'?: string; + 'x-launchdarkly-user-agent'?: string; 'x-launchdarkly-wrapper'?: string; 'x-launchdarkly-tags'?: string; }; @@ -14,11 +15,12 @@ export function defaultHeaders( info: Info, tags?: ApplicationTags, includeAuthorizationHeader: boolean = true, + userAgentHeaderName: 'user-agent' | 'x-launchdarkly-user-agent' = 'user-agent', ): LDHeaders { const { userAgentBase, version, wrapperName, wrapperVersion } = info.sdkData(); const headers: LDHeaders = { - 'user-agent': `${userAgentBase ?? 'NodeJSClient'}/${version}`, + [userAgentHeaderName]: `${userAgentBase ?? 'NodeJSClient'}/${version}`, }; // edge sdks sets this to false because they use the clientSideID diff --git a/packages/shared/common/src/validators.ts b/packages/shared/common/src/validators.ts index d294643bd..a070043a9 100644 --- a/packages/shared/common/src/validators.ts +++ b/packages/shared/common/src/validators.ts @@ -38,12 +38,12 @@ export class FactoryOrInstance implements TypeValidator { * Validate a basic type. */ export class Type implements TypeValidator { - private typeName: string; + private _typeName: string; protected typeOf: string; constructor(typeName: string, example: T) { - this.typeName = typeName; + this._typeName = typeName; this.typeOf = typeof example; } @@ -55,7 +55,7 @@ export class Type implements TypeValidator { } getType(): string { - return this.typeName; + return this._typeName; } } @@ -66,12 +66,12 @@ export class Type implements TypeValidator { * of classes will simply objects. */ export class TypeArray implements TypeValidator { - private typeName: string; + private _typeName: string; protected typeOf: string; constructor(typeName: string, example: T) { - this.typeName = typeName; + this._typeName = typeName; this.typeOf = typeof example; } @@ -86,7 +86,7 @@ export class TypeArray implements TypeValidator { } getType(): string { - return this.typeName; + return this._typeName; } } diff --git a/packages/shared/common/tsconfig.json b/packages/shared/common/tsconfig.json index e2ed2b0f3..b5d080223 100644 --- a/packages/shared/common/tsconfig.json +++ b/packages/shared/common/tsconfig.json @@ -2,9 +2,10 @@ "compilerOptions": { "rootDir": "src", "outDir": "dist", - "target": "ES2017", + "target": "ES2020", "lib": ["es6"], - "module": "commonjs", + "module": "ESNext", + "moduleResolution": "node", "strict": true, "noImplicitOverride": true, // Needed for CommonJS modules: markdown-it, fs-extra @@ -12,8 +13,8 @@ "sourceMap": true, "declaration": true, "declarationMap": true, // enables importers to jump to source - "stripInternal": true, - "composite": true + "stripInternal": true }, + "include": ["src"], "exclude": ["**/*.test.ts", "dist", "node_modules", "__tests__"] } diff --git a/packages/shared/mocks/CHANGELOG.md b/packages/shared/mocks/CHANGELOG.md deleted file mode 100644 index cc1a5afb9..000000000 --- a/packages/shared/mocks/CHANGELOG.md +++ /dev/null @@ -1,3 +0,0 @@ -# Changelog - -All notable changes to `@launchdarkly/private-js-mocks` will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org). diff --git a/packages/shared/mocks/LICENSE b/packages/shared/mocks/LICENSE deleted file mode 100644 index ab8bd335b..000000000 --- a/packages/shared/mocks/LICENSE +++ /dev/null @@ -1,13 +0,0 @@ -Copyright 2023 Catamorphic, Co. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. diff --git a/packages/shared/mocks/README.md b/packages/shared/mocks/README.md deleted file mode 100644 index 2d178b398..000000000 --- a/packages/shared/mocks/README.md +++ /dev/null @@ -1,131 +0,0 @@ -# LaunchDarkly SDK JavaScript Mocks - -[![Actions Status][mocks-ci-badge]][mocks-ci] - -> [!CAUTION] -> Internal use only. -> This project contains JavaScript mocks that are consumed in unit tests in client-side and server-side JavaScript SDKs. - -## Installation - -This package is not published publicly. To use it internally, add the following line to your project's package.json -devDependencies. yarn workspace has been setup to recognize this package so this dependency should automatically work: - -```bash - "devDependencies": { - "@launchdarkly/private-js-mocks": "0.0.1", - ... -``` - -Then in your jest config add `@launchdarkly/private-js-mocks/setup` to setupFilesAfterEnv: - -```js -// jest.config.js or jest.config.json -module.exports = { - setupFilesAfterEnv: ['@launchdarkly/private-js-mocks/setup'], - ... -} -``` - -## Usage - -> [!IMPORTANT] -> basicPlatform and clientContext must be used inside a test because it's setup before each test. - -- `basicPlatform`: a concrete but basic implementation of [Platform](https://github.com/launchdarkly/js-core/blob/main/packages/shared/common/src/api/platform/Platform.ts). This is setup beforeEach so it must be used inside a test. - -- `clientContext`: ClientContext object including `basicPlatform` above. This is setup beforeEach so it must be used inside a test as well. - -- `hasher`: a Hasher object returned by `Crypto.createHash`. All functions in this object are jest mocks. This is exported - separately as a top level export because `Crypto` does not expose this publicly and we want to respect that. - -## Example - -```tsx -import { basicPlatform, clientContext, hasher } from '@launchdarkly/private-js-mocks'; - -// DOES NOT WORK: crypto is undefined because basicPlatform must be inside a test -// because it's setup by the package in beforeEach. -const { crypto } = basicPlatform; // DON'T DO THIS HERE - -// DOES NOT WORK: clientContext must be used inside a test. Otherwise all properties -// of it will be undefined. -const { - basicConfiguration: { serviceEndpoints, tags }, - platform: { info }, -} = clientContext; // DON'T DO THIS HERE - -describe('button', () => { - // DOES NOT WORK: again must be inside an actual test. At the test suite, - // level, beforeEach has not been run. - const { crypto } = basicPlatform; // DON'T DO THIS HERE - - // DO THIS - let crypto: Crypto; - let info: Info; - let serviceEndpoints: ServiceEndpoints; - let tags: ApplicationTags; - - beforeEach(() => { - // WORKS: basicPlatform and clientContext have been setup by the package. - ({ crypto, info } = basicPlatform); - - // WORKS - ({ - basicConfiguration: { serviceEndpoints, tags }, - platform: { info }, - } = clientContext); - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - it('hashes the correct string', () => { - // arrange - const bucketer = new Bucketer(crypto); - - // act - const [bucket, hadContext] = bucketer.bucket(); - - // assert - // WORKS - expect(crypto.createHash).toHaveBeenCalled(); - - // WORKS: alternatively you can just use the full path to access the properties - // of basicPlatform - expect(basicPlatform.crypto.createHash).toHaveBeenCalled(); - - // GOTCHA: hasher is a separte import from crypto to respect - // the public Crypto interface. - expect(hasher.update).toHaveBeenCalledWith(expected); - expect(hasher.digest).toHaveBeenCalledWith('hex'); - }); -}); -``` - -## Developing this package - -If you make changes to this package, you'll need to run `yarn build` in the `mocks` directory for changes to take effect. - -## Contributing - -See [Contributing](../shared/CONTRIBUTING.md). - -## About LaunchDarkly - -- LaunchDarkly is a continuous delivery platform that provides feature flags as a service and allows developers to iterate quickly and safely. We allow you to easily flag your features and manage them from the LaunchDarkly dashboard. With LaunchDarkly, you can: - - Roll out a new feature to a subset of your users (like a group of users who opt-in to a beta tester group), gathering feedback and bug reports from real-world use cases. - - Gradually roll out a feature to an increasing percentage of users, and track the effect that the feature has on key metrics (for instance, how likely is a user to complete a purchase if they have feature A versus feature B?). - - Turn off a feature that you realize is causing performance problems in production, without needing to re-deploy, or even restart the application with a changed configuration file. - - Grant access to certain features based on user attributes, like payment plan (eg: users on the ‘gold’ plan get access to more features than users in the ‘silver’ plan). - - Disable parts of your application to facilitate maintenance, without taking everything offline. -- LaunchDarkly provides feature flag SDKs for a wide variety of languages and technologies. Check out [our documentation](https://docs.launchdarkly.com/sdk) for a complete list. -- Explore LaunchDarkly - - [launchdarkly.com](https://www.launchdarkly.com/ 'LaunchDarkly Main Website') for more information - - [docs.launchdarkly.com](https://docs.launchdarkly.com/ 'LaunchDarkly Documentation') for our documentation and SDK reference guides - - [apidocs.launchdarkly.com](https://apidocs.launchdarkly.com/ 'LaunchDarkly API Documentation') for our API documentation - - [blog.launchdarkly.com](https://blog.launchdarkly.com/ 'LaunchDarkly Blog Documentation') for the latest product updates - -[mocks-ci-badge]: https://github.com/launchdarkly/js-core/actions/workflows/mocks.yml/badge.svg -[mocks-ci]: https://github.com/launchdarkly/js-core/actions/workflows/mocks.yml diff --git a/packages/shared/mocks/jest.config.js b/packages/shared/mocks/jest.config.js deleted file mode 100644 index 6753062cc..000000000 --- a/packages/shared/mocks/jest.config.js +++ /dev/null @@ -1,7 +0,0 @@ -module.exports = { - transform: { '^.+\\.ts?$': 'ts-jest' }, - testMatch: ['**/*.test.ts?(x)'], - testEnvironment: 'node', - moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], - collectCoverageFrom: ['src/**/*.ts'], -}; diff --git a/packages/shared/mocks/package.json b/packages/shared/mocks/package.json deleted file mode 100644 index 4ffdc0ba9..000000000 --- a/packages/shared/mocks/package.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "name": "@launchdarkly/private-js-mocks", - "private": true, - "version": "0.0.1", - "type": "commonjs", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "exports": { - ".": "./dist/index.js" - }, - "homepage": "https://github.com/launchdarkly/js-core/tree/main/packages/shared/common", - "repository": { - "type": "git", - "url": "https://github.com/launchdarkly/js-core.git" - }, - "description": "LaunchDarkly SDK for JavaScript - mocks", - "files": [ - "dist" - ], - "keywords": [ - "mocks", - "unit", - "tests", - "launchdarkly", - "js", - "client" - ], - "scripts": { - "test": "", - "build-types": "yarn workspace @launchdarkly/js-sdk-common build-types", - "build": "yarn build-types && npx tsc", - "clean": "npx tsc --build --clean", - "lint": "npx eslint --ext .ts", - "lint:fix": "yarn run lint -- --fix" - }, - "license": "Apache-2.0", - "devDependencies": { - "@trivago/prettier-plugin-sort-imports": "^4.2.0", - "@types/jest": "^29.5.5", - "@typescript-eslint/eslint-plugin": "^6.20.0", - "@typescript-eslint/parser": "^6.20.0", - "eslint": "^8.50.0", - "eslint-config-airbnb-base": "^15.0.0", - "eslint-config-airbnb-typescript": "^17.1.0", - "eslint-config-prettier": "^9.0.0", - "eslint-plugin-import": "^2.28.1", - "eslint-plugin-jest": "^27.6.3", - "eslint-plugin-prettier": "^5.0.0", - "jest": "^29.7.0", - "launchdarkly-js-test-helpers": "^2.2.0", - "prettier": "^3.0.3", - "ts-jest": "^29.0.5", - "typescript": "^5.2.2" - } -} diff --git a/packages/shared/mocks/src/index.ts b/packages/shared/mocks/src/index.ts deleted file mode 100644 index 0fffd6117..000000000 --- a/packages/shared/mocks/src/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -import ContextDeduplicator from './contextDeduplicator'; -import { MockEventProcessor, setupMockEventProcessor } from './eventProcessor'; -import { createLogger } from './logger'; -import { createBasicPlatform } from './platform'; -import { MockStreamingProcessor, setupMockStreamingProcessor } from './streamingProcessor'; - -export { - createLogger, - ContextDeduplicator, - MockEventProcessor, - setupMockEventProcessor, - MockStreamingProcessor, - setupMockStreamingProcessor, - createBasicPlatform, -}; diff --git a/packages/shared/mocks/src/logger.ts b/packages/shared/mocks/src/logger.ts deleted file mode 100644 index 80cb3e728..000000000 --- a/packages/shared/mocks/src/logger.ts +++ /dev/null @@ -1,6 +0,0 @@ -export const createLogger = () => ({ - error: jest.fn(), - warn: jest.fn(), - info: jest.fn(), - debug: jest.fn(), -}); diff --git a/packages/shared/mocks/tsconfig.json b/packages/shared/mocks/tsconfig.json deleted file mode 100644 index 93b5f38e5..000000000 --- a/packages/shared/mocks/tsconfig.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "compilerOptions": { - "rootDir": "src", - "outDir": "dist", - "target": "ES2017", - "lib": ["es6"], - "module": "commonjs", - "strict": true, - "noImplicitOverride": true, - // Needed for CommonJS modules: markdown-it, fs-extra - "allowSyntheticDefaultImports": true, - "sourceMap": true, - "declaration": true, - "declarationMap": true, // enables importers to jump to source - "stripInternal": true, - "paths": { - "@common": ["../common"] - } - }, - "exclude": ["**/*.test.ts", "dist", "node_modules", "__tests__"], - "references": [ - { - "path": "../common" - } - ] -} diff --git a/packages/shared/sdk-client/CHANGELOG.md b/packages/shared/sdk-client/CHANGELOG.md index a5d328190..f17f351aa 100644 --- a/packages/shared/sdk-client/CHANGELOG.md +++ b/packages/shared/sdk-client/CHANGELOG.md @@ -1,5 +1,105 @@ # Changelog +## [1.11.0](https://github.com/launchdarkly/js-core/compare/js-client-sdk-common-v1.10.0...js-client-sdk-common-v1.11.0) (2024-10-29) + + +### Features + +* Add a module for increased backward compatibility. ([#637](https://github.com/launchdarkly/js-core/issues/637)) ([44a2237](https://github.com/launchdarkly/js-core/commit/44a223730fed10fbd75e8de7c87c63570774fe96)) + +## [1.10.0](https://github.com/launchdarkly/js-core/compare/js-client-sdk-common-v1.9.0...js-client-sdk-common-v1.10.0) (2024-10-17) + + +### Features + +* Add prerequisite information to server-side allFlagsState. ([8c84e01](https://github.com/launchdarkly/js-core/commit/8c84e0149a5621c6fcb95f2cfdbd6112f3540191)) +* Add support for client-side prerequisite events. ([8c84e01](https://github.com/launchdarkly/js-core/commit/8c84e0149a5621c6fcb95f2cfdbd6112f3540191)) +* Add support for inspectors. ([#625](https://github.com/launchdarkly/js-core/issues/625)) ([a986478](https://github.com/launchdarkly/js-core/commit/a986478ed8e39d0f529ca6adec0a09b484421390)) +* Add support for prerequisite details to evaluation detail. ([8c84e01](https://github.com/launchdarkly/js-core/commit/8c84e0149a5621c6fcb95f2cfdbd6112f3540191)) +* adds ping stream support ([#624](https://github.com/launchdarkly/js-core/issues/624)) ([dee53af](https://github.com/launchdarkly/js-core/commit/dee53af9312b74a70b748d49b2d2911d65333cf3)) +* Apply private property naming standard. Mangle browser private properties. ([#620](https://github.com/launchdarkly/js-core/issues/620)) ([3e6d404](https://github.com/launchdarkly/js-core/commit/3e6d404ae665c5cc7e5a1394a59c8f2c9d5d682a)) + + +### Bug Fixes + +* Prerequisites should not trigger hooks. ([#628](https://github.com/launchdarkly/js-core/issues/628)) ([70cf3c3](https://github.com/launchdarkly/js-core/commit/70cf3c3cdc507b6df3597ea4954645bb2cc760df)) +* Update sdk-client rollup configuration to match common ([#630](https://github.com/launchdarkly/js-core/issues/630)) ([e061811](https://github.com/launchdarkly/js-core/commit/e06181158d29824ff0131a88988c84cd4a32f6c0)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-sdk-common bumped from 2.10.0 to 2.11.0 + +## [1.9.0](https://github.com/launchdarkly/js-core/compare/js-client-sdk-common-v1.8.0...js-client-sdk-common-v1.9.0) (2024-10-09) + + +### Features + +* Add basic secure mode support for browser SDK. ([#598](https://github.com/launchdarkly/js-core/issues/598)) ([3389983](https://github.com/launchdarkly/js-core/commit/33899830781affbe986f3bb9df35e5c908884f99)) +* Add bootstrap support. ([#600](https://github.com/launchdarkly/js-core/issues/600)) ([4e5dbee](https://github.com/launchdarkly/js-core/commit/4e5dbee48d6bb236b5febd872c910e809058a012)) +* Add ESM support for common and common-client (rollup) ([#604](https://github.com/launchdarkly/js-core/issues/604)) ([8cd0cdc](https://github.com/launchdarkly/js-core/commit/8cd0cdce988f606b1efdf6bfd19484f6607db2e5)) +* Add support for hooks. ([#605](https://github.com/launchdarkly/js-core/issues/605)) ([04d347b](https://github.com/launchdarkly/js-core/commit/04d347b25e01015134a2545be22bfd8b1d1e85cc)) +* Add visibility handling to allow proactive event flushing. ([#607](https://github.com/launchdarkly/js-core/issues/607)) ([819a311](https://github.com/launchdarkly/js-core/commit/819a311db6f56e323bb84c925789ad4bd19ae4ba)) +* adds datasource status to sdk-client ([#590](https://github.com/launchdarkly/js-core/issues/590)) ([6f26204](https://github.com/launchdarkly/js-core/commit/6f262045b76836e5d2f5ccc2be433094993fcdbb)) +* adds support for individual flag change listeners ([#608](https://github.com/launchdarkly/js-core/issues/608)) ([da31436](https://github.com/launchdarkly/js-core/commit/da3143654331d7d2fd8ba76d9d995855dbf6c7a1)) +* Browser-SDK Automatically start streaming based on event handlers. ([#592](https://github.com/launchdarkly/js-core/issues/592)) ([f2e5cbf](https://github.com/launchdarkly/js-core/commit/f2e5cbf1d0b3ae39a95881fecdcbefc11e9d0363)) + + +### Bug Fixes + +* Ensure client logger is always wrapped in a safe logger. ([#599](https://github.com/launchdarkly/js-core/issues/599)) ([980e4da](https://github.com/launchdarkly/js-core/commit/980e4daaf32864e18f14b1e5e28e308dff0ae94f)) +* Use flagVersion in analytics events. ([#611](https://github.com/launchdarkly/js-core/issues/611)) ([35fa033](https://github.com/launchdarkly/js-core/commit/35fa0332dc1553c82afd75c9a4770a4833f2dca3)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-sdk-common bumped from 2.9.0 to 2.10.0 + +## [1.8.0](https://github.com/launchdarkly/js-core/compare/js-client-sdk-common-v1.7.0...js-client-sdk-common-v1.8.0) (2024-09-26) + + +### Features + +* Add platform support for async hashing. ([#573](https://github.com/launchdarkly/js-core/issues/573)) ([9248035](https://github.com/launchdarkly/js-core/commit/9248035a88fba1c7375c5df22ef6b4a80a867983)) +* Add support for conditional event source capabilities. ([#577](https://github.com/launchdarkly/js-core/issues/577)) ([fe82500](https://github.com/launchdarkly/js-core/commit/fe82500f28cf8d8311502098aa6cc2e73932064e)) +* Add support for js-client-sdk style initialization. ([53f5bb8](https://github.com/launchdarkly/js-core/commit/53f5bb89754ff05405d481a959e75742fbd0d0a9)) +* Add URLs for custom events and URL filtering. ([#587](https://github.com/launchdarkly/js-core/issues/587)) ([7131e69](https://github.com/launchdarkly/js-core/commit/7131e6905f19cc10a1374aae5e74cec66c7fd6de)) +* Adds support for REPORT. ([#575](https://github.com/launchdarkly/js-core/issues/575)) ([916b724](https://github.com/launchdarkly/js-core/commit/916b72409b63abdf350e70cca41331c4204b6e95)) +* Allow using custom user-agent name. ([#580](https://github.com/launchdarkly/js-core/issues/580)) ([ed5a206](https://github.com/launchdarkly/js-core/commit/ed5a206c86f496942664dd73f6f8a7c602a1de28)) +* Implement goals for client-side SDKs. ([#585](https://github.com/launchdarkly/js-core/issues/585)) ([fd38a8f](https://github.com/launchdarkly/js-core/commit/fd38a8fa8560dad0c6721c2eaeed2f3f5c674900)) +* Refactor data source connection handling. ([53f5bb8](https://github.com/launchdarkly/js-core/commit/53f5bb89754ff05405d481a959e75742fbd0d0a9)) + + +### Bug Fixes + +* Flag store should not access values from prototype. ([#567](https://github.com/launchdarkly/js-core/issues/567)) ([fca4d92](https://github.com/launchdarkly/js-core/commit/fca4d9293746d023a0a122110849bbf335aa3b62)) +* Use flag value whenever provided even if variaiton is null or undefined. ([#581](https://github.com/launchdarkly/js-core/issues/581)) ([d11224c](https://github.com/launchdarkly/js-core/commit/d11224c64863c007f4f42f4c48683fd170dd2b32)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-sdk-common bumped from 2.8.0 to 2.9.0 + +## [1.7.0](https://github.com/launchdarkly/js-core/compare/js-client-sdk-common-v1.6.0...js-client-sdk-common-v1.7.0) (2024-09-03) + + +### Features + +* Add support for Payload Filtering ([#551](https://github.com/launchdarkly/js-core/issues/551)) ([6f44383](https://github.com/launchdarkly/js-core/commit/6f4438323baed802d8f951ac82494e6cfa9932c5)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-sdk-common bumped from 2.7.0 to 2.8.0 + ## [1.6.0](https://github.com/launchdarkly/js-core/compare/js-client-sdk-common-v1.5.0...js-client-sdk-common-v1.6.0) (2024-08-28) diff --git a/packages/shared/sdk-client/__tests__/HookRunner.test.ts b/packages/shared/sdk-client/__tests__/HookRunner.test.ts new file mode 100644 index 000000000..85d704df4 --- /dev/null +++ b/packages/shared/sdk-client/__tests__/HookRunner.test.ts @@ -0,0 +1,304 @@ +import { LDContext, LDEvaluationDetail, LDLogger } from '@launchdarkly/js-sdk-common'; + +import { Hook, IdentifySeriesResult } from '../src/api/integrations/Hooks'; +import HookRunner from '../src/HookRunner'; + +describe('given a hook runner and test hook', () => { + let logger: LDLogger; + let testHook: Hook; + let hookRunner: HookRunner; + + beforeEach(() => { + logger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }; + + testHook = { + getMetadata: jest.fn().mockReturnValue({ name: 'Test Hook' }), + beforeEvaluation: jest.fn(), + afterEvaluation: jest.fn(), + beforeIdentify: jest.fn(), + afterIdentify: jest.fn(), + }; + + hookRunner = new HookRunner(logger, [testHook]); + }); + + describe('when evaluating flags', () => { + it('should execute hooks and return the evaluation result', () => { + const key = 'test-flag'; + const context: LDContext = { kind: 'user', key: 'user-123' }; + const defaultValue = false; + const evaluationResult: LDEvaluationDetail = { + value: true, + variationIndex: 1, + reason: { kind: 'OFF' }, + }; + + const method = jest.fn().mockReturnValue(evaluationResult); + + const result = hookRunner.withEvaluation(key, context, defaultValue, method); + + expect(testHook.beforeEvaluation).toHaveBeenCalledWith( + expect.objectContaining({ + flagKey: key, + context, + defaultValue, + }), + {}, + ); + + expect(method).toHaveBeenCalled(); + + expect(testHook.afterEvaluation).toHaveBeenCalledWith( + expect.objectContaining({ + flagKey: key, + context, + defaultValue, + }), + {}, + evaluationResult, + ); + + expect(result).toEqual(evaluationResult); + }); + + it('should handle errors in hooks', () => { + const errorHook: Hook = { + getMetadata: jest.fn().mockReturnValue({ name: 'Error Hook' }), + beforeEvaluation: jest.fn().mockImplementation(() => { + throw new Error('Hook error'); + }), + afterEvaluation: jest.fn(), + }; + + const errorHookRunner = new HookRunner(logger, [errorHook]); + + const method = jest + .fn() + .mockReturnValue({ value: true, variationIndex: 1, reason: { kind: 'OFF' } }); + + errorHookRunner.withEvaluation('test-flag', { kind: 'user', key: 'user-123' }, false, method); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining( + 'An error was encountered in "beforeEvaluation" of the "Error Hook" hook: Error: Hook error', + ), + ); + }); + + it('should skip hook execution if there are no hooks', () => { + const emptyHookRunner = new HookRunner(logger, []); + const method = jest + .fn() + .mockReturnValue({ value: true, variationIndex: 1, reason: { kind: 'OFF' } }); + + emptyHookRunner.withEvaluation('test-flag', { kind: 'user', key: 'user-123' }, false, method); + + expect(method).toHaveBeenCalled(); + expect(logger.error).not.toHaveBeenCalled(); + }); + + it('should pass evaluation series data from before to after hooks', () => { + const key = 'test-flag'; + const context: LDContext = { kind: 'user', key: 'user-123' }; + const defaultValue = false; + const evaluationResult: LDEvaluationDetail = { + value: true, + variationIndex: 1, + reason: { kind: 'OFF' }, + }; + + testHook.beforeEvaluation = jest + .fn() + .mockImplementation((_, series) => ({ ...series, testData: 'before data' })); + + testHook.afterEvaluation = jest.fn(); + + const method = jest.fn().mockReturnValue(evaluationResult); + + hookRunner.withEvaluation(key, context, defaultValue, method); + + expect(testHook.beforeEvaluation).toHaveBeenCalled(); + expect(testHook.afterEvaluation).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ testData: 'before data' }), + evaluationResult, + ); + }); + }); + + describe('when handling an identification', () => { + it('should execute identify hooks', () => { + const context: LDContext = { kind: 'user', key: 'user-123' }; + const timeout = 10; + const identifyResult: IdentifySeriesResult = { status: 'completed' }; + + const identifyCallback = hookRunner.identify(context, timeout); + identifyCallback(identifyResult); + + expect(testHook.beforeIdentify).toHaveBeenCalledWith( + expect.objectContaining({ + context, + timeout, + }), + {}, + ); + + expect(testHook.afterIdentify).toHaveBeenCalledWith( + expect.objectContaining({ + context, + timeout, + }), + {}, + identifyResult, + ); + }); + + it('should handle errors in identify hooks', () => { + const errorHook: Hook = { + getMetadata: jest.fn().mockReturnValue({ name: 'Error Hook' }), + beforeIdentify: jest.fn().mockImplementation(() => { + throw new Error('Hook error'); + }), + afterIdentify: jest.fn(), + }; + + const errorHookRunner = new HookRunner(logger, [errorHook]); + + const identifyCallback = errorHookRunner.identify({ kind: 'user', key: 'user-123' }, 1000); + identifyCallback({ status: 'error' }); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining( + 'An error was encountered in "beforeEvaluation" of the "Error Hook" hook: Error: Hook error', + ), + ); + }); + + it('should pass identify series data from before to after hooks', () => { + const context: LDContext = { kind: 'user', key: 'user-123' }; + const timeout = 10; + const identifyResult: IdentifySeriesResult = { status: 'completed' }; + + testHook.beforeIdentify = jest + .fn() + .mockImplementation((_, series) => ({ ...series, testData: 'before identify data' })); + + testHook.afterIdentify = jest.fn(); + + const identifyCallback = hookRunner.identify(context, timeout); + identifyCallback(identifyResult); + + expect(testHook.beforeIdentify).toHaveBeenCalled(); + expect(testHook.afterIdentify).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ testData: 'before identify data' }), + identifyResult, + ); + }); + }); + + it('should use the added hook in future invocations', () => { + const newHook: Hook = { + getMetadata: jest.fn().mockReturnValue({ name: 'New Hook' }), + beforeEvaluation: jest.fn(), + afterEvaluation: jest.fn(), + }; + + hookRunner.addHook(newHook); + + const method = jest + .fn() + .mockReturnValue({ value: true, variationIndex: 1, reason: { kind: 'OFF' } }); + + hookRunner.withEvaluation('test-flag', { kind: 'user', key: 'user-123' }, false, method); + + expect(newHook.beforeEvaluation).toHaveBeenCalled(); + expect(newHook.afterEvaluation).toHaveBeenCalled(); + }); + + it('should log "unknown hook" when getMetadata throws an error', () => { + const errorHook: Hook = { + getMetadata: jest.fn().mockImplementation(() => { + throw new Error('Metadata error'); + }), + beforeEvaluation: jest.fn().mockImplementation(() => { + throw new Error('Test error in beforeEvaluation'); + }), + afterEvaluation: jest.fn(), + }; + + const errorHookRunner = new HookRunner(logger, [errorHook]); + + errorHookRunner.withEvaluation('test-flag', { kind: 'user', key: 'user-123' }, false, () => ({ + value: true, + variationIndex: 1, + reason: { kind: 'OFF' }, + })); + + expect(logger.error).toHaveBeenCalledWith( + 'Exception thrown getting metadata for hook. Unable to get hook name.', + ); + + // Verify that the error was logged with the correct hook name + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining( + 'An error was encountered in "beforeEvaluation" of the "unknown hook" hook: Error: Test error in beforeEvaluation', + ), + ); + }); + + it('should log "unknown hook" when getMetadata returns an empty name', () => { + const errorHook: Hook = { + getMetadata: jest.fn().mockImplementation(() => ({ + name: '', + })), + beforeEvaluation: jest.fn().mockImplementation(() => { + throw new Error('Test error in beforeEvaluation'); + }), + afterEvaluation: jest.fn(), + }; + + const errorHookRunner = new HookRunner(logger, [errorHook]); + + errorHookRunner.withEvaluation('test-flag', { kind: 'user', key: 'user-123' }, false, () => ({ + value: true, + variationIndex: 1, + reason: { kind: 'OFF' }, + })); + + // Verify that the error was logged with the correct hook name + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining( + 'An error was encountered in "beforeEvaluation" of the "unknown hook" hook: Error: Test error in beforeEvaluation', + ), + ); + }); + + it('should log the correct hook name when an error occurs', () => { + // Modify the testHook to throw an error in beforeEvaluation + testHook.beforeEvaluation = jest.fn().mockImplementation(() => { + throw new Error('Test error in beforeEvaluation'); + }); + + hookRunner.withEvaluation('test-flag', { kind: 'user', key: 'user-123' }, false, () => ({ + value: true, + variationIndex: 1, + reason: { kind: 'OFF' }, + })); + + // Verify that getMetadata was called to get the hook name + expect(testHook.getMetadata).toHaveBeenCalled(); + + // Verify that the error was logged with the correct hook name + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining( + 'An error was encountered in "beforeEvaluation" of the "Test Hook" hook: Error: Test error in beforeEvaluation', + ), + ); + }); +}); diff --git a/packages/shared/sdk-client/__tests__/LDCLientImpl.inspections.test.ts b/packages/shared/sdk-client/__tests__/LDCLientImpl.inspections.test.ts new file mode 100644 index 000000000..13b5f1b78 --- /dev/null +++ b/packages/shared/sdk-client/__tests__/LDCLientImpl.inspections.test.ts @@ -0,0 +1,193 @@ +import { AsyncQueue } from 'launchdarkly-js-test-helpers'; + +import { AutoEnvAttributes, clone } from '@launchdarkly/js-sdk-common'; + +import { LDInspection } from '../src/api/LDInspection'; +import LDClientImpl from '../src/LDClientImpl'; +import { Flags, PatchFlag } from '../src/types'; +import { createBasicPlatform } from './createBasicPlatform'; +import * as mockResponseJson from './evaluation/mockResponse.json'; +import { MockEventSource } from './streaming/LDClientImpl.mocks'; +import { makeTestDataManagerFactory } from './TestDataManager'; + +it('calls flag-used inspectors', async () => { + const flagUsedInspector: LDInspection = { + type: 'flag-used', + name: 'test flag used inspector', + method: jest.fn(), + }; + const platform = createBasicPlatform(); + const factory = makeTestDataManagerFactory('sdk-key', platform, { + disableNetwork: true, + }); + const client = new LDClientImpl( + 'sdk-key', + AutoEnvAttributes.Disabled, + platform, + { + sendEvents: false, + inspectors: [flagUsedInspector], + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, + }, + factory, + ); + + await client.identify({ key: 'user-key' }); + await client.variation('flag-key', false); + + expect(flagUsedInspector.method).toHaveBeenCalledWith( + 'flag-key', + { + value: false, + variationIndex: null, + reason: { + kind: 'ERROR', + errorKind: 'FLAG_NOT_FOUND', + }, + }, + { key: 'user-key' }, + ); +}); + +it('calls client-identity-changed inspectors', async () => { + const identifyInspector: LDInspection = { + type: 'client-identity-changed', + name: 'test client identity inspector', + method: jest.fn(), + }; + + const platform = createBasicPlatform(); + const factory = makeTestDataManagerFactory('sdk-key', platform, { + disableNetwork: true, + }); + const client = new LDClientImpl( + 'sdk-key', + AutoEnvAttributes.Disabled, + platform, + { + sendEvents: false, + inspectors: [identifyInspector], + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, + }, + factory, + ); + + await client.identify({ key: 'user-key' }); + + expect(identifyInspector.method).toHaveBeenCalledWith({ key: 'user-key' }); +}); + +it('calls flag-detail-changed inspector for individial flag changes on patch', async () => { + const eventQueue = new AsyncQueue(); + const flagDetailChangedInspector: LDInspection = { + type: 'flag-detail-changed', + name: 'test flag detail changed inspector', + method: jest.fn(() => eventQueue.add({})), + }; + const platform = createBasicPlatform(); + const factory = makeTestDataManagerFactory('sdk-key', platform); + const client = new LDClientImpl( + 'sdk-key', + AutoEnvAttributes.Disabled, + platform, + { + sendEvents: false, + inspectors: [flagDetailChangedInspector], + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, + }, + factory, + ); + let mockEventSource: MockEventSource; + + const putResponse = clone(mockResponseJson); + const putEvents = [{ data: JSON.stringify(putResponse) }]; + platform.requests.createEventSource.mockImplementation( + (streamUri: string = '', options: any = {}) => { + mockEventSource = new MockEventSource(streamUri, options); + const patchResponse = clone(putResponse['dev-test-flag']); + patchResponse.key = 'dev-test-flag'; + patchResponse.value = false; + patchResponse.version += 1; + const patchEvents = [{ data: JSON.stringify(patchResponse) }]; + + // @ts-ignore + mockEventSource.simulateEvents('patch', patchEvents); + mockEventSource.simulateEvents('put', putEvents); + return mockEventSource; + }, + ); + + await client.identify({ key: 'user-key' }, { waitForNetworkResults: true }); + + await eventQueue.take(); + expect(flagDetailChangedInspector.method).toHaveBeenCalledWith('dev-test-flag', { + reason: null, + value: false, + variationIndex: 0, + }); +}); + +it('calls flag-details-changed inspectors when all flag values change', async () => { + const flagDetailsChangedInspector: LDInspection = { + type: 'flag-details-changed', + name: 'test flag details changed inspector', + method: jest.fn(), + }; + const platform = createBasicPlatform(); + const factory = makeTestDataManagerFactory('sdk-key', platform); + const client = new LDClientImpl( + 'sdk-key', + AutoEnvAttributes.Disabled, + platform, + { + sendEvents: false, + inspectors: [flagDetailsChangedInspector], + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, + }, + factory, + ); + let mockEventSource: MockEventSource; + + platform.requests.createEventSource.mockImplementation( + (streamUri: string = '', options: any = {}) => { + mockEventSource = new MockEventSource(streamUri, options); + const simulatedEvents = [{ data: JSON.stringify(mockResponseJson) }]; + mockEventSource.simulateEvents('put', simulatedEvents); + return mockEventSource; + }, + ); + + await client.identify({ key: 'user-key' }, { waitForNetworkResults: true }); + expect(flagDetailsChangedInspector.method).toHaveBeenCalledWith({ + 'dev-test-flag': { reason: null, value: true, variationIndex: 0 }, + 'easter-i-tunes-special': { reason: null, value: false, variationIndex: 1 }, + 'easter-specials': { reason: null, value: 'no specials', variationIndex: 3 }, + fdsafdsafdsafdsa: { reason: null, value: true, variationIndex: 0 }, + 'log-level': { reason: null, value: 'warn', variationIndex: 3 }, + 'moonshot-demo': { reason: null, value: true, variationIndex: 0 }, + test1: { reason: null, value: 's1', variationIndex: 0 }, + 'this-is-a-test': { reason: null, value: true, variationIndex: 0 }, + 'has-prereq-depth-1': { reason: { kind: 'FALLTHROUGH' }, value: true, variationIndex: 0 }, + 'is-prereq': { reason: { kind: 'FALLTHROUGH' }, value: true, variationIndex: 0 }, + }); +}); diff --git a/packages/shared/sdk-client/__tests__/LDClientImpl.changeemitter.test.ts b/packages/shared/sdk-client/__tests__/LDClientImpl.changeemitter.test.ts new file mode 100644 index 000000000..3484aba0f --- /dev/null +++ b/packages/shared/sdk-client/__tests__/LDClientImpl.changeemitter.test.ts @@ -0,0 +1,176 @@ +import { AutoEnvAttributes, clone, type LDContext, LDLogger } from '@launchdarkly/js-sdk-common'; + +import LDClientImpl from '../src/LDClientImpl'; +import LDEmitter from '../src/LDEmitter'; +import { Flags, PatchFlag } from '../src/types'; +import { createBasicPlatform } from './createBasicPlatform'; +import * as mockResponseJson from './evaluation/mockResponse.json'; +import { MockEventSource } from './streaming/LDClientImpl.mocks'; +import { makeTestDataManagerFactory } from './TestDataManager'; + +let mockPlatform: ReturnType; +let logger: LDLogger; + +beforeEach(() => { + mockPlatform = createBasicPlatform(); + logger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }; +}); + +const testSdkKey = 'test-sdk-key'; +const context: LDContext = { kind: 'org', key: 'Testy Pizza' }; +const flagStorageKey = 'LaunchDarkly_1234567890123456_1234567890123456'; +const indexStorageKey = 'LaunchDarkly_1234567890123456_ContextIndex'; +let ldc: LDClientImpl; +let mockEventSource: MockEventSource; +let emitter: LDEmitter; +let defaultPutResponse: Flags; +let defaultFlagKeys: string[]; + +// Promisify on.change listener so we can await it in tests. +const onChangePromise = () => + new Promise((res) => { + ldc.on('change', (_context: LDContext, changes: string[]) => { + res(changes); + }); + }); + +describe('sdk-client change emitter', () => { + beforeEach(() => { + jest.useFakeTimers(); + defaultPutResponse = clone(mockResponseJson); + defaultFlagKeys = Object.keys(defaultPutResponse); + + (mockPlatform.storage.get as jest.Mock).mockImplementation((storageKey: string) => { + switch (storageKey) { + case flagStorageKey: + return JSON.stringify(defaultPutResponse); + case indexStorageKey: + return undefined; + default: + return undefined; + } + }); + + ldc = new LDClientImpl( + testSdkKey, + AutoEnvAttributes.Disabled, + mockPlatform, + { + logger, + sendEvents: false, + }, + makeTestDataManagerFactory(testSdkKey, mockPlatform), + ); + + // @ts-ignore + emitter = ldc.emitter; + jest.spyOn(emitter as LDEmitter, 'emit'); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + test('initialize from storage emits flags as changed', async () => { + mockPlatform.requests.createEventSource.mockImplementation( + (streamUri: string = '', options: any = {}) => { + mockEventSource = new MockEventSource(streamUri, options); + mockEventSource.simulateError({ status: 404, message: 'error-to-force-cache' }); + return mockEventSource; + }, + ); + + const changePromise = onChangePromise(); + await ldc.identify(context); + await changePromise; + + expect(mockPlatform.storage.get).toHaveBeenCalledWith(flagStorageKey); + + expect(emitter.emit).toHaveBeenCalledWith('change', context, defaultFlagKeys); + + // a few specific flag changes to verify those are also called + expect(emitter.emit).toHaveBeenCalledWith('change:moonshot-demo', context); + expect(emitter.emit).toHaveBeenCalledWith('change:dev-test-flag', context); + expect(emitter.emit).toHaveBeenCalledWith('change:this-is-a-test', context); + }); + + test('put should emit changed flags', async () => { + const putResponse = clone(defaultPutResponse); + putResponse['dev-test-flag'].version = 999; + putResponse['dev-test-flag'].value = false; + + const simulatedEvents = [{ data: JSON.stringify(putResponse) }]; + mockPlatform.requests.createEventSource.mockImplementation( + (streamUri: string = '', options: any = {}) => { + mockEventSource = new MockEventSource(streamUri, options); + mockEventSource.simulateEvents('put', simulatedEvents); + return mockEventSource; + }, + ); + + const changePromise = onChangePromise(); + await ldc.identify(context); + await changePromise; + await jest.runAllTimersAsync(); + + expect(emitter.emit).toHaveBeenCalledWith('change', context, ['dev-test-flag']); + expect(emitter.emit).toHaveBeenCalledWith('change:dev-test-flag', context); + }); + + test('patch should emit changed flags', async () => { + const patchResponse = clone(defaultPutResponse['dev-test-flag']); + patchResponse.key = 'dev-test-flag'; + patchResponse.value = false; + patchResponse.version += 1; + + const putEvents = [{ data: JSON.stringify(defaultPutResponse) }]; + const patchEvents = [{ data: JSON.stringify(patchResponse) }]; + mockPlatform.requests.createEventSource.mockImplementation( + (streamUri: string = '', options: any = {}) => { + mockEventSource = new MockEventSource(streamUri, options); + mockEventSource.simulateEvents('put', putEvents); + mockEventSource.simulateEvents('patch', patchEvents); + return mockEventSource; + }, + ); + + const changePromise = onChangePromise(); + await ldc.identify(context); + await changePromise; + await jest.runAllTimersAsync(); + + expect(emitter.emit).toHaveBeenCalledWith('change', context, ['dev-test-flag']); + expect(emitter.emit).toHaveBeenCalledWith('change:dev-test-flag', context); + }); + + test('delete should emit changed flags', async () => { + const deleteResponse = { + key: 'dev-test-flag', + version: defaultPutResponse['dev-test-flag'].version + 1, + }; + + const putEvents = [{ data: JSON.stringify(defaultPutResponse) }]; + const deleteEvents = [{ data: JSON.stringify(deleteResponse) }]; + mockPlatform.requests.createEventSource.mockImplementation( + (streamUri: string = '', options: any = {}) => { + mockEventSource = new MockEventSource(streamUri, options); + mockEventSource.simulateEvents('put', putEvents); + mockEventSource.simulateEvents('delete', deleteEvents); + return mockEventSource; + }, + ); + + const changePromise = onChangePromise(); + await ldc.identify(context); + await changePromise; + await jest.runAllTimersAsync(); + + expect(emitter.emit).toHaveBeenCalledWith('change', context, ['dev-test-flag']); + expect(emitter.emit).toHaveBeenCalledWith('change:dev-test-flag', context); + }); +}); diff --git a/packages/shared/sdk-client/src/LDClientImpl.events.test.ts b/packages/shared/sdk-client/__tests__/LDClientImpl.events.test.ts similarity index 63% rename from packages/shared/sdk-client/src/LDClientImpl.events.test.ts rename to packages/shared/sdk-client/__tests__/LDClientImpl.events.test.ts index b62971b3b..3d18f013c 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.events.test.ts +++ b/packages/shared/sdk-client/__tests__/LDClientImpl.events.test.ts @@ -4,39 +4,42 @@ import { clone, internal, LDContext, + LDLogger, subsystem, } from '@launchdarkly/js-sdk-common'; -import { - createBasicPlatform, - createLogger, - MockEventProcessor, - setupMockStreamingProcessor, -} from '@launchdarkly/private-js-mocks'; +import LDClientImpl from '../src/LDClientImpl'; +import { Flags } from '../src/types'; +import { createBasicPlatform } from './createBasicPlatform'; import * as mockResponseJson from './evaluation/mockResponse.json'; -import LDClientImpl from './LDClientImpl'; -import { Flags } from './types'; +import { MockEventProcessor } from './eventProcessor'; +import { MockEventSource } from './streaming/LDClientImpl.mocks'; +import { makeTestDataManagerFactory } from './TestDataManager'; type InputCustomEvent = internal.InputCustomEvent; type InputIdentifyEvent = internal.InputIdentifyEvent; let mockPlatform: ReturnType; -let logger: ReturnType; +let logger: LDLogger; beforeEach(() => { mockPlatform = createBasicPlatform(); - logger = createLogger(); + logger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }; }); jest.mock('@launchdarkly/js-sdk-common', () => { const actual = jest.requireActual('@launchdarkly/js-sdk-common'); - const m = jest.requireActual('@launchdarkly/private-js-mocks'); + const m = jest.requireActual('./eventProcessor'); return { ...actual, ...{ internal: { ...actual.internal, - StreamingProcessor: m.MockStreamingProcessor, EventProcessor: m.MockEventProcessor, }, }, @@ -45,6 +48,7 @@ jest.mock('@launchdarkly/js-sdk-common', () => { const testSdkKey = 'test-sdk-key'; let ldc: LDClientImpl; +let mockEventSource: MockEventSource; let defaultPutResponse: Flags; const carContext: LDContext = { kind: 'car', key: 'test-car' }; @@ -66,15 +70,28 @@ describe('sdk-client object', () => { sendEvent: mockedSendEvent, }), ); - setupMockStreamingProcessor(false, defaultPutResponse); + + const simulatedEvents = [{ data: JSON.stringify(defaultPutResponse) }]; + mockPlatform.storage.get.mockImplementation(() => undefined); + mockPlatform.requests.createEventSource.mockImplementation( + (streamUri: string = '', options: any = {}) => { + mockEventSource = new MockEventSource(streamUri, options); + mockEventSource.simulateEvents('put', simulatedEvents); + return mockEventSource; + }, + ); + mockPlatform.crypto.randomUUID.mockReturnValue('random1'); - ldc = new LDClientImpl(testSdkKey, AutoEnvAttributes.Enabled, mockPlatform, { - logger, - }); - jest - .spyOn(LDClientImpl.prototype as any, 'createStreamUriPath') - .mockReturnValue('/stream/path'); + ldc = new LDClientImpl( + testSdkKey, + AutoEnvAttributes.Enabled, + mockPlatform, + { + logger, + }, + makeTestDataManagerFactory(testSdkKey, mockPlatform), + ); }); afterEach(() => { @@ -92,7 +109,7 @@ describe('sdk-client object', () => { expect.objectContaining({ kind: 'identify', context: expect.objectContaining({ - contexts: expect.objectContaining({ + _contexts: expect.objectContaining({ car: { key: 'test-car' }, }), }), @@ -113,7 +130,7 @@ describe('sdk-client object', () => { kind: 'custom', key: 'the-event', context: expect.objectContaining({ - contexts: expect.objectContaining({ + _contexts: expect.objectContaining({ car: { key: 'test-car' }, }), }), @@ -136,7 +153,7 @@ describe('sdk-client object', () => { kind: 'custom', key: 'the-event', context: expect.objectContaining({ - contexts: expect.objectContaining({ + _contexts: expect.objectContaining({ car: { key: 'test-car' }, }), }), @@ -159,7 +176,7 @@ describe('sdk-client object', () => { kind: 'custom', key: 'the-event', context: expect.objectContaining({ - contexts: expect.objectContaining({ + _contexts: expect.objectContaining({ car: { key: 'test-car' }, }), }), @@ -182,4 +199,45 @@ describe('sdk-client object', () => { expect.stringMatching(/was called with a non-numeric/), ); }); + + it('sends events for prerequisite flags', async () => { + await ldc.identify({ kind: 'user', key: 'bob' }); + ldc.variation('has-prereq-depth-1', false); + ldc.flush(); + + // Prerequisite evaluation event should be emitted before the evaluation event for the flag + // being evaluated. + expect(mockedSendEvent).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + context: expect.anything(), + creationDate: expect.any(Number), + default: undefined, + key: 'is-prereq', + kind: 'feature', + samplingRatio: 1, + trackEvents: true, + value: true, + variation: 0, + version: 1, + withReasons: false, + }), + ); + expect(mockedSendEvent).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + context: expect.anything(), + creationDate: expect.any(Number), + default: false, + key: 'has-prereq-depth-1', + kind: 'feature', + samplingRatio: 1, + trackEvents: true, + value: true, + variation: 0, + version: 4, + withReasons: false, + }), + ); + }); }); diff --git a/packages/shared/sdk-client/__tests__/LDClientImpl.hooks.test.ts b/packages/shared/sdk-client/__tests__/LDClientImpl.hooks.test.ts new file mode 100644 index 000000000..9d9c6172f --- /dev/null +++ b/packages/shared/sdk-client/__tests__/LDClientImpl.hooks.test.ts @@ -0,0 +1,310 @@ +import { AutoEnvAttributes } from '@launchdarkly/js-sdk-common'; + +import { Hook, HookMetadata } from '../src/api'; +import LDClientImpl from '../src/LDClientImpl'; +import { createBasicPlatform } from './createBasicPlatform'; +import * as mockResponseJson from './evaluation/mockResponse.json'; +import { MockEventSource } from './streaming/LDClientImpl.mocks'; +import { makeTestDataManagerFactory } from './TestDataManager'; + +it('should use hooks registered during configuration', async () => { + const testHook: Hook = { + beforeEvaluation: jest.fn(), + afterEvaluation: jest.fn(), + beforeIdentify: jest.fn(), + afterIdentify: jest.fn(), + getMetadata(): HookMetadata { + return { + name: 'test hook', + }; + }, + }; + + const platform = createBasicPlatform(); + const factory = makeTestDataManagerFactory('sdk-key', platform, { + disableNetwork: true, + }); + const client = new LDClientImpl( + 'sdk-key', + AutoEnvAttributes.Disabled, + platform, + { + sendEvents: false, + hooks: [testHook], + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, + }, + factory, + ); + + await client.identify({ key: 'user-key' }); + await client.variation('flag-key', false); + + expect(testHook.beforeIdentify).toHaveBeenCalledWith( + { context: { key: 'user-key' }, timeout: undefined }, + {}, + ); + expect(testHook.afterIdentify).toHaveBeenCalledWith( + { context: { key: 'user-key' }, timeout: undefined }, + {}, + { status: 'completed' }, + ); + expect(testHook.beforeEvaluation).toHaveBeenCalledWith( + { context: { key: 'user-key' }, defaultValue: false, flagKey: 'flag-key' }, + {}, + ); + expect(testHook.afterEvaluation).toHaveBeenCalledWith( + { context: { key: 'user-key' }, defaultValue: false, flagKey: 'flag-key' }, + {}, + { + reason: { + errorKind: 'FLAG_NOT_FOUND', + kind: 'ERROR', + }, + value: false, + variationIndex: null, + }, + ); +}); + +it('should execute hooks that are added using addHook', async () => { + const platform = createBasicPlatform(); + const factory = makeTestDataManagerFactory('sdk-key', platform, { + disableNetwork: true, + }); + const client = new LDClientImpl( + 'sdk-key', + AutoEnvAttributes.Disabled, + platform, + { + sendEvents: false, + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, + }, + factory, + ); + + const addedHook: Hook = { + beforeEvaluation: jest.fn(), + afterEvaluation: jest.fn(), + beforeIdentify: jest.fn(), + afterIdentify: jest.fn(), + getMetadata(): HookMetadata { + return { + name: 'added hook', + }; + }, + }; + + client.addHook(addedHook); + + await client.identify({ key: 'user-key' }); + await client.variation('flag-key', false); + + expect(addedHook.beforeIdentify).toHaveBeenCalledWith( + { context: { key: 'user-key' }, timeout: undefined }, + {}, + ); + expect(addedHook.afterIdentify).toHaveBeenCalledWith( + { context: { key: 'user-key' }, timeout: undefined }, + {}, + { status: 'completed' }, + ); + expect(addedHook.beforeEvaluation).toHaveBeenCalledWith( + { context: { key: 'user-key' }, defaultValue: false, flagKey: 'flag-key' }, + {}, + ); + expect(addedHook.afterEvaluation).toHaveBeenCalledWith( + { context: { key: 'user-key' }, defaultValue: false, flagKey: 'flag-key' }, + {}, + { + reason: { + errorKind: 'FLAG_NOT_FOUND', + kind: 'ERROR', + }, + value: false, + variationIndex: null, + }, + ); +}); + +it('should execute both initial hooks and hooks added using addHook', async () => { + const initialHook: Hook = { + beforeEvaluation: jest.fn(), + afterEvaluation: jest.fn(), + beforeIdentify: jest.fn(), + afterIdentify: jest.fn(), + getMetadata(): HookMetadata { + return { + name: 'initial hook', + }; + }, + }; + + const platform = createBasicPlatform(); + const factory = makeTestDataManagerFactory('sdk-key', platform, { + disableNetwork: true, + }); + const client = new LDClientImpl( + 'sdk-key', + AutoEnvAttributes.Disabled, + platform, + { + sendEvents: false, + hooks: [initialHook], + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, + }, + factory, + ); + + const addedHook: Hook = { + beforeEvaluation: jest.fn(), + afterEvaluation: jest.fn(), + beforeIdentify: jest.fn(), + afterIdentify: jest.fn(), + getMetadata(): HookMetadata { + return { + name: 'added hook', + }; + }, + }; + + client.addHook(addedHook); + + await client.identify({ key: 'user-key' }); + await client.variation('flag-key', false); + + // Check initial hook + expect(initialHook.beforeIdentify).toHaveBeenCalledWith( + { context: { key: 'user-key' }, timeout: undefined }, + {}, + ); + expect(initialHook.afterIdentify).toHaveBeenCalledWith( + { context: { key: 'user-key' }, timeout: undefined }, + {}, + { status: 'completed' }, + ); + expect(initialHook.beforeEvaluation).toHaveBeenCalledWith( + { context: { key: 'user-key' }, defaultValue: false, flagKey: 'flag-key' }, + {}, + ); + expect(initialHook.afterEvaluation).toHaveBeenCalledWith( + { context: { key: 'user-key' }, defaultValue: false, flagKey: 'flag-key' }, + {}, + { + reason: { + errorKind: 'FLAG_NOT_FOUND', + kind: 'ERROR', + }, + value: false, + variationIndex: null, + }, + ); + + // Check added hook + expect(addedHook.beforeIdentify).toHaveBeenCalledWith( + { context: { key: 'user-key' }, timeout: undefined }, + {}, + ); + expect(addedHook.afterIdentify).toHaveBeenCalledWith( + { context: { key: 'user-key' }, timeout: undefined }, + {}, + { status: 'completed' }, + ); + expect(addedHook.beforeEvaluation).toHaveBeenCalledWith( + { context: { key: 'user-key' }, defaultValue: false, flagKey: 'flag-key' }, + {}, + ); + expect(addedHook.afterEvaluation).toHaveBeenCalledWith( + { context: { key: 'user-key' }, defaultValue: false, flagKey: 'flag-key' }, + {}, + { + reason: { + errorKind: 'FLAG_NOT_FOUND', + kind: 'ERROR', + }, + value: false, + variationIndex: null, + }, + ); +}); + +it('should not execute hooks for prerequisite evaluations', async () => { + const testHook: Hook = { + beforeEvaluation: jest.fn(), + afterEvaluation: jest.fn(), + beforeIdentify: jest.fn(), + afterIdentify: jest.fn(), + getMetadata(): HookMetadata { + return { + name: 'test hook', + }; + }, + }; + + const platform = createBasicPlatform(); + let mockEventSource: MockEventSource; + const simulatedEvents = [{ data: JSON.stringify(mockResponseJson) }]; + platform.requests.createEventSource.mockImplementation( + (streamUri: string = '', options: any = {}) => { + mockEventSource = new MockEventSource(streamUri, options); + mockEventSource.simulateEvents('put', simulatedEvents); + return mockEventSource; + }, + ); + + const factory = makeTestDataManagerFactory('sdk-key', platform); + const client = new LDClientImpl( + 'sdk-key', + AutoEnvAttributes.Disabled, + platform, + { + sendEvents: false, + hooks: [testHook], + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, + }, + factory, + ); + + await client.identify({ key: 'user-key' }); + await client.variation('has-prereq-depth-1', false); + + expect(testHook.beforeEvaluation).toHaveBeenCalledTimes(1); + + expect(testHook.beforeEvaluation).toHaveBeenCalledWith( + { context: { key: 'user-key' }, defaultValue: false, flagKey: 'has-prereq-depth-1' }, + {}, + ); + + expect(testHook.afterEvaluation).toHaveBeenCalledTimes(1); + + expect(testHook.afterEvaluation).toHaveBeenCalledWith( + { context: { key: 'user-key' }, defaultValue: false, flagKey: 'has-prereq-depth-1' }, + {}, + { + reason: { + kind: 'FALLTHROUGH', + }, + value: true, + variationIndex: 0, + }, + ); +}); diff --git a/packages/shared/sdk-client/__tests__/LDClientImpl.storage.test.ts b/packages/shared/sdk-client/__tests__/LDClientImpl.storage.test.ts new file mode 100644 index 000000000..e46d06a53 --- /dev/null +++ b/packages/shared/sdk-client/__tests__/LDClientImpl.storage.test.ts @@ -0,0 +1,654 @@ +import { AutoEnvAttributes, clone, type LDContext, LDLogger } from '@launchdarkly/js-sdk-common'; + +import { toMulti } from '../src/context/addAutoEnv'; +import LDClientImpl from '../src/LDClientImpl'; +import LDEmitter from '../src/LDEmitter'; +import { Flags, PatchFlag } from '../src/types'; +import { createBasicPlatform } from './createBasicPlatform'; +import * as mockResponseJson from './evaluation/mockResponse.json'; +import { MockEventSource } from './streaming/LDClientImpl.mocks'; +import { makeTestDataManagerFactory } from './TestDataManager'; + +let mockPlatform: ReturnType; +let logger: LDLogger; + +beforeEach(() => { + mockPlatform = createBasicPlatform(); + logger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }; +}); + +const testSdkKey = 'test-sdk-key'; +const context: LDContext = { kind: 'org', key: 'Testy Pizza' }; +const flagStorageKey = 'LaunchDarkly_1234567890123456_1234567890123456'; +const indexStorageKey = 'LaunchDarkly_1234567890123456_ContextIndex'; +let ldc: LDClientImpl; +let mockEventSource: MockEventSource; +let emitter: LDEmitter; +let defaultPutResponse: Flags; +let defaultFlagKeys: string[]; + +// Promisify on.change listener so we can await it in tests. +const onChangePromise = () => + new Promise((res) => { + ldc.on('change', (_context: LDContext, changes: string[]) => { + res(changes); + }); + }); + +describe('sdk-client storage', () => { + beforeEach(() => { + jest.useFakeTimers(); + defaultPutResponse = clone(mockResponseJson); + defaultFlagKeys = Object.keys(defaultPutResponse); + + (mockPlatform.storage.get as jest.Mock).mockImplementation((storageKey: string) => { + switch (storageKey) { + case flagStorageKey: + return JSON.stringify(defaultPutResponse); + case indexStorageKey: + return undefined; + default: + return undefined; + } + }); + + ldc = new LDClientImpl( + testSdkKey, + AutoEnvAttributes.Disabled, + mockPlatform, + { + logger, + sendEvents: false, + }, + makeTestDataManagerFactory(testSdkKey, mockPlatform), + ); + + // @ts-ignore + emitter = ldc.emitter; + jest.spyOn(emitter as LDEmitter, 'emit'); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + test('initialize from storage succeeds without streaming', async () => { + mockPlatform.requests.createEventSource.mockImplementation( + (streamUri: string = '', options: any = {}) => { + mockEventSource = new MockEventSource(streamUri, options); + mockEventSource.simulateError({ status: 404, message: 'test-error' }); + return mockEventSource; + }, + ); + + const changePromise = onChangePromise(); + await ldc.identify(context); + await changePromise; + + expect(mockPlatform.storage.get).toHaveBeenCalledWith(flagStorageKey); + + expect(emitter.emit).toHaveBeenCalledWith('change', context, defaultFlagKeys); + expect(emitter.emit).toHaveBeenCalledWith( + 'error', + context, + expect.objectContaining({ message: 'test-error' }), + ); + + expect(ldc.allFlags()).toEqual({ + 'dev-test-flag': true, + 'easter-i-tunes-special': false, + 'easter-specials': 'no specials', + fdsafdsafdsafdsa: true, + 'has-prereq-depth-1': true, + 'is-prereq': true, + 'log-level': 'warn', + 'moonshot-demo': true, + test1: 's1', + 'this-is-a-test': true, + }); + }); + + test('initialize from storage succeeds with auto env', async () => { + mockPlatform.requests.createEventSource.mockImplementation( + (streamUri: string = '', options: any = {}) => { + mockEventSource = new MockEventSource(streamUri, options); + mockEventSource.simulateError({ status: 404, message: 'test-error' }); + return mockEventSource; + }, + ); + + ldc = new LDClientImpl( + testSdkKey, + AutoEnvAttributes.Enabled, + mockPlatform, + { + logger, + sendEvents: false, + }, + makeTestDataManagerFactory(testSdkKey, mockPlatform), + ); + // @ts-ignore + emitter = ldc.emitter; + jest.spyOn(emitter as LDEmitter, 'emit'); + + await ldc.identify(context); + await jest.runAllTimersAsync(); + + expect(mockPlatform.storage.get).toHaveBeenLastCalledWith( + expect.stringMatching('LaunchDarkly_1234567890123456_1234567890123456'), + ); + + expect(emitter.emit).toHaveBeenCalledWith( + 'change', + expect.objectContaining(toMulti(context)), + defaultFlagKeys, + ); + expect(emitter.emit).toHaveBeenCalledWith( + 'error', + expect.objectContaining(toMulti(context)), + expect.objectContaining({ message: 'test-error' }), + ); + expect(ldc.allFlags()).toEqual({ + 'dev-test-flag': true, + 'easter-i-tunes-special': false, + 'easter-specials': 'no specials', + fdsafdsafdsafdsa: true, + 'has-prereq-depth-1': true, + 'is-prereq': true, + 'log-level': 'warn', + 'moonshot-demo': true, + test1: 's1', + 'this-is-a-test': true, + }); + }); + + test('not emitting change event when changed keys is empty', async () => { + mockPlatform.requests.createEventSource.mockImplementation( + (streamUri: string = '', options: any = {}) => { + mockEventSource = new MockEventSource(streamUri, options); + return mockEventSource; + }, + ); + + // @ts-ignore + emitter = ldc.emitter; + const spy = jest.spyOn(emitter as LDEmitter, 'emit'); + + // expect emission + await ldc.identify(context); + expect(emitter.emit).toHaveBeenCalledWith('change', context, defaultFlagKeys); + + // clear the spy so we can tell if change was invoked again + spy.mockClear(); + // expect no emission + await ldc.identify(context); + expect(emitter.emit).not.toHaveBeenCalledWith('change', context, defaultFlagKeys); + }); + + test('no storage, cold start from streaming', async () => { + const simulatedEvents = [{ data: JSON.stringify(defaultPutResponse) }]; + mockPlatform.storage.get.mockImplementation(() => undefined); + mockPlatform.requests.createEventSource.mockImplementation( + (streamUri: string = '', options: any = {}) => { + mockEventSource = new MockEventSource(streamUri, options); + mockEventSource.simulateEvents('put', simulatedEvents); + return mockEventSource; + }, + ); + + await ldc.identify(context); + await jest.runAllTimersAsync(); + + expect(mockPlatform.storage.set).toHaveBeenNthCalledWith( + 1, + indexStorageKey, + expect.stringContaining('index'), + ); + + expect(mockPlatform.storage.set).toHaveBeenNthCalledWith( + 2, + flagStorageKey, + JSON.stringify(defaultPutResponse), + ); + + // this is defaultPutResponse + expect(ldc.allFlags()).toEqual({ + 'dev-test-flag': true, + 'easter-i-tunes-special': false, + 'easter-specials': 'no specials', + fdsafdsafdsafdsa: true, + 'has-prereq-depth-1': true, + 'is-prereq': true, + 'log-level': 'warn', + 'moonshot-demo': true, + test1: 's1', + 'this-is-a-test': true, + }); + }); + + test('syncing storage when a flag is deleted', async () => { + const putResponse = clone(defaultPutResponse); + delete putResponse['dev-test-flag']; + + const simulatedEvents = [{ data: JSON.stringify(putResponse) }]; + mockPlatform.requests.createEventSource.mockImplementation( + (streamUri: string = '', options: any = {}) => { + mockEventSource = new MockEventSource(streamUri, options); + mockEventSource.simulateEvents('put', simulatedEvents); + return mockEventSource; + }, + ); + + const changePromise = onChangePromise(); + await ldc.identify(context); + await changePromise; + await jest.runAllTimersAsync(); + + expect(ldc.allFlags()).not.toHaveProperty('dev-test-flag'); + expect(mockPlatform.storage.set).toHaveBeenCalledTimes(2); + expect(mockPlatform.storage.set).toHaveBeenNthCalledWith( + 1, + indexStorageKey, + expect.stringContaining('index'), + ); + expect(mockPlatform.storage.set).toHaveBeenNthCalledWith( + 2, + flagStorageKey, + JSON.stringify(putResponse), + ); + + expect(emitter.emit).toHaveBeenCalledWith('change', context, defaultFlagKeys); + expect(emitter.emit).toHaveBeenCalledWith('change', context, ['dev-test-flag']); + }); + + test('syncing storage when a flag is added', async () => { + const putResponse = clone(defaultPutResponse); + putResponse['another-dev-test-flag'] = { + version: 1, + flagVersion: 2, + value: false, + variation: 1, + trackEvents: false, + }; + + const simulatedEvents = [{ data: JSON.stringify(putResponse) }]; + mockPlatform.requests.createEventSource.mockImplementation( + (streamUri: string = '', options: any = {}) => { + mockEventSource = new MockEventSource(streamUri, options); + mockEventSource.simulateEvents('put', simulatedEvents); + return mockEventSource; + }, + ); + + const changePromise = onChangePromise(); + await ldc.identify(context); + await changePromise; + await jest.runAllTimersAsync(); + + expect(ldc.allFlags()).toMatchObject({ 'another-dev-test-flag': false }); + expect(mockPlatform.storage.set).toHaveBeenCalledTimes(2); + expect(mockPlatform.storage.set).toHaveBeenNthCalledWith( + 1, + indexStorageKey, + expect.stringContaining('index'), + ); + expect(mockPlatform.storage.set).toHaveBeenNthCalledWith( + 2, + flagStorageKey, + JSON.stringify(putResponse), + ); + expect(emitter.emit).toHaveBeenCalledWith('change', context, ['another-dev-test-flag']); + }); + + test('syncing storage when a flag is updated', async () => { + const putResponse = clone(defaultPutResponse); + putResponse['dev-test-flag'].version = 999; + putResponse['dev-test-flag'].value = false; + + const simulatedEvents = [{ data: JSON.stringify(putResponse) }]; + mockPlatform.requests.createEventSource.mockImplementation( + (streamUri: string = '', options: any = {}) => { + mockEventSource = new MockEventSource(streamUri, options); + mockEventSource.simulateEvents('put', simulatedEvents); + return mockEventSource; + }, + ); + + const changePromise = onChangePromise(); + await ldc.identify(context); + await changePromise; + await jest.runAllTimersAsync(); + + expect(ldc.allFlags()).toMatchObject({ 'dev-test-flag': false }); + expect(emitter.emit).toHaveBeenCalledWith('change', context, ['dev-test-flag']); + }); + + test('syncing storage on multiple flag operations', async () => { + const putResponse = clone(defaultPutResponse); + const newFlag = clone(putResponse['dev-test-flag']); + + // flag updated, added and deleted + putResponse['dev-test-flag'].value = false; + putResponse['another-dev-test-flag'] = newFlag; + delete putResponse['moonshot-demo']; + + const simulatedEvents = [{ data: JSON.stringify(putResponse) }]; + mockPlatform.requests.createEventSource.mockImplementation( + (streamUri: string = '', options: any = {}) => { + mockEventSource = new MockEventSource(streamUri, options); + mockEventSource.simulateEvents('put', simulatedEvents); + return mockEventSource; + }, + ); + + const changePromise = onChangePromise(); + await ldc.identify(context); + await changePromise; + await jest.runAllTimersAsync(); + + expect(ldc.allFlags()).toMatchObject({ 'dev-test-flag': false, 'another-dev-test-flag': true }); + expect(ldc.allFlags()).not.toHaveProperty('moonshot-demo'); + expect(emitter.emit).toHaveBeenCalledWith('change', context, [ + 'moonshot-demo', + 'dev-test-flag', + 'another-dev-test-flag', + ]); + }); + + test('syncing storage when PUT is consistent so no change', async () => { + const simulatedEvents = [{ data: JSON.stringify(defaultPutResponse) }]; + mockPlatform.requests.createEventSource.mockImplementation( + (streamUri: string = '', options: any = {}) => { + mockEventSource = new MockEventSource(streamUri, options); + mockEventSource.simulateEvents('put', simulatedEvents); + return mockEventSource; + }, + ); + + await ldc.identify(context); + await jest.runAllTimersAsync(); + + expect(mockPlatform.storage.set).toHaveBeenCalledTimes(2); + expect(mockPlatform.storage.set).toHaveBeenNthCalledWith( + 1, + indexStorageKey, + expect.stringContaining('index'), + ); + expect(mockPlatform.storage.set).toHaveBeenNthCalledWith( + 2, + flagStorageKey, + JSON.stringify(defaultPutResponse), + ); + + // we expect one change from the local storage init, but no further change from the PUT + expect(emitter.emit).toHaveBeenCalledWith('change', context, defaultFlagKeys); + + // this is defaultPutResponse + expect(ldc.allFlags()).toEqual({ + 'dev-test-flag': true, + 'easter-i-tunes-special': false, + 'easter-specials': 'no specials', + fdsafdsafdsafdsa: true, + 'has-prereq-depth-1': true, + 'is-prereq': true, + 'log-level': 'warn', + 'moonshot-demo': true, + test1: 's1', + 'this-is-a-test': true, + }); + }); + + test('an update to inExperiment should emit change event', async () => { + const putResponse = clone(defaultPutResponse); + putResponse['dev-test-flag'].reason = { kind: 'RULE_MATCH', inExperiment: true }; + + const simulatedEvents = [{ data: JSON.stringify(putResponse) }]; + mockPlatform.requests.createEventSource.mockImplementation( + (streamUri: string = '', options: any = {}) => { + mockEventSource = new MockEventSource(streamUri, options); + mockEventSource.simulateEvents('put', simulatedEvents); + return mockEventSource; + }, + ); + + const changePromise = onChangePromise(); + await ldc.identify(context); + await changePromise; + await jest.runAllTimersAsync(); + + const flagsInStorage = JSON.parse(mockPlatform.storage.set.mock.lastCall[1]) as Flags; + expect(ldc.allFlags()).toMatchObject({ 'dev-test-flag': true }); + expect(flagsInStorage['dev-test-flag'].reason).toEqual({ + kind: 'RULE_MATCH', + inExperiment: true, + }); + + // both previous and current are true but inExperiment has changed + // so a change event should be emitted + expect(emitter.emit).toHaveBeenCalledWith('change', context, ['dev-test-flag']); + }); + + test('patch should emit change event', async () => { + const patchResponse = clone(defaultPutResponse['dev-test-flag']); + patchResponse.key = 'dev-test-flag'; + patchResponse.value = false; + patchResponse.version += 1; + + const putEvents = [{ data: JSON.stringify(defaultPutResponse) }]; + const patchEvents = [{ data: JSON.stringify(patchResponse) }]; + mockPlatform.requests.createEventSource.mockImplementation( + (streamUri: string = '', options: any = {}) => { + mockEventSource = new MockEventSource(streamUri, options); + mockEventSource.simulateEvents('put', putEvents); + mockEventSource.simulateEvents('patch', patchEvents); + return mockEventSource; + }, + ); + + const changePromise = onChangePromise(); + await ldc.identify(context); + await changePromise; + await jest.runAllTimersAsync(); + + const flagsInStorage = JSON.parse(mockPlatform.storage.set.mock.lastCall[1]) as Flags; + expect(ldc.allFlags()).toMatchObject({ 'dev-test-flag': false }); + expect(mockPlatform.storage.set).toHaveBeenCalledTimes(4); + expect(flagsInStorage['dev-test-flag'].version).toEqual(patchResponse.version); + expect(emitter.emit).toHaveBeenCalledWith('change', context, ['dev-test-flag']); + }); + + test('patch should add new flags', async () => { + const patchResponse = clone(defaultPutResponse['dev-test-flag']); + patchResponse.key = 'another-dev-test-flag'; + + const putEvents = [{ data: JSON.stringify(defaultPutResponse) }]; + const patchEvents = [{ data: JSON.stringify(patchResponse) }]; + mockPlatform.requests.createEventSource.mockImplementation( + (streamUri: string = '', options: any = {}) => { + mockEventSource = new MockEventSource(streamUri, options); + mockEventSource.simulateEvents('put', putEvents); + mockEventSource.simulateEvents('patch', patchEvents); + return mockEventSource; + }, + ); + + const changePromise = onChangePromise(); + await ldc.identify(context); + await changePromise; + await jest.runAllTimersAsync(); + + const flagsInStorage = JSON.parse(mockPlatform.storage.set.mock.lastCall[1]) as Flags; + expect(ldc.allFlags()).toHaveProperty('another-dev-test-flag'); + expect(mockPlatform.storage.set).toHaveBeenNthCalledWith( + 4, + flagStorageKey, + expect.stringContaining(JSON.stringify(patchResponse)), + ); + expect(flagsInStorage).toHaveProperty('another-dev-test-flag'); + expect(emitter.emit).toHaveBeenCalledWith('change', context, ['another-dev-test-flag']); + }); + + test('patch should ignore older version', async () => { + const patchResponse = clone(defaultPutResponse['dev-test-flag']); + patchResponse.key = 'dev-test-flag'; + patchResponse.value = false; + patchResponse.version -= 1; + + const putEvents = [{ data: JSON.stringify(defaultPutResponse) }]; + const patchEvents = [{ data: JSON.stringify(patchResponse) }]; + mockPlatform.requests.createEventSource.mockImplementation( + (streamUri: string = '', options: any = {}) => { + mockEventSource = new MockEventSource(streamUri, options); + mockEventSource.simulateEvents('put', putEvents); + mockEventSource.simulateEvents('patch', patchEvents); + return mockEventSource; + }, + ); + + const changePromise = onChangePromise(); + await ldc.identify(context); + await changePromise; + await jest.runAllTimersAsync(); + + // the initial put is resulting in two sets, one for the index and one for the flag data + expect(mockPlatform.storage.set).toHaveBeenCalledTimes(2); + expect(emitter.emit).not.toHaveBeenCalledWith('change'); + + // this is defaultPutResponse + expect(ldc.allFlags()).toEqual({ + 'dev-test-flag': true, + 'easter-i-tunes-special': false, + 'easter-specials': 'no specials', + fdsafdsafdsafdsa: true, + 'has-prereq-depth-1': true, + 'is-prereq': true, + 'log-level': 'warn', + 'moonshot-demo': true, + test1: 's1', + 'this-is-a-test': true, + }); + }); + + test('delete should emit change event', async () => { + const deleteResponse = { + key: 'dev-test-flag', + version: defaultPutResponse['dev-test-flag'].version + 1, + }; + + const putEvents = [{ data: JSON.stringify(defaultPutResponse) }]; + const deleteEvents = [{ data: JSON.stringify(deleteResponse) }]; + mockPlatform.requests.createEventSource.mockImplementation( + (streamUri: string = '', options: any = {}) => { + mockEventSource = new MockEventSource(streamUri, options); + mockEventSource.simulateEvents('put', putEvents); + mockEventSource.simulateEvents('delete', deleteEvents); + return mockEventSource; + }, + ); + + const changePromise = onChangePromise(); + await ldc.identify(context); + await changePromise; + await jest.runAllTimersAsync(); + + const flagsInStorage = JSON.parse(mockPlatform.storage.set.mock.lastCall[1]) as Flags; + expect(ldc.allFlags()).not.toHaveProperty('dev-test-flag'); + expect(mockPlatform.storage.set).toHaveBeenNthCalledWith( + 4, + flagStorageKey, + expect.stringContaining('dev-test-flag'), + ); + expect(flagsInStorage['dev-test-flag']).toMatchObject({ ...deleteResponse, deleted: true }); + expect(emitter.emit).toHaveBeenCalledWith('change', context, ['dev-test-flag']); + }); + + test('delete should not delete equal version', async () => { + const deleteResponse = { + key: 'dev-test-flag', + version: defaultPutResponse['dev-test-flag'].version, + }; + + const putEvents = [{ data: JSON.stringify(defaultPutResponse) }]; + const deleteEvents = [{ data: JSON.stringify(deleteResponse) }]; + mockPlatform.requests.createEventSource.mockImplementation( + (streamUri: string = '', options: any = {}) => { + mockEventSource = new MockEventSource(streamUri, options); + mockEventSource.simulateEvents('put', putEvents); + mockEventSource.simulateEvents('delete', deleteEvents); + return mockEventSource; + }, + ); + + const changePromise = onChangePromise(); + await ldc.identify(context); + await changePromise; + await jest.runAllTimersAsync(); + + expect(ldc.allFlags()).toHaveProperty('dev-test-flag'); + // the initial put is resulting in two sets, one for the index and one for the flag data + expect(mockPlatform.storage.set).toHaveBeenCalledTimes(2); + expect(emitter.emit).not.toHaveBeenCalledWith('change'); + }); + + test('delete should not delete newer version', async () => { + const deleteResponse = { + key: 'dev-test-flag', + version: defaultPutResponse['dev-test-flag'].version - 1, + }; + + const putEvents = [{ data: JSON.stringify(defaultPutResponse) }]; + const deleteEvents = [{ data: JSON.stringify(deleteResponse) }]; + mockPlatform.requests.createEventSource.mockImplementation( + (streamUri: string = '', options: any = {}) => { + mockEventSource = new MockEventSource(streamUri, options); + mockEventSource.simulateEvents('put', putEvents); + mockEventSource.simulateEvents('delete', deleteEvents); + return mockEventSource; + }, + ); + + const changePromise = onChangePromise(); + await ldc.identify(context); + await changePromise; + await jest.runAllTimersAsync(); + + expect(ldc.allFlags()).toHaveProperty('dev-test-flag'); + // the initial put is resulting in two sets, one for the index and one for the flag data + expect(mockPlatform.storage.set).toHaveBeenCalledTimes(2); + expect(emitter.emit).not.toHaveBeenCalledWith('change'); + }); + + test('delete should add and tombstone non-existing flag', async () => { + const deleteResponse = { + key: 'does-not-exist', + version: 1, + }; + + const putEvents = [{ data: JSON.stringify(defaultPutResponse) }]; + const deleteEvents = [{ data: JSON.stringify(deleteResponse) }]; + mockPlatform.requests.createEventSource.mockImplementation( + (streamUri: string = '', options: any = {}) => { + mockEventSource = new MockEventSource(streamUri, options); + mockEventSource.simulateEvents('put', putEvents); + mockEventSource.simulateEvents('delete', deleteEvents); + return mockEventSource; + }, + ); + + const changePromise = onChangePromise(); + await ldc.identify(context); + await changePromise; + await jest.runAllTimersAsync(); + + const flagsInStorage = JSON.parse(mockPlatform.storage.set.mock.lastCall[1]) as Flags; + + expect(mockPlatform.storage.set).toHaveBeenCalledTimes(4); // two index saves and two flag saves + expect(flagsInStorage['does-not-exist']).toMatchObject({ ...deleteResponse, deleted: true }); + expect(emitter.emit).toHaveBeenCalledWith('change', context, ['does-not-exist']); + }); +}); diff --git a/packages/shared/sdk-client/__tests__/LDClientImpl.test.ts b/packages/shared/sdk-client/__tests__/LDClientImpl.test.ts new file mode 100644 index 000000000..b760e9410 --- /dev/null +++ b/packages/shared/sdk-client/__tests__/LDClientImpl.test.ts @@ -0,0 +1,416 @@ +import { AutoEnvAttributes, clone, Hasher, LDContext, LDLogger } from '@launchdarkly/js-sdk-common'; + +import { DataSourceState } from '../src/datasource/DataSourceStatus'; +import LDClientImpl from '../src/LDClientImpl'; +import { Flags } from '../src/types'; +import { createBasicPlatform } from './createBasicPlatform'; +import * as mockResponseJson from './evaluation/mockResponse.json'; +import { MockEventSource } from './streaming/LDClientImpl.mocks'; +import { makeTestDataManagerFactory } from './TestDataManager'; + +const testSdkKey = 'test-sdk-key'; +const context: LDContext = { kind: 'org', key: 'Testy Pizza' }; +const autoEnv = { + ld_application: { + key: 'digested1', + envAttributesVersion: '1.0', + id: 'com.testapp.ld', + name: 'LDApplication.TestApp', + version: '1.1.1', + }, + ld_device: { + key: 'random1', + envAttributesVersion: '1.0', + manufacturer: 'coconut', + os: { name: 'An OS', version: '1.0.1', family: 'orange' }, + }, +}; + +describe('sdk-client object', () => { + let ldc: LDClientImpl; + let mockEventSource: MockEventSource; + let simulatedEvents: { data?: any }[] = []; + let defaultPutResponse: Flags; + let mockPlatform: ReturnType; + let logger: LDLogger; + + function onDataSourceChangePromise(numToAwait: number) { + let countdown = numToAwait; + // eslint-disable-next-line no-new + return new Promise((res) => { + ldc.on('dataSourceStatus', () => { + countdown -= 1; + if (countdown === 0) { + res(); + } + }); + }); + } + + beforeEach(() => { + mockPlatform = createBasicPlatform(); + logger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }; + defaultPutResponse = clone(mockResponseJson); + mockPlatform.crypto.randomUUID.mockReturnValue('random1'); + const hasher = { + update: jest.fn((): Hasher => hasher), + digest: jest.fn(() => 'digested1'), + }; + mockPlatform.crypto.createHash.mockReturnValue(hasher); + + simulatedEvents = [{ data: JSON.stringify(defaultPutResponse) }]; + mockPlatform.requests.getEventSourceCapabilities.mockImplementation(() => ({ + readTimeout: true, + headers: true, + customMethod: true, + })); + mockPlatform.requests.createEventSource.mockImplementation( + (streamUri: string = '', options: any = {}) => { + mockEventSource = new MockEventSource(streamUri, options); + mockEventSource.simulateEvents('put', simulatedEvents); + return mockEventSource; + }, + ); + + ldc = new LDClientImpl( + testSdkKey, + AutoEnvAttributes.Enabled, + mockPlatform, + { + logger, + sendEvents: false, + }, + makeTestDataManagerFactory(testSdkKey, mockPlatform), + ); + }); + + afterEach(async () => { + await ldc.close(); + jest.resetAllMocks(); + }); + + test('all flags', async () => { + await ldc.identify(context); + const all = ldc.allFlags(); + + expect(all).toEqual({ + 'dev-test-flag': true, + 'easter-i-tunes-special': false, + 'easter-specials': 'no specials', + fdsafdsafdsafdsa: true, + 'has-prereq-depth-1': true, + 'is-prereq': true, + 'log-level': 'warn', + 'moonshot-demo': true, + test1: 's1', + 'this-is-a-test': true, + }); + }); + + test('identify success', async () => { + const carContext: LDContext = { kind: 'car', key: 'test-car' }; + + mockPlatform.crypto.randomUUID.mockReturnValue('random1'); + + // need reference within test to run assertions against + const mockCreateEventSource = jest.fn((streamUri: string = '', options: any = {}) => { + mockEventSource = new MockEventSource(streamUri, options); + mockEventSource.simulateEvents('put', [{ data: JSON.stringify(defaultPutResponse) }]); + return mockEventSource; + }); + mockPlatform.requests.createEventSource = mockCreateEventSource; + + await ldc.identify(carContext); + const c = ldc.getContext(); + const all = ldc.allFlags(); + + expect(c).toEqual({ + kind: 'multi', + car: { key: 'test-car' }, + ...autoEnv, + }); + expect(all).toMatchObject({ + 'dev-test-flag': true, + }); + expect(mockCreateEventSource).toHaveBeenCalledWith( + expect.stringContaining('/stream/path'), + expect.anything(), + ); + }); + + test('identify success withReasons', async () => { + const carContext: LDContext = { kind: 'car', key: 'test-car' }; + + // need reference within test to run assertions against + const mockCreateEventSource = jest.fn((streamUri: string = '', options: any = {}) => { + mockEventSource = new MockEventSource(streamUri, options); + mockEventSource.simulateEvents('put', [{ data: JSON.stringify(defaultPutResponse) }]); + return mockEventSource; + }); + mockPlatform.requests.createEventSource = mockCreateEventSource; + + ldc = new LDClientImpl( + testSdkKey, + AutoEnvAttributes.Enabled, + mockPlatform, + { + logger, + sendEvents: false, + withReasons: true, + }, + makeTestDataManagerFactory(testSdkKey, mockPlatform), + ); + + await ldc.identify(carContext); + + expect(mockCreateEventSource).toHaveBeenCalledWith( + expect.stringContaining('?withReasons=true'), + expect.anything(), + ); + }); + + test('identify success useReport', async () => { + const carContext: LDContext = { kind: 'car', key: 'test-car' }; + + // need reference within test to run assertions against + const mockCreateEventSource = jest.fn((streamUri: string = '', options: any = {}) => { + mockEventSource = new MockEventSource(streamUri, options); + mockEventSource.simulateEvents('put', [{ data: JSON.stringify(defaultPutResponse) }]); + return mockEventSource; + }); + mockPlatform.requests.createEventSource = mockCreateEventSource; + + ldc = new LDClientImpl( + testSdkKey, + AutoEnvAttributes.Enabled, + mockPlatform, + { + logger, + sendEvents: false, + useReport: true, + }, + makeTestDataManagerFactory(testSdkKey, mockPlatform), + ); + + await ldc.identify(carContext); + + expect(mockCreateEventSource).toHaveBeenCalledWith( + expect.stringContaining('/stream/path/report'), + expect.anything(), + ); + }); + + test('identify success without auto env', async () => { + defaultPutResponse['dev-test-flag'].value = false; + simulatedEvents = [{ data: JSON.stringify(defaultPutResponse) }]; + + const carContext: LDContext = { kind: 'car', key: 'test-car' }; + ldc = new LDClientImpl( + testSdkKey, + AutoEnvAttributes.Disabled, + mockPlatform, + { + logger, + sendEvents: false, + }, + makeTestDataManagerFactory(testSdkKey, mockPlatform), + ); + + await ldc.identify(carContext); + const c = ldc.getContext(); + const all = ldc.allFlags(); + + expect(c).toEqual(carContext); + expect(all).toMatchObject({ + 'dev-test-flag': false, + }); + }); + + test('identify anonymous', async () => { + defaultPutResponse['dev-test-flag'].value = false; + simulatedEvents = [{ data: JSON.stringify(defaultPutResponse) }]; + + const carContext: LDContext = { kind: 'car', anonymous: true, key: '' }; + + mockPlatform.crypto.randomUUID.mockReturnValue('random1'); + + await ldc.identify(carContext); + const c = ldc.getContext(); + const all = ldc.allFlags(); + + expect(c).toEqual({ + kind: 'multi', + car: { anonymous: true, key: 'random1' }, + ...autoEnv, + }); + expect(all).toMatchObject({ + 'dev-test-flag': false, + }); + }); + + test('identify error invalid context', async () => { + const carContext: LDContext = { kind: 'car', key: '' }; + + await expect(ldc.identify(carContext)).rejects.toThrow(/no key/); + expect(logger.error).toHaveBeenCalledTimes(1); + expect(ldc.getContext()).toBeUndefined(); + }); + + test('identify error stream error', async () => { + mockPlatform.requests.createEventSource.mockImplementation( + (streamUri: string = '', options: any = {}) => { + mockEventSource = new MockEventSource(streamUri, options); + mockEventSource.simulateError({ status: 404, message: 'test-error' }); + return mockEventSource; + }, + ); + + const carContext: LDContext = { kind: 'car', key: 'test-car' }; + + await expect(ldc.identify(carContext)).rejects.toThrow('test-error'); + expect(logger.error).toHaveBeenCalledTimes(2); + expect(logger.error).toHaveBeenNthCalledWith(1, expect.stringMatching(/^error:.*test-error/)); + expect(logger.error).toHaveBeenNthCalledWith(2, expect.stringContaining('Received error 404')); + }); + + test('identify change and error listeners', async () => { + // @ts-ignore + const { emitter } = ldc; + + await ldc.identify(context); + + const carContext1: LDContext = { kind: 'car', key: 'test-car' }; + await ldc.identify(carContext1); + + const carContext2: LDContext = { kind: 'car', key: 'test-car-2' }; + await ldc.identify(carContext2); + + // No default listeners. This is important for clients to be able to determine if there are + // any listeners and act on that information. + expect(emitter.listenerCount('change')).toEqual(0); + expect(emitter.listenerCount('error')).toEqual(1); + }); + + test('can complete identification using storage', async () => { + const data: Record = {}; + mockPlatform.storage.get.mockImplementation((key) => data[key]); + mockPlatform.storage.set.mockImplementation((key: string, value: string) => { + data[key] = value; + }); + mockPlatform.storage.clear.mockImplementation((key: string) => { + delete data[key]; + }); + + // First identify should populate storage. + await ldc.identify(context); + + expect(logger.debug).not.toHaveBeenCalledWith('Identify completing with cached flags'); + + // Second identify should use storage. + await ldc.identify(context); + + expect(logger.debug).toHaveBeenCalledWith('Identify completing with cached flags'); + }); + + test('does not complete identify using storage when instructed to wait for the network response', async () => { + const data: Record = {}; + mockPlatform.storage.get.mockImplementation((key) => data[key]); + mockPlatform.storage.set.mockImplementation((key: string, value: string) => { + data[key] = value; + }); + mockPlatform.storage.clear.mockImplementation((key: string) => { + delete data[key]; + }); + + // First identify should populate storage. + await ldc.identify(context); + + expect(logger.debug).not.toHaveBeenCalledWith('Identify completing with cached flags'); + + // Second identify would use storage, but we instruct it not to. + await ldc.identify(context, { waitForNetworkResults: true, timeout: 5 }); + + expect(logger.debug).not.toHaveBeenCalledWith('Identify completing with cached flags'); + }); + + test('data source status emits valid when successful initialization', async () => { + const carContext: LDContext = { kind: 'car', key: 'test-car' }; + + mockPlatform.crypto.randomUUID.mockReturnValue('random1'); + + // need reference within test to run assertions against + const mockCreateEventSource = jest.fn((streamUri: string = '', options: any = {}) => { + mockEventSource = new MockEventSource(streamUri, options); + mockEventSource.simulateEvents('put', [{ data: JSON.stringify(defaultPutResponse) }]); + return mockEventSource; + }); + mockPlatform.requests.createEventSource = mockCreateEventSource; + + const spyListener = jest.fn(); + ldc.on('dataSourceStatus', spyListener); + const changePromise = onDataSourceChangePromise(2); + await ldc.identify(carContext); + await changePromise; + + expect(spyListener).toHaveBeenCalledTimes(2); + expect(spyListener).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + state: DataSourceState.Initializing, + stateSince: expect.any(Number), + lastError: undefined, + }), + ); + expect(spyListener).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + state: DataSourceState.Valid, + stateSince: expect.any(Number), + lastError: undefined, + }), + ); + }); + + test('data source status emits closed when initialization encounters unrecoverable error', async () => { + const carContext: LDContext = { kind: 'car', key: 'test-car' }; + + mockPlatform.crypto.randomUUID.mockReturnValue('random1'); + + // need reference within test to run assertions against + const mockCreateEventSource = jest.fn((streamUri: string = '', options: any = {}) => { + mockEventSource = new MockEventSource(streamUri, options); + mockEventSource.simulateError({ status: 404, message: 'test-error' }); // unrecoverable error + return mockEventSource; + }); + mockPlatform.requests.createEventSource = mockCreateEventSource; + + const spyListener = jest.fn(); + ldc.on('dataSourceStatus', spyListener); + const changePromise = onDataSourceChangePromise(2); + await expect(ldc.identify(carContext)).rejects.toThrow('test-error'); + await changePromise; + + expect(spyListener).toHaveBeenCalledTimes(2); + expect(spyListener).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + state: DataSourceState.Initializing, + stateSince: expect.any(Number), + lastError: undefined, + }), + ); + expect(spyListener).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + state: DataSourceState.Closed, + stateSince: expect.any(Number), + lastError: expect.anything(), + }), + ); + }); +}); diff --git a/packages/shared/sdk-client/src/LDClientImpl.timeout.test.ts b/packages/shared/sdk-client/__tests__/LDClientImpl.timeout.test.ts similarity index 64% rename from packages/shared/sdk-client/src/LDClientImpl.timeout.test.ts rename to packages/shared/sdk-client/__tests__/LDClientImpl.timeout.test.ts index 9ed81aca5..f4f5ca70b 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.timeout.test.ts +++ b/packages/shared/sdk-client/__tests__/LDClientImpl.timeout.test.ts @@ -1,34 +1,23 @@ -import { AutoEnvAttributes, clone, LDContext } from '@launchdarkly/js-sdk-common'; -import { - createBasicPlatform, - createLogger, - setupMockStreamingProcessor, -} from '@launchdarkly/private-js-mocks'; - -import { toMulti } from './context/addAutoEnv'; +import { AutoEnvAttributes, clone, LDContext, LDLogger } from '@launchdarkly/js-sdk-common'; + +import { toMulti } from '../src/context/addAutoEnv'; +import LDClientImpl from '../src/LDClientImpl'; +import { Flags } from '../src/types'; +import { createBasicPlatform } from './createBasicPlatform'; import * as mockResponseJson from './evaluation/mockResponse.json'; -import LDClientImpl from './LDClientImpl'; -import { Flags } from './types'; +import { MockEventSource } from './streaming/LDClientImpl.mocks'; +import { makeTestDataManagerFactory } from './TestDataManager'; let mockPlatform: ReturnType; -let logger: ReturnType; +let logger: LDLogger; beforeEach(() => { mockPlatform = createBasicPlatform(); - logger = createLogger(); -}); - -jest.mock('@launchdarkly/js-sdk-common', () => { - const actual = jest.requireActual('@launchdarkly/js-sdk-common'); - const m = jest.requireActual('@launchdarkly/private-js-mocks'); - return { - ...actual, - ...{ - internal: { - ...actual.internal, - StreamingProcessor: m.MockStreamingProcessor, - }, - }, + logger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), }; }); @@ -36,6 +25,8 @@ const testSdkKey = 'test-sdk-key'; const carContext: LDContext = { kind: 'car', key: 'test-car' }; let ldc: LDClientImpl; +let mockEventSource: MockEventSource; +let simulatedEvents: { data?: any }[] = []; let defaultPutResponse: Flags; const DEFAULT_IDENTIFY_TIMEOUT = 5; @@ -48,30 +39,36 @@ describe('sdk-client identify timeout', () => { beforeEach(() => { defaultPutResponse = clone(mockResponseJson); - // simulate streaming error after a long timeout - setupMockStreamingProcessor(true, defaultPutResponse, undefined, undefined, 30); + mockPlatform.requests.createEventSource.mockImplementation( + (streamUri: string = '', options: any = {}) => { + mockEventSource = new MockEventSource(streamUri, options); + mockEventSource.simulateEvents('put', simulatedEvents); + return mockEventSource; + }, + ); - ldc = new LDClientImpl(testSdkKey, AutoEnvAttributes.Enabled, mockPlatform, { - logger, - sendEvents: false, - }); - jest - .spyOn(LDClientImpl.prototype as any, 'createStreamUriPath') - .mockReturnValue('/stream/path'); + ldc = new LDClientImpl( + testSdkKey, + AutoEnvAttributes.Enabled, + mockPlatform, + { + logger, + sendEvents: false, + }, + makeTestDataManagerFactory(testSdkKey, mockPlatform), + ); }); afterEach(() => { jest.resetAllMocks(); }); - // streaming is setup to error in beforeEach to cause a timeout test('rejects with default timeout of 5s', async () => { jest.advanceTimersByTimeAsync(DEFAULT_IDENTIFY_TIMEOUT * 1000).then(); await expect(ldc.identify(carContext)).rejects.toThrow(/identify timed out/); expect(logger.error).toHaveBeenCalledWith(expect.stringMatching(/identify timed out/)); }); - // streaming is setup to error in beforeEach to cause a timeout test('rejects with custom timeout', async () => { const timeout = 15; jest.advanceTimersByTimeAsync(timeout * 1000).then(); @@ -79,7 +76,9 @@ describe('sdk-client identify timeout', () => { }); test('resolves with default timeout', async () => { - setupMockStreamingProcessor(false, defaultPutResponse); + // set simulated events to be default response + simulatedEvents = [{ data: JSON.stringify(defaultPutResponse) }]; + jest.advanceTimersByTimeAsync(DEFAULT_IDENTIFY_TIMEOUT * 1000).then(); await expect(ldc.identify(carContext)).resolves.toBeUndefined(); @@ -90,6 +89,8 @@ describe('sdk-client identify timeout', () => { 'easter-i-tunes-special': false, 'easter-specials': 'no specials', fdsafdsafdsafdsa: true, + 'has-prereq-depth-1': true, + 'is-prereq': true, 'log-level': 'warn', 'moonshot-demo': true, test1: 's1', @@ -99,7 +100,10 @@ describe('sdk-client identify timeout', () => { test('resolves with custom timeout', async () => { const timeout = 15; - setupMockStreamingProcessor(false, defaultPutResponse); + + // set simulated events to be default response + simulatedEvents = [{ data: JSON.stringify(defaultPutResponse) }]; + jest.advanceTimersByTimeAsync(timeout).then(); await expect(ldc.identify(carContext, { timeout })).resolves.toBeUndefined(); @@ -110,6 +114,8 @@ describe('sdk-client identify timeout', () => { 'easter-i-tunes-special': false, 'easter-specials': 'no specials', fdsafdsafdsafdsa: true, + 'has-prereq-depth-1': true, + 'is-prereq': true, 'log-level': 'warn', 'moonshot-demo': true, test1: 's1', @@ -119,7 +125,10 @@ describe('sdk-client identify timeout', () => { test('setting high timeout threshold with internalOptions', async () => { const highTimeoutThreshold = 20; - setupMockStreamingProcessor(false, defaultPutResponse); + + // set simulated events to be default response + simulatedEvents = [{ data: JSON.stringify(defaultPutResponse) }]; + ldc = new LDClientImpl( testSdkKey, AutoEnvAttributes.Enabled, @@ -128,6 +137,7 @@ describe('sdk-client identify timeout', () => { logger, sendEvents: false, }, + makeTestDataManagerFactory(testSdkKey, mockPlatform), { highTimeoutThreshold }, ); const customTimeout = 10; @@ -139,7 +149,10 @@ describe('sdk-client identify timeout', () => { test('warning when timeout is too high', async () => { const highTimeout = 60; - setupMockStreamingProcessor(false, defaultPutResponse); + + // set simulated events to be default response + simulatedEvents = [{ data: JSON.stringify(defaultPutResponse) }]; + jest.advanceTimersByTimeAsync(highTimeout * 1000).then(); await ldc.identify(carContext, { timeout: highTimeout }); @@ -148,7 +161,9 @@ describe('sdk-client identify timeout', () => { }); test('safe timeout should not warn', async () => { - setupMockStreamingProcessor(false, defaultPutResponse); + // set simulated events to be default response + simulatedEvents = [{ data: JSON.stringify(defaultPutResponse) }]; + jest.advanceTimersByTimeAsync(DEFAULT_IDENTIFY_TIMEOUT * 1000).then(); await ldc.identify(carContext, { timeout: DEFAULT_IDENTIFY_TIMEOUT }); diff --git a/packages/shared/sdk-client/src/LDClientImpl.variation.test.ts b/packages/shared/sdk-client/__tests__/LDClientImpl.variation.test.ts similarity index 60% rename from packages/shared/sdk-client/src/LDClientImpl.variation.test.ts rename to packages/shared/sdk-client/__tests__/LDClientImpl.variation.test.ts index 39d63065d..d6fbd74b9 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.variation.test.ts +++ b/packages/shared/sdk-client/__tests__/LDClientImpl.variation.test.ts @@ -1,33 +1,28 @@ -import { AutoEnvAttributes, clone, Context, LDContext } from '@launchdarkly/js-sdk-common'; import { - createBasicPlatform, - createLogger, - setupMockStreamingProcessor, -} from '@launchdarkly/private-js-mocks'; - + AutoEnvAttributes, + clone, + Context, + LDContext, + LDLogger, +} from '@launchdarkly/js-sdk-common'; + +import LDClientImpl from '../src/LDClientImpl'; +import { Flags } from '../src/types'; +import { createBasicPlatform } from './createBasicPlatform'; import * as mockResponseJson from './evaluation/mockResponse.json'; -import LDClientImpl from './LDClientImpl'; -import { Flags } from './types'; +import { MockEventSource } from './streaming/LDClientImpl.mocks'; +import { makeTestDataManagerFactory } from './TestDataManager'; let mockPlatform: ReturnType; -let logger: ReturnType; +let logger: LDLogger; beforeEach(() => { mockPlatform = createBasicPlatform(); - logger = createLogger(); -}); - -jest.mock('@launchdarkly/js-sdk-common', () => { - const actual = jest.requireActual('@launchdarkly/js-sdk-common'); - const actualMock = jest.requireActual('@launchdarkly/private-js-mocks'); - return { - ...actual, - ...{ - internal: { - ...actual.internal, - StreamingProcessor: actualMock.MockStreamingProcessor, - }, - }, + logger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), }; }); @@ -35,19 +30,33 @@ const testSdkKey = 'test-sdk-key'; const context: LDContext = { kind: 'org', key: 'Testy Pizza' }; let ldc: LDClientImpl; +let mockEventSource: MockEventSource; +let simulatedEvents: { data?: any }[] = []; let defaultPutResponse: Flags; describe('sdk-client object', () => { beforeEach(() => { defaultPutResponse = clone(mockResponseJson); - setupMockStreamingProcessor(false, defaultPutResponse); - ldc = new LDClientImpl(testSdkKey, AutoEnvAttributes.Disabled, mockPlatform, { - logger, - sendEvents: false, - }); - jest - .spyOn(LDClientImpl.prototype as any, 'createStreamUriPath') - .mockReturnValue('/stream/path'); + + simulatedEvents = [{ data: JSON.stringify(defaultPutResponse) }]; + mockPlatform.requests.createEventSource.mockImplementation( + (streamUri: string = '', options: any = {}) => { + mockEventSource = new MockEventSource(streamUri, options); + mockEventSource.simulateEvents('put', simulatedEvents); + return mockEventSource; + }, + ); + + ldc = new LDClientImpl( + testSdkKey, + AutoEnvAttributes.Disabled, + mockPlatform, + { + logger, + sendEvents: false, + }, + makeTestDataManagerFactory(testSdkKey, mockPlatform), + ); }); afterEach(() => { @@ -57,7 +66,6 @@ describe('sdk-client object', () => { test('variation', async () => { await ldc.identify(context); const devTestFlag = ldc.variation('dev-test-flag'); - expect(devTestFlag).toBe(true); }); @@ -67,14 +75,11 @@ describe('sdk-client object', () => { ldc.on('error', errorListener); const p = ldc.identify(context); - setTimeout(() => { - // call variation in the next tick to give ldc a chance to hook up event emitter - ldc.variation('does-not-exist', 'not-found'); - }); + ldc.variation('does-not-exist', 'not-found'); await expect(p).resolves.toBeUndefined(); - const error = errorListener.mock.calls[0][1]; expect(errorListener).toHaveBeenCalledTimes(1); + const error = errorListener.mock.calls[0][1]; expect(error.message).toMatch(/unknown feature/i); }); @@ -95,7 +100,8 @@ describe('sdk-client object', () => { const checkedContext = Context.fromLDContext(context); // @ts-ignore - await ldc.flagManager.upsert(checkedContext, 'dev-test-flag', { + // eslint-disable-next-line no-underscore-dangle + await ldc._flagManager.upsert(checkedContext, 'dev-test-flag', { version: 999, flag: { deleted: true, diff --git a/packages/shared/sdk-client/src/LDEmitter.test.ts b/packages/shared/sdk-client/__tests__/LDEmitter.test.ts similarity index 99% rename from packages/shared/sdk-client/src/LDEmitter.test.ts rename to packages/shared/sdk-client/__tests__/LDEmitter.test.ts index def8355a3..66a2a57d9 100644 --- a/packages/shared/sdk-client/src/LDEmitter.test.ts +++ b/packages/shared/sdk-client/__tests__/LDEmitter.test.ts @@ -1,6 +1,6 @@ import { LDContext, LDLogger } from '@launchdarkly/js-sdk-common'; -import LDEmitter from './LDEmitter'; +import LDEmitter from '../src/LDEmitter'; describe('LDEmitter', () => { const error = { type: 'network', message: 'unreachable' }; diff --git a/packages/shared/sdk-client/__tests__/TestDataManager.ts b/packages/shared/sdk-client/__tests__/TestDataManager.ts new file mode 100644 index 000000000..25ccbd4a1 --- /dev/null +++ b/packages/shared/sdk-client/__tests__/TestDataManager.ts @@ -0,0 +1,142 @@ +import { + base64UrlEncode, + Context, + Encoding, + internal, + LDHeaders, + Platform, +} from '@launchdarkly/js-sdk-common'; + +import { LDIdentifyOptions } from '../src/api'; +import { Configuration } from '../src/configuration/Configuration'; +import { BaseDataManager, DataManagerFactory } from '../src/DataManager'; +import { DataSourcePaths } from '../src/datasource/DataSourceConfig'; +import { makeRequestor } from '../src/datasource/Requestor'; +import { FlagManager } from '../src/flag-manager/FlagManager'; +import LDEmitter from '../src/LDEmitter'; + +export default class TestDataManager extends BaseDataManager { + constructor( + platform: Platform, + flagManager: FlagManager, + credential: string, + config: Configuration, + getPollingPaths: () => DataSourcePaths, + getStreamingPaths: () => DataSourcePaths, + baseHeaders: LDHeaders, + emitter: LDEmitter, + private readonly _disableNetwork: boolean, + diagnosticsManager?: internal.DiagnosticsManager, + ) { + super( + platform, + flagManager, + credential, + config, + getPollingPaths, + getStreamingPaths, + baseHeaders, + emitter, + diagnosticsManager, + ); + } + override async identify( + identifyResolve: () => void, + identifyReject: (err: Error) => void, + context: Context, + identifyOptions?: LDIdentifyOptions, + ): Promise { + this.context = context; + const waitForNetworkResults = !!identifyOptions?.waitForNetworkResults; + + const loadedFromCache = await this.flagManager.loadCached(context); + if (loadedFromCache && !waitForNetworkResults) { + this.logger.debug('Identify completing with cached flags'); + identifyResolve(); + } + if (loadedFromCache && waitForNetworkResults) { + this.logger.debug( + 'Identify - Flags loaded from cache, but identify was requested with "waitForNetworkResults"', + ); + } + if (this._disableNetwork) { + identifyResolve(); + return; + } + + this._setupConnection(context, identifyResolve, identifyReject); + } + + private _setupConnection( + context: Context, + identifyResolve?: () => void, + identifyReject?: (err: Error) => void, + ) { + const rawContext = Context.toLDContext(context)!; + + this.updateProcessor?.close(); + + const requestor = makeRequestor( + JSON.stringify(Context.toLDContext(context)), + this.config.serviceEndpoints, + this.getPollingPaths(), + this.platform.requests, + this.platform.encoding!, + this.baseHeaders, + [], + this.config.useReport, + this.config.withReasons, + ); + this.createStreamingProcessor(rawContext, context, requestor, identifyResolve, identifyReject); + + this.updateProcessor!.start(); + } +} + +export function makeTestDataManagerFactory( + sdkKey: string, + platform: Platform, + options?: { + disableNetwork?: boolean; + }, +): DataManagerFactory { + return ( + flagManager: FlagManager, + configuration: Configuration, + baseHeaders: LDHeaders, + emitter: LDEmitter, + diagnosticsManager?: internal.DiagnosticsManager, + ) => + new TestDataManager( + platform, + flagManager, + sdkKey, + configuration, + () => ({ + pathGet(encoding: Encoding, _plainContextString: string): string { + return `/msdk/evalx/contexts/${base64UrlEncode(_plainContextString, encoding)}`; + }, + pathReport(_encoding: Encoding, _plainContextString: string): string { + return `/msdk/evalx/context`; + }, + pathPing(_encoding: Encoding, _plainContextString: string): string { + return `/mping`; + }, + }), + () => ({ + pathGet(_encoding: Encoding, _plainContextString: string): string { + return '/stream/path'; + }, + pathReport(_encoding: Encoding, _plainContextString: string): string { + return '/stream/path/report'; + }, + pathPing(_encoding: Encoding, _plainContextString: string): string { + return '/stream/path/ping'; + }, + }), + baseHeaders, + emitter, + !!options?.disableNetwork, + diagnosticsManager, + ); +} diff --git a/packages/shared/sdk-client/src/configuration/Configuration.test.ts b/packages/shared/sdk-client/__tests__/configuration/Configuration.test.ts similarity index 61% rename from packages/shared/sdk-client/src/configuration/Configuration.test.ts rename to packages/shared/sdk-client/__tests__/configuration/Configuration.test.ts index 9936234f9..3f437855a 100644 --- a/packages/shared/sdk-client/src/configuration/Configuration.test.ts +++ b/packages/shared/sdk-client/__tests__/configuration/Configuration.test.ts @@ -1,5 +1,7 @@ /* eslint-disable no-console */ -import Configuration from './Configuration'; +import { createSafeLogger } from '@launchdarkly/js-sdk-common'; + +import ConfigurationImpl from '../../src/configuration/Configuration'; describe('Configuration', () => { beforeEach(() => { @@ -8,7 +10,7 @@ describe('Configuration', () => { }); it('has valid default values', () => { - const config = new Configuration(); + const config = new ConfigurationImpl(); expect(config).toMatchObject({ allAttributesPrivate: false, @@ -20,11 +22,7 @@ describe('Configuration', () => { withReasons: false, eventsUri: 'https://events.launchdarkly.com', flushInterval: 30, - logger: { - destination: console.error, - logLevel: 1, - name: 'LaunchDarkly', - }, + logger: expect.anything(), maxCachedContexts: 5, privateAttributes: [], sendEvents: true, @@ -37,13 +35,13 @@ describe('Configuration', () => { }); it('allows specifying valid wrapperName', () => { - const config = new Configuration({ wrapperName: 'test' }); + const config = new ConfigurationImpl({ wrapperName: 'test' }); expect(config).toMatchObject({ wrapperName: 'test' }); }); it('warns and ignored invalid keys', () => { // @ts-ignore - const config = new Configuration({ baseballUri: 1 }); + const config = new ConfigurationImpl({ baseballUri: 1 }); expect(config.baseballUri).toBeUndefined(); expect(console.error).toHaveBeenCalledWith(expect.stringContaining('unknown config option')); @@ -51,7 +49,7 @@ describe('Configuration', () => { it('converts boolean types', () => { // @ts-ignore - const config = new Configuration({ sendEvents: 0 }); + const config = new ConfigurationImpl({ sendEvents: 0 }); expect(config.sendEvents).toBeFalsy(); expect(console.error).toHaveBeenCalledWith( @@ -61,7 +59,7 @@ describe('Configuration', () => { it('ignores wrong type for number and logs appropriately', () => { // @ts-ignore - const config = new Configuration({ capacity: true }); + const config = new ConfigurationImpl({ capacity: true }); expect(config.capacity).toEqual(100); expect(console.error).toHaveBeenCalledWith( @@ -70,7 +68,7 @@ describe('Configuration', () => { }); it('enforces minimum flushInterval', () => { - const config = new Configuration({ flushInterval: 1 }); + const config = new ConfigurationImpl({ flushInterval: 1 }); expect(config.flushInterval).toEqual(2); expect(console.error).toHaveBeenNthCalledWith( @@ -80,14 +78,14 @@ describe('Configuration', () => { }); it('allows setting a valid maxCachedContexts', () => { - const config = new Configuration({ maxCachedContexts: 3 }); + const config = new ConfigurationImpl({ maxCachedContexts: 3 }); expect(config.maxCachedContexts).toBeDefined(); expect(console.error).not.toHaveBeenCalled(); }); it('enforces minimum maxCachedContext', () => { - const config = new Configuration({ maxCachedContexts: -1 }); + const config = new ConfigurationImpl({ maxCachedContexts: -1 }); expect(config.maxCachedContexts).toBeDefined(); expect(console.error).toHaveBeenNthCalledWith( @@ -103,7 +101,7 @@ describe('Configuration', () => { ['kebab-case-works'], ['snake_case_works'], ])('allow setting valid payload filter keys', (filter) => { - const config = new Configuration({ payloadFilterKey: filter }); + const config = new ConfigurationImpl({ payloadFilterKey: filter }); expect(config.payloadFilterKey).toEqual(filter); expect(console.error).toHaveBeenCalledTimes(0); }); @@ -111,7 +109,7 @@ describe('Configuration', () => { it.each([['invalid-@-filter'], ['_invalid-filter'], ['-invalid-filter']])( 'ignores invalid filters and logs a warning', (filter) => { - const config = new Configuration({ payloadFilterKey: filter }); + const config = new ConfigurationImpl({ payloadFilterKey: filter }); expect(config.payloadFilterKey).toBeUndefined(); expect(console.error).toHaveBeenNthCalledWith( 1, @@ -120,3 +118,48 @@ describe('Configuration', () => { }, ); }); + +it('makes a safe logger', () => { + const badLogger = { + debug: () => { + throw new Error('bad'); + }, + info: () => { + throw new Error('bad'); + }, + warn: () => { + throw new Error('bad'); + }, + error: () => { + throw new Error('bad'); + }, + }; + const config = new ConfigurationImpl({ + logger: badLogger, + }); + + expect(() => config.logger.debug('debug')).not.toThrow(); + expect(() => config.logger.info('info')).not.toThrow(); + expect(() => config.logger.warn('warn')).not.toThrow(); + expect(() => config.logger.error('error')).not.toThrow(); + expect(config.logger).not.toBe(badLogger); +}); + +it('does not wrap already safe loggers', () => { + const logger = createSafeLogger({ + debug: () => { + throw new Error('bad'); + }, + info: () => { + throw new Error('bad'); + }, + warn: () => { + throw new Error('bad'); + }, + error: () => { + throw new Error('bad'); + }, + }); + const config = new ConfigurationImpl({ logger }); + expect(config.logger).toBe(logger); +}); diff --git a/packages/shared/sdk-client/src/context/addAutoEnv.test.ts b/packages/shared/sdk-client/__tests__/context/addAutoEnv.test.ts similarity index 89% rename from packages/shared/sdk-client/src/context/addAutoEnv.test.ts rename to packages/shared/sdk-client/__tests__/context/addAutoEnv.test.ts index a36379455..cc0759d86 100644 --- a/packages/shared/sdk-client/src/context/addAutoEnv.test.ts +++ b/packages/shared/sdk-client/__tests__/context/addAutoEnv.test.ts @@ -2,20 +2,31 @@ import { Crypto, Info, type LDContext, + LDLogger, LDMultiKindContext, LDUser, } from '@launchdarkly/js-sdk-common'; -import { createBasicPlatform, createLogger } from '@launchdarkly/private-js-mocks'; -import Configuration from '../configuration'; -import { addApplicationInfo, addAutoEnv, addDeviceInfo, toMulti } from './addAutoEnv'; +import { Configuration, ConfigurationImpl } from '../../src/configuration'; +import { + addApplicationInfo, + addAutoEnv, + addDeviceInfo, + toMulti, +} from '../../src/context/addAutoEnv'; +import { createBasicPlatform } from '../createBasicPlatform'; let mockPlatform: ReturnType; -let logger: ReturnType; +let logger: LDLogger; beforeEach(() => { mockPlatform = createBasicPlatform(); - logger = createLogger(); + logger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }; }); describe('automatic environment attributes', () => { @@ -26,7 +37,7 @@ describe('automatic environment attributes', () => { beforeEach(() => { ({ crypto, info } = mockPlatform); (crypto.randomUUID as jest.Mock).mockResolvedValue('test-device-key-1'); - config = new Configuration({ logger }); + config = new ConfigurationImpl({ logger }); }); afterEach(() => { @@ -228,8 +239,8 @@ describe('automatic environment attributes', () => { await addAutoEnv(context, mockPlatform, config); - expect(config.logger.warn).toHaveBeenCalledTimes(1); - expect(config.logger.warn).toHaveBeenCalledWith( + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith( expect.stringMatching(/ld_application.*already exists/), ); }); @@ -246,10 +257,8 @@ describe('automatic environment attributes', () => { await addAutoEnv(context, mockPlatform, config); - expect(config.logger.warn).toHaveBeenCalledTimes(1); - expect(config.logger.warn).toHaveBeenCalledWith( - expect.stringMatching(/ld_device.*already exists/), - ); + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith(expect.stringMatching(/ld_device.*already exists/)); }); test('single context with an attribute called ld_application should have auto env attributes', async () => { @@ -332,8 +341,8 @@ describe('automatic environment attributes', () => { }); describe('addApplicationInfo', () => { - test('add id, version, name, versionName', () => { - config = new Configuration({ + test('add id, version, name, versionName', async () => { + config = new ConfigurationImpl({ applicationInfo: { id: 'com.from-config.ld', version: '2.2.2', @@ -341,7 +350,7 @@ describe('automatic environment attributes', () => { versionName: 'test-ld-version-name', }, }); - const ldApplication = addApplicationInfo(mockPlatform, config); + const ldApplication = await addApplicationInfo(mockPlatform, config); expect(ldApplication).toEqual({ envAttributesVersion: '1.0', @@ -353,8 +362,8 @@ describe('automatic environment attributes', () => { }); }); - test('add auto env application id, name, version', () => { - const ldApplication = addApplicationInfo(mockPlatform, config); + test('add auto env application id, name, version', async () => { + const ldApplication = await addApplicationInfo(mockPlatform, config); expect(ldApplication).toEqual({ envAttributesVersion: '1.0', @@ -365,7 +374,7 @@ describe('automatic environment attributes', () => { }); }); - test('final return value should not contain falsy values', () => { + test('final return value should not contain falsy values', async () => { const mockData = info.platformData(); info.platformData = jest.fn().mockReturnValueOnce({ ...mockData, @@ -379,7 +388,7 @@ describe('automatic environment attributes', () => { }, }); - const ldApplication = addApplicationInfo(mockPlatform, config); + const ldApplication = await addApplicationInfo(mockPlatform, config); expect(ldApplication).toEqual({ envAttributesVersion: '1.0', @@ -388,15 +397,15 @@ describe('automatic environment attributes', () => { }); }); - test('omit if customer and auto env data are unavailable', () => { + test('omit if customer and auto env data are unavailable', async () => { info.platformData = jest.fn().mockReturnValueOnce({}); - const ldApplication = addApplicationInfo(mockPlatform, config); + const ldApplication = await addApplicationInfo(mockPlatform, config); expect(ldApplication).toBeUndefined(); }); - test('omit if customer unavailable and auto env data are falsy', () => { + test('omit if customer unavailable and auto env data are falsy', async () => { const mockData = info.platformData(); info.platformData = jest.fn().mockReturnValueOnce({ ld_application: { @@ -407,27 +416,27 @@ describe('automatic environment attributes', () => { }, }); - const ldApplication = addApplicationInfo(mockPlatform, config); + const ldApplication = await addApplicationInfo(mockPlatform, config); expect(ldApplication).toBeUndefined(); }); - test('omit if customer data is unavailable and auto env data only contains key and attributesVersion', () => { + test('omit if customer data is unavailable and auto env data only contains key and attributesVersion', async () => { info.platformData = jest.fn().mockReturnValueOnce({ ld_application: { key: 'key-from-sdk', envAttributesVersion: '0.0.1' }, }); - const ldApplication = addApplicationInfo(mockPlatform, config); + const ldApplication = await addApplicationInfo(mockPlatform, config); expect(ldApplication).toBeUndefined(); }); - test('omit if no id specified', () => { + test('omit if no id specified', async () => { info.platformData = jest .fn() .mockReturnValueOnce({ ld_application: { version: null, locale: '' } }); - config = new Configuration({ applicationInfo: { version: '1.2.3' } }); - const ldApplication = addApplicationInfo(mockPlatform, config); + config = new ConfigurationImpl({ applicationInfo: { version: '1.2.3' } }); + const ldApplication = await addApplicationInfo(mockPlatform, config); expect(ldApplication).toBeUndefined(); }); diff --git a/packages/shared/sdk-client/src/context/ensureKey.test.ts b/packages/shared/sdk-client/__tests__/context/ensureKey.test.ts similarity index 96% rename from packages/shared/sdk-client/src/context/ensureKey.test.ts rename to packages/shared/sdk-client/__tests__/context/ensureKey.test.ts index a985427c0..60efb0e0a 100644 --- a/packages/shared/sdk-client/src/context/ensureKey.test.ts +++ b/packages/shared/sdk-client/__tests__/context/ensureKey.test.ts @@ -5,9 +5,9 @@ import type { LDMultiKindContext, LDUser, } from '@launchdarkly/js-sdk-common'; -import { createBasicPlatform } from '@launchdarkly/private-js-mocks'; -import { ensureKey } from './ensureKey'; +import { ensureKey } from '../../src/context/ensureKey'; +import { createBasicPlatform } from '../createBasicPlatform'; let mockPlatform: ReturnType; diff --git a/packages/shared/sdk-client/__tests__/createBasicPlatform.ts b/packages/shared/sdk-client/__tests__/createBasicPlatform.ts new file mode 100644 index 000000000..17178f061 --- /dev/null +++ b/packages/shared/sdk-client/__tests__/createBasicPlatform.ts @@ -0,0 +1,59 @@ +import { PlatformData, SdkData } from '@launchdarkly/js-sdk-common'; + +import { setupCrypto } from './setupCrypto'; + +const setupInfo = () => ({ + platformData: jest.fn( + (): PlatformData => ({ + os: { + name: 'An OS', + version: '1.0.1', + arch: 'An Arch', + }, + name: 'The SDK Name', + additional: { + nodeVersion: '42', + }, + ld_application: { + key: '', + envAttributesVersion: '1.0', + id: 'com.testapp.ld', + name: 'LDApplication.TestApp', + version: '1.1.1', + }, + ld_device: { + key: '', + envAttributesVersion: '1.0', + os: { name: 'Another OS', version: '99', family: 'orange' }, + manufacturer: 'coconut', + }, + }), + ), + sdkData: jest.fn( + (): SdkData => ({ + name: 'An SDK', + version: '2.0.2', + userAgentBase: 'TestUserAgent', + wrapperName: 'Rapper', + wrapperVersion: '1.2.3', + }), + ), +}); + +export const createBasicPlatform = () => ({ + encoding: { + btoa: (s: string) => Buffer.from(s).toString('base64'), + }, + info: setupInfo(), + crypto: setupCrypto(), + requests: { + fetch: jest.fn(), + createEventSource: jest.fn(), + getEventSourceCapabilities: jest.fn(), + }, + storage: { + get: jest.fn(), + set: jest.fn(), + clear: jest.fn(), + }, +}); diff --git a/packages/shared/sdk-client/__tests__/datasource/DataSourceStatusManager.test.ts b/packages/shared/sdk-client/__tests__/datasource/DataSourceStatusManager.test.ts new file mode 100644 index 000000000..2383568f9 --- /dev/null +++ b/packages/shared/sdk-client/__tests__/datasource/DataSourceStatusManager.test.ts @@ -0,0 +1,115 @@ +import { DataSourceErrorKind } from '@launchdarkly/js-sdk-common'; + +import { DataSourceState } from '../../src/datasource/DataSourceStatus'; +import DataSourceStatusManager from '../../src/datasource/DataSourceStatusManager'; +import LDEmitter from '../../src/LDEmitter'; + +describe('DataSourceStatusManager', () => { + test('its first state is closed', async () => { + const underTest = new DataSourceStatusManager(new LDEmitter()); + expect(underTest.status.state).toEqual(DataSourceState.Closed); + }); + + test('it stays at initializing if receives recoverable error', async () => { + const underTest = new DataSourceStatusManager(new LDEmitter()); + underTest.requestStateUpdate(DataSourceState.Initializing); + underTest.reportError(DataSourceErrorKind.ErrorResponse, 'womp', 404, true); + expect(underTest.status.state).toEqual(DataSourceState.Initializing); + }); + + test('it moves to closed if receives unrecoverable error', async () => { + const underTest = new DataSourceStatusManager(new LDEmitter()); + underTest.requestStateUpdate(DataSourceState.Initializing); + underTest.reportError(DataSourceErrorKind.ErrorResponse, 'womp', 404, false); + expect(underTest.status.state).toEqual(DataSourceState.Closed); + }); + + test('it updates last error time with each error, but not stateSince', async () => { + let time = 0; + const stamper: () => number = () => time; + const underTest = new DataSourceStatusManager(new LDEmitter(), stamper); + underTest.reportError(DataSourceErrorKind.ErrorResponse, 'womp', 404, true); + expect(underTest.status.stateSince).toEqual(0); + expect(underTest.status.lastError?.time).toEqual(0); + + time += 1; + underTest.reportError(DataSourceErrorKind.ErrorResponse, 'womp', 404, true); + expect(underTest.status.stateSince).toEqual(0); + expect(underTest.status.lastError?.time).toEqual(1); + + time += 1; + underTest.reportError(DataSourceErrorKind.ErrorResponse, 'womp', 404, true); + expect(underTest.status.stateSince).toEqual(0); + expect(underTest.status.lastError?.time).toEqual(2); + }); + + test('it updates stateSince when transitioning', async () => { + let time = 0; + const stamper: () => number = () => time; + + const underTest = new DataSourceStatusManager(new LDEmitter(), stamper); + expect(underTest.status.state).toEqual(DataSourceState.Closed); + expect(underTest.status.stateSince).toEqual(0); + + time += 1; + underTest.requestStateUpdate(DataSourceState.Valid); + expect(underTest.status.stateSince).toEqual(1); + + time += 1; + underTest.requestStateUpdate(DataSourceState.Closed); + expect(underTest.status.stateSince).toEqual(2); + }); + + test('it notifies listeners when state changes', async () => { + let time = 0; + const stamper: () => number = () => time; + const emitter = new LDEmitter(); + const spy = jest.spyOn(emitter, 'emit'); + const underTest = new DataSourceStatusManager(emitter, stamper); + + underTest.requestStateUpdate(DataSourceState.SetOffline); + time += 1; + underTest.reportError(DataSourceErrorKind.ErrorResponse, 'womp', 400, true); + time += 1; + underTest.reportError(DataSourceErrorKind.ErrorResponse, 'womp', 400, true); + time += 1; + underTest.requestStateUpdate(DataSourceState.Closed); + expect(spy).toHaveBeenCalledTimes(4); + expect(spy).toHaveBeenNthCalledWith( + 1, + 'dataSourceStatus', + expect.objectContaining({ + state: DataSourceState.SetOffline, + stateSince: 0, + lastError: undefined, + }), + ); + expect(spy).toHaveBeenNthCalledWith( + 2, + 'dataSourceStatus', + expect.objectContaining({ + state: DataSourceState.Interrupted, + stateSince: 1, + lastError: expect.anything(), + }), + ); + expect(spy).toHaveBeenNthCalledWith( + 3, + 'dataSourceStatus', + expect.objectContaining({ + state: DataSourceState.Interrupted, + stateSince: 1, // still in state interrupted + lastError: expect.anything(), + }), + ); + expect(spy).toHaveBeenNthCalledWith( + 4, + 'dataSourceStatus', + expect.objectContaining({ + state: DataSourceState.Closed, + stateSince: 3, + lastError: expect.anything(), + }), + ); + }); +}); diff --git a/packages/shared/sdk-client/src/diagnostics/createDiagnosticsInitConfig.test.ts b/packages/shared/sdk-client/__tests__/diagnostics/createDiagnosticsInitConfig.test.ts similarity index 87% rename from packages/shared/sdk-client/src/diagnostics/createDiagnosticsInitConfig.test.ts rename to packages/shared/sdk-client/__tests__/diagnostics/createDiagnosticsInitConfig.test.ts index aa70b134e..004dd0f7b 100644 --- a/packages/shared/sdk-client/src/diagnostics/createDiagnosticsInitConfig.test.ts +++ b/packages/shared/sdk-client/__tests__/diagnostics/createDiagnosticsInitConfig.test.ts @@ -1,15 +1,15 @@ import { secondsToMillis } from '@launchdarkly/js-sdk-common'; -import Configuration from '../configuration'; +import { ConfigurationImpl } from '../../src/configuration'; import createDiagnosticsInitConfig, { type DiagnosticsInitConfig, -} from './createDiagnosticsInitConfig'; +} from '../../src/diagnostics/createDiagnosticsInitConfig'; describe('createDiagnosticsInitConfig', () => { let initConfig: DiagnosticsInitConfig; beforeEach(() => { - initConfig = createDiagnosticsInitConfig(new Configuration()); + initConfig = createDiagnosticsInitConfig(new ConfigurationImpl()); }); test('defaults', () => { @@ -29,7 +29,7 @@ describe('createDiagnosticsInitConfig', () => { test('non-default config', () => { const custom = createDiagnosticsInitConfig( - new Configuration({ + new ConfigurationImpl({ baseUri: 'https://dev.ld.com', streamUri: 'https://stream.ld.com', eventsUri: 'https://events.ld.com', diff --git a/packages/shared/sdk-client/src/evaluation/mockResponse.json b/packages/shared/sdk-client/__tests__/evaluation/mockResponse.json similarity index 75% rename from packages/shared/sdk-client/src/evaluation/mockResponse.json rename to packages/shared/sdk-client/__tests__/evaluation/mockResponse.json index d8f8eb5ea..10c3b4882 100644 --- a/packages/shared/sdk-client/src/evaluation/mockResponse.json +++ b/packages/shared/sdk-client/__tests__/evaluation/mockResponse.json @@ -54,5 +54,24 @@ "value": true, "variation": 0, "trackEvents": false + }, + "is-prereq": { + "value": true, + "variation": 0, + "reason": { + "kind": "FALLTHROUGH" + }, + "version": 1, + "trackEvents": true + }, + "has-prereq-depth-1": { + "value": true, + "variation": 0, + "prerequisites": ["is-prereq"], + "reason": { + "kind": "FALLTHROUGH" + }, + "version": 4, + "trackEvents": true } } diff --git a/packages/shared/mocks/src/eventProcessor.ts b/packages/shared/sdk-client/__tests__/eventProcessor.ts similarity index 85% rename from packages/shared/mocks/src/eventProcessor.ts rename to packages/shared/sdk-client/__tests__/eventProcessor.ts index ffa577e16..30fa70802 100644 --- a/packages/shared/mocks/src/eventProcessor.ts +++ b/packages/shared/sdk-client/__tests__/eventProcessor.ts @@ -1,4 +1,4 @@ -import type { ClientContext, internal, subsystem } from '@common'; +import { ClientContext, internal, subsystem } from '@launchdarkly/js-sdk-common'; export const MockEventProcessor = jest.fn(); diff --git a/packages/shared/sdk-client/src/flag-manager/ContextIndex.test.ts b/packages/shared/sdk-client/__tests__/flag-manager/ContextIndex.test.ts similarity index 98% rename from packages/shared/sdk-client/src/flag-manager/ContextIndex.test.ts rename to packages/shared/sdk-client/__tests__/flag-manager/ContextIndex.test.ts index aa9bd45de..5edb004c4 100644 --- a/packages/shared/sdk-client/src/flag-manager/ContextIndex.test.ts +++ b/packages/shared/sdk-client/__tests__/flag-manager/ContextIndex.test.ts @@ -1,4 +1,4 @@ -import ContextIndex from './ContextIndex'; +import ContextIndex from '../../src/flag-manager/ContextIndex'; describe('ContextIndex tests', () => { test('notice adds to index', async () => { diff --git a/packages/shared/sdk-client/src/flag-manager/FlagPersistence.test.ts b/packages/shared/sdk-client/__tests__/flag-manager/FlagPersistence.test.ts similarity index 89% rename from packages/shared/sdk-client/src/flag-manager/FlagPersistence.test.ts rename to packages/shared/sdk-client/__tests__/flag-manager/FlagPersistence.test.ts index 2cd26cd1b..cd31ef46c 100644 --- a/packages/shared/sdk-client/src/flag-manager/FlagPersistence.test.ts +++ b/packages/shared/sdk-client/__tests__/flag-manager/FlagPersistence.test.ts @@ -1,11 +1,14 @@ /* eslint-disable @typescript-eslint/no-use-before-define */ import { Context, Crypto, Hasher, LDLogger, Platform, Storage } from '@launchdarkly/js-sdk-common'; -import { namespaceForContextData, namespaceForContextIndex } from '../storage/namespaceUtils'; -import { Flag, Flags } from '../types'; -import FlagPersistence from './FlagPersistence'; -import { DefaultFlagStore } from './FlagStore'; -import FlagUpdater from './FlagUpdater'; +import FlagPersistence from '../../src/flag-manager/FlagPersistence'; +import { DefaultFlagStore } from '../../src/flag-manager/FlagStore'; +import FlagUpdater from '../../src/flag-manager/FlagUpdater'; +import { + namespaceForContextData, + namespaceForContextIndex, +} from '../../src/storage/namespaceUtils'; +import { Flag, Flags } from '../../src/types'; const TEST_NAMESPACE = 'TestNamespace'; @@ -138,8 +141,12 @@ describe('FlagPersistence tests', () => { await fpUnderTest.init(context, flags); - const contextDataKey = namespaceForContextData(mockPlatform.crypto, TEST_NAMESPACE, context); - const contextIndexKey = namespaceForContextIndex(TEST_NAMESPACE); + const contextDataKey = await namespaceForContextData( + mockPlatform.crypto, + TEST_NAMESPACE, + context, + ); + const contextIndexKey = await namespaceForContextIndex(TEST_NAMESPACE); expect(await memoryStorage.get(contextIndexKey)).toContain(contextDataKey); expect(await memoryStorage.get(contextDataKey)).toContain('flagA'); }); @@ -172,9 +179,17 @@ describe('FlagPersistence tests', () => { await fpUnderTest.init(context1, flags); await fpUnderTest.init(context2, flags); - const context1DataKey = namespaceForContextData(mockPlatform.crypto, TEST_NAMESPACE, context1); - const context2DataKey = namespaceForContextData(mockPlatform.crypto, TEST_NAMESPACE, context2); - const contextIndexKey = namespaceForContextIndex(TEST_NAMESPACE); + const context1DataKey = await namespaceForContextData( + mockPlatform.crypto, + TEST_NAMESPACE, + context1, + ); + const context2DataKey = await namespaceForContextData( + mockPlatform.crypto, + TEST_NAMESPACE, + context2, + ); + const contextIndexKey = await namespaceForContextIndex(TEST_NAMESPACE); const indexData = await memoryStorage.get(contextIndexKey); expect(indexData).not.toContain(context1DataKey); @@ -210,7 +225,7 @@ describe('FlagPersistence tests', () => { await fpUnderTest.init(context, flags); await fpUnderTest.init(context, flags); - const contextIndexKey = namespaceForContextIndex(TEST_NAMESPACE); + const contextIndexKey = await namespaceForContextIndex(TEST_NAMESPACE); const indexData = await memoryStorage.get(contextIndexKey); expect(indexData).toContain(`"timestamp":2`); @@ -245,7 +260,11 @@ describe('FlagPersistence tests', () => { await fpUnderTest.init(context, flags); await fpUnderTest.upsert(context, 'flagA', { version: 2, flag: flagAv2 }); - const contextDataKey = namespaceForContextData(mockPlatform.crypto, TEST_NAMESPACE, context); + const contextDataKey = await namespaceForContextData( + mockPlatform.crypto, + TEST_NAMESPACE, + context, + ); // check memory flag store and persistence expect(flagStore.get('flagA')?.version).toEqual(2); @@ -283,12 +302,12 @@ describe('FlagPersistence tests', () => { flag: makeMockFlag(), }); - const activeContextDataKey = namespaceForContextData( + const activeContextDataKey = await namespaceForContextData( mockPlatform.crypto, TEST_NAMESPACE, activeContext, ); - const inactiveContextDataKey = namespaceForContextData( + const inactiveContextDataKey = await namespaceForContextData( mockPlatform.crypto, TEST_NAMESPACE, inactiveContext, @@ -310,6 +329,7 @@ function makeMockPlatform(storage: Storage, crypto: Crypto): Platform { requests: { fetch: jest.fn(), createEventSource: jest.fn(), + getEventSourceCapabilities: jest.fn(), }, }; } diff --git a/packages/shared/sdk-client/__tests__/flag-manager/FlagStore.test.ts b/packages/shared/sdk-client/__tests__/flag-manager/FlagStore.test.ts new file mode 100644 index 000000000..8cb3389ec --- /dev/null +++ b/packages/shared/sdk-client/__tests__/flag-manager/FlagStore.test.ts @@ -0,0 +1,60 @@ +import { DefaultFlagStore } from '../../src/flag-manager/FlagStore'; + +describe('given an empty flag store', () => { + let store: DefaultFlagStore; + + beforeEach(() => { + store = new DefaultFlagStore(); + }); + + it.each(['unknown', 'toString', 'length'])( + 'gets undefined for a feature that does not exist', + (key) => { + expect(store.get(key)).toBeUndefined(); + }, + ); + + it('can set and get key', () => { + store.insertOrUpdate('toString', { + version: 1, + flag: { + version: 1, + flagVersion: 1, + value: 'test-value', + variation: 0, + trackEvents: false, + }, + }); + + expect(store.get('toString')?.flag.value).toEqual('test-value'); + }); + + it('replaces flags on init', () => { + store.insertOrUpdate('potato', { + version: 1, + flag: { + version: 1, + flagVersion: 1, + value: 'test-value', + variation: 0, + trackEvents: false, + }, + }); + + store.init({ + newFlag: { + version: 1, + flag: { + version: 1, + flagVersion: 1, + value: 'new-test-value', + variation: 0, + trackEvents: false, + }, + }, + }); + + const all = store.getAll(); + expect(Object.keys(all)).toEqual(['newFlag']); + }); +}); diff --git a/packages/shared/sdk-client/src/flag-manager/FlagUpdater.test.ts b/packages/shared/sdk-client/__tests__/flag-manager/FlagUpdater.test.ts similarity index 97% rename from packages/shared/sdk-client/src/flag-manager/FlagUpdater.test.ts rename to packages/shared/sdk-client/__tests__/flag-manager/FlagUpdater.test.ts index 26b5d42da..6bc41a9ff 100644 --- a/packages/shared/sdk-client/src/flag-manager/FlagUpdater.test.ts +++ b/packages/shared/sdk-client/__tests__/flag-manager/FlagUpdater.test.ts @@ -1,8 +1,8 @@ import { Context, LDLogger } from '@launchdarkly/js-sdk-common'; -import { Flag } from '../types'; -import { DefaultFlagStore } from './FlagStore'; -import FlagUpdater, { FlagsChangeCallback } from './FlagUpdater'; +import { DefaultFlagStore } from '../../src/flag-manager/FlagStore'; +import FlagUpdater, { FlagsChangeCallback } from '../../src/flag-manager/FlagUpdater'; +import { Flag } from '../../src/types'; function makeMockFlag(): Flag { // the values of the flag object itself are not relevant for these tests, the diff --git a/packages/shared/sdk-client/__tests__/inspection/InspectionManager.test.ts b/packages/shared/sdk-client/__tests__/inspection/InspectionManager.test.ts new file mode 100644 index 000000000..8871db609 --- /dev/null +++ b/packages/shared/sdk-client/__tests__/inspection/InspectionManager.test.ts @@ -0,0 +1,200 @@ +import { AsyncQueue } from 'launchdarkly-js-test-helpers'; + +import { LDLogger } from '@launchdarkly/js-sdk-common'; + +import InspectorManager from '../../src/inspection/InspectorManager'; + +describe('given an inspector manager with no registered inspectors', () => { + const logger: LDLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + const manager = new InspectorManager([], logger); + + it('does not cause errors and does not produce any logs', () => { + manager.onIdentityChanged({ kind: 'user', key: 'key' }); + manager.onFlagUsed( + 'flag-key', + { + value: null, + reason: null, + }, + { key: 'key' }, + ); + manager.onFlagsChanged({}); + manager.onFlagChanged('flag-key', { + value: null, + reason: null, + }); + + expect(logger.debug).not.toHaveBeenCalled(); + expect(logger.info).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.error).not.toHaveBeenCalled(); + }); + + it('reports that it has no inspectors', () => { + expect(manager.hasInspectors()).toBeFalsy(); + }); +}); + +describe('given an inspector with callbacks of every type', () => { + /** + * @type {AsyncQueue} + */ + const eventQueue = new AsyncQueue(); + const logger: LDLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + const manager = new InspectorManager( + [ + { + type: 'flag-used', + name: 'my-flag-used-inspector', + method: (flagKey, flagDetail, context) => { + eventQueue.add({ type: 'flag-used', flagKey, flagDetail, context }); + }, + }, + // 'flag-used registered twice. + { + type: 'flag-used', + name: 'my-other-flag-used-inspector', + method: (flagKey, flagDetail, context) => { + eventQueue.add({ type: 'flag-used', flagKey, flagDetail, context }); + }, + }, + { + type: 'flag-details-changed', + name: 'my-flag-details-inspector', + method: (details) => { + eventQueue.add({ + type: 'flag-details-changed', + details, + }); + }, + }, + { + type: 'flag-detail-changed', + name: 'my-flag-detail-inspector', + method: (flagKey, flagDetail) => { + eventQueue.add({ + type: 'flag-detail-changed', + flagKey, + flagDetail, + }); + }, + }, + { + type: 'client-identity-changed', + name: 'my-identity-inspector', + method: (context) => { + eventQueue.add({ + type: 'client-identity-changed', + context, + }); + }, + }, + // Invalid inspector shouldn't have an effect. + { + // @ts-ignore + type: 'potato', + name: 'my-potato-inspector', + method: () => {}, + }, + ], + logger, + ); + + afterEach(() => { + expect(eventQueue.length()).toEqual(0); + }); + + afterAll(() => { + eventQueue.close(); + }); + + it('logged that there was a bad inspector', () => { + expect(logger.warn).toHaveBeenCalledWith( + 'an inspector: "my-potato-inspector" of an invalid type (potato) was configured', + ); + }); + + it('executes `onFlagUsed` handlers', async () => { + manager.onFlagUsed( + 'flag-key', + { + value: 'test', + variationIndex: 1, + reason: { + kind: 'OFF', + }, + }, + { key: 'test-key' }, + ); + + const expectedEvent = { + type: 'flag-used', + flagKey: 'flag-key', + flagDetail: { + value: 'test', + variationIndex: 1, + reason: { + kind: 'OFF', + }, + }, + context: { key: 'test-key' }, + }; + const event1 = await eventQueue.take(); + expect(event1).toMatchObject(expectedEvent); + + // There are two handlers, so there should be another event. + const event2 = await eventQueue.take(); + expect(event2).toMatchObject(expectedEvent); + }); + + it('executes `onFlags` handler', async () => { + manager.onFlagsChanged({ + example: { value: 'a-value', reason: null }, + }); + + const event = await eventQueue.take(); + expect(event).toMatchObject({ + type: 'flag-details-changed', + details: { + example: { value: 'a-value' }, + }, + }); + }); + + it('executes `onFlagChanged` handler', async () => { + manager.onFlagChanged('the-flag', { value: 'a-value', reason: null }); + + const event = await eventQueue.take(); + expect(event).toMatchObject({ + type: 'flag-detail-changed', + flagKey: 'the-flag', + flagDetail: { + value: 'a-value', + }, + }); + }); + + it('executes `onIdentityChanged` handler', async () => { + manager.onIdentityChanged({ key: 'the-key' }); + + const event = await eventQueue.take(); + expect(event).toMatchObject({ + type: 'client-identity-changed', + context: { key: 'the-key' }, + }); + }); + + it('reports that it has inspectors', () => { + expect(manager.hasInspectors()).toBeTruthy(); + }); +}); diff --git a/packages/shared/sdk-client/__tests__/inspection/createSafeInspector.test.ts b/packages/shared/sdk-client/__tests__/inspection/createSafeInspector.test.ts new file mode 100644 index 000000000..d85dd1612 --- /dev/null +++ b/packages/shared/sdk-client/__tests__/inspection/createSafeInspector.test.ts @@ -0,0 +1,82 @@ +import { LDLogger } from '@launchdarkly/js-sdk-common'; + +import { LDInspectionFlagUsedHandler } from '../../src/api/LDInspection'; +import createSafeInspector from '../../src/inspection/createSafeInspector'; + +describe('given a safe inspector', () => { + const logger: LDLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + const mockInspector: LDInspectionFlagUsedHandler = { + type: 'flag-used', + name: 'the-inspector-name', + method: () => { + throw new Error('evil inspector'); + }, + }; + const safeInspector = createSafeInspector(mockInspector, logger); + + it('has the correct type', () => { + expect(safeInspector.type).toEqual('flag-used'); + }); + + it('does not allow exceptions to propagate', () => { + // @ts-ignore Calling with invalid parameters. + safeInspector.method(); + }); + + it('only logs one error', () => { + // @ts-ignore Calling with invalid parameters. + safeInspector.method(); + // @ts-ignore Calling with invalid parameters. + safeInspector.method(); + expect(logger.warn).toHaveBeenCalledWith( + 'an inspector: "the-inspector-name" of type: "flag-used" generated an exception', + ); + }); +}); + +// Type and name are required by the schema, but it should operate fine if they are not specified. +describe('given a safe inspector with no name or type', () => { + const logger: LDLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + const mockInspector = { + method: () => { + throw new Error('evil inspector'); + }, + }; + // @ts-ignore Allow registering the invalid inspector. + const safeInspector = createSafeInspector(mockInspector, logger); + + it('has undefined type', () => { + expect(safeInspector.type).toBeUndefined(); + }); + + it('has undefined name', () => { + expect(safeInspector.name).toBeUndefined(); + }); + + it('does not allow exceptions to propagate', () => { + // @ts-ignore Calling with invalid parameters. + safeInspector.method(); + }); + + it('only logs one error', () => { + // @ts-ignore Calling with invalid parameters. + safeInspector.method(); + // @ts-ignore Calling with invalid parameters. + safeInspector.method(); + expect(logger.warn).toHaveBeenCalledWith( + 'an inspector: "undefined" of type: "undefined" generated an exception', + ); + expect(logger.warn).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/shared/sdk-client/src/polling/PollingProcessot.test.ts b/packages/shared/sdk-client/__tests__/polling/PollingProcessor.test.ts similarity index 60% rename from packages/shared/sdk-client/src/polling/PollingProcessot.test.ts rename to packages/shared/sdk-client/__tests__/polling/PollingProcessor.test.ts index ce0a6a8e0..ab9956d7d 100644 --- a/packages/shared/sdk-client/src/polling/PollingProcessot.test.ts +++ b/packages/shared/sdk-client/__tests__/polling/PollingProcessor.test.ts @@ -1,16 +1,19 @@ import { waitFor } from '@testing-library/dom'; import { + Encoding, EventSource, + EventSourceCapabilities, EventSourceInitDict, - Info, - PlatformData, + LDHeaders, Requests, Response, - SdkData, + ServiceEndpoints, } from '@launchdarkly/js-sdk-common'; -import PollingProcessor, { PollingConfig } from './PollingProcessor'; +import Requestor, { makeRequestor } from '../../src/datasource/Requestor'; +import PollingProcessor from '../../src/polling/PollingProcessor'; +import { DataSourcePaths } from '../../src/streaming'; function mockResponse(value: string, statusCode: number) { const response: Response = { @@ -45,49 +48,71 @@ function makeRequests(): Requests { createEventSource(_url: string, _eventSourceInitDict: EventSourceInitDict): EventSource { throw new Error('Function not implemented.'); }, + getEventSourceCapabilities(): EventSourceCapabilities { + return { + readTimeout: false, + headers: false, + customMethod: false, + }; + }, }; } -function makeInfo(sdkData: SdkData = {}, platformData: PlatformData = {}): Info { - return { - sdkData: () => sdkData, - platformData: () => platformData, - }; -} - -function makeConfig(config?: { pollInterval?: number; useReport?: boolean }): PollingConfig { - return { - pollInterval: config?.pollInterval ?? 60 * 5, - // eslint-disable-next-line no-console - logger: { - error: jest.fn(), - warn: jest.fn(), - info: jest.fn(), - debug: jest.fn(), +const serviceEndpoints = { + events: 'mockEventsEndpoint', + polling: 'mockPollingEndpoint', + streaming: 'mockStreamingEndpoint', + diagnosticEventPath: '/diagnostic', + analyticsEventPath: '/bulk', + includeAuthorizationHeader: true, + payloadFilterKey: 'testPayloadFilterKey', +}; + +function makeTestRequestor(options: { + requests: Requests; + plainContextString?: string; + serviceEndpoints?: ServiceEndpoints; + paths?: DataSourcePaths; + encoding?: Encoding; + baseHeaders?: LDHeaders; + baseQueryParams?: { key: string; value: string }[]; + useReport?: boolean; + withReasons?: boolean; + secureModeHash?: string; +}): Requestor { + return makeRequestor( + options.plainContextString ?? 'mockContextString', + options.serviceEndpoints ?? serviceEndpoints, + options.paths ?? { + pathGet(_encoding: Encoding, _plainContextString: string): string { + return '/poll/path/get'; + }, + pathReport(_encoding: Encoding, _plainContextString: string): string { + return '/poll/path/report'; + }, + pathPing(_encoding: Encoding, _plainContextString: string): string { + return '/poll/path/ping'; + }, }, - tags: {}, - useReport: config?.useReport ?? false, - serviceEndpoints: { - streaming: '', - polling: 'http://example.example.example', - events: '', - analyticsEventPath: '', - diagnosticEventPath: '', - includeAuthorizationHeader: false, + options.requests, + options.encoding ?? { + btoa: jest.fn(), }, - }; + options.baseHeaders, + options.baseQueryParams, + options.withReasons ?? true, + options.useReport ?? false, + ); } it('makes no requests until it is started', () => { const requests = makeRequests(); // eslint-disable-next-line no-new new PollingProcessor( - 'the-sdk-key', - requests, - makeInfo(), - '/polling', - [], - makeConfig(), + makeTestRequestor({ + requests, + }), + 1, (_flags) => {}, (_error) => {}, ); @@ -95,16 +120,58 @@ it('makes no requests until it is started', () => { expect(requests.fetch).toHaveBeenCalledTimes(0); }); +it('includes custom query parameters when specified', () => { + const requests = makeRequests(); + + const polling = new PollingProcessor( + makeTestRequestor({ + requests, + baseQueryParams: [ + { key: 'custom', value: 'value' }, + { key: 'custom2', value: 'value2' }, + ], + }), + 1, + (_flags) => {}, + (_error) => {}, + ); + polling.start(); + + expect(requests.fetch).toHaveBeenCalledWith( + 'mockPollingEndpoint/poll/path/get?custom=value&custom2=value2&withReasons=true&filter=testPayloadFilterKey', + expect.anything(), + ); + polling.stop(); +}); + +it('works without any custom query parameters', () => { + const requests = makeRequests(); + + const polling = new PollingProcessor( + makeTestRequestor({ + requests, + }), + 1, + (_flags) => {}, + (_error) => {}, + ); + polling.start(); + + expect(requests.fetch).toHaveBeenCalledWith( + 'mockPollingEndpoint/poll/path/get?withReasons=true&filter=testPayloadFilterKey', + expect.anything(), + ); + polling.stop(); +}); + it('polls immediately when started', () => { const requests = makeRequests(); const polling = new PollingProcessor( - 'the-sdk-key', - requests, - makeInfo(), - '/polling', - [], - makeConfig(), + makeTestRequestor({ + requests, + }), + 1, (_flags) => {}, (_error) => {}, ); @@ -120,12 +187,10 @@ it('calls callback on success', async () => { const errorCallback = jest.fn(); const polling = new PollingProcessor( - 'the-sdk-key', - requests, - makeInfo(), - '/polling', - [], - makeConfig(), + makeTestRequestor({ + requests, + }), + 1000, dataCallback, errorCallback, ); @@ -142,12 +207,10 @@ it('polls repeatedly', async () => { requests.fetch = mockFetch('{ "flagA": true }', 200); const polling = new PollingProcessor( - 'the-sdk-key', - requests, - makeInfo(), - '/polling', - [], - makeConfig({ pollInterval: 0.1 }), + makeTestRequestor({ + requests, + }), + 0.1, dataCallback, errorCallback, ); @@ -169,17 +232,22 @@ it('stops polling when stopped', (done) => { createEventSource(_url: string, _eventSourceInitDict: EventSourceInitDict): EventSource { throw new Error('Function not implemented.'); }, + getEventSourceCapabilities(): EventSourceCapabilities { + return { + readTimeout: false, + headers: false, + customMethod: false, + }; + }, }; const dataCallback = jest.fn(); const errorCallback = jest.fn(); const polling = new PollingProcessor( - 'the-sdk-key', - requests, - makeInfo(), - '/stops', - [], - makeConfig({ pollInterval: 0.01 }), + makeTestRequestor({ + requests, + }), + 0.01, dataCallback, errorCallback, ); @@ -195,17 +263,15 @@ it('stops polling when stopped', (done) => { it('includes the correct headers on requests', () => { const requests = makeRequests(); - const polling = new PollingProcessor( - 'the-sdk-key', - requests, - makeInfo({ - userAgentBase: 'AnSDK', - version: '42', + makeTestRequestor({ + requests, + baseHeaders: { + authorization: 'the-sdk-key', + 'user-agent': 'AnSDK/42', + }, }), - '/polling', - [], - makeConfig(), + 1, (_flags) => {}, (_error) => {}, ); @@ -223,16 +289,14 @@ it('includes the correct headers on requests', () => { polling.stop(); }); -it('defaults to using the "GET" verb', () => { +it('defaults to using the "GET" method', () => { const requests = makeRequests(); const polling = new PollingProcessor( - 'the-sdk-key', - requests, - makeInfo(), - '/polling', - [], - makeConfig(), + makeTestRequestor({ + requests, + }), + 1000, (_flags) => {}, (_error) => {}, ); @@ -242,21 +306,21 @@ it('defaults to using the "GET" verb', () => { expect.anything(), expect.objectContaining({ method: 'GET', + body: undefined, }), ); polling.stop(); }); -it('can be configured to use the "REPORT" verb', () => { +it('can be configured to use the "REPORT" method', () => { const requests = makeRequests(); const polling = new PollingProcessor( - 'the-sdk-key', - requests, - makeInfo(), - '/polling', - [], - makeConfig({ useReport: true }), + makeTestRequestor({ + requests, + useReport: true, + }), + 1000, (_flags) => {}, (_error) => {}, ); @@ -266,6 +330,10 @@ it('can be configured to use the "REPORT" verb', () => { expect.anything(), expect.objectContaining({ method: 'REPORT', + headers: expect.objectContaining({ + 'content-type': 'application/json', + }), + body: 'mockContextString', }), ); polling.stop(); @@ -275,17 +343,21 @@ it('continues polling after receiving bad JSON', async () => { const requests = makeRequests(); const dataCallback = jest.fn(); const errorCallback = jest.fn(); - const config = makeConfig({ pollInterval: 0.1 }); + const logger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }; const polling = new PollingProcessor( - 'the-sdk-key', - requests, - makeInfo(), - '/polling', - [], - config, + makeTestRequestor({ + requests, + }), + 0.1, dataCallback, errorCallback, + logger, ); polling.start(); @@ -296,7 +368,7 @@ it('continues polling after receiving bad JSON', async () => { requests.fetch = mockFetch('{ham', 200); await waitFor(() => expect(requests.fetch).toHaveBeenCalled()); await waitFor(() => expect(errorCallback).toHaveBeenCalled()); - expect(config.logger.error).toHaveBeenCalledWith('Polling received invalid data'); + expect(logger.error).toHaveBeenCalledWith('Polling received invalid data'); polling.stop(); }); @@ -304,17 +376,21 @@ it('continues polling after an exception thrown during a request', async () => { const requests = makeRequests(); const dataCallback = jest.fn(); const errorCallback = jest.fn(); - const config = makeConfig({ pollInterval: 0.1 }); + const logger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }; const polling = new PollingProcessor( - 'the-sdk-key', - requests, - makeInfo(), - '/polling', - [], - config, + makeTestRequestor({ + requests, + }), + 0.1, dataCallback, errorCallback, + logger, ); polling.start(); @@ -327,7 +403,7 @@ it('continues polling after an exception thrown during a request', async () => { }); await waitFor(() => expect(requests.fetch).toHaveBeenCalled()); polling.stop(); - expect(config.logger.error).toHaveBeenCalledWith( + expect(logger.error).toHaveBeenCalledWith( 'Received I/O error (bad) for polling request - will retry', ); }); @@ -336,17 +412,21 @@ it('can handle recoverable http errors', async () => { const requests = makeRequests(); const dataCallback = jest.fn(); const errorCallback = jest.fn(); - const config = makeConfig({ pollInterval: 0.1 }); + const logger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }; const polling = new PollingProcessor( - 'the-sdk-key', - requests, - makeInfo(), - '/polling', - [], - config, + makeTestRequestor({ + requests, + }), + 0.1, dataCallback, errorCallback, + logger, ); polling.start(); @@ -357,26 +437,28 @@ it('can handle recoverable http errors', async () => { requests.fetch = mockFetch('', 408); await waitFor(() => expect(requests.fetch).toHaveBeenCalled()); polling.stop(); - expect(config.logger.error).toHaveBeenCalledWith( - 'Received error 408 for polling request - will retry', - ); + expect(logger.error).toHaveBeenCalledWith('Received error 408 for polling request - will retry'); }); it('stops polling on unrecoverable error codes', (done) => { const requests = makeRequests(); const dataCallback = jest.fn(); const errorCallback = jest.fn(); - const config = makeConfig({ pollInterval: 0.01 }); + const logger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }; const polling = new PollingProcessor( - 'the-sdk-key', - requests, - makeInfo(), - '/polling', - [], - config, + makeTestRequestor({ + requests, + }), + 0.01, dataCallback, errorCallback, + logger, ); polling.start(); @@ -385,7 +467,7 @@ it('stops polling on unrecoverable error codes', (done) => { // Polling should stop on the 401, but we need to give some time for more // polls to be done. setTimeout(() => { - expect(config.logger.error).toHaveBeenCalledWith( + expect(logger.error).toHaveBeenCalledWith( 'Received error 401 (invalid SDK key) for polling request - giving up permanently', ); expect(requests.fetch).toHaveBeenCalledTimes(1); diff --git a/packages/shared/sdk-client/__tests__/setupCrypto.ts b/packages/shared/sdk-client/__tests__/setupCrypto.ts new file mode 100644 index 000000000..fc8d0b460 --- /dev/null +++ b/packages/shared/sdk-client/__tests__/setupCrypto.ts @@ -0,0 +1,20 @@ +import { Hasher } from '@launchdarkly/js-sdk-common'; + +export const setupCrypto = () => { + let counter = 0; + const hasher = { + update: jest.fn((): Hasher => hasher), + digest: jest.fn(() => '1234567890123456'), + }; + + return { + createHash: jest.fn(() => hasher), + createHmac: jest.fn(), + randomUUID: jest.fn(() => { + counter += 1; + // Will provide a unique value for tests. + // Very much not a UUID of course. + return `${counter}`; + }), + }; +}; diff --git a/packages/shared/sdk-client/src/storage/getOrGenerateKey.test.ts b/packages/shared/sdk-client/__tests__/storage/getOrGenerateKey.test.ts similarity index 96% rename from packages/shared/sdk-client/src/storage/getOrGenerateKey.test.ts rename to packages/shared/sdk-client/__tests__/storage/getOrGenerateKey.test.ts index 94403c3e4..589f4e585 100644 --- a/packages/shared/sdk-client/src/storage/getOrGenerateKey.test.ts +++ b/packages/shared/sdk-client/__tests__/storage/getOrGenerateKey.test.ts @@ -1,7 +1,7 @@ import { Crypto, Storage } from '@launchdarkly/js-sdk-common'; -import { createBasicPlatform } from '@launchdarkly/private-js-mocks'; -import { getOrGenerateKey } from './getOrGenerateKey'; +import { getOrGenerateKey } from '../../src/storage/getOrGenerateKey'; +import { createBasicPlatform } from '../createBasicPlatform'; let mockPlatform: ReturnType; diff --git a/packages/shared/sdk-client/src/storage/namespaceUtils.test.ts b/packages/shared/sdk-client/__tests__/storage/namespaceUtils.test.ts similarity index 64% rename from packages/shared/sdk-client/src/storage/namespaceUtils.test.ts rename to packages/shared/sdk-client/__tests__/storage/namespaceUtils.test.ts index 73058eeb0..c7a642195 100644 --- a/packages/shared/sdk-client/src/storage/namespaceUtils.test.ts +++ b/packages/shared/sdk-client/__tests__/storage/namespaceUtils.test.ts @@ -1,23 +1,25 @@ -import { concatNamespacesAndValues } from './namespaceUtils'; +import { concatNamespacesAndValues } from '../../src/storage/namespaceUtils'; -const mockHash = (input: string) => `${input}Hashed`; -const noop = (input: string) => input; +const mockHash = async (input: string) => `${input}Hashed`; +const noop = async (input: string) => input; describe('concatNamespacesAndValues tests', () => { test('it handles one part', async () => { - const result = concatNamespacesAndValues([{ value: 'LaunchDarkly', transform: mockHash }]); + const result = await concatNamespacesAndValues([ + { value: 'LaunchDarkly', transform: mockHash }, + ]); expect(result).toEqual('LaunchDarklyHashed'); }); test('it handles empty parts', async () => { - const result = concatNamespacesAndValues([]); + const result = await concatNamespacesAndValues([]); expect(result).toEqual(''); }); test('it handles many parts', async () => { - const result = concatNamespacesAndValues([ + const result = await concatNamespacesAndValues([ { value: 'LaunchDarkly', transform: mockHash }, { value: 'ContextKeys', transform: mockHash }, { value: 'aKind', transform: mockHash }, @@ -27,7 +29,7 @@ describe('concatNamespacesAndValues tests', () => { }); test('it handles mixture of hashing and no hashing', async () => { - const result = concatNamespacesAndValues([ + const result = await concatNamespacesAndValues([ { value: 'LaunchDarkly', transform: mockHash }, { value: 'ContextKeys', transform: noop }, { value: 'aKind', transform: mockHash }, diff --git a/packages/shared/sdk-client/__tests__/streaming/LDClientImpl.mocks.ts b/packages/shared/sdk-client/__tests__/streaming/LDClientImpl.mocks.ts new file mode 100644 index 000000000..c8e04abf6 --- /dev/null +++ b/packages/shared/sdk-client/__tests__/streaming/LDClientImpl.mocks.ts @@ -0,0 +1,50 @@ +import { EventSource, EventSourceInitDict } from '@launchdarkly/js-sdk-common'; + +export class MockEventSource implements EventSource { + eventsByType: Map = new Map(); + + handlers: Record void> = {}; + + closed = false; + + url: string; + + options: EventSourceInitDict; + + constructor(url: string, options: EventSourceInitDict) { + this.url = url; + this.options = options; + } + + onclose: (() => void) | undefined; + + onerror: (() => void) | undefined; + + onopen: (() => void) | undefined; + + onretrying: ((e: { delayMillis: number }) => void) | undefined; + + addEventListener(type: string, listener: (event?: { data?: any }) => void): void { + this.handlers[type] = listener; + + // replay events to listener + (this.eventsByType.get(type) ?? []).forEach((event) => { + listener(event); + }); + } + + close(): void { + this.closed = true; + } + + simulateEvents(type: string, events: { data?: any }[]) { + this.eventsByType.set(type, events); + } + + simulateError(error: { status: number; message: string }) { + const shouldRetry = this.options.errorFilter(error); + if (!shouldRetry) { + this.closed = true; + } + } +} diff --git a/packages/shared/sdk-client/__tests__/streaming/StreamingProcessor.test.ts b/packages/shared/sdk-client/__tests__/streaming/StreamingProcessor.test.ts new file mode 100644 index 000000000..72f5334ac --- /dev/null +++ b/packages/shared/sdk-client/__tests__/streaming/StreamingProcessor.test.ts @@ -0,0 +1,634 @@ +import { + DataSourceErrorKind, + defaultHeaders, + Encoding, + EventName, + Info, + internal, + LDHeaders, + LDLogger, + LDStreamingError, + Platform, + ProcessStreamResponse, + Requests, + ServiceEndpoints, +} from '@launchdarkly/js-sdk-common'; + +import Requestor, { makeRequestor } from '../../src/datasource/Requestor'; +import { + DataSourcePaths, + StreamingDataSourceConfig, + StreamingProcessor, +} from '../../src/streaming'; +import { createBasicPlatform } from '../createBasicPlatform'; + +let logger: LDLogger; + +const serviceEndpoints = { + events: '', + polling: '', + streaming: 'https://mockstream.ld.com', + diagnosticEventPath: '/diagnostic', + analyticsEventPath: '/bulk', + includeAuthorizationHeader: true, + payloadFilterKey: 'testPayloadFilterKey', +}; + +const dateNowString = '2023-08-10'; +const sdkKey = 'my-sdk-key'; + +const flagData = { + flags: { + flagkey: { key: 'flagkey', version: 1 }, + }, + segments: { + segkey: { key: 'segkey', version: 2 }, + }, +}; +const event = { + data: flagData, +}; + +let basicPlatform: Platform; + +function getStreamingDataSourceConfig( + withReasons: boolean = false, + useReport: boolean = false, + queryParameters?: [{ key: string; value: string }], +): StreamingDataSourceConfig { + return { + credential: sdkKey, + // eslint-disable-next-line object-shorthand + serviceEndpoints: serviceEndpoints, + paths: { + pathGet(_encoding: Encoding, _plainContextString: string): string { + return '/stream/path/get'; + }, + pathReport(_encoding: Encoding, _plainContextString: string): string { + return '/stream/path/report'; + }, + pathPing(_encoding: Encoding, _plainContextString: string): string { + return '/stream/path/ping'; + }, + }, + baseHeaders: { + authorization: 'my-sdk-key', + 'user-agent': 'TestUserAgent/2.0.2', + 'x-launchdarkly-wrapper': 'Rapper/1.2.3', + }, + initialRetryDelayMillis: 1000, + withReasons, + useReport, + queryParameters, + }; +} + +beforeEach(() => { + basicPlatform = createBasicPlatform(); + logger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }; +}); + +const createMockEventSource = (streamUri: string = '', options: any = {}) => ({ + streamUri, + options, + onclose: jest.fn(), + addEventListener: jest.fn(), + close: jest.fn(), +}); + +function makeTestRequestor(options: { + requests: Requests; + plainContextString?: string; + serviceEndpoints?: ServiceEndpoints; + paths?: DataSourcePaths; + encoding?: Encoding; + baseHeaders?: LDHeaders; + baseQueryParams?: { key: string; value: string }[]; + useReport?: boolean; + withReasons?: boolean; + secureModeHash?: string; +}): Requestor { + return makeRequestor( + options.plainContextString ?? 'mockContextString', + options.serviceEndpoints ?? serviceEndpoints, + options.paths ?? { + pathGet(_encoding: Encoding, _plainContextString: string): string { + return '/polling/path/get'; + }, + pathReport(_encoding: Encoding, _plainContextString: string): string { + return '/polling/path/report'; + }, + pathPing(_encoding: Encoding, _plainContextString: string): string { + return '/polling/path/ping'; + }, + }, + options.requests, + options.encoding ?? { + btoa: jest.fn(), + }, + options.baseHeaders, + options.baseQueryParams, + options.withReasons ?? true, + options.useReport ?? false, + ); +} + +describe('given a stream processor', () => { + let info: Info; + let streamingProcessor: StreamingProcessor; + let diagnosticsManager: internal.DiagnosticsManager; + let listeners: Map; + let mockEventSource: any; + let mockListener: ProcessStreamResponse; + let mockErrorHandler: jest.Mock; + let simulatePutEvent: (e?: any) => void; + let simulatePingEvent: () => void; + let simulateError: (e: { status: number; message: string }) => boolean; + + beforeAll(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date(dateNowString)); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + beforeEach(() => { + mockErrorHandler = jest.fn(); + + info = basicPlatform.info; + + basicPlatform.requests = { + createEventSource: jest.fn((streamUri: string, options: any) => { + mockEventSource = createMockEventSource(streamUri, options); + return mockEventSource; + }), + getEventSourceCapabilities: jest.fn(() => ({ + readTimeout: true, + headers: true, + customMethod: true, + })), + fetch: jest.fn(), + } as any; + simulatePutEvent = (e: any = event) => { + mockEventSource.addEventListener.mock.calls[0][1](e); // put listener is at position 0 + }; + simulatePingEvent = () => { + mockEventSource.addEventListener.mock.calls[2][1](); // ping listener is at position 2 + }; + simulateError = (e: { status: number; message: string }): boolean => + mockEventSource.options.errorFilter(e); + + listeners = new Map(); + mockListener = { + deserializeData: jest.fn((data) => data), + processJson: jest.fn(), + }; + listeners.set('put', mockListener); + listeners.set('patch', mockListener); + + diagnosticsManager = new internal.DiagnosticsManager(sdkKey, basicPlatform, {}); + }); + + afterEach(() => { + streamingProcessor.close(); + jest.resetAllMocks(); + }); + + it('uses expected uri and eventSource init args', () => { + streamingProcessor = new StreamingProcessor( + 'mockContextString', + getStreamingDataSourceConfig(), + listeners, + basicPlatform.requests, + basicPlatform.encoding!, + makeTestRequestor({ + requests: basicPlatform.requests, + }), + diagnosticsManager, + mockErrorHandler, + logger, + ); + streamingProcessor.start(); + + expect(basicPlatform.requests.createEventSource).toBeCalledWith( + `${serviceEndpoints.streaming}/stream/path/get?filter=testPayloadFilterKey`, + { + errorFilter: expect.any(Function), + headers: defaultHeaders(sdkKey, info, undefined), + initialRetryDelayMillis: 1000, + readTimeoutMillis: 300000, + retryResetIntervalMillis: 60000, + }, + ); + }); + + it('sets streamInitialReconnectDelay correctly', () => { + streamingProcessor = new StreamingProcessor( + 'mockContextString', + getStreamingDataSourceConfig(), + listeners, + basicPlatform.requests, + basicPlatform.encoding!, + makeTestRequestor({ + requests: basicPlatform.requests, + }), + diagnosticsManager, + mockErrorHandler, + ); + streamingProcessor.start(); + + expect(basicPlatform.requests.createEventSource).toHaveBeenLastCalledWith( + `${serviceEndpoints.streaming}/stream/path/get?filter=testPayloadFilterKey`, + { + errorFilter: expect.any(Function), + headers: defaultHeaders(sdkKey, info, undefined), + initialRetryDelayMillis: 1000, + readTimeoutMillis: 300000, + retryResetIntervalMillis: 60000, + }, + ); + }); + + it('uses the report path and modifies init dict when useReport is true ', () => { + streamingProcessor = new StreamingProcessor( + 'mockContextString', + getStreamingDataSourceConfig(true, true), + listeners, + basicPlatform.requests, + basicPlatform.encoding!, + makeTestRequestor({ + requests: basicPlatform.requests, + }), + diagnosticsManager, + mockErrorHandler, + ); + streamingProcessor.start(); + + expect(basicPlatform.requests.createEventSource).toHaveBeenLastCalledWith( + `${serviceEndpoints.streaming}/stream/path/report?withReasons=true&filter=testPayloadFilterKey`, + expect.objectContaining({ + method: 'REPORT', + body: 'mockContextString', + errorFilter: expect.any(Function), + headers: expect.objectContaining({ 'content-type': 'application/json' }), + initialRetryDelayMillis: 1000, + readTimeoutMillis: 300000, + retryResetIntervalMillis: 60000, + }), + ); + }); + + it('withReasons and payload filter coexist', () => { + streamingProcessor = new StreamingProcessor( + 'mockContextString', + getStreamingDataSourceConfig(true, false), + listeners, + basicPlatform.requests, + basicPlatform.encoding!, + makeTestRequestor({ + requests: basicPlatform.requests, + }), + diagnosticsManager, + mockErrorHandler, + ); + streamingProcessor.start(); + + expect(basicPlatform.requests.createEventSource).toHaveBeenLastCalledWith( + `${serviceEndpoints.streaming}/stream/path/get?withReasons=true&filter=testPayloadFilterKey`, + { + errorFilter: expect.any(Function), + headers: defaultHeaders(sdkKey, info, undefined), + initialRetryDelayMillis: 1000, + readTimeoutMillis: 300000, + retryResetIntervalMillis: 60000, + }, + ); + }); + + it('adds listeners', () => { + streamingProcessor = new StreamingProcessor( + 'mockContextString', + getStreamingDataSourceConfig(), + listeners, + basicPlatform.requests, + basicPlatform.encoding!, + makeTestRequestor({ + requests: basicPlatform.requests, + }), + diagnosticsManager, + mockErrorHandler, + logger, + ); + streamingProcessor.start(); + + expect(mockEventSource.addEventListener).toHaveBeenNthCalledWith( + 1, + 'put', + expect.any(Function), + ); + expect(mockEventSource.addEventListener).toHaveBeenNthCalledWith( + 2, + 'patch', + expect.any(Function), + ); + }); + + it('executes listeners', () => { + streamingProcessor = new StreamingProcessor( + 'mockContextString', + getStreamingDataSourceConfig(), + listeners, + basicPlatform.requests, + basicPlatform.encoding!, + makeTestRequestor({ + requests: basicPlatform.requests, + }), + diagnosticsManager, + mockErrorHandler, + logger, + ); + streamingProcessor.start(); + + simulatePutEvent(); + const patchHandler = mockEventSource.addEventListener.mock.calls[1][1]; + patchHandler(event); + + expect(mockListener.deserializeData).toBeCalledTimes(2); + expect(mockListener.processJson).toBeCalledTimes(2); + }); + + it('passes error to callback if json data is malformed', async () => { + streamingProcessor = new StreamingProcessor( + 'mockContextString', + getStreamingDataSourceConfig(), + listeners, + basicPlatform.requests, + basicPlatform.encoding!, + makeTestRequestor({ + requests: basicPlatform.requests, + }), + diagnosticsManager, + mockErrorHandler, + logger, + ); + streamingProcessor.start(); + + (mockListener.deserializeData as jest.Mock).mockReturnValue(false); + simulatePutEvent(); + + expect(logger.error).toBeCalledWith(expect.stringMatching(/invalid data in "put"/)); + expect(logger.debug).toBeCalledWith(expect.stringMatching(/invalid json/i)); + expect(mockErrorHandler.mock.lastCall[0].message).toMatch(/malformed json/i); + }); + + it('calls error handler if event.data prop is missing', async () => { + streamingProcessor = new StreamingProcessor( + 'mockContextString', + getStreamingDataSourceConfig(), + listeners, + basicPlatform.requests, + basicPlatform.encoding!, + makeTestRequestor({ + requests: basicPlatform.requests, + }), + diagnosticsManager, + mockErrorHandler, + logger, + ); + streamingProcessor.start(); + + simulatePutEvent({ flags: {} }); + + expect(mockListener.deserializeData).not.toBeCalled(); + expect(mockListener.processJson).not.toBeCalled(); + expect(mockErrorHandler.mock.lastCall[0].message).toMatch(/unexpected payload/i); + }); + + it('closes and stops', async () => { + streamingProcessor = new StreamingProcessor( + 'mockContextString', + getStreamingDataSourceConfig(), + listeners, + basicPlatform.requests, + basicPlatform.encoding!, + makeTestRequestor({ + requests: basicPlatform.requests, + }), + diagnosticsManager, + mockErrorHandler, + logger, + ); + + jest.spyOn(streamingProcessor, 'stop'); + streamingProcessor.start(); + streamingProcessor.close(); + + expect(streamingProcessor.stop).toBeCalled(); + expect(mockEventSource.close).toBeCalled(); + // @ts-ignore + expect(streamingProcessor.eventSource).toBeUndefined(); + }); + + it('creates a stream init event', async () => { + streamingProcessor = new StreamingProcessor( + 'mockContextString', + getStreamingDataSourceConfig(), + listeners, + basicPlatform.requests, + basicPlatform.encoding!, + makeTestRequestor({ + requests: basicPlatform.requests, + }), + diagnosticsManager, + mockErrorHandler, + logger, + ); + streamingProcessor.start(); + + const startTime = Date.now(); + simulatePutEvent(); + + const diagnosticEvent = diagnosticsManager.createStatsEventAndReset(0, 0, 0); + expect(diagnosticEvent.streamInits.length).toEqual(1); + const si = diagnosticEvent.streamInits[0]; + expect(si.timestamp).toEqual(startTime); + expect(si.failed).toBeFalsy(); + expect(si.durationMillis).toBeGreaterThanOrEqual(0); + }); + + describe.each([400, 408, 429, 500, 503])('given recoverable http errors', (status) => { + it(`continues retrying after error: ${status}`, () => { + streamingProcessor = new StreamingProcessor( + 'mockContextString', + getStreamingDataSourceConfig(), + listeners, + basicPlatform.requests, + basicPlatform.encoding!, + makeTestRequestor({ + requests: basicPlatform.requests, + }), + diagnosticsManager, + mockErrorHandler, + logger, + ); + streamingProcessor.start(); + + const startTime = Date.now(); + const testError = { status, message: 'retry. recoverable.' }; + const willRetry = simulateError(testError); + + expect(willRetry).toBeTruthy(); + expect(mockErrorHandler).not.toBeCalled(); + expect(logger.warn).toBeCalledWith( + expect.stringMatching(new RegExp(`${status}.*will retry`)), + ); + + const diagnosticEvent = diagnosticsManager.createStatsEventAndReset(0, 0, 0); + expect(diagnosticEvent.streamInits.length).toEqual(1); + const si = diagnosticEvent.streamInits[0]; + expect(si.timestamp).toEqual(startTime); + expect(si.failed).toBeTruthy(); + expect(si.durationMillis).toBeGreaterThanOrEqual(0); + }); + }); + + describe.each([401, 403])('given irrecoverable http errors', (status) => { + it(`stops retrying after error: ${status}`, () => { + streamingProcessor = new StreamingProcessor( + 'mockContextString', + getStreamingDataSourceConfig(), + listeners, + basicPlatform.requests, + basicPlatform.encoding!, + makeTestRequestor({ + requests: basicPlatform.requests, + }), + diagnosticsManager, + mockErrorHandler, + logger, + ); + streamingProcessor.start(); + + const startTime = Date.now(); + const testError = { status, message: 'stopping. irrecoverable.' }; + const willRetry = simulateError(testError); + + expect(willRetry).toBeFalsy(); + expect(mockErrorHandler).toBeCalledWith( + new LDStreamingError(DataSourceErrorKind.Unknown, testError.message, testError.status), + ); + expect(logger.error).toBeCalledWith( + expect.stringMatching(new RegExp(`${status}.*permanently`)), + ); + + const diagnosticEvent = diagnosticsManager.createStatsEventAndReset(0, 0, 0); + expect(diagnosticEvent.streamInits.length).toEqual(1); + const si = diagnosticEvent.streamInits[0]; + expect(si.timestamp).toEqual(startTime); + expect(si.failed).toBeTruthy(); + expect(si.durationMillis).toBeGreaterThanOrEqual(0); + }); + }); + + it('it uses ping stream and polling when use REPORT and eventsource lacks custom method support', async () => { + basicPlatform.requests.getEventSourceCapabilities = jest.fn(() => ({ + readTimeout: true, + headers: true, + customMethod: false, // simulating event source does not support REPORT + })); + + basicPlatform.requests.fetch = jest.fn().mockResolvedValue({ + headers: jest.doMock, + status: 200, + text: jest.fn().mockResolvedValue(JSON.stringify(flagData)), + }); + + streamingProcessor = new StreamingProcessor( + 'mockContextString', + getStreamingDataSourceConfig(true, true), // use report to true + listeners, + basicPlatform.requests, + basicPlatform.encoding!, + makeTestRequestor({ + requests: basicPlatform.requests, + useReport: true, + }), + diagnosticsManager, + mockErrorHandler, + ); + streamingProcessor.start(); + + simulatePingEvent(); + + expect(basicPlatform.requests.createEventSource).toHaveBeenLastCalledWith( + `${serviceEndpoints.streaming}/stream/path/ping?withReasons=true&filter=testPayloadFilterKey`, + expect.anything(), + ); + + expect(basicPlatform.requests.fetch).toHaveBeenCalledWith( + '/polling/path/report?withReasons=true&filter=testPayloadFilterKey', + expect.objectContaining({ + method: 'REPORT', + body: 'mockContextString', + }), + ); + }); +}); + +it('includes custom query parameters', () => { + const { info } = basicPlatform; + const listeners = new Map(); + const mockListener = { + deserializeData: jest.fn((data) => data), + processJson: jest.fn(), + }; + listeners.set('put', mockListener); + listeners.set('patch', mockListener); + const diagnosticsManager = new internal.DiagnosticsManager(sdkKey, basicPlatform, {}); + + basicPlatform.requests = { + createEventSource: jest.fn((streamUri: string, options: any) => { + const mockEventSource = createMockEventSource(streamUri, options); + return mockEventSource; + }), + getEventSourceCapabilities: jest.fn(() => ({ + readTimeout: true, + headers: true, + customMethod: true, + })), + } as any; + + const streamingProcessor = new StreamingProcessor( + 'mockContextString', + getStreamingDataSourceConfig(undefined, undefined, [{ key: 'custom', value: 'value' }]), + listeners, + basicPlatform.requests, + basicPlatform.encoding!, + makeTestRequestor({ + requests: basicPlatform.requests, + }), + diagnosticsManager, + () => {}, + logger, + ); + + streamingProcessor.start(); + + expect(basicPlatform.requests.createEventSource).toHaveBeenCalledWith( + `${serviceEndpoints.streaming}/stream/path/get?custom=value&filter=testPayloadFilterKey`, + { + errorFilter: expect.any(Function), + headers: defaultHeaders(sdkKey, info, undefined), + initialRetryDelayMillis: 1000, + readTimeoutMillis: 300000, + retryResetIntervalMillis: 60000, + }, + ); +}); diff --git a/packages/shared/sdk-client/__tests__/streaming/index.ts b/packages/shared/sdk-client/__tests__/streaming/index.ts new file mode 100644 index 000000000..a29d015e8 --- /dev/null +++ b/packages/shared/sdk-client/__tests__/streaming/index.ts @@ -0,0 +1 @@ +export { MockEventSource } from './LDClientImpl.mocks'; diff --git a/packages/shared/sdk-client/package.json b/packages/shared/sdk-client/package.json index cb562ff9d..28f75f9f0 100644 --- a/packages/shared/sdk-client/package.json +++ b/packages/shared/sdk-client/package.json @@ -1,9 +1,9 @@ { "name": "@launchdarkly/js-client-sdk-common", - "version": "1.6.0", - "type": "commonjs", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", + "version": "1.11.0", + "type": "module", + "main": "./dist/esm/index.mjs", + "types": "./dist/esm/index.d.ts", "homepage": "https://github.com/launchdarkly/js-core/tree/main/packages/shared/sdk-client", "repository": { "type": "git", @@ -18,11 +18,24 @@ "analytics", "client" ], + "exports": { + "require": { + "types": "./dist/cjs/index.d.ts", + "default": "./dist/cjs/index.cjs" + }, + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.mjs" + } + }, "scripts": { "doc": "../../../scripts/build-doc.sh .", "test": "npx jest --ci", - "build": "npx tsc", - "clean": "npx tsc --build --clean", + "make-cjs-package-json": "echo '{\"type\":\"commonjs\"}' > dist/cjs/package.json", + "make-esm-package-json": "echo '{\"type\":\"module\"}' > dist/esm/package.json", + "make-package-jsons": "npm run make-cjs-package-json && npm run make-esm-package-json", + "build": "npx tsc --noEmit && rollup -c rollup.config.js && npm run make-package-jsons", + "clean": "rimraf dist", "lint": "npx eslint . --ext .ts", "lint:fix": "yarn run lint -- --fix", "prettier": "prettier --write 'src/*.@(js|ts|tsx|json)'", @@ -30,10 +43,14 @@ }, "license": "Apache-2.0", "dependencies": { - "@launchdarkly/js-sdk-common": "2.7.0" + "@launchdarkly/js-sdk-common": "2.11.0" }, "devDependencies": { - "@launchdarkly/private-js-mocks": "0.0.1", + "@rollup/plugin-commonjs": "^25.0.0", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^15.0.2", + "@rollup/plugin-terser": "^0.4.3", + "@rollup/plugin-typescript": "^11.1.1", "@testing-library/dom": "^9.3.1", "@testing-library/jest-dom": "^5.16.5", "@types/jest": "^29.5.3", @@ -52,6 +69,8 @@ "jest-environment-jsdom": "^29.6.1", "launchdarkly-js-test-helpers": "^2.2.0", "prettier": "^3.0.0", + "rimraf": "6.0.1", + "rollup": "^3.23.0", "ts-jest": "^29.1.1", "typedoc": "0.25.0", "typescript": "5.1.6" diff --git a/packages/shared/sdk-client/rollup.config.js b/packages/shared/sdk-client/rollup.config.js new file mode 100644 index 000000000..81b9cde6e --- /dev/null +++ b/packages/shared/sdk-client/rollup.config.js @@ -0,0 +1,43 @@ +import common from '@rollup/plugin-commonjs'; +import json from '@rollup/plugin-json'; +import resolve from '@rollup/plugin-node-resolve'; +import typescript from '@rollup/plugin-typescript'; + +// This library is not minified as the final SDK package is responsible for minification. + +const getSharedConfig = (format, file) => ({ + input: 'src/index.ts', + // Intermediate modules don't bundle all dependencies. We leave that to leaf-node + // SDK implementations. + external: ['@launchdarkly/js-sdk-common'], + output: [ + { + format: format, + sourcemap: true, + file: file, + }, + ], +}); + +export default [ + { + ...getSharedConfig('es', 'dist/esm/index.mjs'), + plugins: [ + typescript({ + module: 'esnext', + tsconfig: './tsconfig.json', + outputToFilesystem: true, + }), + common({ + transformMixedEsModules: true, + esmExternals: true, + }), + resolve(), + json(), + ], + }, + { + ...getSharedConfig('cjs', 'dist/cjs/index.cjs'), + plugins: [typescript({ tsconfig: './tsconfig.json', outputToFilesystem: true, }), common(), resolve(), json()], + }, +]; diff --git a/packages/shared/sdk-client/src/DataManager.ts b/packages/shared/sdk-client/src/DataManager.ts new file mode 100644 index 000000000..d3b33de1b --- /dev/null +++ b/packages/shared/sdk-client/src/DataManager.ts @@ -0,0 +1,224 @@ +import { + Context, + EventName, + internal, + LDContext, + LDHeaders, + LDLogger, + Platform, + ProcessStreamResponse, + subsystem, +} from '@launchdarkly/js-sdk-common'; + +import { LDIdentifyOptions } from './api/LDIdentifyOptions'; +import { Configuration } from './configuration/Configuration'; +import DataSourceEventHandler from './datasource/DataSourceEventHandler'; +import { DataSourceState } from './datasource/DataSourceStatus'; +import DataSourceStatusManager from './datasource/DataSourceStatusManager'; +import Requestor from './datasource/Requestor'; +import { FlagManager } from './flag-manager/FlagManager'; +import LDEmitter from './LDEmitter'; +import PollingProcessor from './polling/PollingProcessor'; +import { DataSourcePaths, StreamingProcessor } from './streaming'; +import { DeleteFlag, Flags, PatchFlag } from './types'; + +export interface DataManager { + /** + * This function handles the data management aspects of the identification process. + * + * Implementation Note: The identifyResolve and identifyReject function resolve or reject the + * identify function at LDClient level. It is likely in individual implementations that these + * functions will be passed to other components, such as a datasource, do indicate when the + * identify process has been completed. The data manager identify function should return once + * everything has been set in motion to complete the identification process. + * + * @param identifyResolve Called to reject the identify operation. + * @param identifyReject Called to complete the identify operation. + * @param context The context being identified. + * @param identifyOptions Options for identification. + */ + identify( + identifyResolve: () => void, + identifyReject: (err: Error) => void, + context: Context, + identifyOptions?: LDIdentifyOptions, + ): Promise; +} + +/** + * Factory interface for constructing data managers. + */ +export interface DataManagerFactory { + ( + flagManager: FlagManager, + configuration: Configuration, + baseHeaders: LDHeaders, + emitter: LDEmitter, + diagnosticsManager?: internal.DiagnosticsManager, + ): DataManager; +} + +export interface ConnectionParams { + queryParameters?: { key: string; value: string }[]; +} + +export abstract class BaseDataManager implements DataManager { + protected updateProcessor?: subsystem.LDStreamProcessor; + protected readonly logger: LDLogger; + protected context?: Context; + private _connectionParams?: ConnectionParams; + protected readonly dataSourceStatusManager: DataSourceStatusManager; + private readonly _dataSourceEventHandler: DataSourceEventHandler; + + constructor( + protected readonly platform: Platform, + protected readonly flagManager: FlagManager, + protected readonly credential: string, + protected readonly config: Configuration, + protected readonly getPollingPaths: () => DataSourcePaths, + protected readonly getStreamingPaths: () => DataSourcePaths, + protected readonly baseHeaders: LDHeaders, + protected readonly emitter: LDEmitter, + protected readonly diagnosticsManager?: internal.DiagnosticsManager, + ) { + this.logger = config.logger; + this.dataSourceStatusManager = new DataSourceStatusManager(emitter); + this._dataSourceEventHandler = new DataSourceEventHandler( + flagManager, + this.dataSourceStatusManager, + this.config.logger, + ); + } + + /** + * Set additional connection parameters for requests polling/streaming. + */ + protected setConnectionParams(connectionParams?: ConnectionParams) { + this._connectionParams = connectionParams; + } + + abstract identify( + identifyResolve: () => void, + identifyReject: (err: Error) => void, + context: Context, + identifyOptions?: LDIdentifyOptions, + ): Promise; + + protected createPollingProcessor( + context: LDContext, + checkedContext: Context, + requestor: Requestor, + identifyResolve?: () => void, + identifyReject?: (err: Error) => void, + ) { + const processor = new PollingProcessor( + requestor, + this.config.pollInterval, + async (flags) => { + await this._dataSourceEventHandler.handlePut(checkedContext, flags); + identifyResolve?.(); + }, + (err) => { + this.emitter.emit('error', context, err); + this._dataSourceEventHandler.handlePollingError(err); + identifyReject?.(err); + }, + this.logger, + ); + + this.updateProcessor = this._decorateProcessorWithStatusReporting( + processor, + this.dataSourceStatusManager, + ); + } + + protected createStreamingProcessor( + context: LDContext, + checkedContext: Context, + pollingRequestor: Requestor, + identifyResolve?: () => void, + identifyReject?: (err: Error) => void, + ) { + const processor = new StreamingProcessor( + JSON.stringify(context), + { + credential: this.credential, + serviceEndpoints: this.config.serviceEndpoints, + paths: this.getStreamingPaths(), + baseHeaders: this.baseHeaders, + initialRetryDelayMillis: this.config.streamInitialReconnectDelay * 1000, + withReasons: this.config.withReasons, + useReport: this.config.useReport, + queryParameters: this._connectionParams?.queryParameters, + }, + this.createStreamListeners(checkedContext, identifyResolve), + this.platform.requests, + this.platform.encoding!, + pollingRequestor, + this.diagnosticsManager, + (e) => { + this.emitter.emit('error', context, e); + this._dataSourceEventHandler.handleStreamingError(e); + identifyReject?.(e); + }, + this.logger, + ); + + this.updateProcessor = this._decorateProcessorWithStatusReporting( + processor, + this.dataSourceStatusManager, + ); + } + + protected createStreamListeners( + context: Context, + identifyResolve?: () => void, + ): Map { + const listeners = new Map(); + + listeners.set('put', { + deserializeData: JSON.parse, + processJson: async (flags: Flags) => { + await this._dataSourceEventHandler.handlePut(context, flags); + identifyResolve?.(); + }, + }); + + listeners.set('patch', { + deserializeData: JSON.parse, + processJson: async (patchFlag: PatchFlag) => { + this._dataSourceEventHandler.handlePatch(context, patchFlag); + }, + }); + + listeners.set('delete', { + deserializeData: JSON.parse, + processJson: async (deleteFlag: DeleteFlag) => { + this._dataSourceEventHandler.handleDelete(context, deleteFlag); + }, + }); + + return listeners; + } + + private _decorateProcessorWithStatusReporting( + processor: subsystem.LDStreamProcessor, + statusManager: DataSourceStatusManager, + ): subsystem.LDStreamProcessor { + return { + start: () => { + // update status before starting processor to ensure potential errors are reported after initializing + statusManager.requestStateUpdate(DataSourceState.Initializing); + processor.start(); + }, + stop: () => { + processor.stop(); + statusManager.requestStateUpdate(DataSourceState.Closed); + }, + close: () => { + processor.close(); + statusManager.requestStateUpdate(DataSourceState.Closed); + }, + }; + } +} diff --git a/packages/shared/sdk-client/src/HookRunner.ts b/packages/shared/sdk-client/src/HookRunner.ts new file mode 100644 index 000000000..8380bfba1 --- /dev/null +++ b/packages/shared/sdk-client/src/HookRunner.ts @@ -0,0 +1,167 @@ +import { LDContext, LDLogger } from '@launchdarkly/js-sdk-common'; + +import { + EvaluationSeriesContext, + EvaluationSeriesData, + Hook, + IdentifySeriesContext, + IdentifySeriesData, + IdentifySeriesResult, +} from './api/integrations/Hooks'; +import { LDEvaluationDetail } from './api/LDEvaluationDetail'; + +const UNKNOWN_HOOK_NAME = 'unknown hook'; +const BEFORE_EVALUATION_STAGE_NAME = 'beforeEvaluation'; +const AFTER_EVALUATION_STAGE_NAME = 'afterEvaluation'; + +function tryExecuteStage( + logger: LDLogger, + method: string, + hookName: string, + stage: () => TData, + def: TData, +): TData { + try { + return stage(); + } catch (err) { + logger?.error(`An error was encountered in "${method}" of the "${hookName}" hook: ${err}`); + return def; + } +} + +function getHookName(logger: LDLogger, hook: Hook): string { + try { + return hook.getMetadata().name || UNKNOWN_HOOK_NAME; + } catch { + logger.error(`Exception thrown getting metadata for hook. Unable to get hook name.`); + return UNKNOWN_HOOK_NAME; + } +} + +function executeBeforeEvaluation( + logger: LDLogger, + hooks: Hook[], + hookContext: EvaluationSeriesContext, +): EvaluationSeriesData[] { + return hooks.map((hook) => + tryExecuteStage( + logger, + BEFORE_EVALUATION_STAGE_NAME, + getHookName(logger, hook), + () => hook?.beforeEvaluation?.(hookContext, {}) ?? {}, + {}, + ), + ); +} + +function executeAfterEvaluation( + logger: LDLogger, + hooks: Hook[], + hookContext: EvaluationSeriesContext, + updatedData: EvaluationSeriesData[], + result: LDEvaluationDetail, +) { + // This iterates in reverse, versus reversing a shallow copy of the hooks, + // for efficiency. + for (let hookIndex = hooks.length - 1; hookIndex >= 0; hookIndex -= 1) { + const hook = hooks[hookIndex]; + const data = updatedData[hookIndex]; + tryExecuteStage( + logger, + AFTER_EVALUATION_STAGE_NAME, + getHookName(logger, hook), + () => hook?.afterEvaluation?.(hookContext, data, result) ?? {}, + {}, + ); + } +} + +function executeBeforeIdentify( + logger: LDLogger, + hooks: Hook[], + hookContext: IdentifySeriesContext, +): IdentifySeriesData[] { + return hooks.map((hook) => + tryExecuteStage( + logger, + BEFORE_EVALUATION_STAGE_NAME, + getHookName(logger, hook), + () => hook?.beforeIdentify?.(hookContext, {}) ?? {}, + {}, + ), + ); +} + +function executeAfterIdentify( + logger: LDLogger, + hooks: Hook[], + hookContext: IdentifySeriesContext, + updatedData: IdentifySeriesData[], + result: IdentifySeriesResult, +) { + // This iterates in reverse, versus reversing a shallow copy of the hooks, + // for efficiency. + for (let hookIndex = hooks.length - 1; hookIndex >= 0; hookIndex -= 1) { + const hook = hooks[hookIndex]; + const data = updatedData[hookIndex]; + tryExecuteStage( + logger, + AFTER_EVALUATION_STAGE_NAME, + getHookName(logger, hook), + () => hook?.afterIdentify?.(hookContext, data, result) ?? {}, + {}, + ); + } +} + +export default class HookRunner { + private readonly _hooks: Hook[] = []; + + constructor( + private readonly _logger: LDLogger, + initialHooks: Hook[], + ) { + this._hooks.push(...initialHooks); + } + + withEvaluation( + key: string, + context: LDContext | undefined, + defaultValue: unknown, + method: () => LDEvaluationDetail, + ): LDEvaluationDetail { + if (this._hooks.length === 0) { + return method(); + } + const hooks: Hook[] = [...this._hooks]; + const hookContext: EvaluationSeriesContext = { + flagKey: key, + context, + defaultValue, + }; + + const hookData = executeBeforeEvaluation(this._logger, hooks, hookContext); + const result = method(); + executeAfterEvaluation(this._logger, hooks, hookContext, hookData, result); + return result; + } + + identify( + context: LDContext, + timeout: number | undefined, + ): (result: IdentifySeriesResult) => void { + const hooks: Hook[] = [...this._hooks]; + const hookContext: IdentifySeriesContext = { + context, + timeout, + }; + const hookData = executeBeforeIdentify(this._logger, hooks, hookContext); + return (result) => { + executeAfterIdentify(this._logger, hooks, hookContext, hookData, result); + }; + } + + addHook(hook: Hook): void { + this._hooks.push(hook); + } +} diff --git a/packages/shared/sdk-client/src/LDClientImpl.storage.test.ts b/packages/shared/sdk-client/src/LDClientImpl.storage.test.ts deleted file mode 100644 index 4b25031c6..000000000 --- a/packages/shared/sdk-client/src/LDClientImpl.storage.test.ts +++ /dev/null @@ -1,545 +0,0 @@ -import { AutoEnvAttributes, clone, type LDContext, noop } from '@launchdarkly/js-sdk-common'; -import { - createBasicPlatform, - createLogger, - setupMockStreamingProcessor, -} from '@launchdarkly/private-js-mocks'; - -import { toMulti } from './context/addAutoEnv'; -import * as mockResponseJson from './evaluation/mockResponse.json'; -import LDClientImpl from './LDClientImpl'; -import LDEmitter from './LDEmitter'; -import { DeleteFlag, Flags, PatchFlag } from './types'; - -let mockPlatform: ReturnType; -let logger: ReturnType; - -beforeEach(() => { - mockPlatform = createBasicPlatform(); - logger = createLogger(); -}); - -jest.mock('@launchdarkly/js-sdk-common', () => { - const actual = jest.requireActual('@launchdarkly/js-sdk-common'); - const { MockStreamingProcessor } = jest.requireActual('@launchdarkly/private-js-mocks'); - return { - ...actual, - ...{ - internal: { - ...actual.internal, - StreamingProcessor: MockStreamingProcessor, - }, - }, - }; -}); - -const testSdkKey = 'test-sdk-key'; -const context: LDContext = { kind: 'org', key: 'Testy Pizza' }; -const flagStorageKey = 'LaunchDarkly_1234567890123456_1234567890123456'; -const indexStorageKey = 'LaunchDarkly_1234567890123456_ContextIndex'; -let ldc: LDClientImpl; -let emitter: LDEmitter; -let defaultPutResponse: Flags; -let defaultFlagKeys: string[]; - -// Promisify on.change listener so we can await it in tests. -const onChangePromise = () => - new Promise((res) => { - ldc.on('change', (_context: LDContext, changes: string[]) => { - res(changes); - }); - }); - -// Common setup code for all tests -// 1. Sets up streaming -// 2. Sets up the change listener -// 3. Runs identify -// 4. Get all flags -const identifyGetAllFlags = async ( - shouldError: boolean = false, - putResponse = defaultPutResponse, - patchResponse?: PatchFlag, - deleteResponse?: DeleteFlag, - waitForChange: boolean = true, -) => { - setupMockStreamingProcessor(shouldError, putResponse, patchResponse, deleteResponse); - const changePromise = onChangePromise(); - - try { - await ldc.identify(context); - } catch (e) { - /* empty */ - } - jest.runAllTimers(); - - // if streaming errors, don't wait for 'change' because it will not be sent. - if (waitForChange && !shouldError) { - await changePromise; - } - - return ldc.allFlags(); -}; - -describe('sdk-client storage', () => { - beforeEach(() => { - jest.useFakeTimers(); - defaultPutResponse = clone(mockResponseJson); - defaultFlagKeys = Object.keys(defaultPutResponse); - - (mockPlatform.storage.get as jest.Mock).mockImplementation((storageKey: string) => { - switch (storageKey) { - case flagStorageKey: - return JSON.stringify(defaultPutResponse); - case indexStorageKey: - return undefined; - default: - return undefined; - } - }); - - jest - .spyOn(LDClientImpl.prototype as any, 'createStreamUriPath') - .mockReturnValue('/stream/path'); - - ldc = new LDClientImpl(testSdkKey, AutoEnvAttributes.Disabled, mockPlatform, { - logger, - sendEvents: false, - }); - - // @ts-ignore - emitter = ldc.emitter; - jest.spyOn(emitter as LDEmitter, 'emit'); - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - test('initialize from storage succeeds without streaming', async () => { - // make sure streaming errors - const allFlags = await identifyGetAllFlags(true, defaultPutResponse); - - expect(mockPlatform.storage.get).toHaveBeenCalledWith(flagStorageKey); - - // 'change' should not have been emitted - expect(emitter.emit).toHaveBeenCalledTimes(2); - expect(emitter.emit).toHaveBeenNthCalledWith(1, 'change', context, defaultFlagKeys); - expect(emitter.emit).toHaveBeenNthCalledWith( - 2, - 'error', - context, - expect.objectContaining({ message: 'test-error' }), - ); - expect(allFlags).toEqual({ - 'dev-test-flag': true, - 'easter-i-tunes-special': false, - 'easter-specials': 'no specials', - fdsafdsafdsafdsa: true, - 'log-level': 'warn', - 'moonshot-demo': true, - test1: 's1', - 'this-is-a-test': true, - }); - }); - - test('initialize from storage succeeds with auto env', async () => { - ldc = new LDClientImpl(testSdkKey, AutoEnvAttributes.Enabled, mockPlatform, { - logger, - sendEvents: false, - }); - // @ts-ignore - emitter = ldc.emitter; - jest.spyOn(emitter as LDEmitter, 'emit'); - - const allFlags = await identifyGetAllFlags(true, defaultPutResponse); - - expect(mockPlatform.storage.get).toHaveBeenLastCalledWith( - expect.stringMatching('LaunchDarkly_1234567890123456_1234567890123456'), - ); - - // 'change' should not have been emitted - expect(emitter.emit).toHaveBeenCalledTimes(2); - expect(emitter.emit).toHaveBeenNthCalledWith( - 1, - 'change', - expect.objectContaining(toMulti(context)), - defaultFlagKeys, - ); - expect(emitter.emit).toHaveBeenNthCalledWith( - 2, - 'error', - expect.objectContaining(toMulti(context)), - expect.objectContaining({ message: 'test-error' }), - ); - expect(allFlags).toEqual({ - 'dev-test-flag': true, - 'easter-i-tunes-special': false, - 'easter-specials': 'no specials', - fdsafdsafdsafdsa: true, - 'log-level': 'warn', - 'moonshot-demo': true, - test1: 's1', - 'this-is-a-test': true, - }); - }); - - test('not emitting change event when changed keys is empty', async () => { - let LDClientImplTestNoChange; - jest.isolateModules(async () => { - LDClientImplTestNoChange = jest.requireActual('./LDClientImpl').default; - ldc = new LDClientImplTestNoChange(testSdkKey, AutoEnvAttributes.Enabled, mockPlatform, { - logger, - sendEvents: false, - }); - }); - - // @ts-ignore - emitter = ldc.emitter; - jest.spyOn(emitter as LDEmitter, 'emit'); - - // expect emission - await identifyGetAllFlags(true, defaultPutResponse); - - // expit no emission - await identifyGetAllFlags(true, defaultPutResponse); - - expect(emitter.emit).toHaveBeenCalledTimes(1); - }); - - test('no storage, cold start from streaming', async () => { - // fake previously cached flags even though there's no storage for this context - // @ts-ignore - ldc.flags = defaultPutResponse; - mockPlatform.storage.get.mockImplementation(() => undefined); - setupMockStreamingProcessor(false, defaultPutResponse); - - ldc.identify(context).then(noop); - await jest.runAllTimersAsync(); - - expect(mockPlatform.storage.set).toHaveBeenNthCalledWith( - 1, - indexStorageKey, - expect.stringContaining('index'), - ); - - expect(mockPlatform.storage.set).toHaveBeenNthCalledWith( - 2, - flagStorageKey, - JSON.stringify(defaultPutResponse), - ); - - // this is defaultPutResponse - expect(ldc.allFlags()).toEqual({ - 'dev-test-flag': true, - 'easter-i-tunes-special': false, - 'easter-specials': 'no specials', - fdsafdsafdsafdsa: true, - 'log-level': 'warn', - 'moonshot-demo': true, - test1: 's1', - 'this-is-a-test': true, - }); - }); - - test('syncing storage when a flag is deleted', async () => { - const putResponse = clone(defaultPutResponse); - delete putResponse['dev-test-flag']; - const allFlags = await identifyGetAllFlags(false, putResponse); - - // wait for async code to resolve promises - await jest.runAllTimersAsync(); - - expect(allFlags).not.toHaveProperty('dev-test-flag'); - expect(mockPlatform.storage.set).toHaveBeenCalledTimes(2); - expect(mockPlatform.storage.set).toHaveBeenNthCalledWith( - 1, - indexStorageKey, - expect.stringContaining('index'), - ); - expect(mockPlatform.storage.set).toHaveBeenNthCalledWith( - 2, - flagStorageKey, - JSON.stringify(putResponse), - ); - - expect(emitter.emit).toHaveBeenNthCalledWith(1, 'change', context, defaultFlagKeys); - expect(emitter.emit).toHaveBeenNthCalledWith(2, 'change', context, ['dev-test-flag']); - }); - - test('syncing storage when a flag is added', async () => { - const putResponse = clone(defaultPutResponse); - putResponse['another-dev-test-flag'] = { - version: 1, - flagVersion: 2, - value: false, - variation: 1, - trackEvents: false, - }; - const allFlags = await identifyGetAllFlags(false, putResponse); - - // wait for async code to resolve promises - await jest.runAllTimersAsync(); - - expect(allFlags).toMatchObject({ 'another-dev-test-flag': false }); - expect(mockPlatform.storage.set).toHaveBeenCalledTimes(2); - expect(mockPlatform.storage.set).toHaveBeenNthCalledWith( - 1, - indexStorageKey, - expect.stringContaining('index'), - ); - expect(mockPlatform.storage.set).toHaveBeenNthCalledWith( - 2, - flagStorageKey, - JSON.stringify(putResponse), - ); - expect(emitter.emit).toHaveBeenNthCalledWith(2, 'change', context, ['another-dev-test-flag']); - }); - - test('syncing storage when a flag is updated', async () => { - const putResponse = clone(defaultPutResponse); - putResponse['dev-test-flag'].version = 999; - putResponse['dev-test-flag'].value = false; - const allFlags = await identifyGetAllFlags(false, putResponse); - - expect(allFlags).toMatchObject({ 'dev-test-flag': false }); - expect(emitter.emit).toHaveBeenNthCalledWith(2, 'change', context, ['dev-test-flag']); - }); - - test('syncing storage on multiple flag operations', async () => { - const putResponse = clone(defaultPutResponse); - const newFlag = clone(putResponse['dev-test-flag']); - - // flag updated, added and deleted - putResponse['dev-test-flag'].value = false; - putResponse['another-dev-test-flag'] = newFlag; - delete putResponse['moonshot-demo']; - const allFlags = await identifyGetAllFlags(false, putResponse); - - // wait for async code to resolve promises - await jest.runAllTimersAsync(); - - expect(allFlags).toMatchObject({ 'dev-test-flag': false, 'another-dev-test-flag': true }); - expect(allFlags).not.toHaveProperty('moonshot-demo'); - expect(emitter.emit).toHaveBeenNthCalledWith(2, 'change', context, [ - 'moonshot-demo', - 'dev-test-flag', - 'another-dev-test-flag', - ]); - }); - - test('syncing storage when PUT is consistent so no change', async () => { - const allFlags = await identifyGetAllFlags( - false, - defaultPutResponse, - undefined, - undefined, - false, - ); - - // wait for async code to resolve promises - await jest.runAllTimersAsync(); - - expect(mockPlatform.storage.set).toHaveBeenCalledTimes(2); - expect(mockPlatform.storage.set).toHaveBeenNthCalledWith( - 1, - indexStorageKey, - expect.stringContaining('index'), - ); - expect(mockPlatform.storage.set).toHaveBeenNthCalledWith( - 2, - flagStorageKey, - JSON.stringify(defaultPutResponse), - ); - - // we expect one change from the local storage init, but no further change from the PUT - expect(emitter.emit).toHaveBeenCalledTimes(1); - expect(emitter.emit).toHaveBeenNthCalledWith(1, 'change', context, defaultFlagKeys); - - // this is defaultPutResponse - expect(allFlags).toEqual({ - 'dev-test-flag': true, - 'easter-i-tunes-special': false, - 'easter-specials': 'no specials', - fdsafdsafdsafdsa: true, - 'log-level': 'warn', - 'moonshot-demo': true, - test1: 's1', - 'this-is-a-test': true, - }); - }); - - test('an update to inExperiment should emit change event', async () => { - const putResponse = clone(defaultPutResponse); - putResponse['dev-test-flag'].reason = { kind: 'RULE_MATCH', inExperiment: true }; - - const allFlags = await identifyGetAllFlags(false, putResponse); - - // wait for async code to resolve promises - await jest.runAllTimersAsync(); - const flagsInStorage = JSON.parse(mockPlatform.storage.set.mock.lastCall[1]) as Flags; - - expect(allFlags).toMatchObject({ 'dev-test-flag': true }); - expect(flagsInStorage['dev-test-flag'].reason).toEqual({ - kind: 'RULE_MATCH', - inExperiment: true, - }); - - // both previous and current are true but inExperiment has changed - // so a change event should be emitted - expect(emitter.emit).toHaveBeenNthCalledWith(2, 'change', context, ['dev-test-flag']); - }); - - test('patch should emit change event', async () => { - const patchResponse = clone(defaultPutResponse['dev-test-flag']); - patchResponse.key = 'dev-test-flag'; - patchResponse.value = false; - patchResponse.version += 1; - - const allFlags = await identifyGetAllFlags(false, defaultPutResponse, patchResponse); - - // wait for async code to resolve promises - await jest.runAllTimersAsync(); - - const flagsInStorage = JSON.parse(mockPlatform.storage.set.mock.lastCall[1]) as Flags; - expect(allFlags).toMatchObject({ 'dev-test-flag': false }); - expect(mockPlatform.storage.set).toHaveBeenCalledTimes(4); - expect(flagsInStorage['dev-test-flag'].version).toEqual(patchResponse.version); - expect(emitter.emit).toHaveBeenCalledTimes(2); - expect(emitter.emit).toHaveBeenNthCalledWith(2, 'change', context, ['dev-test-flag']); - }); - - test('patch should add new flags', async () => { - const patchResponse = clone(defaultPutResponse['dev-test-flag']); - patchResponse.key = 'another-dev-test-flag'; - - const allFlags = await identifyGetAllFlags(false, defaultPutResponse, patchResponse); - - // wait for async code to resolve promises - await jest.runAllTimersAsync(); - - const flagsInStorage = JSON.parse(mockPlatform.storage.set.mock.lastCall[1]) as Flags; - expect(allFlags).toHaveProperty('another-dev-test-flag'); - expect(mockPlatform.storage.set).toHaveBeenNthCalledWith( - 4, - flagStorageKey, - expect.stringContaining(JSON.stringify(patchResponse)), - ); - expect(flagsInStorage).toHaveProperty('another-dev-test-flag'); - expect(emitter.emit).toHaveBeenCalledTimes(2); - expect(emitter.emit).toHaveBeenNthCalledWith(2, 'change', context, ['another-dev-test-flag']); - }); - - test('patch should ignore older version', async () => { - const patchResponse = clone(defaultPutResponse['dev-test-flag']); - patchResponse.key = 'dev-test-flag'; - patchResponse.value = false; - patchResponse.version -= 1; - - const allFlags = await identifyGetAllFlags( - false, - defaultPutResponse, - patchResponse, - undefined, - false, - ); - - expect(mockPlatform.storage.set).toHaveBeenCalledTimes(0); - expect(emitter.emit).not.toHaveBeenCalledWith('change'); - - // this is defaultPutResponse - expect(allFlags).toEqual({ - 'dev-test-flag': true, - 'easter-i-tunes-special': false, - 'easter-specials': 'no specials', - fdsafdsafdsafdsa: true, - 'log-level': 'warn', - 'moonshot-demo': true, - test1: 's1', - 'this-is-a-test': true, - }); - }); - - test('delete should emit change event', async () => { - const deleteResponse = { - key: 'dev-test-flag', - version: defaultPutResponse['dev-test-flag'].version + 1, - }; - - const allFlags = await identifyGetAllFlags( - false, - defaultPutResponse, - undefined, - deleteResponse, - ); - - // wait for async code to resolve promises - await jest.runAllTimersAsync(); - - const flagsInStorage = JSON.parse(mockPlatform.storage.set.mock.lastCall[1]) as Flags; - expect(allFlags).not.toHaveProperty('dev-test-flag'); - expect(mockPlatform.storage.set).toHaveBeenNthCalledWith( - 4, - flagStorageKey, - expect.stringContaining('dev-test-flag'), - ); - expect(flagsInStorage['dev-test-flag']).toMatchObject({ ...deleteResponse, deleted: true }); - expect(emitter.emit).toHaveBeenCalledTimes(2); - expect(emitter.emit).toHaveBeenNthCalledWith(2, 'change', context, ['dev-test-flag']); - }); - - test('delete should not delete equal version', async () => { - const deleteResponse = { - key: 'dev-test-flag', - version: defaultPutResponse['dev-test-flag'].version, - }; - - const allFlags = await identifyGetAllFlags( - false, - defaultPutResponse, - undefined, - deleteResponse, - false, - ); - - expect(allFlags).toHaveProperty('dev-test-flag'); - expect(mockPlatform.storage.set).toHaveBeenCalledTimes(0); - expect(emitter.emit).not.toHaveBeenCalledWith('change'); - }); - - test('delete should not delete newer version', async () => { - const deleteResponse = { - key: 'dev-test-flag', - version: defaultPutResponse['dev-test-flag'].version - 1, - }; - - const allFlags = await identifyGetAllFlags( - false, - defaultPutResponse, - undefined, - deleteResponse, - false, - ); - - expect(allFlags).toHaveProperty('dev-test-flag'); - expect(mockPlatform.storage.set).toHaveBeenCalledTimes(0); - expect(emitter.emit).not.toHaveBeenCalledWith('change'); - }); - - test('delete should add and tombstone non-existing flag', async () => { - const deleteResponse = { - key: 'does-not-exist', - version: 1, - }; - - await identifyGetAllFlags(false, defaultPutResponse, undefined, deleteResponse, false); - - // wait for async code to resolve promises - await jest.runAllTimersAsync(); - - const flagsInStorage = JSON.parse(mockPlatform.storage.set.mock.lastCall[1]) as Flags; - - expect(mockPlatform.storage.set).toHaveBeenCalledTimes(4); // two index saves and two flag saves - expect(flagsInStorage['does-not-exist']).toMatchObject({ ...deleteResponse, deleted: true }); - expect(emitter.emit).toHaveBeenCalledWith('change', context, ['does-not-exist']); - }); -}); diff --git a/packages/shared/sdk-client/src/LDClientImpl.test.ts b/packages/shared/sdk-client/src/LDClientImpl.test.ts deleted file mode 100644 index 34008c497..000000000 --- a/packages/shared/sdk-client/src/LDClientImpl.test.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { AutoEnvAttributes, clone, Hasher, LDContext } from '@launchdarkly/js-sdk-common'; -import { - createBasicPlatform, - createLogger, - MockStreamingProcessor, - setupMockStreamingProcessor, -} from '@launchdarkly/private-js-mocks'; - -import * as mockResponseJson from './evaluation/mockResponse.json'; -import LDClientImpl from './LDClientImpl'; -import { Flags } from './types'; - -jest.mock('@launchdarkly/js-sdk-common', () => { - const actual = jest.requireActual('@launchdarkly/js-sdk-common'); - const actualMock = jest.requireActual('@launchdarkly/private-js-mocks'); - return { - ...actual, - ...{ - internal: { - ...actual.internal, - StreamingProcessor: actualMock.MockStreamingProcessor, - }, - }, - }; -}); - -const testSdkKey = 'test-sdk-key'; -const context: LDContext = { kind: 'org', key: 'Testy Pizza' }; -const autoEnv = { - ld_application: { - key: 'digested1', - envAttributesVersion: '1.0', - id: 'com.testapp.ld', - name: 'LDApplication.TestApp', - version: '1.1.1', - }, - ld_device: { - key: 'random1', - envAttributesVersion: '1.0', - manufacturer: 'coconut', - os: { name: 'An OS', version: '1.0.1', family: 'orange' }, - }, -}; -describe('sdk-client object', () => { - let ldc: LDClientImpl; - let defaultPutResponse: Flags; - let mockPlatform: ReturnType; - let logger: ReturnType; - - beforeEach(() => { - mockPlatform = createBasicPlatform(); - logger = createLogger(); - defaultPutResponse = clone(mockResponseJson); - setupMockStreamingProcessor(false, defaultPutResponse); - mockPlatform.crypto.randomUUID.mockReturnValue('random1'); - const hasher = { - update: jest.fn((): Hasher => hasher), - digest: jest.fn(() => 'digested1'), - }; - mockPlatform.crypto.createHash.mockReturnValue(hasher); - - ldc = new LDClientImpl(testSdkKey, AutoEnvAttributes.Enabled, mockPlatform, { - logger, - sendEvents: false, - }); - jest - .spyOn(LDClientImpl.prototype as any, 'createStreamUriPath') - .mockReturnValue('/stream/path'); - }); - - afterEach(async () => { - await ldc.close(); - jest.resetAllMocks(); - }); - - test('all flags', async () => { - await ldc.identify(context); - const all = ldc.allFlags(); - - expect(all).toEqual({ - 'dev-test-flag': true, - 'easter-i-tunes-special': false, - 'easter-specials': 'no specials', - fdsafdsafdsafdsa: true, - 'log-level': 'warn', - 'moonshot-demo': true, - test1: 's1', - 'this-is-a-test': true, - }); - }); - - test('identify success', async () => { - defaultPutResponse['dev-test-flag'].value = false; - const carContext: LDContext = { kind: 'car', key: 'test-car' }; - - mockPlatform.crypto.randomUUID.mockReturnValue('random1'); - - await ldc.identify(carContext); - const c = ldc.getContext(); - const all = ldc.allFlags(); - - expect(c).toEqual({ - kind: 'multi', - car: { key: 'test-car' }, - ...autoEnv, - }); - expect(all).toMatchObject({ - 'dev-test-flag': false, - }); - expect(MockStreamingProcessor).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - '/stream/path', - expect.anything(), - expect.anything(), - undefined, - expect.anything(), - ); - }); - - test('identify success withReasons', async () => { - const carContext: LDContext = { kind: 'car', key: 'test-car' }; - ldc = new LDClientImpl(testSdkKey, AutoEnvAttributes.Enabled, mockPlatform, { - logger, - sendEvents: false, - withReasons: true, - }); - - await ldc.identify(carContext); - - expect(MockStreamingProcessor).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - '/stream/path', - [{ key: 'withReasons', value: 'true' }], - expect.anything(), - undefined, - expect.anything(), - ); - }); - - test('identify success without auto env', async () => { - defaultPutResponse['dev-test-flag'].value = false; - const carContext: LDContext = { kind: 'car', key: 'test-car' }; - ldc = new LDClientImpl(testSdkKey, AutoEnvAttributes.Disabled, mockPlatform, { - logger, - sendEvents: false, - }); - - await ldc.identify(carContext); - const c = ldc.getContext(); - const all = ldc.allFlags(); - - expect(c).toEqual(carContext); - expect(all).toMatchObject({ - 'dev-test-flag': false, - }); - }); - - test('identify anonymous', async () => { - defaultPutResponse['dev-test-flag'].value = false; - const carContext: LDContext = { kind: 'car', anonymous: true, key: '' }; - - mockPlatform.crypto.randomUUID.mockReturnValue('random1'); - - await ldc.identify(carContext); - const c = ldc.getContext(); - const all = ldc.allFlags(); - - expect(c).toEqual({ - kind: 'multi', - car: { anonymous: true, key: 'random1' }, - ...autoEnv, - }); - expect(all).toMatchObject({ - 'dev-test-flag': false, - }); - }); - - test('identify error invalid context', async () => { - const carContext: LDContext = { kind: 'car', key: '' }; - - await expect(ldc.identify(carContext)).rejects.toThrow(/no key/); - expect(logger.error).toHaveBeenCalledTimes(1); - expect(ldc.getContext()).toBeUndefined(); - }); - - test('identify error stream error', async () => { - setupMockStreamingProcessor(true); - const carContext: LDContext = { kind: 'car', key: 'test-car' }; - - await expect(ldc.identify(carContext)).rejects.toThrow('test-error'); - expect(logger.error).toHaveBeenCalledTimes(1); - expect(logger.error).toHaveBeenCalledWith(expect.stringMatching(/^error:.*test-error/)); - }); - - test('identify change and error listeners', async () => { - // @ts-ignore - const { emitter } = ldc; - - await ldc.identify(context); - - const carContext1: LDContext = { kind: 'car', key: 'test-car' }; - await ldc.identify(carContext1); - - const carContext2: LDContext = { kind: 'car', key: 'test-car-2' }; - await ldc.identify(carContext2); - - expect(emitter.listenerCount('change')).toEqual(1); - expect(emitter.listenerCount('error')).toEqual(1); - }); - - test('can complete identification using storage', async () => { - const data: Record = {}; - mockPlatform.storage.get.mockImplementation((key) => data[key]); - mockPlatform.storage.set.mockImplementation((key: string, value: string) => { - data[key] = value; - }); - mockPlatform.storage.clear.mockImplementation((key: string) => { - delete data[key]; - }); - - // First identify should populate storage. - await ldc.identify(context); - - expect(logger.debug).not.toHaveBeenCalledWith('Identify completing with cached flags'); - - // Second identify should use storage. - await ldc.identify(context); - - expect(logger.debug).toHaveBeenCalledWith('Identify completing with cached flags'); - }); - - test('does not complete identify using storage when instructed to wait for the network response', async () => { - const data: Record = {}; - mockPlatform.storage.get.mockImplementation((key) => data[key]); - mockPlatform.storage.set.mockImplementation((key: string, value: string) => { - data[key] = value; - }); - mockPlatform.storage.clear.mockImplementation((key: string) => { - delete data[key]; - }); - - // First identify should populate storage. - await ldc.identify(context); - - expect(logger.debug).not.toHaveBeenCalledWith('Identify completing with cached flags'); - - // Second identify would use storage, but we instruct it not to. - await ldc.identify(context, { waitForNetworkResults: true, timeout: 5 }); - - expect(logger.debug).not.toHaveBeenCalledWith('Identify completing with cached flags'); - }); -}); diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index beac5c1c0..ff6565b2b 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -1,28 +1,28 @@ import { AutoEnvAttributes, - ClientContext, clone, Context, + defaultHeaders, internal, LDClientError, LDContext, LDFlagSet, LDFlagValue, + LDHeaders, LDLogger, Platform, - ProcessStreamResponse, - EventName as StreamEventName, + subsystem, timedPromise, TypeValidators, } from '@launchdarkly/js-sdk-common'; -import { LDStreamProcessor } from '@launchdarkly/js-sdk-common/dist/api/subsystem'; -import { ConnectionMode, LDClient, type LDOptions } from './api'; +import { Hook, LDClient, type LDOptions } from './api'; import { LDEvaluationDetail, LDEvaluationDetailTyped } from './api/LDEvaluationDetail'; import { LDIdentifyOptions } from './api/LDIdentifyOptions'; -import Configuration from './configuration'; +import { Configuration, ConfigurationImpl, LDClientInternalOptions } from './configuration'; import { addAutoEnv } from './context/addAutoEnv'; import { ensureKey } from './context/ensureKey'; +import { DataManager, DataManagerFactory } from './DataManager'; import createDiagnosticsManager from './diagnostics/createDiagnosticsManager'; import { createErrorEvaluationDetail, @@ -30,36 +30,38 @@ import { } from './evaluation/evaluationDetail'; import createEventProcessor from './events/createEventProcessor'; import EventFactory from './events/EventFactory'; -import FlagManager from './flag-manager/FlagManager'; -import { ItemDescriptor } from './flag-manager/ItemDescriptor'; +import DefaultFlagManager, { FlagManager } from './flag-manager/FlagManager'; +import { FlagChangeType } from './flag-manager/FlagUpdater'; +import HookRunner from './HookRunner'; +import { getInspectorHook } from './inspection/getInspectorHook'; +import InspectorManager from './inspection/InspectorManager'; import LDEmitter, { EventName } from './LDEmitter'; -import PollingProcessor from './polling/PollingProcessor'; -import { DeleteFlag, Flags, PatchFlag } from './types'; const { ClientMessages, ErrorKinds } = internal; +const DEFAULT_IDENIFY_TIMEOUT_SECONDS = 5; + export default class LDClientImpl implements LDClient { - private readonly config: Configuration; - private uncheckedContext?: LDContext; - private checkedContext?: Context; - private readonly diagnosticsManager?: internal.DiagnosticsManager; - private eventProcessor?: internal.EventProcessor; - private identifyTimeout: number = 5; + private readonly _config: Configuration; + private _uncheckedContext?: LDContext; + private _checkedContext?: Context; + private readonly _diagnosticsManager?: internal.DiagnosticsManager; + private _eventProcessor?: internal.EventProcessor; readonly logger: LDLogger; - private updateProcessor?: LDStreamProcessor; - - private readonly highTimeoutThreshold: number = 15; + private _updateProcessor?: subsystem.LDStreamProcessor; - private eventFactoryDefault = new EventFactory(false); - private eventFactoryWithReasons = new EventFactory(true); - private emitter: LDEmitter; - private flagManager: FlagManager; + private readonly _highTimeoutThreshold: number = 15; - private readonly clientContext: ClientContext; + private _eventFactoryDefault = new EventFactory(false); + private _eventFactoryWithReasons = new EventFactory(true); + protected emitter: LDEmitter; + private _flagManager: FlagManager; - private eventSendingEnabled: boolean = true; - private networkAvailable: boolean = true; - private connectionMode: ConnectionMode; + private _eventSendingEnabled: boolean = false; + private _baseHeaders: LDHeaders; + protected dataManager: DataManager; + private _hookRunner: HookRunner; + private _inspectorManager: InspectorManager; /** * Creates the client object synchronously. No async, no network calls. @@ -69,7 +71,8 @@ export default class LDClientImpl implements LDClient { public readonly autoEnvAttributes: AutoEnvAttributes, public readonly platform: Platform, options: LDOptions, - internalOptions?: internal.LDInternalOptions, + dataManagerFactory: DataManagerFactory, + internalOptions?: LDClientInternalOptions, ) { if (!sdkKey) { throw new Error('You must configure the client with a client-side SDK key'); @@ -79,88 +82,63 @@ export default class LDClientImpl implements LDClient { throw new Error('Platform must implement Encoding because btoa is required.'); } - this.config = new Configuration(options, internalOptions); - this.connectionMode = this.config.initialConnectionMode; - this.clientContext = new ClientContext(sdkKey, this.config, platform); - this.logger = this.config.logger; - this.flagManager = new FlagManager( + this._config = new ConfigurationImpl(options, internalOptions); + this.logger = this._config.logger; + + this._baseHeaders = defaultHeaders( + this.sdkKey, + this.platform.info, + this._config.tags, + this._config.serviceEndpoints.includeAuthorizationHeader, + this._config.userAgentHeaderName, + ); + + this._flagManager = new DefaultFlagManager( this.platform, sdkKey, - this.config.maxCachedContexts, - this.config.logger, + this._config.maxCachedContexts, + this._config.logger, ); - this.diagnosticsManager = createDiagnosticsManager(sdkKey, this.config, platform); - this.eventProcessor = createEventProcessor( + this._diagnosticsManager = createDiagnosticsManager(sdkKey, this._config, platform); + this._eventProcessor = createEventProcessor( sdkKey, - this.config, + this._config, platform, - this.diagnosticsManager, - !this.isOffline(), + this._baseHeaders, + this._diagnosticsManager, ); this.emitter = new LDEmitter(); - this.emitter.on('change', (c: LDContext, changedKeys: string[]) => { - this.logger.debug(`change: context: ${JSON.stringify(c)}, flags: ${changedKeys}`); - }); this.emitter.on('error', (c: LDContext, err: any) => { this.logger.error(`error: ${err}, context: ${JSON.stringify(c)}`); }); - this.flagManager.on((context, flagKeys) => { + this._flagManager.on((context, flagKeys, type) => { + this._handleInspectionChanged(flagKeys, type); const ldContext = Context.toLDContext(context); this.emitter.emit('change', ldContext, flagKeys); + flagKeys.forEach((it) => { + this.emitter.emit(`change:${it}`, ldContext); + }); }); - } - - /** - * Sets the SDK connection mode. - * - * @param mode - One of supported {@link ConnectionMode}. Default is 'streaming'. - */ - async setConnectionMode(mode: ConnectionMode): Promise { - if (this.connectionMode === mode) { - this.logger.debug(`setConnectionMode ignored. Mode is already '${mode}'.`); - return Promise.resolve(); - } - this.connectionMode = mode; - this.logger.debug(`setConnectionMode ${mode}.`); - - switch (mode) { - case 'offline': - this.updateProcessor?.close(); - break; - case 'polling': - case 'streaming': - if (this.uncheckedContext) { - // identify will start the update processor - return this.identify(this.uncheckedContext, { timeout: this.identifyTimeout }); - } + this.dataManager = dataManagerFactory( + this._flagManager, + this._config, + this._baseHeaders, + this.emitter, + this._diagnosticsManager, + ); - break; - default: - this.logger.warn( - `Unknown ConnectionMode: ${mode}. Only 'offline', 'streaming', and 'polling' are supported.`, - ); - break; + this._hookRunner = new HookRunner(this.logger, this._config.hooks); + this._inspectorManager = new InspectorManager(this._config.inspectors, this.logger); + if (this._inspectorManager.hasInspectors()) { + this._hookRunner.addHook(getInspectorHook(this._inspectorManager)); } - - return Promise.resolve(); - } - - /** - * Gets the SDK connection mode. - */ - getConnectionMode(): ConnectionMode { - return this.connectionMode; - } - - isOffline() { - return this.connectionMode === 'offline'; } allFlags(): LDFlagSet { // extracting all flag values - const result = Object.entries(this.flagManager.getAll()).reduce( + const result = Object.entries(this._flagManager.getAll()).reduce( (acc: LDFlagSet, [key, descriptor]) => { if (descriptor.flag !== null && descriptor.flag !== undefined && !descriptor.flag.deleted) { acc[key] = descriptor.flag.value; @@ -174,14 +152,14 @@ export default class LDClientImpl implements LDClient { async close(): Promise { await this.flush(); - this.eventProcessor?.close(); - this.updateProcessor?.close(); + this._eventProcessor?.close(); + this._updateProcessor?.close(); this.logger.debug('Closed event processor and data source.'); } async flush(): Promise<{ error?: Error; result: boolean }> { try { - await this.eventProcessor?.flush(); + await this._eventProcessor?.flush(); this.logger.debug('Successfully flushed event processor.'); } catch (e) { this.logger.error(`Error flushing event processor: ${e}.`); @@ -197,105 +175,35 @@ export default class LDClientImpl implements LDClient { // code. We are returned the unchecked context so that if a consumer identifies with an invalid context // and then calls getContext, they get back the same context they provided, without any assertion about // validity. - return this.uncheckedContext ? clone(this.uncheckedContext) : undefined; + return this._uncheckedContext ? clone(this._uncheckedContext) : undefined; } - private createStreamListeners( - context: Context, - identifyResolve: any, - ): Map { - const listeners = new Map(); - - listeners.set('put', { - deserializeData: JSON.parse, - processJson: async (evalResults: Flags) => { - this.logger.debug(`Stream PUT: ${Object.keys(evalResults)}`); - - // mapping flags to item descriptors - const descriptors = Object.entries(evalResults).reduce( - (acc: { [k: string]: ItemDescriptor }, [key, flag]) => { - acc[key] = { version: flag.version, flag }; - return acc; - }, - {}, - ); - await this.flagManager.init(context, descriptors).then(identifyResolve()); - }, - }); - - listeners.set('patch', { - deserializeData: JSON.parse, - processJson: async (patchFlag: PatchFlag) => { - this.logger.debug(`Stream PATCH ${JSON.stringify(patchFlag, null, 2)}`); - this.flagManager.upsert(context, patchFlag.key, { - version: patchFlag.version, - flag: patchFlag, - }); - }, - }); - - listeners.set('delete', { - deserializeData: JSON.parse, - processJson: async (deleteFlag: DeleteFlag) => { - this.logger.debug(`Stream DELETE ${JSON.stringify(deleteFlag, null, 2)}`); - - this.flagManager.upsert(context, deleteFlag.key, { - version: deleteFlag.version, - flag: { - ...deleteFlag, - deleted: true, - // props below are set to sensible defaults. they are irrelevant - // because this flag has been deleted. - flagVersion: 0, - value: undefined, - variation: 0, - trackEvents: false, - }, - }); - }, - }); - - return listeners; + protected getInternalContext(): Context | undefined { + return this._checkedContext; } - /** - * Generates the url path for streaming. - * - * @protected This function must be overridden in subclasses for streaming - * to work. - * @param _context The LDContext object - */ - protected createStreamUriPath(_context: LDContext): string { - throw new Error( - 'createStreamUriPath not implemented. Client sdks must implement createStreamUriPath for streaming to work.', - ); - } - - /** - * Generates the url path for polling. - * @param _context - * - * @protected This function must be overridden in subclasses for polling - * to work. - * @param _context The LDContext object - */ - protected createPollUriPath(_context: LDContext): string { - throw new Error( - 'createPollUriPath not implemented. Client sdks must implement createPollUriPath for polling to work.', - ); - } - - private createIdentifyPromise(timeout: number) { + private _createIdentifyPromise( + timeout: number, + noTimeout: boolean, + ): { + identifyPromise: Promise; + identifyResolve: () => void; + identifyReject: (err: Error) => void; + } { let res: any; let rej: any; - const slow = new Promise((resolve, reject) => { + const basePromise = new Promise((resolve, reject) => { res = resolve; rej = reject; }); + if (noTimeout) { + return { identifyPromise: basePromise, identifyResolve: res, identifyReject: rej }; + } + const timed = timedPromise(timeout, 'identify'); - const raced = Promise.race([timed, slow]).catch((e) => { + const raced = Promise.race([timed, basePromise]).catch((e) => { if (e.message.includes('timed out')) { this.logger.error(`identify error: ${e}`); } @@ -321,25 +229,23 @@ export default class LDClientImpl implements LDClient { * 3. A network error is encountered during initialization. */ async identify(pristineContext: LDContext, identifyOptions?: LDIdentifyOptions): Promise { - // In offline mode we do not support waiting for results. - const waitForNetworkResults = !!identifyOptions?.waitForNetworkResults && !this.isOffline(); + const identifyTimeout = identifyOptions?.timeout ?? DEFAULT_IDENIFY_TIMEOUT_SECONDS; + const noTimeout = identifyOptions?.timeout === undefined && identifyOptions?.noTimeout === true; - if (identifyOptions?.timeout) { - this.identifyTimeout = identifyOptions.timeout; - } - - if (this.identifyTimeout > this.highTimeoutThreshold) { + // When noTimeout is specified, and a timeout is not secified, then this condition cannot + // be encountered. (Our default would need to be greater) + if (identifyTimeout > this._highTimeoutThreshold) { this.logger.warn( 'The identify function was called with a timeout greater than ' + - `${this.highTimeoutThreshold} seconds. We recommend a timeout of less than ` + - `${this.highTimeoutThreshold} seconds.`, + `${this._highTimeoutThreshold} seconds. We recommend a timeout of less than ` + + `${this._highTimeoutThreshold} seconds.`, ); } let context = await ensureKey(pristineContext, this.platform); if (this.autoEnvAttributes === AutoEnvAttributes.Enabled) { - context = await addAutoEnv(context, this.platform, this.config); + context = await addAutoEnv(context, this.platform, this._config); } const checkedContext = Context.fromLDContext(context); @@ -348,128 +254,48 @@ export default class LDClientImpl implements LDClient { this.emitter.emit('error', context, error); return Promise.reject(error); } - this.uncheckedContext = context; - this.checkedContext = checkedContext; + this._uncheckedContext = context; + this._checkedContext = checkedContext; - this.eventProcessor?.sendEvent(this.eventFactoryDefault.identifyEvent(this.checkedContext)); - const { identifyPromise, identifyResolve, identifyReject } = this.createIdentifyPromise( - this.identifyTimeout, + this._eventProcessor?.sendEvent(this._eventFactoryDefault.identifyEvent(this._checkedContext)); + const { identifyPromise, identifyResolve, identifyReject } = this._createIdentifyPromise( + identifyTimeout, + noTimeout, ); - this.logger.debug(`Identifying ${JSON.stringify(this.checkedContext)}`); + this.logger.debug(`Identifying ${JSON.stringify(this._checkedContext)}`); - const loadedFromCache = await this.flagManager.loadCached(this.checkedContext); - if (loadedFromCache && !waitForNetworkResults) { - this.logger.debug('Identify completing with cached flags'); - identifyResolve(); - } - if (loadedFromCache && waitForNetworkResults) { - this.logger.debug( - 'Identify - Flags loaded from cache, but identify was requested with "waitForNetworkResults"', - ); - } - - if (this.isOffline()) { - if (loadedFromCache) { - this.logger.debug('Offline identify - using cached flags.'); - } else { - this.logger.debug( - 'Offline identify - no cached flags, using defaults or already loaded flags.', - ); - identifyResolve(); - } - } else { - this.updateProcessor?.close(); - switch (this.getConnectionMode()) { - case 'streaming': - this.createStreamingProcessor(context, checkedContext, identifyResolve, identifyReject); - break; - case 'polling': - this.createPollingProcessor(context, checkedContext, identifyResolve, identifyReject); - break; - default: - break; - } - this.updateProcessor!.start(); - } - - return identifyPromise; - } - - private createPollingProcessor( - context: LDContext, - checkedContext: Context, - identifyResolve: any, - identifyReject: any, - ) { - const parameters: { key: string; value: string }[] = []; - if (this.config.withReasons) { - parameters.push({ key: 'withReasons', value: 'true' }); - } + const afterIdentify = this._hookRunner.identify(context, identifyOptions?.timeout); - this.updateProcessor = new PollingProcessor( - this.sdkKey, - this.clientContext.platform.requests, - this.clientContext.platform.info, - this.createPollUriPath(context), - parameters, - this.config, - async (flags) => { - this.logger.debug(`Handling polling result: ${Object.keys(flags)}`); - - // mapping flags to item descriptors - const descriptors = Object.entries(flags).reduce( - (acc: { [k: string]: ItemDescriptor }, [key, flag]) => { - acc[key] = { version: flag.version, flag }; - return acc; - }, - {}, - ); + await this.dataManager.identify( + identifyResolve, + identifyReject, + checkedContext, + identifyOptions, + ); - await this.flagManager.init(checkedContext, descriptors).then(identifyResolve()); + return identifyPromise.then( + (res) => { + afterIdentify({ status: 'completed' }); + return res; }, - (err) => { - identifyReject(err); - this.emitter.emit('error', context, err); + (e) => { + afterIdentify({ status: 'error' }); + throw e; }, ); } - private createStreamingProcessor( - context: LDContext, - checkedContext: Context, - identifyResolve: any, - identifyReject: any, - ) { - const parameters: { key: string; value: string }[] = []; - if (this.config.withReasons) { - parameters.push({ key: 'withReasons', value: 'true' }); - } - - this.updateProcessor = new internal.StreamingProcessor( - this.sdkKey, - this.clientContext, - this.createStreamUriPath(context), - parameters, - this.createStreamListeners(checkedContext, identifyResolve), - this.diagnosticsManager, - (e) => { - identifyReject(e); - this.emitter.emit('error', context, e); - }, - ); + on(eventName: EventName, listener: Function): void { + this.emitter.on(eventName, listener); } off(eventName: EventName, listener: Function): void { this.emitter.off(eventName, listener); } - on(eventName: EventName, listener: Function): void { - this.emitter.on(eventName, listener); - } - track(key: string, data?: any, metricValue?: number): void { - if (!this.checkedContext || !this.checkedContext.valid) { - this.logger.warn(ClientMessages.missingContextKeyNoEvent); + if (!this._checkedContext || !this._checkedContext.valid) { + this.logger.warn(ClientMessages.MissingContextKeyNoEvent); return; } @@ -478,43 +304,45 @@ export default class LDClientImpl implements LDClient { this.logger?.warn(ClientMessages.invalidMetricValue(typeof metricValue)); } - this.eventProcessor?.sendEvent( - this.eventFactoryDefault.customEvent(key, this.checkedContext!, data, metricValue), + this._eventProcessor?.sendEvent( + this._config.trackEventModifier( + this._eventFactoryDefault.customEvent(key, this._checkedContext!, data, metricValue), + ), ); } - private variationInternal( + private _variationInternal( flagKey: string, defaultValue: any, eventFactory: EventFactory, typeChecker?: (value: any) => [boolean, string], ): LDEvaluationDetail { - if (!this.uncheckedContext) { - this.logger.debug(ClientMessages.missingContextKeyNoEvent); + if (!this._uncheckedContext) { + this.logger.debug(ClientMessages.MissingContextKeyNoEvent); return createErrorEvaluationDetail(ErrorKinds.UserNotSpecified, defaultValue); } - const evalContext = Context.fromLDContext(this.uncheckedContext); - const foundItem = this.flagManager.get(flagKey); + const evalContext = Context.fromLDContext(this._uncheckedContext); + const foundItem = this._flagManager.get(flagKey); if (foundItem === undefined || foundItem.flag.deleted) { const defVal = defaultValue ?? null; const error = new LDClientError( `Unknown feature flag "${flagKey}"; returning default value ${defVal}.`, ); - this.emitter.emit('error', this.uncheckedContext, error); - this.eventProcessor?.sendEvent( - this.eventFactoryDefault.unknownFlagEvent(flagKey, defVal, evalContext), + this.emitter.emit('error', this._uncheckedContext, error); + this._eventProcessor?.sendEvent( + this._eventFactoryDefault.unknownFlagEvent(flagKey, defVal, evalContext), ); return createErrorEvaluationDetail(ErrorKinds.FlagNotFound, defaultValue); } - const { reason, value, variation } = foundItem.flag; + const { reason, value, variation, prerequisites } = foundItem.flag; if (typeChecker) { const [matched, type] = typeChecker(value); if (!matched) { - this.eventProcessor?.sendEvent( + this._eventProcessor?.sendEvent( eventFactory.evalEventClient( flagKey, defaultValue, // track default value on type errors @@ -527,17 +355,21 @@ export default class LDClientImpl implements LDClient { const error = new LDClientError( `Wrong type "${type}" for feature flag "${flagKey}"; returning default value`, ); - this.emitter.emit('error', this.uncheckedContext, error); + this.emitter.emit('error', this._uncheckedContext, error); return createErrorEvaluationDetail(ErrorKinds.WrongType, defaultValue); } } const successDetail = createSuccessEvaluationDetail(value, variation, reason); - if (variation === undefined || variation === null) { - this.logger.debug('Result value is null in variation'); + if (value === undefined || value === null) { + this.logger.debug('Result value is null. Providing default value.'); successDetail.value = defaultValue; } - this.eventProcessor?.sendEvent( + + prerequisites?.forEach((prereqKey) => { + this._variationInternal(prereqKey, undefined, this._eventFactoryDefault); + }); + this._eventProcessor?.sendEvent( eventFactory.evalEventClient( flagKey, value, @@ -551,24 +383,33 @@ export default class LDClientImpl implements LDClient { } variation(flagKey: string, defaultValue?: LDFlagValue): LDFlagValue { - const { value } = this.variationInternal(flagKey, defaultValue, this.eventFactoryDefault); + const { value } = this._hookRunner.withEvaluation( + flagKey, + this._uncheckedContext, + defaultValue, + () => this._variationInternal(flagKey, defaultValue, this._eventFactoryDefault), + ); return value; } variationDetail(flagKey: string, defaultValue?: LDFlagValue): LDEvaluationDetail { - return this.variationInternal(flagKey, defaultValue, this.eventFactoryWithReasons); + return this._hookRunner.withEvaluation(flagKey, this._uncheckedContext, defaultValue, () => + this._variationInternal(flagKey, defaultValue, this._eventFactoryWithReasons), + ); } - private typedEval( + private _typedEval( key: string, defaultValue: T, eventFactory: EventFactory, typeChecker: (value: unknown) => [boolean, string], ): LDEvaluationDetailTyped { - return this.variationInternal(key, defaultValue, eventFactory, typeChecker); + return this._hookRunner.withEvaluation(key, this._uncheckedContext, defaultValue, () => + this._variationInternal(key, defaultValue, eventFactory, typeChecker), + ); } boolVariation(key: string, defaultValue: boolean): boolean { - return this.typedEval(key, defaultValue, this.eventFactoryDefault, (value) => [ + return this._typedEval(key, defaultValue, this._eventFactoryDefault, (value) => [ TypeValidators.Boolean.is(value), TypeValidators.Boolean.getType(), ]).value; @@ -579,35 +420,35 @@ export default class LDClientImpl implements LDClient { } numberVariation(key: string, defaultValue: number): number { - return this.typedEval(key, defaultValue, this.eventFactoryDefault, (value) => [ + return this._typedEval(key, defaultValue, this._eventFactoryDefault, (value) => [ TypeValidators.Number.is(value), TypeValidators.Number.getType(), ]).value; } stringVariation(key: string, defaultValue: string): string { - return this.typedEval(key, defaultValue, this.eventFactoryDefault, (value) => [ + return this._typedEval(key, defaultValue, this._eventFactoryDefault, (value) => [ TypeValidators.String.is(value), TypeValidators.String.getType(), ]).value; } boolVariationDetail(key: string, defaultValue: boolean): LDEvaluationDetailTyped { - return this.typedEval(key, defaultValue, this.eventFactoryWithReasons, (value) => [ + return this._typedEval(key, defaultValue, this._eventFactoryWithReasons, (value) => [ TypeValidators.Boolean.is(value), TypeValidators.Boolean.getType(), ]); } numberVariationDetail(key: string, defaultValue: number): LDEvaluationDetailTyped { - return this.typedEval(key, defaultValue, this.eventFactoryWithReasons, (value) => [ + return this._typedEval(key, defaultValue, this._eventFactoryWithReasons, (value) => [ TypeValidators.Number.is(value), TypeValidators.Number.getType(), ]); } stringVariationDetail(key: string, defaultValue: string): LDEvaluationDetailTyped { - return this.typedEval(key, defaultValue, this.eventFactoryWithReasons, (value) => [ + return this._typedEval(key, defaultValue, this._eventFactoryWithReasons, (value) => [ TypeValidators.String.is(value), TypeValidators.String.getType(), ]); @@ -617,16 +458,8 @@ export default class LDClientImpl implements LDClient { return this.variationDetail(key, defaultValue); } - /** - * Inform the client of the network state. Can be used to modify connection behavior. - * - * For instance the implementation may choose to suppress errors from connections if the client - * knows that there is no network available. - * @param _available True when there is an available network. - */ - protected setNetworkAvailability(available: boolean): void { - this.networkAvailable = available; - // Not yet supported. + addHook(hook: Hook): void { + this._hookRunner.addHook(hook); } /** @@ -635,29 +468,55 @@ export default class LDClientImpl implements LDClient { * @param flush True to flush while disabling. Useful to flush on certain state transitions. */ protected setEventSendingEnabled(enabled: boolean, flush: boolean): void { - if (this.eventSendingEnabled === enabled) { + if (this._eventSendingEnabled === enabled) { return; } - this.eventSendingEnabled = enabled; + this._eventSendingEnabled = enabled; if (enabled) { this.logger.debug('Starting event processor'); - this.eventProcessor?.start(); + this._eventProcessor?.start(); } else if (flush) { this.logger?.debug('Flushing event processor before disabling.'); // Disable and flush. this.flush().then(() => { // While waiting for the flush event sending could be re-enabled, in which case // we do not want to close the event processor. - if (!this.eventSendingEnabled) { + if (!this._eventSendingEnabled) { this.logger?.debug('Stopping event processor.'); - this.eventProcessor?.close(); + this._eventProcessor?.close(); } }); } else { // Just disabled. this.logger?.debug('Stopping event processor.'); - this.eventProcessor?.close(); + this._eventProcessor?.close(); + } + } + + protected sendEvent(event: internal.InputEvent): void { + this._eventProcessor?.sendEvent(event); + } + + private _handleInspectionChanged(flagKeys: Array, type: FlagChangeType) { + if (!this._inspectorManager.hasInspectors()) { + return; + } + + const details: Record = {}; + flagKeys.forEach((flagKey) => { + const item = this._flagManager.get(flagKey); + if (item?.flag && !item.flag.deleted) { + const { reason, value, variation } = item.flag; + details[flagKey] = createSuccessEvaluationDetail(value, variation, reason); + } + }); + if (type === 'init') { + this._inspectorManager.onFlagsChanged(details); + } else if (type === 'patch') { + Object.entries(details).forEach(([flagKey, detail]) => { + this._inspectorManager.onFlagChanged(flagKey, detail); + }); } } } diff --git a/packages/shared/sdk-client/src/LDEmitter.ts b/packages/shared/sdk-client/src/LDEmitter.ts index 1b00b5a8f..76705f90c 100644 --- a/packages/shared/sdk-client/src/LDEmitter.ts +++ b/packages/shared/sdk-client/src/LDEmitter.ts @@ -1,17 +1,29 @@ import { LDLogger } from '@launchdarkly/js-sdk-common'; -export type EventName = 'error' | 'change'; +type FlagChangeKey = `change:${string}`; +/** + * Type for name of emitted events. 'change' is used for all flag changes. 'change:flag-name-here' is used + * for specific flag changes. + */ +export type EventName = 'change' | FlagChangeKey | 'dataSourceStatus' | 'error'; + +/** + * Implementation Note: There should not be any default listeners for change events in a client + * implementation. Default listeners mean a client cannot determine when there are actual + * application developer provided listeners. If we require default listeners, then we should add + * a system to allow listeners which have counts independent of the primary listener counts. + */ export default class LDEmitter { - private listeners: Map = new Map(); + private _listeners: Map = new Map(); - constructor(private logger?: LDLogger) {} + constructor(private _logger?: LDLogger) {} on(name: EventName, listener: Function) { - if (!this.listeners.has(name)) { - this.listeners.set(name, [listener]); + if (!this._listeners.has(name)) { + this._listeners.set(name, [listener]); } else { - this.listeners.get(name)?.push(listener); + this._listeners.get(name)?.push(listener); } } @@ -22,7 +34,7 @@ export default class LDEmitter { * @param listener Optional. If unspecified, all listeners for the event will be removed. */ off(name: EventName, listener?: Function) { - const existingListeners = this.listeners.get(name); + const existingListeners = this._listeners.get(name); if (!existingListeners) { return; } @@ -31,35 +43,35 @@ export default class LDEmitter { // remove from internal cache const updated = existingListeners.filter((fn) => fn !== listener); if (updated.length === 0) { - this.listeners.delete(name); + this._listeners.delete(name); } else { - this.listeners.set(name, updated); + this._listeners.set(name, updated); } return; } // listener was not specified, so remove them all for that event - this.listeners.delete(name); + this._listeners.delete(name); } - private invokeListener(listener: Function, name: EventName, ...detail: any[]) { + private _invokeListener(listener: Function, name: EventName, ...detail: any[]) { try { listener(...detail); } catch (err) { - this.logger?.error(`Encountered error invoking handler for "${name}", detail: "${err}"`); + this._logger?.error(`Encountered error invoking handler for "${name}", detail: "${err}"`); } } emit(name: EventName, ...detail: any[]) { - const listeners = this.listeners.get(name); - listeners?.forEach((listener) => this.invokeListener(listener, name, ...detail)); + const listeners = this._listeners.get(name); + listeners?.forEach((listener) => this._invokeListener(listener, name, ...detail)); } eventNames(): string[] { - return [...this.listeners.keys()]; + return [...this._listeners.keys()]; } listenerCount(name: EventName): number { - return this.listeners.get(name)?.length ?? 0; + return this._listeners.get(name)?.length ?? 0; } } diff --git a/packages/shared/sdk-client/src/api/LDClient.ts b/packages/shared/sdk-client/src/api/LDClient.ts index 8f26ad399..b3a996625 100644 --- a/packages/shared/sdk-client/src/api/LDClient.ts +++ b/packages/shared/sdk-client/src/api/LDClient.ts @@ -1,6 +1,6 @@ import { LDContext, LDFlagSet, LDFlagValue, LDLogger } from '@launchdarkly/js-sdk-common'; -import ConnectionMode from './ConnectionMode'; +import { Hook } from './integrations/Hooks'; import { LDEvaluationDetail, LDEvaluationDetailTyped } from './LDEvaluationDetail'; import { LDIdentifyOptions } from './LDIdentifyOptions'; @@ -58,8 +58,7 @@ export interface LDClient { /** * Shuts down the client and releases its resources, after delivering any pending analytics - * events. After the client is closed, all calls to {@link variation} will return default values, - * and it will not make any requests to LaunchDarkly. + * events. */ close(): Promise; @@ -75,14 +74,6 @@ export interface LDClient { */ flush(): Promise<{ error?: Error; result: boolean }>; - /** - * Gets the SDK connection mode. - * - * @remarks - * Possible values are offline or streaming. See {@link ConnectionMode} for more information. - */ - getConnectionMode(): ConnectionMode; - /** * Returns the client's current context. * @@ -223,6 +214,9 @@ export interface LDClient { * The callback parameters are the context and an Error object. Errors are also output by * the {@link logger} at the error level. * + * - `"dataSourceStatus"`: Event indicating that there has been a change in the status of the + * data source. This will include the state of the data source as well any error information. + * * @param key * The name of the event for which to listen. * @param callback @@ -231,13 +225,6 @@ export interface LDClient { */ on(key: string, callback: (...args: any[]) => void): void; - /** - * Sets the SDK connection mode. - * - * @param mode - One of supported {@link ConnectionMode}. By default, the SDK uses streaming. - */ - setConnectionMode(mode: ConnectionMode): void; - /** * Determines the string variation of a feature flag. * @@ -331,4 +318,14 @@ export interface LDClient { * An {@link LDEvaluationDetail} object containing the value and explanation. */ variationDetail(key: string, defaultValue?: LDFlagValue): LDEvaluationDetail; + + /** + * Add a hook to the client. In order to register a hook before the client + * starts, please use the `hooks` property of {@link LDOptions}. + * + * Hooks provide entrypoints which allow for observation of SDK functions. + * + * @param Hook The hook to add. + */ + addHook(hook: Hook): void; } diff --git a/packages/shared/sdk-client/src/api/LDIdentifyOptions.ts b/packages/shared/sdk-client/src/api/LDIdentifyOptions.ts index 3aecdfab4..467da79b6 100644 --- a/packages/shared/sdk-client/src/api/LDIdentifyOptions.ts +++ b/packages/shared/sdk-client/src/api/LDIdentifyOptions.ts @@ -20,4 +20,17 @@ export interface LDIdentifyOptions { * Defaults to false. */ waitForNetworkResults?: boolean; + + /** + * When set to true, and timeout is not set, this indicates that the identify operation will + * not have any timeout. In typical usage, where an application awaits the promise, a timeout + * is important because identify can potentially take indefinite time depending on network + * conditions. If your application specifically does not block any operations pending the promise + * resolution, then you can use this opton to explicitly indicate that. + * + * If you set this to true, and you do not set a timeout, and you block aspects of operation of + * your application, then those aspects can be blocked indefinitely. Generally this option will + * not be required. + */ + noTimeout?: boolean; } diff --git a/packages/shared/sdk-client/src/api/LDInspection.ts b/packages/shared/sdk-client/src/api/LDInspection.ts new file mode 100644 index 000000000..bf73dea37 --- /dev/null +++ b/packages/shared/sdk-client/src/api/LDInspection.ts @@ -0,0 +1,126 @@ +import { LDContext } from '@launchdarkly/js-sdk-common'; + +import { LDEvaluationDetail } from './LDEvaluationDetail'; + +/** + * Callback interface for collecting information about the SDK at runtime. + * + * This interface is used to collect information about flag usage. + * + * This interface should not be used by the application to access flags for the purpose of controlling application + * flow. It is intended for monitoring, analytics, or debugging purposes. + */ +export interface LDInspectionFlagUsedHandler { + type: 'flag-used'; + + /** + * Name of the inspector. Will be used for logging issues with the inspector. + */ + name: string; + + /** + * @deprecated All inspectors run synchronously. This field will be removed in a future major version. + */ + synchronous?: boolean; + + /** + * This method is called when a flag is accessed via a variation method, or it can be called based on actions in + * wrapper SDKs which have different methods of tracking when a flag was accessed. It is not called when a call is made + * to allFlags. + */ + method: (flagKey: string, flagDetail: LDEvaluationDetail, context?: LDContext) => void; +} + +/** + * Callback interface for collecting information about the SDK at runtime. + * + * This interface is used to collect information about flag data. In order to understand the + * current flag state it should be combined with {@link LDInspectionFlagValueChangedHandler}. + * This interface will get the initial flag information, and + * {@link LDInspectionFlagValueChangedHandler} will provide changes to individual flags. + * + * This interface should not be used by the application to access flags for the purpose of controlling application + * flow. It is intended for monitoring, analytics, or debugging purposes. + */ +export interface LDInspectionFlagDetailsChangedHandler { + type: 'flag-details-changed'; + + /** + * Name of the inspector. Will be used for logging issues with the inspector. + */ + name: string; + + /** + * @deprecated All inspectors run synchronously. This field will be removed in a future major version. + */ + synchronous?: boolean; + + /** + * This method is called when the flags in the store are replaced with new flags. It will contain all flags + * regardless of if they have been evaluated. + */ + method: (details: Record) => void; +} + +/** + * Callback interface for collecting information about the SDK at runtime. + * + * This interface is used to collect changes to flag data, but does not provide the initial + * data. It can be combined with {@link LDInspectionFlagValuesChangedHandler} to track the + * entire flag state. + * + * This interface should not be used by the application to access flags for the purpose of controlling application + * flow. It is intended for monitoring, analytics, or debugging purposes. + */ +export interface LDInspectionFlagDetailChangedHandler { + type: 'flag-detail-changed'; + + /** + * Name of the inspector. Will be used for logging issues with the inspector. + */ + name: string; + + /** + * @deprecated All inspectors run synchronously. This field will be removed in a future major version. + */ + synchronous?: boolean; + + /** + * This method is called when a flag is updated. It will not be called + * when all flags are updated. + */ + method: (flagKey: string, detail: LDEvaluationDetail) => void; +} + +/** + * Callback interface for collecting information about the SDK at runtime. + * + * This interface is used to track current identity state of the SDK. + * + * This interface should not be used by the application to access flags for the purpose of controlling application + * flow. It is intended for monitoring, analytics, or debugging purposes. + */ +export interface LDInspectionIdentifyHandler { + type: 'client-identity-changed'; + + /** + * Name of the inspector. Will be used for logging issues with the inspector. + */ + name: string; + + /** + * @deprecated All inspectors run synchronously. This field will be removed in a future major version. + */ + synchronous?: boolean; + + /** + * This method will be called when an identify operation completes. + */ + method: (context: LDContext) => void; +} + +export type LDInspection = + | LDInspectionFlagUsedHandler + | LDInspectionFlagDetailsChangedHandler + | LDInspectionFlagDetailChangedHandler + | LDInspectionIdentifyHandler; diff --git a/packages/shared/sdk-client/src/api/LDOptions.ts b/packages/shared/sdk-client/src/api/LDOptions.ts index 8d098c881..5ce6b647d 100644 --- a/packages/shared/sdk-client/src/api/LDOptions.ts +++ b/packages/shared/sdk-client/src/api/LDOptions.ts @@ -1,6 +1,7 @@ import type { LDLogger } from '@launchdarkly/js-sdk-common'; -import ConnectionMode from './ConnectionMode'; +import { Hook } from './integrations/Hooks'; +import { LDInspection } from './LDInspection'; export interface LDOptions { /** @@ -113,29 +114,22 @@ export interface LDOptions { eventsUri?: string; /** - * Controls how often the SDK flushes events. + * The interval in between flushes of the analytics events queue, in seconds. * - * @defaultValue 30s. + * @defaultValue 2s for browser implementations 30s for others. */ flushInterval?: number; - /** - * Sets the mode to use for connections when the SDK is initialized. - * - * @remarks - * Possible values are offline or streaming. See {@link ConnectionMode} for more information. - * - * @defaultValue streaming. - */ - initialConnectionMode?: ConnectionMode; - /** * An object that will perform logging for the client. * * @remarks * Set a custom {@link LDLogger} if you want full control of logging behavior. * - * @defaultValue A {@link BasicLogger} which outputs to the console at `info` level. + * @defaultValue The default logging implementation will varybased on platform. For the browser + * the default logger will log "info" level and higher priorty messages and it will log messages to + * console.info, console.warn, and console.error. Other platforms may use a `BasicLogger` instance + * also defaulted to the "info" level. */ logger?: LDLogger; @@ -198,6 +192,14 @@ export interface LDOptions { */ pollInterval?: number; + /** + * Directs the SDK to use the REPORT method for HTTP requests instead of GET. (Default: `false`) + * + * This setting applies both to requests to the streaming service, as well as flag requests when the SDK is in polling + * mode. + */ + useReport?: boolean; + /** * Whether LaunchDarkly should provide additional information about how flag values were * calculated. @@ -240,4 +242,33 @@ export interface LDOptions { * config property. */ payloadFilterKey?: string; + + /** + * Initial set of hooks for the client. + * + * Hooks provide entrypoints which allow for observation of SDK functions. + * + * LaunchDarkly provides integration packages, and most applications will not + * need to implement their own hooks. Refer to the `@launchdarkly/node-server-sdk-otel` + * for instrumentation for the `@launchdarkly/node-server-sdk`. + * + * Example: + * ```typescript + * import { init } from '@launchdarkly/node-server-sdk'; + * import { TheHook } from '@launchdarkly/some-hook'; + * + * const client = init('my-sdk-key', { hooks: [new TheHook()] }); + * ``` + */ + hooks?: Hook[]; + + /** + * Inspectors can be used for collecting information for monitoring, analytics, and debugging. + * + * + * @deprecated Hooks should be used instead of inspectors and inspectors will be removed in + * a future version. If you need functionality that is not exposed using hooks, then please + * let us know through a github issue or support. + */ + inspectors?: LDInspection[]; } diff --git a/packages/shared/sdk-client/src/api/index.ts b/packages/shared/sdk-client/src/api/index.ts index 24c6c13ce..9e3acf6a5 100644 --- a/packages/shared/sdk-client/src/api/index.ts +++ b/packages/shared/sdk-client/src/api/index.ts @@ -3,5 +3,8 @@ import ConnectionMode from './ConnectionMode'; export * from './LDOptions'; export * from './LDClient'; export * from './LDEvaluationDetail'; +export * from './integrations'; export { ConnectionMode }; +export * from './LDIdentifyOptions'; +export * from './LDInspection'; diff --git a/packages/shared/sdk-client/src/api/integrations/Hooks.ts b/packages/shared/sdk-client/src/api/integrations/Hooks.ts new file mode 100644 index 000000000..deb1552c2 --- /dev/null +++ b/packages/shared/sdk-client/src/api/integrations/Hooks.ts @@ -0,0 +1,162 @@ +import { LDContext } from '@launchdarkly/js-sdk-common'; + +import { LDEvaluationDetail } from '../LDEvaluationDetail'; + +/** + * Contextual information provided to evaluation stages. + */ +export interface EvaluationSeriesContext { + readonly flagKey: string; + /** + * Optional in case evaluations are performed before a context is set. + */ + readonly context?: LDContext; + readonly defaultValue: unknown; + + /** + * Implementation note: Omitting method name because of the associated size. + * If we need this functionality, then we may want to consider adding it and + * taking the associated size hit. + */ +} + +/** + * Implementation specific hook data for evaluation stages. + * + * Hook implementations can use this to store data needed between stages. + */ +export interface EvaluationSeriesData { + readonly [index: string]: unknown; +} + +/** + * Meta-data about a hook implementation. + */ +export interface HookMetadata { + readonly name: string; +} + +/** + * Contextual information provided to evaluation stages. + */ +export interface IdentifySeriesContext { + readonly context: LDContext; + /** + * The timeout, in seconds, associated with the identify operation. + */ + readonly timeout?: number; +} + +/** + * Implementation specific hook data for evaluation stages. + * + * Hook implementations can use this to store data needed between stages. + */ +export interface IdentifySeriesData { + readonly [index: string]: unknown; +} + +/** + * The status an identify operation completed with. + */ +export type IdentifySeriesStatus = 'completed' | 'error'; + +/** + * The result applies to a single identify operation. An operation may complete + * with an error and then later complete successfully. Only the first completion + * will be executed in the evaluation series. + */ +export interface IdentifySeriesResult { + status: IdentifySeriesStatus; +} + +/** + * Interface for extending SDK functionality via hooks. + */ +export interface Hook { + /** + * Get metadata about the hook implementation. + */ + getMetadata(): HookMetadata; + + /** + * This method is called during the execution of a variation method + * before the flag value has been determined. The method is executed synchronously. + * + * @param hookContext Contains information about the evaluation being performed. This is not + * mutable. + * @param data A record associated with each stage of hook invocations. Each stage is called with + * the data of the previous stage for a series. The input record should not be modified. + * @returns Data to use when executing the next state of the hook in the evaluation series. It is + * recommended to expand the previous input into the return. This helps ensure your stage remains + * compatible moving forward as more stages are added. + * ```js + * return {...data, "my-new-field": /*my data/*} + * ``` + */ + beforeEvaluation?( + hookContext: EvaluationSeriesContext, + data: EvaluationSeriesData, + ): EvaluationSeriesData; + + /** + * This method is called during the execution of the variation method + * after the flag value has been determined. The method is executed synchronously. + * + * @param hookContext Contains read-only information about the evaluation + * being performed. + * @param data A record associated with each stage of hook invocations. Each + * stage is called with the data of the previous stage for a series. + * @param detail The result of the evaluation. This value should not be + * modified. + * @returns Data to use when executing the next state of the hook in the evaluation series. It is + * recommended to expand the previous input into the return. This helps ensure your stage remains + * compatible moving forward as more stages are added. + * ```js + * return {...data, "my-new-field": /*my data/*} + * ``` + */ + afterEvaluation?( + hookContext: EvaluationSeriesContext, + data: EvaluationSeriesData, + detail: LDEvaluationDetail, + ): EvaluationSeriesData; + + /** + * This method is called during the execution of the identify process before the operation + * completes, but after any context modifications are performed. + * + * @param hookContext Contains information about the evaluation being performed. This is not + * mutable. + * @param data A record associated with each stage of hook invocations. Each stage is called with + * the data of the previous stage for a series. The input record should not be modified. + * @returns Data to use when executing the next state of the hook in the evaluation series. It is + * recommended to expand the previous input into the return. This helps ensure your stage remains + * compatible moving forward as more stages are added. + * ```js + * return {...data, "my-new-field": /*my data/*} + * ``` + */ + beforeIdentify?(hookContext: IdentifySeriesContext, data: IdentifySeriesData): IdentifySeriesData; + + /** + * This method is called during the execution of the identify process before the operation + * completes, but after any context modifications are performed. + * + * @param hookContext Contains information about the evaluation being performed. This is not + * mutable. + * @param data A record associated with each stage of hook invocations. Each stage is called with + * the data of the previous stage for a series. The input record should not be modified. + * @returns Data to use when executing the next state of the hook in the evaluation series. It is + * recommended to expand the previous input into the return. This helps ensure your stage remains + * compatible moving forward as more stages are added. + * ```js + * return {...data, "my-new-field": /*my data/*} + * ``` + */ + afterIdentify?( + hookContext: IdentifySeriesContext, + data: IdentifySeriesData, + result: IdentifySeriesResult, + ): IdentifySeriesData; +} diff --git a/packages/shared/sdk-client/src/api/integrations/index.ts b/packages/shared/sdk-client/src/api/integrations/index.ts new file mode 100644 index 000000000..c2df1c4b0 --- /dev/null +++ b/packages/shared/sdk-client/src/api/integrations/index.ts @@ -0,0 +1 @@ +export * from './Hooks'; diff --git a/packages/shared/sdk-client/src/configuration/Configuration.ts b/packages/shared/sdk-client/src/configuration/Configuration.ts index 55d87f879..a055ffa32 100644 --- a/packages/shared/sdk-client/src/configuration/Configuration.ts +++ b/packages/shared/sdk-client/src/configuration/Configuration.ts @@ -3,26 +3,84 @@ import { createSafeLogger, internal, LDFlagSet, + LDLogger, NumberWithMinimum, OptionMessages, + SafeLogger, ServiceEndpoints, TypeValidators, } from '@launchdarkly/js-sdk-common'; -import { ConnectionMode, type LDOptions } from '../api'; +import { Hook, type LDOptions } from '../api'; +import { LDInspection } from '../api/LDInspection'; import validators from './validators'; const DEFAULT_POLLING_INTERVAL: number = 60 * 5; -export default class Configuration { - public static DEFAULT_POLLING = 'https://clientsdk.launchdarkly.com'; - public static DEFAULT_STREAM = 'https://clientstream.launchdarkly.com'; +export interface LDClientInternalOptions extends internal.LDInternalOptions { + trackEventModifier?: (event: internal.InputCustomEvent) => internal.InputCustomEvent; +} + +export interface Configuration { + readonly logger: LDLogger; + readonly maxCachedContexts: number; + readonly capacity: number; + readonly diagnosticRecordingInterval: number; + readonly flushInterval: number; + readonly streamInitialReconnectDelay: number; + readonly allAttributesPrivate: boolean; + readonly debug: boolean; + readonly diagnosticOptOut: boolean; + readonly sendEvents: boolean; + readonly sendLDHeaders: boolean; + readonly useReport: boolean; + readonly withReasons: boolean; + readonly privateAttributes: string[]; + readonly tags: ApplicationTags; + readonly applicationInfo?: { + id?: string; + version?: string; + name?: string; + versionName?: string; + }; + readonly bootstrap?: LDFlagSet; + readonly requestHeaderTransform?: (headers: Map) => Map; + readonly stream?: boolean; + readonly hash?: string; + readonly wrapperName?: string; + readonly wrapperVersion?: string; + readonly serviceEndpoints: ServiceEndpoints; + readonly pollInterval: number; + readonly userAgentHeaderName: 'user-agent' | 'x-launchdarkly-user-agent'; + readonly trackEventModifier: (event: internal.InputCustomEvent) => internal.InputCustomEvent; + readonly hooks: Hook[]; + readonly inspectors: LDInspection[]; +} - public readonly logger = createSafeLogger(); +const DEFAULT_POLLING: string = 'https://clientsdk.launchdarkly.com'; +const DEFAULT_STREAM: string = 'https://clientstream.launchdarkly.com'; - public readonly baseUri = Configuration.DEFAULT_POLLING; - public readonly eventsUri = ServiceEndpoints.DEFAULT_EVENTS; - public readonly streamUri = Configuration.DEFAULT_STREAM; +export { DEFAULT_POLLING, DEFAULT_STREAM }; + +function ensureSafeLogger(logger?: LDLogger): LDLogger { + if (logger instanceof SafeLogger) { + return logger; + } + // Even if logger is not defined this will produce a valid logger. + return createSafeLogger(logger); +} + +export default class ConfigurationImpl implements Configuration { + public readonly logger: LDLogger = createSafeLogger(); + + // Naming conventions is not followed for these lines because the config validation + // accesses members based on the keys of the options. (sdk-763) + // eslint-disable-next-line @typescript-eslint/naming-convention + private readonly baseUri = DEFAULT_POLLING; + // eslint-disable-next-line @typescript-eslint/naming-convention + private readonly eventsUri = ServiceEndpoints.DEFAULT_EVENTS; + // eslint-disable-next-line @typescript-eslint/naming-convention + private readonly streamUri = DEFAULT_STREAM; public readonly maxCachedContexts = 5; @@ -31,19 +89,17 @@ export default class Configuration { public readonly flushInterval = 30; public readonly streamInitialReconnectDelay = 1; - public readonly allAttributesPrivate = false; - public readonly debug = false; - public readonly diagnosticOptOut = false; - public readonly sendEvents = true; - public readonly sendLDHeaders = true; + public readonly allAttributesPrivate: boolean = false; + public readonly debug: boolean = false; + public readonly diagnosticOptOut: boolean = false; + public readonly sendEvents: boolean = true; + public readonly sendLDHeaders: boolean = true; - public readonly useReport = false; - public readonly withReasons = false; + public readonly useReport: boolean = false; + public readonly withReasons: boolean = false; public readonly privateAttributes: string[] = []; - public readonly initialConnectionMode: ConnectionMode = 'streaming'; - public readonly tags: ApplicationTags; public readonly applicationInfo?: { id?: string; @@ -64,11 +120,22 @@ export default class Configuration { public readonly pollInterval: number = DEFAULT_POLLING_INTERVAL; + public readonly userAgentHeaderName: 'user-agent' | 'x-launchdarkly-user-agent'; + + public readonly hooks: Hook[] = []; + + public readonly inspectors: LDInspection[] = []; + + public readonly trackEventModifier: ( + event: internal.InputCustomEvent, + ) => internal.InputCustomEvent; + // Allow indexing Configuration by a string [index: string]: any; - constructor(pristineOptions: LDOptions = {}, internalOptions: internal.LDInternalOptions = {}) { - const errors = this.validateTypesAndNames(pristineOptions); + constructor(pristineOptions: LDOptions = {}, internalOptions: LDClientInternalOptions = {}) { + this.logger = ensureSafeLogger(pristineOptions.logger); + const errors = this._validateTypesAndNames(pristineOptions); errors.forEach((e: string) => this.logger.warn(e)); this.serviceEndpoints = new ServiceEndpoints( @@ -80,10 +147,14 @@ export default class Configuration { internalOptions.includeAuthorizationHeader, pristineOptions.payloadFilterKey, ); + this.useReport = pristineOptions.useReport ?? false; + this.tags = new ApplicationTags({ application: this.applicationInfo, logger: this.logger }); + this.userAgentHeaderName = internalOptions.userAgentHeaderName ?? 'user-agent'; + this.trackEventModifier = internalOptions.trackEventModifier ?? ((event) => event); } - validateTypesAndNames(pristineOptions: LDOptions): string[] { + private _validateTypesAndNames(pristineOptions: LDOptions): string[] { const errors: string[] = []; Object.entries(pristineOptions).forEach(([k, v]) => { @@ -109,6 +180,8 @@ export default class Configuration { } else { errors.push(OptionMessages.wrongOptionType(k, validator.getType(), typeof v)); } + } else if (k === 'logger') { + // Logger already assigned. } else { // if an option is explicitly null, coerce to undefined this[k] = v ?? undefined; diff --git a/packages/shared/sdk-client/src/configuration/index.ts b/packages/shared/sdk-client/src/configuration/index.ts index b22c3ea49..cdb5c1344 100644 --- a/packages/shared/sdk-client/src/configuration/index.ts +++ b/packages/shared/sdk-client/src/configuration/index.ts @@ -1,3 +1,14 @@ -import Configuration from './Configuration'; +import ConfigurationImpl, { + Configuration, + DEFAULT_POLLING, + DEFAULT_STREAM, + LDClientInternalOptions, +} from './Configuration'; -export default Configuration; +export { + Configuration, + ConfigurationImpl, + LDClientInternalOptions, + DEFAULT_POLLING, + DEFAULT_STREAM, +}; diff --git a/packages/shared/sdk-client/src/configuration/validators.ts b/packages/shared/sdk-client/src/configuration/validators.ts index cc22874b4..fba69b438 100644 --- a/packages/shared/sdk-client/src/configuration/validators.ts +++ b/packages/shared/sdk-client/src/configuration/validators.ts @@ -3,18 +3,7 @@ import { TypeValidator, TypeValidators } from '@launchdarkly/js-sdk-common'; import { type LDOptions } from '../api'; -class ConnectionModeValidator implements TypeValidator { - is(u: unknown): boolean { - return u === 'offline' || u === 'streaming' || u === 'polling'; - } - - getType(): string { - return `offline | streaming | polling`; - } -} - const validators: Record = { - initialConnectionMode: new ConnectionModeValidator(), logger: TypeValidators.Object, maxCachedContexts: TypeValidators.numberWithMin(0), @@ -35,12 +24,16 @@ const validators: Record = { pollInterval: TypeValidators.numberWithMin(30), + useReport: TypeValidators.Boolean, + privateAttributes: TypeValidators.StringArray, applicationInfo: TypeValidators.Object, wrapperName: TypeValidators.String, wrapperVersion: TypeValidators.String, payloadFilterKey: TypeValidators.stringMatchingRegex(/^[a-zA-Z0-9](\w|\.|-)*$/), + hooks: TypeValidators.createTypeArray('Hook[]', {}), + inspectors: TypeValidators.createTypeArray('LDInspection', {}), }; export default validators; diff --git a/packages/shared/sdk-client/src/context/addAutoEnv.ts b/packages/shared/sdk-client/src/context/addAutoEnv.ts index c769b1b6b..0a2a2417d 100644 --- a/packages/shared/sdk-client/src/context/addAutoEnv.ts +++ b/packages/shared/sdk-client/src/context/addAutoEnv.ts @@ -11,7 +11,8 @@ import { Platform, } from '@launchdarkly/js-sdk-common'; -import Configuration from '../configuration'; +import { Configuration } from '../configuration'; +import digest from '../crypto/digest'; import { getOrGenerateKey } from '../storage/getOrGenerateKey'; import { namespaceForGeneratedContextKey } from '../storage/namespaceUtils'; @@ -36,10 +37,10 @@ export const toMulti = (c: LDSingleKindContext) => { * @param config * @return An LDApplication object with populated key, envAttributesVersion, id and version. */ -export const addApplicationInfo = ( +export const addApplicationInfo = async ( { crypto, info }: Platform, { applicationInfo }: Configuration, -): LDApplication | undefined => { +): Promise => { const { ld_application } = info.platformData(); let app = deepCompact(ld_application) ?? ({} as LDApplication); const id = applicationInfo?.id || app?.id; @@ -58,9 +59,7 @@ export const addApplicationInfo = ( ...(versionName ? { versionName } : {}), }; - const hasher = crypto.createHash('sha256'); - hasher.update(id); - app.key = hasher.digest('base64'); + app.key = await digest(crypto.createHash('sha256').update(id), 'base64'); app.envAttributesVersion = app.envAttributesVersion || defaultAutoEnvSchemaVersion; return app; @@ -95,7 +94,7 @@ export const addDeviceInfo = async (platform: Platform) => { // Check if device has any meaningful data before we return it. if (Object.keys(device).filter((k) => k !== 'key' && k !== 'envAttributesVersion').length) { - const ldDeviceNamespace = namespaceForGeneratedContextKey('ld_device'); + const ldDeviceNamespace = await namespaceForGeneratedContextKey('ld_device'); device.key = await getOrGenerateKey(ldDeviceNamespace, platform); device.envAttributesVersion = device.envAttributesVersion || defaultAutoEnvSchemaVersion; return device; @@ -104,7 +103,11 @@ export const addDeviceInfo = async (platform: Platform) => { return undefined; }; -export const addAutoEnv = async (context: LDContext, platform: Platform, config: Configuration) => { +export const addAutoEnv = async ( + context: LDContext, + platform: Platform, + config: Configuration, +): Promise => { // LDUser is not supported for auto env reporting if (isLegacyUser(context)) { return context as LDUser; @@ -118,7 +121,7 @@ export const addAutoEnv = async (context: LDContext, platform: Platform, config: (isSingleKind(context) && context.kind !== 'ld_application') || (isMultiKind(context) && !context.ld_application) ) { - ld_application = addApplicationInfo(platform, config); + ld_application = await addApplicationInfo(platform, config); } else { config.logger.warn( 'Not adding ld_application environment attributes because it already exists.', diff --git a/packages/shared/sdk-client/src/context/ensureKey.ts b/packages/shared/sdk-client/src/context/ensureKey.ts index 7b7f18cf3..877ef7dd8 100644 --- a/packages/shared/sdk-client/src/context/ensureKey.ts +++ b/packages/shared/sdk-client/src/context/ensureKey.ts @@ -31,7 +31,7 @@ const ensureKeyCommon = async (kind: string, c: LDContextCommon, platform: Platf const { anonymous, key } = c; if (anonymous && !key) { - const storageKey = namespaceForAnonymousGeneratedContextKey(kind); + const storageKey = await namespaceForAnonymousGeneratedContextKey(kind); // This mutates a cloned copy of the original context from ensureyKey so this is safe. // eslint-disable-next-line no-param-reassign c.key = await getOrGenerateKey(storageKey, platform); @@ -63,7 +63,7 @@ const ensureKeyLegacy = async (c: LDUser, platform: Platform) => { * @param context * @param platform */ -export const ensureKey = async (context: LDContext, platform: Platform) => { +export const ensureKey = async (context: LDContext, platform: Platform): Promise => { const cloned = clone(context); if (isSingleKind(cloned)) { diff --git a/packages/shared/sdk-client/src/crypto/digest.ts b/packages/shared/sdk-client/src/crypto/digest.ts new file mode 100644 index 000000000..c6b38292a --- /dev/null +++ b/packages/shared/sdk-client/src/crypto/digest.ts @@ -0,0 +1,12 @@ +import { Hasher } from '@launchdarkly/js-sdk-common'; + +export default async function digest(hasher: Hasher, encoding: string): Promise { + if (hasher.digest) { + return hasher.digest(encoding); + } + if (hasher.asyncDigest) { + return hasher.asyncDigest(encoding); + } + // This represents an error in platform implementation. + throw new Error('Platform must implement digest or asyncDigest'); +} diff --git a/packages/shared/sdk-client/src/datasource/DataSourceConfig.ts b/packages/shared/sdk-client/src/datasource/DataSourceConfig.ts new file mode 100644 index 000000000..9abe66d50 --- /dev/null +++ b/packages/shared/sdk-client/src/datasource/DataSourceConfig.ts @@ -0,0 +1,28 @@ +import { Encoding, LDHeaders, ServiceEndpoints } from '@launchdarkly/js-sdk-common'; + +export interface DataSourceConfig { + credential: string; + serviceEndpoints: ServiceEndpoints; + baseHeaders: LDHeaders; + withReasons: boolean; + useReport: boolean; + paths: DataSourcePaths; + queryParameters?: { key: string; value: string }[]; +} + +export interface PollingDataSourceConfig extends DataSourceConfig { + pollInterval: number; +} + +export interface StreamingDataSourceConfig extends DataSourceConfig { + initialRetryDelayMillis: number; +} + +export interface DataSourcePaths { + // Returns the path to get flag data via GET request + pathGet(encoding: Encoding, plainContextString: string): string; + // Returns the path to get flag data via REPORT request + pathReport(encoding: Encoding, plainContextString: string): string; + // Returns the path to get ping stream notifications when flag data changes + pathPing(encoding: Encoding, plainContextString: string): string; +} diff --git a/packages/shared/sdk-client/src/datasource/DataSourceEventHandler.ts b/packages/shared/sdk-client/src/datasource/DataSourceEventHandler.ts new file mode 100644 index 000000000..2e8791939 --- /dev/null +++ b/packages/shared/sdk-client/src/datasource/DataSourceEventHandler.ts @@ -0,0 +1,64 @@ +import { Context, LDLogger, LDPollingError, LDStreamingError } from '@launchdarkly/js-sdk-common'; + +import { FlagManager } from '../flag-manager/FlagManager'; +import { ItemDescriptor } from '../flag-manager/ItemDescriptor'; +import { DeleteFlag, Flags, PatchFlag } from '../types'; +import { DataSourceState } from './DataSourceStatus'; +import DataSourceStatusManager from './DataSourceStatusManager'; + +export default class DataSourceEventHandler { + constructor( + private readonly _flagManager: FlagManager, + private readonly _statusManager: DataSourceStatusManager, + private readonly _logger: LDLogger, + ) {} + + async handlePut(context: Context, flags: Flags) { + this._logger.debug(`Got PUT: ${Object.keys(flags)}`); + + // mapping flags to item descriptors + const descriptors = Object.entries(flags).reduce( + (acc: { [k: string]: ItemDescriptor }, [key, flag]) => { + acc[key] = { version: flag.version, flag }; + return acc; + }, + {}, + ); + await this._flagManager.init(context, descriptors); + this._statusManager.requestStateUpdate(DataSourceState.Valid); + } + + async handlePatch(context: Context, patchFlag: PatchFlag) { + this._logger.debug(`Got PATCH ${JSON.stringify(patchFlag, null, 2)}`); + this._flagManager.upsert(context, patchFlag.key, { + version: patchFlag.version, + flag: patchFlag, + }); + } + + async handleDelete(context: Context, deleteFlag: DeleteFlag) { + this._logger.debug(`Got DELETE ${JSON.stringify(deleteFlag, null, 2)}`); + + this._flagManager.upsert(context, deleteFlag.key, { + version: deleteFlag.version, + flag: { + ...deleteFlag, + deleted: true, + // props below are set to sensible defaults. they are irrelevant + // because this flag has been deleted. + flagVersion: 0, + value: undefined, + variation: 0, + trackEvents: false, + }, + }); + } + + handleStreamingError(error: LDStreamingError) { + this._statusManager.reportError(error.kind, error.message, error.code, error.recoverable); + } + + handlePollingError(error: LDPollingError) { + this._statusManager.reportError(error.kind, error.message, error.status, error.recoverable); + } +} diff --git a/packages/shared/sdk-client/src/datasource/DataSourceStatus.ts b/packages/shared/sdk-client/src/datasource/DataSourceStatus.ts new file mode 100644 index 000000000..bb1fcf308 --- /dev/null +++ b/packages/shared/sdk-client/src/datasource/DataSourceStatus.ts @@ -0,0 +1,44 @@ +import DataSourceStatusErrorInfo from './DataSourceStatusErrorInfo'; + +export enum DataSourceState { + Initializing = 'INITIALIZING', + Valid = 'VALID', + Interrupted = 'INTERRUPTED', + SetOffline = 'SET_OFFLINE', + Closed = 'CLOSED', + // TODO: SDK-702 - Implement network availability behaviors + // NetworkUnavailable, +} + +export default interface DataSourceStatus { + /** + * An enumerated value representing the overall current state of the data source. + */ + readonly state: DataSourceState; + + /** + * The UNIX epoch timestamp in milliseconds that the value of State most recently changed. + * + * The meaning of this depends on the current state: + * For {@link DataSourceState.Initializing}, it is the time that the datasource started + * attempting to retrieve data. + * + * For {@link DataSourceState.Valid}, it is the time that the data source most + * recently entered a valid state, after previously having been + * {@link DataSourceStatus.Initializing} or an invalid state such as + * {@link DataSourceState.Interrupted}. + * + * - For {@link DataSourceState.interrupted}, it is the time that the data source + * most recently entered an error state, after previously having been + * {@link DataSourceState.valid}. + * + * For {@link DataSourceState.Closed}, it is the time that the data source + * encountered an unrecoverable error or that the datasource was explicitly closed. + */ + readonly stateSince: number; + + /** + * The last error encountered. May be absent after application restart. + */ + readonly lastError?: DataSourceStatusErrorInfo; +} diff --git a/packages/shared/sdk-client/src/datasource/DataSourceStatusErrorInfo.ts b/packages/shared/sdk-client/src/datasource/DataSourceStatusErrorInfo.ts new file mode 100644 index 000000000..102c9851a --- /dev/null +++ b/packages/shared/sdk-client/src/datasource/DataSourceStatusErrorInfo.ts @@ -0,0 +1,19 @@ +import { DataSourceErrorKind } from '@launchdarkly/js-sdk-common'; + +/// A description of an error condition that the data source encountered. +export default interface DataSourceStatusErrorInfo { + /// An enumerated value representing the general category of the error. + readonly kind: DataSourceErrorKind; + + /// Any additional human-readable information relevant to the error. + /// + /// The format is subject to change and should not be relied on + /// programmatically. + readonly message: string; + + /// The UNIX epoch timestamp in milliseconds that the event occurred. + readonly time: number; + + /// The HTTP status code if the error was [ErrorKind.errorResponse]. + readonly statusCode?: number; +} diff --git a/packages/shared/sdk-client/src/datasource/DataSourceStatusManager.ts b/packages/shared/sdk-client/src/datasource/DataSourceStatusManager.ts new file mode 100644 index 000000000..dd739625c --- /dev/null +++ b/packages/shared/sdk-client/src/datasource/DataSourceStatusManager.ts @@ -0,0 +1,95 @@ +import { DataSourceErrorKind } from '@launchdarkly/js-sdk-common'; + +import LDEmitter from '../LDEmitter'; +import DataSourceStatus, { DataSourceState } from './DataSourceStatus'; +import DataSourceStatusErrorInfo from './DataSourceStatusErrorInfo'; + +/** + * Tracks the current data source status and emits updates when the status changes. + */ +export default class DataSourceStatusManager { + private _state: DataSourceState; + private _stateSinceMillis: number; // UNIX epoch timestamp in milliseconds + private _errorInfo?: DataSourceStatusErrorInfo; + private _timeStamper: () => number; + + constructor( + private readonly _emitter: LDEmitter, + timeStamper: () => number = () => Date.now(), + ) { + this._state = DataSourceState.Closed; + this._stateSinceMillis = timeStamper(); + this._timeStamper = timeStamper; + } + + get status(): DataSourceStatus { + return { + state: this._state, + stateSince: this._stateSinceMillis, + lastError: this._errorInfo, + }; + } + + /** + * Updates the state of the manager. + * + * @param requestedState to track + * @param isError to indicate that the state update is a result of an error occurring. + */ + private _updateState(requestedState: DataSourceState, isError = false) { + const newState = + requestedState === DataSourceState.Interrupted && this._state === DataSourceState.Initializing // don't go to interrupted from initializing (recoverable errors when initializing are not noteworthy) + ? DataSourceState.Initializing + : requestedState; + + const changedState = this._state !== newState; + if (changedState) { + this._state = newState; + this._stateSinceMillis = this._timeStamper(); + } + + if (changedState || isError) { + this._emitter.emit('dataSourceStatus', this.status); + } + } + + /** + * Requests the manager move to the provided state. This request may be ignored + * if the current state cannot transition to the requested state. + * @param state that is requested + */ + requestStateUpdate(state: DataSourceState) { + this._updateState(state); + } + + /** + * Reports a datasource error to this manager. Since the {@link DataSourceStatus} includes error + * information, it is possible that that a {@link DataSourceStatus} update is emitted with + * the same {@link DataSourceState}. + * + * @param kind of the error + * @param message for the error + * @param statusCode of the error if there was one + * @param recoverable to indicate that the error is anticipated to be recoverable + */ + reportError( + kind: DataSourceErrorKind, + message: string, + statusCode?: number, + recoverable: boolean = false, + ) { + const errorInfo: DataSourceStatusErrorInfo = { + kind, + message, + statusCode, + time: this._timeStamper(), + }; + this._errorInfo = errorInfo; + this._updateState(recoverable ? DataSourceState.Interrupted : DataSourceState.Closed, true); + } + + // TODO: SDK-702 - Implement network availability behaviors + // setNetworkUnavailable() { + // this.updateState(DataSourceState.NetworkUnavailable); + // } +} diff --git a/packages/shared/sdk-client/src/datasource/Requestor.ts b/packages/shared/sdk-client/src/datasource/Requestor.ts new file mode 100644 index 000000000..2e88837bf --- /dev/null +++ b/packages/shared/sdk-client/src/datasource/Requestor.ts @@ -0,0 +1,96 @@ +// eslint-disable-next-line max-classes-per-file +import { + Encoding, + getPollingUri, + HttpErrorResponse, + LDHeaders, + Requests, + ServiceEndpoints, +} from '@launchdarkly/js-sdk-common'; + +import { DataSourcePaths } from './DataSourceConfig'; + +function isOk(status: number) { + return status >= 200 && status <= 299; +} + +export class LDRequestError extends Error implements HttpErrorResponse { + public status?: number; + + constructor(message: string, status?: number) { + super(message); + this.status = status; + this.name = 'LaunchDarklyRequestError'; + } +} + +/** + * Note: The requestor is implemented independently from polling such that it can be used to + * make a one-off request. + */ +export default class Requestor { + constructor( + private _requests: Requests, + private readonly _uri: string, + private readonly _headers: { [key: string]: string }, + private readonly _method: string, + private readonly _body?: string, + ) {} + + async requestPayload(): Promise { + let status: number | undefined; + try { + const res = await this._requests.fetch(this._uri, { + method: this._method, + headers: this._headers, + body: this._body, + }); + if (isOk(res.status)) { + return await res.text(); + } + // Assigning so it can be thrown after the try/catch. + status = res.status; + } catch (err: any) { + throw new LDRequestError(err?.message); + } + throw new LDRequestError(`Unexpected status code: ${status}`, status); + } +} + +export function makeRequestor( + plainContextString: string, + serviceEndpoints: ServiceEndpoints, + paths: DataSourcePaths, + requests: Requests, + encoding: Encoding, + baseHeaders?: LDHeaders, + baseQueryParams?: { key: string; value: string }[], + withReasons?: boolean, + useReport?: boolean, + secureModeHash?: string, +) { + let body; + let method = 'GET'; + const headers: { [key: string]: string } = { ...baseHeaders }; + + if (useReport) { + method = 'REPORT'; + headers['content-type'] = 'application/json'; + body = plainContextString; // context is in body for REPORT + } + + const path = useReport + ? paths.pathReport(encoding, plainContextString) + : paths.pathGet(encoding, plainContextString); + + const parameters: { key: string; value: string }[] = [...(baseQueryParams ?? [])]; + if (withReasons) { + parameters.push({ key: 'withReasons', value: 'true' }); + } + if (secureModeHash) { + parameters.push({ key: 'h', value: secureModeHash }); + } + + const uri = getPollingUri(serviceEndpoints, path, parameters); + return new Requestor(requests, uri, headers, method, body); +} diff --git a/packages/shared/sdk-client/src/diagnostics/createDiagnosticsInitConfig.ts b/packages/shared/sdk-client/src/diagnostics/createDiagnosticsInitConfig.ts index aeb8bf3c5..da6be4db1 100644 --- a/packages/shared/sdk-client/src/diagnostics/createDiagnosticsInitConfig.ts +++ b/packages/shared/sdk-client/src/diagnostics/createDiagnosticsInitConfig.ts @@ -1,6 +1,6 @@ import { secondsToMillis, ServiceEndpoints } from '@launchdarkly/js-sdk-common'; -import Configuration from '../configuration'; +import { Configuration, DEFAULT_POLLING, DEFAULT_STREAM } from '../configuration'; export type DiagnosticsInitConfig = { // client & server common properties @@ -18,9 +18,9 @@ export type DiagnosticsInitConfig = { bootstrapMode: boolean; }; const createDiagnosticsInitConfig = (config: Configuration): DiagnosticsInitConfig => ({ - customBaseURI: config.baseUri !== Configuration.DEFAULT_POLLING, - customStreamURI: config.streamUri !== Configuration.DEFAULT_STREAM, - customEventsURI: config.eventsUri !== ServiceEndpoints.DEFAULT_EVENTS, + customBaseURI: config.serviceEndpoints.polling !== DEFAULT_POLLING, + customStreamURI: config.serviceEndpoints.streaming !== DEFAULT_STREAM, + customEventsURI: config.serviceEndpoints.events !== ServiceEndpoints.DEFAULT_EVENTS, eventsCapacity: config.capacity, eventsFlushIntervalMillis: secondsToMillis(config.flushInterval), reconnectTimeMillis: secondsToMillis(config.streamInitialReconnectDelay), diff --git a/packages/shared/sdk-client/src/diagnostics/createDiagnosticsManager.ts b/packages/shared/sdk-client/src/diagnostics/createDiagnosticsManager.ts index c1ed9928a..15ac0f4b1 100644 --- a/packages/shared/sdk-client/src/diagnostics/createDiagnosticsManager.ts +++ b/packages/shared/sdk-client/src/diagnostics/createDiagnosticsManager.ts @@ -1,6 +1,6 @@ import { internal, Platform } from '@launchdarkly/js-sdk-common'; -import Configuration from '../configuration'; +import { Configuration } from '../configuration'; import createDiagnosticsInitConfig from './createDiagnosticsInitConfig'; const createDiagnosticsManager = ( diff --git a/packages/shared/sdk-client/src/evaluation/evaluationDetail.ts b/packages/shared/sdk-client/src/evaluation/evaluationDetail.ts index f0e8d741c..16de25bfd 100644 --- a/packages/shared/sdk-client/src/evaluation/evaluationDetail.ts +++ b/packages/shared/sdk-client/src/evaluation/evaluationDetail.ts @@ -18,9 +18,10 @@ export function createSuccessEvaluationDetail( variationIndex?: number, reason?: LDEvaluationReason, ): LDEvaluationDetail { - return { + const res: LDEvaluationDetail = { value, variationIndex: variationIndex ?? null, reason: reason ?? null, }; + return res; } diff --git a/packages/shared/sdk-client/src/events/EventFactory.ts b/packages/shared/sdk-client/src/events/EventFactory.ts index 25ef0e055..fa052136c 100644 --- a/packages/shared/sdk-client/src/events/EventFactory.ts +++ b/packages/shared/sdk-client/src/events/EventFactory.ts @@ -14,7 +14,8 @@ export default class EventFactory extends internal.EventFactoryBase { context: Context, reason?: LDEvaluationReason, ): internal.InputEvalEvent { - const { trackEvents, debugEventsUntilDate, trackReason, version, variation } = flag; + const { trackEvents, debugEventsUntilDate, trackReason, flagVersion, version, variation } = + flag; return super.evalEvent({ addExperimentData: trackReason, @@ -23,10 +24,10 @@ export default class EventFactory extends internal.EventFactoryBase { defaultVal, flagKey, reason, - trackEvents, + trackEvents: !!trackEvents, value, variation, - version, + version: flagVersion ?? version, }); } } diff --git a/packages/shared/sdk-client/src/events/createEventProcessor.ts b/packages/shared/sdk-client/src/events/createEventProcessor.ts index 8e9075e7d..ab4887925 100644 --- a/packages/shared/sdk-client/src/events/createEventProcessor.ts +++ b/packages/shared/sdk-client/src/events/createEventProcessor.ts @@ -1,21 +1,22 @@ -import { ClientContext, internal, Platform } from '@launchdarkly/js-sdk-common'; +import { ClientContext, internal, LDHeaders, Platform } from '@launchdarkly/js-sdk-common'; -import Configuration from '../configuration'; +import { Configuration } from '../configuration'; const createEventProcessor = ( clientSideID: string, config: Configuration, platform: Platform, + baseHeaders: LDHeaders, diagnosticsManager?: internal.DiagnosticsManager, - start: boolean = false, ): internal.EventProcessor | undefined => { if (config.sendEvents) { return new internal.EventProcessor( { ...config, eventsCapacity: config.capacity }, new ClientContext(clientSideID, config, platform), + baseHeaders, undefined, diagnosticsManager, - start, + false, ); } diff --git a/packages/shared/sdk-client/src/flag-manager/FlagManager.ts b/packages/shared/sdk-client/src/flag-manager/FlagManager.ts index c90b32c51..61338c4f7 100644 --- a/packages/shared/sdk-client/src/flag-manager/FlagManager.ts +++ b/packages/shared/sdk-client/src/flag-manager/FlagManager.ts @@ -8,14 +8,59 @@ import { ItemDescriptor } from './ItemDescriptor'; /** * Top level manager of flags for the client. LDClient should be using this - * class and not any of the specific instances managed by it. Updates from + * interface and not any of the specific instances managed by it. Updates from * data sources should be directed to the [init] and [upsert] methods of this - * class. + * interface. */ -export default class FlagManager { - private flagStore = new DefaultFlagStore(); - private flagUpdater: FlagUpdater; - private flagPersistence: FlagPersistence; +export interface FlagManager { + /** + * Attempts to get a flag by key from the current flags. + */ + get(key: string): ItemDescriptor | undefined; + + /** + * Gets all the current flags. + */ + getAll(): { [key: string]: ItemDescriptor }; + + /** + * Initializes the flag manager with data from a data source. + * Persistence initialization is handled by {@link FlagPersistence} + */ + init(context: Context, newFlags: { [key: string]: ItemDescriptor }): Promise; + + /** + * Attempt to update a flag. If the flag is for the wrong context, or + * it is of an older version, then an update will not be performed. + */ + upsert(context: Context, key: string, item: ItemDescriptor): Promise; + + /** + * Asynchronously load cached values from persistence. + */ + loadCached(context: Context): Promise; + + /** + * Update in-memory storage with the specified flags, but do not persistent them to cache + * storage. + */ + setBootstrap(context: Context, newFlags: { [key: string]: ItemDescriptor }): void; + + /** + * Register a flag change callback. + */ + on(callback: FlagsChangeCallback): void; + + /** + * Unregister a flag change callback. + */ + off(callback: FlagsChangeCallback): void; +} + +export default class DefaultFlagManager implements FlagManager { + private _flagStore = new DefaultFlagStore(); + private _flagUpdater: FlagUpdater; + private _flagPersistencePromise: Promise; /** * @param platform implementation of various platform provided functionality @@ -29,70 +74,69 @@ export default class FlagManager { sdkKey: string, maxCachedContexts: number, logger: LDLogger, - private readonly timeStamper: () => number = () => Date.now(), + timeStamper: () => number = () => Date.now(), ) { - const environmentNamespace = namespaceForEnvironment(platform.crypto, sdkKey); + this._flagUpdater = new FlagUpdater(this._flagStore, logger); + this._flagPersistencePromise = this._initPersistence( + platform, + sdkKey, + maxCachedContexts, + logger, + timeStamper, + ); + } + + private async _initPersistence( + platform: Platform, + sdkKey: string, + maxCachedContexts: number, + logger: LDLogger, + timeStamper: () => number = () => Date.now(), + ): Promise { + const environmentNamespace = await namespaceForEnvironment(platform.crypto, sdkKey); - this.flagUpdater = new FlagUpdater(this.flagStore, logger); - this.flagPersistence = new FlagPersistence( + return new FlagPersistence( platform, environmentNamespace, maxCachedContexts, - this.flagStore, - this.flagUpdater, + this._flagStore, + this._flagUpdater, logger, timeStamper, ); } - /** - * Attempts to get a flag by key from the current flags. - */ get(key: string): ItemDescriptor | undefined { - return this.flagStore.get(key); + return this._flagStore.get(key); } - /** - * Gets all the current flags. - */ getAll(): { [key: string]: ItemDescriptor } { - return this.flagStore.getAll(); + return this._flagStore.getAll(); + } + + setBootstrap(context: Context, newFlags: { [key: string]: ItemDescriptor }): void { + // Bypasses the persistence as we do not want to put these flags into any cache. + // Generally speaking persistence likely *SHOULD* be disabled when using bootstrap. + this._flagUpdater.init(context, newFlags); } - /** - * Initializes the flag manager with data from a data source. - * Persistence initialization is handled by {@link FlagPersistence} - */ async init(context: Context, newFlags: { [key: string]: ItemDescriptor }): Promise { - return this.flagPersistence.init(context, newFlags); + return (await this._flagPersistencePromise).init(context, newFlags); } - /** - * Attempt to update a flag. If the flag is for the wrong context, or - * it is of an older version, then an update will not be performed. - */ async upsert(context: Context, key: string, item: ItemDescriptor): Promise { - return this.flagPersistence.upsert(context, key, item); + return (await this._flagPersistencePromise).upsert(context, key, item); } - /** - * Asynchronously load cached values from persistence. - */ async loadCached(context: Context): Promise { - return this.flagPersistence.loadCached(context); + return (await this._flagPersistencePromise).loadCached(context); } - /** - * Register a flag change callback. - */ on(callback: FlagsChangeCallback): void { - this.flagUpdater.on(callback); + this._flagUpdater.on(callback); } - /** - * Unregister a flag change callback. - */ off(callback: FlagsChangeCallback): void { - this.flagUpdater.off(callback); + this._flagUpdater.off(callback); } } diff --git a/packages/shared/sdk-client/src/flag-manager/FlagPersistence.ts b/packages/shared/sdk-client/src/flag-manager/FlagPersistence.ts index e7d903e24..3977412d2 100644 --- a/packages/shared/sdk-client/src/flag-manager/FlagPersistence.ts +++ b/packages/shared/sdk-client/src/flag-manager/FlagPersistence.ts @@ -13,19 +13,20 @@ import { ItemDescriptor } from './ItemDescriptor'; * then persists changes after the updater has completed. */ export default class FlagPersistence { - private contextIndex: ContextIndex | undefined; - private indexKey: string; + private _contextIndex: ContextIndex | undefined; + private _indexKey?: string; + private _indexKeyPromise: Promise; constructor( - private readonly platform: Platform, - private readonly environmentNamespace: string, - private readonly maxCachedContexts: number, - private readonly flagStore: FlagStore, - private readonly flagUpdater: FlagUpdater, - private readonly logger: LDLogger, - private readonly timeStamper: () => number = () => Date.now(), + private readonly _platform: Platform, + private readonly _environmentNamespace: string, + private readonly _maxCachedContexts: number, + private readonly _flagStore: FlagStore, + private readonly _flagUpdater: FlagUpdater, + private readonly _logger: LDLogger, + private readonly _timeStamper: () => number = () => Date.now(), ) { - this.indexKey = namespaceForContextIndex(this.environmentNamespace); + this._indexKeyPromise = namespaceForContextIndex(this._environmentNamespace); } /** @@ -33,8 +34,8 @@ export default class FlagPersistence { * in the underlying {@link FlagUpdater} switching its active context. */ async init(context: Context, newFlags: { [key: string]: ItemDescriptor }): Promise { - this.flagUpdater.init(context, newFlags); - await this.storeCache(context); + this._flagUpdater.init(context, newFlags); + await this._storeCache(context); } /** @@ -43,8 +44,8 @@ export default class FlagPersistence { * the active context. */ async upsert(context: Context, key: string, item: ItemDescriptor): Promise { - if (this.flagUpdater.upsert(context, key, item)) { - await this.storeCache(context); + if (this._flagUpdater.upsert(context, key, item)) { + await this._storeCache(context); return true; } return false; @@ -55,24 +56,24 @@ export default class FlagPersistence { * {@link FlagUpdater} this {@link FlagPersistence} was constructed with. */ async loadCached(context: Context): Promise { - const storageKey = namespaceForContextData( - this.platform.crypto, - this.environmentNamespace, + const storageKey = await namespaceForContextData( + this._platform.crypto, + this._environmentNamespace, context, ); - let flagsJson = await this.platform.storage?.get(storageKey); + let flagsJson = await this._platform.storage?.get(storageKey); if (flagsJson === null || flagsJson === undefined) { // Fallback: in version <10.3.1 flag data was stored under the canonical key, check // to see if data is present and migrate the data if present. - flagsJson = await this.platform.storage?.get(context.canonicalKey); + flagsJson = await this._platform.storage?.get(context.canonicalKey); if (flagsJson === null || flagsJson === undefined) { // return false indicating cache did not load if flag json is still absent return false; } // migrate data from version <10.3.1 and cleanup data that was under canonical key - await this.platform.storage?.set(storageKey, flagsJson); - await this.platform.storage?.clear(context.canonicalKey); + await this._platform.storage?.set(storageKey, flagsJson); + await this._platform.storage?.clear(context.canonicalKey); } try { @@ -87,53 +88,53 @@ export default class FlagPersistence { {}, ); - this.flagUpdater.initCached(context, descriptors); - this.logger.debug('Loaded cached flag evaluations from persistent storage'); + this._flagUpdater.initCached(context, descriptors); + this._logger.debug('Loaded cached flag evaluations from persistent storage'); return true; } catch (e: any) { - this.logger.warn( + this._logger.warn( `Could not load cached flag evaluations from persistent storage: ${e.message}`, ); return false; } } - private async loadIndex(): Promise { - if (this.contextIndex !== undefined) { - return this.contextIndex; + private async _loadIndex(): Promise { + if (this._contextIndex !== undefined) { + return this._contextIndex; } - const json = await this.platform.storage?.get(this.indexKey); + const json = await this._platform.storage?.get(await this._indexKeyPromise); if (!json) { - this.contextIndex = new ContextIndex(); - return this.contextIndex; + this._contextIndex = new ContextIndex(); + return this._contextIndex; } try { - this.contextIndex = ContextIndex.fromJson(json); - this.logger.debug('Loaded context index from persistent storage'); + this._contextIndex = ContextIndex.fromJson(json); + this._logger.debug('Loaded context index from persistent storage'); } catch (e: any) { - this.logger.warn(`Could not load index from persistent storage: ${e.message}`); - this.contextIndex = new ContextIndex(); + this._logger.warn(`Could not load index from persistent storage: ${e.message}`); + this._contextIndex = new ContextIndex(); } - return this.contextIndex; + return this._contextIndex; } - private async storeCache(context: Context): Promise { - const index = await this.loadIndex(); - const storageKey = namespaceForContextData( - this.platform.crypto, - this.environmentNamespace, + private async _storeCache(context: Context): Promise { + const index = await this._loadIndex(); + const storageKey = await namespaceForContextData( + this._platform.crypto, + this._environmentNamespace, context, ); - index.notice(storageKey, this.timeStamper()); + index.notice(storageKey, this._timeStamper()); - const pruned = index.prune(this.maxCachedContexts); - await Promise.all(pruned.map(async (it) => this.platform.storage?.clear(it.id))); + const pruned = index.prune(this._maxCachedContexts); + await Promise.all(pruned.map(async (it) => this._platform.storage?.clear(it.id))); // store index - await this.platform.storage?.set(this.indexKey, index.toJson()); - const allFlags = this.flagStore.getAll(); + await this._platform.storage?.set(await this._indexKeyPromise, index.toJson()); + const allFlags = this._flagStore.getAll(); // mapping item descriptors to flags const flags = Object.entries(allFlags).reduce((acc: Flags, [key, descriptor]) => { @@ -145,6 +146,6 @@ export default class FlagPersistence { const jsonAll = JSON.stringify(flags); // store flag data - await this.platform.storage?.set(storageKey, jsonAll); + await this._platform.storage?.set(storageKey, jsonAll); } } diff --git a/packages/shared/sdk-client/src/flag-manager/FlagStore.ts b/packages/shared/sdk-client/src/flag-manager/FlagStore.ts index d9ce91b11..2108aa646 100644 --- a/packages/shared/sdk-client/src/flag-manager/FlagStore.ts +++ b/packages/shared/sdk-client/src/flag-manager/FlagStore.ts @@ -14,10 +14,10 @@ export default interface FlagStore { * In memory flag store. */ export class DefaultFlagStore implements FlagStore { - private flags: { [key: string]: ItemDescriptor } = {}; + private _flags: { [key: string]: ItemDescriptor } = {}; init(newFlags: { [key: string]: ItemDescriptor }) { - this.flags = Object.entries(newFlags).reduce( + this._flags = Object.entries(newFlags).reduce( (acc: { [k: string]: ItemDescriptor }, [key, flag]) => { acc[key] = flag; return acc; @@ -27,14 +27,17 @@ export class DefaultFlagStore implements FlagStore { } insertOrUpdate(key: string, update: ItemDescriptor) { - this.flags[key] = update; + this._flags[key] = update; } get(key: string): ItemDescriptor | undefined { - return this.flags[key]; + if (Object.prototype.hasOwnProperty.call(this._flags, key)) { + return this._flags[key]; + } + return undefined; } getAll(): { [key: string]: ItemDescriptor } { - return this.flags; + return this._flags; } } diff --git a/packages/shared/sdk-client/src/flag-manager/FlagUpdater.ts b/packages/shared/sdk-client/src/flag-manager/FlagUpdater.ts index d96f9b393..c7b1a130f 100644 --- a/packages/shared/sdk-client/src/flag-manager/FlagUpdater.ts +++ b/packages/shared/sdk-client/src/flag-manager/FlagUpdater.ts @@ -4,6 +4,8 @@ import calculateChangedKeys from './calculateChangedKeys'; import FlagStore from './FlagStore'; import { ItemDescriptor } from './ItemDescriptor'; +export type FlagChangeType = 'init' | 'patch'; + /** * This callback indicates that the details associated with one or more flags * have changed. @@ -17,7 +19,11 @@ import { ItemDescriptor } from './ItemDescriptor'; * This event does not include the value of the flag. It is expected that you * will call a variation method for flag values which you require. */ -export type FlagsChangeCallback = (context: Context, flagKeys: Array) => void; +export type FlagsChangeCallback = ( + context: Context, + flagKeys: Array, + type: FlagChangeType, +) => void; /** * The flag updater handles logic required during the flag update process. @@ -25,25 +31,25 @@ export type FlagsChangeCallback = (context: Context, flagKeys: Array) => * also handles flag comparisons for change notification. */ export default class FlagUpdater { - private flagStore: FlagStore; - private logger: LDLogger; - private activeContextKey: string | undefined; - private changeCallbacks = new Array(); + private _flagStore: FlagStore; + private _logger: LDLogger; + private _activeContextKey: string | undefined; + private _changeCallbacks = new Array(); constructor(flagStore: FlagStore, logger: LDLogger) { - this.flagStore = flagStore; - this.logger = logger; + this._flagStore = flagStore; + this._logger = logger; } init(context: Context, newFlags: { [key: string]: ItemDescriptor }) { - this.activeContextKey = context.canonicalKey; - const oldFlags = this.flagStore.getAll(); - this.flagStore.init(newFlags); + this._activeContextKey = context.canonicalKey; + const oldFlags = this._flagStore.getAll(); + this._flagStore.init(newFlags); const changed = calculateChangedKeys(oldFlags, newFlags); if (changed.length > 0) { - this.changeCallbacks.forEach((callback) => { + this._changeCallbacks.forEach((callback) => { try { - callback(context, changed); + callback(context, changed, 'init'); } catch (err) { /* intentionally empty */ } @@ -52,7 +58,7 @@ export default class FlagUpdater { } initCached(context: Context, newFlags: { [key: string]: ItemDescriptor }) { - if (this.activeContextKey === context.canonicalKey) { + if (this._activeContextKey === context.canonicalKey) { return; } @@ -60,21 +66,21 @@ export default class FlagUpdater { } upsert(context: Context, key: string, item: ItemDescriptor): boolean { - if (this.activeContextKey !== context.canonicalKey) { - this.logger.warn('Received an update for an inactive context.'); + if (this._activeContextKey !== context.canonicalKey) { + this._logger.warn('Received an update for an inactive context.'); return false; } - const currentValue = this.flagStore.get(key); + const currentValue = this._flagStore.get(key); if (currentValue !== undefined && currentValue.version >= item.version) { // this is an out of order update that can be ignored return false; } - this.flagStore.insertOrUpdate(key, item); - this.changeCallbacks.forEach((callback) => { + this._flagStore.insertOrUpdate(key, item); + this._changeCallbacks.forEach((callback) => { try { - callback(context, [key]); + callback(context, [key], 'patch'); } catch (err) { /* intentionally empty */ } @@ -83,13 +89,13 @@ export default class FlagUpdater { } on(callback: FlagsChangeCallback): void { - this.changeCallbacks.push(callback); + this._changeCallbacks.push(callback); } off(callback: FlagsChangeCallback): void { - const index = this.changeCallbacks.indexOf(callback); + const index = this._changeCallbacks.indexOf(callback); if (index > -1) { - this.changeCallbacks.splice(index, 1); + this._changeCallbacks.splice(index, 1); } } } diff --git a/packages/shared/sdk-client/src/index.ts b/packages/shared/sdk-client/src/index.ts index 59821cf54..8d8c86ff6 100644 --- a/packages/shared/sdk-client/src/index.ts +++ b/packages/shared/sdk-client/src/index.ts @@ -1,4 +1,9 @@ +import { LDClientInternalOptions } from './configuration/Configuration'; +import DataSourceStatus, { DataSourceState } from './datasource/DataSourceStatus'; +import DataSourceStatusErrorInfo from './datasource/DataSourceStatusErrorInfo'; +import Requestor, { makeRequestor } from './datasource/Requestor'; import LDClientImpl from './LDClientImpl'; +import LDEmitter, { EventName } from './LDEmitter'; export * from '@launchdarkly/js-sdk-common'; @@ -14,6 +19,35 @@ export type { LDClient, LDOptions, ConnectionMode, + LDIdentifyOptions, + Hook, + HookMetadata, + EvaluationSeriesContext, + EvaluationSeriesData, + IdentifySeriesContext, + IdentifySeriesData, + IdentifySeriesResult, + IdentifySeriesStatus, + LDInspection, } from './api'; -export { LDClientImpl }; +export type { DataManager, DataManagerFactory, ConnectionParams } from './DataManager'; +export type { FlagManager } from './flag-manager/FlagManager'; +export type { Configuration } from './configuration/Configuration'; + +export type { LDEmitter }; +export type { ItemDescriptor } from './flag-manager/ItemDescriptor'; +export type { Flag } from './types'; + +export { DataSourcePaths } from './streaming'; +export { BaseDataManager } from './DataManager'; +export { makeRequestor, Requestor }; + +export { + DataSourceStatus, + DataSourceStatusErrorInfo, + LDClientImpl, + LDClientInternalOptions, + DataSourceState, + EventName as LDEmitterEventName, +}; diff --git a/packages/shared/sdk-client/src/inspection/InspectorManager.ts b/packages/shared/sdk-client/src/inspection/InspectorManager.ts new file mode 100644 index 000000000..779f80249 --- /dev/null +++ b/packages/shared/sdk-client/src/inspection/InspectorManager.ts @@ -0,0 +1,106 @@ +import { LDContext, LDLogger } from '@launchdarkly/js-sdk-common'; + +import { LDEvaluationDetail } from '../api'; +import { LDInspection } from '../api/LDInspection'; +import createSafeInspector from './createSafeInspector'; +import { invalidInspector } from './messages'; + +const FLAG_USED_TYPE = 'flag-used'; +const FLAG_DETAILS_CHANGED_TYPE = 'flag-details-changed'; +const FLAG_DETAIL_CHANGED_TYPE = 'flag-detail-changed'; +const IDENTITY_CHANGED_TYPE = 'client-identity-changed'; + +const VALID__TYPES = [ + FLAG_USED_TYPE, + FLAG_DETAILS_CHANGED_TYPE, + FLAG_DETAIL_CHANGED_TYPE, + IDENTITY_CHANGED_TYPE, +]; + +function validateInspector(inspector: LDInspection, logger: LDLogger): boolean { + const valid = + VALID__TYPES.includes(inspector.type) && + inspector.method && + typeof inspector.method === 'function'; + + if (!valid) { + logger.warn(invalidInspector(inspector.type, inspector.name)); + } + + return valid; +} + +/** + * Manages dispatching of inspection data to registered inspectors. + */ +export default class InspectorManager { + private _safeInspectors: LDInspection[] = []; + + constructor(inspectors: LDInspection[], logger: LDLogger) { + const validInspectors = inspectors.filter((inspector) => validateInspector(inspector, logger)); + this._safeInspectors = validInspectors.map((inspector) => + createSafeInspector(inspector, logger), + ); + } + + hasInspectors(): boolean { + return this._safeInspectors.length !== 0; + } + + /** + * Notify registered inspectors of a flag being used. + * + * @param flagKey The key for the flag. + * @param detail The LDEvaluationDetail for the flag. + * @param context The LDContext for the flag. + */ + onFlagUsed(flagKey: string, detail: LDEvaluationDetail, context?: LDContext) { + this._safeInspectors.forEach((inspector) => { + if (inspector.type === FLAG_USED_TYPE) { + inspector.method(flagKey, detail, context); + } + }); + } + + /** + * Notify registered inspectors that the flags have been replaced. + * + * @param flags The current flags as a Record. + */ + onFlagsChanged(flags: Record) { + this._safeInspectors.forEach((inspector) => { + if (inspector.type === FLAG_DETAILS_CHANGED_TYPE) { + inspector.method(flags); + } + }); + } + + /** + * Notify registered inspectors that a flag value has changed. + * + * @param flagKey The key for the flag that changed. + * @param flag An `LDEvaluationDetail` for the flag. + */ + onFlagChanged(flagKey: string, flag: LDEvaluationDetail) { + this._safeInspectors.forEach((inspector) => { + if (inspector.type === FLAG_DETAIL_CHANGED_TYPE) { + inspector.method(flagKey, flag); + } + }); + } + + /** + * Notify the registered inspectors that the context identity has changed. + * + * The notification itself will be dispatched asynchronously. + * + * @param context The `LDContext` which is now identified. + */ + onIdentityChanged(context: LDContext) { + this._safeInspectors.forEach((inspector) => { + if (inspector.type === IDENTITY_CHANGED_TYPE) { + inspector.method(context); + } + }); + } +} diff --git a/packages/shared/sdk-client/src/inspection/createSafeInspector.ts b/packages/shared/sdk-client/src/inspection/createSafeInspector.ts new file mode 100644 index 000000000..02f95ac1f --- /dev/null +++ b/packages/shared/sdk-client/src/inspection/createSafeInspector.ts @@ -0,0 +1,43 @@ +import { LDLogger } from '@launchdarkly/js-sdk-common'; + +import { LDInspection } from '../api/LDInspection'; +import { inspectorMethodError } from './messages'; + +/** + * Wrap an inspector ensuring that calling its methods are safe. + * @param inspector Inspector to wrap. + */ +export default function createSafeInspector( + inspector: LDInspection, + logger: LDLogger, +): LDInspection { + let errorLogged = false; + const wrapper: LDInspection = { + method: (...args: any[]) => { + try { + // We are proxying arguments here to the underlying method. Typescript doesn't care + // for this as it cannot validate the parameters are correct, but we are also the caller + // in this case and will dispatch things with the correct arguments. The dispatch to this + // will itself happen with a type guard. + // @ts-ignore + inspector.method(...args); + } catch { + // If something goes wrong in an inspector we want to log that something + // went wrong. We don't want to flood the logs, so we only log something + // the first time that something goes wrong. + // We do not include the exception in the log, because we do not know what + // kind of data it may contain. + if (!errorLogged) { + errorLogged = true; + logger.warn(inspectorMethodError(wrapper.type, wrapper.name)); + } + // Prevent errors. + } + }, + type: inspector.type, + name: inspector.name, + synchronous: inspector.synchronous, + }; + + return wrapper; +} diff --git a/packages/shared/sdk-client/src/inspection/getInspectorHook.ts b/packages/shared/sdk-client/src/inspection/getInspectorHook.ts new file mode 100644 index 000000000..14e33ce6d --- /dev/null +++ b/packages/shared/sdk-client/src/inspection/getInspectorHook.ts @@ -0,0 +1,24 @@ +import { EvaluationSeriesContext, EvaluationSeriesData, Hook, LDEvaluationDetail } from '../api'; +import InspectorManager from './InspectorManager'; + +export function getInspectorHook(inspectorManager: InspectorManager): Hook { + return { + getMetadata() { + return { + name: 'LaunchDarkly-Inspector-Adapter', + }; + }, + afterEvaluation: ( + hookContext: EvaluationSeriesContext, + data: EvaluationSeriesData, + detail: LDEvaluationDetail, + ) => { + inspectorManager.onFlagUsed(hookContext.flagKey, detail, hookContext.context); + return data; + }, + afterIdentify(hookContext, data, _result) { + inspectorManager.onIdentityChanged(hookContext.context); + return data; + }, + }; +} diff --git a/packages/shared/sdk-client/src/inspection/messages.ts b/packages/shared/sdk-client/src/inspection/messages.ts new file mode 100644 index 000000000..7a7094f4b --- /dev/null +++ b/packages/shared/sdk-client/src/inspection/messages.ts @@ -0,0 +1,7 @@ +export function invalidInspector(type: string, name: string) { + return `an inspector: "${name}" of an invalid type (${type}) was configured`; +} + +export function inspectorMethodError(type: string, name: string) { + return `an inspector: "${name}" of type: "${type}" generated an exception`; +} diff --git a/packages/shared/sdk-client/src/polling/PollingProcessor.ts b/packages/shared/sdk-client/src/polling/PollingProcessor.ts index 2b95f27fd..9366e6d31 100644 --- a/packages/shared/sdk-client/src/polling/PollingProcessor.ts +++ b/packages/shared/sdk-client/src/polling/PollingProcessor.ts @@ -1,87 +1,60 @@ import { - ApplicationTags, - getPollingUri, + DataSourceErrorKind, httpErrorMessage, HttpErrorResponse, - Info, isHttpRecoverable, LDLogger, LDPollingError, - Requests, - ServiceEndpoints, subsystem, } from '@launchdarkly/js-sdk-common'; +import Requestor, { LDRequestError } from '../datasource/Requestor'; import { Flags } from '../types'; -import Requestor, { LDRequestError } from './Requestor'; export type PollingErrorHandler = (err: LDPollingError) => void; -/** - * Subset of configuration required for polling. - * - * @internal - */ -export type PollingConfig = { - logger: LDLogger; - pollInterval: number; - tags: ApplicationTags; - useReport: boolean; - serviceEndpoints: ServiceEndpoints; -}; - /** * @internal */ export default class PollingProcessor implements subsystem.LDStreamProcessor { - private stopped = false; - - private logger?: LDLogger; - - private pollInterval: number; + private _stopped = false; - private timeoutHandle: any; - - private requestor: Requestor; + private _timeoutHandle: any; constructor( - sdkKey: string, - requests: Requests, - info: Info, - uriPath: string, - parameters: { key: string; value: string }[], - config: PollingConfig, - private readonly dataHandler: (flags: Flags) => void, - private readonly errorHandler?: PollingErrorHandler, - ) { - const uri = getPollingUri(config.serviceEndpoints, uriPath, parameters); - this.logger = config.logger; - this.pollInterval = config.pollInterval; - - this.requestor = new Requestor(sdkKey, requests, info, uri, config.useReport, config.tags); - } - - private async poll() { - if (this.stopped) { + private readonly _requestor: Requestor, + private readonly _pollIntervalSeconds: number, + private readonly _dataHandler: (flags: Flags) => void, + private readonly _errorHandler?: PollingErrorHandler, + private readonly _logger?: LDLogger, + ) {} + + private async _poll() { + if (this._stopped) { return; } const reportJsonError = (data: string) => { - this.logger?.error('Polling received invalid data'); - this.logger?.debug(`Invalid JSON follows: ${data}`); - this.errorHandler?.(new LDPollingError('Malformed JSON data in polling response')); + this._logger?.error('Polling received invalid data'); + this._logger?.debug(`Invalid JSON follows: ${data}`); + this._errorHandler?.( + new LDPollingError( + DataSourceErrorKind.InvalidData, + 'Malformed JSON data in polling response', + ), + ); }; - this.logger?.debug('Polling LaunchDarkly for feature flag updates'); + this._logger?.debug('Polling LaunchDarkly for feature flag updates'); const startTime = Date.now(); try { - const res = await this.requestor.requestPayload(); + const res = await this._requestor.requestPayload(); try { const flags = JSON.parse(res); try { - this.dataHandler?.(flags); + this._dataHandler?.(flags); } catch (err) { - this.logger?.error(`Exception from data handler: ${err}`); + this._logger?.error(`Exception from data handler: ${err}`); } } catch { reportJsonError(res); @@ -90,36 +63,42 @@ export default class PollingProcessor implements subsystem.LDStreamProcessor { const requestError = err as LDRequestError; if (requestError.status !== undefined) { if (!isHttpRecoverable(requestError.status)) { - this.logger?.error(httpErrorMessage(err as HttpErrorResponse, 'polling request')); - this.errorHandler?.(new LDPollingError(requestError.message, requestError.status)); + this._logger?.error(httpErrorMessage(err as HttpErrorResponse, 'polling request')); + this._errorHandler?.( + new LDPollingError( + DataSourceErrorKind.ErrorResponse, + requestError.message, + requestError.status, + ), + ); return; } } - this.logger?.error( + this._logger?.error( httpErrorMessage(err as HttpErrorResponse, 'polling request', 'will retry'), ); } const elapsed = Date.now() - startTime; - const sleepFor = Math.max(this.pollInterval * 1000 - elapsed, 0); + const sleepFor = Math.max(this._pollIntervalSeconds * 1000 - elapsed, 0); - this.logger?.debug('Elapsed: %d ms, sleeping for %d ms', elapsed, sleepFor); + this._logger?.debug('Elapsed: %d ms, sleeping for %d ms', elapsed, sleepFor); - this.timeoutHandle = setTimeout(() => { - this.poll(); + this._timeoutHandle = setTimeout(() => { + this._poll(); }, sleepFor); } start() { - this.poll(); + this._poll(); } stop() { - if (this.timeoutHandle) { - clearTimeout(this.timeoutHandle); - this.timeoutHandle = undefined; + if (this._timeoutHandle) { + clearTimeout(this._timeoutHandle); + this._timeoutHandle = undefined; } - this.stopped = true; + this._stopped = true; } close() { diff --git a/packages/shared/sdk-client/src/polling/Requestor.ts b/packages/shared/sdk-client/src/polling/Requestor.ts deleted file mode 100644 index 6a46dfcff..000000000 --- a/packages/shared/sdk-client/src/polling/Requestor.ts +++ /dev/null @@ -1,63 +0,0 @@ -// eslint-disable-next-line max-classes-per-file -import { - ApplicationTags, - defaultHeaders, - HttpErrorResponse, - Info, - Requests, -} from '@launchdarkly/js-sdk-common'; - -function isOk(status: number) { - return status >= 200 && status <= 299; -} - -export class LDRequestError extends Error implements HttpErrorResponse { - public status?: number; - - constructor(message: string, status?: number) { - super(message); - this.status = status; - this.name = 'LaunchDarklyRequestError'; - } -} - -/** - * Note: The requestor is implemented independently from polling such that it can be used to - * make a one-off request. - * - * @internal - */ -export default class Requestor { - private readonly headers: { [key: string]: string }; - private verb: string; - - constructor( - sdkKey: string, - private requests: Requests, - info: Info, - private readonly uri: string, - useReport: boolean, - tags: ApplicationTags, - ) { - this.headers = defaultHeaders(sdkKey, info, tags); - this.verb = useReport ? 'REPORT' : 'GET'; - } - - async requestPayload(): Promise { - let status: number | undefined; - try { - const res = await this.requests.fetch(this.uri, { - method: this.verb, - headers: this.headers, - }); - if (isOk(res.status)) { - return await res.text(); - } - // Assigning so it can be thrown after the try/catch. - status = res.status; - } catch (err: any) { - throw new LDRequestError(err?.message); - } - throw new LDRequestError(`Unexpected status code: ${status}`, status); - } -} diff --git a/packages/shared/sdk-client/src/storage/namespaceUtils.ts b/packages/shared/sdk-client/src/storage/namespaceUtils.ts index c977bf18a..e5a28123c 100644 --- a/packages/shared/sdk-client/src/storage/namespaceUtils.ts +++ b/packages/shared/sdk-client/src/storage/namespaceUtils.ts @@ -1,24 +1,26 @@ import { Context, Crypto } from '@launchdarkly/js-sdk-common'; +import digest from '../crypto/digest'; + export type Namespace = 'LaunchDarkly' | 'AnonymousKeys' | 'ContextKeys' | 'ContextIndex'; /** * Hashes the input and encodes it as base64 */ -function hashAndBase64Encode(crypto: Crypto): (input: string) => string { - return (input) => crypto.createHash('sha256').update(input).digest('base64'); +function hashAndBase64Encode(crypto: Crypto): (input: string) => Promise { + return async (input) => digest(crypto.createHash('sha256').update(input), 'base64'); } -const noop = (input: string) => input; // no-op transform +const noop = async (input: string) => input; // no-op transform -export function concatNamespacesAndValues( - parts: { value: Namespace | string; transform: (value: string) => string }[], -): string { - const processedParts = parts.map((part) => part.transform(part.value)); // use the transform from each part to transform the value +export async function concatNamespacesAndValues( + parts: { value: Namespace | string; transform: (value: string) => Promise }[], +): Promise { + const processedParts = await Promise.all(parts.map((part) => part.transform(part.value))); // use the transform from each part to transform the value return processedParts.join('_'); } -export function namespaceForEnvironment(crypto: Crypto, sdkKey: string): string { +export async function namespaceForEnvironment(crypto: Crypto, sdkKey: string): Promise { return concatNamespacesAndValues([ { value: 'LaunchDarkly', transform: noop }, { value: sdkKey, transform: hashAndBase64Encode(crypto) }, // hash sdk key and encode it @@ -33,7 +35,7 @@ export function namespaceForEnvironment(crypto: Crypto, sdkKey: string): string * when the data under the LaunchDarkly_AnonymousKeys namespace is merged with data under the * LaunchDarkly_ContextKeys namespace. */ -export function namespaceForAnonymousGeneratedContextKey(kind: string): string { +export async function namespaceForAnonymousGeneratedContextKey(kind: string): Promise { return concatNamespacesAndValues([ { value: 'LaunchDarkly', transform: noop }, { value: 'AnonymousKeys', transform: noop }, @@ -41,7 +43,7 @@ export function namespaceForAnonymousGeneratedContextKey(kind: string): string { ]); } -export function namespaceForGeneratedContextKey(kind: string): string { +export async function namespaceForGeneratedContextKey(kind: string): Promise { return concatNamespacesAndValues([ { value: 'LaunchDarkly', transform: noop }, { value: 'ContextKeys', transform: noop }, @@ -49,18 +51,18 @@ export function namespaceForGeneratedContextKey(kind: string): string { ]); } -export function namespaceForContextIndex(environmentNamespace: string): string { +export async function namespaceForContextIndex(environmentNamespace: string): Promise { return concatNamespacesAndValues([ { value: environmentNamespace, transform: noop }, { value: 'ContextIndex', transform: noop }, ]); } -export function namespaceForContextData( +export async function namespaceForContextData( crypto: Crypto, environmentNamespace: string, context: Context, -): string { +): Promise { return concatNamespacesAndValues([ { value: environmentNamespace, transform: noop }, // use existing namespace as is, don't transform { value: context.canonicalKey, transform: hashAndBase64Encode(crypto) }, // hash and encode canonical key diff --git a/packages/shared/sdk-client/src/streaming/StreamingProcessor.ts b/packages/shared/sdk-client/src/streaming/StreamingProcessor.ts new file mode 100644 index 000000000..f6aad31af --- /dev/null +++ b/packages/shared/sdk-client/src/streaming/StreamingProcessor.ts @@ -0,0 +1,228 @@ +import { + DataSourceErrorKind, + Encoding, + EventName, + EventSource, + getStreamingUri, + httpErrorMessage, + HttpErrorResponse, + internal, + LDLogger, + LDPollingError, + LDStreamingError, + ProcessStreamResponse, + Requests, + shouldRetry, + subsystem, +} from '@launchdarkly/js-sdk-common'; + +import { StreamingDataSourceConfig } from '../datasource/DataSourceConfig'; +import Requestor, { LDRequestError } from '../datasource/Requestor'; + +const reportJsonError = ( + type: string, + data: string, + logger?: LDLogger, + errorHandler?: internal.StreamingErrorHandler, +) => { + logger?.error(`Stream received invalid data in "${type}" message`); + logger?.debug(`Invalid JSON follows: ${data}`); + errorHandler?.( + new LDStreamingError(DataSourceErrorKind.InvalidData, 'Malformed JSON data in event stream'), + ); +}; + +class StreamingProcessor implements subsystem.LDStreamProcessor { + private readonly _headers: { [key: string]: string | string[] }; + private readonly _streamUri: string; + + private _eventSource?: EventSource; + private _connectionAttemptStartTime?: number; + + constructor( + private readonly _plainContextString: string, + private readonly _dataSourceConfig: StreamingDataSourceConfig, + private readonly _listeners: Map, + private readonly _requests: Requests, + encoding: Encoding, + private readonly _pollingRequestor: Requestor, + private readonly _diagnosticsManager?: internal.DiagnosticsManager, + private readonly _errorHandler?: internal.StreamingErrorHandler, + private readonly _logger?: LDLogger, + ) { + let path: string; + if (_dataSourceConfig.useReport && !_requests.getEventSourceCapabilities().customMethod) { + path = _dataSourceConfig.paths.pathPing(encoding, _plainContextString); + } else { + path = _dataSourceConfig.useReport + ? _dataSourceConfig.paths.pathReport(encoding, _plainContextString) + : _dataSourceConfig.paths.pathGet(encoding, _plainContextString); + } + const parameters: { key: string; value: string }[] = [ + ...(_dataSourceConfig.queryParameters ?? []), + ]; + if (this._dataSourceConfig.withReasons) { + parameters.push({ key: 'withReasons', value: 'true' }); + } + + this._requests = _requests; + this._headers = { ..._dataSourceConfig.baseHeaders }; + this._logger = _logger; + this._streamUri = getStreamingUri(_dataSourceConfig.serviceEndpoints, path, parameters); + } + + private _logConnectionStarted() { + this._connectionAttemptStartTime = Date.now(); + } + + private _logConnectionResult(success: boolean) { + if (this._connectionAttemptStartTime && this._diagnosticsManager) { + this._diagnosticsManager.recordStreamInit( + this._connectionAttemptStartTime, + !success, + Date.now() - this._connectionAttemptStartTime, + ); + } + + this._connectionAttemptStartTime = undefined; + } + + /** + * This is a wrapper around the passed errorHandler which adds additional + * diagnostics and logging logic. + * + * @param err The error to be logged and handled. + * @return boolean whether to retry the connection. + * + * @private + */ + private _retryAndHandleError(err: HttpErrorResponse) { + if (!shouldRetry(err)) { + this._logConnectionResult(false); + this._errorHandler?.( + new LDStreamingError(DataSourceErrorKind.ErrorResponse, err.message, err.status, false), + ); + this._logger?.error(httpErrorMessage(err, 'streaming request')); + return false; + } + + this._logger?.warn(httpErrorMessage(err, 'streaming request', 'will retry')); + this._logConnectionResult(false); + this._logConnectionStarted(); + return true; + } + + start() { + this._logConnectionStarted(); + + let methodAndBodyOverrides; + if (this._dataSourceConfig.useReport) { + // REPORT will include a body, so content type is required. + this._headers['content-type'] = 'application/json'; + + // orverrides default method with REPORT and adds body. + methodAndBodyOverrides = { method: 'REPORT', body: this._plainContextString }; + } else { + // no method or body override + methodAndBodyOverrides = {}; + } + + // TLS is handled by the platform implementation. + const eventSource = this._requests.createEventSource(this._streamUri, { + headers: this._headers, // adds content-type header required when body will be present + ...methodAndBodyOverrides, + errorFilter: (error: HttpErrorResponse) => this._retryAndHandleError(error), + initialRetryDelayMillis: this._dataSourceConfig.initialRetryDelayMillis, + readTimeoutMillis: 5 * 60 * 1000, + retryResetIntervalMillis: 60 * 1000, + }); + this._eventSource = eventSource; + + eventSource.onclose = () => { + this._logger?.info('Closed LaunchDarkly stream connection'); + }; + + eventSource.onerror = () => { + // The work is done by `errorFilter`. + }; + + eventSource.onopen = () => { + this._logger?.info('Opened LaunchDarkly stream connection'); + }; + + eventSource.onretrying = (e) => { + this._logger?.info(`Will retry stream connection in ${e.delayMillis} milliseconds`); + }; + + this._listeners.forEach(({ deserializeData, processJson }, eventName) => { + eventSource.addEventListener(eventName, (event) => { + this._logger?.debug(`Received ${eventName} event`); + + if (event?.data) { + this._logConnectionResult(true); + const { data } = event; + const dataJson = deserializeData(data); + + if (!dataJson) { + reportJsonError(eventName, data, this._logger, this._errorHandler); + return; + } + processJson(dataJson); + } else { + this._errorHandler?.( + new LDStreamingError( + DataSourceErrorKind.InvalidData, + 'Unexpected payload from event stream', + ), + ); + } + }); + }); + + // here we set up a listener that will poll when ping is received + eventSource.addEventListener('ping', async () => { + this._logger?.debug('Got PING, going to poll LaunchDarkly for feature flag updates'); + try { + const res = await this._pollingRequestor.requestPayload(); + try { + const payload = JSON.parse(res); + try { + // forward the payload on to the PUT listener + this._listeners.get('put')?.processJson(payload); + } catch (err) { + this._logger?.error(`Exception from data handler: ${err}`); + } + } catch { + this._logger?.error('Polling after ping received invalid data'); + this._logger?.debug(`Invalid JSON follows: ${res}`); + this._errorHandler?.( + new LDPollingError( + DataSourceErrorKind.InvalidData, + 'Malformed JSON data in ping polling response', + ), + ); + } + } catch (err) { + const requestError = err as LDRequestError; + this._errorHandler?.( + new LDPollingError( + DataSourceErrorKind.ErrorResponse, + requestError.message, + requestError.status, + ), + ); + } + }); + } + + stop() { + this._eventSource?.close(); + this._eventSource = undefined; + } + + close() { + this.stop(); + } +} + +export default StreamingProcessor; diff --git a/packages/shared/sdk-client/src/streaming/index.ts b/packages/shared/sdk-client/src/streaming/index.ts new file mode 100644 index 000000000..cb1074706 --- /dev/null +++ b/packages/shared/sdk-client/src/streaming/index.ts @@ -0,0 +1,8 @@ +import { + DataSourcePaths, + PollingDataSourceConfig, + StreamingDataSourceConfig, +} from '../datasource/DataSourceConfig'; +import StreamingProcessor from './StreamingProcessor'; + +export { DataSourcePaths, PollingDataSourceConfig, StreamingProcessor, StreamingDataSourceConfig }; diff --git a/packages/shared/sdk-client/src/types/index.ts b/packages/shared/sdk-client/src/types/index.ts index 18b24736d..c424c0e3d 100644 --- a/packages/shared/sdk-client/src/types/index.ts +++ b/packages/shared/sdk-client/src/types/index.ts @@ -2,14 +2,15 @@ import { LDEvaluationReason, LDFlagValue } from '@launchdarkly/js-sdk-common'; export interface Flag { version: number; - flagVersion: number; + flagVersion?: number; value: LDFlagValue; - variation: number; - trackEvents: boolean; + variation?: number; + trackEvents?: boolean; trackReason?: boolean; reason?: LDEvaluationReason; debugEventsUntilDate?: number; deleted?: boolean; + prerequisites?: string[]; } export interface PatchFlag extends Flag { diff --git a/packages/shared/sdk-client/tsconfig.json b/packages/shared/sdk-client/tsconfig.json index a3374fce0..e247bb278 100644 --- a/packages/shared/sdk-client/tsconfig.json +++ b/packages/shared/sdk-client/tsconfig.json @@ -2,9 +2,10 @@ "compilerOptions": { "rootDir": "src", "outDir": "dist", - "target": "ES2017", + "target": "ES2020", "lib": ["es6", "DOM"], - "module": "commonjs", + "module": "ESNext", + "moduleResolution": "node", "strict": true, "noImplicitOverride": true, // Needed for CommonJS modules: markdown-it, fs-extra diff --git a/packages/shared/sdk-server-edge/CHANGELOG.md b/packages/shared/sdk-server-edge/CHANGELOG.md index 3b0b8a63e..9f3f3d788 100644 --- a/packages/shared/sdk-server-edge/CHANGELOG.md +++ b/packages/shared/sdk-server-edge/CHANGELOG.md @@ -96,6 +96,61 @@ * dependencies * @launchdarkly/js-server-sdk-common bumped from 2.2.1 to 2.2.2 +## [2.5.0](https://github.com/launchdarkly/js-core/compare/js-server-sdk-common-edge-v2.4.1...js-server-sdk-common-edge-v2.5.0) (2024-10-17) + + +### Features + +* Apply private property naming standard. Mangle browser private properties. ([#620](https://github.com/launchdarkly/js-core/issues/620)) ([3e6d404](https://github.com/launchdarkly/js-core/commit/3e6d404ae665c5cc7e5a1394a59c8f2c9d5d682a)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common bumped from 2.8.0 to 2.9.0 + +## [2.4.1](https://github.com/launchdarkly/js-core/compare/js-server-sdk-common-edge-v2.4.0...js-server-sdk-common-edge-v2.4.1) (2024-10-09) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common bumped from 2.7.0 to 2.8.0 + +## [2.4.0](https://github.com/launchdarkly/js-core/compare/js-server-sdk-common-edge-v2.3.9...js-server-sdk-common-edge-v2.4.0) (2024-09-26) + + +### Features + +* Add support for conditional event source capabilities. ([#577](https://github.com/launchdarkly/js-core/issues/577)) ([fe82500](https://github.com/launchdarkly/js-core/commit/fe82500f28cf8d8311502098aa6cc2e73932064e)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common bumped from 2.6.1 to 2.7.0 + +## [2.3.9](https://github.com/launchdarkly/js-core/compare/js-server-sdk-common-edge-v2.3.8...js-server-sdk-common-edge-v2.3.9) (2024-09-05) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common bumped from 2.6.0 to 2.6.1 + +## [2.3.8](https://github.com/launchdarkly/js-core/compare/js-server-sdk-common-edge-v2.3.7...js-server-sdk-common-edge-v2.3.8) (2024-09-03) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common bumped from 2.5.0 to 2.6.0 + ## [2.3.7](https://github.com/launchdarkly/js-core/compare/js-server-sdk-common-edge-v2.3.6...js-server-sdk-common-edge-v2.3.7) (2024-08-28) diff --git a/packages/shared/sdk-server-edge/src/api/EdgeFeatureStore.test.ts b/packages/shared/sdk-server-edge/__tests__/api/EdgeFeatureStore.test.ts similarity index 95% rename from packages/shared/sdk-server-edge/src/api/EdgeFeatureStore.test.ts rename to packages/shared/sdk-server-edge/__tests__/api/EdgeFeatureStore.test.ts index 4b8acf6f1..203d0bee0 100644 --- a/packages/shared/sdk-server-edge/src/api/EdgeFeatureStore.test.ts +++ b/packages/shared/sdk-server-edge/__tests__/api/EdgeFeatureStore.test.ts @@ -1,8 +1,8 @@ import { AsyncStoreFacade, LDFeatureStore } from '@launchdarkly/js-server-sdk-common'; -import mockEdgeProvider from '../utils/mockEdgeProvider'; -import * as testData from '../utils/testData.json'; -import { EdgeFeatureStore } from './EdgeFeatureStore'; +import { EdgeFeatureStore } from '../../src/api/EdgeFeatureStore'; +import mockEdgeProvider from '../../src/utils/mockEdgeProvider'; +import * as testData from './testData.json'; describe('EdgeFeatureStore', () => { const sdkKey = 'sdkKey'; diff --git a/packages/shared/sdk-server-edge/src/api/LDClient.test.ts b/packages/shared/sdk-server-edge/__tests__/api/LDClient.test.ts similarity index 91% rename from packages/shared/sdk-server-edge/src/api/LDClient.test.ts rename to packages/shared/sdk-server-edge/__tests__/api/LDClient.test.ts index d91e1a751..26bad83fa 100644 --- a/packages/shared/sdk-server-edge/src/api/LDClient.test.ts +++ b/packages/shared/sdk-server-edge/__tests__/api/LDClient.test.ts @@ -1,7 +1,7 @@ import { internal } from '@launchdarkly/js-server-sdk-common'; -import { createBasicPlatform } from '@launchdarkly/private-js-mocks'; -import LDClient from './LDClient'; +import LDClient from '../../src/api/LDClient'; +import { createBasicPlatform } from '../createBasicPlatform'; jest.mock('@launchdarkly/js-sdk-common', () => { const actual = jest.requireActual('@launchdarkly/js-sdk-common'); diff --git a/packages/shared/sdk-server-edge/src/api/createCallbacks.test.ts b/packages/shared/sdk-server-edge/__tests__/api/createCallbacks.test.ts similarity index 89% rename from packages/shared/sdk-server-edge/src/api/createCallbacks.test.ts rename to packages/shared/sdk-server-edge/__tests__/api/createCallbacks.test.ts index 43d8d8150..665f3f19b 100644 --- a/packages/shared/sdk-server-edge/src/api/createCallbacks.test.ts +++ b/packages/shared/sdk-server-edge/__tests__/api/createCallbacks.test.ts @@ -1,16 +1,19 @@ import { EventEmitter } from 'node:events'; -import { noop } from '@launchdarkly/js-server-sdk-common'; -import { createLogger } from '@launchdarkly/private-js-mocks'; +import { LDLogger, noop } from '@launchdarkly/js-server-sdk-common'; -import createCallbacks from './createCallbacks'; +import createCallbacks from '../../src/api/createCallbacks'; -let logger: ReturnType; +let logger: LDLogger; beforeEach(() => { - logger = createLogger(); + logger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }; }); - describe('createCallbacks', () => { let emitter: EventEmitter; const err = new Error('test error'); diff --git a/packages/shared/sdk-server-edge/src/api/createOptions.test.ts b/packages/shared/sdk-server-edge/__tests__/api/createOptions.test.ts similarity index 83% rename from packages/shared/sdk-server-edge/src/api/createOptions.test.ts rename to packages/shared/sdk-server-edge/__tests__/api/createOptions.test.ts index 4c5d4962b..02fcc4b45 100644 --- a/packages/shared/sdk-server-edge/src/api/createOptions.test.ts +++ b/packages/shared/sdk-server-edge/__tests__/api/createOptions.test.ts @@ -1,6 +1,6 @@ import { BasicLogger } from '@launchdarkly/js-server-sdk-common'; -import createOptions, { defaultOptions } from './createOptions'; +import createOptions, { defaultOptions } from '../../src/api/createOptions'; describe('createOptions', () => { test('default options', () => { diff --git a/packages/shared/sdk-server-edge/src/utils/testData.json b/packages/shared/sdk-server-edge/__tests__/api/testData.json similarity index 100% rename from packages/shared/sdk-server-edge/src/utils/testData.json rename to packages/shared/sdk-server-edge/__tests__/api/testData.json diff --git a/packages/shared/sdk-server-edge/__tests__/createBasicPlatform.ts b/packages/shared/sdk-server-edge/__tests__/createBasicPlatform.ts new file mode 100644 index 000000000..e5139ccec --- /dev/null +++ b/packages/shared/sdk-server-edge/__tests__/createBasicPlatform.ts @@ -0,0 +1,59 @@ +import { PlatformData, SdkData } from '@launchdarkly/js-server-sdk-common'; + +import { setupCrypto } from './setupCrypto'; + +const setupInfo = () => ({ + platformData: jest.fn( + (): PlatformData => ({ + os: { + name: 'An OS', + version: '1.0.1', + arch: 'An Arch', + }, + name: 'The SDK Name', + additional: { + nodeVersion: '42', + }, + ld_application: { + key: '', + envAttributesVersion: '1.0', + id: 'com.testapp.ld', + name: 'LDApplication.TestApp', + version: '1.1.1', + }, + ld_device: { + key: '', + envAttributesVersion: '1.0', + os: { name: 'Another OS', version: '99', family: 'orange' }, + manufacturer: 'coconut', + }, + }), + ), + sdkData: jest.fn( + (): SdkData => ({ + name: 'An SDK', + version: '2.0.2', + userAgentBase: 'TestUserAgent', + wrapperName: 'Rapper', + wrapperVersion: '1.2.3', + }), + ), +}); + +export const createBasicPlatform = () => ({ + encoding: { + btoa: (s: string) => Buffer.from(s).toString('base64'), + }, + info: setupInfo(), + crypto: setupCrypto(), + requests: { + fetch: jest.fn(), + createEventSource: jest.fn(), + getEventSourceCapabilities: jest.fn(), + }, + storage: { + get: jest.fn(), + set: jest.fn(), + clear: jest.fn(), + }, +}); diff --git a/packages/shared/sdk-server-edge/__tests__/setupCrypto.ts b/packages/shared/sdk-server-edge/__tests__/setupCrypto.ts new file mode 100644 index 000000000..bdf62024f --- /dev/null +++ b/packages/shared/sdk-server-edge/__tests__/setupCrypto.ts @@ -0,0 +1,20 @@ +import { Hasher } from '@launchdarkly/js-server-sdk-common'; + +export const setupCrypto = () => { + let counter = 0; + const hasher = { + update: jest.fn((): Hasher => hasher), + digest: jest.fn(() => '1234567890123456'), + }; + + return { + createHash: jest.fn(() => hasher), + createHmac: jest.fn(), + randomUUID: jest.fn(() => { + counter += 1; + // Will provide a unique value for tests. + // Very much not a UUID of course. + return `${counter}`; + }), + }; +}; diff --git a/packages/shared/sdk-server-edge/src/utils/validateOptions.test.ts b/packages/shared/sdk-server-edge/__tests__/utils/validateOptions.test.ts similarity index 91% rename from packages/shared/sdk-server-edge/src/utils/validateOptions.test.ts rename to packages/shared/sdk-server-edge/__tests__/utils/validateOptions.test.ts index 4836160ee..cc39f925c 100644 --- a/packages/shared/sdk-server-edge/src/utils/validateOptions.test.ts +++ b/packages/shared/sdk-server-edge/__tests__/utils/validateOptions.test.ts @@ -1,7 +1,7 @@ import { BasicLogger } from '@launchdarkly/js-server-sdk-common'; -import mockFeatureStore from './mockFeatureStore'; -import validateOptions from './validateOptions'; +import mockFeatureStore from '../../src/utils/mockFeatureStore'; +import validateOptions from '../../src/utils/validateOptions'; describe('validateOptions', () => { test('throws without SDK key', () => { diff --git a/packages/shared/sdk-server-edge/package.json b/packages/shared/sdk-server-edge/package.json index 4a4f09432..a878b98c1 100644 --- a/packages/shared/sdk-server-edge/package.json +++ b/packages/shared/sdk-server-edge/package.json @@ -1,6 +1,6 @@ { "name": "@launchdarkly/js-server-sdk-common-edge", - "version": "2.3.7", + "version": "2.5.0", "homepage": "https://github.com/launchdarkly/js-core/tree/main/packages/shared/sdk-server-edge", "repository": { "type": "git", @@ -36,11 +36,10 @@ "check": "yarn prettier && yarn lint && yarn build && yarn test && yarn doc" }, "dependencies": { - "@launchdarkly/js-server-sdk-common": "2.5.0", + "@launchdarkly/js-server-sdk-common": "2.9.0", "crypto-js": "^4.1.1" }, "devDependencies": { - "@launchdarkly/private-js-mocks": "0.0.1", "@trivago/prettier-plugin-sort-imports": "^4.1.1", "@types/crypto-js": "^4.1.1", "@types/jest": "^29.5.0", diff --git a/packages/shared/sdk-server-edge/src/api/EdgeFeatureStore.ts b/packages/shared/sdk-server-edge/src/api/EdgeFeatureStore.ts index 933943d20..039ee4a30 100644 --- a/packages/shared/sdk-server-edge/src/api/EdgeFeatureStore.ts +++ b/packages/shared/sdk-server-edge/src/api/EdgeFeatureStore.ts @@ -13,15 +13,15 @@ export interface EdgeProvider { } export class EdgeFeatureStore implements LDFeatureStore { - private readonly rootKey: string; + private readonly _rootKey: string; constructor( - private readonly edgeProvider: EdgeProvider, - private readonly sdkKey: string, - private readonly description: string, - private logger: LDLogger, + private readonly _edgeProvider: EdgeProvider, + sdkKey: string, + private readonly _description: string, + private _logger: LDLogger, ) { - this.rootKey = `LD-Env-${sdkKey}`; + this._rootKey = `LD-Env-${sdkKey}`; } async get( @@ -31,13 +31,13 @@ export class EdgeFeatureStore implements LDFeatureStore { ): Promise { const { namespace } = kind; const kindKey = namespace === 'features' ? 'flags' : namespace; - this.logger.debug(`Requesting ${dataKey} from ${this.rootKey}.${kindKey}`); + this._logger.debug(`Requesting ${dataKey} from ${this._rootKey}.${kindKey}`); try { - const i = await this.edgeProvider.get(this.rootKey); + const i = await this._edgeProvider.get(this._rootKey); if (!i) { - throw new Error(`${this.rootKey}.${kindKey} is not found in KV.`); + throw new Error(`${this._rootKey}.${kindKey} is not found in KV.`); } const item = deserializePoll(i); @@ -56,7 +56,7 @@ export class EdgeFeatureStore implements LDFeatureStore { callback(null); } } catch (err) { - this.logger.error(err); + this._logger.error(err); callback(null); } } @@ -64,11 +64,11 @@ export class EdgeFeatureStore implements LDFeatureStore { async all(kind: DataKind, callback: (res: LDFeatureStoreKindData) => void = noop): Promise { const { namespace } = kind; const kindKey = namespace === 'features' ? 'flags' : namespace; - this.logger.debug(`Requesting all from ${this.rootKey}.${kindKey}`); + this._logger.debug(`Requesting all from ${this._rootKey}.${kindKey}`); try { - const i = await this.edgeProvider.get(this.rootKey); + const i = await this._edgeProvider.get(this._rootKey); if (!i) { - throw new Error(`${this.rootKey}.${kindKey} is not found in KV.`); + throw new Error(`${this._rootKey}.${kindKey} is not found in KV.`); } const item = deserializePoll(i); @@ -87,15 +87,15 @@ export class EdgeFeatureStore implements LDFeatureStore { callback({}); } } catch (err) { - this.logger.error(err); + this._logger.error(err); callback({}); } } async initialized(callback: (isInitialized: boolean) => void = noop): Promise { - const config = await this.edgeProvider.get(this.rootKey); + const config = await this._edgeProvider.get(this._rootKey); const result = config !== null; - this.logger.debug(`Is ${this.rootKey} initialized? ${result}`); + this._logger.debug(`Is ${this._rootKey} initialized? ${result}`); callback(result); } @@ -104,7 +104,7 @@ export class EdgeFeatureStore implements LDFeatureStore { } getDescription(): string { - return this.description; + return this._description; } // unused diff --git a/packages/shared/sdk-server-edge/src/platform/crypto/cryptoJSHasher.ts b/packages/shared/sdk-server-edge/src/platform/crypto/cryptoJSHasher.ts index b8596649e..4aec22105 100644 --- a/packages/shared/sdk-server-edge/src/platform/crypto/cryptoJSHasher.ts +++ b/packages/shared/sdk-server-edge/src/platform/crypto/cryptoJSHasher.ts @@ -5,7 +5,7 @@ import { Hasher as LDHasher } from '@launchdarkly/js-server-sdk-common'; import { SupportedHashAlgorithm, SupportedOutputEncoding } from './types'; export default class CryptoJSHasher implements LDHasher { - private cryptoJSHasher; + private _cryptoJSHasher; constructor(algorithm: SupportedHashAlgorithm) { let algo; @@ -21,11 +21,11 @@ export default class CryptoJSHasher implements LDHasher { throw new Error('unsupported hash algorithm. Only sha1 and sha256 are supported.'); } - this.cryptoJSHasher = algo.create(); + this._cryptoJSHasher = algo.create(); } digest(encoding: SupportedOutputEncoding): string { - const result = this.cryptoJSHasher.finalize(); + const result = this._cryptoJSHasher.finalize(); let enc; switch (encoding) { @@ -43,7 +43,7 @@ export default class CryptoJSHasher implements LDHasher { } update(data: string): this { - this.cryptoJSHasher.update(data); + this._cryptoJSHasher.update(data); return this; } } diff --git a/packages/shared/sdk-server-edge/src/platform/crypto/cryptoJSHmac.ts b/packages/shared/sdk-server-edge/src/platform/crypto/cryptoJSHmac.ts index 38f7ea259..98e8976bb 100644 --- a/packages/shared/sdk-server-edge/src/platform/crypto/cryptoJSHmac.ts +++ b/packages/shared/sdk-server-edge/src/platform/crypto/cryptoJSHmac.ts @@ -5,7 +5,7 @@ import { Hmac as LDHmac } from '@launchdarkly/js-server-sdk-common'; import { SupportedHashAlgorithm, SupportedOutputEncoding } from './types'; export default class CryptoJSHmac implements LDHmac { - private CryptoJSHmac; + private _cryptoJSHmac; constructor(algorithm: SupportedHashAlgorithm, key: string) { let algo; @@ -21,11 +21,11 @@ export default class CryptoJSHmac implements LDHmac { throw new Error('unsupported hash algorithm. Only sha1 and sha256 are supported.'); } - this.CryptoJSHmac = CryptoJS.algo.HMAC.create(algo, key); + this._cryptoJSHmac = CryptoJS.algo.HMAC.create(algo, key); } digest(encoding: SupportedOutputEncoding): string { - const result = this.CryptoJSHmac.finalize(); + const result = this._cryptoJSHmac.finalize(); if (encoding === 'base64') { return result.toString(CryptoJS.enc.Base64); @@ -39,7 +39,7 @@ export default class CryptoJSHmac implements LDHmac { } update(data: string): this { - this.CryptoJSHmac.update(data); + this._cryptoJSHmac.update(data); return this; } } diff --git a/packages/shared/sdk-server-edge/src/platform/requests.ts b/packages/shared/sdk-server-edge/src/platform/requests.ts index 2bc8010dc..413bbb6b3 100644 --- a/packages/shared/sdk-server-edge/src/platform/requests.ts +++ b/packages/shared/sdk-server-edge/src/platform/requests.ts @@ -1,6 +1,7 @@ import { NullEventSource } from '@launchdarkly/js-server-sdk-common'; import type { EventSource, + EventSourceCapabilities, EventSourceInitDict, Options, Requests, @@ -16,4 +17,12 @@ export default class EdgeRequests implements Requests { createEventSource(url: string, eventSourceInitDict: EventSourceInitDict): EventSource { return new NullEventSource(url, eventSourceInitDict); } + + getEventSourceCapabilities(): EventSourceCapabilities { + return { + readTimeout: false, + headers: false, + customMethod: false, + }; + } } diff --git a/packages/shared/sdk-server/CHANGELOG.md b/packages/shared/sdk-server/CHANGELOG.md index 0104a1151..b2aeab878 100644 --- a/packages/shared/sdk-server/CHANGELOG.md +++ b/packages/shared/sdk-server/CHANGELOG.md @@ -8,6 +8,74 @@ All notable changes to `@launchdarkly/js-server-sdk-common` will be documented i * dependencies * @launchdarkly/js-sdk-common bumped from 2.3.0 to 2.3.1 +## [2.9.0](https://github.com/launchdarkly/js-core/compare/js-server-sdk-common-v2.8.0...js-server-sdk-common-v2.9.0) (2024-10-17) + + +### Features + +* Add prerequisite information to server-side allFlagsState. ([8c84e01](https://github.com/launchdarkly/js-core/commit/8c84e0149a5621c6fcb95f2cfdbd6112f3540191)) +* Add support for client-side prerequisite events. ([8c84e01](https://github.com/launchdarkly/js-core/commit/8c84e0149a5621c6fcb95f2cfdbd6112f3540191)) +* Add support for prerequisite details to evaluation detail. ([8c84e01](https://github.com/launchdarkly/js-core/commit/8c84e0149a5621c6fcb95f2cfdbd6112f3540191)) +* Apply private property naming standard. Mangle browser private properties. ([#620](https://github.com/launchdarkly/js-core/issues/620)) ([3e6d404](https://github.com/launchdarkly/js-core/commit/3e6d404ae665c5cc7e5a1394a59c8f2c9d5d682a)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-sdk-common bumped from 2.10.0 to 2.11.0 + +## [2.8.0](https://github.com/launchdarkly/js-core/compare/js-server-sdk-common-v2.7.0...js-server-sdk-common-v2.8.0) (2024-10-09) + + +### Features + +* adds datasource status to sdk-client ([#590](https://github.com/launchdarkly/js-core/issues/590)) ([6f26204](https://github.com/launchdarkly/js-core/commit/6f262045b76836e5d2f5ccc2be433094993fcdbb)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-sdk-common bumped from 2.9.0 to 2.10.0 + +## [2.7.0](https://github.com/launchdarkly/js-core/compare/js-server-sdk-common-v2.6.1...js-server-sdk-common-v2.7.0) (2024-09-26) + + +### Features + +* Add platform support for async hashing. ([#573](https://github.com/launchdarkly/js-core/issues/573)) ([9248035](https://github.com/launchdarkly/js-core/commit/9248035a88fba1c7375c5df22ef6b4a80a867983)) +* Add support for conditional event source capabilities. ([#577](https://github.com/launchdarkly/js-core/issues/577)) ([fe82500](https://github.com/launchdarkly/js-core/commit/fe82500f28cf8d8311502098aa6cc2e73932064e)) +* Allow using custom user-agent name. ([#580](https://github.com/launchdarkly/js-core/issues/580)) ([ed5a206](https://github.com/launchdarkly/js-core/commit/ed5a206c86f496942664dd73f6f8a7c602a1de28)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-sdk-common bumped from 2.8.0 to 2.9.0 + +## [2.6.1](https://github.com/launchdarkly/js-core/compare/js-server-sdk-common-v2.6.0...js-server-sdk-common-v2.6.1) (2024-09-05) + + +### Bug Fixes + +* Correctly handle null values in JSON variations. ([#569](https://github.com/launchdarkly/js-core/issues/569)) ([907d08b](https://github.com/launchdarkly/js-core/commit/907d08b730ce9745c1b221f2f539f7c56c3a0234)), closes [#568](https://github.com/launchdarkly/js-core/issues/568) + +## [2.6.0](https://github.com/launchdarkly/js-core/compare/js-server-sdk-common-v2.5.0...js-server-sdk-common-v2.6.0) (2024-09-03) + + +### Features + +* Add support for Payload Filtering ([#551](https://github.com/launchdarkly/js-core/issues/551)) ([6f44383](https://github.com/launchdarkly/js-core/commit/6f4438323baed802d8f951ac82494e6cfa9932c5)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-sdk-common bumped from 2.7.0 to 2.8.0 + ## [2.5.0](https://github.com/launchdarkly/js-core/compare/js-server-sdk-common-v2.4.5...js-server-sdk-common-v2.5.0) (2024-08-28) diff --git a/packages/shared/sdk-server/__tests__/BigSegmentsManager.test.ts b/packages/shared/sdk-server/__tests__/BigSegmentsManager.test.ts index 1bda8c9dd..ab4a157cd 100644 --- a/packages/shared/sdk-server/__tests__/BigSegmentsManager.test.ts +++ b/packages/shared/sdk-server/__tests__/BigSegmentsManager.test.ts @@ -22,15 +22,15 @@ const userKey = 'userkey'; const userHash = 'is_hashed:userkey'; class TestHasher implements Hasher { - private value: string = 'is_hashed:'; + private _value: string = 'is_hashed:'; update(toAdd: string): Hasher { - this.value += toAdd; + this._value += toAdd; return this; } digest() { - return this.value; + return this._value; } } diff --git a/packages/shared/sdk-server/__tests__/LDClient.allFlags.test.ts b/packages/shared/sdk-server/__tests__/LDClient.allFlags.test.ts index 9903eb51d..830f25100 100644 --- a/packages/shared/sdk-server/__tests__/LDClient.allFlags.test.ts +++ b/packages/shared/sdk-server/__tests__/LDClient.allFlags.test.ts @@ -1,7 +1,6 @@ -import { createBasicPlatform } from '@launchdarkly/private-js-mocks'; - import { LDClientImpl } from '../src'; import TestData from '../src/integrations/test_data/TestData'; +import { createBasicPlatform } from './createBasicPlatform'; import TestLogger, { LogLevel } from './Logger'; import makeCallbacks from './makeCallbacks'; @@ -16,7 +15,7 @@ describe('given an LDClient with test data', () => { logger = new TestLogger(); td = new TestData(); client = new LDClientImpl( - 'sdk-key', + 'sdk-key-all-flags-test-data', createBasicPlatform(), { updateProcessor: td.getFactory(), @@ -269,6 +268,60 @@ describe('given an LDClient with test data', () => { done(); }); }); + + it('includes prerequisites in flag meta', async () => { + await td.update(td.flag('is-prereq').valueForAll(true)); + await td.usePreconfiguredFlag({ + key: 'has-prereq-depth-1', + on: true, + prerequisites: [ + { + key: 'is-prereq', + variation: 0, + }, + ], + fallthrough: { + variation: 0, + }, + offVariation: 1, + variations: [true, false], + clientSideAvailability: { + usingMobileKey: true, + usingEnvironmentId: true, + }, + clientSide: true, + version: 4, + }); + + const state = await client.allFlagsState(defaultUser, { + withReasons: true, + detailsOnlyForTrackedFlags: false, + }); + expect(state.valid).toEqual(true); + expect(state.allValues()).toEqual({ 'is-prereq': true, 'has-prereq-depth-1': true }); + expect(state.toJSON()).toEqual({ + 'is-prereq': true, + 'has-prereq-depth-1': true, + $flagsState: { + 'is-prereq': { + variation: 0, + reason: { + kind: 'FALLTHROUGH', + }, + version: 1, + }, + 'has-prereq-depth-1': { + variation: 0, + prerequisites: ['is-prereq'], + reason: { + kind: 'FALLTHROUGH', + }, + version: 4, + }, + }, + $valid: true, + }); + }); }); describe('given an offline client', () => { @@ -280,7 +333,7 @@ describe('given an offline client', () => { logger = new TestLogger(); td = new TestData(); client = new LDClientImpl( - 'sdk-key', + 'sdk-key-all-flags-offline', createBasicPlatform(), { offline: true, diff --git a/packages/shared/sdk-server/__tests__/LDClient.evaluation.test.ts b/packages/shared/sdk-server/__tests__/LDClient.evaluation.test.ts index 2ef0d660d..a57dc8599 100644 --- a/packages/shared/sdk-server/__tests__/LDClient.evaluation.test.ts +++ b/packages/shared/sdk-server/__tests__/LDClient.evaluation.test.ts @@ -1,31 +1,14 @@ import { subsystem } from '@launchdarkly/js-sdk-common'; -import { - createBasicPlatform, - MockStreamingProcessor, - setupMockStreamingProcessor, -} from '@launchdarkly/private-js-mocks'; import { LDClientImpl, LDFeatureStore } from '../src'; import TestData from '../src/integrations/test_data/TestData'; import AsyncStoreFacade from '../src/store/AsyncStoreFacade'; import InMemoryFeatureStore from '../src/store/InMemoryFeatureStore'; import VersionedDataKinds from '../src/store/VersionedDataKinds'; +import { createBasicPlatform } from './createBasicPlatform'; import TestLogger, { LogLevel } from './Logger'; import makeCallbacks from './makeCallbacks'; -jest.mock('@launchdarkly/js-sdk-common', () => { - const actual = jest.requireActual('@launchdarkly/js-sdk-common'); - return { - ...actual, - ...{ - internal: { - ...actual.internal, - StreamingProcessor: MockStreamingProcessor, - }, - }, - }; -}); - const defaultUser = { key: 'user' }; describe('given an LDClient with test data', () => { @@ -35,7 +18,7 @@ describe('given an LDClient with test data', () => { beforeEach(async () => { td = new TestData(); client = new LDClientImpl( - 'sdk-key', + 'sdk-key-evaluation-test-data', createBasicPlatform(), { updateProcessor: td.getFactory(), @@ -281,7 +264,7 @@ describe('given an offline client', () => { logger = new TestLogger(); td = new TestData(); client = new LDClientImpl( - 'sdk-key', + 'sdk-key-evaluation-offline', createBasicPlatform(), { offline: true, @@ -345,7 +328,7 @@ describe('given a client and store that are uninitialized', () => { }); client = new LDClientImpl( - 'sdk-key', + 'sdk-key-evaluation-uninitialized-store', createBasicPlatform(), { updateProcessor: new InertUpdateProcessor(), @@ -392,14 +375,18 @@ describe('given a client that is un-initialized and store that is initialized', }, segments: {}, }); - setupMockStreamingProcessor(true); client = new LDClientImpl( - 'sdk-key', + 'sdk-key-initialized-store', createBasicPlatform(), { sendEvents: false, featureStore: store, + updateProcessor: () => ({ + start: jest.fn(), + stop: jest.fn(), + close: jest.fn(), + }), }, makeCallbacks(true), ); diff --git a/packages/shared/sdk-server/__tests__/LDClient.events.test.ts b/packages/shared/sdk-server/__tests__/LDClient.events.test.ts index 075fb5802..5d1cdd84e 100644 --- a/packages/shared/sdk-server/__tests__/LDClient.events.test.ts +++ b/packages/shared/sdk-server/__tests__/LDClient.events.test.ts @@ -1,10 +1,10 @@ import { AsyncQueue } from 'launchdarkly-js-test-helpers'; import { Context, internal } from '@launchdarkly/js-sdk-common'; -import { createBasicPlatform } from '@launchdarkly/private-js-mocks'; import { LDClientImpl } from '../src'; import TestData from '../src/integrations/test_data/TestData'; +import { createBasicPlatform } from './createBasicPlatform'; import TestLogger, { LogLevel } from './Logger'; import makeCallbacks from './makeCallbacks'; @@ -29,7 +29,7 @@ describe('given a client with mock event processor', () => { td = new TestData(); client = new LDClientImpl( - 'sdk-key', + 'sdk-key-events', createBasicPlatform(), { updateProcessor: td.getFactory(), diff --git a/packages/shared/sdk-server/__tests__/LDClient.hooks.test.ts b/packages/shared/sdk-server/__tests__/LDClient.hooks.test.ts index b108a71d2..929d8f67a 100644 --- a/packages/shared/sdk-server/__tests__/LDClient.hooks.test.ts +++ b/packages/shared/sdk-server/__tests__/LDClient.hooks.test.ts @@ -1,8 +1,7 @@ -import { createBasicPlatform } from '@launchdarkly/private-js-mocks'; - import { LDClientImpl, LDMigrationStage } from '../src'; import Reasons from '../src/evaluation/Reasons'; import TestData from '../src/integrations/test_data/TestData'; +import { createBasicPlatform } from './createBasicPlatform'; import { TestHook } from './hooks/TestHook'; import TestLogger from './Logger'; import makeCallbacks from './makeCallbacks'; @@ -20,7 +19,7 @@ describe('given an LDClient with test data', () => { testHook = new TestHook(); td = new TestData(); client = new LDClientImpl( - 'sdk-key', + 'sdk-key-hooks-test-data', createBasicPlatform(), { updateProcessor: td.getFactory(), @@ -351,7 +350,7 @@ it('can add a hook after initialization', async () => { const logger = new TestLogger(); const td = new TestData(); const client = new LDClientImpl( - 'sdk-key', + 'sdk-key-hook-after-init', createBasicPlatform(), { updateProcessor: td.getFactory(), diff --git a/packages/shared/sdk-server/__tests__/LDClient.migrations.test.ts b/packages/shared/sdk-server/__tests__/LDClient.migrations.test.ts index d494245a7..3a5df82a4 100644 --- a/packages/shared/sdk-server/__tests__/LDClient.migrations.test.ts +++ b/packages/shared/sdk-server/__tests__/LDClient.migrations.test.ts @@ -1,8 +1,7 @@ -import { createBasicPlatform } from '@launchdarkly/private-js-mocks'; - import { LDClientImpl, LDMigrationStage } from '../src'; import TestData from '../src/integrations/test_data/TestData'; import { LDClientCallbacks } from '../src/LDClientImpl'; +import { createBasicPlatform } from './createBasicPlatform'; /** * Basic callback handler that records errors for tests. @@ -33,7 +32,7 @@ describe('given an LDClient with test data', () => { td = new TestData(); [errors, callbacks] = makeCallbacks(); client = new LDClientImpl( - 'sdk-key', + 'sdk-key-migration', createBasicPlatform(), { updateProcessor: td.getFactory(), diff --git a/packages/shared/sdk-server/__tests__/LDClientImpl.bigSegments.test.ts b/packages/shared/sdk-server/__tests__/LDClientImpl.bigSegments.test.ts index 471121256..e5ef9bc52 100644 --- a/packages/shared/sdk-server/__tests__/LDClientImpl.bigSegments.test.ts +++ b/packages/shared/sdk-server/__tests__/LDClientImpl.bigSegments.test.ts @@ -1,11 +1,11 @@ import { Crypto, Hasher, Hmac } from '@launchdarkly/js-sdk-common'; -import { createBasicPlatform } from '@launchdarkly/private-js-mocks'; import { LDBigSegmentsOptions } from '../src'; import { BigSegmentStore } from '../src/api/interfaces'; import makeBigSegmentRef from '../src/evaluation/makeBigSegmentRef'; import TestData from '../src/integrations/test_data/TestData'; import LDClientImpl from '../src/LDClientImpl'; +import { createBasicPlatform } from './createBasicPlatform'; import { makeSegmentMatchClause } from './evaluation/flags'; import makeCallbacks from './makeCallbacks'; @@ -25,15 +25,15 @@ const flag = { }; class TestHasher implements Hasher { - private value: string = 'is_hashed:'; + private _value: string = 'is_hashed:'; update(toAdd: string): Hasher { - this.value += toAdd; + this._value += toAdd; return this; } digest() { - return this.value; + return this._value; } } @@ -75,7 +75,7 @@ describe('given test data with big segments', () => { }; client = new LDClientImpl( - 'sdk-key', + 'sdk-key-big-segments-test-data', { ...createBasicPlatform(), crypto }, { updateProcessor: td.getFactory(), @@ -114,7 +114,7 @@ describe('given test data with big segments', () => { }; client = new LDClientImpl( - 'sdk-key', + 'sdk-key-big-segments-with-user', { ...createBasicPlatform(), crypto }, { updateProcessor: td.getFactory(), @@ -153,7 +153,7 @@ describe('given test data with big segments', () => { }; client = new LDClientImpl( - 'sdk-key', + 'sdk-key-big-segments-store-error', { ...createBasicPlatform(), crypto }, { updateProcessor: td.getFactory(), @@ -180,7 +180,7 @@ describe('given test data with big segments', () => { describe('given a client without big segment support.', () => { beforeEach(async () => { client = new LDClientImpl( - 'sdk-key', + 'sdk-key-big-segments-no-store', { ...createBasicPlatform(), crypto }, { updateProcessor: td.getFactory(), diff --git a/packages/shared/sdk-server/__tests__/LDClientImpl.listeners.test.ts b/packages/shared/sdk-server/__tests__/LDClientImpl.listeners.test.ts index a97b7c083..d32ef6d24 100644 --- a/packages/shared/sdk-server/__tests__/LDClientImpl.listeners.test.ts +++ b/packages/shared/sdk-server/__tests__/LDClientImpl.listeners.test.ts @@ -1,10 +1,9 @@ import { AsyncQueue } from 'launchdarkly-js-test-helpers'; -import { createBasicPlatform } from '@launchdarkly/private-js-mocks'; - import { AttributeReference, LDClientImpl } from '../src'; import { Op } from '../src/evaluation/data/Clause'; import TestData from '../src/integrations/test_data/TestData'; +import { createBasicPlatform } from './createBasicPlatform'; import { makeFlagWithSegmentMatch } from './evaluation/flags'; import TestLogger from './Logger'; import makeCallbacks from './makeCallbacks'; @@ -18,7 +17,7 @@ describe('given an LDClient with test data', () => { queue = new AsyncQueue(); td = new TestData(); client = new LDClientImpl( - 'sdk-key', + 'sdk-key-listeners', createBasicPlatform(), { updateProcessor: td.getFactory(), diff --git a/packages/shared/sdk-server/__tests__/LDClientImpl.test.ts b/packages/shared/sdk-server/__tests__/LDClientImpl.test.ts index 4995ebc6b..34d97823d 100644 --- a/packages/shared/sdk-server/__tests__/LDClientImpl.test.ts +++ b/packages/shared/sdk-server/__tests__/LDClientImpl.test.ts @@ -1,24 +1,46 @@ -import { - createBasicPlatform, - MockStreamingProcessor, - setupMockStreamingProcessor, -} from '@launchdarkly/private-js-mocks'; +import { LDClientContext, LDStreamingError } from '@launchdarkly/js-sdk-common'; -import { LDClientImpl, LDOptions } from '../src'; +import { LDOptions } from '../src/api/options/LDOptions'; +import { LDFeatureStore } from '../src/api/subsystems/LDFeatureStore'; +import LDClientImpl from '../src/LDClientImpl'; +import { createBasicPlatform } from './createBasicPlatform'; import TestLogger, { LogLevel } from './Logger'; -jest.mock('@launchdarkly/js-sdk-common', () => { - const actual = jest.requireActual('@launchdarkly/js-sdk-common'); - return { - ...actual, - ...{ - internal: { - ...actual.internal, - StreamingProcessor: MockStreamingProcessor, - }, - }, - }; -}); +function getUpdateProcessorFactory(shouldError: boolean = false, initTimeoutMs: number = 0) { + let initTimeoutHandle: any; + let patchTimeoutHandle: any; + let deleteTimeoutHandle: any; + + return ( + _clientContext: LDClientContext, + featureStore: LDFeatureStore, + initSuccessHandler: Function, + errorHandler?: (e: Error) => void, + ) => ({ + start: jest.fn(async () => { + if (shouldError) { + initTimeoutHandle = setTimeout(() => { + const unauthorized = new Error('test-error') as LDStreamingError; + // @ts-ignore + unauthorized.code = 401; + errorHandler?.(unauthorized); + }, 0); + } else { + // execute put which will resolve the identify promise + initTimeoutHandle = setTimeout(() => { + featureStore.init({}, () => {}); + initSuccessHandler(); + }, initTimeoutMs); + } + }), + close: jest.fn(() => { + clearTimeout(initTimeoutHandle); + clearTimeout(patchTimeoutHandle); + clearTimeout(deleteTimeoutHandle); + }), + eventSource: {}, + }); +} describe('LDClientImpl', () => { let client: LDClientImpl; @@ -30,11 +52,7 @@ describe('LDClientImpl', () => { hasEventListeners: jest.fn().mockName('hasEventListeners'), }; const createClient = (options: LDOptions = {}) => - new LDClientImpl('sdk-key', createBasicPlatform(), options, callbacks); - - beforeEach(() => { - setupMockStreamingProcessor(); - }); + new LDClientImpl('sdk-key-ldclientimpl.test', createBasicPlatform(), options, callbacks); afterEach(() => { client.close(); @@ -42,7 +60,7 @@ describe('LDClientImpl', () => { }); it('fires ready event in online mode', async () => { - client = createClient(); + client = createClient({ updateProcessor: getUpdateProcessorFactory() }); const initializedClient = await client.waitForInitialization({ timeout: 10 }); expect(initializedClient).toEqual(client); @@ -53,8 +71,7 @@ describe('LDClientImpl', () => { }); it('wait for initialization completes even if initialization completes before it is called', (done) => { - setupMockStreamingProcessor(); - client = createClient(); + client = createClient({ updateProcessor: getUpdateProcessorFactory() }); setTimeout(async () => { const initializedClient = await client.waitForInitialization({ timeout: 10 }); @@ -64,7 +81,7 @@ describe('LDClientImpl', () => { }); it('waiting for initialization the second time produces the same result', async () => { - client = createClient(); + client = createClient({ updateProcessor: getUpdateProcessorFactory() }); await client.waitForInitialization({ timeout: 10 }); const initializedClient = await client.waitForInitialization({ timeout: 10 }); @@ -83,9 +100,7 @@ describe('LDClientImpl', () => { }); it('initialization fails: failed event fires and initialization promise rejects', async () => { - setupMockStreamingProcessor(true); - client = createClient(); - + client = createClient({ updateProcessor: getUpdateProcessorFactory(true) }); await expect(client.waitForInitialization({ timeout: 10 })).rejects.toThrow('failed'); expect(client.initialized()).toBeFalsy(); @@ -95,8 +110,7 @@ describe('LDClientImpl', () => { }); it('initialization promise is rejected even if the failure happens before wait is called', (done) => { - setupMockStreamingProcessor(true); - client = createClient(); + client = createClient({ updateProcessor: getUpdateProcessorFactory(true) }); setTimeout(async () => { await expect(client.waitForInitialization({ timeout: 10 })).rejects.toThrow('failed'); @@ -110,8 +124,7 @@ describe('LDClientImpl', () => { }); it('waiting a second time results in the same rejection', async () => { - setupMockStreamingProcessor(true); - client = createClient(); + client = createClient({ updateProcessor: getUpdateProcessorFactory(true) }); await expect(client.waitForInitialization({ timeout: 10 })).rejects.toThrow('failed'); await expect(client.waitForInitialization({ timeout: 10 })).rejects.toThrow('failed'); @@ -128,13 +141,13 @@ describe('LDClientImpl', () => { }); it('resolves immediately if the client is already ready', async () => { - client = createClient(); + client = createClient({ updateProcessor: getUpdateProcessorFactory() }); await client.waitForInitialization({ timeout: 10 }); await client.waitForInitialization({ timeout: 10 }); }); it('creates only one Promise when waiting for initialization - when not using a timeout', async () => { - client = createClient(); + client = createClient({ updateProcessor: getUpdateProcessorFactory() }); const p1 = client.waitForInitialization(); const p2 = client.waitForInitialization(); @@ -142,17 +155,20 @@ describe('LDClientImpl', () => { }); it('rejects the returned promise when initialization does not complete within the timeout', async () => { - setupMockStreamingProcessor(undefined, undefined, undefined, undefined, undefined, 10000); - client = createClient(); + client = createClient({ + updateProcessor: getUpdateProcessorFactory(false, 10000), + }); await expect(async () => client.waitForInitialization({ timeout: 1 })).rejects.toThrow( 'waitForInitialization timed out after 1 seconds.', ); }); it('logs an error when the initialization does not complete within the timeout', async () => { - setupMockStreamingProcessor(undefined, undefined, undefined, undefined, undefined, 10000); const logger = new TestLogger(); - client = createClient({ logger }); + client = createClient({ + logger, + updateProcessor: getUpdateProcessorFactory(false, 10000), + }); try { await client.waitForInitialization({ timeout: 1 }); } catch { @@ -167,14 +183,18 @@ describe('LDClientImpl', () => { }); it('does not reject the returned promise when initialization completes within the timeout', async () => { - setupMockStreamingProcessor(undefined, undefined, undefined, undefined, undefined, 1000); - client = createClient(); - await expect(async () => client.waitForInitialization({ timeout: 5 })).not.toThrow(); + client = createClient({ + updateProcessor: getUpdateProcessorFactory(false, 100), + }); + await expect(client.waitForInitialization({ timeout: 3 })).resolves.not.toThrow(); }); it('logs when no timeout is set', async () => { const logger = new TestLogger(); - client = createClient({ logger }); + client = createClient({ + logger, + updateProcessor: getUpdateProcessorFactory(), + }); await client.waitForInitialization(); logger.expectMessages([ { @@ -187,7 +207,10 @@ describe('LDClientImpl', () => { it('logs when the timeout is too high', async () => { const logger = new TestLogger(); - client = createClient({ logger }); + client = createClient({ + logger, + updateProcessor: getUpdateProcessorFactory(), + }); await client.waitForInitialization({ timeout: Number.MAX_SAFE_INTEGER }); logger.expectMessages([ @@ -203,7 +226,7 @@ describe('LDClientImpl', () => { 'does not log when timeout is under high timeout threshold', async (timeout) => { const logger = new TestLogger(); - client = createClient({ logger }); + client = createClient({ logger, updateProcessor: getUpdateProcessorFactory() }); await client.waitForInitialization({ timeout }); expect(logger.getCount(LogLevel.Warn)).toBe(0); }, diff --git a/packages/shared/sdk-server/__tests__/Logger.ts b/packages/shared/sdk-server/__tests__/Logger.ts index 6f7e2ff5f..7340cf6d5 100644 --- a/packages/shared/sdk-server/__tests__/Logger.ts +++ b/packages/shared/sdk-server/__tests__/Logger.ts @@ -18,7 +18,7 @@ function replacer(key: string, value: any) { } export default class TestLogger implements LDLogger { - private readonly messages: Record = { + private readonly _messages: Record = { debug: [], info: [], warn: [], @@ -27,13 +27,13 @@ export default class TestLogger implements LDLogger { none: [], }; - private callCount = 0; + private _callCount = 0; - private waiters: Array<() => void> = []; + private _waiters: Array<() => void> = []; timeout(timeoutMs: number): Promise { return new Promise((resolve) => { - setTimeout(() => resolve(this.callCount), timeoutMs); + setTimeout(() => resolve(this._callCount), timeoutMs); }); } @@ -41,12 +41,12 @@ export default class TestLogger implements LDLogger { return Promise.race([ new Promise((resolve) => { const waiter = () => { - if (this.callCount >= count) { - resolve(this.callCount); + if (this._callCount >= count) { + resolve(this._callCount); } }; waiter(); - this.waiters.push(waiter); + this._waiters.push(waiter); }), this.timeout(timeoutMs), ]); @@ -69,7 +69,7 @@ export default class TestLogger implements LDLogger { }; expectedMessages.forEach((expectedMessage) => { - const received = this.messages[expectedMessage.level]; + const received = this._messages[expectedMessage.level]; const index = received.findIndex((receivedMessage) => receivedMessage.match(expectedMessage.matches), ); @@ -78,14 +78,14 @@ export default class TestLogger implements LDLogger { `Did not find expected message: ${JSON.stringify( expectedMessage, replacer, - )} received: ${JSON.stringify(this.messages)}`, + )} received: ${JSON.stringify(this._messages)}`, ); } else if (matched[expectedMessage.level].indexOf(index) >= 0) { throw new Error( `Did not find expected message: ${JSON.stringify( expectedMessage, replacer, - )} received: ${JSON.stringify(this.messages)}`, + )} received: ${JSON.stringify(this._messages)}`, ); } else { matched[expectedMessage.level].push(index); @@ -95,34 +95,34 @@ export default class TestLogger implements LDLogger { getCount(level?: LogLevel) { if (level === undefined) { - return this.callCount; + return this._callCount; } - return this.messages[level].length; + return this._messages[level].length; } - private checkResolves() { - this.waiters.forEach((waiter) => waiter()); + private _checkResolves() { + this._waiters.forEach((waiter) => waiter()); } - private log(level: LDLogLevel, ...args: any[]) { - this.messages[level].push(args.join(' ')); - this.callCount += 1; - this.checkResolves(); + private _log(level: LDLogLevel, ...args: any[]) { + this._messages[level].push(args.join(' ')); + this._callCount += 1; + this._checkResolves(); } error(...args: any[]): void { - this.log('error', args); + this._log('error', args); } warn(...args: any[]): void { - this.log('warn', args); + this._log('warn', args); } info(...args: any[]): void { - this.log('info', args); + this._log('info', args); } debug(...args: any[]): void { - this.log('debug', args); + this._log('debug', args); } } diff --git a/packages/shared/sdk-server/__tests__/Migration.test.ts b/packages/shared/sdk-server/__tests__/Migration.test.ts index 6434ec5f5..06db9a688 100644 --- a/packages/shared/sdk-server/__tests__/Migration.test.ts +++ b/packages/shared/sdk-server/__tests__/Migration.test.ts @@ -1,5 +1,3 @@ -import { createBasicPlatform } from '@launchdarkly/private-js-mocks'; - import { LDClientImpl, LDConcurrentExecution, @@ -10,6 +8,7 @@ import { import { TestData } from '../src/integrations'; import { LDClientCallbacks } from '../src/LDClientImpl'; import { createMigration, LDMigrationError, LDMigrationSuccess } from '../src/Migration'; +import { createBasicPlatform } from './createBasicPlatform'; import makeCallbacks from './makeCallbacks'; describe('given an LDClient with test data', () => { @@ -21,7 +20,7 @@ describe('given an LDClient with test data', () => { td = new TestData(); callbacks = makeCallbacks(false); client = new LDClientImpl( - 'sdk-key', + 'sdk-key-migration', createBasicPlatform(), { updateProcessor: td.getFactory(), diff --git a/packages/shared/sdk-server/__tests__/MigrationOpEvent.test.ts b/packages/shared/sdk-server/__tests__/MigrationOpEvent.test.ts index f3b60b406..435c45a70 100644 --- a/packages/shared/sdk-server/__tests__/MigrationOpEvent.test.ts +++ b/packages/shared/sdk-server/__tests__/MigrationOpEvent.test.ts @@ -1,7 +1,6 @@ import { AsyncQueue } from 'launchdarkly-js-test-helpers'; import { internal } from '@launchdarkly/js-sdk-common'; -import { createBasicPlatform } from '@launchdarkly/private-js-mocks'; import { LDClientImpl, @@ -16,6 +15,7 @@ import { TestData } from '../src/integrations'; import { LDClientCallbacks } from '../src/LDClientImpl'; import { createMigration, LDMigrationError, LDMigrationSuccess } from '../src/Migration'; import MigrationOpEventConversion from '../src/MigrationOpEventConversion'; +import { createBasicPlatform } from './createBasicPlatform'; import makeCallbacks from './makeCallbacks'; jest.mock('@launchdarkly/js-sdk-common', () => ({ @@ -43,7 +43,7 @@ describe('given an LDClient with test data', () => { td = new TestData(); callbacks = makeCallbacks(false); client = new LDClientImpl( - 'sdk-key', + 'sdk-key-migration-op', createBasicPlatform(), { updateProcessor: td.getFactory(), diff --git a/packages/shared/sdk-server/__tests__/createBasicPlatform.ts b/packages/shared/sdk-server/__tests__/createBasicPlatform.ts new file mode 100644 index 000000000..17178f061 --- /dev/null +++ b/packages/shared/sdk-server/__tests__/createBasicPlatform.ts @@ -0,0 +1,59 @@ +import { PlatformData, SdkData } from '@launchdarkly/js-sdk-common'; + +import { setupCrypto } from './setupCrypto'; + +const setupInfo = () => ({ + platformData: jest.fn( + (): PlatformData => ({ + os: { + name: 'An OS', + version: '1.0.1', + arch: 'An Arch', + }, + name: 'The SDK Name', + additional: { + nodeVersion: '42', + }, + ld_application: { + key: '', + envAttributesVersion: '1.0', + id: 'com.testapp.ld', + name: 'LDApplication.TestApp', + version: '1.1.1', + }, + ld_device: { + key: '', + envAttributesVersion: '1.0', + os: { name: 'Another OS', version: '99', family: 'orange' }, + manufacturer: 'coconut', + }, + }), + ), + sdkData: jest.fn( + (): SdkData => ({ + name: 'An SDK', + version: '2.0.2', + userAgentBase: 'TestUserAgent', + wrapperName: 'Rapper', + wrapperVersion: '1.2.3', + }), + ), +}); + +export const createBasicPlatform = () => ({ + encoding: { + btoa: (s: string) => Buffer.from(s).toString('base64'), + }, + info: setupInfo(), + crypto: setupCrypto(), + requests: { + fetch: jest.fn(), + createEventSource: jest.fn(), + getEventSourceCapabilities: jest.fn(), + }, + storage: { + get: jest.fn(), + set: jest.fn(), + clear: jest.fn(), + }, +}); diff --git a/packages/shared/sdk-server/__tests__/data_sources/FileDataSource.test.ts b/packages/shared/sdk-server/__tests__/data_sources/FileDataSource.test.ts index 000314563..df0a2c920 100644 --- a/packages/shared/sdk-server/__tests__/data_sources/FileDataSource.test.ts +++ b/packages/shared/sdk-server/__tests__/data_sources/FileDataSource.test.ts @@ -1,5 +1,4 @@ import { ClientContext, Context, Filesystem, WatchHandle } from '@launchdarkly/js-sdk-common'; -import { createBasicPlatform } from '@launchdarkly/private-js-mocks'; import { Flag } from '../../src/evaluation/data/Flag'; import { Segment } from '../../src/evaluation/data/Segment'; @@ -9,6 +8,7 @@ import Configuration from '../../src/options/Configuration'; import AsyncStoreFacade from '../../src/store/AsyncStoreFacade'; import InMemoryFeatureStore from '../../src/store/InMemoryFeatureStore'; import VersionedDataKinds from '../../src/store/VersionedDataKinds'; +import { createBasicPlatform } from '../createBasicPlatform'; import TestLogger from '../Logger'; const flag1Key = 'flag1'; diff --git a/packages/shared/sdk-server/__tests__/data_sources/PollingProcessor.test.ts b/packages/shared/sdk-server/__tests__/data_sources/PollingProcessor.test.ts index 19e2ac8a0..05ae9ff28 100644 --- a/packages/shared/sdk-server/__tests__/data_sources/PollingProcessor.test.ts +++ b/packages/shared/sdk-server/__tests__/data_sources/PollingProcessor.test.ts @@ -1,5 +1,4 @@ import { ClientContext } from '@launchdarkly/js-sdk-common'; -import { createBasicPlatform } from '@launchdarkly/private-js-mocks'; import { LDFeatureStore } from '../../src'; import PollingProcessor from '../../src/data_sources/PollingProcessor'; @@ -8,6 +7,7 @@ import Configuration from '../../src/options/Configuration'; import AsyncStoreFacade from '../../src/store/AsyncStoreFacade'; import InMemoryFeatureStore from '../../src/store/InMemoryFeatureStore'; import VersionedDataKinds from '../../src/store/VersionedDataKinds'; +import { createBasicPlatform } from '../createBasicPlatform'; import TestLogger, { LogLevel } from '../Logger'; describe('given an event processor', () => { diff --git a/packages/shared/sdk-server/__tests__/data_sources/Requestor.test.ts b/packages/shared/sdk-server/__tests__/data_sources/Requestor.test.ts index 1fb055815..3f3d8537a 100644 --- a/packages/shared/sdk-server/__tests__/data_sources/Requestor.test.ts +++ b/packages/shared/sdk-server/__tests__/data_sources/Requestor.test.ts @@ -6,7 +6,6 @@ import { Requests, Response, } from '@launchdarkly/js-sdk-common'; -import { createBasicPlatform } from '@launchdarkly/private-js-mocks'; import promisify from '../../src/async/promisify'; import Requestor from '../../src/data_sources/Requestor'; @@ -75,14 +74,14 @@ describe('given a requestor', () => { createEventSource(_url: string, _eventSourceInitDict: EventSourceInitDict): EventSource { throw new Error('Function not implemented.'); }, + getEventSourceCapabilities() { + throw new Error('Function not implemented.'); + }, }; - requestor = new Requestor( - 'sdkKey', - new Configuration({}), - createBasicPlatform().info, - requests, - ); + requestor = new Requestor(new Configuration({}), requests, { + authorization: 'sdkKey', + }); }); it('gets data', (done) => { diff --git a/packages/shared/sdk-server/src/diagnostics/createDiagnosticsInitConfig.test.ts b/packages/shared/sdk-server/__tests__/diagnostics/createDiagnosticsInitConfig.test.ts similarity index 92% rename from packages/shared/sdk-server/src/diagnostics/createDiagnosticsInitConfig.test.ts rename to packages/shared/sdk-server/__tests__/diagnostics/createDiagnosticsInitConfig.test.ts index 1605d277c..f0e3d21ad 100644 --- a/packages/shared/sdk-server/src/diagnostics/createDiagnosticsInitConfig.test.ts +++ b/packages/shared/sdk-server/__tests__/diagnostics/createDiagnosticsInitConfig.test.ts @@ -1,8 +1,7 @@ -import { createBasicPlatform } from '@launchdarkly/private-js-mocks'; - -import { LDOptions } from '../api'; -import Configuration from '../options/Configuration'; -import createDiagnosticsInitConfig from './createDiagnosticsInitConfig'; +import { LDOptions } from '../../src/api'; +import createDiagnosticsInitConfig from '../../src/diagnostics/createDiagnosticsInitConfig'; +import Configuration from '../../src/options/Configuration'; +import { createBasicPlatform } from '../createBasicPlatform'; let mockPlatform: ReturnType; diff --git a/packages/shared/sdk-server/__tests__/evaluation/Bucketer.test.ts b/packages/shared/sdk-server/__tests__/evaluation/Bucketer.test.ts index a1c10754c..5da1868e5 100644 --- a/packages/shared/sdk-server/__tests__/evaluation/Bucketer.test.ts +++ b/packages/shared/sdk-server/__tests__/evaluation/Bucketer.test.ts @@ -9,9 +9,9 @@ import { Hasher, LDContext, } from '@launchdarkly/js-sdk-common'; -import { createBasicPlatform } from '@launchdarkly/private-js-mocks'; import Bucketer from '../../src/evaluation/Bucketer'; +import { createBasicPlatform } from '../createBasicPlatform'; let mockPlatform: ReturnType; diff --git a/packages/shared/sdk-server/__tests__/evaluation/Evaluator.bucketing.test.ts b/packages/shared/sdk-server/__tests__/evaluation/Evaluator.bucketing.test.ts index 35ddcd431..568292887 100644 --- a/packages/shared/sdk-server/__tests__/evaluation/Evaluator.bucketing.test.ts +++ b/packages/shared/sdk-server/__tests__/evaluation/Evaluator.bucketing.test.ts @@ -1,9 +1,9 @@ import { Context } from '@launchdarkly/js-sdk-common'; -import { createBasicPlatform } from '@launchdarkly/private-js-mocks'; import { Flag } from '../../src/evaluation/data/Flag'; import { Rollout } from '../../src/evaluation/data/Rollout'; import Evaluator from '../../src/evaluation/Evaluator'; +import { createBasicPlatform } from '../createBasicPlatform'; import noQueries from './mocks/noQueries'; describe('given a flag with a rollout', () => { diff --git a/packages/shared/sdk-server/__tests__/evaluation/Evaluator.clause.test.ts b/packages/shared/sdk-server/__tests__/evaluation/Evaluator.clause.test.ts index 8c32c816b..df260d17d 100644 --- a/packages/shared/sdk-server/__tests__/evaluation/Evaluator.clause.test.ts +++ b/packages/shared/sdk-server/__tests__/evaluation/Evaluator.clause.test.ts @@ -1,10 +1,10 @@ import { AttributeReference, Context, LDContext } from '@launchdarkly/js-sdk-common'; -import { createBasicPlatform } from '@launchdarkly/private-js-mocks'; import { Clause } from '../../src/evaluation/data/Clause'; import { Flag } from '../../src/evaluation/data/Flag'; import { FlagRule } from '../../src/evaluation/data/FlagRule'; import Evaluator from '../../src/evaluation/Evaluator'; +import { createBasicPlatform } from '../createBasicPlatform'; import { makeBooleanFlagWithOneClause, makeBooleanFlagWithRules, diff --git a/packages/shared/sdk-server/__tests__/evaluation/Evaluator.prerequisite.test.ts b/packages/shared/sdk-server/__tests__/evaluation/Evaluator.prerequisite.test.ts new file mode 100644 index 000000000..400fc937a --- /dev/null +++ b/packages/shared/sdk-server/__tests__/evaluation/Evaluator.prerequisite.test.ts @@ -0,0 +1,181 @@ +import { Context, deserializePoll } from '../../src'; +import { BigSegmentStoreMembership } from '../../src/api/interfaces'; +import { Flag } from '../../src/evaluation/data/Flag'; +import { Segment } from '../../src/evaluation/data/Segment'; +import Evaluator from '../../src/evaluation/Evaluator'; +import { Queries } from '../../src/evaluation/Queries'; +import EventFactory from '../../src/events/EventFactory'; +import { FlagsAndSegments } from '../../src/store/serialization'; +import { createBasicPlatform } from '../createBasicPlatform'; + +describe('given a flag payload with prerequisites', () => { + let evaluator: Evaluator; + const basePayload = { + segments: {}, + flags: { + 'has-prereq-depth-1': { + key: 'has-prereq-depth-1', + on: true, + prerequisites: [ + { + key: 'is-prereq', + variation: 0, + }, + ], + fallthrough: { + variation: 0, + }, + offVariation: 1, + variations: [true, false], + clientSideAvailability: { + usingMobileKey: true, + usingEnvironmentId: true, + }, + clientSide: true, + version: 4, + }, + 'has-prereq-depth-2': { + key: 'has-prereq-depth-2', + on: true, + prerequisites: [ + { + key: 'has-prereq-depth-1', + variation: 0, + }, + ], + fallthrough: { + variation: 0, + }, + offVariation: 1, + variations: [true, false], + clientSideAvailability: { + usingMobileKey: true, + usingEnvironmentId: true, + }, + clientSide: true, + version: 3, + }, + 'has-prereq-depth-3': { + key: 'has-prereq-depth-3', + on: true, + prerequisites: [ + { + key: 'has-prereq-depth-1', + variation: 0, + }, + { + key: 'has-prereq-depth-2', + variation: 0, + }, + { + key: 'is-prereq', + variation: 0, + }, + ], + fallthrough: { + variation: 0, + }, + offVariation: 1, + variations: [true, false], + clientSideAvailability: { + usingMobileKey: true, + usingEnvironmentId: true, + }, + clientSide: true, + version: 3, + }, + 'is-prereq': { + key: 'is-prereq', + on: true, + fallthrough: { + variation: 0, + }, + offVariation: 1, + variations: [true, false], + clientSideAvailability: { + usingMobileKey: true, + usingEnvironmentId: true, + }, + clientSide: true, + version: 3, + }, + }, + }; + + let testPayload: FlagsAndSegments; + + class TestQueries implements Queries { + constructor(private readonly _data: FlagsAndSegments) {} + + getFlag(key: string, cb: (flag: Flag | undefined) => void): void { + const res = this._data.flags[key]; + cb(res); + } + + getSegment(key: string, cb: (segment: Segment | undefined) => void): void { + const res = this._data.segments[key]; + cb(res); + } + + getBigSegmentsMembership( + _userKey: string, + ): Promise<[BigSegmentStoreMembership | null, string] | undefined> { + throw new Error('Method not implemented.'); + } + } + + beforeEach(() => { + testPayload = deserializePoll(JSON.stringify(basePayload))!; + evaluator = new Evaluator(createBasicPlatform(), new TestQueries(testPayload!)); + }); + + it('can track prerequisites for a basic prereq', async () => { + const res = await evaluator.evaluate( + testPayload?.flags['has-prereq-depth-1']!, + Context.fromLDContext({ kind: 'user', key: 'bob' }), + new EventFactory(true), + ); + + expect(res.detail.reason.kind).toEqual('FALLTHROUGH'); + + expect(res.prerequisites).toEqual(['is-prereq']); + }); + + it('can track prerequisites for a prereq of a prereq', async () => { + const res = await evaluator.evaluate( + testPayload?.flags['has-prereq-depth-2']!, + Context.fromLDContext({ kind: 'user', key: 'bob' }), + new EventFactory(true), + ); + + expect(res.detail.reason.kind).toEqual('FALLTHROUGH'); + + expect(res.prerequisites).toEqual(['has-prereq-depth-1']); + }); + + it('can track prerequisites for a flag with multiple prereqs with and without additional prereqs', async () => { + const res = await evaluator.evaluate( + testPayload?.flags['has-prereq-depth-3']!, + Context.fromLDContext({ kind: 'user', key: 'bob' }), + new EventFactory(true), + ); + + expect(res.detail.reason.kind).toEqual('FALLTHROUGH'); + + expect(res.prerequisites).toEqual(['has-prereq-depth-1', 'has-prereq-depth-2', 'is-prereq']); + }); + + it('has can handle a prerequisite failure', async () => { + testPayload.flags['is-prereq'].on = false; + const res = await evaluator.evaluate( + testPayload?.flags['has-prereq-depth-3']!, + Context.fromLDContext({ kind: 'user', key: 'bob' }), + new EventFactory(true), + ); + + expect(res.detail.reason.kind).toEqual('PREREQUISITE_FAILED'); + expect(res.detail.reason.prerequisiteKey).toEqual('has-prereq-depth-1'); + + expect(res.prerequisites).toEqual(['has-prereq-depth-1']); + }); +}); diff --git a/packages/shared/sdk-server/__tests__/evaluation/Evaluator.rules.test.ts b/packages/shared/sdk-server/__tests__/evaluation/Evaluator.rules.test.ts index 7ee33eaba..3be3e2819 100644 --- a/packages/shared/sdk-server/__tests__/evaluation/Evaluator.rules.test.ts +++ b/packages/shared/sdk-server/__tests__/evaluation/Evaluator.rules.test.ts @@ -1,12 +1,12 @@ // Tests of flag evaluation at the rule level. Clause-level behavior is covered // in detail in Evaluator.clause.tests and (TODO: File for segments). import { AttributeReference, Context, LDContext } from '@launchdarkly/js-sdk-common'; -import { createBasicPlatform } from '@launchdarkly/private-js-mocks'; import { Clause } from '../../src/evaluation/data/Clause'; import { Flag } from '../../src/evaluation/data/Flag'; import { FlagRule } from '../../src/evaluation/data/FlagRule'; import Evaluator from '../../src/evaluation/Evaluator'; +import { createBasicPlatform } from '../createBasicPlatform'; import { makeClauseThatDoesNotMatchUser, makeClauseThatMatchesUser, diff --git a/packages/shared/sdk-server/__tests__/evaluation/Evaluator.segments.test.ts b/packages/shared/sdk-server/__tests__/evaluation/Evaluator.segments.test.ts index d0f1f4eeb..f4cf80ced 100644 --- a/packages/shared/sdk-server/__tests__/evaluation/Evaluator.segments.test.ts +++ b/packages/shared/sdk-server/__tests__/evaluation/Evaluator.segments.test.ts @@ -7,13 +7,13 @@ import { Hmac, LDContext, } from '@launchdarkly/js-sdk-common'; -import { createBasicPlatform } from '@launchdarkly/private-js-mocks'; import { BigSegmentStoreMembership } from '../../src/api/interfaces'; import { Flag } from '../../src/evaluation/data/Flag'; import { Segment } from '../../src/evaluation/data/Segment'; import Evaluator from '../../src/evaluation/Evaluator'; import { Queries } from '../../src/evaluation/Queries'; +import { createBasicPlatform } from '../createBasicPlatform'; import { makeClauseThatDoesNotMatchUser, makeClauseThatMatchesUser, @@ -32,19 +32,19 @@ const basicMultiKindUser: LDContext = { kind: 'multi', user: { key: 'userkey' } class TestQueries implements Queries { constructor( - private readonly data: { + private readonly _data: { flags?: Flag[]; segments?: Segment[]; }, ) {} getFlag(key: string, cb: (flag: Flag | undefined) => void): void { - const res = this.data.flags?.find((flag) => flag.key === key); + const res = this._data.flags?.find((flag) => flag.key === key); cb(res); } getSegment(key: string, cb: (segment: Segment | undefined) => void): void { - const res = this.data.segments?.find((segment) => segment.key === key); + const res = this._data.segments?.find((segment) => segment.key === key); cb(res); } diff --git a/packages/shared/sdk-server/__tests__/evaluation/Evaluator.test.ts b/packages/shared/sdk-server/__tests__/evaluation/Evaluator.test.ts index 99325b8ef..64640b0c4 100644 --- a/packages/shared/sdk-server/__tests__/evaluation/Evaluator.test.ts +++ b/packages/shared/sdk-server/__tests__/evaluation/Evaluator.test.ts @@ -1,10 +1,10 @@ import { Context, LDContext } from '@launchdarkly/js-sdk-common'; -import { createBasicPlatform } from '@launchdarkly/private-js-mocks'; import { Flag } from '../../src/evaluation/data/Flag'; import EvalResult from '../../src/evaluation/EvalResult'; import Evaluator from '../../src/evaluation/Evaluator'; import Reasons from '../../src/evaluation/Reasons'; +import { createBasicPlatform } from '../createBasicPlatform'; import noQueries from './mocks/noQueries'; const offBaseFlag = { diff --git a/packages/shared/sdk-server/__tests__/events/EventProcessor.test.ts b/packages/shared/sdk-server/__tests__/events/EventProcessor.test.ts index 8aed40555..98d747890 100644 --- a/packages/shared/sdk-server/__tests__/events/EventProcessor.test.ts +++ b/packages/shared/sdk-server/__tests__/events/EventProcessor.test.ts @@ -14,11 +14,11 @@ import { Response, SdkData, } from '@launchdarkly/js-sdk-common'; -import { createBasicPlatform } from '@launchdarkly/private-js-mocks'; import ContextDeduplicator from '../../src/events/ContextDeduplicator'; import Configuration from '../../src/options/Configuration'; import InMemoryFeatureStore from '../../src/store/InMemoryFeatureStore'; +import { createBasicPlatform } from '../createBasicPlatform'; let mockPlatform: ReturnType; @@ -111,6 +111,9 @@ function makePlatform(requestState: RequestState) { createEventSource(_url: string, _eventSourceInitDict: EventSourceInitDict): EventSource { throw new Error('Function not implemented.'); }, + getEventSourceCapabilities() { + throw new Error('Function not implemented.'); + }, }; return { info, @@ -196,6 +199,7 @@ describe('given an event processor with diagnostics manager', () => { eventProcessor = new internal.EventProcessor( testConfig, clientContext, + {}, new ContextDeduplicator(config), diagnosticsManager, ); diff --git a/packages/shared/sdk-server/__tests__/integrations/test_data/TestData.test.ts b/packages/shared/sdk-server/__tests__/integrations/test_data/TestData.test.ts index 06c97f0fe..aee9a6b3b 100644 --- a/packages/shared/sdk-server/__tests__/integrations/test_data/TestData.test.ts +++ b/packages/shared/sdk-server/__tests__/integrations/test_data/TestData.test.ts @@ -1,5 +1,4 @@ import { AttributeReference, ClientContext } from '@launchdarkly/js-sdk-common'; -import { createBasicPlatform } from '@launchdarkly/private-js-mocks'; import { Flag } from '../../../src/evaluation/data/Flag'; import { FlagRule } from '../../../src/evaluation/data/FlagRule'; @@ -8,6 +7,7 @@ import Configuration from '../../../src/options/Configuration'; import AsyncStoreFacade from '../../../src/store/AsyncStoreFacade'; import InMemoryFeatureStore from '../../../src/store/InMemoryFeatureStore'; import VersionedDataKinds from '../../../src/store/VersionedDataKinds'; +import { createBasicPlatform } from '../../createBasicPlatform'; const basicBooleanFlag: Flag = { fallthrough: { @@ -318,7 +318,7 @@ describe('given a TestData instance', () => { { attribute: 'name', attributeReference: { - components: ['name'], + _components: ['name'], isValid: true, redactionName: 'name', }, diff --git a/packages/shared/sdk-server/__tests__/setupCrypto.ts b/packages/shared/sdk-server/__tests__/setupCrypto.ts new file mode 100644 index 000000000..fc8d0b460 --- /dev/null +++ b/packages/shared/sdk-server/__tests__/setupCrypto.ts @@ -0,0 +1,20 @@ +import { Hasher } from '@launchdarkly/js-sdk-common'; + +export const setupCrypto = () => { + let counter = 0; + const hasher = { + update: jest.fn((): Hasher => hasher), + digest: jest.fn(() => '1234567890123456'), + }; + + return { + createHash: jest.fn(() => hasher), + createHmac: jest.fn(), + randomUUID: jest.fn(() => { + counter += 1; + // Will provide a unique value for tests. + // Very much not a UUID of course. + return `${counter}`; + }), + }; +}; diff --git a/packages/shared/sdk-server/__tests__/store/serialization.test.ts b/packages/shared/sdk-server/__tests__/store/serialization.test.ts index 4656ea551..56e41e964 100644 --- a/packages/shared/sdk-server/__tests__/store/serialization.test.ts +++ b/packages/shared/sdk-server/__tests__/store/serialization.test.ts @@ -1,11 +1,14 @@ +import { AttributeReference } from '@launchdarkly/js-sdk-common'; + import { Flag } from '../../src/evaluation/data/Flag'; import { Segment } from '../../src/evaluation/data/Segment'; import { deserializeAll, deserializeDelete, deserializePatch, + nullReplacer, replacer, - reviver, + serializeFlag, serializeSegment, } from '../../src/store/serialization'; @@ -152,6 +155,38 @@ const segmentWithBucketBy = { deleted: false, }; +const flagWithNullInJsonVariation = { + key: 'flagName', + on: true, + fallthrough: { variation: 1 }, + variations: [[true, null, 'potato'], [null, null], { null: null }, { arr: [null] }], + version: 1, +}; + +const flagWithManyNulls = { + key: 'test-after-value1', + on: true, + rules: [ + { + variation: 0, + id: 'ruleid', + clauses: [ + { + attribute: 'attrname', + op: 'after', + values: ['not valid'], + negate: null, + }, + ], + trackEvents: null, + }, + ], + offVariation: null, + fallthrough: { variation: 1 }, + variations: [true, false], + version: 1, +}; + function makeAllData(flag?: any, segment?: any): any { const allData: any = { data: { @@ -239,6 +274,42 @@ describe('when deserializing all data', () => { const ref = parsed?.data.flags.flagName.rules?.[0].rollout?.bucketByAttributeReference; expect(ref?.isValid).toBeTruthy(); }); + + it('does not replace null in Objects or array JSON variations', () => { + const jsonString = makeSerializedAllData(flagWithNullInJsonVariation); + const parsed = deserializeAll(jsonString); + + expect(parsed?.data.flags.flagName.variations).toStrictEqual( + flagWithNullInJsonVariation.variations, + ); + }); + + it('removes null values outside variations', () => { + const jsonString = makeSerializedAllData(flagWithManyNulls); + const parsed = deserializeAll(jsonString); + + expect(parsed?.data.flags.flagName).toStrictEqual({ + key: 'test-after-value1', + on: true, + rules: [ + { + variation: 0, + id: 'ruleid', + clauses: [ + { + attribute: 'attrname', + attributeReference: new AttributeReference('attrname'), + op: 'after', + values: ['not valid'], + }, + ], + }, + ], + fallthrough: { variation: 1 }, + variations: [true, false], + version: 1, + }); + }); }); describe('when deserializing patch data', () => { @@ -290,9 +361,45 @@ describe('when deserializing patch data', () => { const ref = (parsed?.data as Flag).rules?.[0].rollout?.bucketByAttributeReference; expect(ref?.isValid).toBeTruthy(); }); + + it('does not replace null in Objects or array JSON variations', () => { + const jsonString = makeSerializedPatchData(flagWithNullInJsonVariation); + const parsed = deserializePatch(jsonString); + + expect((parsed?.data as Flag)?.variations).toStrictEqual( + flagWithNullInJsonVariation.variations, + ); + }); + + it('removes null values outside variations', () => { + const jsonString = makeSerializedPatchData(flagWithManyNulls); + const parsed = deserializePatch(jsonString); + + expect(parsed?.data as Flag).toStrictEqual({ + key: 'test-after-value1', + on: true, + rules: [ + { + variation: 0, + id: 'ruleid', + clauses: [ + { + attribute: 'attrname', + attributeReference: new AttributeReference('attrname'), + op: 'after', + values: ['not valid'], + }, + ], + }, + ], + fallthrough: { variation: 1 }, + variations: [true, false], + version: 1, + }); + }); }); -it('removes null elements', () => { +it('removes null elements that are not part of arrays', () => { const baseData = { a: 'b', b: 'c', @@ -306,10 +413,49 @@ it('removes null elements', () => { polluted.c.f = null; const stringPolluted = JSON.stringify(polluted); - const parsed = JSON.parse(stringPolluted, reviver); + const parsed = JSON.parse(stringPolluted); + nullReplacer(parsed); expect(parsed).toStrictEqual(baseData); }); +it('does not remove null in arrays', () => { + const data = { + a: ['b', null, { arr: [null] }], + c: { + d: ['e', null, { arr: [null] }], + }, + }; + + const parsed = JSON.parse(JSON.stringify(data)); + nullReplacer(parsed); + expect(parsed).toStrictEqual(data); +}); + +it('does remove null from objects that are inside of arrays', () => { + const data = { + a: ['b', null, { null: null, notNull: true }], + c: { + d: ['e', null, { null: null, notNull: true }], + }, + }; + + const parsed = JSON.parse(JSON.stringify(data)); + nullReplacer(parsed); + expect(parsed).toStrictEqual({ + a: ['b', null, { notNull: true }], + c: { + d: ['e', null, { notNull: true }], + }, + }); +}); + +it('can handle attempting to replace nulls for an undefined or null value', () => { + expect(() => { + nullReplacer(null); + nullReplacer(undefined); + }).not.toThrow(); +}); + it.each([ [flagWithAttributeNameInClause, undefined], [flagWithAttributeReferenceInClause, undefined], @@ -450,3 +596,11 @@ it('serialization converts sets back to arrays for includedContexts/excludedCont expect(jsonDeserialized.includedContexts[0].generated_valuesSet).toBeUndefined(); expect(jsonDeserialized.excludedContexts[0].generated_valuesSet).toBeUndefined(); }); + +it('serializes null values without issue', () => { + const jsonString = makeSerializedAllData(flagWithNullInJsonVariation); + const parsed = deserializeAll(jsonString); + const serialized = serializeFlag(parsed!.data.flags.flagName); + // After serialization nulls should still be there, and any memo generated items should be gone. + expect(JSON.parse(serialized)).toEqual(flagWithNullInJsonVariation); +}); diff --git a/packages/shared/mocks/src/streamingProcessor.ts b/packages/shared/sdk-server/__tests__/streamingProcessor.ts similarity index 96% rename from packages/shared/mocks/src/streamingProcessor.ts rename to packages/shared/sdk-server/__tests__/streamingProcessor.ts index e596b443f..2f6e2a802 100644 --- a/packages/shared/mocks/src/streamingProcessor.ts +++ b/packages/shared/sdk-server/__tests__/streamingProcessor.ts @@ -2,9 +2,10 @@ import type { ClientContext, EventName, internal, + LDHeaders, LDStreamingError, ProcessStreamResponse, -} from '@common'; +} from '@launchdarkly/js-sdk-common'; export const MockStreamingProcessor = jest.fn(); @@ -22,11 +23,11 @@ export const setupMockStreamingProcessor = ( MockStreamingProcessor.mockImplementation( ( - sdkKey: string, clientContext: ClientContext, streamUriPath: string, parameters: { key: string; value: string }[], listeners: Map, + baseHeaders: LDHeaders, diagnosticsManager: internal.DiagnosticsManager, errorHandler: internal.StreamingErrorHandler, _streamInitialReconnectDelay: number, diff --git a/packages/shared/sdk-server/package.json b/packages/shared/sdk-server/package.json index afac2230f..88a829750 100644 --- a/packages/shared/sdk-server/package.json +++ b/packages/shared/sdk-server/package.json @@ -1,6 +1,6 @@ { "name": "@launchdarkly/js-server-sdk-common", - "version": "2.5.0", + "version": "2.9.0", "type": "commonjs", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -27,11 +27,10 @@ }, "license": "Apache-2.0", "dependencies": { - "@launchdarkly/js-sdk-common": "2.7.0", + "@launchdarkly/js-sdk-common": "2.11.0", "semver": "7.5.4" }, "devDependencies": { - "@launchdarkly/private-js-mocks": "0.0.1", "@trivago/prettier-plugin-sort-imports": "^4.1.1", "@types/jest": "^29.4.0", "@types/semver": "^7.3.13", diff --git a/packages/shared/sdk-server/src/BigSegmentStatusProviderImpl.ts b/packages/shared/sdk-server/src/BigSegmentStatusProviderImpl.ts index 07ae740d6..0b5a9dfc4 100644 --- a/packages/shared/sdk-server/src/BigSegmentStatusProviderImpl.ts +++ b/packages/shared/sdk-server/src/BigSegmentStatusProviderImpl.ts @@ -5,11 +5,11 @@ import { BigSegmentStoreStatus, BigSegmentStoreStatusProvider } from './api/inte * @ignore */ export default class BigSegmentStoreStatusProviderImpl implements BigSegmentStoreStatusProvider { - private lastStatus: BigSegmentStoreStatus | undefined; + private _lastStatus: BigSegmentStoreStatus | undefined; - private listener?: (status: BigSegmentStoreStatus) => void; + private _listener?: (status: BigSegmentStoreStatus) => void; - constructor(private readonly onRequestStatus: () => Promise) {} + constructor(private readonly _onRequestStatus: () => Promise) {} /** * Gets the current status of the store, if known. @@ -18,7 +18,7 @@ export default class BigSegmentStoreStatusProviderImpl implements BigSegmentStor * Big Segment store status */ getStatus(): BigSegmentStoreStatus | undefined { - return this.lastStatus; + return this._lastStatus; } /** @@ -27,25 +27,25 @@ export default class BigSegmentStoreStatusProviderImpl implements BigSegmentStor * @returns a Promise for the status of the store */ async requireStatus(): Promise { - if (!this.lastStatus) { - await this.onRequestStatus(); + if (!this._lastStatus) { + await this._onRequestStatus(); } // Status will be defined at this point. - return this.lastStatus!; + return this._lastStatus!; } notify() { - if (this.lastStatus) { - this.listener?.(this.lastStatus); + if (this._lastStatus) { + this._listener?.(this._lastStatus); } } setListener(listener: (status: BigSegmentStoreStatus) => void) { - this.listener = listener; + this._listener = listener; } setStatus(status: BigSegmentStoreStatus) { - this.lastStatus = status; + this._lastStatus = status; } } diff --git a/packages/shared/sdk-server/src/BigSegmentsManager.ts b/packages/shared/sdk-server/src/BigSegmentsManager.ts index 10cedd57b..e6c933bf3 100644 --- a/packages/shared/sdk-server/src/BigSegmentsManager.ts +++ b/packages/shared/sdk-server/src/BigSegmentsManager.ts @@ -15,27 +15,27 @@ interface MembershipCacheItem { } export default class BigSegmentsManager { - private cache: LruCache | undefined; + private _cache: LruCache | undefined; - private pollHandle: any; + private _pollHandle: any; - private staleTimeMs: number; + private _staleTimeMs: number; public readonly statusProvider: BigSegmentStoreStatusProviderImpl; constructor( - private store: BigSegmentStore | undefined, + private _store: BigSegmentStore | undefined, // The store will have been created before the manager is instantiated, so we do not need // it in the options at this stage. config: Omit, - private readonly logger: LDLogger | undefined, - private readonly crypto: Crypto, + private readonly _logger: LDLogger | undefined, + private readonly _crypto: Crypto, ) { this.statusProvider = new BigSegmentStoreStatusProviderImpl(async () => - this.pollStoreAndUpdateStatus(), + this._pollStoreAndUpdateStatus(), ); - this.staleTimeMs = + this._staleTimeMs = (TypeValidators.Number.is(config.staleAfter) && config.staleAfter > 0 ? config.staleAfter : DEFAULT_STALE_AFTER_SECONDS) * 1000; @@ -45,12 +45,12 @@ export default class BigSegmentsManager { ? config.statusPollInterval : DEFAULT_STATUS_POLL_INTERVAL_SECONDS) * 1000; - this.pollHandle = store - ? setInterval(() => this.pollStoreAndUpdateStatus(), pollIntervalMs) + this._pollHandle = _store + ? setInterval(() => this._pollStoreAndUpdateStatus(), pollIntervalMs) : null; - if (store) { - this.cache = new LruCache({ + if (_store) { + this._cache = new LruCache({ max: config.userCacheSize || DEFAULT_USER_CACHE_SIZE, maxAge: (config.userCacheTime || DEFAULT_USER_CACHE_TIME_SECONDS) * 1000, }); @@ -58,31 +58,31 @@ export default class BigSegmentsManager { } public close() { - if (this.pollHandle) { - clearInterval(this.pollHandle); - this.pollHandle = undefined; + if (this._pollHandle) { + clearInterval(this._pollHandle); + this._pollHandle = undefined; } - if (this.store) { - this.store.close(); + if (this._store) { + this._store.close(); } } public async getUserMembership( userKey: string, ): Promise<[BigSegmentStoreMembership | null, string] | undefined> { - if (!this.store) { + if (!this._store) { return undefined; } - const memberCache: MembershipCacheItem | undefined = this.cache?.get(userKey); + const memberCache: MembershipCacheItem | undefined = this._cache?.get(userKey); let membership: BigSegmentStoreMembership | undefined; if (!memberCache) { try { - membership = await this.store.getUserMembership(this.hashForUserKey(userKey)); + membership = await this._store.getUserMembership(this._hashForUserKey(userKey)); const cacheItem: MembershipCacheItem = { membership }; - this.cache?.set(userKey, cacheItem); + this._cache?.set(userKey, cacheItem); } catch (err) { - this.logger?.error(`Big Segment store membership query returned error: ${err}`); + this._logger?.error(`Big Segment store membership query returned error: ${err}`); return [null, 'STORE_ERROR']; } } else { @@ -90,7 +90,7 @@ export default class BigSegmentsManager { } if (!this.statusProvider.getStatus()) { - await this.pollStoreAndUpdateStatus(); + await this._pollStoreAndUpdateStatus(); } // Status will be present, because polling is done earlier in this method if it is not. @@ -103,24 +103,24 @@ export default class BigSegmentsManager { return [membership || null, lastStatus.stale ? 'STALE' : 'HEALTHY']; } - private async pollStoreAndUpdateStatus() { - if (!this.store) { + private async _pollStoreAndUpdateStatus() { + if (!this._store) { this.statusProvider.setStatus({ available: false, stale: false }); return; } - this.logger?.debug('Querying Big Segment store status'); + this._logger?.debug('Querying Big Segment store status'); let newStatus; try { - const metadata = await this.store.getMetadata(); + const metadata = await this._store.getMetadata(); newStatus = { available: true, - stale: !metadata || !metadata.lastUpToDate || this.isStale(metadata.lastUpToDate), + stale: !metadata || !metadata.lastUpToDate || this._isStale(metadata.lastUpToDate), }; } catch (err) { - this.logger?.error(`Big Segment store status query returned error: ${err}`); + this._logger?.error(`Big Segment store status query returned error: ${err}`); newStatus = { available: false, stale: false }; } @@ -131,7 +131,7 @@ export default class BigSegmentsManager { lastStatus.available !== newStatus.available || lastStatus.stale !== newStatus.stale ) { - this.logger?.debug( + this._logger?.debug( 'Big Segment store status changed from %s to %s', JSON.stringify(lastStatus), JSON.stringify(newStatus), @@ -141,13 +141,17 @@ export default class BigSegmentsManager { } } - private hashForUserKey(userKey: string): string { - const hasher = this.crypto.createHash('sha256'); + private _hashForUserKey(userKey: string): string { + const hasher = this._crypto.createHash('sha256'); hasher.update(userKey); + if (!hasher.digest) { + // This represents an error in platform implementation. + throw new Error('Platform must implement digest or asyncDigest'); + } return hasher.digest('base64'); } - private isStale(timestamp: number) { - return Date.now() - timestamp >= this.staleTimeMs; + private _isStale(timestamp: number) { + return Date.now() - timestamp >= this._staleTimeMs; } } diff --git a/packages/shared/sdk-server/src/FlagsStateBuilder.ts b/packages/shared/sdk-server/src/FlagsStateBuilder.ts index 86bbc23bb..1ca0613cc 100644 --- a/packages/shared/sdk-server/src/FlagsStateBuilder.ts +++ b/packages/shared/sdk-server/src/FlagsStateBuilder.ts @@ -10,16 +10,17 @@ interface FlagMeta { trackEvents?: boolean; trackReason?: boolean; debugEventsUntilDate?: number; + prerequisites?: string[]; } export default class FlagsStateBuilder { - private flagValues: LDFlagSet = {}; + private _flagValues: LDFlagSet = {}; - private flagMetadata: Record = {}; + private _flagMetadata: Record = {}; constructor( - private valid: boolean, - private withReasons: boolean, + private _valid: boolean, + private _withReasons: boolean, ) {} addFlag( @@ -30,8 +31,9 @@ export default class FlagsStateBuilder { trackEvents: boolean, trackReason: boolean, detailsOnlyIfTracked: boolean, + prerequisites?: string[], ) { - this.flagValues[flag.key] = value; + this._flagValues[flag.key] = value; const meta: FlagMeta = {}; if (variation !== undefined) { meta.variation = variation; @@ -44,7 +46,7 @@ export default class FlagsStateBuilder { if (!omitDetails) { meta.version = flag.version; } - if (reason && (trackReason || (this.withReasons && !omitDetails))) { + if (reason && (trackReason || (this._withReasons && !omitDetails))) { meta.reason = reason; } if (trackEvents) { @@ -56,21 +58,23 @@ export default class FlagsStateBuilder { if (flag.debugEventsUntilDate !== undefined) { meta.debugEventsUntilDate = flag.debugEventsUntilDate; } - this.flagMetadata[flag.key] = meta; + if (prerequisites && prerequisites.length) { + meta.prerequisites = prerequisites; + } + this._flagMetadata[flag.key] = meta; } build(): LDFlagsState { - const state = this; return { - valid: state.valid, - allValues: () => state.flagValues, - getFlagValue: (key) => state.flagValues[key], + valid: this._valid, + allValues: () => this._flagValues, + getFlagValue: (key) => this._flagValues[key], getFlagReason: (key) => - (state.flagMetadata[key] ? state.flagMetadata[key].reason : null) ?? null, + (this._flagMetadata[key] ? this._flagMetadata[key].reason : null) ?? null, toJSON: () => ({ - ...state.flagValues, - $flagsState: state.flagMetadata, - $valid: state.valid, + ...this._flagValues, + $flagsState: this._flagMetadata, + $valid: this._valid, }), }; } diff --git a/packages/shared/sdk-server/src/LDClientImpl.ts b/packages/shared/sdk-server/src/LDClientImpl.ts index 688a36b40..bdc4df775 100644 --- a/packages/shared/sdk-server/src/LDClientImpl.ts +++ b/packages/shared/sdk-server/src/LDClientImpl.ts @@ -3,6 +3,7 @@ import { cancelableTimedPromise, ClientContext, Context, + defaultHeaders, internal, LDClientError, LDContext, @@ -89,43 +90,43 @@ const VARIATION_METHOD_DETAIL_NAME = 'LDClient.variationDetail'; * @ignore */ export default class LDClientImpl implements LDClient { - private initState: InitState = InitState.Initializing; + private _initState: InitState = InitState.Initializing; - private featureStore: LDFeatureStore; + private _featureStore: LDFeatureStore; - private updateProcessor?: subsystem.LDStreamProcessor; + private _updateProcessor?: subsystem.LDStreamProcessor; - private eventFactoryDefault = new EventFactory(false); + private _eventFactoryDefault = new EventFactory(false); - private eventFactoryWithReasons = new EventFactory(true); + private _eventFactoryWithReasons = new EventFactory(true); - private eventProcessor: subsystem.LDEventProcessor; + private _eventProcessor: subsystem.LDEventProcessor; - private evaluator: Evaluator; + private _evaluator: Evaluator; - private initResolve?: (value: LDClient | PromiseLike) => void; + private _initResolve?: (value: LDClient | PromiseLike) => void; - private initReject?: (err: Error) => void; + private _initReject?: (err: Error) => void; - private rejectionReason: Error | undefined; + private _rejectionReason: Error | undefined; - private initializedPromise?: Promise; + private _initializedPromise?: Promise; - private logger?: LDLogger; + private _logger?: LDLogger; - private config: Configuration; + private _config: Configuration; - private bigSegmentsManager: BigSegmentsManager; + private _bigSegmentsManager: BigSegmentsManager; - private onError: (err: Error) => void; + private _onError: (err: Error) => void; - private onFailed: (err: Error) => void; + private _onFailed: (err: Error) => void; - private onReady: () => void; + private _onReady: () => void; - private diagnosticsManager?: internal.DiagnosticsManager; + private _diagnosticsManager?: internal.DiagnosticsManager; - private hookRunner: HookRunner; + private _hookRunner: HookRunner; /** * Intended for use by platform specific client implementations. @@ -137,60 +138,62 @@ export default class LDClientImpl implements LDClient { protected bigSegmentStatusProviderInternal: BigSegmentStoreStatusProvider; constructor( - private sdkKey: string, - private platform: Platform, + private _sdkKey: string, + private _platform: Platform, options: LDOptions, callbacks: LDClientCallbacks, internalOptions?: internal.LDInternalOptions, ) { - this.onError = callbacks.onError; - this.onFailed = callbacks.onFailed; - this.onReady = callbacks.onReady; + this._onError = callbacks.onError; + this._onFailed = callbacks.onFailed; + this._onReady = callbacks.onReady; const { onUpdate, hasEventListeners } = callbacks; const config = new Configuration(options, internalOptions); - this.hookRunner = new HookRunner(config.logger, config.hooks || []); + this._hookRunner = new HookRunner(config.logger, config.hooks || []); - if (!sdkKey && !config.offline) { + if (!_sdkKey && !config.offline) { throw new Error('You must configure the client with an SDK key'); } - this.config = config; - this.logger = config.logger; + this._config = config; + this._logger = config.logger; + const baseHeaders = defaultHeaders(_sdkKey, _platform.info, config.tags); - const clientContext = new ClientContext(sdkKey, config, platform); + const clientContext = new ClientContext(_sdkKey, config, _platform); const featureStore = config.featureStoreFactory(clientContext); const dataSourceUpdates = new DataSourceUpdates(featureStore, hasEventListeners, onUpdate); if (config.sendEvents && !config.offline && !config.diagnosticOptOut) { - this.diagnosticsManager = new internal.DiagnosticsManager( - sdkKey, - platform, - createDiagnosticsInitConfig(config, platform, featureStore), + this._diagnosticsManager = new internal.DiagnosticsManager( + _sdkKey, + _platform, + createDiagnosticsInitConfig(config, _platform, featureStore), ); } if (!config.sendEvents || config.offline) { - this.eventProcessor = new NullEventProcessor(); + this._eventProcessor = new NullEventProcessor(); } else { - this.eventProcessor = new internal.EventProcessor( + this._eventProcessor = new internal.EventProcessor( config, clientContext, + baseHeaders, new ContextDeduplicator(config), - this.diagnosticsManager, + this._diagnosticsManager, ); } - this.featureStore = featureStore; + this._featureStore = featureStore; const manager = new BigSegmentsManager( config.bigSegments?.store?.(clientContext), config.bigSegments ?? {}, config.logger, - this.platform.crypto, + this._platform.crypto, ); - this.bigSegmentsManager = manager; + this._bigSegmentsManager = manager; this.bigSegmentStatusProviderInternal = manager.statusProvider as BigSegmentStoreStatusProvider; const queries: Queries = { @@ -206,52 +209,52 @@ export default class LDClientImpl implements LDClient { return manager.getUserMembership(userKey); }, }; - this.evaluator = new Evaluator(this.platform, queries); + this._evaluator = new Evaluator(this._platform, queries); - const listeners = createStreamListeners(dataSourceUpdates, this.logger, { - put: () => this.initSuccess(), + const listeners = createStreamListeners(dataSourceUpdates, this._logger, { + put: () => this._initSuccess(), }); const makeDefaultProcessor = () => config.stream ? new internal.StreamingProcessor( - sdkKey, clientContext, '/all', [], listeners, - this.diagnosticsManager, - (e) => this.dataSourceErrorHandler(e), - this.config.streamInitialReconnectDelay, + baseHeaders, + this._diagnosticsManager, + (e) => this._dataSourceErrorHandler(e), + this._config.streamInitialReconnectDelay, ) : new PollingProcessor( config, - new Requestor(sdkKey, config, this.platform.info, this.platform.requests), + new Requestor(config, this._platform.requests, baseHeaders), dataSourceUpdates, - () => this.initSuccess(), - (e) => this.dataSourceErrorHandler(e), + () => this._initSuccess(), + (e) => this._dataSourceErrorHandler(e), ); if (!(config.offline || config.useLdd)) { - this.updateProcessor = + this._updateProcessor = config.updateProcessorFactory?.( clientContext, dataSourceUpdates, - () => this.initSuccess(), - (e) => this.dataSourceErrorHandler(e), + () => this._initSuccess(), + (e) => this._dataSourceErrorHandler(e), ) ?? makeDefaultProcessor(); } - if (this.updateProcessor) { - this.updateProcessor.start(); + if (this._updateProcessor) { + this._updateProcessor.start(); } else { // Deferring the start callback should allow client construction to complete before we start // emitting events. Allowing the client an opportunity to register events. - setTimeout(() => this.initSuccess(), 0); + setTimeout(() => this._initSuccess(), 0); } } initialized(): boolean { - return this.initState === InitState.Initialized; + return this._initState === InitState.Initialized; } waitForInitialization(options?: LDWaitForInitializationOptions): Promise { @@ -263,8 +266,8 @@ export default class LDClientImpl implements LDClient { // If there is no update processor, then there is functionally no initialization // so it is fine not to wait. - if (options?.timeout === undefined && this.updateProcessor !== undefined) { - this.logger?.warn( + if (options?.timeout === undefined && this._updateProcessor !== undefined) { + this._logger?.warn( 'The waitForInitialization function was called without a timeout specified.' + ' In a future version a default timeout will be applied.', ); @@ -272,9 +275,9 @@ export default class LDClientImpl implements LDClient { if ( options?.timeout !== undefined && options?.timeout > HIGH_TIMEOUT_THRESHOLD && - this.updateProcessor !== undefined + this._updateProcessor !== undefined ) { - this.logger?.warn( + this._logger?.warn( 'The waitForInitialization function was called with a timeout greater than ' + `${HIGH_TIMEOUT_THRESHOLD} seconds. We recommend a timeout of less than ` + `${HIGH_TIMEOUT_THRESHOLD} seconds.`, @@ -282,34 +285,34 @@ export default class LDClientImpl implements LDClient { } // Initialization promise was created by a previous call to waitForInitialization. - if (this.initializedPromise) { + if (this._initializedPromise) { // This promise may already be resolved/rejected, but it doesn't hurt to wrap it in a timeout. - return this.clientWithTimeout(this.initializedPromise, options?.timeout, this.logger); + return this._clientWithTimeout(this._initializedPromise, options?.timeout, this._logger); } // Initialization completed before waitForInitialization was called, so we have completed // and there was no promise. So we make a resolved promise and return it. - if (this.initState === InitState.Initialized) { - this.initializedPromise = Promise.resolve(this); + if (this._initState === InitState.Initialized) { + this._initializedPromise = Promise.resolve(this); // Already initialized, no need to timeout. - return this.initializedPromise; + return this._initializedPromise; } // Initialization failed before waitForInitialization was called, so we have completed // and there was no promise. So we make a rejected promise and return it. - if (this.initState === InitState.Failed) { + if (this._initState === InitState.Failed) { // Already failed, no need to timeout. - this.initializedPromise = Promise.reject(this.rejectionReason); - return this.initializedPromise; + this._initializedPromise = Promise.reject(this._rejectionReason); + return this._initializedPromise; } - if (!this.initializedPromise) { - this.initializedPromise = new Promise((resolve, reject) => { - this.initResolve = resolve; - this.initReject = reject; + if (!this._initializedPromise) { + this._initializedPromise = new Promise((resolve, reject) => { + this._initResolve = resolve; + this._initReject = reject; }); } - return this.clientWithTimeout(this.initializedPromise, options?.timeout, this.logger); + return this._clientWithTimeout(this._initializedPromise, options?.timeout, this._logger); } variation( @@ -318,7 +321,7 @@ export default class LDClientImpl implements LDClient { defaultValue: any, callback?: (err: any, res: any) => void, ): Promise { - return this.hookRunner + return this._hookRunner .withEvaluationSeries( key, context, @@ -326,9 +329,15 @@ export default class LDClientImpl implements LDClient { VARIATION_METHOD_NAME, () => new Promise((resolve) => { - this.evaluateIfPossible(key, context, defaultValue, this.eventFactoryDefault, (res) => { - resolve(res.detail); - }); + this._evaluateIfPossible( + key, + context, + defaultValue, + this._eventFactoryDefault, + (res) => { + resolve(res.detail); + }, + ); }), ) .then((detail) => { @@ -343,18 +352,18 @@ export default class LDClientImpl implements LDClient { defaultValue: any, callback?: (err: any, res: LDEvaluationDetail) => void, ): Promise { - return this.hookRunner.withEvaluationSeries( + return this._hookRunner.withEvaluationSeries( key, context, defaultValue, VARIATION_METHOD_DETAIL_NAME, () => new Promise((resolve) => { - this.evaluateIfPossible( + this._evaluateIfPossible( key, context, defaultValue, - this.eventFactoryWithReasons, + this._eventFactoryWithReasons, (res) => { resolve(res.detail); callback?.(null, res.detail); @@ -364,7 +373,7 @@ export default class LDClientImpl implements LDClient { ); } - private typedEval( + private _typedEval( key: string, context: LDContext, defaultValue: TResult, @@ -372,14 +381,14 @@ export default class LDClientImpl implements LDClient { methodName: string, typeChecker: (value: unknown) => [boolean, string], ): Promise { - return this.hookRunner.withEvaluationSeries( + return this._hookRunner.withEvaluationSeries( key, context, defaultValue, methodName, () => new Promise>((resolve) => { - this.evaluateIfPossible( + this._evaluateIfPossible( key, context, defaultValue, @@ -400,11 +409,11 @@ export default class LDClientImpl implements LDClient { async boolVariation(key: string, context: LDContext, defaultValue: boolean): Promise { return ( - await this.typedEval( + await this._typedEval( key, context, defaultValue, - this.eventFactoryDefault, + this._eventFactoryDefault, BOOL_VARIATION_METHOD_NAME, (value) => [TypeValidators.Boolean.is(value), TypeValidators.Boolean.getType()], ) @@ -413,11 +422,11 @@ export default class LDClientImpl implements LDClient { async numberVariation(key: string, context: LDContext, defaultValue: number): Promise { return ( - await this.typedEval( + await this._typedEval( key, context, defaultValue, - this.eventFactoryDefault, + this._eventFactoryDefault, NUMBER_VARIATION_METHOD_NAME, (value) => [TypeValidators.Number.is(value), TypeValidators.Number.getType()], ) @@ -426,11 +435,11 @@ export default class LDClientImpl implements LDClient { async stringVariation(key: string, context: LDContext, defaultValue: string): Promise { return ( - await this.typedEval( + await this._typedEval( key, context, defaultValue, - this.eventFactoryDefault, + this._eventFactoryDefault, STRING_VARIATION_METHOD_NAME, (value) => [TypeValidators.String.is(value), TypeValidators.String.getType()], ) @@ -438,7 +447,7 @@ export default class LDClientImpl implements LDClient { } jsonVariation(key: string, context: LDContext, defaultValue: unknown): Promise { - return this.hookRunner + return this._hookRunner .withEvaluationSeries( key, context, @@ -446,9 +455,15 @@ export default class LDClientImpl implements LDClient { JSON_VARIATION_METHOD_NAME, () => new Promise((resolve) => { - this.evaluateIfPossible(key, context, defaultValue, this.eventFactoryDefault, (res) => { - resolve(res.detail); - }); + this._evaluateIfPossible( + key, + context, + defaultValue, + this._eventFactoryDefault, + (res) => { + resolve(res.detail); + }, + ); }), ) .then((detail) => detail.value); @@ -459,11 +474,11 @@ export default class LDClientImpl implements LDClient { context: LDContext, defaultValue: boolean, ): Promise> { - return this.typedEval( + return this._typedEval( key, context, defaultValue, - this.eventFactoryWithReasons, + this._eventFactoryWithReasons, BOOL_VARIATION_DETAIL_METHOD_NAME, (value) => [TypeValidators.Boolean.is(value), TypeValidators.Boolean.getType()], ); @@ -474,11 +489,11 @@ export default class LDClientImpl implements LDClient { context: LDContext, defaultValue: number, ): Promise> { - return this.typedEval( + return this._typedEval( key, context, defaultValue, - this.eventFactoryWithReasons, + this._eventFactoryWithReasons, NUMBER_VARIATION_DETAIL_METHOD_NAME, (value) => [TypeValidators.Number.is(value), TypeValidators.Number.getType()], ); @@ -489,11 +504,11 @@ export default class LDClientImpl implements LDClient { context: LDContext, defaultValue: string, ): Promise> { - return this.typedEval( + return this._typedEval( key, context, defaultValue, - this.eventFactoryWithReasons, + this._eventFactoryWithReasons, STRING_VARIATION_DETAIL_METHOD_NAME, (value) => [TypeValidators.String.is(value), TypeValidators.String.getType()], ); @@ -504,18 +519,18 @@ export default class LDClientImpl implements LDClient { context: LDContext, defaultValue: unknown, ): Promise> { - return this.hookRunner.withEvaluationSeries( + return this._hookRunner.withEvaluationSeries( key, context, defaultValue, JSON_VARIATION_DETAIL_METHOD_NAME, () => new Promise((resolve) => { - this.evaluateIfPossible( + this._evaluateIfPossible( key, context, defaultValue, - this.eventFactoryWithReasons, + this._eventFactoryWithReasons, (res) => { resolve(res.detail); }, @@ -524,24 +539,24 @@ export default class LDClientImpl implements LDClient { ); } - private async migrationVariationInternal( + private async _migrationVariationInternal( key: string, context: LDContext, defaultValue: LDMigrationStage, ): Promise<{ detail: LDEvaluationDetail; migration: LDMigrationVariation }> { const convertedContext = Context.fromLDContext(context); const res = await new Promise<{ detail: LDEvaluationDetail; flag?: Flag }>((resolve) => { - this.evaluateIfPossible( + this._evaluateIfPossible( key, context, defaultValue, - this.eventFactoryWithReasons, + this._eventFactoryWithReasons, ({ detail }, flag) => { if (!IsMigrationStage(detail.value)) { const error = new Error( `Unrecognized MigrationState for "${key}"; returning default value.`, ); - this.onError(error); + this._onError(error); const reason = { kind: 'ERROR', errorKind: ErrorKinds.WrongType, @@ -580,7 +595,7 @@ export default class LDClientImpl implements LDClient { detail.variationIndex === null ? undefined : detail.variationIndex, flag?.version, samplingRatio, - this.logger, + this._logger, ), }, }; @@ -591,12 +606,12 @@ export default class LDClientImpl implements LDClient { context: LDContext, defaultValue: LDMigrationStage, ): Promise { - const res = await this.hookRunner.withEvaluationSeriesExtraDetail( + const res = await this._hookRunner.withEvaluationSeriesExtraDetail( key, context, defaultValue, MIGRATION_VARIATION_METHOD_NAME, - () => this.migrationVariationInternal(key, context, defaultValue), + () => this._migrationVariationInternal(key, context, defaultValue), ); return res.migration; @@ -607,8 +622,8 @@ export default class LDClientImpl implements LDClient { options?: LDFlagsStateOptions, callback?: (err: Error | null, res: LDFlagsState) => void, ): Promise { - if (this.config.offline) { - this.logger?.info('allFlagsState() called in offline mode. Returning empty state.'); + if (this._config.offline) { + this._logger?.info('allFlagsState() called in offline mode. Returning empty state.'); const allFlagState = new FlagsStateBuilder(false, false).build(); callback?.(null, allFlagState); return Promise.resolve(allFlagState); @@ -616,13 +631,13 @@ export default class LDClientImpl implements LDClient { const evalContext = Context.fromLDContext(context); if (!evalContext.valid) { - this.logger?.info(`${evalContext.message ?? 'Invalid context.'}. Returning empty state.`); + this._logger?.info(`${evalContext.message ?? 'Invalid context.'}. Returning empty state.`); return Promise.resolve(new FlagsStateBuilder(false, false).build()); } return new Promise((resolve) => { const doEval = (valid: boolean) => - this.featureStore.all(VersionedDataKinds.Features, (allFlags) => { + this._featureStore.all(VersionedDataKinds.Features, (allFlags) => { const builder = new FlagsStateBuilder(valid, !!options?.withReasons); const clientOnly = !!options?.clientSideOnly; const detailsOnlyIfTracked = !!options?.detailsOnlyForTrackedFlags; @@ -635,9 +650,9 @@ export default class LDClientImpl implements LDClient { iterCb(true); return; } - this.evaluator.evaluateCb(flag, evalContext, (res) => { + this._evaluator.evaluateCb(flag, evalContext, (res) => { if (res.isError) { - this.onError( + this._onError( new Error( `Error for feature flag "${flag.key}" while evaluating all flags: ${res.message}`, ), @@ -652,6 +667,7 @@ export default class LDClientImpl implements LDClient { flag.trackEvents || requireExperimentData, requireExperimentData, detailsOnlyIfTracked, + res.prerequisites, ); iterCb(true); }); @@ -664,15 +680,15 @@ export default class LDClientImpl implements LDClient { ); }); if (!this.initialized()) { - this.featureStore.initialized((storeInitialized) => { + this._featureStore.initialized((storeInitialized) => { let valid = true; if (storeInitialized) { - this.logger?.warn( + this._logger?.warn( 'Called allFlagsState before client initialization; using last known' + ' values from data store', ); } else { - this.logger?.warn( + this._logger?.warn( 'Called allFlagsState before client initialization. Data store not available; ' + 'returning empty state', ); @@ -689,7 +705,12 @@ export default class LDClientImpl implements LDClient { secureModeHash(context: LDContext): string { const checkedContext = Context.fromLDContext(context); const key = checkedContext.valid ? checkedContext.canonicalKey : undefined; - const hmac = this.platform.crypto.createHmac('sha256', this.sdkKey); + if (!this._platform.crypto.createHmac) { + // This represents an error in platform implementation. + throw new Error('Platform must implement createHmac'); + } + const hmac = this._platform.crypto.createHmac('sha256', this._sdkKey); + if (key === undefined) { throw new LDClientError('Could not generate secure mode hash for invalid context'); } @@ -698,30 +719,30 @@ export default class LDClientImpl implements LDClient { } close(): void { - this.eventProcessor.close(); - this.updateProcessor?.close(); - this.featureStore.close(); - this.bigSegmentsManager.close(); + this._eventProcessor.close(); + this._updateProcessor?.close(); + this._featureStore.close(); + this._bigSegmentsManager.close(); } isOffline(): boolean { - return this.config.offline; + return this._config.offline; } track(key: string, context: LDContext, data?: any, metricValue?: number): void { const checkedContext = Context.fromLDContext(context); if (!checkedContext.valid) { - this.logger?.warn(ClientMessages.missingContextKeyNoEvent); + this._logger?.warn(ClientMessages.MissingContextKeyNoEvent); return; } // 0 is valid, so do not truthy check the metric value if (metricValue !== undefined && !TypeValidators.Number.is(metricValue)) { - this.logger?.warn(ClientMessages.invalidMetricValue(typeof metricValue)); + this._logger?.warn(ClientMessages.invalidMetricValue(typeof metricValue)); } - this.eventProcessor.sendEvent( - this.eventFactoryDefault.customEvent(key, checkedContext!, data, metricValue), + this._eventProcessor.sendEvent( + this._eventFactoryDefault.customEvent(key, checkedContext!, data, metricValue), ); } @@ -731,21 +752,21 @@ export default class LDClientImpl implements LDClient { return; } - this.eventProcessor.sendEvent(converted); + this._eventProcessor.sendEvent(converted); } identify(context: LDContext): void { const checkedContext = Context.fromLDContext(context); if (!checkedContext.valid) { - this.logger?.warn(ClientMessages.missingContextKeyNoEvent); + this._logger?.warn(ClientMessages.MissingContextKeyNoEvent); return; } - this.eventProcessor.sendEvent(this.eventFactoryDefault.identifyEvent(checkedContext!)); + this._eventProcessor.sendEvent(this._eventFactoryDefault.identifyEvent(checkedContext!)); } async flush(callback?: (err: Error | null, res: boolean) => void): Promise { try { - await this.eventProcessor.flush(); + await this._eventProcessor.flush(); } catch (err) { callback?.(err as Error, false); } @@ -753,10 +774,10 @@ export default class LDClientImpl implements LDClient { } addHook(hook: Hook): void { - this.hookRunner.addHook(hook); + this._hookRunner.addHook(hook); } - private variationInternal( + private _variationInternal( flagKey: string, context: LDContext, defaultValue: any, @@ -764,14 +785,14 @@ export default class LDClientImpl implements LDClient { cb: (res: EvalResult, flag?: Flag) => void, typeChecker?: (value: any) => [boolean, string], ): void { - if (this.config.offline) { - this.logger?.info('Variation called in offline mode. Returning default value.'); + if (this._config.offline) { + this._logger?.info('Variation called in offline mode. Returning default value.'); cb(EvalResult.forError(ErrorKinds.ClientNotReady, undefined, defaultValue)); return; } const evalContext = Context.fromLDContext(context); if (!evalContext.valid) { - this.onError( + this._onError( new LDClientError( `${evalContext.message ?? 'Context not valid;'} returning default value.`, ), @@ -780,21 +801,21 @@ export default class LDClientImpl implements LDClient { return; } - this.featureStore.get(VersionedDataKinds.Features, flagKey, (item) => { + this._featureStore.get(VersionedDataKinds.Features, flagKey, (item) => { const flag = item as Flag; if (!flag) { const error = new LDClientError( `Unknown feature flag "${flagKey}"; returning default value`, ); - this.onError(error); + this._onError(error); const result = EvalResult.forError(ErrorKinds.FlagNotFound, undefined, defaultValue); - this.eventProcessor.sendEvent( - this.eventFactoryDefault.unknownFlagEvent(flagKey, defaultValue, evalContext), + this._eventProcessor.sendEvent( + this._eventFactoryDefault.unknownFlagEvent(flagKey, defaultValue, evalContext), ); cb(result); return; } - this.evaluator.evaluateCb( + this._evaluator.evaluateCb( flag, evalContext, (evalRes) => { @@ -802,7 +823,7 @@ export default class LDClientImpl implements LDClient { evalRes.detail.variationIndex === undefined || evalRes.detail.variationIndex === null ) { - this.logger?.debug('Result value is null in variation'); + this._logger?.debug('Result value is null in variation'); evalRes.setDefault(defaultValue); } @@ -814,13 +835,13 @@ export default class LDClientImpl implements LDClient { `Did not receive expected type (${type}) evaluating feature flag "${flagKey}"`, defaultValue, ); - this.sendEvalEvent(errorRes, eventFactory, flag, evalContext, defaultValue); + this._sendEvalEvent(errorRes, eventFactory, flag, evalContext, defaultValue); cb(errorRes, flag); return; } } - this.sendEvalEvent(evalRes, eventFactory, flag, evalContext, defaultValue); + this._sendEvalEvent(evalRes, eventFactory, flag, evalContext, defaultValue); cb(evalRes, flag); }, eventFactory, @@ -828,7 +849,7 @@ export default class LDClientImpl implements LDClient { }); } - private sendEvalEvent( + private _sendEvalEvent( evalRes: EvalResult, eventFactory: EventFactory, flag: Flag, @@ -836,14 +857,14 @@ export default class LDClientImpl implements LDClient { defaultValue: any, ) { evalRes.events?.forEach((event) => { - this.eventProcessor.sendEvent({ ...event }); + this._eventProcessor.sendEvent({ ...event }); }); - this.eventProcessor.sendEvent( + this._eventProcessor.sendEvent( eventFactory.evalEventServer(flag, evalContext, evalRes.detail, defaultValue, undefined), ); } - private evaluateIfPossible( + private _evaluateIfPossible( flagKey: string, context: LDContext, defaultValue: any, @@ -852,16 +873,16 @@ export default class LDClientImpl implements LDClient { typeChecker?: (value: any) => [boolean, string], ): void { if (!this.initialized()) { - this.featureStore.initialized((storeInitialized) => { + this._featureStore.initialized((storeInitialized) => { if (storeInitialized) { - this.logger?.warn( + this._logger?.warn( 'Variation called before LaunchDarkly client initialization completed' + " (did you wait for the 'ready' event?) - using last known values from feature store", ); - this.variationInternal(flagKey, context, defaultValue, eventFactory, cb, typeChecker); + this._variationInternal(flagKey, context, defaultValue, eventFactory, cb, typeChecker); return; } - this.logger?.warn( + this._logger?.warn( 'Variation called before LaunchDarkly client initialization completed (did you wait for the' + "'ready' event?) - using default value", ); @@ -869,28 +890,28 @@ export default class LDClientImpl implements LDClient { }); return; } - this.variationInternal(flagKey, context, defaultValue, eventFactory, cb, typeChecker); + this._variationInternal(flagKey, context, defaultValue, eventFactory, cb, typeChecker); } - private dataSourceErrorHandler(e: any) { + private _dataSourceErrorHandler(e: any) { const error = e.code === 401 ? new Error('Authentication failed. Double check your SDK key.') : e; - this.onError(error); - this.onFailed(error); + this._onError(error); + this._onFailed(error); if (!this.initialized()) { - this.initState = InitState.Failed; - this.rejectionReason = error; - this.initReject?.(error); + this._initState = InitState.Failed; + this._rejectionReason = error; + this._initReject?.(error); } } - private initSuccess() { + private _initSuccess() { if (!this.initialized()) { - this.initState = InitState.Initialized; - this.initResolve?.(this); - this.onReady(); + this._initState = InitState.Initialized; + this._initResolve?.(this); + this._onReady(); } } @@ -906,7 +927,7 @@ export default class LDClientImpl implements LDClient { * @param logger A logger to log when the timeout expires. * @returns */ - private clientWithTimeout( + private _clientWithTimeout( basePromise: Promise, timeout?: number, logger?: LDLogger, diff --git a/packages/shared/sdk-server/src/Migration.ts b/packages/shared/sdk-server/src/Migration.ts index 475eef32e..2a5b2a037 100644 --- a/packages/shared/sdk-server/src/Migration.ts +++ b/packages/shared/sdk-server/src/Migration.ts @@ -99,58 +99,70 @@ class Migration< > implements LDMigration { - private readonly execution: LDSerialExecution | LDConcurrentExecution; + private readonly _execution: LDSerialExecution | LDConcurrentExecution; - private readonly errorTracking: boolean; + private readonly _errorTracking: boolean; - private readonly latencyTracking: boolean; + private readonly _latencyTracking: boolean; - private readonly readTable: { + private readonly _readTable: { [index: string]: ( context: MigrationContext, ) => Promise>; } = { [LDMigrationStage.Off]: async (context) => - this.doSingleOp(context, 'old', this.config.readOld.bind(this.config)), + this._doSingleOp(context, 'old', this._config.readOld.bind(this._config)), [LDMigrationStage.DualWrite]: async (context) => - this.doSingleOp(context, 'old', this.config.readOld.bind(this.config)), + this._doSingleOp(context, 'old', this._config.readOld.bind(this._config)), [LDMigrationStage.Shadow]: async (context) => { - const { fromOld, fromNew } = await this.doRead(context); + const { fromOld, fromNew } = await this._doRead(context); - this.trackConsistency(context, fromOld, fromNew); + this._trackConsistency(context, fromOld, fromNew); return fromOld; }, [LDMigrationStage.Live]: async (context) => { - const { fromNew, fromOld } = await this.doRead(context); + const { fromNew, fromOld } = await this._doRead(context); - this.trackConsistency(context, fromOld, fromNew); + this._trackConsistency(context, fromOld, fromNew); return fromNew; }, [LDMigrationStage.RampDown]: async (context) => - this.doSingleOp(context, 'new', this.config.readNew.bind(this.config)), + this._doSingleOp(context, 'new', this._config.readNew.bind(this._config)), [LDMigrationStage.Complete]: async (context) => - this.doSingleOp(context, 'new', this.config.readNew.bind(this.config)), + this._doSingleOp(context, 'new', this._config.readNew.bind(this._config)), }; - private readonly writeTable: { + private readonly _writeTable: { [index: string]: ( context: MigrationContext, ) => Promise>; } = { [LDMigrationStage.Off]: async (context) => ({ - authoritative: await this.doSingleOp(context, 'old', this.config.writeOld.bind(this.config)), + authoritative: await this._doSingleOp( + context, + 'old', + this._config.writeOld.bind(this._config), + ), }), [LDMigrationStage.DualWrite]: async (context) => { - const fromOld = await this.doSingleOp(context, 'old', this.config.writeOld.bind(this.config)); + const fromOld = await this._doSingleOp( + context, + 'old', + this._config.writeOld.bind(this._config), + ); if (!fromOld.success) { return { authoritative: fromOld, }; } - const fromNew = await this.doSingleOp(context, 'new', this.config.writeNew.bind(this.config)); + const fromNew = await this._doSingleOp( + context, + 'new', + this._config.writeNew.bind(this._config), + ); return { authoritative: fromOld, @@ -158,14 +170,22 @@ class Migration< }; }, [LDMigrationStage.Shadow]: async (context) => { - const fromOld = await this.doSingleOp(context, 'old', this.config.writeOld.bind(this.config)); + const fromOld = await this._doSingleOp( + context, + 'old', + this._config.writeOld.bind(this._config), + ); if (!fromOld.success) { return { authoritative: fromOld, }; } - const fromNew = await this.doSingleOp(context, 'new', this.config.writeNew.bind(this.config)); + const fromNew = await this._doSingleOp( + context, + 'new', + this._config.writeNew.bind(this._config), + ); return { authoritative: fromOld, @@ -173,14 +193,22 @@ class Migration< }; }, [LDMigrationStage.Live]: async (context) => { - const fromNew = await this.doSingleOp(context, 'new', this.config.writeNew.bind(this.config)); + const fromNew = await this._doSingleOp( + context, + 'new', + this._config.writeNew.bind(this._config), + ); if (!fromNew.success) { return { authoritative: fromNew, }; } - const fromOld = await this.doSingleOp(context, 'old', this.config.writeOld.bind(this.config)); + const fromOld = await this._doSingleOp( + context, + 'old', + this._config.writeOld.bind(this._config), + ); return { authoritative: fromNew, @@ -188,14 +216,22 @@ class Migration< }; }, [LDMigrationStage.RampDown]: async (context) => { - const fromNew = await this.doSingleOp(context, 'new', this.config.writeNew.bind(this.config)); + const fromNew = await this._doSingleOp( + context, + 'new', + this._config.writeNew.bind(this._config), + ); if (!fromNew.success) { return { authoritative: fromNew, }; } - const fromOld = await this.doSingleOp(context, 'old', this.config.writeOld.bind(this.config)); + const fromOld = await this._doSingleOp( + context, + 'old', + this._config.writeOld.bind(this._config), + ); return { authoritative: fromNew, @@ -203,27 +239,31 @@ class Migration< }; }, [LDMigrationStage.Complete]: async (context) => ({ - authoritative: await this.doSingleOp(context, 'new', this.config.writeNew.bind(this.config)), + authoritative: await this._doSingleOp( + context, + 'new', + this._config.writeNew.bind(this._config), + ), }), }; constructor( - private readonly client: LDClient, - private readonly config: LDMigrationOptions< + private readonly _client: LDClient, + private readonly _config: LDMigrationOptions< TMigrationRead, TMigrationWrite, TMigrationReadInput, TMigrationWriteInput >, ) { - if (this.config.execution) { - this.execution = this.config.execution; + if (this._config.execution) { + this._execution = this._config.execution; } else { - this.execution = new LDConcurrentExecution(); + this._execution = new LDConcurrentExecution(); } - this.latencyTracking = this.config.latencyTracking ?? true; - this.errorTracking = this.config.errorTracking ?? true; + this._latencyTracking = this._config.latencyTracking ?? true; + this._errorTracking = this._config.errorTracking ?? true; } async read( @@ -232,13 +272,13 @@ class Migration< defaultStage: LDMigrationStage, payload?: TMigrationReadInput, ): Promise> { - const stage = await this.client.migrationVariation(key, context, defaultStage); - const res = await this.readTable[stage.value]({ + const stage = await this._client.migrationVariation(key, context, defaultStage); + const res = await this._readTable[stage.value]({ payload, tracker: stage.tracker, }); stage.tracker.op('read'); - this.sendEvent(stage.tracker); + this._sendEvent(stage.tracker); return res; } @@ -248,57 +288,65 @@ class Migration< defaultStage: LDMigrationStage, payload?: TMigrationWriteInput, ): Promise> { - const stage = await this.client.migrationVariation(key, context, defaultStage); - const res = await this.writeTable[stage.value]({ + const stage = await this._client.migrationVariation(key, context, defaultStage); + const res = await this._writeTable[stage.value]({ payload, tracker: stage.tracker, }); stage.tracker.op('write'); - this.sendEvent(stage.tracker); + this._sendEvent(stage.tracker); return res; } - private sendEvent(tracker: LDMigrationTracker) { + private _sendEvent(tracker: LDMigrationTracker) { const event = tracker.createEvent(); if (event) { - this.client.trackMigration(event); + this._client.trackMigration(event); } } - private trackConsistency( + private _trackConsistency( context: MigrationContext, oldValue: LDMethodResult, newValue: LDMethodResult, ) { - if (!this.config.check) { + if (!this._config.check) { return; } if (oldValue.success && newValue.success) { // Check is validated before this point, so it is force unwrapped. - context.tracker.consistency(() => this.config.check!(oldValue.result, newValue.result)); + context.tracker.consistency(() => this._config.check!(oldValue.result, newValue.result)); } } - private async readSequentialFixed( + private async _readSequentialFixed( context: MigrationContext, ): Promise> { - const fromOld = await this.doSingleOp(context, 'old', this.config.readOld.bind(this.config)); - const fromNew = await this.doSingleOp(context, 'new', this.config.readNew.bind(this.config)); + const fromOld = await this._doSingleOp(context, 'old', this._config.readOld.bind(this._config)); + const fromNew = await this._doSingleOp(context, 'new', this._config.readNew.bind(this._config)); return { fromOld, fromNew }; } - private async readConcurrent( + private async _readConcurrent( context: MigrationContext, ): Promise> { - const fromOldPromise = this.doSingleOp(context, 'old', this.config.readOld.bind(this.config)); - const fromNewPromise = this.doSingleOp(context, 'new', this.config.readNew.bind(this.config)); + const fromOldPromise = this._doSingleOp( + context, + 'old', + this._config.readOld.bind(this._config), + ); + const fromNewPromise = this._doSingleOp( + context, + 'new', + this._config.readNew.bind(this._config), + ); const [fromOld, fromNew] = await Promise.all([fromOldPromise, fromNewPromise]); return { fromOld, fromNew }; } - private async readSequentialRandom( + private async _readSequentialRandom( context: MigrationContext, ): Promise> { // This number is not used for a purpose requiring cryptographic security. @@ -306,49 +354,57 @@ class Migration< // Effectively flip a coin and do it on one order or the other. if (randomIndex === 0) { - const fromOld = await this.doSingleOp(context, 'old', this.config.readOld.bind(this.config)); - const fromNew = await this.doSingleOp(context, 'new', this.config.readNew.bind(this.config)); + const fromOld = await this._doSingleOp( + context, + 'old', + this._config.readOld.bind(this._config), + ); + const fromNew = await this._doSingleOp( + context, + 'new', + this._config.readNew.bind(this._config), + ); return { fromOld, fromNew }; } - const fromNew = await this.doSingleOp(context, 'new', this.config.readNew.bind(this.config)); - const fromOld = await this.doSingleOp(context, 'old', this.config.readOld.bind(this.config)); + const fromNew = await this._doSingleOp(context, 'new', this._config.readNew.bind(this._config)); + const fromOld = await this._doSingleOp(context, 'old', this._config.readOld.bind(this._config)); return { fromOld, fromNew }; } - private async doRead( + private async _doRead( context: MigrationContext, ): Promise> { - if (this.execution?.type === LDExecution.Serial) { - const serial = this.execution as LDSerialExecution; + if (this._execution?.type === LDExecution.Serial) { + const serial = this._execution as LDSerialExecution; if (serial.ordering === LDExecutionOrdering.Fixed) { - return this.readSequentialFixed(context); + return this._readSequentialFixed(context); } - return this.readSequentialRandom(context); + return this._readSequentialRandom(context); } - return this.readConcurrent(context); + return this._readConcurrent(context); } - private async doSingleOp( + private async _doSingleOp( context: MigrationContext, origin: LDMigrationOrigin, method: (payload?: TInput) => Promise>, ): Promise> { context.tracker.invoked(origin); - const res = await this.trackLatency(context.tracker, origin, () => + const res = await this._trackLatency(context.tracker, origin, () => safeCall(() => method(context.payload)), ); - if (!res.success && this.errorTracking) { + if (!res.success && this._errorTracking) { context.tracker.error(origin); } return { origin, ...res }; } - private async trackLatency( + private async _trackLatency( tracker: LDMigrationTracker, origin: LDMigrationOrigin, method: () => Promise, ): Promise { - if (!this.latencyTracking) { + if (!this._latencyTracking) { return method(); } let start; diff --git a/packages/shared/sdk-server/src/MigrationOpTracker.ts b/packages/shared/sdk-server/src/MigrationOpTracker.ts index fae4884bf..a1fa23c84 100644 --- a/packages/shared/sdk-server/src/MigrationOpTracker.ts +++ b/packages/shared/sdk-server/src/MigrationOpTracker.ts @@ -21,57 +21,57 @@ function isPopulated(data: number): boolean { } export default class MigrationOpTracker implements LDMigrationTracker { - private errors = { + private _errors = { old: false, new: false, }; - private wasInvoked = { + private _wasInvoked = { old: false, new: false, }; - private consistencyCheck: LDConsistencyCheck = LDConsistencyCheck.NotChecked; + private _consistencyCheck: LDConsistencyCheck = LDConsistencyCheck.NotChecked; - private latencyMeasurement = { + private _latencyMeasurement = { old: NaN, new: NaN, }; - private operation?: LDMigrationOp; + private _operation?: LDMigrationOp; constructor( - private readonly flagKey: string, - private readonly contextKeys: Record, - private readonly defaultStage: LDMigrationStage, - private readonly stage: LDMigrationStage, - private readonly reason: LDEvaluationReason, - private readonly checkRatio?: number, - private readonly variation?: number, - private readonly version?: number, - private readonly samplingRatio?: number, - private readonly logger?: LDLogger, + private readonly _flagKey: string, + private readonly _contextKeys: Record, + private readonly _defaultStage: LDMigrationStage, + private readonly _stage: LDMigrationStage, + private readonly _reason: LDEvaluationReason, + private readonly _checkRatio?: number, + private readonly _variation?: number, + private readonly _version?: number, + private readonly _samplingRatio?: number, + private readonly _logger?: LDLogger, ) {} op(op: LDMigrationOp) { - this.operation = op; + this._operation = op; } error(origin: LDMigrationOrigin) { - this.errors[origin] = true; + this._errors[origin] = true; } consistency(check: () => boolean) { - if (internal.shouldSample(this.checkRatio ?? 1)) { + if (internal.shouldSample(this._checkRatio ?? 1)) { try { const res = check(); - this.consistencyCheck = res + this._consistencyCheck = res ? LDConsistencyCheck.Consistent : LDConsistencyCheck.Inconsistent; } catch (exception) { - this.logger?.error( + this._logger?.error( 'Exception when executing consistency check function for migration' + - ` '${this.flagKey}' the consistency check will not be included in the generated migration` + + ` '${this._flagKey}' the consistency check will not be included in the generated migration` + ` op event. Exception: ${exception}`, ); } @@ -79,106 +79,106 @@ export default class MigrationOpTracker implements LDMigrationTracker { } latency(origin: LDMigrationOrigin, value: number) { - this.latencyMeasurement[origin] = value; + this._latencyMeasurement[origin] = value; } invoked(origin: LDMigrationOrigin) { - this.wasInvoked[origin] = true; + this._wasInvoked[origin] = true; } createEvent(): LDMigrationOpEvent | undefined { - if (!TypeValidators.String.is(this.flagKey) || this.flagKey === '') { - this.logger?.error('The flag key for a migration operation must be a non-empty string.'); + if (!TypeValidators.String.is(this._flagKey) || this._flagKey === '') { + this._logger?.error('The flag key for a migration operation must be a non-empty string.'); return undefined; } - if (!this.operation) { - this.logger?.error('The operation must be set using "op" before an event can be created.'); + if (!this._operation) { + this._logger?.error('The operation must be set using "op" before an event can be created.'); return undefined; } - if (Object.keys(this.contextKeys).length === 0) { - this.logger?.error( + if (Object.keys(this._contextKeys).length === 0) { + this._logger?.error( 'The migration was not done against a valid context and cannot generate an event.', ); return undefined; } - if (!this.wasInvoked.old && !this.wasInvoked.new) { - this.logger?.error( + if (!this._wasInvoked.old && !this._wasInvoked.new) { + this._logger?.error( 'The migration invoked neither the "old" or "new" implementation and' + 'an event cannot be generated', ); return undefined; } - if (!this.measurementConsistencyCheck()) { + if (!this._measurementConsistencyCheck()) { return undefined; } const measurements: LDMigrationMeasurement[] = []; - this.populateInvoked(measurements); - this.populateConsistency(measurements); - this.populateLatency(measurements); - this.populateErrors(measurements); + this._populateInvoked(measurements); + this._populateConsistency(measurements); + this._populateLatency(measurements); + this._populateErrors(measurements); return { kind: 'migration_op', - operation: this.operation, + operation: this._operation, creationDate: Date.now(), - contextKeys: this.contextKeys, + contextKeys: this._contextKeys, evaluation: { - key: this.flagKey, - value: this.stage, - default: this.defaultStage, - reason: this.reason, - variation: this.variation, - version: this.version, + key: this._flagKey, + value: this._stage, + default: this._defaultStage, + reason: this._reason, + variation: this._variation, + version: this._version, }, measurements, - samplingRatio: this.samplingRatio ?? 1, + samplingRatio: this._samplingRatio ?? 1, }; } - private logTag() { - return `For migration ${this.operation}-${this.flagKey}:`; + private _logTag() { + return `For migration ${this._operation}-${this._flagKey}:`; } - private latencyConsistencyMessage(origin: LDMigrationOrigin) { + private _latencyConsistencyMessage(origin: LDMigrationOrigin) { return `Latency measurement for "${origin}", but "${origin}" was not invoked.`; } - private errorConsistencyMessage(origin: LDMigrationOrigin) { + private _errorConsistencyMessage(origin: LDMigrationOrigin) { return `Error occurred for "${origin}", but "${origin}" was not invoked.`; } - private consistencyCheckConsistencyMessage(origin: LDMigrationOrigin) { + private _consistencyCheckConsistencyMessage(origin: LDMigrationOrigin) { return ( `Consistency check was done, but "${origin}" was not invoked.` + 'Both "old" and "new" must be invoked to do a consistency check.' ); } - private checkOriginEventConsistency(origin: LDMigrationOrigin): boolean { - if (this.wasInvoked[origin]) { + private _checkOriginEventConsistency(origin: LDMigrationOrigin): boolean { + if (this._wasInvoked[origin]) { return true; } // If the specific origin was not invoked, but it contains measurements, then // that is a problem. Check each measurement and log a message if it is present. - if (!Number.isNaN(this.latencyMeasurement[origin])) { - this.logger?.error(`${this.logTag()} ${this.latencyConsistencyMessage(origin)}`); + if (!Number.isNaN(this._latencyMeasurement[origin])) { + this._logger?.error(`${this._logTag()} ${this._latencyConsistencyMessage(origin)}`); return false; } - if (this.errors[origin]) { - this.logger?.error(`${this.logTag()} ${this.errorConsistencyMessage(origin)}`); + if (this._errors[origin]) { + this._logger?.error(`${this._logTag()} ${this._errorConsistencyMessage(origin)}`); return false; } - if (this.consistencyCheck !== LDConsistencyCheck.NotChecked) { - this.logger?.error(`${this.logTag()} ${this.consistencyCheckConsistencyMessage(origin)}`); + if (this._consistencyCheck !== LDConsistencyCheck.NotChecked) { + this._logger?.error(`${this._logTag()} ${this._consistencyCheckConsistencyMessage(origin)}`); return false; } return true; @@ -187,66 +187,66 @@ export default class MigrationOpTracker implements LDMigrationTracker { /** * Check that the latency, error, consistency and invoked measurements are self-consistent. */ - private measurementConsistencyCheck(): boolean { - return this.checkOriginEventConsistency('old') && this.checkOriginEventConsistency('new'); + private _measurementConsistencyCheck(): boolean { + return this._checkOriginEventConsistency('old') && this._checkOriginEventConsistency('new'); } - private populateInvoked(measurements: LDMigrationMeasurement[]) { + private _populateInvoked(measurements: LDMigrationMeasurement[]) { const measurement: LDMigrationInvokedMeasurement = { key: 'invoked', values: {}, }; - if (!this.wasInvoked.old && !this.wasInvoked.new) { - this.logger?.error('Migration op completed without executing any origins (old/new).'); + if (!this._wasInvoked.old && !this._wasInvoked.new) { + this._logger?.error('Migration op completed without executing any origins (old/new).'); } - if (this.wasInvoked.old) { + if (this._wasInvoked.old) { measurement.values.old = true; } - if (this.wasInvoked.new) { + if (this._wasInvoked.new) { measurement.values.new = true; } measurements.push(measurement); } - private populateConsistency(measurements: LDMigrationMeasurement[]) { + private _populateConsistency(measurements: LDMigrationMeasurement[]) { if ( - this.consistencyCheck !== undefined && - this.consistencyCheck !== LDConsistencyCheck.NotChecked + this._consistencyCheck !== undefined && + this._consistencyCheck !== LDConsistencyCheck.NotChecked ) { measurements.push({ key: 'consistent', - value: this.consistencyCheck === LDConsistencyCheck.Consistent, - samplingRatio: this.checkRatio ?? 1, + value: this._consistencyCheck === LDConsistencyCheck.Consistent, + samplingRatio: this._checkRatio ?? 1, }); } } - private populateErrors(measurements: LDMigrationMeasurement[]) { - if (this.errors.new || this.errors.old) { + private _populateErrors(measurements: LDMigrationMeasurement[]) { + if (this._errors.new || this._errors.old) { const measurement: LDMigrationErrorMeasurement = { key: 'error', values: {}, }; - if (this.errors.new) { + if (this._errors.new) { measurement.values.new = true; } - if (this.errors.old) { + if (this._errors.old) { measurement.values.old = true; } measurements.push(measurement); } } - private populateLatency(measurements: LDMigrationMeasurement[]) { - const newIsPopulated = isPopulated(this.latencyMeasurement.new); - const oldIsPopulated = isPopulated(this.latencyMeasurement.old); + private _populateLatency(measurements: LDMigrationMeasurement[]) { + const newIsPopulated = isPopulated(this._latencyMeasurement.new); + const oldIsPopulated = isPopulated(this._latencyMeasurement.old); if (newIsPopulated || oldIsPopulated) { const values: { old?: number; new?: number } = {}; if (newIsPopulated) { - values.new = this.latencyMeasurement.new; + values.new = this._latencyMeasurement.new; } if (oldIsPopulated) { - values.old = this.latencyMeasurement.old; + values.old = this._latencyMeasurement.old; } measurements.push({ key: 'latency_ms', diff --git a/packages/shared/sdk-server/src/cache/LruCache.ts b/packages/shared/sdk-server/src/cache/LruCache.ts index 7180efccc..c904b4bf8 100644 --- a/packages/shared/sdk-server/src/cache/LruCache.ts +++ b/packages/shared/sdk-server/src/cache/LruCache.ts @@ -16,143 +16,143 @@ export interface LruCacheOptions { * @internal */ export default class LruCache { - private values: any[]; + private _values: any[]; - private keys: Array; + private _keys: Array; - private lastUpdated: number[]; + private _lastUpdated: number[]; - private next: Uint32Array; + private _next: Uint32Array; - private prev: Uint32Array; + private _prev: Uint32Array; - private keyMap: Map = new Map(); + private _keyMap: Map = new Map(); - private head: number = 0; + private _head: number = 0; - private tail: number = 0; + private _tail: number = 0; - private max: number; + private _max: number; - private size: number = 0; + private _size: number = 0; - private maxAge: number; + private _maxAge: number; constructor(options: LruCacheOptions) { const { max } = options; - this.max = max; + this._max = max; // This is effectively a struct-of-arrays implementation // of a linked list. All the nodes exist statically and then // the links between them are changed by updating the previous/next // arrays. - this.values = new Array(max); - this.keys = new Array(max); - this.next = new Uint32Array(max); - this.prev = new Uint32Array(max); + this._values = new Array(max); + this._keys = new Array(max); + this._next = new Uint32Array(max); + this._prev = new Uint32Array(max); if (options.maxAge) { - this.lastUpdated = new Array(max).fill(0); - this.maxAge = options.maxAge; + this._lastUpdated = new Array(max).fill(0); + this._maxAge = options.maxAge; } else { // To please linting. - this.lastUpdated = []; - this.maxAge = 0; + this._lastUpdated = []; + this._maxAge = 0; } } set(key: string, val: any) { - let index = this.keyMap.get(key); + let index = this._keyMap.get(key); if (index === undefined) { - index = this.index(); - this.keys[index] = key; - this.keyMap.set(key, index); - this.next[this.tail] = index; - this.prev[index] = this.tail; - this.tail = index; - this.size += 1; + index = this._index(); + this._keys[index] = key; + this._keyMap.set(key, index); + this._next[this._tail] = index; + this._prev[index] = this._tail; + this._tail = index; + this._size += 1; } else { - this.setTail(index); + this._setTail(index); } - this.values[index] = val; - if (this.maxAge) { - this.lastUpdated[index] = Date.now(); + this._values[index] = val; + if (this._maxAge) { + this._lastUpdated[index] = Date.now(); } } get(key: string): any { - const index = this.keyMap.get(key); + const index = this._keyMap.get(key); if (index !== undefined) { - if (this.maxAge) { - const lastUpdated = this.lastUpdated[index]; - if (Date.now() - lastUpdated > this.maxAge) { + if (this._maxAge) { + const lastUpdated = this._lastUpdated[index]; + if (Date.now() - lastUpdated > this._maxAge) { // The oldest items are always the head, so they get incrementally // replaced. This would not be the case if we supported per item TTL. return undefined; } } - this.setTail(index); - if (this.maxAge) { - this.lastUpdated[index] = Date.now(); + this._setTail(index); + if (this._maxAge) { + this._lastUpdated[index] = Date.now(); } - return this.values[index]; + return this._values[index]; } return undefined; } clear() { - this.head = 0; - this.tail = 0; - this.size = 0; - this.values.fill(undefined); - this.keys.fill(undefined); - this.next.fill(0); - this.prev.fill(0); - this.keyMap.clear(); + this._head = 0; + this._tail = 0; + this._size = 0; + this._values.fill(undefined); + this._keys.fill(undefined); + this._next.fill(0); + this._prev.fill(0); + this._keyMap.clear(); } - private index() { - if (this.size === 0) { - return this.tail; + private _index() { + if (this._size === 0) { + return this._tail; } - if (this.size === this.max) { - return this.evict(); + if (this._size === this._max) { + return this._evict(); } // The initial list is being populated, so we can just continue increasing size. - return this.size; + return this._size; } - private evict(): number { - const { head } = this; - const k = this.keys[head]; - this.head = this.next[head]; - this.keyMap.delete(k!); - this.size -= 1; + private _evict(): number { + const { _head: head } = this; + const k = this._keys[head]; + this._head = this._next[head]; + this._keyMap.delete(k!); + this._size -= 1; return head; } - private link(p: number, n: number) { - this.prev[n] = p; - this.next[p] = n; + private _link(p: number, n: number) { + this._prev[n] = p; + this._next[p] = n; } - private setTail(index: number) { + private _setTail(index: number) { // If it is already the tail, then there is nothing to do. - if (index !== this.tail) { + if (index !== this._tail) { // If this is the head, then we change the next item // to the head. - if (index === this.head) { - this.head = this.next[index]; + if (index === this._head) { + this._head = this._next[index]; } else { // Link the previous item to the next item, effectively removing // the current node. - this.link(this.prev[index], this.next[index]); + this._link(this._prev[index], this._next[index]); } // Connect the current tail to this node. - this.link(this.tail, index); - this.tail = index; + this._link(this._tail, index); + this._tail = index; } } } diff --git a/packages/shared/sdk-server/src/cache/TtlCache.ts b/packages/shared/sdk-server/src/cache/TtlCache.ts index 11e5fcb9e..8ec208efa 100644 --- a/packages/shared/sdk-server/src/cache/TtlCache.ts +++ b/packages/shared/sdk-server/src/cache/TtlCache.ts @@ -30,14 +30,14 @@ interface CacheRecord { * @internal */ export default class TtlCache { - private storage: Map = new Map(); + private _storage: Map = new Map(); - private checkIntervalHandle: any; + private _checkIntervalHandle: any; - constructor(private readonly options: TtlCacheOptions) { - this.checkIntervalHandle = setInterval(() => { - this.purgeStale(); - }, options.checkInterval * 1000); + constructor(private readonly _options: TtlCacheOptions) { + this._checkIntervalHandle = setInterval(() => { + this._purgeStale(); + }, _options.checkInterval * 1000); } /** @@ -47,9 +47,9 @@ export default class TtlCache { * if the value has expired. */ public get(key: string): any { - const record = this.storage.get(key); + const record = this._storage.get(key); if (record && isStale(record)) { - this.storage.delete(key); + this._storage.delete(key); return undefined; } return record?.value; @@ -62,9 +62,9 @@ export default class TtlCache { * @param value The value to set. */ public set(key: string, value: any) { - this.storage.set(key, { + this._storage.set(key, { value, - expiration: Date.now() + this.options.ttl * 1000, + expiration: Date.now() + this._options.ttl * 1000, }); } @@ -74,14 +74,14 @@ export default class TtlCache { * @param key The key of the value to delete. */ public delete(key: string) { - this.storage.delete(key); + this._storage.delete(key); } /** * Clear the items that are in the cache. */ public clear() { - this.storage.clear(); + this._storage.clear(); } /** @@ -90,16 +90,16 @@ export default class TtlCache { */ public close() { this.clear(); - if (this.checkIntervalHandle) { - clearInterval(this.checkIntervalHandle); - this.checkIntervalHandle = null; + if (this._checkIntervalHandle) { + clearInterval(this._checkIntervalHandle); + this._checkIntervalHandle = null; } } - private purgeStale() { - this.storage.forEach((record, key) => { + private _purgeStale() { + this._storage.forEach((record, key) => { if (isStale(record)) { - this.storage.delete(key); + this._storage.delete(key); } }); } @@ -109,6 +109,6 @@ export default class TtlCache { * @internal */ public get size() { - return this.storage.size; + return this._storage.size; } } diff --git a/packages/shared/sdk-server/src/data_sources/DataSourceUpdates.ts b/packages/shared/sdk-server/src/data_sources/DataSourceUpdates.ts index 1187ff268..ac6e3820d 100644 --- a/packages/shared/sdk-server/src/data_sources/DataSourceUpdates.ts +++ b/packages/shared/sdk-server/src/data_sources/DataSourceUpdates.ts @@ -58,26 +58,26 @@ function computeDependencies(namespace: string, item: LDFeatureStoreItem) { * @internal */ export default class DataSourceUpdates implements LDDataSourceUpdates { - private readonly dependencyTracker = new DependencyTracker(); + private readonly _dependencyTracker = new DependencyTracker(); constructor( - private readonly featureStore: LDFeatureStore, - private readonly hasEventListeners: () => boolean, - private readonly onChange: (key: string) => void, + private readonly _featureStore: LDFeatureStore, + private readonly _hasEventListeners: () => boolean, + private readonly _onChange: (key: string) => void, ) {} init(allData: LDFeatureStoreDataStorage, callback: () => void): void { - const checkForChanges = this.hasEventListeners(); + const checkForChanges = this._hasEventListeners(); const doInit = (oldData?: LDFeatureStoreDataStorage) => { - this.featureStore.init(allData, () => { + this._featureStore.init(allData, () => { // Defer change events so they execute after the callback. Promise.resolve().then(() => { - this.dependencyTracker.reset(); + this._dependencyTracker.reset(); Object.entries(allData).forEach(([namespace, items]) => { Object.keys(items || {}).forEach((key) => { const item = items[key]; - this.dependencyTracker.updateDependenciesFrom( + this._dependencyTracker.updateDependenciesFrom( namespace, key, computeDependencies(namespace, item), @@ -109,8 +109,8 @@ export default class DataSourceUpdates implements LDDataSourceUpdates { }; if (checkForChanges) { - this.featureStore.all(VersionedDataKinds.Features, (oldFlags) => { - this.featureStore.all(VersionedDataKinds.Segments, (oldSegments) => { + this._featureStore.all(VersionedDataKinds.Features, (oldFlags) => { + this._featureStore.all(VersionedDataKinds.Segments, (oldSegments) => { const oldData = { [VersionedDataKinds.Features.namespace]: oldFlags, [VersionedDataKinds.Segments.namespace]: oldSegments, @@ -125,12 +125,12 @@ export default class DataSourceUpdates implements LDDataSourceUpdates { upsert(kind: DataKind, data: LDKeyedFeatureStoreItem, callback: () => void): void { const { key } = data; - const checkForChanges = this.hasEventListeners(); + const checkForChanges = this._hasEventListeners(); const doUpsert = (oldItem?: LDFeatureStoreItem | null) => { - this.featureStore.upsert(kind, data, () => { + this._featureStore.upsert(kind, data, () => { // Defer change events so they execute after the callback. Promise.resolve().then(() => { - this.dependencyTracker.updateDependenciesFrom( + this._dependencyTracker.updateDependenciesFrom( kind.namespace, key, computeDependencies(kind.namespace, data), @@ -146,7 +146,7 @@ export default class DataSourceUpdates implements LDDataSourceUpdates { }); }; if (checkForChanges) { - this.featureStore.get(kind, key, doUpsert); + this._featureStore.get(kind, key, doUpsert); } else { doUpsert(); } @@ -162,13 +162,13 @@ export default class DataSourceUpdates implements LDDataSourceUpdates { if (newValue && oldValue && newValue.version <= oldValue.version) { return; } - this.dependencyTracker.updateModifiedItems(toDataSet, namespace, key); + this._dependencyTracker.updateModifiedItems(toDataSet, namespace, key); } sendChangeEvents(dataSet: NamespacedDataSet) { dataSet.enumerate((namespace, key) => { if (namespace === VersionedDataKinds.Features.namespace) { - this.onChange(key); + this._onChange(key); } }); } diff --git a/packages/shared/sdk-server/src/data_sources/DependencyTracker.ts b/packages/shared/sdk-server/src/data_sources/DependencyTracker.ts index 0904150dc..050b4ef2a 100644 --- a/packages/shared/sdk-server/src/data_sources/DependencyTracker.ts +++ b/packages/shared/sdk-server/src/data_sources/DependencyTracker.ts @@ -4,27 +4,27 @@ import NamespacedDataSet from './NamespacedDataSet'; * @internal */ export default class DependencyTracker { - private readonly dependenciesFrom = new NamespacedDataSet>(); + private readonly _dependenciesFrom = new NamespacedDataSet>(); - private readonly dependenciesTo = new NamespacedDataSet>(); + private readonly _dependenciesTo = new NamespacedDataSet>(); updateDependenciesFrom( namespace: string, key: string, newDependencySet: NamespacedDataSet, ) { - const oldDependencySet = this.dependenciesFrom.get(namespace, key); + const oldDependencySet = this._dependenciesFrom.get(namespace, key); oldDependencySet?.enumerate((depNs, depKey) => { - const depsToThisDep = this.dependenciesTo.get(depNs, depKey); + const depsToThisDep = this._dependenciesTo.get(depNs, depKey); depsToThisDep?.remove(namespace, key); }); - this.dependenciesFrom.set(namespace, key, newDependencySet); + this._dependenciesFrom.set(namespace, key, newDependencySet); newDependencySet?.enumerate((depNs, depKey) => { - let depsToThisDep = this.dependenciesTo.get(depNs, depKey); + let depsToThisDep = this._dependenciesTo.get(depNs, depKey); if (!depsToThisDep) { depsToThisDep = new NamespacedDataSet(); - this.dependenciesTo.set(depNs, depKey, depsToThisDep); + this._dependenciesTo.set(depNs, depKey, depsToThisDep); } depsToThisDep.set(namespace, key, true); }); @@ -37,7 +37,7 @@ export default class DependencyTracker { ) { if (!inDependencySet.get(modifiedNamespace, modifiedKey)) { inDependencySet.set(modifiedNamespace, modifiedKey, true); - const affectedItems = this.dependenciesTo.get(modifiedNamespace, modifiedKey); + const affectedItems = this._dependenciesTo.get(modifiedNamespace, modifiedKey); affectedItems?.enumerate((namespace, key) => { this.updateModifiedItems(inDependencySet, namespace, key); }); @@ -45,7 +45,7 @@ export default class DependencyTracker { } reset() { - this.dependenciesFrom.removeAll(); - this.dependenciesTo.removeAll(); + this._dependenciesFrom.removeAll(); + this._dependenciesTo.removeAll(); } } diff --git a/packages/shared/sdk-server/src/data_sources/FileDataSource.ts b/packages/shared/sdk-server/src/data_sources/FileDataSource.ts index 87d896e71..0422a4d34 100644 --- a/packages/shared/sdk-server/src/data_sources/FileDataSource.ts +++ b/packages/shared/sdk-server/src/data_sources/FileDataSource.ts @@ -27,13 +27,13 @@ function makeFlagWithValue(key: string, value: any, version: number): Flag { } export default class FileDataSource implements subsystem.LDStreamProcessor { - private logger?: LDLogger; + private _logger?: LDLogger; - private yamlParser?: (data: string) => any; + private _yamlParser?: (data: string) => any; - private fileLoader: FileLoader; + private _fileLoader: FileLoader; - private allData: LDFeatureStoreDataStorage = {}; + private _allData: LDFeatureStoreDataStorage = {}; /** * This is internal because we want instances to only be created with the @@ -43,11 +43,11 @@ export default class FileDataSource implements subsystem.LDStreamProcessor { constructor( options: FileDataSourceOptions, filesystem: Filesystem, - private readonly featureStore: LDDataSourceUpdates, - private initSuccessHandler: VoidFunction = () => {}, - private readonly errorHandler?: FileDataSourceErrorHandler, + private readonly _featureStore: LDDataSourceUpdates, + private _initSuccessHandler: VoidFunction = () => {}, + private readonly _errorHandler?: FileDataSourceErrorHandler, ) { - this.fileLoader = new FileLoader( + this._fileLoader = new FileLoader( filesystem, options.paths, options.autoUpdate ?? false, @@ -55,17 +55,17 @@ export default class FileDataSource implements subsystem.LDStreamProcessor { // Whenever changes are detected we re-process all of the data. // The FileLoader will have handled debouncing for us. try { - this.processFileData(results); + this._processFileData(results); } catch (err) { // If this was during start, then the initCallback will be present. - this.errorHandler?.(err as LDFileDataSourceError); - this.logger?.error(`Error processing files: ${err}`); + this._errorHandler?.(err as LDFileDataSourceError); + this._logger?.error(`Error processing files: ${err}`); } }, ); - this.logger = options.logger; - this.yamlParser = options.yamlParser; + this._logger = options.logger; + this._yamlParser = options.yamlParser; } start(): void { @@ -73,45 +73,45 @@ export default class FileDataSource implements subsystem.LDStreamProcessor { // async loading without making start async itself. (async () => { try { - await this.fileLoader.loadAndWatch(); + await this._fileLoader.loadAndWatch(); } catch (err) { // There was an issue loading/watching the files. // Report back to the caller. - this.errorHandler?.(err as LDFileDataSourceError); + this._errorHandler?.(err as LDFileDataSourceError); } })(); } stop(): void { - this.fileLoader.close(); + this._fileLoader.close(); } close(): void { this.stop(); } - private addItem(kind: DataKind, item: any) { - if (!this.allData[kind.namespace]) { - this.allData[kind.namespace] = {}; + private _addItem(kind: DataKind, item: any) { + if (!this._allData[kind.namespace]) { + this._allData[kind.namespace] = {}; } - if (this.allData[kind.namespace][item.key]) { + if (this._allData[kind.namespace][item.key]) { throw new Error(`found duplicate key: "${item.key}"`); } else { - this.allData[kind.namespace][item.key] = item; + this._allData[kind.namespace][item.key] = item; } } - private processFileData(fileData: { path: string; data: string }[]) { + private _processFileData(fileData: { path: string; data: string }[]) { // Clear any existing data before re-populating it. - const oldData = this.allData; - this.allData = {}; + const oldData = this._allData; + this._allData = {}; // We let the parsers throw, and the caller can handle the rejection. fileData.forEach((fd) => { let parsed: any; if (fd.path.endsWith('.yml') || fd.path.endsWith('.yaml')) { - if (this.yamlParser) { - parsed = this.yamlParser(fd.data); + if (this._yamlParser) { + parsed = this._yamlParser(fd.data); } else { throw new Error(`Attempted to parse yaml file (${fd.path}) without parser.`); } @@ -119,21 +119,21 @@ export default class FileDataSource implements subsystem.LDStreamProcessor { parsed = JSON.parse(fd.data); } - this.processParsedData(parsed, oldData); + this._processParsedData(parsed, oldData); }); - this.featureStore.init(this.allData, () => { + this._featureStore.init(this._allData, () => { // Call the init callback if present. // Then clear the callback so we cannot call it again. - this.initSuccessHandler(); - this.initSuccessHandler = () => {}; + this._initSuccessHandler(); + this._initSuccessHandler = () => {}; }); } - private processParsedData(parsed: any, oldData: LDFeatureStoreDataStorage) { + private _processParsedData(parsed: any, oldData: LDFeatureStoreDataStorage) { Object.keys(parsed.flags || {}).forEach((key) => { processFlag(parsed.flags[key]); - this.addItem(VersionedDataKinds.Features, parsed.flags[key]); + this._addItem(VersionedDataKinds.Features, parsed.flags[key]); }); Object.keys(parsed.flagValues || {}).forEach((key) => { const previousInstance = oldData[VersionedDataKinds.Features.namespace]?.[key]; @@ -147,11 +147,11 @@ export default class FileDataSource implements subsystem.LDStreamProcessor { } const flag = makeFlagWithValue(key, parsed.flagValues[key], version); processFlag(flag); - this.addItem(VersionedDataKinds.Features, flag); + this._addItem(VersionedDataKinds.Features, flag); }); Object.keys(parsed.segments || {}).forEach((key) => { processSegment(parsed.segments[key]); - this.addItem(VersionedDataKinds.Segments, parsed.segments[key]); + this._addItem(VersionedDataKinds.Segments, parsed.segments[key]); }); } } diff --git a/packages/shared/sdk-server/src/data_sources/FileLoader.ts b/packages/shared/sdk-server/src/data_sources/FileLoader.ts index d7762b146..fe2168c43 100644 --- a/packages/shared/sdk-server/src/data_sources/FileLoader.ts +++ b/packages/shared/sdk-server/src/data_sources/FileLoader.ts @@ -13,69 +13,69 @@ import { Filesystem, WatchHandle } from '@launchdarkly/js-sdk-common'; * @internal */ export default class FileLoader { - private watchers: WatchHandle[] = []; + private _watchers: WatchHandle[] = []; - private fileData: Record = {}; + private _fileData: Record = {}; - private fileTimestamps: Record = {}; + private _fileTimestamps: Record = {}; - private debounceHandle: any; + private _debounceHandle: any; constructor( - private readonly filesystem: Filesystem, - private readonly paths: string[], - private readonly watch: boolean, - private readonly callback: (results: { path: string; data: string }[]) => void, + private readonly _filesystem: Filesystem, + private readonly _paths: string[], + private readonly _watch: boolean, + private readonly _callback: (results: { path: string; data: string }[]) => void, ) {} /** * Load all the files and start watching them if watching is enabled. */ async loadAndWatch() { - const promises = this.paths.map(async (path) => { - const data = await this.filesystem.readFile(path); - const timeStamp = await this.filesystem.getFileTimestamp(path); + const promises = this._paths.map(async (path) => { + const data = await this._filesystem.readFile(path); + const timeStamp = await this._filesystem.getFileTimestamp(path); return { data, path, timeStamp }; }); // This promise could be rejected, let the caller handle it. const results = await Promise.all(promises); results.forEach((res) => { - this.fileData[res.path] = res.data; - this.fileTimestamps[res.path] = res.timeStamp; + this._fileData[res.path] = res.data; + this._fileTimestamps[res.path] = res.timeStamp; }); - this.callback(results); + this._callback(results); // If we are watching, then setup watchers and notify of any changes. - if (this.watch) { - this.paths.forEach((path) => { - const watcher = this.filesystem.watch(path, async (_: string, updatePath: string) => { - const timeStamp = await this.filesystem.getFileTimestamp(updatePath); + if (this._watch) { + this._paths.forEach((path) => { + const watcher = this._filesystem.watch(path, async (_: string, updatePath: string) => { + const timeStamp = await this._filesystem.getFileTimestamp(updatePath); // The modification time is the same, so we are going to ignore this update. // In some implementations watch might be triggered multiple times for a single update. - if (timeStamp === this.fileTimestamps[updatePath]) { + if (timeStamp === this._fileTimestamps[updatePath]) { return; } - this.fileTimestamps[updatePath] = timeStamp; - const data = await this.filesystem.readFile(updatePath); - this.fileData[updatePath] = data; - this.debounceCallback(); + this._fileTimestamps[updatePath] = timeStamp; + const data = await this._filesystem.readFile(updatePath); + this._fileData[updatePath] = data; + this._debounceCallback(); }); - this.watchers.push(watcher); + this._watchers.push(watcher); }); } } close() { - this.watchers.forEach((watcher) => watcher.close()); + this._watchers.forEach((watcher) => watcher.close()); } - private debounceCallback() { + private _debounceCallback() { // If there is a handle, then we have already started the debounce process. - if (!this.debounceHandle) { - this.debounceHandle = setTimeout(() => { - this.debounceHandle = undefined; - this.callback( - Object.entries(this.fileData).reduce( + if (!this._debounceHandle) { + this._debounceHandle = setTimeout(() => { + this._debounceHandle = undefined; + this._callback( + Object.entries(this._fileData).reduce( (acc: { path: string; data: string }[], [path, data]) => { acc.push({ path, data }); return acc; diff --git a/packages/shared/sdk-server/src/data_sources/NamespacedDataSet.ts b/packages/shared/sdk-server/src/data_sources/NamespacedDataSet.ts index 1b6ae6fee..1510579b4 100644 --- a/packages/shared/sdk-server/src/data_sources/NamespacedDataSet.ts +++ b/packages/shared/sdk-server/src/data_sources/NamespacedDataSet.ts @@ -2,32 +2,32 @@ * @internal */ export default class NamespacedDataSet { - private itemsByNamespace: Record> = {}; + private _itemsByNamespace: Record> = {}; get(namespace: string, key: string): T | undefined { - return this.itemsByNamespace[namespace]?.[key]; + return this._itemsByNamespace[namespace]?.[key]; } set(namespace: string, key: string, value: T) { - if (!(namespace in this.itemsByNamespace)) { - this.itemsByNamespace[namespace] = {}; + if (!(namespace in this._itemsByNamespace)) { + this._itemsByNamespace[namespace] = {}; } - this.itemsByNamespace[namespace][key] = value; + this._itemsByNamespace[namespace][key] = value; } remove(namespace: string, key: string) { - const items = this.itemsByNamespace[namespace]; + const items = this._itemsByNamespace[namespace]; if (items) { delete items[key]; } } removeAll() { - this.itemsByNamespace = {}; + this._itemsByNamespace = {}; } enumerate(callback: (namespace: string, key: string, value: T) => void) { - Object.entries(this.itemsByNamespace).forEach(([namespace, values]) => { + Object.entries(this._itemsByNamespace).forEach(([namespace, values]) => { Object.entries(values).forEach(([key, value]) => { callback(namespace, key, value); }); diff --git a/packages/shared/sdk-server/src/data_sources/PollingProcessor.ts b/packages/shared/sdk-server/src/data_sources/PollingProcessor.ts index a51beee7c..d376b4535 100644 --- a/packages/shared/sdk-server/src/data_sources/PollingProcessor.ts +++ b/packages/shared/sdk-server/src/data_sources/PollingProcessor.ts @@ -1,4 +1,5 @@ import { + DataSourceErrorKind, httpErrorMessage, isHttpRecoverable, LDLogger, @@ -19,54 +20,61 @@ export type PollingErrorHandler = (err: LDPollingError) => void; * @internal */ export default class PollingProcessor implements subsystem.LDStreamProcessor { - private stopped = false; + private _stopped = false; - private logger?: LDLogger; + private _logger?: LDLogger; - private pollInterval: number; + private _pollInterval: number; - private timeoutHandle: any; + private _timeoutHandle: any; constructor( config: Configuration, - private readonly requestor: Requestor, - private readonly featureStore: LDDataSourceUpdates, - private readonly initSuccessHandler: VoidFunction = () => {}, - private readonly errorHandler?: PollingErrorHandler, + private readonly _requestor: Requestor, + private readonly _featureStore: LDDataSourceUpdates, + private readonly _initSuccessHandler: VoidFunction = () => {}, + private readonly _errorHandler?: PollingErrorHandler, ) { - this.logger = config.logger; - this.pollInterval = config.pollInterval; + this._logger = config.logger; + this._pollInterval = config.pollInterval; } - private poll() { - if (this.stopped) { + private _poll() { + if (this._stopped) { return; } const reportJsonError = (data: string) => { - this.logger?.error('Polling received invalid data'); - this.logger?.debug(`Invalid JSON follows: ${data}`); - this.errorHandler?.(new LDPollingError('Malformed JSON data in polling response')); + this._logger?.error('Polling received invalid data'); + this._logger?.debug(`Invalid JSON follows: ${data}`); + this._errorHandler?.( + new LDPollingError( + DataSourceErrorKind.InvalidData, + 'Malformed JSON data in polling response', + ), + ); }; const startTime = Date.now(); - this.logger?.debug('Polling LaunchDarkly for feature flag updates'); - this.requestor.requestAllData((err, body) => { + this._logger?.debug('Polling LaunchDarkly for feature flag updates'); + this._requestor.requestAllData((err, body) => { const elapsed = Date.now() - startTime; - const sleepFor = Math.max(this.pollInterval * 1000 - elapsed, 0); + const sleepFor = Math.max(this._pollInterval * 1000 - elapsed, 0); - this.logger?.debug('Elapsed: %d ms, sleeping for %d ms', elapsed, sleepFor); + this._logger?.debug('Elapsed: %d ms, sleeping for %d ms', elapsed, sleepFor); if (err) { const { status } = err; if (status && !isHttpRecoverable(status)) { const message = httpErrorMessage(err, 'polling request'); - this.logger?.error(message); - this.errorHandler?.(new LDPollingError(message, status)); + this._logger?.error(message); + this._errorHandler?.( + new LDPollingError(DataSourceErrorKind.ErrorResponse, message, status), + ); // It is not recoverable, return and do not trigger another // poll. return; } - this.logger?.warn(httpErrorMessage(err, 'polling request', 'will retry')); + this._logger?.warn(httpErrorMessage(err, 'polling request', 'will retry')); } else if (body) { const parsed = deserializePoll(body); if (!parsed) { @@ -78,11 +86,11 @@ export default class PollingProcessor implements subsystem.LDStreamProcessor { [VersionedDataKinds.Features.namespace]: parsed.flags, [VersionedDataKinds.Segments.namespace]: parsed.segments, }; - this.featureStore.init(initData, () => { - this.initSuccessHandler(); + this._featureStore.init(initData, () => { + this._initSuccessHandler(); // Triggering the next poll after the init has completed. - this.timeoutHandle = setTimeout(() => { - this.poll(); + this._timeoutHandle = setTimeout(() => { + this._poll(); }, sleepFor); }); // The poll will be triggered by the feature store initialization @@ -93,22 +101,22 @@ export default class PollingProcessor implements subsystem.LDStreamProcessor { // Falling through, there was some type of error and we need to trigger // a new poll. - this.timeoutHandle = setTimeout(() => { - this.poll(); + this._timeoutHandle = setTimeout(() => { + this._poll(); }, sleepFor); }); } start() { - this.poll(); + this._poll(); } stop() { - if (this.timeoutHandle) { - clearTimeout(this.timeoutHandle); - this.timeoutHandle = undefined; + if (this._timeoutHandle) { + clearTimeout(this._timeoutHandle); + this._timeoutHandle = undefined; } - this.stopped = true; + this._stopped = true; } close() { diff --git a/packages/shared/sdk-server/src/data_sources/Requestor.ts b/packages/shared/sdk-server/src/data_sources/Requestor.ts index d6498c604..0d3567eae 100644 --- a/packages/shared/sdk-server/src/data_sources/Requestor.ts +++ b/packages/shared/sdk-server/src/data_sources/Requestor.ts @@ -1,8 +1,8 @@ import { - defaultHeaders, + DataSourceErrorKind, getPollingUri, - Info, - LDStreamingError, + LDHeaders, + LDPollingError, Options, Requests, Response, @@ -15,11 +15,11 @@ import Configuration from '../options/Configuration'; * @internal */ export default class Requestor implements LDFeatureRequestor { - private readonly headers: Record; + private readonly _headers: Record; - private readonly uri: string; + private readonly _uri: string; - private readonly eTagCache: Record< + private readonly _eTagCache: Record< string, { etag: string; @@ -28,27 +28,26 @@ export default class Requestor implements LDFeatureRequestor { > = {}; constructor( - sdkKey: string, config: Configuration, - info: Info, - private readonly requests: Requests, + private readonly _requests: Requests, + baseHeaders: LDHeaders, ) { - this.headers = defaultHeaders(sdkKey, info, config.tags); - this.uri = getPollingUri(config.serviceEndpoints, '/sdk/latest-all', []); + this._headers = { ...baseHeaders }; + this._uri = getPollingUri(config.serviceEndpoints, '/sdk/latest-all', []); } /** * Perform a request and utilize the ETag cache. The ETags are cached in the * requestor instance. */ - private async requestWithETagCache( + private async _requestWithETagCache( requestUrl: string, options: Options, ): Promise<{ res: Response; body: string; }> { - const cacheEntry = this.eTagCache[requestUrl]; + const cacheEntry = this._eTagCache[requestUrl]; const cachedETag = cacheEntry?.etag; const updatedOptions = cachedETag @@ -58,7 +57,7 @@ export default class Requestor implements LDFeatureRequestor { } : options; - const res = await this.requests.fetch(requestUrl, updatedOptions); + const res = await this._requests.fetch(requestUrl, updatedOptions); if (res.status === 304 && cacheEntry) { return { res, body: cacheEntry.body }; @@ -66,7 +65,7 @@ export default class Requestor implements LDFeatureRequestor { const etag = res.headers.get('etag'); const body = await res.text(); if (etag) { - this.eTagCache[requestUrl] = { etag, body }; + this._eTagCache[requestUrl] = { etag, body }; } return { res, body }; } @@ -74,12 +73,16 @@ export default class Requestor implements LDFeatureRequestor { async requestAllData(cb: (err: any, body: any) => void) { const options: Options = { method: 'GET', - headers: this.headers, + headers: this._headers, }; try { - const { res, body } = await this.requestWithETagCache(this.uri, options); + const { res, body } = await this._requestWithETagCache(this._uri, options); if (res.status !== 200 && res.status !== 304) { - const err = new LDStreamingError(`Unexpected status code: ${res.status}`, res.status); + const err = new LDPollingError( + DataSourceErrorKind.ErrorResponse, + `Unexpected status code: ${res.status}`, + res.status, + ); return cb(err, undefined); } return cb(undefined, res.status === 304 ? null : body); diff --git a/packages/shared/sdk-server/src/data_sources/createStreamListeners.test.ts b/packages/shared/sdk-server/src/data_sources/createStreamListeners.test.ts index 661a084c4..02df75aad 100644 --- a/packages/shared/sdk-server/src/data_sources/createStreamListeners.test.ts +++ b/packages/shared/sdk-server/src/data_sources/createStreamListeners.test.ts @@ -1,4 +1,4 @@ -import { createLogger } from '@launchdarkly/private-js-mocks'; +import { LDLogger } from '@launchdarkly/js-sdk-common'; import { LDDataSourceUpdates } from '../api/subsystems'; import { deserializeAll, deserializeDelete, deserializePatch } from '../store/serialization'; @@ -7,10 +7,15 @@ import { createStreamListeners } from './createStreamListeners'; jest.mock('../store/serialization'); -let logger: ReturnType; +let logger: LDLogger; beforeEach(() => { - logger = createLogger(); + logger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }; }); const allData = { diff --git a/packages/shared/sdk-server/src/evaluation/Bucketer.ts b/packages/shared/sdk-server/src/evaluation/Bucketer.ts index 1d10fba2b..34367ce6f 100644 --- a/packages/shared/sdk-server/src/evaluation/Bucketer.ts +++ b/packages/shared/sdk-server/src/evaluation/Bucketer.ts @@ -17,15 +17,19 @@ function valueForBucketing(value: any): string | null { } export default class Bucketer { - private crypto: Crypto; + private _crypto: Crypto; constructor(crypto: Crypto) { - this.crypto = crypto; + this._crypto = crypto; } - private sha1Hex(value: string) { - const hash = this.crypto.createHash('sha1'); + private _sha1Hex(value: string) { + const hash = this._crypto.createHash('sha1'); hash.update(value); + if (!hash.digest) { + // This represents an error in platform implementation. + throw new Error('Platform must implement digest or asyncDigest'); + } return hash.digest('hex'); } @@ -65,7 +69,7 @@ export default class Bucketer { const prefix = seed ? Number(seed) : `${key}.${salt}`; const hashKey = `${prefix}.${bucketableValue}`; - const hashVal = parseInt(this.sha1Hex(hashKey).substring(0, 15), 16); + const hashVal = parseInt(this._sha1Hex(hashKey).substring(0, 15), 16); // This is how this has worked in previous implementations, but it is not // ideal. diff --git a/packages/shared/sdk-server/src/evaluation/EvalResult.ts b/packages/shared/sdk-server/src/evaluation/EvalResult.ts index cc1f70dcc..3799dc252 100644 --- a/packages/shared/sdk-server/src/evaluation/EvalResult.ts +++ b/packages/shared/sdk-server/src/evaluation/EvalResult.ts @@ -10,6 +10,7 @@ import Reasons from './Reasons'; */ export default class EvalResult { public events?: internal.InputEvalEvent[]; + public prerequisites?: string[]; protected constructor( public readonly isError: boolean, diff --git a/packages/shared/sdk-server/src/evaluation/Evaluator.ts b/packages/shared/sdk-server/src/evaluation/Evaluator.ts index ef95b9eee..a5ecf7bca 100644 --- a/packages/shared/sdk-server/src/evaluation/Evaluator.ts +++ b/packages/shared/sdk-server/src/evaluation/Evaluator.ts @@ -68,6 +68,7 @@ function computeUpdatedBigSegmentsStatus( interface EvalState { events?: internal.InputEvalEvent[]; + prerequisites?: string[]; bigSegmentsStatus?: BigSegmentStoreStatusString; @@ -105,35 +106,18 @@ type MatchOrError = Match | MatchError; * @internal */ export default class Evaluator { - private queries: Queries; + private _queries: Queries; - private bucketer: Bucketer; + private _bucketer: Bucketer; constructor(platform: Platform, queries: Queries) { - this.queries = queries; - this.bucketer = new Bucketer(platform.crypto); + this._queries = queries; + this._bucketer = new Bucketer(platform.crypto); } async evaluate(flag: Flag, context: Context, eventFactory?: EventFactory): Promise { return new Promise((resolve) => { - const state: EvalState = {}; - this.evaluateInternal( - flag, - context, - state, - [], - (res) => { - if (state.bigSegmentsStatus) { - res.detail.reason = { - ...res.detail.reason, - bigSegmentsStatus: state.bigSegmentsStatus, - }; - } - res.events = state.events; - resolve(res); - }, - eventFactory, - ); + this.evaluateCb(flag, context, resolve, eventFactory); }); } @@ -144,7 +128,7 @@ export default class Evaluator { eventFactory?: EventFactory, ) { const state: EvalState = {}; - this.evaluateInternal( + this._evaluateInternal( flag, context, state, @@ -156,9 +140,13 @@ export default class Evaluator { bigSegmentsStatus: state.bigSegmentsStatus, }; } + if (state.prerequisites) { + res.prerequisites = state.prerequisites; + } res.events = state.events; cb(res); }, + true, eventFactory, ); } @@ -172,13 +160,16 @@ export default class Evaluator { * @param state The current evaluation state. * @param visitedFlags The flags that have been visited during this evaluation. * This is not part of the state, because it needs to be forked during prerequisite evaluations. + * @param topLevel True when this function is being called in the direct evaluation of a flag, + * versus the evaluataion of a prerequisite. */ - private evaluateInternal( + private _evaluateInternal( flag: Flag, context: Context, state: EvalState, visitedFlags: string[], cb: (res: EvalResult) => void, + topLevel: boolean, eventFactory?: EventFactory, ): void { if (!flag.on) { @@ -186,7 +177,7 @@ export default class Evaluator { return; } - this.checkPrerequisites( + this._checkPrerequisites( flag, context, state, @@ -205,15 +196,16 @@ export default class Evaluator { return; } - this.evaluateRules(flag, context, state, (evalRes) => { + this._evaluateRules(flag, context, state, (evalRes) => { if (evalRes) { cb(evalRes); return; } - cb(this.variationForContext(flag.fallthrough, context, flag, Reasons.Fallthrough)); + cb(this._variationForContext(flag.fallthrough, context, flag, Reasons.Fallthrough)); }); }, + topLevel, eventFactory, ); } @@ -227,13 +219,16 @@ export default class Evaluator { * @param cb A callback which is executed when prerequisite checks are complete it is called with * an {@link EvalResult} containing an error result or `undefined` if the prerequisites * are met. + * @param topLevel True when this function is being called in the direct evaluation of a flag, + * versus the evaluataion of a prerequisite. */ - private checkPrerequisites( + private _checkPrerequisites( flag: Flag, context: Context, state: EvalState, visitedFlags: string[], cb: (res: EvalResult | undefined) => void, + topLevel: boolean, eventFactory?: EventFactory, ): void { let prereqResult: EvalResult | undefined; @@ -258,14 +253,14 @@ export default class Evaluator { return; } const updatedVisitedFlags = [...visitedFlags, prereq.key]; - this.queries.getFlag(prereq.key, (prereqFlag) => { + this._queries.getFlag(prereq.key, (prereqFlag) => { if (!prereqFlag) { prereqResult = getOffVariation(flag, Reasons.prerequisiteFailed(prereq.key)); iterCb(false); return; } - this.evaluateInternal( + this._evaluateInternal( prereqFlag, context, state, @@ -273,7 +268,12 @@ export default class Evaluator { (res) => { // eslint-disable-next-line no-param-reassign state.events ??= []; + if (topLevel) { + // eslint-disable-next-line no-param-reassign + state.prerequisites ??= []; + state.prerequisites.push(prereqFlag.key); + } if (eventFactory) { state.events.push( eventFactory.evalEventServer(prereqFlag, context, res.detail, null, flag), @@ -291,6 +291,7 @@ export default class Evaluator { } return iterCb(true); }, + false, // topLevel false evaluating the prerequisite. eventFactory, ); }); @@ -310,7 +311,7 @@ export default class Evaluator { * @param cb Callback called when rule evaluation is complete, it will be called with either * an {@link EvalResult} or 'undefined'. */ - private evaluateRules( + private _evaluateRules( flag: Flag, context: Context, state: EvalState, @@ -321,7 +322,7 @@ export default class Evaluator { firstSeriesAsync( flag.rules, (rule, ruleIndex, iterCb: (res: boolean) => void) => { - this.ruleMatchContext(flag, rule, ruleIndex, context, state, [], (res) => { + this._ruleMatchContext(flag, rule, ruleIndex, context, state, [], (res) => { ruleResult = res; iterCb(!!res); }); @@ -330,7 +331,7 @@ export default class Evaluator { ); } - private clauseMatchContext( + private _clauseMatchContext( clause: Clause, context: Context, segmentsVisited: string[], @@ -342,7 +343,7 @@ export default class Evaluator { firstSeriesAsync( clause.values, (value, _index, iterCb) => { - this.queries.getSegment(value, (segment) => { + this._queries.getSegment(value, (segment) => { if (segment) { if (segmentsVisited.includes(segment.key)) { errorResult = EvalResult.forError( @@ -399,7 +400,7 @@ export default class Evaluator { * @param cb Called when matching is complete with an {@link EvalResult} or `undefined` if there * are no matches or errors. */ - private ruleMatchContext( + private _ruleMatchContext( flag: Flag, rule: FlagRule, ruleIndex: number, @@ -416,7 +417,7 @@ export default class Evaluator { allSeriesAsync( rule.clauses, (clause, _index, iterCb) => { - this.clauseMatchContext(clause, context, segmentsVisited, state, (res) => { + this._clauseMatchContext(clause, context, segmentsVisited, state, (res) => { errorResult = res.result; return iterCb(res.error || res.isMatch); }); @@ -428,7 +429,7 @@ export default class Evaluator { if (match) { return cb( - this.variationForContext(rule, context, flag, Reasons.ruleMatch(rule.id, ruleIndex)), + this._variationForContext(rule, context, flag, Reasons.ruleMatch(rule.id, ruleIndex)), ); } return cb(undefined); @@ -436,7 +437,7 @@ export default class Evaluator { ); } - private variationForContext( + private _variationForContext( varOrRollout: VariationOrRollout, context: Context, flag: Flag, @@ -467,7 +468,7 @@ export default class Evaluator { ); } - const [bucket, hadContext] = this.bucketer.bucket( + const [bucket, hadContext] = this._bucketer.bucket( context, flag.key, bucketBy, @@ -522,7 +523,7 @@ export default class Evaluator { allSeriesAsync( rule.clauses, (clause, _index, iterCb) => { - this.clauseMatchContext(clause, context, segmentsVisited, state, (res) => { + this._clauseMatchContext(clause, context, segmentsVisited, state, (res) => { errorResult = res.result; iterCb(res.error || res.isMatch); }); @@ -548,7 +549,7 @@ export default class Evaluator { ); } - const [bucket] = this.bucketer.bucket( + const [bucket] = this._bucketer.bucket( context, segment.key, bucketBy, @@ -647,7 +648,7 @@ export default class Evaluator { return; } - this.queries.getBigSegmentsMembership(keyForBigSegment).then((result) => { + this._queries.getBigSegmentsMembership(keyForBigSegment).then((result) => { // eslint-disable-next-line no-param-reassign state.bigSegmentsMembership = state.bigSegmentsMembership || {}; if (result) { diff --git a/packages/shared/sdk-server/src/evaluation/evalTargets.ts b/packages/shared/sdk-server/src/evaluation/evalTargets.ts index 9f489593d..4440a0763 100644 --- a/packages/shared/sdk-server/src/evaluation/evalTargets.ts +++ b/packages/shared/sdk-server/src/evaluation/evalTargets.ts @@ -34,7 +34,7 @@ export default function evalTargets(flag: Flag, context: Context): EvalResult | } return firstResult(flag.contextTargets, (target) => { - if (!target.contextKind || target.contextKind === Context.userKind) { + if (!target.contextKind || target.contextKind === Context.UserKind) { // When a context target is for a user, then use a user target with the same variation. const userTarget = (flag.targets || []).find((ut) => ut.variation === target.variation); if (userTarget) { diff --git a/packages/shared/sdk-server/src/events/ContextDeduplicator.ts b/packages/shared/sdk-server/src/events/ContextDeduplicator.ts index 4792d7f14..bfedb5825 100644 --- a/packages/shared/sdk-server/src/events/ContextDeduplicator.ts +++ b/packages/shared/sdk-server/src/events/ContextDeduplicator.ts @@ -10,23 +10,23 @@ export interface ContextDeduplicatorOptions { export default class ContextDeduplicator implements subsystem.LDContextDeduplicator { public readonly flushInterval: number; - private contextKeysCache: LruCache; + private _contextKeysCache: LruCache; constructor(options: ContextDeduplicatorOptions) { - this.contextKeysCache = new LruCache({ max: options.contextKeysCapacity }); + this._contextKeysCache = new LruCache({ max: options.contextKeysCapacity }); this.flushInterval = options.contextKeysFlushInterval; } public processContext(context: Context): boolean { const { canonicalKey } = context; - const inCache = this.contextKeysCache.get(canonicalKey); - this.contextKeysCache.set(canonicalKey, true); + const inCache = this._contextKeysCache.get(canonicalKey); + this._contextKeysCache.set(canonicalKey, true); // If it is in the cache, then we do not want to add an event. return !inCache; } public flush(): void { - this.contextKeysCache.clear(); + this._contextKeysCache.clear(); } } diff --git a/packages/shared/sdk-server/src/hooks/HookRunner.ts b/packages/shared/sdk-server/src/hooks/HookRunner.ts index 5e9e531ed..c7c1e61f4 100644 --- a/packages/shared/sdk-server/src/hooks/HookRunner.ts +++ b/packages/shared/sdk-server/src/hooks/HookRunner.ts @@ -7,13 +7,13 @@ const AFTER_EVALUATION_STAGE_NAME = 'afterEvaluation'; const UNKNOWN_HOOK_NAME = 'unknown hook'; export default class HookRunner { - private readonly hooks: Hook[] = []; + private readonly _hooks: Hook[] = []; constructor( - private readonly logger: LDLogger | undefined, + private readonly _logger: LDLogger | undefined, hooks: Hook[], ) { - this.hooks.push(...hooks); + this._hooks.push(...hooks); } public async withEvaluationSeries( @@ -25,7 +25,7 @@ export default class HookRunner { ): Promise { // This early return is here to avoid the extra async/await associated with // using withHooksDataWithDetail. - if (this.hooks.length === 0) { + if (this._hooks.length === 0) { return method(); } @@ -52,18 +52,18 @@ export default class HookRunner { methodName: string, method: () => Promise<{ detail: LDEvaluationDetail; [index: string]: any }>, ): Promise<{ detail: LDEvaluationDetail; [index: string]: any }> { - if (this.hooks.length === 0) { + if (this._hooks.length === 0) { return method(); } const { hooks, hookContext }: { hooks: Hook[]; hookContext: EvaluationSeriesContext } = - this.prepareHooks(key, context, defaultValue, methodName); - const hookData = this.executeBeforeEvaluation(hooks, hookContext); + this._prepareHooks(key, context, defaultValue, methodName); + const hookData = this._executeBeforeEvaluation(hooks, hookContext); const result = await method(); - this.executeAfterEvaluation(hooks, hookContext, hookData, result.detail); + this._executeAfterEvaluation(hooks, hookContext, hookData, result.detail); return result; } - private tryExecuteStage( + private _tryExecuteStage( method: string, hookName: string, stage: () => EvaluationSeriesData, @@ -71,23 +71,23 @@ export default class HookRunner { try { return stage(); } catch (err) { - this.logger?.error( + this._logger?.error( `An error was encountered in "${method}" of the "${hookName}" hook: ${err}`, ); return {}; } } - private hookName(hook?: Hook): string { + private _hookName(hook?: Hook): string { try { return hook?.getMetadata().name ?? UNKNOWN_HOOK_NAME; } catch { - this.logger?.error(`Exception thrown getting metadata for hook. Unable to get hook name.`); + this._logger?.error(`Exception thrown getting metadata for hook. Unable to get hook name.`); return UNKNOWN_HOOK_NAME; } } - private executeAfterEvaluation( + private _executeAfterEvaluation( hooks: Hook[], hookContext: EvaluationSeriesContext, updatedData: (EvaluationSeriesData | undefined)[], @@ -98,28 +98,28 @@ export default class HookRunner { for (let hookIndex = hooks.length - 1; hookIndex >= 0; hookIndex -= 1) { const hook = hooks[hookIndex]; const data = updatedData[hookIndex] ?? {}; - this.tryExecuteStage( + this._tryExecuteStage( AFTER_EVALUATION_STAGE_NAME, - this.hookName(hook), + this._hookName(hook), () => hook?.afterEvaluation?.(hookContext, data, result) ?? {}, ); } } - private executeBeforeEvaluation( + private _executeBeforeEvaluation( hooks: Hook[], hookContext: EvaluationSeriesContext, ): EvaluationSeriesData[] { return hooks.map((hook) => - this.tryExecuteStage( + this._tryExecuteStage( BEFORE_EVALUATION_STAGE_NAME, - this.hookName(hook), + this._hookName(hook), () => hook?.beforeEvaluation?.(hookContext, {}) ?? {}, ), ); } - private prepareHooks( + private _prepareHooks( key: string, context: LDContext, defaultValue: unknown, @@ -131,7 +131,7 @@ export default class HookRunner { // Copy the hooks to use a consistent set during evaluation. Hooks could be added and we want // to ensure all correct stages for any give hook execute. Not for instance the afterEvaluation // stage without beforeEvaluation having been called on that hook. - const hooks: Hook[] = [...this.hooks]; + const hooks: Hook[] = [...this._hooks]; const hookContext: EvaluationSeriesContext = { flagKey: key, context, @@ -142,6 +142,6 @@ export default class HookRunner { } addHook(hook: Hook): void { - this.hooks.push(hook); + this._hooks.push(hook); } } diff --git a/packages/shared/sdk-server/src/integrations/FileDataSourceFactory.ts b/packages/shared/sdk-server/src/integrations/FileDataSourceFactory.ts index 885776ee2..60f49d4ef 100644 --- a/packages/shared/sdk-server/src/integrations/FileDataSourceFactory.ts +++ b/packages/shared/sdk-server/src/integrations/FileDataSourceFactory.ts @@ -18,7 +18,7 @@ export interface FileDataSourceFactoryConfig { */ export default class FileDataSourceFactory { - constructor(private readonly options: FileDataSourceOptions) {} + constructor(private readonly _options: FileDataSourceOptions) {} /** * Method for creating instances of the file data source. This method is intended to be used @@ -37,10 +37,10 @@ export default class FileDataSourceFactory { errorHandler?: FileDataSourceErrorHandler, ) { const updatedOptions: FileDataSourceOptions = { - paths: this.options.paths, - autoUpdate: this.options.autoUpdate, - logger: this.options.logger || ldClientContext.basicConfiguration.logger, - yamlParser: this.options.yamlParser, + paths: this._options.paths, + autoUpdate: this._options.autoUpdate, + logger: this._options.logger || ldClientContext.basicConfiguration.logger, + yamlParser: this._options.yamlParser, }; return new FileDataSource( updatedOptions, diff --git a/packages/shared/sdk-server/src/integrations/test_data/TestData.ts b/packages/shared/sdk-server/src/integrations/test_data/TestData.ts index cd4b41417..dc17aa1a2 100644 --- a/packages/shared/sdk-server/src/integrations/test_data/TestData.ts +++ b/packages/shared/sdk-server/src/integrations/test_data/TestData.ts @@ -43,13 +43,13 @@ import TestDataSource from './TestDataSource'; * any changes made to the data will propagate to all of the `LDClient`s. */ export default class TestData { - private currentFlags: Record = {}; + private _currentFlags: Record = {}; - private currentSegments: Record = {}; + private _currentSegments: Record = {}; - private dataSources: TestDataSource[] = []; + private _dataSources: TestDataSource[] = []; - private flagBuilders: Record = {}; + private _flagBuilders: Record = {}; /** * Get a factory for update processors that will be attached to this TestData instance. @@ -78,15 +78,15 @@ export default class TestData { ); const newSource = new TestDataSource( new AsyncStoreFacade(featureStore), - this.currentFlags, - this.currentSegments, + this._currentFlags, + this._currentSegments, (tds) => { - this.dataSources.splice(this.dataSources.indexOf(tds)); + this._dataSources.splice(this._dataSources.indexOf(tds)); }, listeners, ); - this.dataSources.push(newSource); + this._dataSources.push(newSource); return newSource; }; } @@ -112,8 +112,8 @@ export default class TestData { * */ flag(key: string): TestDataFlagBuilder { - if (this.flagBuilders[key]) { - return this.flagBuilders[key].clone(); + if (this._flagBuilders[key]) { + return this._flagBuilders[key].clone(); } return new TestDataFlagBuilder(key).booleanFlag(); } @@ -136,14 +136,14 @@ export default class TestData { */ update(flagBuilder: TestDataFlagBuilder): Promise { const flagKey = flagBuilder.getKey(); - const oldItem = this.currentFlags[flagKey]; + const oldItem = this._currentFlags[flagKey]; const oldVersion = oldItem ? oldItem.version : 0; const newFlag = flagBuilder.build(oldVersion + 1); - this.currentFlags[flagKey] = newFlag; - this.flagBuilders[flagKey] = flagBuilder.clone(); + this._currentFlags[flagKey] = newFlag; + this._flagBuilders[flagKey] = flagBuilder.clone(); return Promise.all( - this.dataSources.map((impl) => impl.upsert(VersionedDataKinds.Features, newFlag)), + this._dataSources.map((impl) => impl.upsert(VersionedDataKinds.Features, newFlag)), ); } @@ -169,13 +169,13 @@ export default class TestData { // We need to do things like process attribute reference, and // we do not want to modify the passed in value. const flagConfig = JSON.parse(JSON.stringify(inConfig)); - const oldItem = this.currentFlags[flagConfig.key]; + const oldItem = this._currentFlags[flagConfig.key]; const newItem = { ...flagConfig, version: oldItem ? oldItem.version + 1 : flagConfig.version }; processFlag(newItem); - this.currentFlags[flagConfig.key] = newItem; + this._currentFlags[flagConfig.key] = newItem; return Promise.all( - this.dataSources.map((impl) => impl.upsert(VersionedDataKinds.Features, newItem)), + this._dataSources.map((impl) => impl.upsert(VersionedDataKinds.Features, newItem)), ); } @@ -198,16 +198,16 @@ export default class TestData { usePreconfiguredSegment(inConfig: any): Promise { const segmentConfig = JSON.parse(JSON.stringify(inConfig)); - const oldItem = this.currentSegments[segmentConfig.key]; + const oldItem = this._currentSegments[segmentConfig.key]; const newItem = { ...segmentConfig, version: oldItem ? oldItem.version + 1 : segmentConfig.version, }; processSegment(newItem); - this.currentSegments[segmentConfig.key] = newItem; + this._currentSegments[segmentConfig.key] = newItem; return Promise.all( - this.dataSources.map((impl) => impl.upsert(VersionedDataKinds.Segments, newItem)), + this._dataSources.map((impl) => impl.upsert(VersionedDataKinds.Segments, newItem)), ); } } diff --git a/packages/shared/sdk-server/src/integrations/test_data/TestDataFlagBuilder.ts b/packages/shared/sdk-server/src/integrations/test_data/TestDataFlagBuilder.ts index 88dade591..a3cf80a41 100644 --- a/packages/shared/sdk-server/src/integrations/test_data/TestDataFlagBuilder.ts +++ b/packages/shared/sdk-server/src/integrations/test_data/TestDataFlagBuilder.ts @@ -28,7 +28,7 @@ interface BuilderData { * A builder for feature flag configurations to be used with {@link TestData}. */ export default class TestDataFlagBuilder { - private data: BuilderData = { + private _data: BuilderData = { on: true, variations: [], }; @@ -37,38 +37,38 @@ export default class TestDataFlagBuilder { * @internal */ constructor( - private readonly key: string, + private readonly _key: string, data?: BuilderData, ) { if (data) { // Not the fastest way to deep copy, but this is a testing mechanism. - this.data = { + this._data = { on: data.on, variations: [...data.variations], }; if (data.offVariation !== undefined) { - this.data.offVariation = data.offVariation; + this._data.offVariation = data.offVariation; } if (data.fallthroughVariation !== undefined) { - this.data.fallthroughVariation = data.fallthroughVariation; + this._data.fallthroughVariation = data.fallthroughVariation; } if (data.targetsByVariation) { - this.data.targetsByVariation = JSON.parse(JSON.stringify(data.targetsByVariation)); + this._data.targetsByVariation = JSON.parse(JSON.stringify(data.targetsByVariation)); } if (data.rules) { - this.data.rules = []; + this._data.rules = []; data.rules.forEach((rule) => { - this.data.rules?.push(rule.clone()); + this._data.rules?.push(rule.clone()); }); } } } - private get isBooleanFlag(): boolean { + private get _isBooleanFlag(): boolean { return ( - this.data.variations.length === 2 && - this.data.variations[TRUE_VARIATION_INDEX] === true && - this.data.variations[FALSE_VARIATION_INDEX] === false + this._data.variations.length === 2 && + this._data.variations[TRUE_VARIATION_INDEX] === true && + this._data.variations[FALSE_VARIATION_INDEX] === false ); } @@ -83,7 +83,7 @@ export default class TestDataFlagBuilder { * @return the flag builder */ booleanFlag(): TestDataFlagBuilder { - if (this.isBooleanFlag) { + if (this._isBooleanFlag) { return this; } // Change this flag into a boolean flag. @@ -103,7 +103,7 @@ export default class TestDataFlagBuilder { * @return the flag builder */ variations(...values: any[]): TestDataFlagBuilder { - this.data.variations = [...values]; + this._data.variations = [...values]; return this; } @@ -120,7 +120,7 @@ export default class TestDataFlagBuilder { * @return the flag builder */ on(targetingOn: boolean): TestDataFlagBuilder { - this.data.on = targetingOn; + this._data.on = targetingOn; return this; } @@ -141,7 +141,7 @@ export default class TestDataFlagBuilder { if (TypeValidators.Boolean.is(variation)) { return this.booleanFlag().fallthroughVariation(variationForBoolean(variation)); } - this.data.fallthroughVariation = variation; + this._data.fallthroughVariation = variation; return this; } @@ -161,7 +161,7 @@ export default class TestDataFlagBuilder { if (TypeValidators.Boolean.is(variation)) { return this.booleanFlag().offVariation(variationForBoolean(variation)); } - this.data.offVariation = variation; + this._data.offVariation = variation; return this; } @@ -254,14 +254,14 @@ export default class TestDataFlagBuilder { ); } - if (!this.data.targetsByVariation) { - this.data.targetsByVariation = {}; + if (!this._data.targetsByVariation) { + this._data.targetsByVariation = {}; } - this.data.variations.forEach((_, i) => { + this._data.variations.forEach((_, i) => { if (i === variation) { // If there is nothing set at the current variation then set it to the empty array - const targetsForVariation = this.data.targetsByVariation![i] || {}; + const targetsForVariation = this._data.targetsByVariation![i] || {}; if (!(contextKind in targetsForVariation)) { targetsForVariation[contextKind] = []; @@ -272,10 +272,10 @@ export default class TestDataFlagBuilder { targetsForVariation[contextKind].push(contextKey); } - this.data.targetsByVariation![i] = targetsForVariation; + this._data.targetsByVariation![i] = targetsForVariation; } else { // remove user from other variation set if necessary - const targetsForVariation = this.data.targetsByVariation![i]; + const targetsForVariation = this._data.targetsByVariation![i]; if (targetsForVariation) { const targetsForContextKind = targetsForVariation[contextKind]; if (targetsForContextKind) { @@ -288,7 +288,7 @@ export default class TestDataFlagBuilder { } } if (!Object.keys(targetsForVariation).length) { - delete this.data.targetsByVariation![i]; + delete this._data.targetsByVariation![i]; } } } @@ -304,7 +304,7 @@ export default class TestDataFlagBuilder { * @return the same flag builder */ clearRules(): TestDataFlagBuilder { - delete this.data.rules; + delete this._data.rules; return this; } @@ -315,7 +315,7 @@ export default class TestDataFlagBuilder { * @return the same flag builder */ clearAllTargets(): TestDataFlagBuilder { - delete this.data.targetsByVariation; + delete this._data.targetsByVariation; return this; } @@ -372,13 +372,13 @@ export default class TestDataFlagBuilder { } checkRatio(ratio: number): TestDataFlagBuilder { - this.data.migration = this.data.migration ?? {}; - this.data.migration.checkRatio = ratio; + this._data.migration = this._data.migration ?? {}; + this._data.migration.checkRatio = ratio; return this; } samplingRatio(ratio: number): TestDataFlagBuilder { - this.data.samplingRatio = ratio; + this._data.samplingRatio = ratio; return this; } @@ -386,10 +386,10 @@ export default class TestDataFlagBuilder { * @internal */ addRule(flagRuleBuilder: TestDataRuleBuilder) { - if (!this.data.rules) { - this.data.rules = []; + if (!this._data.rules) { + this._data.rules = []; } - this.data.rules.push(flagRuleBuilder as TestDataRuleBuilder); + this._data.rules.push(flagRuleBuilder as TestDataRuleBuilder); } /** @@ -397,22 +397,22 @@ export default class TestDataFlagBuilder { */ build(version: number) { const baseFlagObject: Flag = { - key: this.key, + key: this._key, version, - on: this.data.on, - offVariation: this.data.offVariation, + on: this._data.on, + offVariation: this._data.offVariation, fallthrough: { - variation: this.data.fallthroughVariation, + variation: this._data.fallthroughVariation, }, - variations: [...this.data.variations], - migration: this.data.migration, - samplingRatio: this.data.samplingRatio, + variations: [...this._data.variations], + migration: this._data.migration, + samplingRatio: this._data.samplingRatio, }; - if (this.data.targetsByVariation) { + if (this._data.targetsByVariation) { const contextTargets: Target[] = []; const userTargets: Omit[] = []; - Object.entries(this.data.targetsByVariation).forEach( + Object.entries(this._data.targetsByVariation).forEach( ([variation, contextTargetsForVariation]) => { Object.entries(contextTargetsForVariation).forEach(([contextKind, values]) => { const numberVariation = parseInt(variation, 10); @@ -432,8 +432,8 @@ export default class TestDataFlagBuilder { baseFlagObject.contextTargets = contextTargets; } - if (this.data.rules) { - baseFlagObject.rules = this.data.rules.map((rule, i) => + if (this._data.rules) { + baseFlagObject.rules = this._data.rules.map((rule, i) => (rule as TestDataRuleBuilder).build(String(i)), ); } @@ -445,13 +445,13 @@ export default class TestDataFlagBuilder { * @internal */ clone(): TestDataFlagBuilder { - return new TestDataFlagBuilder(this.key, this.data); + return new TestDataFlagBuilder(this._key, this._data); } /** * @internal */ getKey(): string { - return this.key; + return this._key; } } diff --git a/packages/shared/sdk-server/src/integrations/test_data/TestDataRuleBuilder.ts b/packages/shared/sdk-server/src/integrations/test_data/TestDataRuleBuilder.ts index a134231de..000fb2fd0 100644 --- a/packages/shared/sdk-server/src/integrations/test_data/TestDataRuleBuilder.ts +++ b/packages/shared/sdk-server/src/integrations/test_data/TestDataRuleBuilder.ts @@ -18,15 +18,15 @@ import { variationForBoolean } from './booleanVariation'; */ export default class TestDataRuleBuilder { - private clauses: Clause[] = []; + private _clauses: Clause[] = []; - private variation?: number; + private _variation?: number; /** * @internal */ constructor( - private readonly flagBuilder: BuilderType & { + private readonly _flagBuilder: BuilderType & { addRule: (rule: TestDataRuleBuilder) => void; booleanFlag: () => BuilderType; }, @@ -34,10 +34,10 @@ export default class TestDataRuleBuilder { variation?: number, ) { if (clauses) { - this.clauses = [...clauses]; + this._clauses = [...clauses]; } if (variation !== undefined) { - this.variation = variation; + this._variation = variation; } } @@ -62,7 +62,7 @@ export default class TestDataRuleBuilder { attribute: string, ...values: any ): TestDataRuleBuilder { - this.clauses.push({ + this._clauses.push({ contextKind, attribute, attributeReference: new AttributeReference(attribute), @@ -94,7 +94,7 @@ export default class TestDataRuleBuilder { attribute: string, ...values: any ): TestDataRuleBuilder { - this.clauses.push({ + this._clauses.push({ contextKind, attribute, attributeReference: new AttributeReference(attribute), @@ -121,13 +121,13 @@ export default class TestDataRuleBuilder { */ thenReturn(variation: number | boolean): BuilderType { if (TypeValidators.Boolean.is(variation)) { - this.flagBuilder.booleanFlag(); + this._flagBuilder.booleanFlag(); return this.thenReturn(variationForBoolean(variation)); } - this.variation = variation; - this.flagBuilder.addRule(this); - return this.flagBuilder; + this._variation = variation; + this._flagBuilder.addRule(this); + return this._flagBuilder; } /** @@ -136,8 +136,8 @@ export default class TestDataRuleBuilder { build(id: string) { return { id: `rule${id}`, - variation: this.variation, - clauses: this.clauses, + variation: this._variation, + clauses: this._clauses, }; } @@ -145,6 +145,6 @@ export default class TestDataRuleBuilder { * @internal */ clone(): TestDataRuleBuilder { - return new TestDataRuleBuilder(this.flagBuilder, this.clauses, this.variation); + return new TestDataRuleBuilder(this._flagBuilder, this._clauses, this._variation); } } diff --git a/packages/shared/sdk-server/src/integrations/test_data/TestDataSource.ts b/packages/shared/sdk-server/src/integrations/test_data/TestDataSource.ts index a4984ef07..152d595e7 100644 --- a/packages/shared/sdk-server/src/integrations/test_data/TestDataSource.ts +++ b/packages/shared/sdk-server/src/integrations/test_data/TestDataSource.ts @@ -9,30 +9,30 @@ import AsyncStoreFacade from '../../store/AsyncStoreFacade'; * @internal */ export default class TestDataSource implements subsystem.LDStreamProcessor { - private readonly flags: Record; - private readonly segments: Record; + private readonly _flags: Record; + private readonly _segments: Record; constructor( - private readonly featureStore: AsyncStoreFacade, + private readonly _featureStore: AsyncStoreFacade, initialFlags: Record, initialSegments: Record, - private readonly onStop: (tfs: TestDataSource) => void, - private readonly listeners: Map, + private readonly _onStop: (tfs: TestDataSource) => void, + private readonly _listeners: Map, ) { // make copies of these objects to decouple them from the originals // so updates made to the originals don't affect these internal data. - this.flags = { ...initialFlags }; - this.segments = { ...initialSegments }; + this._flags = { ...initialFlags }; + this._segments = { ...initialSegments }; } async start() { - this.listeners.forEach(({ processJson }) => { - const dataJson = { data: { flags: this.flags, segments: this.segments } }; + this._listeners.forEach(({ processJson }) => { + const dataJson = { data: { flags: this._flags, segments: this._segments } }; processJson(dataJson); }); } stop() { - this.onStop(this); + this._onStop(this); } close() { @@ -40,6 +40,6 @@ export default class TestDataSource implements subsystem.LDStreamProcessor { } async upsert(kind: DataKind, value: LDKeyedFeatureStoreItem) { - return this.featureStore.upsert(kind, value); + return this._featureStore.upsert(kind, value); } } diff --git a/packages/shared/sdk-server/src/store/AsyncStoreFacade.ts b/packages/shared/sdk-server/src/store/AsyncStoreFacade.ts index 358a402b7..5d24d8199 100644 --- a/packages/shared/sdk-server/src/store/AsyncStoreFacade.ts +++ b/packages/shared/sdk-server/src/store/AsyncStoreFacade.ts @@ -15,49 +15,49 @@ import promisify from '../async/promisify'; * */ export default class AsyncStoreFacade { - private store: LDFeatureStore; + private _store: LDFeatureStore; constructor(store: LDFeatureStore) { - this.store = store; + this._store = store; } async get(kind: DataKind, key: string): Promise { return promisify((cb) => { - this.store.get(kind, key, cb); + this._store.get(kind, key, cb); }); } async all(kind: DataKind): Promise { return promisify((cb) => { - this.store.all(kind, cb); + this._store.all(kind, cb); }); } async init(allData: LDFeatureStoreDataStorage): Promise { return promisify((cb) => { - this.store.init(allData, cb); + this._store.init(allData, cb); }); } async delete(kind: DataKind, key: string, version: number): Promise { return promisify((cb) => { - this.store.delete(kind, key, version, cb); + this._store.delete(kind, key, version, cb); }); } async upsert(kind: DataKind, data: LDKeyedFeatureStoreItem): Promise { return promisify((cb) => { - this.store.upsert(kind, data, cb); + this._store.upsert(kind, data, cb); }); } async initialized(): Promise { return promisify((cb) => { - this.store.initialized(cb); + this._store.initialized(cb); }); } close(): void { - this.store.close(); + this._store.close(); } } diff --git a/packages/shared/sdk-server/src/store/InMemoryFeatureStore.ts b/packages/shared/sdk-server/src/store/InMemoryFeatureStore.ts index 6464a87cb..61814f2aa 100644 --- a/packages/shared/sdk-server/src/store/InMemoryFeatureStore.ts +++ b/packages/shared/sdk-server/src/store/InMemoryFeatureStore.ts @@ -8,15 +8,15 @@ import { } from '../api/subsystems'; export default class InMemoryFeatureStore implements LDFeatureStore { - private allData: LDFeatureStoreDataStorage = {}; + private _allData: LDFeatureStoreDataStorage = {}; - private initCalled = false; + private _initCalled = false; - private addItem(kind: DataKind, key: string, item: LDFeatureStoreItem) { - let items = this.allData[kind.namespace]; + private _addItem(kind: DataKind, key: string, item: LDFeatureStoreItem) { + let items = this._allData[kind.namespace]; if (!items) { items = {}; - this.allData[kind.namespace] = items; + this._allData[kind.namespace] = items; } if (Object.hasOwnProperty.call(items, key)) { const old = items[key]; @@ -29,7 +29,7 @@ export default class InMemoryFeatureStore implements LDFeatureStore { } get(kind: DataKind, key: string, callback: (res: LDFeatureStoreItem | null) => void): void { - const items = this.allData[kind.namespace]; + const items = this._allData[kind.namespace]; if (items) { if (Object.prototype.hasOwnProperty.call(items, key)) { const item = items[key]; @@ -43,7 +43,7 @@ export default class InMemoryFeatureStore implements LDFeatureStore { all(kind: DataKind, callback: (res: LDFeatureStoreKindData) => void): void { const result: LDFeatureStoreKindData = {}; - const items = this.allData[kind.namespace] ?? {}; + const items = this._allData[kind.namespace] ?? {}; Object.entries(items).forEach(([key, item]) => { if (item && !item.deleted) { result[key] = item; @@ -53,24 +53,24 @@ export default class InMemoryFeatureStore implements LDFeatureStore { } init(allData: LDFeatureStoreDataStorage, callback: () => void): void { - this.initCalled = true; - this.allData = allData as LDFeatureStoreDataStorage; + this._initCalled = true; + this._allData = allData as LDFeatureStoreDataStorage; callback?.(); } delete(kind: DataKind, key: string, version: number, callback: () => void): void { const deletedItem = { version, deleted: true }; - this.addItem(kind, key, deletedItem); + this._addItem(kind, key, deletedItem); callback?.(); } upsert(kind: DataKind, data: LDKeyedFeatureStoreItem, callback: () => void): void { - this.addItem(kind, data.key, data); + this._addItem(kind, data.key, data); callback?.(); } initialized(callback: (isInitialized: boolean) => void): void { - return callback?.(this.initCalled); + return callback?.(this._initCalled); } /* eslint-disable class-methods-use-this */ diff --git a/packages/shared/sdk-server/src/store/PersistentDataStoreWrapper.ts b/packages/shared/sdk-server/src/store/PersistentDataStoreWrapper.ts index 63b21c80f..c682e4234 100644 --- a/packages/shared/sdk-server/src/store/PersistentDataStoreWrapper.ts +++ b/packages/shared/sdk-server/src/store/PersistentDataStoreWrapper.ts @@ -88,33 +88,33 @@ function deserialize( * create new database integrations by implementing only the database-specific logic. */ export default class PersistentDataStoreWrapper implements LDFeatureStore { - private isInitialized = false; + private _isInitialized = false; /** * Cache for storing individual items. */ - private itemCache: TtlCache | undefined; + private _itemCache: TtlCache | undefined; /** * Cache for storing all items of a type. */ - private allItemsCache: TtlCache | undefined; + private _allItemsCache: TtlCache | undefined; /** * Used to preserve order of operations of async requests. */ - private queue: UpdateQueue = new UpdateQueue(); + private _queue: UpdateQueue = new UpdateQueue(); constructor( - private readonly core: PersistentDataStore, + private readonly _core: PersistentDataStore, ttl: number, ) { if (ttl) { - this.itemCache = new TtlCache({ + this._itemCache = new TtlCache({ ttl, checkInterval: defaultCheckInterval, }); - this.allItemsCache = new TtlCache({ + this._allItemsCache = new TtlCache({ ttl, checkInterval: defaultCheckInterval, }); @@ -122,17 +122,17 @@ export default class PersistentDataStoreWrapper implements LDFeatureStore { } init(allData: LDFeatureStoreDataStorage, callback: () => void): void { - this.queue.enqueue((cb) => { + this._queue.enqueue((cb) => { const afterStoreInit = () => { - this.isInitialized = true; - if (this.itemCache) { - this.itemCache.clear(); - this.allItemsCache!.clear(); + this._isInitialized = true; + if (this._itemCache) { + this._itemCache.clear(); + this._allItemsCache!.clear(); Object.keys(allData).forEach((kindNamespace) => { const kind = persistentStoreKinds[kindNamespace]; const items = allData[kindNamespace]; - this.allItemsCache!.set(allForKindCacheKey(kind), items); + this._allItemsCache!.set(allForKindCacheKey(kind), items); Object.keys(items).forEach((key) => { const itemForKey = items[key]; @@ -140,20 +140,20 @@ export default class PersistentDataStoreWrapper implements LDFeatureStore { version: itemForKey.version, item: itemForKey, }; - this.itemCache!.set(cacheKey(kind, key), itemDescriptor); + this._itemCache!.set(cacheKey(kind, key), itemDescriptor); }); }); } cb(); }; - this.core.init(sortDataSet(allData), afterStoreInit); + this._core.init(sortDataSet(allData), afterStoreInit); }, callback); } get(kind: DataKind, key: string, callback: (res: LDFeatureStoreItem | null) => void): void { - if (this.itemCache) { - const item = this.itemCache.get(cacheKey(kind, key)); + if (this._itemCache) { + const item = this._itemCache.get(cacheKey(kind, key)); if (item) { callback(itemIfNotDeleted(item)); return; @@ -161,10 +161,10 @@ export default class PersistentDataStoreWrapper implements LDFeatureStore { } const persistKind = persistentStoreKinds[kind.namespace]; - this.core.get(persistKind, key, (descriptor) => { + this._core.get(persistKind, key, (descriptor) => { if (descriptor && descriptor.serializedItem) { const value = deserialize(persistKind, descriptor); - this.itemCache?.set(cacheKey(kind, key), value); + this._itemCache?.set(cacheKey(kind, key), value); callback(itemIfNotDeleted(value)); return; } @@ -173,30 +173,30 @@ export default class PersistentDataStoreWrapper implements LDFeatureStore { } initialized(callback: (isInitialized: boolean) => void): void { - if (this.isInitialized) { + if (this._isInitialized) { callback(true); - } else if (this.itemCache?.get(initializationCheckedKey)) { + } else if (this._itemCache?.get(initializationCheckedKey)) { callback(false); } else { - this.core.initialized((storeInitialized) => { - this.isInitialized = storeInitialized; - if (!this.isInitialized) { - this.itemCache?.set(initializationCheckedKey, true); + this._core.initialized((storeInitialized) => { + this._isInitialized = storeInitialized; + if (!this._isInitialized) { + this._itemCache?.set(initializationCheckedKey, true); } - callback(this.isInitialized); + callback(this._isInitialized); }); } } all(kind: DataKind, callback: (res: LDFeatureStoreKindData) => void): void { - const items = this.allItemsCache?.get(allForKindCacheKey(kind)); + const items = this._allItemsCache?.get(allForKindCacheKey(kind)); if (items) { callback(items); return; } const persistKind = persistentStoreKinds[kind.namespace]; - this.core.getAll(persistKind, (storeItems) => { + this._core.getAll(persistKind, (storeItems) => { if (!storeItems) { callback({}); return; @@ -211,20 +211,20 @@ export default class PersistentDataStoreWrapper implements LDFeatureStore { } }); - this.allItemsCache?.set(allForKindCacheKey(kind), filteredItems); + this._allItemsCache?.set(allForKindCacheKey(kind), filteredItems); callback(filteredItems); }); } upsert(kind: DataKind, data: LDKeyedFeatureStoreItem, callback: () => void): void { - this.queue.enqueue((cb) => { + this._queue.enqueue((cb) => { // Clear the caches which contain all the values of a specific kind. - if (this.allItemsCache) { - this.allItemsCache.clear(); + if (this._allItemsCache) { + this._allItemsCache.clear(); } const persistKind = persistentStoreKinds[kind.namespace]; - this.core.upsert( + this._core.upsert( persistKind, data.key, persistKind.serialize(data), @@ -232,10 +232,10 @@ export default class PersistentDataStoreWrapper implements LDFeatureStore { if (!err && updatedDescriptor) { if (updatedDescriptor.serializedItem) { const value = deserialize(persistKind, updatedDescriptor); - this.itemCache?.set(cacheKey(kind, data.key), value); + this._itemCache?.set(cacheKey(kind, data.key), value); } else if (updatedDescriptor.deleted) { // Deleted and there was not a serialized representation. - this.itemCache?.set(data.key, { + this._itemCache?.set(data.key, { key: data.key, version: updatedDescriptor.version, deleted: true, @@ -253,12 +253,12 @@ export default class PersistentDataStoreWrapper implements LDFeatureStore { } close(): void { - this.itemCache?.close(); - this.allItemsCache?.close(); - this.core.close(); + this._itemCache?.close(); + this._allItemsCache?.close(); + this._core.close(); } getDescription(): string { - return this.core.getDescription(); + return this._core.getDescription(); } } diff --git a/packages/shared/sdk-server/src/store/UpdateQueue.ts b/packages/shared/sdk-server/src/store/UpdateQueue.ts index baeda25e4..02d69a888 100644 --- a/packages/shared/sdk-server/src/store/UpdateQueue.ts +++ b/packages/shared/sdk-server/src/store/UpdateQueue.ts @@ -2,11 +2,11 @@ type CallbackFunction = () => void; type UpdateFunction = (cb: CallbackFunction) => void; export default class UpdateQueue { - private queue: [UpdateFunction, CallbackFunction][] = []; + private _queue: [UpdateFunction, CallbackFunction][] = []; enqueue(updateFn: UpdateFunction, cb: CallbackFunction) { - this.queue.push([updateFn, cb]); - if (this.queue.length === 1) { + this._queue.push([updateFn, cb]); + if (this._queue.length === 1) { // If this is the only item in the queue, then there is not a series // of updates already in progress. So we can start executing those updates. this.executePendingUpdates(); @@ -14,15 +14,15 @@ export default class UpdateQueue { } executePendingUpdates() { - if (this.queue.length > 0) { - const [fn, cb] = this.queue[0]; + if (this._queue.length > 0) { + const [fn, cb] = this._queue[0]; const newCb = () => { // We just completed work, so remove it from the queue. // Don't remove it before the work is done, because then the // count could hit 0, and overlapping execution chains could be started. - this.queue.shift(); + this._queue.shift(); // There is more work to do, so schedule an update. - if (this.queue.length > 0) { + if (this._queue.length > 0) { setTimeout(() => this.executePendingUpdates(), 0); } // Call the original callback. diff --git a/packages/shared/sdk-server/src/store/serialization.ts b/packages/shared/sdk-server/src/store/serialization.ts index aaba1bc4d..44bdf7edd 100644 --- a/packages/shared/sdk-server/src/store/serialization.ts +++ b/packages/shared/sdk-server/src/store/serialization.ts @@ -13,19 +13,6 @@ import VersionedDataKinds, { VersionedDataKind } from './VersionedDataKinds'; // The max size where we use an array instead of a set. const TARGET_LIST_ARRAY_CUTOFF = 100; -/** - * @internal - */ -export function reviver(this: any, key: string, value: any): any { - // Whenever a null is included we want to remove the field. - // In this way validation checks do not have to consider null, only undefined. - if (value === null) { - return undefined; - } - - return value; -} - export interface FlagsAndSegments { flags: { [name: string]: Flag }; segments: { [name: string]: Segment }; @@ -35,6 +22,60 @@ export interface AllData { data: FlagsAndSegments; } +/** + * Performs deep removal of null values. + * + * Does not remove null values from arrays. + * + * Note: This is a non-recursive implementation for performance and to avoid + * potential stack overflows. + * + * @param target The target to remove null values from. + * @param excludeKeys A list of top-level keys to exclude from null removal. + */ +export function nullReplacer(target: any, excludeKeys?: string[]): void { + const stack: { + key: string; + value: any; + parent: any; + }[] = []; + + if (target === null || target === undefined) { + return; + } + + const filteredEntries = Object.entries(target).filter( + ([key, _value]) => !excludeKeys?.includes(key), + ); + + stack.push( + ...filteredEntries.map(([key, value]) => ({ + key, + value, + parent: target, + })), + ); + + while (stack.length) { + const item = stack.pop()!; + // Do not remove items from arrays. + if (item.value === null && !Array.isArray(item.parent)) { + delete item.parent[item.key]; + } else if (typeof item.value === 'object' && item.value !== null) { + // Add all the children to the stack. This includes array children. + // The items in the array could themselves be objects which need nulls + // removed from them. + stack.push( + ...Object.entries(item.value).map(([key, value]) => ({ + key, + value, + parent: item.value, + })), + ); + } + } +} + /** * For use when serializing flags/segments. This will ensure local types * are converted to the appropriate JSON representation. @@ -54,6 +95,10 @@ export function replacer(this: any, key: string, value: any): any { return undefined; } } + // Allow null/undefined values to pass through without modification. + if (value === null || value === undefined) { + return value; + } if (value.generated_includedSet) { value.included = [...value.generated_includedSet]; delete value.generated_includedSet; @@ -108,6 +153,8 @@ function processRollout(rollout?: Rollout) { * @internal */ export function processFlag(flag: Flag) { + nullReplacer(flag, ['variations']); + if (flag.fallthrough && flag.fallthrough.rollout) { const rollout = flag.fallthrough.rollout!; processRollout(rollout); @@ -121,7 +168,7 @@ export function processFlag(flag: Flag) { // So use the contextKind to indicate if this is new or old data. clause.attributeReference = new AttributeReference(clause.attribute, !clause.contextKind); } else if (clause) { - clause.attributeReference = AttributeReference.invalidReference; + clause.attributeReference = AttributeReference.InvalidReference; } }); }); @@ -131,6 +178,7 @@ export function processFlag(flag: Flag) { * @internal */ export function processSegment(segment: Segment) { + nullReplacer(segment); if (segment?.included?.length && segment.included.length > TARGET_LIST_ARRAY_CUTOFF) { segment.generated_includedSet = new Set(segment.included); delete segment.included; @@ -175,7 +223,7 @@ export function processSegment(segment: Segment) { // So use the contextKind to indicate if this is new or old data. clause.attributeReference = new AttributeReference(clause.attribute, !clause.contextKind); } else if (clause) { - clause.attributeReference = AttributeReference.invalidReference; + clause.attributeReference = AttributeReference.InvalidReference; } }); }); @@ -183,7 +231,7 @@ export function processSegment(segment: Segment) { function tryParse(data: string): any { try { - return JSON.parse(data, reviver); + return JSON.parse(data); } catch { return undefined; } diff --git a/packages/store/node-server-sdk-dynamodb/CHANGELOG.md b/packages/store/node-server-sdk-dynamodb/CHANGELOG.md index f955f9937..fd694478d 100644 --- a/packages/store/node-server-sdk-dynamodb/CHANGELOG.md +++ b/packages/store/node-server-sdk-dynamodb/CHANGELOG.md @@ -90,6 +90,66 @@ * devDependencies * @launchdarkly/node-server-sdk bumped from 9.2.1 to 9.2.2 +## [6.2.0](https://github.com/launchdarkly/js-core/compare/node-server-sdk-dynamodb-v6.1.23...node-server-sdk-dynamodb-v6.2.0) (2024-10-17) + + +### Features + +* Apply private property naming standard. Mangle browser private properties. ([#620](https://github.com/launchdarkly/js-core/issues/620)) ([3e6d404](https://github.com/launchdarkly/js-core/commit/3e6d404ae665c5cc7e5a1394a59c8f2c9d5d682a)) + + +### Dependencies + +* The following workspace dependencies were updated + * devDependencies + * @launchdarkly/node-server-sdk bumped from 9.6.1 to 9.7.0 + * peerDependencies + * @launchdarkly/node-server-sdk bumped from >=9.4.3 to >=9.7.0 + +## [6.1.23](https://github.com/launchdarkly/js-core/compare/node-server-sdk-dynamodb-v6.1.22...node-server-sdk-dynamodb-v6.1.23) (2024-10-09) + + +### Dependencies + +* The following workspace dependencies were updated + * devDependencies + * @launchdarkly/node-server-sdk bumped from 9.6.0 to 9.6.1 + * peerDependencies + * @launchdarkly/node-server-sdk bumped from >=9.4.3 to >=9.6.1 + +## [6.1.22](https://github.com/launchdarkly/js-core/compare/node-server-sdk-dynamodb-v6.1.21...node-server-sdk-dynamodb-v6.1.22) (2024-09-26) + + +### Dependencies + +* The following workspace dependencies were updated + * devDependencies + * @launchdarkly/node-server-sdk bumped from 9.5.4 to 9.6.0 + * peerDependencies + * @launchdarkly/node-server-sdk bumped from >=9.4.3 to >=9.6.0 + +## [6.1.21](https://github.com/launchdarkly/js-core/compare/node-server-sdk-dynamodb-v6.1.20...node-server-sdk-dynamodb-v6.1.21) (2024-09-05) + + +### Dependencies + +* The following workspace dependencies were updated + * devDependencies + * @launchdarkly/node-server-sdk bumped from 9.5.3 to 9.5.4 + * peerDependencies + * @launchdarkly/node-server-sdk bumped from >=9.4.3 to >=9.5.4 + +## [6.1.20](https://github.com/launchdarkly/js-core/compare/node-server-sdk-dynamodb-v6.1.19...node-server-sdk-dynamodb-v6.1.20) (2024-09-03) + + +### Dependencies + +* The following workspace dependencies were updated + * devDependencies + * @launchdarkly/node-server-sdk bumped from 9.5.2 to 9.5.3 + * peerDependencies + * @launchdarkly/node-server-sdk bumped from >=9.4.3 to >=9.5.3 + ## [6.1.19](https://github.com/launchdarkly/js-core/compare/node-server-sdk-dynamodb-v6.1.18...node-server-sdk-dynamodb-v6.1.19) (2024-08-28) diff --git a/packages/store/node-server-sdk-dynamodb/__tests__/DynamoDBCore.test.ts b/packages/store/node-server-sdk-dynamodb/__tests__/DynamoDBCore.test.ts index e6a7f8c01..faf0b47a4 100644 --- a/packages/store/node-server-sdk-dynamodb/__tests__/DynamoDBCore.test.ts +++ b/packages/store/node-server-sdk-dynamodb/__tests__/DynamoDBCore.test.ts @@ -38,23 +38,23 @@ type UpsertResult = { }; class AsyncCoreFacade { - constructor(private readonly core: DynamoDBCore) {} + constructor(private readonly _core: DynamoDBCore) {} init(allData: interfaces.KindKeyedStore): Promise { - return promisify((cb) => this.core.init(allData, cb)); + return promisify((cb) => this._core.init(allData, cb)); } get( kind: interfaces.PersistentStoreDataKind, key: string, ): Promise { - return promisify((cb) => this.core.get(kind, key, cb)); + return promisify((cb) => this._core.get(kind, key, cb)); } getAll( kind: interfaces.PersistentStoreDataKind, ): Promise[] | undefined> { - return promisify((cb) => this.core.getAll(kind, cb)); + return promisify((cb) => this._core.getAll(kind, cb)); } upsert( @@ -63,22 +63,22 @@ class AsyncCoreFacade { descriptor: interfaces.SerializedItemDescriptor, ): Promise { return new Promise((resolve) => { - this.core.upsert(kind, key, descriptor, (err, updatedDescriptor) => { + this._core.upsert(kind, key, descriptor, (err, updatedDescriptor) => { resolve({ err, updatedDescriptor }); }); }); } initialized(): Promise { - return promisify((cb) => this.core.initialized(cb)); + return promisify((cb) => this._core.initialized(cb)); } close(): void { - this.core.close(); + this._core.close(); } getDescription(): string { - return this.core.getDescription(); + return this._core.getDescription(); } } diff --git a/packages/store/node-server-sdk-dynamodb/package.json b/packages/store/node-server-sdk-dynamodb/package.json index e1f222580..32e82fcc2 100644 --- a/packages/store/node-server-sdk-dynamodb/package.json +++ b/packages/store/node-server-sdk-dynamodb/package.json @@ -1,6 +1,6 @@ { "name": "@launchdarkly/node-server-sdk-dynamodb", - "version": "6.1.19", + "version": "6.2.0", "description": "DynamoDB-backed feature store for the LaunchDarkly Server-Side SDK for Node.js", "homepage": "https://github.com/launchdarkly/js-core/tree/main/packages/store/node-server-sdk-dynamodb", "repository": { @@ -35,7 +35,7 @@ }, "devDependencies": { "@aws-sdk/client-dynamodb": "3.348.0", - "@launchdarkly/node-server-sdk": "9.5.2", + "@launchdarkly/node-server-sdk": "9.7.0", "@trivago/prettier-plugin-sort-imports": "^4.1.1", "@types/jest": "^29.4.0", "@typescript-eslint/eslint-plugin": "^6.20.0", diff --git a/packages/store/node-server-sdk-dynamodb/src/DynamoDBBigSegmentStore.ts b/packages/store/node-server-sdk-dynamodb/src/DynamoDBBigSegmentStore.ts index e7ed6614b..a984562e1 100644 --- a/packages/store/node-server-sdk-dynamodb/src/DynamoDBBigSegmentStore.ts +++ b/packages/store/node-server-sdk-dynamodb/src/DynamoDBBigSegmentStore.ts @@ -35,21 +35,21 @@ export const ATTR_INCLUDED = 'included'; export const ATTR_EXCLUDED = 'excluded'; export default class DynamoDBBigSegmentStore implements interfaces.BigSegmentStore { - private state: DynamoDBClientState; + private _state: DynamoDBClientState; // Logger is not currently used, but is included to reduce the chance of a // compatibility break to add a log. constructor( - private readonly tableName: string, + private readonly _tableName: string, options?: LDDynamoDBOptions, - private readonly logger?: LDLogger, + _logger?: LDLogger, ) { - this.state = new DynamoDBClientState(options); + this._state = new DynamoDBClientState(options); } async getMetadata(): Promise { - const key = this.state.prefixedKey(KEY_METADATA); - const data = await this.state.get(this.tableName, { + const key = this._state.prefixedKey(KEY_METADATA); + const data = await this._state.get(this._tableName, { namespace: stringValue(key), key: stringValue(key), }); @@ -65,8 +65,8 @@ export default class DynamoDBBigSegmentStore implements interfaces.BigSegmentSto async getUserMembership( userHash: string, ): Promise { - const data = await this.state.get(this.tableName, { - namespace: stringValue(this.state.prefixedKey(KEY_USER_DATA)), + const data = await this._state.get(this._tableName, { + namespace: stringValue(this._state.prefixedKey(KEY_USER_DATA)), key: stringValue(userHash), }); if (data) { @@ -87,6 +87,6 @@ export default class DynamoDBBigSegmentStore implements interfaces.BigSegmentSto } close(): void { - this.state.close(); + this._state.close(); } } diff --git a/packages/store/node-server-sdk-dynamodb/src/DynamoDBClientState.ts b/packages/store/node-server-sdk-dynamodb/src/DynamoDBClientState.ts index 47d0f5a3c..ed96866b9 100644 --- a/packages/store/node-server-sdk-dynamodb/src/DynamoDBClientState.ts +++ b/packages/store/node-server-sdk-dynamodb/src/DynamoDBClientState.ts @@ -30,25 +30,25 @@ const WRITE_BATCH_SIZE = 25; */ export default class DynamoDBClientState { // This will include the ':' if a prefix is set. - private prefix: string; + private _prefix: string; - private client: DynamoDBClient; + private _client: DynamoDBClient; - private owned: boolean; + private _owned: boolean; constructor(options?: LDDynamoDBOptions) { - this.prefix = options?.prefix ? `${options!.prefix}:` : DEFAULT_PREFIX; + this._prefix = options?.prefix ? `${options!.prefix}:` : DEFAULT_PREFIX; // We track if we own the client so that we can destroy clients that we own. if (options?.dynamoDBClient) { - this.client = options.dynamoDBClient; - this.owned = false; + this._client = options.dynamoDBClient; + this._owned = false; } else if (options?.clientOptions) { - this.client = new DynamoDBClient(options.clientOptions); - this.owned = true; + this._client = new DynamoDBClient(options.clientOptions); + this._owned = true; } else { - this.client = new DynamoDBClient({}); - this.owned = true; + this._client = new DynamoDBClient({}); + this._owned = true; } } @@ -58,14 +58,14 @@ export default class DynamoDBClientState { * @returns The prefixed key. */ prefixedKey(key: string): string { - return `${this.prefix}${key}`; + return `${this._prefix}${key}`; } async query(params: QueryCommandInput): Promise[]> { const records: Record[] = []; // Using a generator here is a substantial ergonomic improvement. // eslint-disable-next-line no-restricted-syntax - for await (const page of paginateQuery({ client: this.client }, params)) { + for await (const page of paginateQuery({ client: this._client }, params)) { if (page.Items) { records.push(...page.Items); } @@ -83,7 +83,7 @@ export default class DynamoDBClientState { // Execute all the batches and wait for them to complete. await Promise.all( batches.map((batch) => - this.client.send( + this._client.send( new BatchWriteItemCommand({ RequestItems: { [table]: batch }, }), @@ -96,7 +96,7 @@ export default class DynamoDBClientState { table: string, key: Record, ): Promise | undefined> { - const res = await this.client.send( + const res = await this._client.send( new GetItemCommand({ TableName: table, Key: key, @@ -108,7 +108,7 @@ export default class DynamoDBClientState { async put(params: PutItemCommandInput): Promise { try { - await this.client.send(new PutItemCommand(params)); + await this._client.send(new PutItemCommand(params)); } catch (err) { // If we couldn't upsert because of the version, then that is fine. // Otherwise we return failure. @@ -119,8 +119,8 @@ export default class DynamoDBClientState { } close() { - if (this.owned) { - this.client.destroy(); + if (this._owned) { + this._client.destroy(); } } } diff --git a/packages/store/node-server-sdk-dynamodb/src/DynamoDBCore.ts b/packages/store/node-server-sdk-dynamodb/src/DynamoDBCore.ts index c4983e94c..e2fbf89e7 100644 --- a/packages/store/node-server-sdk-dynamodb/src/DynamoDBCore.ts +++ b/packages/store/node-server-sdk-dynamodb/src/DynamoDBCore.ts @@ -62,13 +62,13 @@ export function calculateSize(item: Record, logger?: LDL */ export default class DynamoDBCore implements interfaces.PersistentDataStore { constructor( - private readonly tableName: string, - private readonly state: DynamoDBClientState, - private readonly logger?: LDLogger, + private readonly _tableName: string, + private readonly _state: DynamoDBClientState, + private readonly _logger?: LDLogger, ) {} - private initializedToken() { - const prefixed = stringValue(this.state.prefixedKey('$inited')); + private _initializedToken() { + const prefixed = stringValue(this._state.prefixedKey('$inited')); return { namespace: prefixed, key: prefixed }; } @@ -77,12 +77,12 @@ export default class DynamoDBCore implements interfaces.PersistentDataStore { * @param allData A set of init data. * @returns A list of all data with matching namespaces. */ - private async readExistingItems( + private async _readExistingItems( allData: interfaces.KindKeyedStore, ) { const promises = allData.map((kind) => { const { namespace } = kind.key; - return this.state.query(this.queryParamsForNamespace(namespace)); + return this._state.query(this._queryParamsForNamespace(namespace)); }); const records = (await Promise.all(promises)).flat(); @@ -95,12 +95,12 @@ export default class DynamoDBCore implements interfaces.PersistentDataStore { * @param item The item to marshal. * @returns The marshalled data. */ - private marshalItem( + private _marshalItem( kind: interfaces.PersistentStoreDataKind, item: interfaces.KeyedItem, ): Record { const dbItem: Record = { - namespace: stringValue(this.state.prefixedKey(kind.namespace)), + namespace: stringValue(this._state.prefixedKey(kind.namespace)), key: stringValue(item.key), version: numberValue(item.item.version), }; @@ -110,7 +110,7 @@ export default class DynamoDBCore implements interfaces.PersistentDataStore { return dbItem; } - private unmarshalItem( + private _unmarshalItem( dbItem: Record, ): interfaces.SerializedItemDescriptor { return { @@ -126,7 +126,7 @@ export default class DynamoDBCore implements interfaces.PersistentDataStore { allData: interfaces.KindKeyedStore, callback: () => void, ) { - const items = await this.readExistingItems(allData); + const items = await this._readExistingItems(allData); // Make a key from an existing DB item. function makeNamespaceKey(item: Record) { @@ -137,17 +137,17 @@ export default class DynamoDBCore implements interfaces.PersistentDataStore { items.forEach((item) => { existingNamespaceKeys[makeNamespaceKey(item)] = true; }); - delete existingNamespaceKeys[makeNamespaceKey(this.initializedToken())]; + delete existingNamespaceKeys[makeNamespaceKey(this._initializedToken())]; // Generate a list of write operations, and then execute them in a batch. const ops: WriteRequest[] = []; allData.forEach((collection) => { collection.item.forEach((item) => { - const dbItem = this.marshalItem(collection.key, item); - if (this.checkSizeLimit(dbItem)) { + const dbItem = this._marshalItem(collection.key, item); + if (this._checkSizeLimit(dbItem)) { delete existingNamespaceKeys[ - `${this.state.prefixedKey(collection.key.namespace)}$${item.key}` + `${this._state.prefixedKey(collection.key.namespace)}$${item.key}` ]; ops.push({ PutRequest: { Item: dbItem } }); } @@ -165,9 +165,9 @@ export default class DynamoDBCore implements interfaces.PersistentDataStore { }); // Always write the initialized token when we initialize. - ops.push({ PutRequest: { Item: this.initializedToken() } }); + ops.push({ PutRequest: { Item: this._initializedToken() } }); - await this.state.batchWrite(this.tableName, ops); + await this._state.batchWrite(this._tableName, ops); callback(); } @@ -176,12 +176,12 @@ export default class DynamoDBCore implements interfaces.PersistentDataStore { key: string, callback: (descriptor: interfaces.SerializedItemDescriptor | undefined) => void, ) { - const read = await this.state.get(this.tableName, { - namespace: stringValue(this.state.prefixedKey(kind.namespace)), + const read = await this._state.get(this._tableName, { + namespace: stringValue(this._state.prefixedKey(kind.namespace)), key: stringValue(key), }); if (read) { - callback(this.unmarshalItem(read)); + callback(this._unmarshalItem(read)); } else { callback(undefined); } @@ -193,9 +193,11 @@ export default class DynamoDBCore implements interfaces.PersistentDataStore { descriptors: interfaces.KeyedItem[] | undefined, ) => void, ) { - const params = this.queryParamsForNamespace(kind.namespace); - const results = await this.state.query(params); - callback(results.map((record) => ({ key: record!.key!.S!, item: this.unmarshalItem(record) }))); + const params = this._queryParamsForNamespace(kind.namespace); + const results = await this._state.query(params); + callback( + results.map((record) => ({ key: record!.key!.S!, item: this._unmarshalItem(record) })), + ); } async upsert( @@ -207,8 +209,8 @@ export default class DynamoDBCore implements interfaces.PersistentDataStore { updatedDescriptor?: interfaces.SerializedItemDescriptor | undefined, ) => void, ) { - const params = this.makeVersionedPutRequest(kind, { key, item: descriptor }); - if (!this.checkSizeLimit(params.Item)) { + const params = this._makeVersionedPutRequest(kind, { key, item: descriptor }); + if (!this._checkSizeLimit(params.Item)) { // We deliberately don't report this back to the SDK as an error, because we don't want to trigger any // useless retry behavior. We just won't do the update. callback(); @@ -216,7 +218,7 @@ export default class DynamoDBCore implements interfaces.PersistentDataStore { } try { - await this.state.put(params); + await this._state.put(params); this.get(kind, key, (readDescriptor) => { callback(undefined, readDescriptor); }); @@ -228,11 +230,11 @@ export default class DynamoDBCore implements interfaces.PersistentDataStore { async initialized(callback: (isInitialized: boolean) => void) { let initialized = false; try { - const token = this.initializedToken(); - const data = await this.state.get(this.tableName, token); + const token = this._initializedToken(); + const data = await this._state.get(this._tableName, token); initialized = !!(data?.key?.S === token.key.S); } catch (err) { - this.logger?.error(`Error reading inited: ${err}`); + this._logger?.error(`Error reading inited: ${err}`); initialized = false; } // Callback outside the try. In case it raised an exception. @@ -240,44 +242,44 @@ export default class DynamoDBCore implements interfaces.PersistentDataStore { } close(): void { - this.state.close(); + this._state.close(); } getDescription(): string { return 'DynamoDB'; } - private queryParamsForNamespace(namespace: string): QueryCommandInput { + private _queryParamsForNamespace(namespace: string): QueryCommandInput { return { - TableName: this.tableName, + TableName: this._tableName, KeyConditionExpression: 'namespace = :namespace', FilterExpression: 'attribute_not_exists(deleted) OR deleted = :deleted', ExpressionAttributeValues: { - ':namespace': stringValue(this.state.prefixedKey(namespace)), + ':namespace': stringValue(this._state.prefixedKey(namespace)), ':deleted': boolValue(false), }, }; } - private makeVersionedPutRequest( + private _makeVersionedPutRequest( kind: interfaces.PersistentStoreDataKind, item: interfaces.KeyedItem, ) { return { - TableName: this.tableName, - Item: this.marshalItem(kind, item), + TableName: this._tableName, + Item: this._marshalItem(kind, item), ConditionExpression: 'attribute_not_exists(version) OR version < :new_version', ExpressionAttributeValues: { ':new_version': numberValue(item.item.version) }, }; } - private checkSizeLimit(item: Record) { + private _checkSizeLimit(item: Record) { const size = calculateSize(item); if (size <= DYNAMODB_MAX_SIZE) { return true; } - this.logger?.error( + this._logger?.error( `The item "${item.key.S}" in "${item.namespace.S}" was too large to store in DynamoDB and was dropped`, ); return false; diff --git a/packages/store/node-server-sdk-dynamodb/src/DynamoDBFeatureStore.ts b/packages/store/node-server-sdk-dynamodb/src/DynamoDBFeatureStore.ts index f41ea37c9..100d7b92d 100644 --- a/packages/store/node-server-sdk-dynamodb/src/DynamoDBFeatureStore.ts +++ b/packages/store/node-server-sdk-dynamodb/src/DynamoDBFeatureStore.ts @@ -18,10 +18,10 @@ import TtlFromOptions from './TtlFromOptions'; * Integration between the LaunchDarkly SDK and DynamoDB. */ export default class DynamoDBFeatureStore implements LDFeatureStore { - private wrapper: PersistentDataStoreWrapper; + private _wrapper: PersistentDataStoreWrapper; constructor(tableName: string, options?: LDDynamoDBOptions, logger?: LDLogger) { - this.wrapper = new PersistentDataStoreWrapper( + this._wrapper = new PersistentDataStoreWrapper( new DynamoDBCore(tableName, new DynamoDBClientState(options), logger), TtlFromOptions(options), ); @@ -32,34 +32,34 @@ export default class DynamoDBFeatureStore implements LDFeatureStore { key: string, callback: (res: LDFeatureStoreItem | null) => void, ): void { - this.wrapper.get(kind, key, callback); + this._wrapper.get(kind, key, callback); } all(kind: interfaces.DataKind, callback: (res: LDFeatureStoreKindData) => void): void { - this.wrapper.all(kind, callback); + this._wrapper.all(kind, callback); } init(allData: LDFeatureStoreDataStorage, callback: () => void): void { - this.wrapper.init(allData, callback); + this._wrapper.init(allData, callback); } delete(kind: interfaces.DataKind, key: string, version: number, callback: () => void): void { - this.wrapper.delete(kind, key, version, callback); + this._wrapper.delete(kind, key, version, callback); } upsert(kind: interfaces.DataKind, data: LDKeyedFeatureStoreItem, callback: () => void): void { - this.wrapper.upsert(kind, data, callback); + this._wrapper.upsert(kind, data, callback); } initialized(callback: (isInitialized: boolean) => void): void { - this.wrapper.initialized(callback); + this._wrapper.initialized(callback); } close(): void { - this.wrapper.close(); + this._wrapper.close(); } getDescription?(): string { - return this.wrapper.getDescription(); + return this._wrapper.getDescription(); } } diff --git a/packages/store/node-server-sdk-redis/CHANGELOG.md b/packages/store/node-server-sdk-redis/CHANGELOG.md index 7b7c4ee9b..bbd775b63 100644 --- a/packages/store/node-server-sdk-redis/CHANGELOG.md +++ b/packages/store/node-server-sdk-redis/CHANGELOG.md @@ -90,6 +90,66 @@ * devDependencies * @launchdarkly/node-server-sdk bumped from 9.2.1 to 9.2.2 +## [4.2.0](https://github.com/launchdarkly/js-core/compare/node-server-sdk-redis-v4.1.23...node-server-sdk-redis-v4.2.0) (2024-10-17) + + +### Features + +* Apply private property naming standard. Mangle browser private properties. ([#620](https://github.com/launchdarkly/js-core/issues/620)) ([3e6d404](https://github.com/launchdarkly/js-core/commit/3e6d404ae665c5cc7e5a1394a59c8f2c9d5d682a)) + + +### Dependencies + +* The following workspace dependencies were updated + * devDependencies + * @launchdarkly/node-server-sdk bumped from 9.6.1 to 9.7.0 + * peerDependencies + * @launchdarkly/node-server-sdk bumped from >=9.4.3 to >=9.7.0 + +## [4.1.23](https://github.com/launchdarkly/js-core/compare/node-server-sdk-redis-v4.1.22...node-server-sdk-redis-v4.1.23) (2024-10-09) + + +### Dependencies + +* The following workspace dependencies were updated + * devDependencies + * @launchdarkly/node-server-sdk bumped from 9.6.0 to 9.6.1 + * peerDependencies + * @launchdarkly/node-server-sdk bumped from >=9.4.3 to >=9.6.1 + +## [4.1.22](https://github.com/launchdarkly/js-core/compare/node-server-sdk-redis-v4.1.21...node-server-sdk-redis-v4.1.22) (2024-09-26) + + +### Dependencies + +* The following workspace dependencies were updated + * devDependencies + * @launchdarkly/node-server-sdk bumped from 9.5.4 to 9.6.0 + * peerDependencies + * @launchdarkly/node-server-sdk bumped from >=9.4.3 to >=9.6.0 + +## [4.1.21](https://github.com/launchdarkly/js-core/compare/node-server-sdk-redis-v4.1.20...node-server-sdk-redis-v4.1.21) (2024-09-05) + + +### Dependencies + +* The following workspace dependencies were updated + * devDependencies + * @launchdarkly/node-server-sdk bumped from 9.5.3 to 9.5.4 + * peerDependencies + * @launchdarkly/node-server-sdk bumped from >=9.4.3 to >=9.5.4 + +## [4.1.20](https://github.com/launchdarkly/js-core/compare/node-server-sdk-redis-v4.1.19...node-server-sdk-redis-v4.1.20) (2024-09-03) + + +### Dependencies + +* The following workspace dependencies were updated + * devDependencies + * @launchdarkly/node-server-sdk bumped from 9.5.2 to 9.5.3 + * peerDependencies + * @launchdarkly/node-server-sdk bumped from >=9.4.3 to >=9.5.3 + ## [4.1.19](https://github.com/launchdarkly/js-core/compare/node-server-sdk-redis-v4.1.18...node-server-sdk-redis-v4.1.19) (2024-08-28) diff --git a/packages/store/node-server-sdk-redis/__tests__/RedisCore.test.ts b/packages/store/node-server-sdk-redis/__tests__/RedisCore.test.ts index fffb1151d..4a8a940ba 100644 --- a/packages/store/node-server-sdk-redis/__tests__/RedisCore.test.ts +++ b/packages/store/node-server-sdk-redis/__tests__/RedisCore.test.ts @@ -26,23 +26,23 @@ type UpsertResult = { }; class AsyncCoreFacade { - constructor(private readonly core: RedisCore) {} + constructor(private readonly _core: RedisCore) {} init(allData: interfaces.KindKeyedStore): Promise { - return promisify((cb) => this.core.init(allData, cb)); + return promisify((cb) => this._core.init(allData, cb)); } get( kind: interfaces.PersistentStoreDataKind, key: string, ): Promise { - return promisify((cb) => this.core.get(kind, key, cb)); + return promisify((cb) => this._core.get(kind, key, cb)); } getAll( kind: interfaces.PersistentStoreDataKind, ): Promise[] | undefined> { - return promisify((cb) => this.core.getAll(kind, cb)); + return promisify((cb) => this._core.getAll(kind, cb)); } upsert( @@ -51,22 +51,22 @@ class AsyncCoreFacade { descriptor: interfaces.SerializedItemDescriptor, ): Promise { return new Promise((resolve) => { - this.core.upsert(kind, key, descriptor, (err, updatedDescriptor) => { + this._core.upsert(kind, key, descriptor, (err, updatedDescriptor) => { resolve({ err, updatedDescriptor }); }); }); } initialized(): Promise { - return promisify((cb) => this.core.initialized(cb)); + return promisify((cb) => this._core.initialized(cb)); } close(): void { - this.core.close(); + this._core.close(); } getDescription(): string { - return this.core.getDescription(); + return this._core.getDescription(); } } diff --git a/packages/store/node-server-sdk-redis/package.json b/packages/store/node-server-sdk-redis/package.json index 8f5d97000..68cf7bffb 100644 --- a/packages/store/node-server-sdk-redis/package.json +++ b/packages/store/node-server-sdk-redis/package.json @@ -1,6 +1,6 @@ { "name": "@launchdarkly/node-server-sdk-redis", - "version": "4.1.19", + "version": "4.2.0", "description": "Redis-backed feature store for the LaunchDarkly Server-Side SDK for Node.js", "homepage": "https://github.com/launchdarkly/js-core/tree/main/packages/store/node-server-sdk-redis", "repository": { @@ -33,7 +33,7 @@ "@launchdarkly/node-server-sdk": ">=9.4.3" }, "devDependencies": { - "@launchdarkly/node-server-sdk": "9.5.2", + "@launchdarkly/node-server-sdk": "9.7.0", "@trivago/prettier-plugin-sort-imports": "^4.1.1", "@types/jest": "^29.4.0", "@typescript-eslint/eslint-plugin": "^6.20.0", diff --git a/packages/store/node-server-sdk-redis/src/RedisBigSegmentStore.ts b/packages/store/node-server-sdk-redis/src/RedisBigSegmentStore.ts index 8934bd2ba..f6d3bf75e 100644 --- a/packages/store/node-server-sdk-redis/src/RedisBigSegmentStore.ts +++ b/packages/store/node-server-sdk-redis/src/RedisBigSegmentStore.ts @@ -19,19 +19,16 @@ export const KEY_USER_INCLUDE = 'big_segment_include'; export const KEY_USER_EXCLUDE = 'big_segment_exclude'; export default class RedisBigSegmentStore implements interfaces.BigSegmentStore { - private state: RedisClientState; + private _state: RedisClientState; // Logger is not currently used, but is included to reduce the chance of a // compatibility break to add a log. - constructor( - options?: LDRedisOptions, - private readonly logger?: LDLogger, - ) { - this.state = new RedisClientState(options); + constructor(options?: LDRedisOptions, _logger?: LDLogger) { + this._state = new RedisClientState(options); } async getMetadata(): Promise { - const value = await this.state.getClient().get(this.state.prefixedKey(KEY_LAST_SYNCHRONIZED)); + const value = await this._state.getClient().get(this._state.prefixedKey(KEY_LAST_SYNCHRONIZED)); // Value will be true if it is a string containing any characters, which is fine // for this check. if (value) { @@ -43,12 +40,12 @@ export default class RedisBigSegmentStore implements interfaces.BigSegmentStore async getUserMembership( userHash: string, ): Promise { - const includedRefs = await this.state + const includedRefs = await this._state .getClient() - .smembers(this.state.prefixedKey(`${KEY_USER_INCLUDE}:${userHash}`)); - const excludedRefs = await this.state + .smembers(this._state.prefixedKey(`${KEY_USER_INCLUDE}:${userHash}`)); + const excludedRefs = await this._state .getClient() - .smembers(this.state.prefixedKey(`${KEY_USER_EXCLUDE}:${userHash}`)); + .smembers(this._state.prefixedKey(`${KEY_USER_EXCLUDE}:${userHash}`)); // If there are no included/excluded refs, the don't return any membership. if ((!includedRefs || !includedRefs.length) && (!excludedRefs || !excludedRefs.length)) { @@ -68,6 +65,6 @@ export default class RedisBigSegmentStore implements interfaces.BigSegmentStore } close(): void { - this.state.close(); + this._state.close(); } } diff --git a/packages/store/node-server-sdk-redis/src/RedisClientState.ts b/packages/store/node-server-sdk-redis/src/RedisClientState.ts index 34332f203..9701fd3a6 100644 --- a/packages/store/node-server-sdk-redis/src/RedisClientState.ts +++ b/packages/store/node-server-sdk-redis/src/RedisClientState.ts @@ -14,17 +14,17 @@ const DEFAULT_PREFIX = 'launchdarkly'; * @internal */ export default class RedisClientState { - private connected: boolean = false; + private _connected: boolean = false; - private attempt: number = 0; + private _attempt: number = 0; - private initialConnection: boolean = true; + private _initialConnection: boolean = true; - private readonly client: Redis; + private readonly _client: Redis; - private readonly owned: boolean; + private readonly _owned: boolean; - private readonly base_prefix: string; + private readonly _basePrefix: string; /** * Construct a state with the given client. @@ -35,52 +35,52 @@ export default class RedisClientState { */ constructor( options?: LDRedisOptions, - private readonly logger?: LDLogger, + private readonly _logger?: LDLogger, ) { if (options?.client) { - this.client = options.client; - this.owned = false; + this._client = options.client; + this._owned = false; } else if (options?.redisOpts) { - this.client = new Redis(options.redisOpts); - this.owned = true; + this._client = new Redis(options.redisOpts); + this._owned = true; } else { - this.client = new Redis(); - this.owned = true; + this._client = new Redis(); + this._owned = true; } - this.base_prefix = options?.prefix || DEFAULT_PREFIX; + this._basePrefix = options?.prefix || DEFAULT_PREFIX; // If the client is not owned, then it should already be connected. - this.connected = !this.owned; + this._connected = !this._owned; // We don't want to log a message on the first connection, only when reconnecting. - this.initialConnection = !this.connected; + this._initialConnection = !this._connected; - const { client } = this; + const { _client: client } = this; client.on('error', (err) => { - logger?.error(`Redis error - ${err}`); + _logger?.error(`Redis error - ${err}`); }); client.on('reconnecting', (delay: number) => { - this.attempt += 1; - logger?.info( - `Attempting to reconnect to redis (attempt # ${this.attempt}, delay: ${delay}ms)`, + this._attempt += 1; + _logger?.info( + `Attempting to reconnect to redis (attempt # ${this._attempt}, delay: ${delay}ms)`, ); }); client.on('connect', () => { - this.attempt = 0; + this._attempt = 0; - if (!this.initialConnection) { - this?.logger?.warn('Reconnecting to Redis'); + if (!this._initialConnection) { + this?._logger?.warn('Reconnecting to Redis'); } - this.initialConnection = false; - this.connected = true; + this._initialConnection = false; + this._connected = true; }); client.on('end', () => { - this.connected = false; + this._connected = false; }); } @@ -90,7 +90,7 @@ export default class RedisClientState { * @returns True if currently connected. */ isConnected(): boolean { - return this.connected; + return this._connected; } /** @@ -99,7 +99,7 @@ export default class RedisClientState { * @returns True if using the initial connection. */ isInitialConnection(): boolean { - return this.initialConnection; + return this._initialConnection; } /** @@ -108,17 +108,17 @@ export default class RedisClientState { * @returns The redis client. */ getClient(): Redis { - return this.client; + return this._client; } /** * If the client is owned, then this will 'quit' the client. */ close() { - if (this.owned) { - this.client.quit().catch((err) => { + if (this._owned) { + this._client.quit().catch((err) => { // Not any action that can be taken for an error on quit. - this.logger?.debug('Error closing ioredis client:', err); + this._logger?.debug('Error closing ioredis client:', err); }); } } @@ -129,6 +129,6 @@ export default class RedisClientState { * @returns The prefixed key. */ prefixedKey(key: string): string { - return `${this.base_prefix}:${key}`; + return `${this._basePrefix}:${key}`; } } diff --git a/packages/store/node-server-sdk-redis/src/RedisCore.ts b/packages/store/node-server-sdk-redis/src/RedisCore.ts index 1c3c30944..853540a18 100644 --- a/packages/store/node-server-sdk-redis/src/RedisCore.ts +++ b/packages/store/node-server-sdk-redis/src/RedisCore.ts @@ -24,25 +24,25 @@ import RedisClientState from './RedisClientState'; * @internal */ export default class RedisCore implements interfaces.PersistentDataStore { - private initedKey: string; + private _initedKey: string; constructor( - private readonly state: RedisClientState, - private readonly logger?: LDLogger, + private readonly _state: RedisClientState, + private readonly _logger?: LDLogger, ) { - this.initedKey = this.state.prefixedKey('$inited'); + this._initedKey = this._state.prefixedKey('$inited'); } init( allData: interfaces.KindKeyedStore, callback: () => void, ): void { - const multi = this.state.getClient().multi(); + const multi = this._state.getClient().multi(); allData.forEach((keyedItems) => { const kind = keyedItems.key; const items = keyedItems.item; - const namespaceKey = this.state.prefixedKey(kind.namespace); + const namespaceKey = this._state.prefixedKey(kind.namespace); // Delete the namespace for the kind. multi.del(namespaceKey); @@ -60,11 +60,11 @@ export default class RedisCore implements interfaces.PersistentDataStore { } }); - multi.set(this.initedKey, ''); + multi.set(this._initedKey, ''); multi.exec((err) => { if (err) { - this.logger?.error(`Error initializing Redis store ${err}`); + this._logger?.error(`Error initializing Redis store ${err}`); } callback(); }); @@ -75,15 +75,15 @@ export default class RedisCore implements interfaces.PersistentDataStore { key: string, callback: (descriptor: interfaces.SerializedItemDescriptor | undefined) => void, ): void { - if (!this.state.isConnected() && !this.state.isInitialConnection()) { - this.logger?.warn(`Attempted to fetch key '${key}' while Redis connection is down`); + if (!this._state.isConnected() && !this._state.isInitialConnection()) { + this._logger?.warn(`Attempted to fetch key '${key}' while Redis connection is down`); callback(undefined); return; } - this.state.getClient().hget(this.state.prefixedKey(kind.namespace), key, (err, val) => { + this._state.getClient().hget(this._state.prefixedKey(kind.namespace), key, (err, val) => { if (err) { - this.logger?.error(`Error fetching key '${key}' from Redis in '${kind.namespace}' ${err}`); + this._logger?.error(`Error fetching key '${key}' from Redis in '${kind.namespace}' ${err}`); callback(undefined); } else if (val) { // When getting we do not populate version and deleted. @@ -105,15 +105,15 @@ export default class RedisCore implements interfaces.PersistentDataStore { descriptors: interfaces.KeyedItem[] | undefined, ) => void, ): void { - if (!this.state.isConnected() && !this.state.isInitialConnection()) { - this.logger?.warn('Attempted to fetch all keys while Redis connection is down'); + if (!this._state.isConnected() && !this._state.isInitialConnection()) { + this._logger?.warn('Attempted to fetch all keys while Redis connection is down'); callback(undefined); return; } - this.state.getClient().hgetall(this.state.prefixedKey(kind.namespace), (err, values) => { + this._state.getClient().hgetall(this._state.prefixedKey(kind.namespace), (err, values) => { if (err) { - this.logger?.error(`Error fetching '${kind.namespace}' from Redis ${err}`); + this._logger?.error(`Error fetching '${kind.namespace}' from Redis ${err}`); } else if (values) { const results: interfaces.KeyedItem[] = []; Object.keys(values).forEach((key) => { @@ -140,8 +140,8 @@ export default class RedisCore implements interfaces.PersistentDataStore { ): void { // The persistent store wrapper manages interactions with a queue, so we can use watch like // this without concerns for overlapping transactions. - this.state.getClient().watch(this.state.prefixedKey(kind.namespace)); - const multi = this.state.getClient().multi(); + this._state.getClient().watch(this._state.prefixedKey(kind.namespace)); + const multi = this._state.getClient().multi(); this.get(kind, key, (old) => { if (old?.serializedItem) { @@ -163,23 +163,23 @@ export default class RedisCore implements interfaces.PersistentDataStore { } if (descriptor.deleted) { multi.hset( - this.state.prefixedKey(kind.namespace), + this._state.prefixedKey(kind.namespace), key, JSON.stringify({ version: descriptor.version, deleted: true }), ); } else if (descriptor.serializedItem) { - multi.hset(this.state.prefixedKey(kind.namespace), key, descriptor.serializedItem); + multi.hset(this._state.prefixedKey(kind.namespace), key, descriptor.serializedItem); } else { // This call violates the contract. multi.discard(); - this.logger?.error('Attempt to write a non-deleted item without data to Redis.'); + this._logger?.error('Attempt to write a non-deleted item without data to Redis.'); callback(undefined, undefined); return; } multi.exec((err, replies) => { if (!err && (replies === null || replies === undefined)) { // This means the EXEC failed because someone modified the watched key - this.logger?.debug('Concurrent modification detected, retrying'); + this._logger?.debug('Concurrent modification detected, retrying'); this.upsert(kind, key, descriptor, callback); } else { callback(err || undefined, descriptor); @@ -189,7 +189,7 @@ export default class RedisCore implements interfaces.PersistentDataStore { } initialized(callback: (isInitialized: boolean) => void): void { - this.state.getClient().exists(this.initedKey, (err, count) => { + this._state.getClient().exists(this._initedKey, (err, count) => { // Initialized if there is not an error and the key does exists. // (A count >= 1) callback(!!(!err && count)); @@ -197,7 +197,7 @@ export default class RedisCore implements interfaces.PersistentDataStore { } close(): void { - this.state.close(); + this._state.close(); } getDescription(): string { diff --git a/packages/store/node-server-sdk-redis/src/RedisFeatureStore.ts b/packages/store/node-server-sdk-redis/src/RedisFeatureStore.ts index 450328e61..c01b5d752 100644 --- a/packages/store/node-server-sdk-redis/src/RedisFeatureStore.ts +++ b/packages/store/node-server-sdk-redis/src/RedisFeatureStore.ts @@ -18,13 +18,10 @@ import TtlFromOptions from './TtlFromOptions'; * Integration between the LaunchDarkly SDK and Redis. */ export default class RedisFeatureStore implements LDFeatureStore { - private wrapper: PersistentDataStoreWrapper; + private _wrapper: PersistentDataStoreWrapper; - constructor( - options?: LDRedisOptions, - private readonly logger?: LDLogger, - ) { - this.wrapper = new PersistentDataStoreWrapper( + constructor(options?: LDRedisOptions, logger?: LDLogger) { + this._wrapper = new PersistentDataStoreWrapper( new RedisCore(new RedisClientState(options, logger), logger), TtlFromOptions(options), ); @@ -35,34 +32,34 @@ export default class RedisFeatureStore implements LDFeatureStore { key: string, callback: (res: LDFeatureStoreItem | null) => void, ): void { - this.wrapper.get(kind, key, callback); + this._wrapper.get(kind, key, callback); } all(kind: interfaces.DataKind, callback: (res: LDFeatureStoreKindData) => void): void { - this.wrapper.all(kind, callback); + this._wrapper.all(kind, callback); } init(allData: LDFeatureStoreDataStorage, callback: () => void): void { - this.wrapper.init(allData, callback); + this._wrapper.init(allData, callback); } delete(kind: interfaces.DataKind, key: string, version: number, callback: () => void): void { - this.wrapper.delete(kind, key, version, callback); + this._wrapper.delete(kind, key, version, callback); } upsert(kind: interfaces.DataKind, data: LDKeyedFeatureStoreItem, callback: () => void): void { - this.wrapper.upsert(kind, data, callback); + this._wrapper.upsert(kind, data, callback); } initialized(callback: (isInitialized: boolean) => void): void { - this.wrapper.initialized(callback); + this._wrapper.initialized(callback); } close(): void { - this.wrapper.close(); + this._wrapper.close(); } getDescription?(): string { - return this.wrapper.getDescription(); + return this._wrapper.getDescription(); } } diff --git a/packages/telemetry/node-server-sdk-otel/CHANGELOG.md b/packages/telemetry/node-server-sdk-otel/CHANGELOG.md index e22857e3e..eaf0346bc 100644 --- a/packages/telemetry/node-server-sdk-otel/CHANGELOG.md +++ b/packages/telemetry/node-server-sdk-otel/CHANGELOG.md @@ -1,5 +1,65 @@ # Changelog +## [1.1.0](https://github.com/launchdarkly/js-core/compare/node-server-sdk-otel-v1.0.15...node-server-sdk-otel-v1.1.0) (2024-10-17) + + +### Features + +* Apply private property naming standard. Mangle browser private properties. ([#620](https://github.com/launchdarkly/js-core/issues/620)) ([3e6d404](https://github.com/launchdarkly/js-core/commit/3e6d404ae665c5cc7e5a1394a59c8f2c9d5d682a)) + + +### Dependencies + +* The following workspace dependencies were updated + * devDependencies + * @launchdarkly/node-server-sdk bumped from 9.6.1 to 9.7.0 + * peerDependencies + * @launchdarkly/node-server-sdk bumped from >=9.4.3 to >=9.7.0 + +## [1.0.15](https://github.com/launchdarkly/js-core/compare/node-server-sdk-otel-v1.0.14...node-server-sdk-otel-v1.0.15) (2024-10-09) + + +### Dependencies + +* The following workspace dependencies were updated + * devDependencies + * @launchdarkly/node-server-sdk bumped from 9.6.0 to 9.6.1 + * peerDependencies + * @launchdarkly/node-server-sdk bumped from >=9.4.3 to >=9.6.1 + +## [1.0.14](https://github.com/launchdarkly/js-core/compare/node-server-sdk-otel-v1.0.13...node-server-sdk-otel-v1.0.14) (2024-09-26) + + +### Dependencies + +* The following workspace dependencies were updated + * devDependencies + * @launchdarkly/node-server-sdk bumped from 9.5.4 to 9.6.0 + * peerDependencies + * @launchdarkly/node-server-sdk bumped from >=9.4.3 to >=9.6.0 + +## [1.0.13](https://github.com/launchdarkly/js-core/compare/node-server-sdk-otel-v1.0.12...node-server-sdk-otel-v1.0.13) (2024-09-05) + + +### Dependencies + +* The following workspace dependencies were updated + * devDependencies + * @launchdarkly/node-server-sdk bumped from 9.5.3 to 9.5.4 + * peerDependencies + * @launchdarkly/node-server-sdk bumped from >=9.4.3 to >=9.5.4 + +## [1.0.12](https://github.com/launchdarkly/js-core/compare/node-server-sdk-otel-v1.0.11...node-server-sdk-otel-v1.0.12) (2024-09-03) + + +### Dependencies + +* The following workspace dependencies were updated + * devDependencies + * @launchdarkly/node-server-sdk bumped from 9.5.2 to 9.5.3 + * peerDependencies + * @launchdarkly/node-server-sdk bumped from >=9.4.3 to >=9.5.3 + ## [1.0.11](https://github.com/launchdarkly/js-core/compare/node-server-sdk-otel-v1.0.10...node-server-sdk-otel-v1.0.11) (2024-08-28) diff --git a/packages/telemetry/node-server-sdk-otel/src/TracingHook.test.ts b/packages/telemetry/node-server-sdk-otel/__tests__/TracingHook.test.ts similarity index 99% rename from packages/telemetry/node-server-sdk-otel/src/TracingHook.test.ts rename to packages/telemetry/node-server-sdk-otel/__tests__/TracingHook.test.ts index 7438fe9c3..edc110b1e 100644 --- a/packages/telemetry/node-server-sdk-otel/src/TracingHook.test.ts +++ b/packages/telemetry/node-server-sdk-otel/__tests__/TracingHook.test.ts @@ -4,7 +4,7 @@ import { InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-tr import { basicLogger, init, integrations } from '@launchdarkly/node-server-sdk'; -import TracingHook from './TracingHook'; +import TracingHook from '../src/TracingHook'; const spanExporter = new InMemorySpanExporter(); const sdk = new NodeSDK({ diff --git a/packages/telemetry/node-server-sdk-otel/package.json b/packages/telemetry/node-server-sdk-otel/package.json index a95c257ec..235bb5f05 100644 --- a/packages/telemetry/node-server-sdk-otel/package.json +++ b/packages/telemetry/node-server-sdk-otel/package.json @@ -1,6 +1,6 @@ { "name": "@launchdarkly/node-server-sdk-otel", - "version": "1.0.11", + "version": "1.1.0", "type": "commonjs", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -33,8 +33,7 @@ "@opentelemetry/api": ">=1.3.0" }, "devDependencies": { - "@launchdarkly/node-server-sdk": "9.5.2", - "@launchdarkly/private-js-mocks": "0.0.1", + "@launchdarkly/node-server-sdk": "9.7.0", "@opentelemetry/api": ">=1.3.0", "@opentelemetry/sdk-node": "0.49.1", "@opentelemetry/sdk-trace-node": "1.22.0", diff --git a/packages/telemetry/node-server-sdk-otel/src/TracingHook.ts b/packages/telemetry/node-server-sdk-otel/src/TracingHook.ts index 82aade9ff..087464c5c 100644 --- a/packages/telemetry/node-server-sdk-otel/src/TracingHook.ts +++ b/packages/telemetry/node-server-sdk-otel/src/TracingHook.ts @@ -106,8 +106,8 @@ function validateOptions(options?: TracingHookOptions): ValidatedHookOptions { * (LaunchDarkly), and the key of the flag being evaluated. */ export default class TracingHook implements integrations.Hook { - private readonly options: ValidatedHookOptions; - private readonly tracer = trace.getTracer('launchdarkly-client'); + private readonly _options: ValidatedHookOptions; + private readonly _tracer = trace.getTracer('launchdarkly-client'); /** * Construct a TracingHook with the given options. @@ -115,7 +115,7 @@ export default class TracingHook implements integrations.Hook { * @param options Options to customize tracing behavior. */ constructor(options?: TracingHookOptions) { - this.options = validateOptions(options); + this._options = validateOptions(options); } /** @@ -134,10 +134,10 @@ export default class TracingHook implements integrations.Hook { hookContext: integrations.EvaluationSeriesContext, data: integrations.EvaluationSeriesData, ): integrations.EvaluationSeriesData { - if (this.options.spans) { + if (this._options.spans) { const { canonicalKey } = Context.fromLDContext(hookContext.context); - const span = this.tracer.startSpan(hookContext.method, undefined, context.active()); + const span = this._tracer.startSpan(hookContext.method, undefined, context.active()); span.setAttribute('feature_flag.context.key', canonicalKey); span.setAttribute('feature_flag.key', hookContext.flagKey); @@ -163,7 +163,7 @@ export default class TracingHook implements integrations.Hook { [FEATURE_FLAG_PROVIDER_ATTR]: 'LaunchDarkly', [FEATURE_FLAG_CONTEXT_KEY_ATTR]: Context.fromLDContext(hookContext.context).canonicalKey, }; - if (this.options.includeVariant) { + if (this._options.includeVariant) { eventAttributes[FEATURE_FLAG_VARIANT_ATTR] = JSON.stringify(detail.value); } currentTrace.addEvent(FEATURE_FLAG_SCOPE, eventAttributes); diff --git a/packages/tooling/jest/example/react-native-example/.gitignore b/packages/tooling/jest/example/react-native-example/.gitignore new file mode 100644 index 000000000..78c70b32e --- /dev/null +++ b/packages/tooling/jest/example/react-native-example/.gitignore @@ -0,0 +1,38 @@ +# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files + +# dependencies +node_modules/ + +# Expo +.expo/ +dist/ +web-build/ + +# Native +*.orig.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision + +# Metro +.metro-health-check* + +# debug +npm-debug.* +yarn-debug.* +yarn-error.* + +# macOS +.DS_Store +*.pem + +# local env files +.env*.local + +# typescript +*.tsbuildinfo + +# vscode +.vscode \ No newline at end of file diff --git a/packages/tooling/jest/example/react-native-example/App.tsx b/packages/tooling/jest/example/react-native-example/App.tsx new file mode 100644 index 000000000..8521ffd2c --- /dev/null +++ b/packages/tooling/jest/example/react-native-example/App.tsx @@ -0,0 +1,36 @@ +import { StyleSheet } from 'react-native'; +import { + AutoEnvAttributes, + LDProvider, + ReactNativeLDClient, + LDOptions, +} from '@launchdarkly/react-native-client-sdk'; +import Welcome from './src/welcome'; + +const options: LDOptions = { + debug: true, +} +//TODO Set MOBILE_KEY in .env file to a mobile key in your project/environment. +const MOBILE_KEY = 'YOUR_MOBILE_KEY'; +const featureClient = new ReactNativeLDClient(MOBILE_KEY, AutoEnvAttributes.Enabled, options); + +const userContext = { kind: 'user', key: '', anonymous: true }; + +export default function App() { + featureClient.identify(userContext).catch((e: any) => console.log(e)); + + return ( + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#fff', + alignItems: 'center', + justifyContent: 'center', + }, +}); diff --git a/packages/tooling/jest/example/react-native-example/app.json b/packages/tooling/jest/example/react-native-example/app.json new file mode 100644 index 000000000..c33b70a74 --- /dev/null +++ b/packages/tooling/jest/example/react-native-example/app.json @@ -0,0 +1,29 @@ +{ + "expo": { + "name": "react-native-jest-example", + "slug": "react-native-jest-example", + "version": "0.0.1", + "orientation": "portrait", + "icon": "./assets/icon.png", + "userInterfaceStyle": "light", + "splash": { + "image": "./assets/splash.png", + "resizeMode": "contain", + "backgroundColor": "#ffffff" + }, + "ios": { + "supportsTablet": true, + "bundleIdentifier": "com.anonymous.reactnativejestexample" + }, + "android": { + "adaptiveIcon": { + "foregroundImage": "./assets/adaptive-icon.png", + "backgroundColor": "#ffffff" + }, + "package": "com.anonymous.reactnativejestexample" + }, + "web": { + "favicon": "./assets/favicon.png" + } + } +} diff --git a/packages/tooling/jest/example/react-native-example/assets/adaptive-icon.png b/packages/tooling/jest/example/react-native-example/assets/adaptive-icon.png new file mode 100644 index 000000000..03d6f6b6c Binary files /dev/null and b/packages/tooling/jest/example/react-native-example/assets/adaptive-icon.png differ diff --git a/packages/tooling/jest/example/react-native-example/assets/favicon.png b/packages/tooling/jest/example/react-native-example/assets/favicon.png new file mode 100644 index 000000000..e75f697b1 Binary files /dev/null and b/packages/tooling/jest/example/react-native-example/assets/favicon.png differ diff --git a/packages/tooling/jest/example/react-native-example/assets/icon.png b/packages/tooling/jest/example/react-native-example/assets/icon.png new file mode 100644 index 000000000..a0b1526fc Binary files /dev/null and b/packages/tooling/jest/example/react-native-example/assets/icon.png differ diff --git a/packages/tooling/jest/example/react-native-example/assets/splash.png b/packages/tooling/jest/example/react-native-example/assets/splash.png new file mode 100644 index 000000000..0e89705a9 Binary files /dev/null and b/packages/tooling/jest/example/react-native-example/assets/splash.png differ diff --git a/packages/tooling/jest/example/react-native-example/babel.config.js b/packages/tooling/jest/example/react-native-example/babel.config.js new file mode 100644 index 000000000..28dcb83ba --- /dev/null +++ b/packages/tooling/jest/example/react-native-example/babel.config.js @@ -0,0 +1,7 @@ +module.exports = function (api) { + api.cache(true); + return { + presets: ['babel-preset-expo', '@babel/preset-typescript'], + + }; +}; diff --git a/packages/tooling/jest/example/react-native-example/index.js b/packages/tooling/jest/example/react-native-example/index.js new file mode 100644 index 000000000..202e3f47d --- /dev/null +++ b/packages/tooling/jest/example/react-native-example/index.js @@ -0,0 +1,10 @@ +// We have to use a custom entrypoint for monorepo workspaces to work. +// https://docs.expo.dev/guides/monorepos/#change-default-entrypoint +import { registerRootComponent } from 'expo'; + +import App from './App'; + +// registerRootComponent calls AppRegistry.registerComponent('main', () => App); +// It also ensures that whether you load the app in Expo Go or in a native build, +// the environment is set up appropriately +registerRootComponent(App); diff --git a/packages/tooling/jest/example/react-native-example/jest.config.js b/packages/tooling/jest/example/react-native-example/jest.config.js new file mode 100644 index 000000000..5fcf170e8 --- /dev/null +++ b/packages/tooling/jest/example/react-native-example/jest.config.js @@ -0,0 +1,4 @@ +module.exports = { + preset: 'jest-expo', + setupFiles: ['@launchdarkly/jest/react-native'], +}; diff --git a/packages/tooling/jest/example/react-native-example/metro.config.js b/packages/tooling/jest/example/react-native-example/metro.config.js new file mode 100644 index 000000000..8dd286e02 --- /dev/null +++ b/packages/tooling/jest/example/react-native-example/metro.config.js @@ -0,0 +1,28 @@ +// We need to use a custom metro config for monorepo workspaces to work. +// https://docs.expo.dev/guides/monorepos/#modify-the-metro-config +/** + * @type {import('expo/metro-config')} + */ +const { getDefaultConfig } = require('expo/metro-config'); +const path = require('path'); + +// Find the project and workspace directories +const projectRoot = __dirname; + +const findWorkspaceRoot = require('find-yarn-workspace-root'); + +const workspaceRoot = findWorkspaceRoot(__dirname); // Absolute path or null + +const config = getDefaultConfig(projectRoot); + +// 1. Watch all files within the monorepo +config.watchFolders = [workspaceRoot]; +// 2. Let Metro know where to resolve packages and in what order +config.resolver.nodeModulesPaths = [ + path.resolve(projectRoot, 'node_modules'), + path.resolve(workspaceRoot, 'node_modules'), +]; +// 3. Force Metro to resolve (sub)dependencies only from the `nodeModulesPaths` +config.resolver.disableHierarchicalLookup = true; + +module.exports = config; diff --git a/packages/tooling/jest/example/react-native-example/package.json b/packages/tooling/jest/example/react-native-example/package.json new file mode 100644 index 000000000..a2898a7d1 --- /dev/null +++ b/packages/tooling/jest/example/react-native-example/package.json @@ -0,0 +1,40 @@ +{ + "name": "react-native-jest-example", + "version": "0.0.1", + "main": "index.js", + "scripts": { + "start": "expo start", + "android": "expo run:android", + "ios": "expo run:ios", + "test": "jest", + "clean": "rm -rf node_modules && rm -rf package-lock.json && rm -rf yarn.lock" + }, + "dependencies": { + "@launchdarkly/react-native-client-sdk": "^10.9.0", + "expo": "^51.0.26", + "expo-status-bar": "~1.12.1", + "find-yarn-workspace-root": "^2.0.0", + "react": "^18.2.0", + "react-native": "0.74.5" + }, + "devDependencies": { + "@babel/core": "^7.20.0", + "@babel/preset-env": "^7.25.4", + "@babel/preset-react": "^7.24.7", + "@babel/runtime": "^7.25.4", + "@launchdarkly/jest": "workspace:^", + "@react-native/babel-preset": "^0.75.2", + "@testing-library/react-native": "^12.6.1", + "@types/jest": "^29.5.12", + "@types/react": "~18.2.45", + "jest": "^29.7.0", + "jest-expo": "^51.0.4", + "react-test-renderer": "^18.2.0", + "typescript": "^5.1.3" + }, + "packageManager": "yarn@3.4.1", + "installConfig": { + "hoistingLimits": "workspaces" + }, + "private": true +} diff --git a/packages/tooling/jest/example/react-native-example/src/welcome.test.tsx b/packages/tooling/jest/example/react-native-example/src/welcome.test.tsx new file mode 100644 index 000000000..8bd9f4e21 --- /dev/null +++ b/packages/tooling/jest/example/react-native-example/src/welcome.test.tsx @@ -0,0 +1,29 @@ +/** + * @jest-environment jsdom + */ + +import { mockFlags, resetLDMocks } from '@launchdarkly/jest/react-native'; +import { screen, render } from '@testing-library/react-native'; +import { useLDClient } from '@launchdarkly/react-native-client-sdk'; +import Welcome from './welcome'; + +describe('Welcome component test', () => { + + afterEach(() => { + resetLDMocks(); + }); + + test('mock boolean flag correctly', () => { + mockFlags({ 'my-boolean-flag': true }); + render(); + expect(screen.getByText('Flag value is true')).toBeTruthy(); + }); + + test('mock ldClient correctly', () => { + const current = useLDClient(); + + current?.track('event'); + expect(current.track).toHaveBeenCalledTimes(1); + }); + +}); diff --git a/packages/tooling/jest/example/react-native-example/src/welcome.tsx b/packages/tooling/jest/example/react-native-example/src/welcome.tsx new file mode 100644 index 000000000..f167b11fc --- /dev/null +++ b/packages/tooling/jest/example/react-native-example/src/welcome.tsx @@ -0,0 +1,25 @@ +import { StyleSheet, Text, View } from 'react-native'; +import { useLDClient } from '@launchdarkly/react-native-client-sdk'; + +export default function Welcome() { + + const ldClient = useLDClient(); + + const flagValue = ldClient.boolVariation('my-boolean-flag', false); + + return ( + + Welcome to LaunchDarkly + Flag value is {`${flagValue}`} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#fff', + alignItems: 'center', + justifyContent: 'center', + }, +}); diff --git a/packages/tooling/jest/example/react-native-example/tsconfig.eslint.json b/packages/tooling/jest/example/react-native-example/tsconfig.eslint.json new file mode 100644 index 000000000..9101efe40 --- /dev/null +++ b/packages/tooling/jest/example/react-native-example/tsconfig.eslint.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "/**/*.ts", + "/**/*.tsx", + "/*.js", + "/*.tsx" + ], + "exclude": ["node_modules"] +} diff --git a/packages/tooling/jest/example/react-native-example/tsconfig.json b/packages/tooling/jest/example/react-native-example/tsconfig.json new file mode 100644 index 000000000..bb0ef71b7 --- /dev/null +++ b/packages/tooling/jest/example/react-native-example/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "expo/tsconfig.base", + "compilerOptions": { + "strict": true, + "moduleResolution": "bundler", + "jsx": "react-jsx" + } +} diff --git a/packages/tooling/jest/jest.config.json b/packages/tooling/jest/jest.config.json index 617480774..ddf54bb47 100644 --- a/packages/tooling/jest/jest.config.json +++ b/packages/tooling/jest/jest.config.json @@ -3,7 +3,7 @@ "testMatch": ["**/*.test.ts?(x)"], "testPathIgnorePatterns": ["node_modules", "example", "dist"], "modulePathIgnorePatterns": ["dist"], - "testEnvironment": "node", + "testEnvironment": "jsdom", "moduleFileExtensions": ["ts", "tsx", "js", "jsx", "json", "node"], "collectCoverageFrom": ["src/**/*.ts"] } diff --git a/packages/tooling/jest/package.json b/packages/tooling/jest/package.json index 088526942..1cb51dd22 100644 --- a/packages/tooling/jest/package.json +++ b/packages/tooling/jest/package.json @@ -55,9 +55,18 @@ "eslint-plugin-prettier": "^5.0.0", "jest": "^29.5.0", "prettier": "^3.0.0", + "react-test-renderer": "^18.3.1", "rimraf": "^5.0.1", "ts-jest": "^29.1.0", "typedoc": "0.25.0", "typescript": "5.1.6" + }, + "dependencies": { + "@launchdarkly/react-native-client-sdk": "~10.9.0", + "@testing-library/react-hooks": "^8.0.1", + "@testing-library/react-native": "^12.7.2", + "@types/lodash": "^4.17.7", + "launchdarkly-react-client-sdk": "^3.4.0", + "react": "^18.3.1" } } diff --git a/packages/tooling/jest/src/react-native/index.test.ts b/packages/tooling/jest/src/react-native/index.test.ts index 66cbbd832..bb37ecd1c 100644 --- a/packages/tooling/jest/src/react-native/index.test.ts +++ b/packages/tooling/jest/src/react-native/index.test.ts @@ -1,3 +1,72 @@ +import { + ldClientMock, + mockFlags, + mockLDProvider, + mockReactNativeLDClient, + mockUseLDClient, + resetLDMocks, +} from '.'; + describe('react-native', () => { - test.todo('Add react-native tests'); + afterEach(() => { + resetLDMocks(); + }); + + test('reset LD Mocks', () => { + const current = mockUseLDClient(); + + current?.track('event'); + expect(ldClientMock.track).toHaveBeenCalledTimes(1); + + resetLDMocks(); + expect(ldClientMock.track).toHaveBeenCalledTimes(0); + }); + + test('mock boolean flag correctly', () => { + mockFlags({ 'bool-flag': true }); + expect(ldClientMock.boolVariation).toBeDefined(); + }); + + test('mock number flag correctly', () => { + mockFlags({ 'number-flag': 42 }); + expect(ldClientMock.numberVariation).toBeDefined(); + }); + + test('mock string flag correctly', () => { + mockFlags({ 'string-flag': 'hello' }); + expect(ldClientMock.stringVariation).toBeDefined(); + }); + + test('mock json flag correctly', () => { + mockFlags({ 'json-flag': { key: 'value' } }); + expect(ldClientMock.jsonVariation).toBeDefined(); + }); + + test('mock LDProvider correctly', () => { + expect(mockLDProvider).toBeDefined(); + }); + + test('mock ReactNativeLDClient correctly', () => { + expect(mockReactNativeLDClient).toBeDefined(); + }); + + test('mock ldClient correctly', () => { + const current = mockUseLDClient(); + + current?.track('event'); + expect(ldClientMock.track).toHaveBeenCalledTimes(1); + }); + + test('mock ldClient complete set of methods correctly', () => { + expect(ldClientMock.identify).toBeDefined(); + expect(ldClientMock.allFlags.mock).toBeDefined(); + expect(ldClientMock.close.mock).toBeDefined(); + expect(ldClientMock.flush).toBeDefined(); + expect(ldClientMock.getContext.mock).toBeDefined(); + expect(ldClientMock.off.mock).toBeDefined(); + expect(ldClientMock.on.mock).toBeDefined(); + expect(ldClientMock.track.mock).toBeDefined(); + expect(ldClientMock.variation.mock).toBeDefined(); + expect(ldClientMock.variationDetail.mock).toBeDefined(); + }); }); diff --git a/packages/tooling/jest/src/react-native/index.ts b/packages/tooling/jest/src/react-native/index.ts index 07dd57aa0..f73555080 100644 --- a/packages/tooling/jest/src/react-native/index.ts +++ b/packages/tooling/jest/src/react-native/index.ts @@ -1,3 +1,109 @@ -jest.mock('@launchdarkly/react-client-sdk', () => { - // TODO: -}); +import { + LDClient, + LDFlagSet, + LDProvider, + ReactNativeLDClient, + useBoolVariation, + useJsonVariation, + useLDClient, + useNumberVariation, + useStringVariation, +} from '@launchdarkly/react-native-client-sdk'; + +jest.mock('@launchdarkly/react-native-client-sdk', () => ({ + LDFlagSet: jest.fn(() => ({})), + LDProvider: jest.fn().mockImplementation(({ children }) => children), + ReactNativeLDClient: jest.fn().mockImplementation(), + useLDClient: jest.fn().mockImplementation(), + useBoolVariation: jest.fn(), + useBoolVariationDetail: jest.fn(), + useNumberVariation: jest.fn(), + useNumberVariationDetail: jest.fn(), + useStringVariation: jest.fn(), + useStringVariationDetail: jest.fn(), + useJsonVariation: jest.fn(), + useJsonVariationDetail: jest.fn(), + useTypedVariation: jest.fn(), + useTypedVariationDetail: jest.fn(), +})); + +export const ldClientMock: jest.Mocked = { + allFlags: jest.fn(), + boolVariation: jest.fn(), + boolVariationDetail: jest.fn(), + close: jest.fn(), + flush: jest.fn(() => Promise.resolve({ result: true })), + // getConnectionMode: jest.fn(), + getContext: jest.fn(), + identify: jest.fn().mockResolvedValue(undefined), + jsonVariation: jest.fn(), + jsonVariationDetail: jest.fn(), + logger: { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }, + numberVariation: jest.fn(), + numberVariationDetail: jest.fn(), + off: jest.fn(), + on: jest.fn(), + // setConnectionMode: jest.fn(), + stringVariation: jest.fn(), + stringVariationDetail: jest.fn(), + track: jest.fn(), + variation: jest.fn(), + variationDetail: jest.fn(), + addHook: jest.fn(), +}; + +export const mockLDProvider = LDProvider as jest.Mock; +export const mockReactNativeLDClient = ReactNativeLDClient as jest.Mock; +export const mockUseLDClient = useLDClient as jest.Mock; + +const mockUseBoolVariation = useBoolVariation as jest.Mock; +const mockUseNumberVariation = useNumberVariation as jest.Mock; +const mockUseStringVariation = useStringVariation as jest.Mock; +const mockUseJsonVariation = useJsonVariation as jest.Mock; + +mockLDProvider.mockImplementation(({ children }) => children); +mockReactNativeLDClient.mockImplementation(() => ldClientMock); +mockUseLDClient.mockImplementation(() => ldClientMock); + +export const mockFlags = (flags: LDFlagSet): any => { + Object.keys(flags).forEach((key) => { + const defaultValue = flags[key]; + switch (typeof defaultValue) { + case 'boolean': + mockUseBoolVariation.mockImplementation((flagKey: string) => flags[flagKey] as boolean); + ldClientMock.boolVariation.mockImplementation( + (flagKey: string) => flags[flagKey] as boolean, + ); + break; + case 'number': + mockUseNumberVariation.mockImplementation((flagKey: string) => flags[flagKey] as number); + ldClientMock.numberVariation.mockImplementation( + (flagKey: string) => flags[flagKey] as number, + ); + break; + case 'string': + mockUseStringVariation.mockImplementation((flagKey: string) => flags[flagKey] as string); + ldClientMock.stringVariation.mockImplementation( + (flagKey: string) => flags[flagKey] as string, + ); + break; + case 'object': + mockUseJsonVariation.mockImplementation((flagKey: string) => flags[flagKey] as object); + ldClientMock.jsonVariation.mockImplementation( + (flagKey: string) => flags[flagKey] as object, + ); + break; + default: + break; + } + }); +}; + +export const resetLDMocks = () => { + jest.clearAllMocks(); +}; diff --git a/packages/tooling/jest/src/react/index.ts b/packages/tooling/jest/src/react/index.ts deleted file mode 100644 index 728a12ae4..000000000 --- a/packages/tooling/jest/src/react/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -jest.mock('@launchdarkly/react-native-client-sdk', () => { - // TODO: -}); diff --git a/packages/tooling/jest/tsconfig.eslint.json b/packages/tooling/jest/tsconfig.eslint.json index 56c9b3830..c46cca0b5 100644 --- a/packages/tooling/jest/tsconfig.eslint.json +++ b/packages/tooling/jest/tsconfig.eslint.json @@ -1,5 +1,5 @@ { "extends": "./tsconfig.json", - "include": ["/**/*.ts"], + "include": ["/**/*.ts", "/**/*.tsx", "/**/*.js"], "exclude": ["node_modules"] } diff --git a/release-please-config.json b/release-please-config.json index 9fd8fffd0..013be3c00 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -28,7 +28,10 @@ }, "packages/store/node-server-sdk-dynamodb": {}, "packages/store/node-server-sdk-redis": {}, - "packages/telemetry/node-server-sdk-otel": {} + "packages/telemetry/node-server-sdk-otel": {}, + "packages/sdk/browser": { + "bump-minor-pre-major": true + } }, "plugins": [ { diff --git a/tsconfig.json b/tsconfig.json index 02923fad6..e7ffa9fe3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,9 +7,6 @@ { "path": "./packages/shared/common/tsconfig.ref.json" }, - { - "path": "./packages/shared/mocks/tsconfig.ref.json" - }, { "path": "./packages/shared/sdk-client/tsconfig.ref.json" }, @@ -57,6 +54,12 @@ }, { "path": "./packages/sdk/browser/tsconfig.ref.json" + }, + { + "path": "./packages/sdk/browser/contract-tests/adapter/tsconfig.ref.json" + }, + { + "path": "./packages/sdk/browser/contract-tests/entity/tsconfig.ref.json" } ] }