├── .dockerignore ├── .eslintrc.js ├── .github ├── CODEOWNERS ├── dependabot.yml └── workflows │ ├── analyze-commits.yml │ ├── build.yml │ ├── coverage-pr.yml │ ├── coverage.yml │ ├── create-pr.yml │ ├── release.yml │ ├── teste2e-release.yml │ └── teste2e.yml ├── .gitignore ├── .husky ├── commit-msg ├── pre-commit └── pre-push ├── .node-version ├── .prettierrc ├── .releaserc.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── assets ├── favicon.ico └── license_banner.txt ├── commitlint.config.js ├── contributing.md ├── jest.config.js ├── package.json ├── playwright.config.ts ├── pnpm-lock.yaml ├── rollup.config.js ├── scripts └── downloadGitHubRelease.mjs ├── src ├── config.ts ├── env.ts ├── handler.ts ├── handlers │ ├── handleDownloadScript.ts │ ├── handleIngressAPI.ts │ ├── handleStatusPage.ts │ └── index.ts ├── index.ts └── utils │ ├── addProxyIntegrationHeaders.ts │ ├── addTrafficMonitoring.ts │ ├── cookie.ts │ ├── createErrorResponse.ts │ ├── createResponseWithMaxAge.ts │ ├── fetchCacheable.ts │ ├── getCacheControlHeaderWithMaxAgeIfLower.ts │ ├── index.ts │ ├── proxyEndpoint.ts │ ├── returnHttpResponse.ts │ └── routing.ts ├── test ├── e2e │ ├── utils │ │ ├── areVisitorIdAndRequestIdValid.ts │ │ ├── index.ts │ │ └── wait.ts │ └── visitorId.spec.ts ├── endpoints │ ├── __snapshots__ │ │ └── status.test.ts.snap │ ├── agentDownload.test.ts │ ├── getResult.test.ts │ └── status.test.ts ├── handleRequest │ └── handleRequestWithRoutes.test.ts └── utils │ ├── addProxyIntegrationHeaders.test.ts │ ├── addTrafficMonitoring.test.ts │ ├── cookie.test.ts │ ├── createErrorResponse.test.ts │ ├── getCacheControlHeaderWithMaxAgeIfLower.test.ts │ ├── proxyEndpoint.test.ts │ ├── returnHttpResponse.test.ts │ └── routing.test.ts ├── tsconfig.json └── wrangler.toml /.dockerignore: -------------------------------------------------------------------------------- 1 | # Project artifacts 2 | /dist/ 3 | /node_modules/ 4 | 5 | 6 | # Random excess stuff 7 | .DS_Store 8 | .idea/ 9 | .vscode/ 10 | yarn-error.log -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@fingerprintjs/eslint-config-dx-team'], 3 | ignorePatterns: ['dist/*'], 4 | } 5 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Users referenced in this file will automatically be requested as reviewers for PRs that modify the given paths. 2 | # See https://help.github.com/articles/about-code-owners/ 3 | 4 | * @necipallef @ilfa 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | open-pull-requests-limit: 0 8 | commit-message: 9 | prefix: "build(deps)" 10 | -------------------------------------------------------------------------------- /.github/workflows/analyze-commits.yml: -------------------------------------------------------------------------------- 1 | name: Analyze Commit Messages 2 | on: 3 | pull_request: 4 | 5 | permissions: 6 | pull-requests: write 7 | contents: write 8 | jobs: 9 | analyze-commits: 10 | name: Generate docs and coverage report 11 | uses: fingerprintjs/dx-team-toolkit/.github/workflows/analyze-commits.yml@v1 12 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Lint, build and test 2 | on: 3 | push: 4 | branches: 5 | - main 6 | - rc 7 | pull_request: 8 | jobs: 9 | build-and-check: 10 | name: Build project and run CI checks 11 | uses: fingerprintjs/dx-team-toolkit/.github/workflows/build-typescript-project.yml@v1 12 | -------------------------------------------------------------------------------- /.github/workflows/coverage-pr.yml: -------------------------------------------------------------------------------- 1 | name: Check coverage for PR 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | run-tests-check-coverage: 8 | name: Run tests & check coverage 9 | permissions: 10 | checks: write 11 | pull-requests: write 12 | uses: fingerprintjs/dx-team-toolkit/.github/workflows/coverage-diff.yml@v1 13 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yml: -------------------------------------------------------------------------------- 1 | name: Coverage 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | coverage-report: 10 | name: Coverage report 11 | uses: fingerprintjs/dx-team-toolkit/.github/workflows/docs-and-coverage.yml@v1 12 | with: 13 | skip-docs-step: true 14 | prepare-gh-pages-commands: | 15 | mv coverage/lcov-report/* ./gh-pages 16 | -------------------------------------------------------------------------------- /.github/workflows/create-pr.yml: -------------------------------------------------------------------------------- 1 | name: Create PR 2 | 3 | on: 4 | release: 5 | types: 6 | - published 7 | 8 | jobs: 9 | create-pr: 10 | name: Create PR 11 | uses: fingerprintjs/dx-team-toolkit/.github/workflows/create-pr.yml@v1 12 | with: 13 | target_branch: ${{ github.event.release.prerelease && 'main' || 'rc' }} 14 | tag_name: ${{ github.event.release.tag_name }} 15 | prerelease: ${{ github.event.release.prerelease }} 16 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | - rc 9 | 10 | 11 | jobs: 12 | build-and-release: 13 | name: 'Build project, run CI checks and publish new release' 14 | uses: fingerprintjs/dx-team-toolkit/.github/workflows/release-typescript-project.yml@v1 15 | with: 16 | appId: ${{ vars.APP_ID }} 17 | secrets: 18 | APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }} 19 | -------------------------------------------------------------------------------- /.github/workflows/teste2e-release.yml: -------------------------------------------------------------------------------- 1 | name: E2E test for release 2 | 3 | on: 4 | release: 5 | types: 6 | - published 7 | 8 | jobs: 9 | test-release: 10 | runs-on: ubuntu-24.04 11 | name: Test release 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - name: Install node 16 | uses: actions/setup-node@v4 17 | with: 18 | node-version-file: '.node-version' 19 | 20 | - name: Install pnpm 21 | uses: pnpm/action-setup@129abb77bf5884e578fcaf1f37628e41622cc371 22 | with: 23 | version: 8 24 | 25 | - name: Install dependencies 26 | run: pnpm install 27 | 28 | - name: Download release asset 29 | run: node scripts/downloadGitHubRelease.mjs 30 | env: 31 | TAG: ${{ github.event.release.tag_name }} 32 | 33 | - name: Generate random paths 34 | run: | 35 | postfix=$(date +%s) 36 | echo WORKER_NAME=automated-test-$postfix >> $GITHUB_OUTPUT 37 | echo WORKER_PATH=fpjs-worker-$postfix >> $GITHUB_OUTPUT 38 | echo GET_RESULT_PATH=get-result-$postfix >> $GITHUB_OUTPUT 39 | echo AGENT_DOWNLOAD_PATH=agent-download-$postfix >> $GITHUB_OUTPUT 40 | id: random-path-generator 41 | - name: Modify wrangler.toml 42 | run: | 43 | sed -i '/^account_id = ""$/d' wrangler.toml 44 | sed -i 's/name = .*/name = "${{steps.random-path-generator.outputs.WORKER_NAME}}"/' wrangler.toml 45 | sed -i 's/route = .*/route = "${{ secrets.TEST_CLIENT_DOMAIN }}\/${{steps.random-path-generator.outputs.WORKER_PATH}}\/*"/' wrangler.toml 46 | echo [vars] >> wrangler.toml 47 | echo GET_RESULT_PATH = \"${{ steps.random-path-generator.outputs.GET_RESULT_PATH }}\" >> wrangler.toml 48 | echo AGENT_SCRIPT_DOWNLOAD_PATH = \"${{ steps.random-path-generator.outputs.AGENT_DOWNLOAD_PATH }}\" >> wrangler.toml 49 | echo PROXY_SECRET = \"${{secrets.PROXY_SECRET}}\" >> wrangler.toml 50 | sed -i '/\[build\]/,+1d' wrangler.toml 51 | sed -i 's/main = "\.\/dist\/fingerprintjs-pro-cloudflare-worker\.esm\.js"/main = "\.\/fingerprintjs-pro-cloudflare-worker\.esm\.js"/' wrangler.toml 52 | 53 | cat wrangler.toml 54 | - name: Publish 55 | uses: cloudflare/wrangler-action@v3 56 | with: 57 | apiToken: ${{ secrets.CF_API_TOKEN }} 58 | accountId: ${{ secrets.CF_ACCOUNT_ID }} 59 | - name: Get version 60 | id: version 61 | uses: notiz-dev/github-action-json-property@a5a9c668b16513c737c3e1f8956772c99c73f6e8 # commit hash = v0.2.0 62 | with: 63 | path: 'package.json' 64 | prop_path: 'version' 65 | - name: Install dependencies 66 | run: pnpm exec playwright install 67 | - name: Run test 68 | run: pnpm test:e2e 69 | env: 70 | test_client_domain: ${{secrets.TEST_CLIENT_DOMAIN}} 71 | worker_version: ${{steps.version.outputs.prop}} 72 | worker_path: ${{steps.random-path-generator.outputs.WORKER_PATH}} 73 | get_result_path: ${{ steps.random-path-generator.outputs.GET_RESULT_PATH }} 74 | agent_download_path: ${{ steps.random-path-generator.outputs.AGENT_DOWNLOAD_PATH }} 75 | - name: Clean up worker 76 | run: | 77 | curl -i -X DELETE "https://api.cloudflare.com/client/v4/accounts/${{secrets.CF_ACCOUNT_ID}}/workers/scripts/${{steps.random-path-generator.outputs.WORKER_NAME}}" -H"Authorization: bearer ${{secrets.CF_API_TOKEN}}" 78 | - name: Report Status 79 | if: always() 80 | uses: ravsamhq/notify-slack-action@0d9c6ff1de9903da88d24c0564f6e83cb28faca9 81 | with: 82 | status: ${{ job.status }} 83 | notification_title: "Cloudflare Worker E2E Release Test has {status_message}" 84 | env: 85 | SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} 86 | -------------------------------------------------------------------------------- /.github/workflows/teste2e.yml: -------------------------------------------------------------------------------- 1 | name: Test e2e 2 | 3 | on: 4 | pull_request: 5 | paths-ignore: 6 | - '**/*.md' 7 | schedule: 8 | - cron: '30 1 * * *' 9 | workflow_dispatch: 10 | 11 | jobs: 12 | build-and-deploy-and-test-e2e-mock: 13 | runs-on: ubuntu-24.04 14 | name: Build & Deploy & Test e2e using mock app 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | with: 19 | ref: ${{ github.event.pull_request.head.sha }} 20 | 21 | - name: Install node 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version-file: '.node-version' 25 | 26 | - name: Install pnpm 27 | uses: pnpm/action-setup@129abb77bf5884e578fcaf1f37628e41622cc371 28 | with: 29 | version: 8 30 | 31 | - name: Upgrade version 32 | run: | 33 | pnpm version prerelease --preid snapshot --no-commit-hooks --no-git-tag-version --ignore-scripts 34 | 35 | - name: Generate random paths 36 | run: | 37 | postfix=$(date +%s) 38 | echo WORKER_NAME=automated-test-$postfix >> $GITHUB_OUTPUT 39 | echo WORKER_PATH=fpjs-worker-$postfix >> $GITHUB_OUTPUT 40 | echo GET_RESULT_PATH=get-result-$postfix >> $GITHUB_OUTPUT 41 | echo AGENT_DOWNLOAD_PATH=agent-download-$postfix >> $GITHUB_OUTPUT 42 | id: random-path-generator 43 | - name: Modify wrangler.toml 44 | run: | 45 | sed -i 's/name = .*/name = "${{steps.random-path-generator.outputs.WORKER_NAME}}"/' wrangler.toml 46 | sed -i 's/route = .*/route = "${{ secrets.TEST_CLIENT_DOMAIN }}\/${{steps.random-path-generator.outputs.WORKER_PATH}}\/*"/' wrangler.toml 47 | echo [vars] >> wrangler.toml 48 | echo GET_RESULT_PATH = \"${{ steps.random-path-generator.outputs.GET_RESULT_PATH }}\" >> wrangler.toml 49 | echo AGENT_SCRIPT_DOWNLOAD_PATH = \"${{ steps.random-path-generator.outputs.AGENT_DOWNLOAD_PATH }}\" >> wrangler.toml 50 | echo PROXY_SECRET = \"secret\" >> wrangler.toml 51 | echo FPJS_CDN_URL = \"${{secrets.MOCK_FPCDN}}\" >> wrangler.toml 52 | echo FPJS_INGRESS_BASE_HOST = \"${{secrets.MOCK_INGRESS_API}}\" >> wrangler.toml 53 | cat wrangler.toml 54 | - name: Publish 55 | uses: cloudflare/wrangler-action@v3 56 | with: 57 | apiToken: ${{ secrets.CF_API_TOKEN }} 58 | accountId: ${{ secrets.CF_ACCOUNT_ID }} 59 | - name: Get version 60 | id: version 61 | uses: notiz-dev/github-action-json-property@a5a9c668b16513c737c3e1f8956772c99c73f6e8 # commit hash = v0.2.0 62 | with: 63 | path: 'package.json' 64 | prop_path: 'version' 65 | - name: Wait for some time for the worker to come online 66 | run: sleep 180 67 | shell: bash 68 | - name: Run test 69 | run: | 70 | npm exec -y "git+https://github.com/fingerprintjs/dx-team-mock-for-proxy-integrations-e2e-tests.git" -- --api-url="${{env.api_url}}" --cdn-proxy-url="https://${{env.test_client_domain}}/${{env.worker_path}}/${{env.agent_download_path}}" --ingress-proxy-url="https://${{env.test_client_domain}}/${{env.worker_path}}/${{env.get_result_path}}" --traffic-name="fingerprintjs-pro-cloudflare" --integration-version="${{steps.version.outputs.prop}}" 71 | env: 72 | test_client_domain: ${{secrets.TEST_CLIENT_DOMAIN}} 73 | worker_version: ${{steps.version.outputs.prop}} 74 | worker_path: ${{steps.random-path-generator.outputs.WORKER_PATH}} 75 | get_result_path: ${{ steps.random-path-generator.outputs.GET_RESULT_PATH }} 76 | agent_download_path: ${{ steps.random-path-generator.outputs.AGENT_DOWNLOAD_PATH }} 77 | api_url: https://${{ secrets.MOCK_FPCDN }} 78 | - name: Clean up worker 79 | run: | 80 | curl -i -X DELETE "https://api.cloudflare.com/client/v4/accounts/${{secrets.CF_ACCOUNT_ID}}/workers/scripts/${{steps.random-path-generator.outputs.WORKER_NAME}}" -H"Authorization: bearer ${{secrets.CF_API_TOKEN}}" 81 | - name: Report Status 82 | if: always() 83 | uses: ravsamhq/notify-slack-action@0d9c6ff1de9903da88d24c0564f6e83cb28faca9 84 | with: 85 | status: ${{ job.status }} 86 | notification_title: "Cloudflare Worker E2E Test using mock app has {status_message}" 87 | env: 88 | SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} 89 | 90 | build-and-deploy-and-test-e2e: 91 | runs-on: ubuntu-24.04 92 | name: Build & Deploy & Test e2e 93 | steps: 94 | - uses: actions/checkout@v4 95 | with: 96 | ref: ${{ github.event.pull_request.head.sha }} 97 | 98 | - name: Install node 99 | uses: actions/setup-node@v4 100 | with: 101 | node-version-file: '.node-version' 102 | 103 | - name: Install pnpm 104 | uses: pnpm/action-setup@129abb77bf5884e578fcaf1f37628e41622cc371 105 | with: 106 | version: 8 107 | 108 | - name: Upgrade version 109 | run: | 110 | pnpm version prerelease --preid snapshot --no-commit-hooks --no-git-tag-version --ignore-scripts 111 | - name: Generate random paths 112 | run: | 113 | postfix=$(date +%s) 114 | echo WORKER_NAME=automated-test-$postfix >> $GITHUB_OUTPUT 115 | echo WORKER_PATH=fpjs-worker-$postfix >> $GITHUB_OUTPUT 116 | echo GET_RESULT_PATH=get-result-$postfix >> $GITHUB_OUTPUT 117 | echo AGENT_DOWNLOAD_PATH=agent-download-$postfix >> $GITHUB_OUTPUT 118 | id: random-path-generator 119 | - name: Modify wrangler.toml 120 | run: | 121 | sed -i 's/name = .*/name = "${{steps.random-path-generator.outputs.WORKER_NAME}}"/' wrangler.toml 122 | sed -i 's/route = .*/route = "${{ secrets.TEST_CLIENT_DOMAIN }}\/${{steps.random-path-generator.outputs.WORKER_PATH}}\/*"/' wrangler.toml 123 | echo [vars] >> wrangler.toml 124 | echo GET_RESULT_PATH = \"${{ steps.random-path-generator.outputs.GET_RESULT_PATH }}\" >> wrangler.toml 125 | echo AGENT_SCRIPT_DOWNLOAD_PATH = \"${{ steps.random-path-generator.outputs.AGENT_DOWNLOAD_PATH }}\" >> wrangler.toml 126 | echo PROXY_SECRET = \"${{secrets.PROXY_SECRET}}\" >> wrangler.toml 127 | cat wrangler.toml 128 | - name: Publish 129 | uses: cloudflare/wrangler-action@v3 130 | with: 131 | apiToken: ${{ secrets.CF_API_TOKEN }} 132 | accountId: ${{ secrets.CF_ACCOUNT_ID }} 133 | - name: Get version 134 | id: version 135 | uses: notiz-dev/github-action-json-property@a5a9c668b16513c737c3e1f8956772c99c73f6e8 # commit hash = v0.2.0 136 | with: 137 | path: 'package.json' 138 | prop_path: 'version' 139 | - name: Install dependencies 140 | run: pnpm exec playwright install 141 | - name: Run test 142 | run: pnpm test:e2e 143 | env: 144 | test_client_domain: ${{secrets.TEST_CLIENT_DOMAIN}} 145 | worker_version: ${{steps.version.outputs.prop}} 146 | worker_path: ${{steps.random-path-generator.outputs.WORKER_PATH}} 147 | get_result_path: ${{ steps.random-path-generator.outputs.GET_RESULT_PATH }} 148 | agent_download_path: ${{ steps.random-path-generator.outputs.AGENT_DOWNLOAD_PATH }} 149 | - name: Clean up worker 150 | run: | 151 | curl -i -X DELETE "https://api.cloudflare.com/client/v4/accounts/${{secrets.CF_ACCOUNT_ID}}/workers/scripts/${{steps.random-path-generator.outputs.WORKER_NAME}}" -H"Authorization: bearer ${{secrets.CF_API_TOKEN}}" 152 | - name: Report Status 153 | if: always() 154 | uses: ravsamhq/notify-slack-action@0d9c6ff1de9903da88d24c0564f6e83cb28faca9 155 | with: 156 | status: ${{ job.status }} 157 | notification_title: "Cloudflare Worker E2E Test has {status_message}" 158 | env: 159 | SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} 160 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Project artifacts 2 | /node_modules/ 3 | 4 | # code coverage 5 | /coverage 6 | 7 | # Random excess stuff 8 | .DS_Store 9 | .idea/ 10 | .vscode/ 11 | yarn-error.log 12 | dist/ 13 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx commitlint --edit $1 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | containsref() { if [[ $2 =~ $1 ]]; then echo 1; else echo 0; fi } 5 | 6 | push_command=$(ps -ocommand= -p $PPID | cut -d' ' -f 4) 7 | protected_branch='main' 8 | current_branch=$(git symbolic-ref HEAD | sed -e 's,.*/\(.*\),\1,') 9 | is_push_to_main_origin=$(containsref 'git@github.com:/?fingerprintjs/' "$push_command") 10 | 11 | # Block pushes only to protected branch in main repository 12 | if [ $is_push_to_main_origin = 1 ] && [ "$protected_branch" = "$current_branch" ]; then 13 | echo "You are on the $protected_branch branch, push blocked." 14 | exit 1 # push will not execute 15 | fi 16 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 20 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | "@fingerprintjs/prettier-config-dx-team" 2 | -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "branches": [ 3 | "main", 4 | { 5 | "name": "rc", 6 | "prerelease": true 7 | } 8 | ], 9 | "plugins":[ 10 | [ 11 | "@semantic-release/commit-analyzer", 12 | { 13 | "config": "@fingerprintjs/conventional-changelog-dx-team", 14 | "releaseRules": "@fingerprintjs/conventional-changelog-dx-team/release-rules" 15 | } 16 | ], 17 | [ 18 | "@semantic-release/release-notes-generator", 19 | { 20 | "config": "@fingerprintjs/conventional-changelog-dx-team" 21 | } 22 | ], 23 | "@semantic-release/changelog", 24 | [ 25 | "@semantic-release/npm", 26 | { 27 | "npmPublish":false 28 | } 29 | ], 30 | [ 31 | "@semantic-release/exec", 32 | { 33 | "prepareCmd":"pnpm build" 34 | } 35 | ], 36 | [ 37 | "@semantic-release/git", 38 | { 39 | "message": "chore(release): ${nextRelease.version}\n\n${nextRelease.notes}", 40 | "assets":[ 41 | "CHANGELOG.md", 42 | "package.json" 43 | ] 44 | } 45 | ], 46 | [ 47 | "@semantic-release/github", 48 | { 49 | "assets":[ 50 | { 51 | "path":"dist/fingerprintjs-pro-cloudflare-worker.d.ts", 52 | "label":"fingerprintjs-pro-cloudflare-worker.d.ts" 53 | }, 54 | { 55 | "path":"dist/fingerprintjs-pro-cloudflare-worker.esm.js", 56 | "label":"fingerprintjs-pro-cloudflare-worker.esm.js" 57 | } 58 | ] 59 | } 60 | ] 61 | ] 62 | } 63 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [1.6.0](https://github.com/fingerprintjs/fingerprintjs-pro-cloudflare-worker/compare/v1.5.0...v1.6.0) (2025-03-19) 2 | 3 | 4 | ### Features 5 | 6 | * proxy integration headers handling when proxy secret is missing ([e8c1948](https://github.com/fingerprintjs/fingerprintjs-pro-cloudflare-worker/commit/e8c19489d2974a284c28bb965c2df42c13a42853)) 7 | * remove hard-coded proxy endpoints, introduce config in build ([f5e91c2](https://github.com/fingerprintjs/fingerprintjs-pro-cloudflare-worker/commit/f5e91c247c74f08662c34a60efa9681d784fb38c)) 8 | 9 | 10 | ### Bug Fixes 11 | 12 | * correctly resolve client IP from headers ([97f2515](https://github.com/fingerprintjs/fingerprintjs-pro-cloudflare-worker/commit/97f25156f35595ac741c7bbf1ea23f95885e9cf5)) 13 | 14 | 15 | ### Performance Improvements 16 | 17 | * updated Cloudflare worker runtime compabilitiy date ([07eaaba](https://github.com/fingerprintjs/fingerprintjs-pro-cloudflare-worker/commit/07eaabaed1be610074b7e68827f8e19a6834de46)) 18 | 19 | ## [1.6.0-rc.2](https://github.com/fingerprintjs/fingerprintjs-pro-cloudflare-worker/compare/v1.6.0-rc.1...v1.6.0-rc.2) (2025-03-18) 20 | 21 | 22 | ### Features 23 | 24 | * proxy integration headers handling when proxy secret is missing ([e8c1948](https://github.com/fingerprintjs/fingerprintjs-pro-cloudflare-worker/commit/e8c19489d2974a284c28bb965c2df42c13a42853)) 25 | 26 | 27 | ### Bug Fixes 28 | 29 | * correctly resolve client IP from headers ([97f2515](https://github.com/fingerprintjs/fingerprintjs-pro-cloudflare-worker/commit/97f25156f35595ac741c7bbf1ea23f95885e9cf5)) 30 | 31 | ## [1.6.0-rc.2](https://github.com/fingerprintjs/fingerprintjs-pro-cloudflare-worker/compare/v1.6.0-rc.1...v1.6.0-rc.2) (2025-03-18) 32 | 33 | 34 | ### Features 35 | 36 | * proxy integration headers handling when proxy secret is missing ([e8c1948](https://github.com/fingerprintjs/fingerprintjs-pro-cloudflare-worker/commit/e8c19489d2974a284c28bb965c2df42c13a42853)) 37 | 38 | 39 | ### Bug Fixes 40 | 41 | * correctly resolve client IP from headers ([97f2515](https://github.com/fingerprintjs/fingerprintjs-pro-cloudflare-worker/commit/97f25156f35595ac741c7bbf1ea23f95885e9cf5)) 42 | 43 | ## [1.6.0-rc.2](https://github.com/fingerprintjs/fingerprintjs-pro-cloudflare-worker/compare/v1.6.0-rc.1...v1.6.0-rc.2) (2025-03-13) 44 | 45 | 46 | ### Features 47 | 48 | * proxy integration headers handling when proxy secret is missing ([e8c1948](https://github.com/fingerprintjs/fingerprintjs-pro-cloudflare-worker/commit/e8c19489d2974a284c28bb965c2df42c13a42853)) 49 | 50 | 51 | ### Bug Fixes 52 | 53 | * correctly resolve client IP from headers ([97f2515](https://github.com/fingerprintjs/fingerprintjs-pro-cloudflare-worker/commit/97f25156f35595ac741c7bbf1ea23f95885e9cf5)) 54 | 55 | ## [1.6.0-rc.1](https://github.com/fingerprintjs/fingerprintjs-pro-cloudflare-worker/compare/v1.5.0...v1.6.0-rc.1) (2024-04-10) 56 | 57 | 58 | ### Features 59 | 60 | * remove hard-coded proxy endpoints, introduce config in build ([f5e91c2](https://github.com/fingerprintjs/fingerprintjs-pro-cloudflare-worker/commit/f5e91c247c74f08662c34a60efa9681d784fb38c)) 61 | 62 | 63 | ### Performance Improvements 64 | 65 | * updated Cloudflare worker runtime compabilitiy date ([07eaaba](https://github.com/fingerprintjs/fingerprintjs-pro-cloudflare-worker/commit/07eaabaed1be610074b7e68827f8e19a6834de46)) 66 | 67 | ## [1.6.0-rc.1](https://github.com/fingerprintjs/fingerprintjs-pro-cloudflare-worker/compare/v1.5.0...v1.6.0-rc.1) (2024-02-21) 68 | 69 | 70 | ### Features 71 | 72 | * remove hard-coded proxy endpoints, introduce config in build ([f5e91c2](https://github.com/fingerprintjs/fingerprintjs-pro-cloudflare-worker/commit/f5e91c247c74f08662c34a60efa9681d784fb38c)) 73 | 74 | ## [1.5.0](https://github.com/fingerprintjs/fingerprintjs-pro-cloudflare-worker/compare/v1.4.0...v1.5.0) (2023-12-13) 75 | 76 | 77 | ### Features 78 | 79 | * **proxy-host-header:** add proxy host header ([5022e7d](https://github.com/fingerprintjs/fingerprintjs-pro-cloudflare-worker/commit/5022e7d6403044567f2e3a56adc141fa2c7fe42e)) 80 | 81 | 82 | ### Bug Fixes 83 | 84 | * improve endpoint creation ([5414d7f](https://github.com/fingerprintjs/fingerprintjs-pro-cloudflare-worker/commit/5414d7fa58be67689e194c39460afbefd08ae0fa)) 85 | 86 | 87 | ### Build System 88 | 89 | * **deps:** remove punycode & suffix list ([67d0e5a](https://github.com/fingerprintjs/fingerprintjs-pro-cloudflare-worker/commit/67d0e5a27b964bc793320212fe90147c2dae620b)) 90 | 91 | ## [1.5.0-rc.2](https://github.com/fingerprintjs/fingerprintjs-pro-cloudflare-worker/compare/v1.5.0-rc.1...v1.5.0-rc.2) (2023-12-13) 92 | 93 | 94 | ### Bug Fixes 95 | 96 | * improve endpoint creation ([5414d7f](https://github.com/fingerprintjs/fingerprintjs-pro-cloudflare-worker/commit/5414d7fa58be67689e194c39460afbefd08ae0fa)) 97 | 98 | ## [1.5.0-rc.1](https://github.com/fingerprintjs/fingerprintjs-pro-cloudflare-worker/compare/v1.4.0...v1.5.0-rc.1) (2023-12-11) 99 | 100 | 101 | ### Features 102 | 103 | * **proxy-host-header:** add proxy host header ([5022e7d](https://github.com/fingerprintjs/fingerprintjs-pro-cloudflare-worker/commit/5022e7d6403044567f2e3a56adc141fa2c7fe42e)) 104 | 105 | 106 | ### Build System 107 | 108 | * **deps:** remove punycode & suffix list ([67d0e5a](https://github.com/fingerprintjs/fingerprintjs-pro-cloudflare-worker/commit/67d0e5a27b964bc793320212fe90147c2dae620b)) 109 | 110 | ## [1.5.0-rc.1](https://github.com/fingerprintjs/fingerprintjs-pro-cloudflare-worker/compare/v1.4.0...v1.5.0-rc.1) (2023-12-11) 111 | 112 | 113 | ### Features 114 | 115 | * **proxy-host-header:** add proxy host header ([5022e7d](https://github.com/fingerprintjs/fingerprintjs-pro-cloudflare-worker/commit/5022e7d6403044567f2e3a56adc141fa2c7fe42e)) 116 | 117 | 118 | ### Build System 119 | 120 | * **deps:** remove punycode & suffix list ([67d0e5a](https://github.com/fingerprintjs/fingerprintjs-pro-cloudflare-worker/commit/67d0e5a27b964bc793320212fe90147c2dae620b)) 121 | 122 | ## [1.4.0](https://github.com/fingerprintjs/fingerprintjs-pro-cloudflare-worker/compare/v1.3.1...v1.4.0) (2023-07-04) 123 | 124 | 125 | ### Features 126 | 127 | * **cachingendpoint:** do not set edge network cache and do not override cache-control ([072faa4](https://github.com/fingerprintjs/fingerprintjs-pro-cloudflare-worker/commit/072faa42d89f92623348f5b866c4cb76dc42c401)) 128 | * **handleingressapi:** handleIngressAPI now proxies req to servers with suffix ([a26cd8f](https://github.com/fingerprintjs/fingerprintjs-pro-cloudflare-worker/commit/a26cd8f17132353ff5a8a5568d6cc89c42c24be5)) 129 | * **ingress-api:** add cache-control header to ingress api ([1ad08e0](https://github.com/fingerprintjs/fingerprintjs-pro-cloudflare-worker/commit/1ad08e0f449af6416fefbc4ce1f9ef124ff557be)) 130 | * **ingress-api:** support for get/post methods ([b217028](https://github.com/fingerprintjs/fingerprintjs-pro-cloudflare-worker/commit/b21702806fc400cf5d0e57922206b30a36dece63)) 131 | * **ingressapi:** ingressAPI now handles suffixed routes ([acd4af4](https://github.com/fingerprintjs/fingerprintjs-pro-cloudflare-worker/commit/acd4af4234ef72c9c9bfa68a2a70eda45849ebf9)) 132 | * **router:** router now provides RegExpMatchArray as 3rd argument to handlers ([003005c](https://github.com/fingerprintjs/fingerprintjs-pro-cloudflare-worker/commit/003005c418d0f4cc42e1be277dca1812a311922b)) 133 | 134 | ## [1.3.1](https://github.com/fingerprintjs/fingerprintjs-pro-cloudflare-worker/compare/v1.3.0...v1.3.1) (2023-06-15) 135 | 136 | 137 | ### Bug Fixes 138 | 139 | * **cookies:** fixed a case where TLD+1 cookies are not calculated correctly ([#170](https://github.com/fingerprintjs/fingerprintjs-pro-cloudflare-worker/issues/170)) ([64ba2a4](https://github.com/fingerprintjs/fingerprintjs-pro-cloudflare-worker/commit/64ba2a41baad77a9d2949e1e964f5f872dc6400f)) 140 | 141 | ## [1.3.0](https://github.com/fingerprintjs/fingerprintjs-pro-cloudflare-worker/compare/v1.2.0...v1.3.0) (2023-06-08) 142 | 143 | 144 | ### Features 145 | 146 | * **worker-path:** remove ([f404a3a](https://github.com/fingerprintjs/fingerprintjs-pro-cloudflare-worker/commit/f404a3a87cfd1d6df8244e4291301a1b69102ad1)) 147 | 148 | 149 | ### Bug Fixes 150 | 151 | * **routing:** support more characters for createRoute ([87764d2](https://github.com/fingerprintjs/fingerprintjs-pro-cloudflare-worker/commit/87764d29ebce8d56f71feb7c7dff5328fa4e2133)) 152 | 153 | 154 | ### Build System 155 | 156 | * **deps:** add semantic-release ([d5f7842](https://github.com/fingerprintjs/fingerprintjs-pro-cloudflare-worker/commit/d5f784269e50617eb58f56f577c06536b2cec179)) 157 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 FingerprintJS 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | 5 | 6 | Fingerprint logo 7 | 8 | 9 |

10 | Current NPM version 11 | coverage 12 | MIT license 13 | Discord server 14 | 15 | # Fingerprint Pro Cloudflare worker 16 | 17 | [Fingerprint](https://fingerprint.com/) is a device intelligence platform offering visitor identification and smart signals with industry-leading accuracy. 18 | 19 | Fingerprint Pro Cloudflare Integration is responsible for 20 | 21 | - Proxying download requests of the latest Fingerprint Pro JS Agent between your site and Fingerprint CDN. 22 | - Proxying identification requests and responses between your site and Fingerprint Pro's APIs. 23 | 24 | This [improves](https://dev.fingerprint.com/docs/cloudflare-integration#the-benefits-of-using-the-cloudflare-integration) both accuracy and reliability of visitor identification and bot detection on your site. 25 | 26 | ## Requirements 27 | 28 | * [Fingerprint Pro account](https://dashboard.fingerprint.com/signup) with the _Owner_ role assigned. 29 | * A website served by Cloudflare. For maximum accuracy benefits, your website should be [proxied by Cloudflare](https://developers.cloudflare.com/dns/manage-dns-records/reference/proxied-dns-records/) (not DNS-only). 30 | 31 | See the [Cloudflare integration guide](https://dev.fingerprint.com/docs/cloudflare-integration#setup) for more details. 32 | 33 | 34 | ## How to install 35 | 36 | You can install the Cloudflare integration using an [installation wizard](https://dashboard.fingerprint.com/integrations) in the Fingerprint dashboard. 37 | 38 | See the [Cloudflare integration guide](https://dev.fingerprint.com/docs/cloudflare-integration#setup) for more details. 39 | 40 | ## Support 41 | 42 | To report problems, ask questions or provide feedback, please use [Issues](https://github.com/fingerprintjs/fingerprintjs-pro-cloudflare-worker/issues). If you need private support, you can email us at [oss-support@fingerprint.com](mailto:oss-support@fingerprint.com). 43 | 44 | ## License 45 | 46 | This project is licensed under the MIT license. See the [LICENSE](https://github.com/fingerprintjs/fingerprintjs-pro-cloudflare-worker/blob/main/LICENSE) file for more info. 47 | -------------------------------------------------------------------------------- /assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fingerprintjs/fingerprintjs-pro-cloudflare-worker/62eccc962c830053ee7222457841c8dd1a8cb4ae/assets/favicon.ico -------------------------------------------------------------------------------- /assets/license_banner.txt: -------------------------------------------------------------------------------- 1 | FingerprintJS Pro Cloudflare Worker v<%= pkg.version %> - Copyright (c) FingerprintJS, Inc, <%= new Date().getFullYear() %> (https://fingerprint.com) 2 | Licensed under the MIT (http://www.opensource.org/licenses/mit-license.php) license. -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = {extends: ['@commitlint/config-conventional']} 2 | -------------------------------------------------------------------------------- /contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing to FingerprintJS Pro Cloudflare Worker 2 | 3 | ## Requirements 4 | 5 | - Node 20 6 | - Typescript 4+ 7 | - Playwright 8 | - [Wrangler](https://developers.cloudflare.com/workers/wrangler/install-and-update/) v3+ 9 | 10 | ## Working with code 11 | 12 | We prefer using [pnpm](https://pnpm.io/) for installing dependencies and running scripts. 13 | 14 | The `main` is locked for the push action. 15 | 16 | `main` branch is always where we create releases. 17 | 18 | For proposing changes, use the standard [pull request approach](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request). It's recommended to discuss fixes or new functionality in the Issues, first. 19 | 20 | Create pull requests for the `main` branch. 21 | 22 | ### How to build 23 | After cloning the repo, run `pnpm install` to install packages. 24 | 25 | Run `pnpm build` for creating a build in `dist` folder. After building, `dist/fingerprintjs-pro-cloudflare-worker.esm.js` file is created, and it is used to deploy to CF. 26 | 27 | ### How to run locally 28 | 29 | Install [Wrangler](https://developers.cloudflare.com/workers/get-started/guide/#1-install-wrangler-workers-cli) provided by Cloudflare. 30 | 31 | ❗Please use `Wrangler 2` instead of `Wrangler 1`. For more info, please visit [here](https://developers.cloudflare.com/workers/wrangler/compare-v1-v2/). 32 | 33 | First run `wrangler login`. This will open the browser and ask you to log in to your CF account to authorize Wrangler in your local machine. You can use `wrangler logout` any time to log out. 34 | 35 | Then, you can run `wrangler dev` to run the worker locally. By default, it will run on `http://localhost:8787` and will have the following endpoints: 36 | - `/cf-worker/agent` for downloading the Pro Agent script (a.k.a `import` url or `scriptUrlPattern`) 37 | - `/cf-worker/getResult` for getting the result (a.k.a. `endpoint`) 38 | 39 | You can use the worker locally with a client like the example below: 40 | ```html 41 | 57 | ``` 58 | 59 | ### Code style 60 | 61 | The code style is controlled by [ESLint](https://eslint.org/) and [Prettier](https://prettier.io/). Run to check that the code style is ok: 62 | ```shell 63 | pnpm lint 64 | ``` 65 | 66 | You aren't required to run the check manually, the CI will do it. Run the following command to fix style issues (not all issues can be fixed automatically): 67 | ```shell 68 | pnpm lint:fix 69 | ``` 70 | 71 | ### Commit style 72 | 73 | You are required to follow [conventional commits](https://www.conventionalcommits.org) rules. 74 | 75 | ### How to test 76 | 77 | #### Unit tests 78 | 79 | Run `pnpm test`. 80 | 81 | #### e2e tests 82 | 83 | End-to-end tests are run automatically on every PR. They also run daily on the `main` branch. 84 | 85 | End-to-end tests are located in the `test/e2e` folder and run by [playwright](https://github.com/microsoft/playwright) environment. 86 | The `teste2e.yml` workflow is responsible for deploying a new Cloudflare worker, running end-to-end tests, and cleaning up the worker in the end. `teste2e.yml` works like this: 87 | 1. Check out the current branch (can be any branch). 88 | 2. Bump version according to the input, default to `patch`. 89 | 3. Generate environment variables, such as `WORKER_NAME` and `GET_RESULT_PATH`. Put them inside `wranger.toml`. 90 | 4. Publish the worker using `cloudfare/wrangler-action` to the designated Cloudflare account. 91 | 5. Install `playwright`. 92 | 6. Run `pnpm test:e2e` with env variables `test_client_domain`, `worker_version`, `worker_path`, `get_result_path`, and `agent_download_path`. 93 | 7. Delete the published Cloudflare worker from the Cloudflare account. 94 | 95 | If tests fail, the last step (cleaning up the worker) is never executed by design, so that there is opportunity to inspect the worker to understand what went wrong. 96 | Do not forget to delete the worker manually after using the Cloudflare dashboard. You can find the name of the worker in the workflow logs. 97 | 98 | If the required environment variables are supplied, `pnpm test:e2e` can be run locally without needing `teste2e.yml`. For example, the command `worker_version=1.2.3 pnpm test:e2e` sets `worker_version` as a temporary env variable on *nix systems. 99 | 100 | ### How to release a new version 101 | 102 | The workflow `release.yml` is responsible for releasing a new version. Run it on the `main` branch. 103 | 104 | ### How to keep your worker up-to-date 105 | 106 | CF Integration by Fingerprint always uses the latest stable version for the customers, and upgrades customer workers automatically. 107 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'miniflare', 5 | testRegex: '/test/.+test.tsx?$', 6 | passWithNoTests: true, 7 | collectCoverageFrom: ['./src/**/*.ts'], 8 | coverageReporters: ['lcov', 'json-summary', ['text', { file: 'coverage.txt', path: './' }]], 9 | } 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fingerprintjs/cloudflare-worker-typescript", 3 | "version": "1.6.0", 4 | "description": "FingerprintJS Pro Cloudflare Worker", 5 | "author": "FingerprintJS, Inc (https://fingerprintjs.com)", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/fingerprintjs/fingerprintjs-pro-cloudflare-worker.git" 10 | }, 11 | "scripts": { 12 | "build": "rimraf dist && rollup -c rollup.config.js --bundleConfigAsCjs", 13 | "lint": "eslint --ext .js,.ts,.mjs --ignore-path .gitignore --max-warnings 0 src/ test/ scripts/", 14 | "lint:fix": "pnpm lint --fix", 15 | "test": "jest --coverage", 16 | "test:coverage": "jest --coverage", 17 | "test:dts": "tsc --noEmit --isolatedModules dist/fingerprintjs-pro-cloudflare-worker.d.ts", 18 | "test:e2e": "pnpm exec playwright test", 19 | "prepare": "husky install" 20 | }, 21 | "lint-staged": { 22 | "*.{js,ts,mjs}": "pnpm lint:fix" 23 | }, 24 | "config": { 25 | "commitizen": { 26 | "path": "./node_modules/cz-conventional-changelog" 27 | } 28 | }, 29 | "main": "dist/fingerprintjs-pro-cloudflare-worker.esm.js", 30 | "module": "dist/fingerprintjs-pro-cloudflare-worker.esm.js", 31 | "types": "dist/fingerprintjs-pro-cloudflare-worker.d.ts", 32 | "sideEffects": false, 33 | "files": [ 34 | "dist" 35 | ], 36 | "engines": { 37 | "node": ">=16" 38 | }, 39 | "devDependencies": { 40 | "@cloudflare/workers-types": "^3.4.0", 41 | "@commitlint/cli": "^17.6.5", 42 | "@commitlint/config-conventional": "^17.4.4", 43 | "@fingerprintjs/commit-lint-dx-team": "^0.0.2", 44 | "@fingerprintjs/conventional-changelog-dx-team": "^0.1.0", 45 | "@fingerprintjs/eslint-config-dx-team": "^0.1.0", 46 | "@fingerprintjs/prettier-config-dx-team": "^0.1.0", 47 | "@fingerprintjs/tsconfig-dx-team": "^0.0.2", 48 | "@playwright/test": "^1.43.0", 49 | "@rollup/plugin-commonjs": "^25.0.7", 50 | "@rollup/plugin-json": "^6.1.0", 51 | "@rollup/plugin-node-resolve": "^15.2.3", 52 | "@rollup/plugin-replace": "^5.0.5", 53 | "@rollup/plugin-typescript": "^11.1.6", 54 | "@types/cookie": "^0.5.1", 55 | "@types/jest": "^29.5.12", 56 | "@types/node": "^20.2.5", 57 | "@typescript-eslint/eslint-plugin": "^6.21.0", 58 | "@typescript-eslint/parser": "^6.21.0", 59 | "commitizen": "^4.3.0", 60 | "cz-conventional-changelog": "^3.3.0", 61 | "husky": "^9.0.11", 62 | "jest": "^29.7.0", 63 | "jest-environment-miniflare": "^2.14.0", 64 | "lint-staged": "^13.2.2", 65 | "prettier": "^3.2.5", 66 | "rimraf": "^5.0.1", 67 | "rollup": "^4.14.1", 68 | "rollup-plugin-dts": "^6.1.0", 69 | "rollup-plugin-license": "^3.3.1", 70 | "ts-jest": "^29.1.2", 71 | "typescript": "^5.4.4" 72 | }, 73 | "dependencies": { 74 | "cookie": "0.5.0" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { PlaywrightTestConfig } from '@playwright/test'; 2 | 3 | const config: PlaywrightTestConfig = { 4 | testDir: './test/e2e', 5 | }; 6 | 7 | export default config; 8 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from '@rollup/plugin-typescript' 2 | import jsonPlugin from '@rollup/plugin-json' 3 | import { dts } from 'rollup-plugin-dts' 4 | import licensePlugin from 'rollup-plugin-license' 5 | import { join } from 'path' 6 | import replace from '@rollup/plugin-replace' 7 | import nodeResolve from '@rollup/plugin-node-resolve' 8 | import commonjs from '@rollup/plugin-commonjs' 9 | 10 | const packageJson = require('./package.json') 11 | 12 | const inputFile = 'src/index.ts' 13 | const outputDirectory = 'dist' 14 | const artifactName = 'fingerprintjs-pro-cloudflare-worker' 15 | 16 | const commonBanner = licensePlugin({ 17 | banner: { 18 | content: { 19 | file: join(__dirname, 'assets', 'license_banner.txt'), 20 | }, 21 | }, 22 | }) 23 | 24 | const commonInput = { 25 | input: inputFile, 26 | plugins: [ 27 | replace({ 28 | __current_worker_version__: packageJson.version, 29 | preventAssignment: true, 30 | }), 31 | jsonPlugin(), 32 | typescript(), 33 | commonBanner, 34 | nodeResolve({ preferBuiltins: false }), 35 | commonjs(), 36 | ], 37 | } 38 | 39 | const commonOutput = { 40 | name: 'fingerprintjs-pro-cloudflare-worker', 41 | exports: 'named', 42 | } 43 | 44 | export default [ 45 | { 46 | ...commonInput, 47 | output: [ 48 | { 49 | ...commonOutput, 50 | file: `${outputDirectory}/${artifactName}.esm.js`, 51 | format: 'es', 52 | }, 53 | ], 54 | }, 55 | 56 | // TypeScript definition 57 | { 58 | ...commonInput, 59 | plugins: [dts(), commonBanner], 60 | output: { 61 | file: `${outputDirectory}/${artifactName}.d.ts`, 62 | format: 'es', 63 | }, 64 | }, 65 | ] 66 | -------------------------------------------------------------------------------- /scripts/downloadGitHubRelease.mjs: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import path from 'path' 3 | import { fileURLToPath } from 'url' 4 | 5 | const config = { 6 | token: process.env.GITHUB_TOKEN, 7 | owner: 'fingerprintjs', 8 | repo: 'fingerprintjs-pro-cloudflare-worker', 9 | } 10 | 11 | const dirname = path.dirname(fileURLToPath(import.meta.url)) 12 | 13 | console.debug('dirname', dirname) 14 | 15 | async function main() { 16 | const tag = process.env.TAG 17 | 18 | if (!tag) { 19 | throw new Error('TAG env variable is required') 20 | } 21 | 22 | const release = await getGitHubReleaseByTag(tag) 23 | 24 | if (!release) { 25 | console.warn('No release found') 26 | 27 | return 28 | } 29 | 30 | console.info('Release', release.tag_name) 31 | 32 | const asset = await findReleaseAsset(release.assets) 33 | 34 | const distPath = path.resolve(dirname, '../') 35 | const assetPath = path.join(distPath, asset.name) 36 | 37 | const file = await downloadReleaseAsset(asset.url, config.token) 38 | 39 | console.info('Writing file', assetPath) 40 | 41 | await fs.writeFileSync(assetPath, file) 42 | } 43 | 44 | function bearer(token) { 45 | return `Bearer ${token}` 46 | } 47 | 48 | async function getGitHubReleaseByTag(tag) { 49 | const url = `https://api.github.com/repos/${config.owner}/${config.repo}/releases/tags/${tag}` 50 | 51 | console.debug('getGitHubReleaseByTag url', url) 52 | 53 | return await doGitHubGetRequest(url) 54 | } 55 | 56 | async function doGitHubGetRequest(url) { 57 | const response = await fetch(url, { 58 | headers: config.token 59 | ? { 60 | Authorization: bearer(config.token), 61 | } 62 | : undefined, 63 | }) 64 | 65 | return await response.json() 66 | } 67 | 68 | async function downloadReleaseAsset(url, token) { 69 | const headers = { 70 | Accept: 'application/octet-stream', 71 | 'User-Agent': 'fingerprint-pro-cloudflare-integration', 72 | } 73 | if (token) { 74 | headers['Authorization'] = bearer(token) 75 | } 76 | 77 | console.info('Downloading release asset...', url) 78 | 79 | const response = await fetch(url, { headers }) 80 | 81 | const arrayBuffer = await response.arrayBuffer() 82 | 83 | console.info('Downloaded release asset') 84 | 85 | return Buffer.from(arrayBuffer) 86 | } 87 | 88 | async function findReleaseAsset(assets) { 89 | const targetAssetsName = 'fingerprintjs-pro-cloudflare-worker.esm.js' 90 | 91 | const targetAsset = assets.find((asset) => asset.name === targetAssetsName && asset.state === 'uploaded') 92 | 93 | if (!targetAsset) { 94 | throw new Error('Release asset not found') 95 | } 96 | 97 | return targetAsset 98 | } 99 | 100 | main().catch((err) => { 101 | console.error(err) 102 | process.exit(1) 103 | }) 104 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | export const config = { 2 | fpcdn: 'fpcdn.io', 3 | ingressApi: 'api.fpjs.io', 4 | } 5 | -------------------------------------------------------------------------------- /src/env.ts: -------------------------------------------------------------------------------- 1 | import { config } from './config' 2 | 3 | export type WorkerEnv = { 4 | AGENT_SCRIPT_DOWNLOAD_PATH: string | null 5 | GET_RESULT_PATH: string | null 6 | PROXY_SECRET: string | null 7 | FPJS_CDN_URL: string | null 8 | FPJS_INGRESS_BASE_HOST: string | null 9 | } 10 | 11 | export const Defaults: WorkerEnv = { 12 | AGENT_SCRIPT_DOWNLOAD_PATH: 'agent', 13 | GET_RESULT_PATH: 'getResult', 14 | PROXY_SECRET: null, 15 | FPJS_CDN_URL: config.fpcdn, 16 | FPJS_INGRESS_BASE_HOST: config.ingressApi, 17 | } 18 | 19 | function getVarOrDefault(variable: keyof WorkerEnv, defaults: WorkerEnv): (env: WorkerEnv) => string | null { 20 | return function (env: WorkerEnv): string | null { 21 | return (env[variable] || defaults[variable]) as string | null 22 | } 23 | } 24 | 25 | function isVarSet(variable: keyof WorkerEnv): (env: WorkerEnv) => boolean { 26 | return function (env: WorkerEnv): boolean { 27 | return env[variable] != null 28 | } 29 | } 30 | 31 | export const getCdnUrl = getVarOrDefault('FPJS_CDN_URL', Defaults) 32 | export const getIngressBaseHost = getVarOrDefault('FPJS_INGRESS_BASE_HOST', Defaults) 33 | 34 | export const agentScriptDownloadPathVarName = 'AGENT_SCRIPT_DOWNLOAD_PATH' 35 | const getAgentPathVar = getVarOrDefault(agentScriptDownloadPathVarName, Defaults) 36 | export const isScriptDownloadPathSet = isVarSet(agentScriptDownloadPathVarName) 37 | 38 | export function getScriptDownloadPath(env: WorkerEnv): string { 39 | const agentPathVar = getAgentPathVar(env) 40 | return `/${agentPathVar}` 41 | } 42 | 43 | export const getResultPathVarName = 'GET_RESULT_PATH' 44 | const getGetResultPathVar = getVarOrDefault(getResultPathVarName, Defaults) 45 | export const isGetResultPathSet = isVarSet(getResultPathVarName) 46 | 47 | export function getGetResultPath(env: WorkerEnv): string { 48 | const getResultPathVar = getGetResultPathVar(env) 49 | return `/${getResultPathVar}(/.*)?` 50 | } 51 | 52 | export const proxySecretVarName = 'PROXY_SECRET' 53 | const getProxySecretVar = getVarOrDefault(proxySecretVarName, Defaults) 54 | export const isProxySecretSet = isVarSet(proxySecretVarName) 55 | 56 | export function getProxySecret(env: WorkerEnv): string | null { 57 | return getProxySecretVar(env) 58 | } 59 | 60 | export function getStatusPagePath(): string { 61 | return `/status` 62 | } 63 | -------------------------------------------------------------------------------- /src/handler.ts: -------------------------------------------------------------------------------- 1 | import { getScriptDownloadPath, getGetResultPath, WorkerEnv, getStatusPagePath } from './env' 2 | 3 | import { handleDownloadScript, handleIngressAPI, handleStatusPage } from './handlers' 4 | import { createRoute } from './utils' 5 | 6 | export type Route = { 7 | pathPattern: RegExp 8 | handler: ( 9 | request: Request, 10 | env: WorkerEnv, 11 | routeMatchArray: RegExpMatchArray | undefined 12 | ) => Response | Promise 13 | } 14 | 15 | function createRoutes(env: WorkerEnv): Route[] { 16 | const routes: Route[] = [] 17 | const downloadScriptRoute: Route = { 18 | pathPattern: createRoute(getScriptDownloadPath(env)), 19 | handler: handleDownloadScript, 20 | } 21 | const ingressAPIRoute: Route = { 22 | pathPattern: createRoute(getGetResultPath(env)), 23 | handler: handleIngressAPI, 24 | } 25 | const statusRoute: Route = { 26 | pathPattern: createRoute(getStatusPagePath()), 27 | handler: (request, env) => handleStatusPage(request, env), 28 | } 29 | routes.push(downloadScriptRoute) 30 | routes.push(ingressAPIRoute) 31 | routes.push(statusRoute) 32 | 33 | return routes 34 | } 35 | 36 | function handleNoMatch(urlPathname: string): Response { 37 | const responseHeaders = new Headers({ 38 | 'content-type': 'application/json', 39 | }) 40 | 41 | return new Response(JSON.stringify({ error: `unmatched path ${urlPathname}` }), { 42 | status: 404, 43 | headers: responseHeaders, 44 | }) 45 | } 46 | 47 | export function handleRequestWithRoutes( 48 | request: Request, 49 | env: WorkerEnv, 50 | routes: Route[] 51 | ): Promise | Response { 52 | const url = new URL(request.url) 53 | for (const route of routes) { 54 | const matches = url.pathname.match(route.pathPattern) 55 | if (matches) { 56 | return route.handler(request, env, matches) 57 | } 58 | } 59 | 60 | return handleNoMatch(url.pathname) 61 | } 62 | 63 | export async function handleRequest(request: Request, env: WorkerEnv): Promise { 64 | const routes = createRoutes(env) 65 | return handleRequestWithRoutes(request, env, routes) 66 | } 67 | -------------------------------------------------------------------------------- /src/handlers/handleDownloadScript.ts: -------------------------------------------------------------------------------- 1 | import { 2 | fetchCacheable, 3 | addTrafficMonitoringSearchParamsForProCDN, 4 | createFallbackErrorResponse, 5 | getAgentScriptEndpoint, 6 | createResponseWithMaxAge, 7 | } from '../utils' 8 | import { getCdnUrl, WorkerEnv } from '../env' 9 | 10 | function copySearchParams(oldURL: URL, newURL: URL): void { 11 | newURL.search = new URLSearchParams(oldURL.search).toString() 12 | } 13 | 14 | function makeDownloadScriptRequest(request: Request, env: WorkerEnv): Promise { 15 | const cdnUrl = getCdnUrl(env)! 16 | 17 | const oldURL = new URL(request.url) 18 | const agentScriptEndpoint = getAgentScriptEndpoint(cdnUrl, oldURL.searchParams) 19 | const newURL = new URL(agentScriptEndpoint) 20 | copySearchParams(oldURL, newURL) 21 | addTrafficMonitoringSearchParamsForProCDN(newURL) 22 | 23 | const headers = new Headers(request.headers) 24 | headers.delete('Cookie') 25 | 26 | console.log(`Downloading script from cdnEndpoint ${newURL.toString()}...`) 27 | const newRequest = new Request(newURL.toString(), new Request(request, { headers })) 28 | const workerCacheTtl = 60 29 | const maxMaxAge = 60 * 60 30 | const maxSMaxAge = 60 31 | 32 | return fetchCacheable(newRequest, workerCacheTtl).then((res) => createResponseWithMaxAge(res, maxMaxAge, maxSMaxAge)) 33 | } 34 | 35 | export async function handleDownloadScript(request: Request, env: WorkerEnv): Promise { 36 | try { 37 | return await makeDownloadScriptRequest(request, env) 38 | } catch (e) { 39 | return createFallbackErrorResponse(e) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/handlers/handleIngressAPI.ts: -------------------------------------------------------------------------------- 1 | import { getIngressBaseHost, WorkerEnv } from '../env' 2 | import { 3 | addProxyIntegrationHeaders, 4 | addTrafficMonitoringSearchParamsForVisitorIdRequest, 5 | createErrorResponseForIngress, 6 | filterCookies, 7 | getVisitorIdEndpoint, 8 | createFallbackErrorResponse, 9 | } from '../utils' 10 | 11 | function copySearchParams(oldURL: URL, newURL: URL): void { 12 | newURL.search = new URLSearchParams(oldURL.search).toString() 13 | } 14 | 15 | function createRequestURL( 16 | ingressBaseUrl: string, 17 | receivedRequestURL: string, 18 | routeMatches: RegExpMatchArray | undefined 19 | ) { 20 | const routeSuffix = routeMatches ? routeMatches[1] : undefined 21 | const oldURL = new URL(receivedRequestURL) 22 | const endpoint = getVisitorIdEndpoint(ingressBaseUrl, oldURL.searchParams, routeSuffix) 23 | const newURL = new URL(endpoint) 24 | copySearchParams(oldURL, newURL) 25 | 26 | return newURL 27 | } 28 | 29 | async function makeIngressRequest( 30 | receivedRequest: Request, 31 | env: WorkerEnv, 32 | routeMatches: RegExpMatchArray | undefined 33 | ) { 34 | const ingressBaseUrl = getIngressBaseHost(env)! 35 | 36 | const requestURL = createRequestURL(ingressBaseUrl, receivedRequest.url, routeMatches) 37 | addTrafficMonitoringSearchParamsForVisitorIdRequest(requestURL) 38 | let headers = new Headers(receivedRequest.headers) 39 | headers = filterCookies(headers, (key) => key === '_iidt') 40 | addProxyIntegrationHeaders(headers, receivedRequest.url, env) 41 | const body = await (receivedRequest.headers.get('Content-Type') ? receivedRequest.blob() : Promise.resolve(null)) 42 | console.log(`sending ingress request to ${requestURL}...`) 43 | const request = new Request(requestURL, new Request(receivedRequest, { headers, body })) 44 | 45 | return fetch(request).then((oldResponse) => new Response(oldResponse.body, oldResponse)) 46 | } 47 | 48 | function makeCacheEndpointRequest( 49 | receivedRequest: Request, 50 | env: WorkerEnv, 51 | routeMatches: RegExpMatchArray | undefined 52 | ) { 53 | const ingressBaseUrl = getIngressBaseHost(env)! 54 | 55 | const requestURL = createRequestURL(ingressBaseUrl, receivedRequest.url, routeMatches) 56 | const headers = new Headers(receivedRequest.headers) 57 | headers.delete('Cookie') 58 | 59 | console.log(`sending cache request to ${requestURL}...`) 60 | const request = new Request(requestURL, new Request(receivedRequest, { headers })) 61 | 62 | return fetch(request).then((oldResponse) => new Response(oldResponse.body, oldResponse)) 63 | } 64 | 65 | export async function handleIngressAPI(request: Request, env: WorkerEnv, routeMatches: RegExpMatchArray | undefined) { 66 | if (request.method === 'GET') { 67 | try { 68 | return await makeCacheEndpointRequest(request, env, routeMatches) 69 | } catch (e) { 70 | return createFallbackErrorResponse(e) 71 | } 72 | } 73 | 74 | try { 75 | return await makeIngressRequest(request, env, routeMatches) 76 | } catch (e) { 77 | return createErrorResponseForIngress(request, e) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/handlers/handleStatusPage.ts: -------------------------------------------------------------------------------- 1 | import { 2 | WorkerEnv, 3 | isScriptDownloadPathSet, 4 | isGetResultPathSet, 5 | isProxySecretSet, 6 | agentScriptDownloadPathVarName, 7 | getResultPathVarName, 8 | proxySecretVarName, 9 | } from '../env' 10 | 11 | function generateNonce() { 12 | let result = '' 13 | const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' 14 | const indices = crypto.getRandomValues(new Uint8Array(24)) 15 | for (const index of indices) { 16 | result += characters[index % characters.length] 17 | } 18 | return btoa(result) 19 | } 20 | 21 | function buildHeaders(styleNonce: string): Headers { 22 | const headers = new Headers() 23 | headers.append('Content-Type', 'text/html') 24 | headers.append( 25 | 'Content-Security-Policy', 26 | `default-src 'none'; img-src https://fingerprint.com; style-src 'nonce-${styleNonce}'` 27 | ) 28 | return headers 29 | } 30 | 31 | function createWorkerVersionElement(): string { 32 | return ` 33 | 34 | ℹ️ Worker version: __current_worker_version__ 35 | 36 | ` 37 | } 38 | 39 | function createContactInformationElement(): string { 40 | return ` 41 | 42 | ❓Please reach out our support via support@fingerprint.com if you have any issues 43 | 44 | ` 45 | } 46 | 47 | function createEnvVarsInformationElement(env: WorkerEnv): string { 48 | const isScriptDownloadPathAvailable = isScriptDownloadPathSet(env) 49 | const isGetResultPathAvailable = isGetResultPathSet(env) 50 | const isProxySecretAvailable = isProxySecretSet(env) 51 | const isAllVarsAvailable = isScriptDownloadPathAvailable && isGetResultPathAvailable && isProxySecretAvailable 52 | 53 | let result = '' 54 | if (!isAllVarsAvailable) { 55 | result += ` 56 | 57 | The following environment variables are not defined. Please reach out our support team. 58 | 59 | ` 60 | if (!isScriptDownloadPathAvailable) { 61 | result += ` 62 | 63 | ⚠️ ${agentScriptDownloadPathVarName} is not set 64 | 65 | ` 66 | } 67 | if (!isGetResultPathAvailable) { 68 | result += ` 69 | 70 | ⚠️ ${getResultPathVarName} is not set 71 | 72 | ` 73 | } 74 | if (!isProxySecretAvailable) { 75 | result += ` 76 | 77 | ⚠️ ${proxySecretVarName} is not set 78 | 79 | ` 80 | } 81 | } else { 82 | result += ` 83 | 84 | ✅ All environment variables are set 85 | 86 | ` 87 | } 88 | return result 89 | } 90 | 91 | function buildBody(env: WorkerEnv, styleNonce: string): string { 92 | let body = ` 93 | 94 | 95 | 96 | Fingerprint Pro Cloudflare Worker 97 | 98 | 106 | 107 | 108 |

Fingerprint Pro Cloudflare Integration

109 | ` 110 | 111 | body += `🎉 Your Cloudflare worker is deployed` 112 | 113 | body += createWorkerVersionElement() 114 | body += createEnvVarsInformationElement(env) 115 | body += createContactInformationElement() 116 | 117 | body += ` 118 | 119 | 120 | ` 121 | return body 122 | } 123 | 124 | export function handleStatusPage(request: Request, env: WorkerEnv): Response { 125 | if (request.method !== 'GET') { 126 | return new Response(null, { status: 405 }) 127 | } 128 | 129 | const styleNonce = generateNonce() 130 | const headers = buildHeaders(styleNonce) 131 | const body = buildBody(env, styleNonce) 132 | 133 | return new Response(body, { 134 | status: 200, 135 | statusText: 'OK', 136 | headers, 137 | }) 138 | } 139 | -------------------------------------------------------------------------------- /src/handlers/index.ts: -------------------------------------------------------------------------------- 1 | import { handleDownloadScript } from './handleDownloadScript' 2 | import { handleIngressAPI } from './handleIngressAPI' 3 | import { handleStatusPage } from './handleStatusPage' 4 | 5 | export { handleDownloadScript, handleIngressAPI, handleStatusPage } 6 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { handleRequest } from './handler' 2 | import { WorkerEnv } from './env' 3 | import { returnHttpResponse } from './utils' 4 | 5 | export default { 6 | async fetch(request: Request, env: WorkerEnv) { 7 | return handleRequest(request, env).then(returnHttpResponse) 8 | }, 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/addProxyIntegrationHeaders.ts: -------------------------------------------------------------------------------- 1 | import { getProxySecret, WorkerEnv } from '../env' 2 | 3 | export function addProxyIntegrationHeaders(headers: Headers, url: string, env: WorkerEnv) { 4 | const proxySecret = getProxySecret(env) 5 | if (proxySecret) { 6 | headers.set('FPJS-Proxy-Secret', proxySecret) 7 | } 8 | 9 | headers.set('FPJS-Proxy-Client-IP', getIPFromHeaders(headers)) 10 | headers.set('FPJS-Proxy-Forwarded-Host', new URL(url).hostname) 11 | } 12 | 13 | export function getIPFromHeaders(headers: Headers) { 14 | const connectingIP = headers.get('CF-Connecting-IP') 15 | 16 | // Correctly handle CloudFlare's [Pseudo IPv4](https://developers.cloudflare.com/network/pseudo-ipv4/) feature set to `Overwrite headers` 17 | if (headers.get('Cf-Pseudo-IPv4') === connectingIP) { 18 | return headers.get('CF-Connecting-IPv6') || '' 19 | } 20 | 21 | return connectingIP || '' 22 | } 23 | -------------------------------------------------------------------------------- /src/utils/addTrafficMonitoring.ts: -------------------------------------------------------------------------------- 1 | const INT_VERSION = '__current_worker_version__' 2 | const PARAM_NAME = 'ii' 3 | 4 | function getTrafficMonitoringValue(type: 'procdn' | 'ingress'): string { 5 | return `fingerprintjs-pro-cloudflare/${INT_VERSION}/${type}` 6 | } 7 | 8 | export function addTrafficMonitoringSearchParamsForProCDN(url: URL) { 9 | url.searchParams.append(PARAM_NAME, getTrafficMonitoringValue('procdn')) 10 | } 11 | 12 | export function addTrafficMonitoringSearchParamsForVisitorIdRequest(url: URL) { 13 | url.searchParams.append(PARAM_NAME, getTrafficMonitoringValue('ingress')) 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/cookie.ts: -------------------------------------------------------------------------------- 1 | import { parse } from 'cookie' 2 | 3 | export function filterCookies(headers: Headers, filterFunc: (key: string) => boolean): Headers { 4 | const newHeaders = new Headers(headers) 5 | const cookie = parse(headers.get('cookie') || '') 6 | const filteredCookieList = [] 7 | for (const cookieName in cookie) { 8 | if (filterFunc(cookieName)) { 9 | filteredCookieList.push(`${cookieName}=${cookie[cookieName]}`) 10 | } 11 | } 12 | newHeaders.delete('cookie') 13 | if (filteredCookieList.length > 0) { 14 | newHeaders.set('cookie', filteredCookieList.join('; ')) 15 | } 16 | 17 | return newHeaders 18 | } 19 | -------------------------------------------------------------------------------- /src/utils/createErrorResponse.ts: -------------------------------------------------------------------------------- 1 | export interface FPJSResponse { 2 | v: '2' 3 | notifications?: Notification[] 4 | requestId: string 5 | error?: ErrorData 6 | products: {} 7 | } 8 | 9 | export interface Notification { 10 | level: 'info' | 'warning' | 'error' 11 | message: string 12 | } 13 | 14 | export interface ErrorData { 15 | code?: 'IntegrationFailed' 16 | message: string 17 | } 18 | 19 | function errorToString(error: string | Error | unknown): string { 20 | try { 21 | return typeof error === 'string' ? error : error instanceof Error ? error.message : String(error) 22 | } catch (e) { 23 | return 'unknown' 24 | } 25 | } 26 | 27 | function generateRandomString(length: number): string { 28 | let result = '' 29 | const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' 30 | for (let i = 0; i < length; i++) { 31 | result += characters.charAt(Math.floor(Math.random() * characters.length)) 32 | } 33 | return result 34 | } 35 | 36 | function generateRequestUniqueId(): string { 37 | return generateRandomString(6) 38 | } 39 | 40 | function generateRequestId(): string { 41 | const uniqueId = generateRequestUniqueId() 42 | const now = new Date().getTime() 43 | return `${now}.${uniqueId}` 44 | } 45 | 46 | export function createErrorResponseForIngress(request: Request, error: string | Error | unknown): Response { 47 | const reason = errorToString(error) 48 | const errorBody: ErrorData = { 49 | code: 'IntegrationFailed', 50 | message: `An error occurred with Cloudflare worker. Reason: ${reason}`, 51 | } 52 | const responseBody: FPJSResponse = { 53 | v: '2', 54 | error: errorBody, 55 | requestId: generateRequestId(), 56 | products: {}, 57 | } 58 | const requestOrigin = request.headers.get('origin') || '' 59 | const responseHeaders: HeadersInit = { 60 | 'Access-Control-Allow-Origin': requestOrigin, 61 | 'Access-Control-Allow-Credentials': 'true', 62 | 'content-type': 'application/json', 63 | } 64 | return new Response(JSON.stringify(responseBody), { status: 500, headers: responseHeaders }) 65 | } 66 | 67 | export function createFallbackErrorResponse(error: string | Error | unknown): Response { 68 | const responseBody = { error: errorToString(error) } 69 | return new Response(JSON.stringify(responseBody), { status: 500, headers: { 'content-type': 'application/json' } }) 70 | } 71 | -------------------------------------------------------------------------------- /src/utils/createResponseWithMaxAge.ts: -------------------------------------------------------------------------------- 1 | import { getCacheControlHeaderWithMaxAgeIfLower } from './getCacheControlHeaderWithMaxAgeIfLower' 2 | 3 | export function createResponseWithMaxAge(oldResponse: Response, maxMaxAge: number, maxSMaxAge: number): Response { 4 | const response = new Response(oldResponse.body, oldResponse) 5 | const oldCacheControlHeader = oldResponse.headers.get('cache-control') 6 | if (!oldCacheControlHeader) { 7 | return response 8 | } 9 | 10 | const cacheControlHeader = getCacheControlHeaderWithMaxAgeIfLower(oldCacheControlHeader, maxMaxAge, maxSMaxAge) 11 | response.headers.set('cache-control', cacheControlHeader) 12 | return response 13 | } 14 | -------------------------------------------------------------------------------- /src/utils/fetchCacheable.ts: -------------------------------------------------------------------------------- 1 | export async function fetchCacheable(request: Request, ttl: number) { 2 | return fetch(request, { cf: { cacheTtl: ttl } }) 3 | } 4 | -------------------------------------------------------------------------------- /src/utils/getCacheControlHeaderWithMaxAgeIfLower.ts: -------------------------------------------------------------------------------- 1 | function setDirective(directives: string[], directive: 'max-age' | 's-maxage', maxMaxAge: number) { 2 | const directiveIndex = directives.findIndex( 3 | (directivePair) => directivePair.split('=')[0].trim().toLowerCase() === directive 4 | ) 5 | if (directiveIndex === -1) { 6 | directives.push(`${directive}=${maxMaxAge}`) 7 | } else { 8 | const oldValue = Number(directives[directiveIndex].split('=')[1]) 9 | const newValue = Math.min(maxMaxAge, oldValue) 10 | directives[directiveIndex] = `${directive}=${newValue}` 11 | } 12 | } 13 | 14 | export function getCacheControlHeaderWithMaxAgeIfLower( 15 | cacheControlHeaderValue: string, 16 | maxMaxAge: number, 17 | maxSMaxAge: number 18 | ): string { 19 | const cacheControlDirectives = cacheControlHeaderValue.split(', ') 20 | 21 | setDirective(cacheControlDirectives, 'max-age', maxMaxAge) 22 | setDirective(cacheControlDirectives, 's-maxage', maxSMaxAge) 23 | 24 | return cacheControlDirectives.join(', ') 25 | } 26 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export { getCacheControlHeaderWithMaxAgeIfLower } from './getCacheControlHeaderWithMaxAgeIfLower' 2 | export { 3 | createErrorResponseForIngress, 4 | createFallbackErrorResponse, 5 | ErrorData, 6 | FPJSResponse, 7 | Notification, 8 | } from './createErrorResponse' 9 | export { fetchCacheable } from './fetchCacheable' 10 | export { 11 | addTrafficMonitoringSearchParamsForVisitorIdRequest, 12 | addTrafficMonitoringSearchParamsForProCDN, 13 | } from './addTrafficMonitoring' 14 | export { returnHttpResponse } from './returnHttpResponse' 15 | export { addProxyIntegrationHeaders, getIPFromHeaders } from './addProxyIntegrationHeaders' 16 | export { filterCookies } from './cookie' 17 | export { 18 | createRoute, 19 | addTrailingWildcard, 20 | removeTrailingSlashesAndMultiSlashes, 21 | replaceDot, 22 | addPathnameMatchBeforeRoute, 23 | addEndingTrailingSlashToRoute, 24 | } from './routing' 25 | export { getAgentScriptEndpoint, getVisitorIdEndpoint } from './proxyEndpoint' 26 | export { createResponseWithMaxAge } from './createResponseWithMaxAge' 27 | -------------------------------------------------------------------------------- /src/utils/proxyEndpoint.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_AGENT_VERSION = '3' 2 | 3 | export function getAgentScriptEndpoint(baseCdnUrl: string, searchParams: URLSearchParams): string { 4 | const apiKey = searchParams.get('apiKey') 5 | const apiVersion = searchParams.get('version') || DEFAULT_AGENT_VERSION 6 | 7 | const base = `https://${baseCdnUrl}/v${apiVersion}/${apiKey}` 8 | const loaderVersion = searchParams.get('loaderVersion') 9 | const lv = loaderVersion ? `/loader_v${loaderVersion}.js` : '' 10 | 11 | return `${base}${lv}` 12 | } 13 | 14 | export function getVisitorIdEndpoint( 15 | baseIngressUrl: string, 16 | searchParams: URLSearchParams, 17 | pathSuffix: string | undefined = undefined 18 | ): string { 19 | const region = searchParams.get('region') || 'us' 20 | let prefix = '' 21 | switch (region) { 22 | case 'eu': 23 | prefix = 'eu.' 24 | break 25 | case 'ap': 26 | prefix = 'ap.' 27 | break 28 | default: 29 | prefix = '' 30 | break 31 | } 32 | let suffix = pathSuffix ?? '' 33 | if (suffix.length > 0 && !suffix.startsWith('/')) { 34 | suffix = '/' + suffix 35 | } 36 | return `https://${prefix}${baseIngressUrl}${suffix}` 37 | } 38 | -------------------------------------------------------------------------------- /src/utils/returnHttpResponse.ts: -------------------------------------------------------------------------------- 1 | export function returnHttpResponse(oldResponse: Response): Response { 2 | oldResponse.headers.delete('Strict-Transport-Security') 3 | return oldResponse 4 | } 5 | -------------------------------------------------------------------------------- /src/utils/routing.ts: -------------------------------------------------------------------------------- 1 | export function removeTrailingSlashesAndMultiSlashes(str: string): string { 2 | return str.replace(/\/+$/, '').replace(/(?<=\/)\/+/, '') 3 | } 4 | 5 | export function addTrailingWildcard(str: string): string { 6 | return str.replace(/(\/?)\*/g, '($1.*)?') 7 | } 8 | 9 | export function replaceDot(str: string): string { 10 | return str.replace(/\.(?=[\w(])/, '\\.') 11 | } 12 | 13 | export function addPathnameMatchBeforeRoute(route: string): string { 14 | return `[\\/[A-Za-z0-9:._-]*${route}` 15 | } 16 | 17 | export function addEndingTrailingSlashToRoute(route: string): string { 18 | return `${route}\\/*` 19 | } 20 | 21 | export function createRoute(route: string): RegExp { 22 | let routeRegExp = route 23 | // routeRegExp = addTrailingWildcard(routeRegExp) // Can be uncommented if wildcard (*) is needed 24 | routeRegExp = removeTrailingSlashesAndMultiSlashes(routeRegExp) 25 | routeRegExp = addPathnameMatchBeforeRoute(routeRegExp) 26 | routeRegExp = addEndingTrailingSlashToRoute(routeRegExp) 27 | // routeRegExp = replaceDot(routeRegExp) // Can be uncommented if dot (.) is needed 28 | return RegExp(`^${routeRegExp}$`) 29 | } 30 | -------------------------------------------------------------------------------- /test/e2e/utils/areVisitorIdAndRequestIdValid.ts: -------------------------------------------------------------------------------- 1 | export function areVisitorIdAndRequestIdValid(visitorId: string, requestId: string): boolean { 2 | const isVisitorIdFormatValid = /^[a-zA-Z\d]{20}$/.test(visitorId) 3 | const isRequestIdFormatValid = /^\d{13}\.[a-zA-Z\d]{6}$/.test(requestId) 4 | return isRequestIdFormatValid && isVisitorIdFormatValid 5 | } 6 | -------------------------------------------------------------------------------- /test/e2e/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { areVisitorIdAndRequestIdValid } from './areVisitorIdAndRequestIdValid' 2 | import { wait } from './wait' 3 | 4 | export { areVisitorIdAndRequestIdValid, wait } 5 | -------------------------------------------------------------------------------- /test/e2e/utils/wait.ts: -------------------------------------------------------------------------------- 1 | export const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) 2 | -------------------------------------------------------------------------------- /test/e2e/visitorId.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect, Page, request, APIRequestContext } from '@playwright/test' 2 | import { areVisitorIdAndRequestIdValid, wait } from './utils' 3 | import { ElementHandle } from 'playwright-core' 4 | 5 | const INT_VERSION = process.env.worker_version || '' 6 | const WORKER_PATH = process.env.worker_path || 'fpjs-worker-default' 7 | const GET_RESULT_PATH = process.env.get_result_path || 'get-result-default' 8 | const AGENT_DOWNLOAD_PATH = process.env.agent_download_path || 'agent-download-default' 9 | 10 | const testWebsiteURL = new URL(`https://${process.env.test_client_domain}`) 11 | testWebsiteURL.searchParams.set('worker-path', WORKER_PATH) 12 | testWebsiteURL.searchParams.set('get-result-path', GET_RESULT_PATH) 13 | testWebsiteURL.searchParams.set('agent-download-path', AGENT_DOWNLOAD_PATH) 14 | 15 | const workerDomain = process.env.test_client_domain 16 | 17 | interface GetResult { 18 | requestId: string 19 | visitorId: string 20 | visitorFound: boolean 21 | } 22 | 23 | test.describe('visitorId', () => { 24 | async function waitUntilOnline( 25 | reqContext: APIRequestContext, 26 | expectedVersion: string, 27 | retryCounter = 0, 28 | maxRetries = 10 29 | ): Promise { 30 | const statusEndpoint = `https://${workerDomain}/${WORKER_PATH}/status` 31 | console.log({ statusEndpoint }) 32 | const res = await reqContext.get(statusEndpoint) 33 | try { 34 | const responseBody = await res.text() 35 | if (responseBody.includes('Your Cloudflare worker is deployed')) { 36 | const matches = responseBody.match(/Worker version: (.+)/) 37 | if (matches && matches.length > 0) { 38 | const version = matches[1] 39 | if (version === expectedVersion) { 40 | return Promise.resolve(true) 41 | } 42 | } 43 | } 44 | } catch (e) { 45 | // do nothing 46 | } 47 | 48 | const newRetryCounter = retryCounter + 1 49 | if (newRetryCounter > maxRetries) { 50 | return Promise.resolve(false) 51 | } 52 | 53 | await wait(1000) 54 | return waitUntilOnline(reqContext, expectedVersion, newRetryCounter, maxRetries) 55 | } 56 | 57 | async function testForElement(el: ElementHandle) { 58 | const textContent = await el.textContent() 59 | expect(textContent != null).toStrictEqual(true) 60 | let jsonContent 61 | try { 62 | jsonContent = JSON.parse(textContent as string) 63 | } catch (e) { 64 | // do nothing 65 | } 66 | expect(jsonContent).toBeTruthy() 67 | const { visitorId, requestId } = jsonContent as GetResult 68 | expect(areVisitorIdAndRequestIdValid(visitorId, requestId)).toStrictEqual(true) 69 | } 70 | 71 | async function runTest(page: Page, url: string) { 72 | console.log(`Running goto url: ${url}...`) 73 | await page.goto(url, { 74 | waitUntil: 'networkidle', 75 | }) 76 | 77 | await wait(5000) 78 | await page.waitForSelector('#result > code').then(testForElement) 79 | await page.waitForSelector('#cdn-result > code').then(testForElement) 80 | } 81 | 82 | test('should show visitorId in the HTML (NPM & CDN)', async ({ page }) => { 83 | const reqContext = await request.newContext() 84 | const isOnline = await waitUntilOnline(reqContext, INT_VERSION) 85 | expect(isOnline).toBeTruthy() 86 | 87 | await runTest(page, testWebsiteURL.href) 88 | }) 89 | }) 90 | -------------------------------------------------------------------------------- /test/endpoints/__snapshots__/status.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`status page content when agent script download path and proxy secret are not set 1`] = ` 4 | " 5 | 6 | 7 | 8 | Fingerprint Pro Cloudflare Worker 9 | 10 | 18 | 19 | 20 |

Fingerprint Pro Cloudflare Integration

21 | 🎉 Your Cloudflare worker is deployed 22 | 23 | ℹ️ Worker version: __current_worker_version__ 24 | 25 | 26 | 27 | The following environment variables are not defined. Please reach out our support team. 28 | 29 | 30 | 31 | ⚠️ AGENT_SCRIPT_DOWNLOAD_PATH is not set 32 | 33 | 34 | 35 | ⚠️ PROXY_SECRET is not set 36 | 37 | 38 | 39 | ❓Please reach out our support via support@fingerprint.com if you have any issues 40 | 41 | 42 | 43 | 44 | " 45 | `; 46 | 47 | exports[`status page content when agent script download path is not set 1`] = ` 48 | " 49 | 50 | 51 | 52 | Fingerprint Pro Cloudflare Worker 53 | 54 | 62 | 63 | 64 |

Fingerprint Pro Cloudflare Integration

65 | 🎉 Your Cloudflare worker is deployed 66 | 67 | ℹ️ Worker version: __current_worker_version__ 68 | 69 | 70 | 71 | The following environment variables are not defined. Please reach out our support team. 72 | 73 | 74 | 75 | ⚠️ AGENT_SCRIPT_DOWNLOAD_PATH is not set 76 | 77 | 78 | 79 | ❓Please reach out our support via support@fingerprint.com if you have any issues 80 | 81 | 82 | 83 | 84 | " 85 | `; 86 | 87 | exports[`status page content when all variables are set 1`] = ` 88 | " 89 | 90 | 91 | 92 | Fingerprint Pro Cloudflare Worker 93 | 94 | 102 | 103 | 104 |

Fingerprint Pro Cloudflare Integration

105 | 🎉 Your Cloudflare worker is deployed 106 | 107 | ℹ️ Worker version: __current_worker_version__ 108 | 109 | 110 | 111 | ✅ All environment variables are set 112 | 113 | 114 | 115 | ❓Please reach out our support via support@fingerprint.com if you have any issues 116 | 117 | 118 | 119 | 120 | " 121 | `; 122 | 123 | exports[`status page content when get result path is not set 1`] = ` 124 | " 125 | 126 | 127 | 128 | Fingerprint Pro Cloudflare Worker 129 | 130 | 138 | 139 | 140 |

Fingerprint Pro Cloudflare Integration

141 | 🎉 Your Cloudflare worker is deployed 142 | 143 | ℹ️ Worker version: __current_worker_version__ 144 | 145 | 146 | 147 | The following environment variables are not defined. Please reach out our support team. 148 | 149 | 150 | 151 | ⚠️ GET_RESULT_PATH is not set 152 | 153 | 154 | 155 | ❓Please reach out our support via support@fingerprint.com if you have any issues 156 | 157 | 158 | 159 | 160 | " 161 | `; 162 | 163 | exports[`status page content when proxy secret is not set 1`] = ` 164 | " 165 | 166 | 167 | 168 | Fingerprint Pro Cloudflare Worker 169 | 170 | 178 | 179 | 180 |

Fingerprint Pro Cloudflare Integration

181 | 🎉 Your Cloudflare worker is deployed 182 | 183 | ℹ️ Worker version: __current_worker_version__ 184 | 185 | 186 | 187 | The following environment variables are not defined. Please reach out our support team. 188 | 189 | 190 | 191 | ⚠️ PROXY_SECRET is not set 192 | 193 | 194 | 195 | ❓Please reach out our support via support@fingerprint.com if you have any issues 196 | 197 | 198 | 199 | 200 | " 201 | `; 202 | -------------------------------------------------------------------------------- /test/endpoints/agentDownload.test.ts: -------------------------------------------------------------------------------- 1 | import worker from '../../src/index' 2 | import { WorkerEnv } from '../../src/env' 3 | import { config } from '../../src/config' 4 | 5 | const workerEnv: WorkerEnv = { 6 | FPJS_CDN_URL: config.fpcdn, 7 | FPJS_INGRESS_BASE_HOST: config.ingressApi, 8 | PROXY_SECRET: 'proxy_secret', 9 | GET_RESULT_PATH: 'get_result', 10 | AGENT_SCRIPT_DOWNLOAD_PATH: 'agent_download', 11 | } 12 | 13 | describe('agent download cdn url from worker env', () => { 14 | let fetchSpy: jest.MockInstance, any> 15 | let reqURL: URL 16 | let receivedReqURL = '' 17 | 18 | beforeAll(() => { 19 | fetchSpy = jest.spyOn(globalThis, 'fetch') 20 | fetchSpy.mockImplementation(async (input, init) => { 21 | const req = new Request(input, init) 22 | receivedReqURL = req.url 23 | return new Response() 24 | }) 25 | }) 26 | 27 | beforeEach(() => { 28 | reqURL = new URL('https://example.com/worker_path/agent_download') 29 | reqURL.searchParams.append('apiKey', 'someApiKey') 30 | 31 | receivedReqURL = '' 32 | }) 33 | 34 | afterAll(() => { 35 | fetchSpy.mockRestore() 36 | }) 37 | 38 | test('custom cdn url', async () => { 39 | const env = { 40 | ...workerEnv, 41 | FPJS_CDN_URL: 'cdn.test.com', 42 | } satisfies WorkerEnv 43 | 44 | const req = new Request(reqURL.toString()) 45 | await worker.fetch(req, env) 46 | const receivedURL = new URL(receivedReqURL) 47 | expect(receivedURL.origin).toBe(`https://cdn.test.com`) 48 | expect(receivedURL.pathname).toBe('/v3/someApiKey') 49 | }) 50 | 51 | test('null cdn url', async () => { 52 | const env = { 53 | ...workerEnv, 54 | FPJS_CDN_URL: null, 55 | } satisfies WorkerEnv 56 | 57 | const req = new Request(reqURL.toString()) 58 | await worker.fetch(req, env) 59 | const receivedURL = new URL(receivedReqURL) 60 | expect(receivedURL.origin).toBe(`https://${config.fpcdn}`.toLowerCase()) 61 | expect(receivedURL.pathname).toBe('/v3/someApiKey') 62 | }) 63 | }) 64 | 65 | describe('agent download request proxy URL', () => { 66 | let fetchSpy: jest.MockInstance, any> 67 | let reqURL: URL 68 | let receivedReqURL = '' 69 | 70 | beforeAll(() => { 71 | fetchSpy = jest.spyOn(globalThis, 'fetch') 72 | fetchSpy.mockImplementation(async (input, init) => { 73 | const req = new Request(input, init) 74 | receivedReqURL = req.url 75 | return new Response() 76 | }) 77 | }) 78 | 79 | beforeEach(() => { 80 | reqURL = new URL('https://example.com/worker_path/agent_download') 81 | reqURL.searchParams.append('apiKey', 'someApiKey') 82 | 83 | receivedReqURL = '' 84 | }) 85 | 86 | afterAll(() => { 87 | fetchSpy.mockRestore() 88 | }) 89 | 90 | test('no version', async () => { 91 | const req = new Request(reqURL.toString()) 92 | await worker.fetch(req, workerEnv) 93 | const receivedURL = new URL(receivedReqURL) 94 | expect(receivedURL.origin).toBe(`https://${config.fpcdn}`.toLowerCase()) 95 | expect(receivedURL.pathname).toBe('/v3/someApiKey') 96 | }) 97 | 98 | test('version but no loaderVersion', async () => { 99 | reqURL.searchParams.append('version', '4') 100 | const req = new Request(reqURL.toString()) 101 | await worker.fetch(req, workerEnv) 102 | const receivedURL = new URL(receivedReqURL) 103 | expect(receivedURL.origin).toBe(`https://${config.fpcdn}`.toLowerCase()) 104 | expect(receivedURL.pathname).toBe('/v4/someApiKey') 105 | }) 106 | 107 | test('both version and loaderVersion', async () => { 108 | reqURL.searchParams.append('version', '5') 109 | reqURL.searchParams.append('loaderVersion', '1.2.3') 110 | const req = new Request(reqURL.toString()) 111 | await worker.fetch(req, workerEnv) 112 | const receivedURL = new URL(receivedReqURL) 113 | expect(receivedURL.origin).toBe(`https://${config.fpcdn}`.toLowerCase()) 114 | expect(receivedURL.pathname).toBe('/v5/someApiKey/loader_v1.2.3.js') 115 | }) 116 | 117 | test('invalid apiKey, version and loaderVersion', async () => { 118 | reqURL.searchParams.set('apiKey', 'foo.bar/baz') 119 | reqURL.searchParams.set('version', 'bar.foo/baz') 120 | reqURL.searchParams.set('loaderVersion', 'baz.bar/foo') 121 | const req = new Request(reqURL.toString()) 122 | await worker.fetch(req, workerEnv) 123 | const receivedURL = new URL(receivedReqURL) 124 | expect(receivedURL.origin).toBe(`https://${config.fpcdn}`.toLowerCase()) 125 | }) 126 | }) 127 | 128 | describe('agent download request query parameters', () => { 129 | let fetchSpy: jest.MockInstance, any> 130 | let reqURL: URL 131 | let receivedReqURL = '' 132 | 133 | beforeAll(() => { 134 | fetchSpy = jest.spyOn(globalThis, 'fetch') 135 | fetchSpy.mockImplementation(async (input, init) => { 136 | const req = new Request(input, init) 137 | receivedReqURL = req.url 138 | 139 | return new Response('') 140 | }) 141 | }) 142 | 143 | beforeEach(() => { 144 | reqURL = new URL('https://example.com/worker_path/agent_download') 145 | reqURL.searchParams.append('apiKey', 'someApiKey') 146 | reqURL.searchParams.append('someKey', 'someValue') 147 | 148 | receivedReqURL = '' 149 | }) 150 | 151 | afterAll(() => { 152 | fetchSpy.mockRestore() 153 | }) 154 | 155 | test('traffic monitoring when no ii parameter before', async () => { 156 | const req = new Request(reqURL.toString()) 157 | await worker.fetch(req, workerEnv) 158 | const url = new URL(receivedReqURL) 159 | const iiValues = url.searchParams.getAll('ii') 160 | expect(iiValues.length).toBe(1) 161 | expect(iiValues[0]).toBe('fingerprintjs-pro-cloudflare/__current_worker_version__/procdn') 162 | }) 163 | test('traffic monitoring when there is ii parameter before', async () => { 164 | reqURL.searchParams.append('ii', 'fingerprintjs-pro-react/v1.2.3') 165 | const req = new Request(reqURL.toString()) 166 | await worker.fetch(req, workerEnv) 167 | const url = new URL(receivedReqURL) 168 | const iiValues = url.searchParams.getAll('ii') 169 | expect(iiValues.length).toBe(2) 170 | expect(iiValues[0]).toBe('fingerprintjs-pro-react/v1.2.3') 171 | expect(iiValues[1]).toBe('fingerprintjs-pro-cloudflare/__current_worker_version__/procdn') 172 | }) 173 | test('whole query string when no ii parameter before', async () => { 174 | const req = new Request(reqURL.toString()) 175 | await worker.fetch(req, workerEnv) 176 | const url = new URL(receivedReqURL) 177 | expect(url.search).toBe( 178 | '?apiKey=someApiKey' + 179 | '&someKey=someValue' + 180 | '&ii=fingerprintjs-pro-cloudflare%2F__current_worker_version__%2Fprocdn' 181 | ) 182 | }) 183 | test('whole query string when there is ii parameter before', async () => { 184 | reqURL.searchParams.append('ii', 'fingerprintjs-pro-react/v1.2.3') 185 | const req = new Request(reqURL.toString()) 186 | await worker.fetch(req, workerEnv) 187 | const url = new URL(receivedReqURL) 188 | expect(url.search).toBe( 189 | '?apiKey=someApiKey' + 190 | '&someKey=someValue' + 191 | '&ii=fingerprintjs-pro-react%2Fv1.2.3' + 192 | '&ii=fingerprintjs-pro-cloudflare%2F__current_worker_version__%2Fprocdn' 193 | ) 194 | }) 195 | }) 196 | 197 | describe('agent download request HTTP headers', () => { 198 | let fetchSpy: jest.MockInstance, any> 199 | const reqURL = new URL('https://example.com/worker_path/agent_download?apiKey=someApiKey') 200 | let receivedHeaders: Headers 201 | 202 | beforeAll(() => { 203 | fetchSpy = jest.spyOn(globalThis, 'fetch') 204 | fetchSpy.mockImplementation(async (input, init) => { 205 | const req = new Request(input, init) 206 | receivedHeaders = req.headers 207 | 208 | return new Response('') 209 | }) 210 | }) 211 | 212 | beforeEach(() => { 213 | receivedHeaders = new Headers() 214 | }) 215 | 216 | afterAll(() => { 217 | fetchSpy.mockRestore() 218 | }) 219 | 220 | test('req headers are the same (except Cookie)', async () => { 221 | const reqHeaders = new Headers({ 222 | accept: '*/*', 223 | 'cache-control': 'no-cache', 224 | 'accept-language': 'en-US', 225 | 'user-agent': 'Mozilla/5.0', 226 | 'x-some-header': 'some value', 227 | }) 228 | const req = new Request(reqURL.toString(), { headers: reqHeaders }) 229 | await worker.fetch(req, workerEnv) 230 | receivedHeaders.forEach((value, key) => { 231 | expect(reqHeaders.get(key)).toBe(value) 232 | }) 233 | reqHeaders.forEach((value, key) => { 234 | expect(receivedHeaders.get(key)).toBe(value) 235 | }) 236 | }) 237 | test('req headers do not have cookies', async () => { 238 | const reqHeaders = new Headers({ 239 | accept: '*/*', 240 | 'cache-control': 'no-cache', 241 | 'accept-language': 'en-US', 242 | 'user-agent': 'Mozilla/5.0', 243 | 'x-some-header': 'some value', 244 | cookie: 245 | '_iidt=GlMQaHMfzYvomxCuA7Uymy7ArmjH04jPkT+enN7j/Xk8tJG+UYcQV+Qw60Ry4huw9bmDoO/smyjQp5vLCuSf8t4Jow==; auth_token=123456', 246 | }) 247 | const req = new Request(reqURL.toString(), { headers: reqHeaders }) 248 | await worker.fetch(req, workerEnv) 249 | expect(receivedHeaders.has('cookie')).toBe(false) 250 | }) 251 | }) 252 | 253 | describe('agent download request cache durations', () => { 254 | let fetchSpy: jest.MockInstance, any> 255 | const reqURL = new URL('https://example.com/worker_path/agent_download?apiKey=someApiKey') 256 | let receivedCfObject: IncomingRequestCfProperties | RequestInitCfProperties | null | undefined = null 257 | 258 | beforeAll(() => { 259 | fetchSpy = jest.spyOn(globalThis, 'fetch') 260 | }) 261 | 262 | beforeEach(() => { 263 | receivedCfObject = null 264 | }) 265 | 266 | afterAll(() => { 267 | fetchSpy.mockRestore() 268 | }) 269 | 270 | test('browser cache set to an hour when original value is higher', async () => { 271 | fetchSpy.mockImplementation(async (_, init) => { 272 | receivedCfObject = (init as RequestInit).cf 273 | const responseHeaders = new Headers({ 274 | 'cache-control': 'public, max-age=3613', 275 | }) 276 | 277 | return new Response('', { headers: responseHeaders }) 278 | }) 279 | const req = new Request(reqURL.toString()) 280 | const response = await worker.fetch(req, workerEnv) 281 | expect(response.headers.get('cache-control')).toBe('public, max-age=3600, s-maxage=60') 282 | }) 283 | test('browser cache is the same when original value is lower than an hour', async () => { 284 | fetchSpy.mockImplementation(async (_, init) => { 285 | receivedCfObject = (init as RequestInit).cf 286 | const responseHeaders = new Headers({ 287 | 'cache-control': 'public, max-age=100', 288 | }) 289 | 290 | return new Response('', { headers: responseHeaders }) 291 | }) 292 | const req = new Request(reqURL.toString()) 293 | const response = await worker.fetch(req, workerEnv) 294 | expect(response.headers.get('cache-control')).toBe('public, max-age=100, s-maxage=60') 295 | }) 296 | test('proxy cache set to a minute when original value is higher', async () => { 297 | fetchSpy.mockImplementation(async (_, init) => { 298 | receivedCfObject = (init as RequestInit).cf 299 | const responseHeaders = new Headers({ 300 | 'cache-control': 'public, max-age=3613, s-maxage=575500', 301 | }) 302 | 303 | return new Response('', { headers: responseHeaders }) 304 | }) 305 | const req = new Request(reqURL.toString()) 306 | const response = await worker.fetch(req, workerEnv) 307 | expect(response.headers.get('cache-control')).toBe('public, max-age=3600, s-maxage=60') 308 | }) 309 | test('proxy cache is the same when original value is lower than a minute', async () => { 310 | fetchSpy.mockImplementation(async (_, init) => { 311 | receivedCfObject = (init as RequestInit).cf 312 | const responseHeaders = new Headers({ 313 | 'cache-control': 'public, max-age=3613, s-maxage=10', 314 | }) 315 | 316 | return new Response('', { headers: responseHeaders }) 317 | }) 318 | const req = new Request(reqURL.toString()) 319 | const response = await worker.fetch(req, workerEnv) 320 | expect(response.headers.get('cache-control')).toBe('public, max-age=3600, s-maxage=10') 321 | }) 322 | test('cloudflare network cache is set to 1 min', async () => { 323 | fetchSpy.mockImplementation(async (_, init) => { 324 | receivedCfObject = (init as RequestInit).cf 325 | const responseHeaders = new Headers({ 326 | 'cache-control': 'public, max-age=3613, s-maxage=575500', 327 | }) 328 | 329 | return new Response('', { headers: responseHeaders }) 330 | }) 331 | const req = new Request(reqURL.toString()) 332 | await worker.fetch(req, workerEnv) 333 | expect(receivedCfObject).toMatchObject({ cacheTtl: 60 }) 334 | expect({ cacheTtl: 60 }).toMatchObject(receivedCfObject!) 335 | }) 336 | }) 337 | 338 | describe('agent download request HTTP method', () => { 339 | let fetchSpy: jest.MockInstance, any> 340 | const reqURL = new URL('https://example.com/worker_path/agent_download?apiKey=someApiKey') 341 | let requestMethod: string 342 | 343 | beforeAll(() => { 344 | fetchSpy = jest.spyOn(globalThis, 'fetch') 345 | fetchSpy.mockImplementation(async (input, init) => { 346 | const req = new Request(input, init) 347 | requestMethod = req.method 348 | 349 | return new Response('') 350 | }) 351 | }) 352 | 353 | beforeEach(() => { 354 | requestMethod = '' 355 | }) 356 | 357 | afterAll(() => { 358 | fetchSpy.mockRestore() 359 | }) 360 | 361 | test('when method is GET', async () => { 362 | const req = new Request(reqURL.toString(), { method: 'GET' }) 363 | await worker.fetch(req, workerEnv) 364 | expect(requestMethod).toBe('GET') 365 | }) 366 | 367 | test('when method is POST', async () => { 368 | const req = new Request(reqURL.toString(), { method: 'POST' }) 369 | await worker.fetch(req, workerEnv) 370 | expect(requestMethod).toBe('POST') 371 | }) 372 | }) 373 | 374 | describe('agent download response', () => { 375 | let fetchSpy: jest.MockInstance, any> 376 | 377 | beforeAll(() => { 378 | fetchSpy = jest.spyOn(globalThis, 'fetch') 379 | }) 380 | 381 | afterAll(() => { 382 | fetchSpy.mockRestore() 383 | }) 384 | 385 | test("response body and content-type don't change", async () => { 386 | const agentScript = 387 | '/** FingerprintJS Pro - Copyright (c) FingerprintJS, Inc, 2022 (https://fingerprint.com) /** function hi() { console.log("hello world!!") }' 388 | fetchSpy.mockImplementation(async () => { 389 | const headers: HeadersInit = { 390 | 'content-type': 'text/javascript; charset=utf-8', 391 | } 392 | return new Response(agentScript, { headers }) 393 | }) 394 | const req = new Request('https://example.com/worker_path/agent_download') 395 | const response = await worker.fetch(req, workerEnv) 396 | expect(response.headers.get('content-type')).toBe('text/javascript; charset=utf-8') 397 | expect(await response.text()).toBe(agentScript) 398 | }) 399 | test('strict-transport-security is removed', async () => { 400 | fetchSpy.mockImplementation(async () => { 401 | const headers: HeadersInit = { 402 | 'strict-transport-security': 'max-age=63072000', 403 | } 404 | return new Response('', { headers }) 405 | }) 406 | const req = new Request('https://example.com/worker_path/agent_download') 407 | const response = await worker.fetch(req, workerEnv) 408 | expect(response.headers.get('strict-transport-security')).toBe(null) 409 | }) 410 | test('other headers remain the same', async () => { 411 | fetchSpy.mockImplementation(async () => { 412 | const headers: HeadersInit = { 413 | 'some-header': 'some-value', 414 | } 415 | return new Response('', { headers }) 416 | }) 417 | const req = new Request('https://example.com/worker_path/agent_download') 418 | const response = await worker.fetch(req, workerEnv) 419 | expect(response.headers.get('some-header')).toBe('some-value') 420 | }) 421 | test('failure response', async () => { 422 | fetchSpy.mockImplementation(async () => { 423 | return new Response('some error', { status: 500, headers: { 'content-type': 'text/plain; charset=UTF-8' } }) 424 | }) 425 | const req = new Request('https://example.com/worker_path/agent_download') 426 | const response = await worker.fetch(req, workerEnv) 427 | expect(response.headers.get('content-type')).toBe('text/plain; charset=UTF-8') 428 | expect(response.status).toBe(500) 429 | expect(await response.text()).toBe('some error') 430 | }) 431 | test('error response', async () => { 432 | fetchSpy.mockImplementation(async () => { 433 | throw new Error('some error') 434 | }) 435 | const req = new Request('https://example.com/worker_path/agent_download') 436 | const response = await worker.fetch(req, workerEnv) 437 | expect(response.headers.get('content-type')).toBe('application/json') 438 | expect(response.status).toBe(500) 439 | const responseBody = await response.json() 440 | // Note: toStrictEqual does not work for some reason, using double toMatchObject instead 441 | expect(responseBody).toMatchObject({ error: 'some error' }) 442 | expect({ error: 'some error' }).toMatchObject(responseBody as any) 443 | }) 444 | }) 445 | -------------------------------------------------------------------------------- /test/endpoints/getResult.test.ts: -------------------------------------------------------------------------------- 1 | import { WorkerEnv } from '../../src/env' 2 | import worker from '../../src' 3 | import { FPJSResponse } from '../../src/utils' 4 | import { config } from '../../src/config' 5 | 6 | const workerEnv: WorkerEnv = { 7 | FPJS_CDN_URL: config.fpcdn, 8 | FPJS_INGRESS_BASE_HOST: config.ingressApi, 9 | PROXY_SECRET: 'proxy_secret', 10 | GET_RESULT_PATH: 'get_result', 11 | AGENT_SCRIPT_DOWNLOAD_PATH: 'agent_download', 12 | } 13 | 14 | describe('ingress API url from worker env', () => { 15 | let fetchSpy: jest.MockInstance, any> 16 | let reqURL: URL 17 | let receivedReqURL = '' 18 | 19 | beforeAll(() => { 20 | fetchSpy = jest.spyOn(globalThis, 'fetch') 21 | fetchSpy.mockImplementation(async (input, init) => { 22 | const req = new Request(input, init) 23 | receivedReqURL = req.url 24 | return new Response() 25 | }) 26 | }) 27 | 28 | beforeEach(() => { 29 | reqURL = new URL('https://example.com/worker_path/get_result') 30 | 31 | receivedReqURL = '' 32 | }) 33 | 34 | afterAll(() => { 35 | fetchSpy.mockRestore() 36 | }) 37 | 38 | test('custom ingress url', async () => { 39 | const env = { 40 | ...workerEnv, 41 | FPJS_INGRESS_BASE_HOST: 'ingress.test.com', 42 | } satisfies WorkerEnv 43 | 44 | const req = new Request(reqURL.toString(), { method: 'POST' }) 45 | await worker.fetch(req, env) 46 | const receivedURL = new URL(receivedReqURL) 47 | expect(receivedURL.origin).toBe(`https://ingress.test.com`) 48 | }) 49 | 50 | test('null ingress url', async () => { 51 | const env = { 52 | ...workerEnv, 53 | FPJS_INGRESS_BASE_HOST: null, 54 | } satisfies WorkerEnv 55 | 56 | const req = new Request(reqURL.toString(), { method: 'POST' }) 57 | await worker.fetch(req, env) 58 | const receivedURL = new URL(receivedReqURL) 59 | expect(receivedURL.origin).toBe(`https://${config.ingressApi}`.toLowerCase()) 60 | }) 61 | }) 62 | 63 | describe('ingress API request proxy URL', () => { 64 | let fetchSpy: jest.MockInstance, any> 65 | let reqURL: URL 66 | let receivedReqURL = '' 67 | 68 | beforeAll(() => { 69 | fetchSpy = jest.spyOn(globalThis, 'fetch') 70 | fetchSpy.mockImplementation(async (input, init) => { 71 | const req = new Request(input, init) 72 | receivedReqURL = req.url 73 | return new Response() 74 | }) 75 | }) 76 | 77 | beforeEach(() => { 78 | reqURL = new URL('https://example.com/worker_path/get_result') 79 | 80 | receivedReqURL = '' 81 | }) 82 | 83 | afterAll(() => { 84 | fetchSpy.mockRestore() 85 | }) 86 | 87 | test('no region', async () => { 88 | const req = new Request(reqURL.toString(), { method: 'POST' }) 89 | await worker.fetch(req, workerEnv) 90 | const receivedURL = new URL(receivedReqURL) 91 | expect(receivedURL.origin).toBe(`https://${config.ingressApi}`.toLowerCase()) 92 | }) 93 | 94 | test('us region', async () => { 95 | reqURL.searchParams.append('region', 'us') 96 | const req = new Request(reqURL.toString(), { method: 'POST' }) 97 | await worker.fetch(req, workerEnv) 98 | const receivedURL = new URL(receivedReqURL) 99 | expect(receivedURL.origin).toBe(`https://${config.ingressApi}`.toLowerCase()) 100 | }) 101 | 102 | test('eu region', async () => { 103 | reqURL.searchParams.append('region', 'eu') 104 | const req = new Request(reqURL.toString(), { method: 'POST' }) 105 | await worker.fetch(req, workerEnv) 106 | const receivedURL = new URL(receivedReqURL) 107 | expect(receivedURL.origin).toBe(`https://eu.${config.ingressApi}`.toLowerCase()) 108 | }) 109 | 110 | test('ap region', async () => { 111 | reqURL.searchParams.append('region', 'ap') 112 | const req = new Request(reqURL.toString(), { method: 'POST' }) 113 | await worker.fetch(req, workerEnv) 114 | const receivedURL = new URL(receivedReqURL) 115 | expect(receivedURL.origin).toBe(`https://ap.${config.ingressApi}`.toLowerCase()) 116 | }) 117 | 118 | test('invalid region', async () => { 119 | reqURL.searchParams.append('region', 'foo.bar/baz') 120 | const req = new Request(reqURL.toString(), { method: 'POST' }) 121 | await worker.fetch(req, workerEnv) 122 | const receivedURL = new URL(receivedReqURL) 123 | expect(receivedURL.origin).toBe(`https://${config.ingressApi}`.toLowerCase()) 124 | }) 125 | }) 126 | 127 | describe('ingress API request proxy URL with suffix', () => { 128 | let fetchSpy: jest.MockInstance, any> 129 | let reqURL: URL 130 | let receivedReqURL = '' 131 | 132 | beforeAll(() => { 133 | fetchSpy = jest.spyOn(globalThis, 'fetch') 134 | fetchSpy.mockImplementation(async (input, init) => { 135 | const req = new Request(input, init) 136 | receivedReqURL = req.url 137 | return new Response() 138 | }) 139 | }) 140 | 141 | beforeEach(() => { 142 | reqURL = new URL('https://example.com/worker_path/get_result/suffix/more/path') 143 | 144 | receivedReqURL = '' 145 | }) 146 | 147 | afterAll(() => { 148 | fetchSpy.mockRestore() 149 | }) 150 | 151 | test('no region', async () => { 152 | const req = new Request(reqURL.toString(), { method: 'POST' }) 153 | await worker.fetch(req, workerEnv) 154 | const receivedURL = new URL(receivedReqURL) 155 | expect(receivedURL.origin).toBe(`https://${config.ingressApi}`.toLowerCase()) 156 | expect(receivedURL.pathname).toBe('/suffix/more/path') 157 | }) 158 | 159 | test('us region', async () => { 160 | reqURL.searchParams.append('region', 'us') 161 | const req = new Request(reqURL.toString(), { method: 'POST' }) 162 | await worker.fetch(req, workerEnv) 163 | const receivedURL = new URL(receivedReqURL) 164 | expect(receivedURL.origin).toBe(`https://${config.ingressApi}`.toLowerCase()) 165 | expect(receivedURL.pathname).toBe('/suffix/more/path') 166 | }) 167 | 168 | test('eu region', async () => { 169 | reqURL.searchParams.append('region', 'eu') 170 | const req = new Request(reqURL.toString(), { method: 'POST' }) 171 | await worker.fetch(req, workerEnv) 172 | const receivedURL = new URL(receivedReqURL) 173 | expect(receivedURL.origin).toBe(`https://eu.${config.ingressApi}`.toLowerCase()) 174 | expect(receivedURL.pathname).toBe('/suffix/more/path') 175 | }) 176 | 177 | test('ap region', async () => { 178 | reqURL.searchParams.append('region', 'ap') 179 | const req = new Request(reqURL.toString(), { method: 'POST' }) 180 | await worker.fetch(req, workerEnv) 181 | const receivedURL = new URL(receivedReqURL) 182 | expect(receivedURL.origin).toBe(`https://ap.${config.ingressApi}`.toLowerCase()) 183 | expect(receivedURL.pathname).toBe('/suffix/more/path') 184 | }) 185 | 186 | test('invalid region', async () => { 187 | reqURL.searchParams.append('region', 'foo.bar/baz') 188 | const req = new Request(reqURL.toString(), { method: 'POST' }) 189 | await worker.fetch(req, workerEnv) 190 | const receivedURL = new URL(receivedReqURL) 191 | expect(receivedURL.origin).toBe(`https://${config.ingressApi}`.toLowerCase()) 192 | expect(receivedURL.pathname).toBe('/suffix/more/path') 193 | }) 194 | 195 | test('suffix with dot', async () => { 196 | reqURL = new URL('https://example.com/worker_path/get_result/.suffix/more/path') 197 | reqURL.searchParams.append('region', 'ap') 198 | const req = new Request(reqURL.toString(), { method: 'POST' }) 199 | await worker.fetch(req, workerEnv) 200 | const receivedURL = new URL(receivedReqURL) 201 | expect(receivedURL.origin).toBe(`https://ap.${config.ingressApi}`.toLowerCase()) 202 | expect(receivedURL.pathname).toBe('/.suffix/more/path') 203 | }) 204 | 205 | test('invalid region GET req', async () => { 206 | reqURL.searchParams.append('region', 'foo.bar/baz') 207 | const req = new Request(reqURL.toString(), { method: 'GET' }) 208 | await worker.fetch(req, workerEnv) 209 | const receivedURL = new URL(receivedReqURL) 210 | expect(receivedURL.origin).toBe(`https://${config.ingressApi}`.toLowerCase()) 211 | expect(receivedURL.pathname).toBe('/suffix/more/path') 212 | }) 213 | 214 | test('suffix with dot GET req', async () => { 215 | reqURL = new URL('https://example.com/worker_path/get_result/.suffix/more/path') 216 | reqURL.searchParams.append('region', 'ap') 217 | const req = new Request(reqURL.toString(), { method: 'GET' }) 218 | await worker.fetch(req, workerEnv) 219 | const receivedURL = new URL(receivedReqURL) 220 | expect(receivedURL.origin).toBe(`https://ap.${config.ingressApi}`.toLowerCase()) 221 | expect(receivedURL.pathname).toBe('/.suffix/more/path') 222 | }) 223 | }) 224 | 225 | describe('ingress API request query parameters', () => { 226 | let fetchSpy: jest.MockInstance, any> 227 | let reqURL: URL 228 | let receivedReqURL = '' 229 | 230 | beforeAll(() => { 231 | fetchSpy = jest.spyOn(globalThis, 'fetch') 232 | fetchSpy.mockImplementation(async (input, init) => { 233 | const req = new Request(input, init) 234 | receivedReqURL = req.url 235 | 236 | return new Response('') 237 | }) 238 | }) 239 | 240 | beforeEach(() => { 241 | reqURL = new URL('https://example.com/worker_path/get_result') 242 | reqURL.searchParams.append('someKey', 'someValue') 243 | 244 | receivedReqURL = '' 245 | }) 246 | 247 | afterAll(() => { 248 | fetchSpy.mockRestore() 249 | }) 250 | 251 | test('traffic monitoring when no ii parameter before', async () => { 252 | const req = new Request(reqURL.toString(), { method: 'POST' }) 253 | await worker.fetch(req, workerEnv) 254 | const url = new URL(receivedReqURL) 255 | const iiValues = url.searchParams.getAll('ii') 256 | expect(iiValues.length).toBe(1) 257 | expect(iiValues[0]).toBe('fingerprintjs-pro-cloudflare/__current_worker_version__/ingress') 258 | }) 259 | test('traffic monitoring when there is ii parameter before', async () => { 260 | reqURL.searchParams.append('ii', 'fingerprintjs-pro-react/v1.2.3') 261 | const req = new Request(reqURL.toString(), { method: 'POST' }) 262 | await worker.fetch(req, workerEnv) 263 | const url = new URL(receivedReqURL) 264 | const iiValues = url.searchParams.getAll('ii') 265 | expect(iiValues.length).toBe(2) 266 | expect(iiValues[0]).toBe('fingerprintjs-pro-react/v1.2.3') 267 | expect(iiValues[1]).toBe('fingerprintjs-pro-cloudflare/__current_worker_version__/ingress') 268 | }) 269 | test('whole query string when no ii parameter before', async () => { 270 | const req = new Request(reqURL.toString(), { method: 'POST' }) 271 | await worker.fetch(req, workerEnv) 272 | const url = new URL(receivedReqURL) 273 | expect(url.search).toBe( 274 | '?someKey=someValue' + '&ii=fingerprintjs-pro-cloudflare%2F__current_worker_version__%2Fingress' 275 | ) 276 | }) 277 | test('whole query string when there is ii parameter before', async () => { 278 | reqURL.searchParams.append('ii', 'fingerprintjs-pro-react/v1.2.3') 279 | const req = new Request(reqURL.toString(), { method: 'POST' }) 280 | await worker.fetch(req, workerEnv) 281 | const url = new URL(receivedReqURL) 282 | expect(url.search).toBe( 283 | '?someKey=someValue' + 284 | '&ii=fingerprintjs-pro-react%2Fv1.2.3' + 285 | '&ii=fingerprintjs-pro-cloudflare%2F__current_worker_version__%2Fingress' 286 | ) 287 | }) 288 | }) 289 | 290 | describe('ingress API request headers', () => { 291 | let fetchSpy: jest.MockInstance, any> 292 | const reqURL = new URL('https://example.com/worker_path/get_result') 293 | let receivedHeaders: Headers 294 | 295 | beforeAll(() => { 296 | fetchSpy = jest.spyOn(globalThis, 'fetch') 297 | fetchSpy.mockImplementation(async (input, init) => { 298 | const req = new Request(input, init) 299 | receivedHeaders = req.headers 300 | 301 | return new Response('') 302 | }) 303 | }) 304 | 305 | beforeEach(() => { 306 | receivedHeaders = new Headers() 307 | }) 308 | 309 | afterAll(() => { 310 | fetchSpy.mockRestore() 311 | }) 312 | 313 | test('even if proxy secret is undefined, other FPJS-Proxy-* headers are still added to the proxied request headers. Original headers are preserved.', async () => { 314 | const workerEnv: WorkerEnv = { 315 | FPJS_CDN_URL: config.fpcdn, 316 | FPJS_INGRESS_BASE_HOST: config.ingressApi, 317 | PROXY_SECRET: null, 318 | GET_RESULT_PATH: 'get_result', 319 | AGENT_SCRIPT_DOWNLOAD_PATH: 'agent_download', 320 | } 321 | const testIP = '203.0.1113.195' 322 | const reqHeaders = new Headers({ 323 | accept: '*/*', 324 | 'cache-control': 'no-cache', 325 | 'accept-language': 'en-US', 326 | 'user-agent': 'Mozilla/5.0', 327 | 'x-some-header': 'some value', 328 | 'cf-connecting-ip': testIP, 329 | 'x-forwarded-for': `${testIP}, 2001:db8:85a3:8d3:1319:8a2e:370:7348`, 330 | }) 331 | const req = new Request(reqURL.toString(), { headers: reqHeaders, method: 'POST' }) 332 | await worker.fetch(req, workerEnv) 333 | const expectedHeaders = new Headers(reqHeaders) 334 | expectedHeaders.set('FPJS-Proxy-Client-IP', testIP) 335 | expectedHeaders.set('FPJS-Proxy-Forwarded-Host', 'example.com') 336 | receivedHeaders.forEach((value, key) => { 337 | expect(expectedHeaders.get(key)).toBe(value) 338 | }) 339 | expectedHeaders.forEach((value, key) => { 340 | expect(receivedHeaders.get(key)).toBe(value) 341 | }) 342 | }) 343 | test('req headers are correct when proxy secret is set', async () => { 344 | const reqHeaders = new Headers({ 345 | accept: '*/*', 346 | 'cache-control': 'no-cache', 347 | 'accept-language': 'en-US', 348 | 'user-agent': 'Mozilla/5.0', 349 | 'x-some-header': 'some value', 350 | 'cf-connecting-ip': '203.0.113.195', 351 | 'x-forwarded-for': '203.0.113.195, 2001:db8:85a3:8d3:1319:8a2e:370:7348', 352 | }) 353 | const req = new Request(reqURL.toString(), { headers: reqHeaders, method: 'POST' }) 354 | await worker.fetch(req, workerEnv) 355 | const expectedHeaders = new Headers(reqHeaders) 356 | expectedHeaders.set('FPJS-Proxy-Secret', 'proxy_secret') 357 | expectedHeaders.set('FPJS-Proxy-Client-IP', '203.0.113.195') 358 | expectedHeaders.set('FPJS-Proxy-Forwarded-Host', 'example.com') 359 | receivedHeaders.forEach((value, key) => { 360 | expect(expectedHeaders.get(key)).toBe(value) 361 | }) 362 | expectedHeaders.forEach((value, key) => { 363 | expect(receivedHeaders.get(key)).toBe(value) 364 | }) 365 | }) 366 | test('POST req headers do not have cookies except _iidt', async () => { 367 | const reqHeaders = new Headers({ 368 | accept: '*/*', 369 | 'cache-control': 'no-cache', 370 | 'accept-language': 'en-US', 371 | 'user-agent': 'Mozilla/5.0', 372 | 'x-some-header': 'some value', 373 | cookie: 374 | '_iidt=GlMQaHMfzYvomxCuA7Uymy7ArmjH04jPkT+enN7j/Xk8tJG+UYcQV+Qw60Ry4huw9bmDoO/smyjQp5vLCuSf8t4Jow==; auth_token=123456', 375 | }) 376 | const req = new Request(reqURL.toString(), { headers: reqHeaders, method: 'POST' }) 377 | await worker.fetch(req, workerEnv) 378 | expect(receivedHeaders.get('cookie')).toBe( 379 | '_iidt=GlMQaHMfzYvomxCuA7Uymy7ArmjH04jPkT+enN7j/Xk8tJG+UYcQV+Qw60Ry4huw9bmDoO/smyjQp5vLCuSf8t4Jow==' 380 | ) 381 | }) 382 | test('GET req headers do not have cookies (including _iidt)', async () => { 383 | const reqHeaders = new Headers({ 384 | accept: '*/*', 385 | 'cache-control': 'no-cache', 386 | 'accept-language': 'en-US', 387 | 'user-agent': 'Mozilla/5.0', 388 | 'x-some-header': 'some value', 389 | cookie: 390 | '_iidt=GlMQaHMfzYvomxCuA7Uymy7ArmjH04jPkT+enN7j/Xk8tJG+UYcQV+Qw60Ry4huw9bmDoO/smyjQp5vLCuSf8t4Jow==; auth_token=123456', 391 | }) 392 | const req = new Request(reqURL.toString(), { headers: reqHeaders, method: 'GET' }) 393 | await worker.fetch(req, workerEnv) 394 | expect(receivedHeaders.get('cookie')).toBe(null) 395 | }) 396 | }) 397 | 398 | describe('ingress API request body', () => { 399 | let fetchSpy: jest.MockInstance, any> 400 | const reqURL = new URL('https://example.com/worker_path/get_result') 401 | let receivedBody = '' 402 | 403 | beforeAll(() => { 404 | fetchSpy = jest.spyOn(globalThis, 'fetch') 405 | fetchSpy.mockImplementation(async (input, init) => { 406 | const req = new Request(input, init) 407 | receivedBody = await req.text() 408 | 409 | return new Response('') 410 | }) 411 | }) 412 | 413 | afterAll(() => { 414 | fetchSpy.mockRestore() 415 | }) 416 | 417 | test('request body is not modified', async () => { 418 | const reqBody = 'some request body' 419 | const req = new Request(reqURL.toString(), { body: reqBody, method: 'POST' }) 420 | await worker.fetch(req, workerEnv) 421 | expect(receivedBody).toBe(reqBody) 422 | }) 423 | }) 424 | 425 | describe('ingress API request HTTP method', () => { 426 | let fetchSpy: jest.MockInstance, any> 427 | const reqURL = new URL('https://example.com/worker_path/get_result') 428 | let requestMethod: string 429 | 430 | beforeAll(() => { 431 | fetchSpy = jest.spyOn(globalThis, 'fetch') 432 | fetchSpy.mockImplementation(async (input, init) => { 433 | const req = new Request(input, init) 434 | requestMethod = req.method 435 | 436 | return new Response('') 437 | }) 438 | }) 439 | 440 | beforeEach(() => { 441 | requestMethod = '' 442 | }) 443 | 444 | afterAll(() => { 445 | fetchSpy.mockRestore() 446 | }) 447 | 448 | test('when method is GET', async () => { 449 | const req = new Request(reqURL.toString(), { method: 'GET' }) 450 | await worker.fetch(req, workerEnv) 451 | expect(requestMethod).toBe('GET') 452 | }) 453 | 454 | test('when method is POST', async () => { 455 | const req = new Request(reqURL.toString(), { method: 'POST' }) 456 | await worker.fetch(req, workerEnv) 457 | expect(requestMethod).toBe('POST') 458 | }) 459 | 460 | test('when method is PUT', async () => { 461 | const req = new Request(reqURL.toString(), { method: 'PUT' }) 462 | await worker.fetch(req, workerEnv) 463 | expect(requestMethod).toBe('PUT') 464 | }) 465 | }) 466 | 467 | describe('ingress API response headers for GET req', () => { 468 | let fetchSpy: jest.MockInstance, any> 469 | 470 | beforeAll(() => { 471 | fetchSpy = jest.spyOn(globalThis, 'fetch') 472 | }) 473 | 474 | afterAll(() => { 475 | fetchSpy.mockRestore() 476 | }) 477 | 478 | test('response headers are the same (except HSTS)', async () => { 479 | const responseHeaders = new Headers({ 480 | 'access-control-allow-credentials': 'true', 481 | 'access-control-expose-headers': 'Retry-After', 482 | 'content-type': 'text/plain', 483 | }) 484 | fetchSpy.mockImplementation(async () => { 485 | return new Response('', { headers: responseHeaders }) 486 | }) 487 | const req = new Request('https://example.com/worker_path/get_result', { method: 'GET' }) 488 | const response = await worker.fetch(req, workerEnv) 489 | response.headers.forEach((value, key) => { 490 | expect(responseHeaders.get(key)).toBe(value) 491 | }) 492 | responseHeaders.forEach((value, key) => { 493 | expect(response.headers.get(key)).toBe(value) 494 | }) 495 | }) 496 | test('strict-transport-security is removed', async () => { 497 | fetchSpy.mockImplementation(async () => { 498 | const headers = new Headers({ 499 | 'strict-transport-security': 'max-age=63072000', 500 | }) 501 | return new Response('', { headers }) 502 | }) 503 | const req = new Request('https://example.com/worker_path/get_result', { method: 'GET' }) 504 | const response = await worker.fetch(req, workerEnv) 505 | expect(response.headers.get('strict-transport-security')).toBe(null) 506 | }) 507 | }) 508 | 509 | describe('ingress API response headers for POST req', () => { 510 | let fetchSpy: jest.MockInstance, any> 511 | 512 | beforeAll(() => { 513 | fetchSpy = jest.spyOn(globalThis, 'fetch') 514 | }) 515 | 516 | afterAll(() => { 517 | fetchSpy.mockRestore() 518 | }) 519 | 520 | test('response headers are the same (except HSTS and set-cookie)', async () => { 521 | const responseHeaders = new Headers({ 522 | 'access-control-allow-credentials': 'true', 523 | 'access-control-expose-headers': 'Retry-After', 524 | 'content-type': 'text/plain', 525 | }) 526 | fetchSpy.mockImplementation(async () => { 527 | return new Response('', { headers: responseHeaders }) 528 | }) 529 | const req = new Request('https://example.com/worker_path/get_result', { method: 'POST' }) 530 | const response = await worker.fetch(req, workerEnv) 531 | response.headers.forEach((value, key) => { 532 | expect(responseHeaders.get(key)).toBe(value) 533 | }) 534 | responseHeaders.forEach((value, key) => { 535 | expect(response.headers.get(key)).toBe(value) 536 | }) 537 | }) 538 | test('strict-transport-security is removed', async () => { 539 | fetchSpy.mockImplementation(async () => { 540 | const headers = new Headers({ 541 | 'strict-transport-security': 'max-age=63072000', 542 | }) 543 | return new Response('', { headers }) 544 | }) 545 | const req = new Request('https://example.com/worker_path/get_result', { method: 'POST' }) 546 | const response = await worker.fetch(req, workerEnv) 547 | expect(response.headers.get('strict-transport-security')).toBe(null) 548 | }) 549 | }) 550 | 551 | describe('ingress API response body when successful', () => { 552 | let fetchSpy: jest.MockInstance, any> 553 | 554 | beforeAll(() => { 555 | fetchSpy = jest.spyOn(globalThis, 'fetch') 556 | }) 557 | 558 | afterAll(() => { 559 | fetchSpy.mockRestore() 560 | }) 561 | 562 | test('body is unchanged', async () => { 563 | fetchSpy.mockImplementation(async () => { 564 | return new Response('some text') 565 | }) 566 | const req = new Request('https://example.com/worker_path/get_result', { method: 'POST' }) 567 | const response = await worker.fetch(req, workerEnv) 568 | expect(await response.text()).toBe('some text') 569 | }) 570 | }) 571 | 572 | describe('GET req response when failure', () => { 573 | let fetchSpy: jest.MockInstance, any> 574 | 575 | beforeAll(() => { 576 | fetchSpy = jest.spyOn(globalThis, 'fetch') 577 | }) 578 | 579 | afterAll(() => { 580 | fetchSpy.mockRestore() 581 | }) 582 | 583 | test('body and headers are unchanged when ingress API fails', async () => { 584 | const responseHeaders = new Headers({ 585 | 'access-control-allow-credentials': 'true', 586 | 'access-control-expose-headers': 'Retry-After', 587 | 'content-type': 'text/plain', 588 | }) 589 | fetchSpy.mockImplementation(async () => { 590 | return new Response('some error', { status: 500, headers: responseHeaders }) 591 | }) 592 | const req = new Request('https://example.com/worker_path/get_result', { method: 'GET' }) 593 | const response = await worker.fetch(req, workerEnv) 594 | expect(response.status).toBe(500) 595 | expect(await response.text()).toBe('some error') 596 | response.headers.forEach((value, key) => { 597 | expect(responseHeaders.get(key)).toBe(value) 598 | }) 599 | responseHeaders.forEach((value, key) => { 600 | expect(response.headers.get(key)).toBe(value) 601 | }) 602 | }) 603 | test('error response when error inside the worker', async () => { 604 | fetchSpy.mockImplementation(async () => { 605 | throw new Error('some error') 606 | }) 607 | const req = new Request('https://example.com/worker_path/get_result', { method: 'GET' }) 608 | const response = await worker.fetch(req, workerEnv) 609 | expect(response.headers.get('content-type')).toBe('application/json') 610 | expect(response.status).toBe(500) 611 | const responseBody = await response.json() 612 | // Note: toStrictEqual does not work for some reason, using double toMatchObject instead 613 | expect(responseBody).toMatchObject({ error: 'some error' }) 614 | expect({ error: 'some error' }).toMatchObject(responseBody as any) 615 | }) 616 | }) 617 | 618 | describe('ingress API response when failure', () => { 619 | let fetchSpy: jest.MockInstance, any> 620 | 621 | beforeAll(() => { 622 | fetchSpy = jest.spyOn(globalThis, 'fetch') 623 | }) 624 | 625 | afterAll(() => { 626 | fetchSpy.mockRestore() 627 | }) 628 | 629 | test('body and headers are unchanged when ingress API fails', async () => { 630 | const responseHeaders = new Headers({ 631 | 'access-control-allow-credentials': 'true', 632 | 'access-control-expose-headers': 'Retry-After', 633 | 'content-type': 'text/plain', 634 | }) 635 | fetchSpy.mockImplementation(async () => { 636 | return new Response('some error', { status: 500, headers: responseHeaders }) 637 | }) 638 | const req = new Request('https://example.com/worker_path/get_result', { method: 'POST' }) 639 | const response = await worker.fetch(req, workerEnv) 640 | expect(response.status).toBe(500) 641 | expect(await response.text()).toBe('some error') 642 | response.headers.forEach((value, key) => { 643 | expect(responseHeaders.get(key)).toBe(value) 644 | }) 645 | responseHeaders.forEach((value, key) => { 646 | expect(response.headers.get(key)).toBe(value) 647 | }) 648 | }) 649 | test('error response when error inside the worker', async () => { 650 | fetchSpy.mockImplementation(async () => { 651 | throw new Error('some error') 652 | }) 653 | const req = new Request('https://example.com/worker_path/get_result', { method: 'POST' }) 654 | const response = await worker.fetch(req, workerEnv) 655 | expect(response.headers.get('content-type')).toBe('application/json') 656 | expect(response.status).toBe(500) 657 | const responseBody = (await response.json()) as FPJSResponse 658 | const expectedResponseBody: Omit = { 659 | v: '2', 660 | error: { 661 | code: 'IntegrationFailed', 662 | message: 'An error occurred with Cloudflare worker. Reason: some error', 663 | }, 664 | products: {}, 665 | } 666 | const requestId = responseBody.requestId 667 | expect(requestId).toMatch(/^\d{13}\.[a-zA-Z\d]{6}$/) 668 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 669 | const modifiedResponseBody: Omit = (({ requestId, ...rest }) => ({ ...rest }))( 670 | responseBody 671 | ) 672 | // Note: toStrictEqual does not work for some reason, using double toMatchObject instead 673 | expect(modifiedResponseBody).toMatchObject(expectedResponseBody) 674 | expect(expectedResponseBody).toMatchObject(modifiedResponseBody) 675 | }) 676 | }) 677 | 678 | describe('GET request cache durations', () => { 679 | let fetchSpy: jest.MockInstance, any> 680 | const reqURL = new URL('https://example.com/worker_path/get_result') 681 | let receivedCfObject: IncomingRequestCfProperties | RequestInitCfProperties | null | undefined = null 682 | 683 | beforeAll(() => { 684 | fetchSpy = jest.spyOn(globalThis, 'fetch') 685 | }) 686 | 687 | beforeEach(() => { 688 | receivedCfObject = null 689 | }) 690 | 691 | afterAll(() => { 692 | fetchSpy.mockRestore() 693 | }) 694 | 695 | test('browser cache is the same as the subrequest response', async () => { 696 | fetchSpy.mockImplementation(async () => { 697 | const responseHeaders = new Headers({ 698 | 'cache-control': 'max-age=2592000, immutable, private', 699 | }) 700 | 701 | return new Response('', { headers: responseHeaders }) 702 | }) 703 | const req = new Request(reqURL.toString(), { method: 'GET' }) 704 | const response = await worker.fetch(req, workerEnv) 705 | expect(response.headers.get('cache-control')).toBe('max-age=2592000, immutable, private') 706 | }) 707 | test('cloudflare network cache is not set', async () => { 708 | fetchSpy.mockImplementation(async (_, init) => { 709 | receivedCfObject = (init as RequestInit).cf 710 | const responseHeaders = new Headers({ 711 | 'cache-control': 'public, max-age=3613, s-maxage=575500', 712 | }) 713 | 714 | return new Response('', { headers: responseHeaders }) 715 | }) 716 | const req = new Request(reqURL.toString(), { method: 'GET' }) 717 | await worker.fetch(req, workerEnv) 718 | expect(receivedCfObject).toBe(null) 719 | }) 720 | }) 721 | 722 | describe('POST request cache durations', () => { 723 | let fetchSpy: jest.MockInstance, any> 724 | const reqURL = new URL('https://example.com/worker_path/get_result') 725 | let receivedCfObject: IncomingRequestCfProperties | RequestInitCfProperties | null | undefined = null 726 | 727 | beforeAll(() => { 728 | fetchSpy = jest.spyOn(globalThis, 'fetch') 729 | }) 730 | 731 | beforeEach(() => { 732 | receivedCfObject = null 733 | }) 734 | 735 | afterAll(() => { 736 | fetchSpy.mockRestore() 737 | }) 738 | 739 | test('cache-control is not added', async () => { 740 | fetchSpy.mockImplementation(async (_, init) => { 741 | receivedCfObject = (init as RequestInit).cf 742 | const responseHeaders = new Headers({ 743 | 'access-control-allow-credentials': 'true', 744 | 'access-control-expose-headers': 'Retry-After', 745 | 'content-type': 'text/plain', 746 | }) 747 | 748 | return new Response('', { headers: responseHeaders }) 749 | }) 750 | const req = new Request(reqURL.toString(), { method: 'POST' }) 751 | const response = await worker.fetch(req, workerEnv) 752 | expect(response.headers.get('cache-control')).toBe(null) 753 | }) 754 | test('cloudflare network cache is not set', async () => { 755 | fetchSpy.mockImplementation(async (_, init) => { 756 | receivedCfObject = (init as RequestInit).cf 757 | const responseHeaders = new Headers({ 758 | 'access-control-allow-credentials': 'true', 759 | 'access-control-expose-headers': 'Retry-After', 760 | 'content-type': 'text/plain', 761 | }) 762 | 763 | return new Response('', { headers: responseHeaders }) 764 | }) 765 | const req = new Request(reqURL.toString(), { method: 'POST' }) 766 | await worker.fetch(req, workerEnv) 767 | expect(receivedCfObject).toBe(null) 768 | }) 769 | }) 770 | -------------------------------------------------------------------------------- /test/endpoints/status.test.ts: -------------------------------------------------------------------------------- 1 | import worker from '../../src' 2 | import { config } from '../../src/config' 3 | import { WorkerEnv } from '../../src/env' 4 | 5 | describe('status page content', () => { 6 | beforeEach(() => { 7 | Object.defineProperty(globalThis, 'crypto', { 8 | value: { 9 | getRandomValues: () => new Uint8Array(24), 10 | }, 11 | }) 12 | }) 13 | test('when all variables are set', async () => { 14 | const workerEnv: WorkerEnv = { 15 | FPJS_CDN_URL: config.fpcdn, 16 | FPJS_INGRESS_BASE_HOST: config.ingressApi, 17 | PROXY_SECRET: 'proxy_secret', 18 | GET_RESULT_PATH: 'get_result', 19 | AGENT_SCRIPT_DOWNLOAD_PATH: 'agent_download', 20 | } 21 | const req = new Request('http://localhost/worker_path/status') 22 | const response = await worker.fetch(req, workerEnv) 23 | expect(await response.text()).toMatchSnapshot() 24 | }) 25 | test('when proxy secret is not set', async () => { 26 | const workerEnv: WorkerEnv = { 27 | FPJS_CDN_URL: config.fpcdn, 28 | FPJS_INGRESS_BASE_HOST: config.ingressApi, 29 | PROXY_SECRET: null, 30 | GET_RESULT_PATH: 'get_result', 31 | AGENT_SCRIPT_DOWNLOAD_PATH: 'agent_download', 32 | } 33 | const req = new Request('http://localhost/worker_path/status') 34 | const response = await worker.fetch(req, workerEnv) 35 | expect(await response.text()).toMatchSnapshot() 36 | }) 37 | test('when get result path is not set', async () => { 38 | const workerEnv: WorkerEnv = { 39 | FPJS_CDN_URL: config.fpcdn, 40 | FPJS_INGRESS_BASE_HOST: config.ingressApi, 41 | PROXY_SECRET: 'proxy_secret', 42 | GET_RESULT_PATH: null, 43 | AGENT_SCRIPT_DOWNLOAD_PATH: 'agent_download', 44 | } 45 | const req = new Request('http://localhost/worker_path/status') 46 | const response = await worker.fetch(req, workerEnv) 47 | expect(await response.text()).toMatchSnapshot() 48 | }) 49 | test('when agent script download path is not set', async () => { 50 | const workerEnv: WorkerEnv = { 51 | FPJS_CDN_URL: config.fpcdn, 52 | FPJS_INGRESS_BASE_HOST: config.ingressApi, 53 | PROXY_SECRET: 'proxy_secret', 54 | GET_RESULT_PATH: 'get_result', 55 | AGENT_SCRIPT_DOWNLOAD_PATH: null, 56 | } 57 | const req = new Request('http://localhost/worker_path/status') 58 | const response = await worker.fetch(req, workerEnv) 59 | expect(await response.text()).toMatchSnapshot() 60 | }) 61 | test('when agent script download path and proxy secret are not set', async () => { 62 | const workerEnv: WorkerEnv = { 63 | FPJS_CDN_URL: config.fpcdn, 64 | FPJS_INGRESS_BASE_HOST: config.ingressApi, 65 | PROXY_SECRET: null, 66 | GET_RESULT_PATH: 'get_result', 67 | AGENT_SCRIPT_DOWNLOAD_PATH: null, 68 | } 69 | const req = new Request('http://localhost/worker_path/status') 70 | const response = await worker.fetch(req, workerEnv) 71 | expect(await response.text()).toMatchSnapshot() 72 | }) 73 | }) 74 | 75 | describe('status page response headers', () => { 76 | test('CSP is set', async () => { 77 | const workerEnv: WorkerEnv = { 78 | FPJS_CDN_URL: config.fpcdn, 79 | FPJS_INGRESS_BASE_HOST: config.ingressApi, 80 | PROXY_SECRET: 'proxy_secret', 81 | GET_RESULT_PATH: 'get_result', 82 | AGENT_SCRIPT_DOWNLOAD_PATH: 'agent_download', 83 | } 84 | const req = new Request('http://localhost/worker_path/status') 85 | const response = await worker.fetch(req, workerEnv) 86 | expect(response.headers.get('content-security-policy')).toMatch( 87 | /^default-src 'none'; img-src https:\/\/fingerprint\.com; style-src 'nonce-[\w=]+'$/ 88 | ) 89 | }) 90 | }) 91 | 92 | describe('status page other HTTP methods than GET', () => { 93 | test('returns 405 when method is POST', async () => { 94 | const workerEnv: WorkerEnv = { 95 | FPJS_CDN_URL: config.fpcdn, 96 | FPJS_INGRESS_BASE_HOST: config.ingressApi, 97 | PROXY_SECRET: 'proxy_secret', 98 | GET_RESULT_PATH: 'get_result', 99 | AGENT_SCRIPT_DOWNLOAD_PATH: 'agent_download', 100 | } 101 | const req = new Request('http://localhost/worker_path/status', { method: 'POST' }) 102 | const response = await worker.fetch(req, workerEnv) 103 | expect(response.status).toBe(405) 104 | }) 105 | }) 106 | -------------------------------------------------------------------------------- /test/handleRequest/handleRequestWithRoutes.test.ts: -------------------------------------------------------------------------------- 1 | import { handleRequestWithRoutes, Route } from '../../src/handler' 2 | import { getGetResultPath, getScriptDownloadPath, getStatusPagePath, WorkerEnv } from '../../src/env' 3 | import { createRoute } from '../../src/utils' 4 | import { config } from '../../src/config' 5 | 6 | const workerPath = 'worker' 7 | const agentScriptDownloadPath = 'agent' 8 | const getResultPath = 'get-result' 9 | const proxySecret = 'proxySecret' 10 | const env: WorkerEnv = { 11 | FPJS_CDN_URL: config.fpcdn, 12 | FPJS_INGRESS_BASE_HOST: config.ingressApi, 13 | AGENT_SCRIPT_DOWNLOAD_PATH: agentScriptDownloadPath, 14 | GET_RESULT_PATH: getResultPath, 15 | PROXY_SECRET: proxySecret, 16 | } 17 | 18 | describe('download Pro Agent Script', () => { 19 | let routes: Route[] = [] 20 | let mockAgentDownloadHandler: jest.Mock 21 | beforeEach(() => { 22 | mockAgentDownloadHandler = jest.fn() 23 | routes = [ 24 | { 25 | pathPattern: createRoute(getScriptDownloadPath(env)), 26 | handler: mockAgentDownloadHandler, 27 | }, 28 | ] 29 | }) 30 | test('standard path', async () => { 31 | const request = new Request(`https://example.com/${workerPath}/${agentScriptDownloadPath}`) 32 | await handleRequestWithRoutes(request, env, routes) 33 | expect(mockAgentDownloadHandler).toHaveBeenCalledTimes(1) 34 | }) 35 | test('slash in the end', async () => { 36 | const request = new Request(`https://example.com/${workerPath}/${agentScriptDownloadPath}/`) 37 | await handleRequestWithRoutes(request, env, routes) 38 | expect(mockAgentDownloadHandler).toHaveBeenCalledTimes(1) 39 | }) 40 | test('with query params', async () => { 41 | const request = new Request(`https://example.com/${workerPath}/${agentScriptDownloadPath}?key1=value1&key2=value2`) 42 | await handleRequestWithRoutes(request, env, routes) 43 | expect(mockAgentDownloadHandler).toHaveBeenCalledTimes(1) 44 | }) 45 | test('HTTP Post method', async () => { 46 | const request = new Request(`https://example.com/${workerPath}/${agentScriptDownloadPath}`, { method: 'POST' }) 47 | await handleRequestWithRoutes(request, env, routes) 48 | expect(mockAgentDownloadHandler).toHaveBeenCalledTimes(1) 49 | }) 50 | test('with prefix', async () => { 51 | const request = new Request(`https://example.com/foobar/${agentScriptDownloadPath}`) 52 | await handleRequestWithRoutes(request, env, routes) 53 | expect(mockAgentDownloadHandler).toHaveBeenCalled() 54 | }) 55 | test('incorrect path', async () => { 56 | const request = new Request(`https://example.com/${agentScriptDownloadPath}/some-path`) 57 | await handleRequestWithRoutes(request, env, routes) 58 | expect(mockAgentDownloadHandler).not.toHaveBeenCalled() 59 | }) 60 | }) 61 | 62 | describe('get GetResult', () => { 63 | let routes: Route[] = [] 64 | let mockIngressAPIHandler: jest.Mock 65 | beforeEach(() => { 66 | mockIngressAPIHandler = jest.fn() 67 | routes = [ 68 | { 69 | pathPattern: createRoute(getGetResultPath(env)), 70 | handler: mockIngressAPIHandler, 71 | }, 72 | ] 73 | }) 74 | test('standard path', async () => { 75 | const request = new Request(`https://example.com/${workerPath}/${getResultPath}`) 76 | await handleRequestWithRoutes(request, env, routes) 77 | expect(mockIngressAPIHandler).toHaveBeenCalledTimes(1) 78 | }) 79 | test('slash in the end', async () => { 80 | const request = new Request(`https://example.com/${workerPath}/${getResultPath}/`) 81 | await handleRequestWithRoutes(request, env, routes) 82 | expect(mockIngressAPIHandler).toHaveBeenCalledTimes(1) 83 | }) 84 | test('with query params', async () => { 85 | const request = new Request(`https://example.com/${workerPath}/${getResultPath}?key1=value1&key2=value2`) 86 | await handleRequestWithRoutes(request, env, routes) 87 | expect(mockIngressAPIHandler).toHaveBeenCalledTimes(1) 88 | }) 89 | test('HTTP Post method', async () => { 90 | const request = new Request(`https://example.com/${workerPath}/${getResultPath}`, { method: 'POST' }) 91 | await handleRequestWithRoutes(request, env, routes) 92 | expect(mockIngressAPIHandler).toHaveBeenCalledTimes(1) 93 | }) 94 | test('with prefix', async () => { 95 | const request = new Request(`https://example.com/foobar/${getResultPath}`) 96 | await handleRequestWithRoutes(request, env, routes) 97 | expect(mockIngressAPIHandler).toHaveBeenCalled() 98 | }) 99 | test('with suffix', async () => { 100 | const request = new Request(`https://example.com/${getResultPath}/some-path`) 101 | await handleRequestWithRoutes(request, env, routes) 102 | expect(mockIngressAPIHandler).toHaveBeenCalled() 103 | }) 104 | test('incorrect path', async () => { 105 | const request = new Request(`https://example.com/${getResultPath}foobar`) 106 | await handleRequestWithRoutes(request, env, routes) 107 | expect(mockIngressAPIHandler).not.toHaveBeenCalled() 108 | }) 109 | }) 110 | 111 | describe('status page', () => { 112 | let routes: Route[] = [] 113 | let mockStatusPageHandler: jest.Mock 114 | beforeEach(() => { 115 | mockStatusPageHandler = jest.fn() 116 | routes = [ 117 | { 118 | pathPattern: createRoute(getStatusPagePath()), 119 | handler: mockStatusPageHandler, 120 | }, 121 | ] 122 | }) 123 | test('standard path', async () => { 124 | const request = new Request(`https://example.com/${workerPath}/status`) 125 | await handleRequestWithRoutes(request, env, routes) 126 | expect(mockStatusPageHandler).toHaveBeenCalledTimes(1) 127 | }) 128 | test('slash in the end', async () => { 129 | const request = new Request(`https://example.com/${workerPath}/status/`) 130 | await handleRequestWithRoutes(request, env, routes) 131 | expect(mockStatusPageHandler).toHaveBeenCalledTimes(1) 132 | }) 133 | test('with query params', async () => { 134 | const request = new Request(`https://example.com/${workerPath}/status?key1=value1&key2=value2`) 135 | await handleRequestWithRoutes(request, env, routes) 136 | expect(mockStatusPageHandler).toHaveBeenCalledTimes(1) 137 | }) 138 | test('HTTP Post method', async () => { 139 | const request = new Request(`https://example.com/${workerPath}/status`, { method: 'POST' }) 140 | await handleRequestWithRoutes(request, env, routes) 141 | expect(mockStatusPageHandler).toHaveBeenCalledTimes(1) 142 | }) 143 | test('with prefix', async () => { 144 | const request = new Request(`https://example.com/foobar/status`) 145 | await handleRequestWithRoutes(request, env, routes) 146 | expect(mockStatusPageHandler).toHaveBeenCalled() 147 | }) 148 | test('incorrect path', async () => { 149 | const request = new Request(`https://example.com/status/some-path`) 150 | await handleRequestWithRoutes(request, env, routes) 151 | expect(mockStatusPageHandler).not.toHaveBeenCalled() 152 | }) 153 | }) 154 | 155 | describe('no match paths', () => { 156 | let routes: Route[] = [] 157 | let mockAgentDownloadHandler: jest.Mock 158 | let mockIngressAPIHandler: jest.Mock 159 | let mockStatusPageHandler: jest.Mock 160 | beforeEach(() => { 161 | mockAgentDownloadHandler = jest.fn() 162 | mockIngressAPIHandler = jest.fn() 163 | mockStatusPageHandler = jest.fn() 164 | routes = [ 165 | { 166 | pathPattern: createRoute(getScriptDownloadPath(env)), 167 | handler: mockAgentDownloadHandler, 168 | }, 169 | { 170 | pathPattern: createRoute(getGetResultPath(env)), 171 | handler: mockIngressAPIHandler, 172 | }, 173 | { 174 | pathPattern: createRoute(getStatusPagePath()), 175 | handler: mockStatusPageHandler, 176 | }, 177 | ] 178 | }) 179 | test('no match', async () => { 180 | const reqURL = `https://example.com/${workerPath}/hello` 181 | const request = new Request(reqURL) 182 | const response = await handleRequestWithRoutes(request, env, routes) 183 | expect(mockAgentDownloadHandler).not.toHaveBeenCalled() 184 | expect(mockIngressAPIHandler).not.toHaveBeenCalled() 185 | expect(mockStatusPageHandler).not.toHaveBeenCalled() 186 | expect(response.status).toBe(404) 187 | expect(response.headers.get('content-type')).toBe('application/json') 188 | const responseBody = await response.json() 189 | const expected = { error: `unmatched path /${workerPath}/hello` } 190 | expect(responseBody).toMatchObject(expected) 191 | expect(expected).toMatchObject(responseBody as any) 192 | }) 193 | }) 194 | -------------------------------------------------------------------------------- /test/utils/addProxyIntegrationHeaders.test.ts: -------------------------------------------------------------------------------- 1 | import { addProxyIntegrationHeaders, getIPFromHeaders } from '../../src/utils' 2 | import { WorkerEnv } from '../../src/env' 3 | 4 | const ipv6 = '84D:1111:222:3333:4444:5555:6:77' 5 | const ipv4 = '19.117.63.126' 6 | 7 | describe('addProxyIntegrationHeaders', () => { 8 | let headers: Headers 9 | let env: WorkerEnv 10 | 11 | beforeEach(() => { 12 | headers = new Headers() 13 | headers.set('CF-Connecting-IP', ipv4) 14 | headers.set('x-custom-header', 'custom-value') 15 | env = { 16 | AGENT_SCRIPT_DOWNLOAD_PATH: 'agent-path', 17 | GET_RESULT_PATH: 'result-path', 18 | PROXY_SECRET: 'secret_value', 19 | FPJS_CDN_URL: null, 20 | FPJS_INGRESS_BASE_HOST: null, 21 | } 22 | }) 23 | 24 | it('append proxy headers when PROXY_SECRET is set', () => { 25 | addProxyIntegrationHeaders(headers, 'https://example.com/worker/result', env) 26 | expect(headers.get('FPJS-Proxy-Secret')).toBe('secret_value') 27 | expect(headers.get('FPJS-Proxy-Client-IP')).toBe(ipv4) 28 | expect(headers.get('FPJS-Proxy-Forwarded-Host')).toBe('example.com') 29 | expect(headers.get('x-custom-header')).toBe('custom-value') 30 | }) 31 | 32 | test('even if proxy secret is null, other FPJS-Proxy-* headers are still added to the proxied request headers. Original headers are preserved.', () => { 33 | env.PROXY_SECRET = null 34 | addProxyIntegrationHeaders(headers, 'https://example.com/worker/result', env) 35 | expect(headers.get('FPJS-Proxy-Secret')).toBe(null) 36 | expect(headers.get('FPJS-Proxy-Client-IP')).toBe(ipv4) 37 | expect(headers.get('FPJS-Proxy-Forwarded-Host')).toBe('example.com') 38 | expect(headers.get('x-custom-header')).toBe('custom-value') 39 | }) 40 | 41 | test('use ipv6 when connecting ip is ipv6', () => { 42 | headers.set('CF-Connecting-IP', ipv6) 43 | addProxyIntegrationHeaders(headers, 'https://example.com/worker/result', env) 44 | expect(headers.get('FPJS-Proxy-Secret')).toBe('secret_value') 45 | expect(headers.get('FPJS-Proxy-Client-IP')).toBe(ipv6) 46 | expect(headers.get('FPJS-Proxy-Forwarded-Host')).toBe('example.com') 47 | expect(headers.get('x-custom-header')).toBe('custom-value') 48 | }) 49 | 50 | test('use CF-Connecting-IP as FPJS-Proxy-Client-IP when Cf-Pseudo-IPv4 is present but different', () => { 51 | headers.set('CF-Connecting-IP', ipv6) 52 | headers.set('Cf-Pseudo-IPv4', ipv4) 53 | addProxyIntegrationHeaders(headers, 'https://example.com/worker/result', env) 54 | expect(headers.get('FPJS-Proxy-Client-IP')).toBe(ipv6) 55 | }) 56 | 57 | test('use CF-Connecting-IPv6 as FPJS-Proxy-Client-IP when Cf-Pseudo-IPv4 matches CF-Connecting-IP', () => { 58 | headers.set('CF-Connecting-IP', ipv4) 59 | headers.set('Cf-Pseudo-IPv4', ipv4) 60 | headers.set('CF-Connecting-IPv6', ipv6) 61 | addProxyIntegrationHeaders(headers, 'https://example.com/worker/result', env) 62 | expect(headers.get('FPJS-Proxy-Client-IP')).toBe(ipv6) 63 | }) 64 | }) 65 | 66 | describe('getIPFromHeaders', () => { 67 | it('returns CF-Connecting-IP when only CF-Connecting-IP is set', () => { 68 | const headers = new Headers() 69 | headers.set('CF-Connecting-IP', ipv4) 70 | 71 | expect(getIPFromHeaders(headers)).toEqual(ipv4) 72 | }) 73 | 74 | it('returns CF-Connecting-IP when Cf-Pseudo-IPv4 is present but different', () => { 75 | const headers = new Headers() 76 | headers.set('CF-Connecting-IP', ipv6) 77 | headers.set('Cf-Pseudo-IPv4', ipv4) 78 | 79 | expect(getIPFromHeaders(headers)).toEqual(ipv6) 80 | }) 81 | 82 | it('returns CF-Connecting-IPv6 when Cf-Pseudo-IPv4 matches CF-Connecting-IP', () => { 83 | const headers = new Headers() 84 | headers.set('CF-Connecting-IP', ipv4) 85 | headers.set('Cf-Pseudo-IPv4', ipv4) 86 | headers.set('CF-Connecting-IPv6', ipv6) 87 | 88 | expect(getIPFromHeaders(headers)).toEqual(ipv6) 89 | }) 90 | 91 | it('returns an empty string when CF-Connecting-IP header is set to an empty string', () => { 92 | const headers = new Headers() 93 | headers.set('CF-Connecting-IP', '') 94 | 95 | expect(getIPFromHeaders(headers)).toEqual('') 96 | }) 97 | 98 | it('returns an empty string when no headers are set', () => { 99 | expect(getIPFromHeaders(new Headers())).toEqual('') 100 | }) 101 | }) 102 | -------------------------------------------------------------------------------- /test/utils/addTrafficMonitoring.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | addTrafficMonitoringSearchParamsForProCDN, 3 | addTrafficMonitoringSearchParamsForVisitorIdRequest, 4 | } from '../../src/utils' 5 | 6 | const SEARCH_PARAM_NAME = 'ii' 7 | 8 | function expectTwoArraysToBeEqual(arr1: Array, arr2: Array) { 9 | expect(arr1).toEqual(expect.arrayContaining(arr2)) 10 | expect(arr2).toEqual(expect.arrayContaining(arr1)) 11 | } 12 | 13 | describe('addTrafficMonitoringSearchParamsForProCDN', () => { 14 | test('plain domain works', () => { 15 | const url = new URL('https://fingerprint.com') 16 | addTrafficMonitoringSearchParamsForProCDN(url) 17 | expect(url.searchParams.get(SEARCH_PARAM_NAME)).toBe( 18 | 'fingerprintjs-pro-cloudflare/__current_worker_version__/procdn' 19 | ) 20 | }) 21 | test('works with other query parameters', () => { 22 | const url = new URL('https://fingerprint.com') 23 | url.searchParams.append(SEARCH_PARAM_NAME, 'some_other_integration') 24 | addTrafficMonitoringSearchParamsForProCDN(url) 25 | url.searchParams.append(SEARCH_PARAM_NAME, 'some_other_integration_2') 26 | const expected = [ 27 | 'some_other_integration', 28 | 'fingerprintjs-pro-cloudflare/__current_worker_version__/procdn', 29 | 'some_other_integration_2', 30 | ] 31 | expectTwoArraysToBeEqual(url.searchParams.getAll(SEARCH_PARAM_NAME), expected) 32 | }) 33 | }) 34 | 35 | describe('addTrafficMonitoringSearchParamsForVisitorIdRequest', () => { 36 | test('plain domain works', () => { 37 | const url = new URL('https://fingerprint.com') 38 | addTrafficMonitoringSearchParamsForVisitorIdRequest(url) 39 | expect(url.searchParams.get(SEARCH_PARAM_NAME)).toBe( 40 | 'fingerprintjs-pro-cloudflare/__current_worker_version__/ingress' 41 | ) 42 | }) 43 | test('works with other query parameters', () => { 44 | const url = new URL('https://fingerprint.com') 45 | url.searchParams.append(SEARCH_PARAM_NAME, 'some_other_integration') 46 | addTrafficMonitoringSearchParamsForVisitorIdRequest(url) 47 | url.searchParams.append(SEARCH_PARAM_NAME, 'some_other_integration_2') 48 | const expected = [ 49 | 'some_other_integration', 50 | 'fingerprintjs-pro-cloudflare/__current_worker_version__/ingress', 51 | 'some_other_integration_2', 52 | ] 53 | expectTwoArraysToBeEqual(url.searchParams.getAll(SEARCH_PARAM_NAME), expected) 54 | }) 55 | }) 56 | -------------------------------------------------------------------------------- /test/utils/cookie.test.ts: -------------------------------------------------------------------------------- 1 | import { filterCookies } from '../../src/utils' 2 | 3 | describe('filterCookies', () => { 4 | it('works when there is no cookie', () => { 5 | const headers = new Headers() 6 | headers.set('x', 'y') 7 | const resultHeaders = filterCookies(headers, (key) => key === 'a') 8 | expect(resultHeaders.get('cookie')).toBe(null) 9 | expect(resultHeaders.get('x')).toBe('y') 10 | }) 11 | it('removes other keys', () => { 12 | const headers = new Headers() 13 | headers.set('cookie', 'a=1; b=2') 14 | const resultHeaders = filterCookies(headers, (key) => key === 'a') 15 | expect(resultHeaders.get('cookie')).toBe('a=1') 16 | }) 17 | it('removes other keys when no match', () => { 18 | const headers = new Headers() 19 | headers.set('cookie', 'a=1; b=2') 20 | headers.set('authentication', 'basic YWRtaW46MTIzNDU2') 21 | headers.set('x-custom-header', 'foo_bar') 22 | const resultHeaders = filterCookies(headers, (key) => key === 'c') 23 | expect(resultHeaders.get('cookie')).toBe(null) 24 | expect(resultHeaders.get('authentication')).toBe('basic YWRtaW46MTIzNDU2') 25 | expect(resultHeaders.get('x-custom-header')).toBe('foo_bar') 26 | }) 27 | it('works for _iidt', () => { 28 | const headers = new Headers() 29 | const _iidtCookieValue = 30 | 'jF5EK63pIrQofJ2za7GCbkn+Wy35Qmf2TLAih50+S2fNq86nv9wPH/aOuY7Xkcv1GUIKB1ky2aYT1ilQKoHHZW2tWA==' 31 | headers.set('cookie', `x=y; _iidt=${_iidtCookieValue}; b=2`) 32 | const resultHeaders = filterCookies(headers, (key) => key === '_iidt') 33 | expect(resultHeaders.get('cookie')).toBe(`_iidt=${_iidtCookieValue}`) 34 | }) 35 | }) 36 | -------------------------------------------------------------------------------- /test/utils/createErrorResponse.test.ts: -------------------------------------------------------------------------------- 1 | import { createErrorResponseForIngress, createFallbackErrorResponse } from '../../src/utils' 2 | import { FPJSResponse } from '../../src/utils' 3 | 4 | describe('createErrorResponseForIngress', () => { 5 | let response: Response 6 | beforeEach(() => { 7 | const req = new Request('https://example.com', { headers: { origin: 'https://some-website.com' } }) 8 | const errorReason = 'some error message' 9 | response = createErrorResponseForIngress(req, errorReason) 10 | }) 11 | test('response body is as expected', async () => { 12 | expect(response.body).not.toBeNull() 13 | if (response.body == null) { 14 | return 15 | } 16 | const bodyReader = response.body.getReader() 17 | await bodyReader.read().then((body) => { 18 | if (body.value == null) { 19 | return 20 | } 21 | const bodyString = Array.from(body.value) 22 | .map((el) => String.fromCharCode(el)) 23 | .join('') 24 | const errorData = JSON.parse(bodyString) as FPJSResponse 25 | expect(errorData.v).toBe('2') 26 | expect(errorData.error).not.toBeNull() 27 | expect(errorData.error?.code).toBe('IntegrationFailed') 28 | expect(errorData.error?.message).toBe(`An error occurred with Cloudflare worker. Reason: some error message`) 29 | expect(errorData.requestId).toMatch(/^\d{13}\.[a-zA-Z\d]{6}$/) 30 | expect(errorData.products).toStrictEqual({}) 31 | }) 32 | }) 33 | test('response headers are as expected', () => { 34 | expect(response.headers.get('Access-Control-Allow-Origin')).toBe('https://some-website.com') 35 | expect(response.headers.get('Access-Control-Allow-Credentials')).toBe('true') 36 | }) 37 | test('response status is as expected', () => { 38 | expect(response.status).toBe(500) 39 | }) 40 | test('response headers are as expected when origin is not found', () => { 41 | const reqWithNoOrigin = new Request('https://example.com') 42 | const errorReason = 'some error' 43 | const response = createErrorResponseForIngress(reqWithNoOrigin, errorReason) 44 | expect(response.headers.get('Access-Control-Allow-Origin')).toBe('') 45 | }) 46 | test('handles error type error messages correctly', async () => { 47 | const reqWithNoOrigin = new Request('https://example.com') 48 | const errorReason = new Error('some error message') 49 | const response = createErrorResponseForIngress(reqWithNoOrigin, errorReason) 50 | if (response.body == null) { 51 | return 52 | } 53 | const bodyReader = response.body.getReader() 54 | await bodyReader.read().then((body) => { 55 | if (body.value == null) { 56 | return 57 | } 58 | const bodyString = Array.from(body.value) 59 | .map((el) => String.fromCharCode(el)) 60 | .join('') 61 | const errorData = JSON.parse(bodyString) as FPJSResponse 62 | expect(errorData.error).not.toBeNull() 63 | expect(errorData.error?.code).toBe('IntegrationFailed') 64 | expect(errorData.error?.message).toBe('An error occurred with Cloudflare worker. Reason: some error message') 65 | }) 66 | }) 67 | test('handles unknown type error messages correctly', async () => { 68 | const reqWithNoOrigin = new Request('https://example.com') 69 | const errorReason = { toString: null } 70 | const response = createErrorResponseForIngress(reqWithNoOrigin, errorReason) 71 | if (response.body == null) { 72 | return 73 | } 74 | const bodyReader = response.body.getReader() 75 | await bodyReader.read().then((body) => { 76 | if (body.value == null) { 77 | return 78 | } 79 | const bodyString = Array.from(body.value) 80 | .map((el) => String.fromCharCode(el)) 81 | .join('') 82 | const errorData = JSON.parse(bodyString) as FPJSResponse 83 | expect(errorData.error).not.toBeNull() 84 | expect(errorData.error?.code).toBe('IntegrationFailed') 85 | expect(errorData.error?.message).toBe('An error occurred with Cloudflare worker. Reason: unknown') 86 | }) 87 | }) 88 | }) 89 | 90 | describe('createFallbackErrorResponse', () => { 91 | let response: Response 92 | beforeEach(() => { 93 | const errorReason = 'some error message' 94 | response = createFallbackErrorResponse(errorReason) 95 | }) 96 | test('response body is as expected', async () => { 97 | expect(response.body).not.toBeNull() 98 | if (response.body == null) { 99 | return 100 | } 101 | const bodyReader = response.body.getReader() 102 | await bodyReader.read().then((body) => { 103 | if (body.value == null) { 104 | return 105 | } 106 | const bodyString = Array.from(body.value) 107 | .map((el) => String.fromCharCode(el)) 108 | .join('') 109 | const errorData = JSON.parse(bodyString) as { error: string } 110 | expect(errorData.error).toBe('some error message') 111 | }) 112 | }) 113 | test('response status is as expected', () => { 114 | expect(response.status).toBe(500) 115 | }) 116 | }) 117 | -------------------------------------------------------------------------------- /test/utils/getCacheControlHeaderWithMaxAgeIfLower.test.ts: -------------------------------------------------------------------------------- 1 | import { getCacheControlHeaderWithMaxAgeIfLower } from '../../src/utils' 2 | 3 | describe('getCacheControlHeaderWithMaxAgeIfLower', () => { 4 | const f = getCacheControlHeaderWithMaxAgeIfLower 5 | test('if maxAge < maxMaxAge then use maxAge', () => { 6 | expect(f('public, max-age=3600, s-maxage=633059', 1200, 100)).toBe('public, max-age=1200, s-maxage=100') 7 | }) 8 | test('if maxAge > maxMaxAge then use maxMaxAge', () => { 9 | expect(f('public, max-age=3600, s-maxage=633059', 6000, 100)).toBe('public, max-age=3600, s-maxage=100') 10 | }) 11 | test('if maxAge is absent then use maxMaxAge', () => { 12 | expect(f('public', 6000, 100)).toBe('public, max-age=6000, s-maxage=100') 13 | }) 14 | test('if s-maxAge < maxSMaxAge then use maxSMaxAge', () => { 15 | expect(f('public, max-age=3600, s-maxage=3600', 1200, 1200)).toBe('public, max-age=1200, s-maxage=1200') 16 | }) 17 | test('if s-maxAge > maxMaxAge then use s-MaxAge', () => { 18 | expect(f('public, max-age=3600, s-maxage=3600', 6000, 6000)).toBe('public, max-age=3600, s-maxage=3600') 19 | }) 20 | test('if s-maxAge is absent then use maxSMaxAge', () => { 21 | expect(f('public', 6000, 6000)).toBe('public, max-age=6000, s-maxage=6000') 22 | }) 23 | }) 24 | -------------------------------------------------------------------------------- /test/utils/proxyEndpoint.test.ts: -------------------------------------------------------------------------------- 1 | import { getAgentScriptEndpoint, getVisitorIdEndpoint } from '../../src/utils' 2 | import { config } from '../../src/config' 3 | 4 | describe('getAgentScriptEndpoint', () => { 5 | const f = getAgentScriptEndpoint 6 | const apiKey = 'randomlyGeneratedApiKey' 7 | 8 | test('apiKey exists, version does not exist', () => { 9 | const urlSearchParams = new URLSearchParams() 10 | urlSearchParams.set('apiKey', apiKey) 11 | expect(f(config.fpcdn, urlSearchParams)).toBe(`https://${config.fpcdn}/v3/${apiKey}`) 12 | }) 13 | test('apiKey exists, version exists', () => { 14 | const version = '4' 15 | const urlSearchParams = new URLSearchParams() 16 | urlSearchParams.set('apiKey', apiKey) 17 | urlSearchParams.set('version', version) 18 | expect(f(config.fpcdn, urlSearchParams)).toBe(`https://${config.fpcdn}/v${version}/${apiKey}`) 19 | }) 20 | test('apiKey exists, version does not exist, loaderVersion exists', () => { 21 | const loaderVersion = '3.7.0' 22 | const urlSearchParams = new URLSearchParams() 23 | urlSearchParams.set('apiKey', apiKey) 24 | urlSearchParams.set('loaderVersion', loaderVersion) 25 | expect(f(config.fpcdn, urlSearchParams)).toBe(`https://${config.fpcdn}/v3/${apiKey}/loader_v${loaderVersion}.js`) 26 | }) 27 | test('apiKey exists, version exists, loaderVersion exists', () => { 28 | const version = '4' 29 | const loaderVersion = '3.7.0' 30 | const urlSearchParams = new URLSearchParams() 31 | urlSearchParams.set('apiKey', apiKey) 32 | urlSearchParams.set('version', version) 33 | urlSearchParams.set('loaderVersion', loaderVersion) 34 | expect(f(config.fpcdn, urlSearchParams)).toBe( 35 | `https://${config.fpcdn}/v${version}/${apiKey}/loader_v${loaderVersion}.js` 36 | ) 37 | }) 38 | }) 39 | 40 | describe('getVisitorIdEndpoint', () => { 41 | test('no region', () => { 42 | const urlSearchParams = new URLSearchParams() 43 | expect(getVisitorIdEndpoint(config.ingressApi, urlSearchParams)).toBe(`https://${config.ingressApi}`) 44 | }) 45 | test('us region', () => { 46 | const urlSearchParams = new URLSearchParams() 47 | urlSearchParams.set('region', 'us') 48 | expect(getVisitorIdEndpoint(config.ingressApi, urlSearchParams)).toBe(`https://${config.ingressApi}`) 49 | }) 50 | test('eu region', () => { 51 | const urlSearchParams = new URLSearchParams() 52 | urlSearchParams.set('region', 'eu') 53 | expect(getVisitorIdEndpoint(config.ingressApi, urlSearchParams)).toBe(`https://eu.${config.ingressApi}`) 54 | }) 55 | test('ap region', () => { 56 | const urlSearchParams = new URLSearchParams() 57 | urlSearchParams.set('region', 'ap') 58 | expect(getVisitorIdEndpoint(config.ingressApi, urlSearchParams)).toBe(`https://ap.${config.ingressApi}`) 59 | }) 60 | test('invalid region', () => { 61 | const urlSearchParams = new URLSearchParams() 62 | urlSearchParams.set('region', 'foo.bar/baz') 63 | expect(getVisitorIdEndpoint(config.ingressApi, urlSearchParams)).toBe(`https://${config.ingressApi}`) 64 | }) 65 | test('no region with suffix', () => { 66 | const urlSearchParams = new URLSearchParams() 67 | const pathName = '/suffix/more/path' 68 | expect(getVisitorIdEndpoint(config.ingressApi, urlSearchParams, pathName)).toBe( 69 | `https://${config.ingressApi}/suffix/more/path` 70 | ) 71 | }) 72 | test('us region with suffix', () => { 73 | const urlSearchParams = new URLSearchParams() 74 | const pathName = '/suffix/more/path' 75 | urlSearchParams.set('region', 'us') 76 | expect(getVisitorIdEndpoint(config.ingressApi, urlSearchParams, pathName)).toBe( 77 | `https://${config.ingressApi}/suffix/more/path` 78 | ) 79 | }) 80 | test('eu region with suffix', () => { 81 | const urlSearchParams = new URLSearchParams() 82 | const pathName = '/suffix/more/path' 83 | urlSearchParams.set('region', 'eu') 84 | expect(getVisitorIdEndpoint(config.ingressApi, urlSearchParams, pathName)).toBe( 85 | `https://eu.${config.ingressApi}/suffix/more/path` 86 | ) 87 | }) 88 | test('ap region with suffix', () => { 89 | const urlSearchParams = new URLSearchParams() 90 | const pathName = '/suffix/more/path' 91 | urlSearchParams.set('region', 'ap') 92 | expect(getVisitorIdEndpoint(config.ingressApi, urlSearchParams, pathName)).toBe( 93 | `https://ap.${config.ingressApi}/suffix/more/path` 94 | ) 95 | }) 96 | test('invalid region with suffix', () => { 97 | const urlSearchParams = new URLSearchParams() 98 | const pathName = '/suffix/more/path' 99 | urlSearchParams.set('region', 'foo.bar/baz') 100 | expect(getVisitorIdEndpoint(config.ingressApi, urlSearchParams, pathName)).toBe( 101 | `https://${config.ingressApi}/suffix/more/path` 102 | ) 103 | }) 104 | test('ap region with suffix with dot', () => { 105 | const urlSearchParams = new URLSearchParams() 106 | const pathName = '/.suffix/more/path' 107 | urlSearchParams.set('region', 'ap') 108 | expect(getVisitorIdEndpoint(config.ingressApi, urlSearchParams, pathName)).toBe( 109 | `https://ap.${config.ingressApi}/.suffix/more/path` 110 | ) 111 | }) 112 | test('invalid suffix', () => { 113 | const urlSearchParams = new URLSearchParams() 114 | const pathName = 'suffix/more/path' 115 | urlSearchParams.set('region', 'ap') 116 | expect(getVisitorIdEndpoint(config.ingressApi, urlSearchParams, pathName)).toBe( 117 | `https://ap.${config.ingressApi}/suffix/more/path` 118 | ) 119 | }) 120 | test('invalid suffix starts from dot', () => { 121 | const urlSearchParams = new URLSearchParams() 122 | const pathName = '.suffix/more/path' 123 | urlSearchParams.set('region', 'ap') 124 | expect(getVisitorIdEndpoint(config.ingressApi, urlSearchParams, pathName)).toBe( 125 | `https://ap.${config.ingressApi}/.suffix/more/path` 126 | ) 127 | }) 128 | }) 129 | -------------------------------------------------------------------------------- /test/utils/returnHttpResponse.test.ts: -------------------------------------------------------------------------------- 1 | import { returnHttpResponse } from '../../src/utils' 2 | 3 | describe('returnHttpResponse', () => { 4 | it('remove correct header', () => { 5 | const headers = new Headers() 6 | headers.append('Content-Type', 'image/jpeg') 7 | headers.append('Set-Cookie', 'name=hello') 8 | headers.append('Set-Cookie', 'name=world') 9 | headers.append('Strict-Transport-Security', 'need to remove') 10 | const response = new Response(null, { headers }) 11 | const filteredResponse = returnHttpResponse(response) 12 | expect(filteredResponse.headers.get('Content-Type')).toBe('image/jpeg') 13 | expect(filteredResponse.headers.get('Set-Cookie')).toBe('name=hello, name=world') 14 | expect(filteredResponse.headers.get('Strict-Transport-Security')).toBeNull() 15 | }) 16 | it('do nothing if header is not set', () => { 17 | const headers = new Headers() 18 | headers.append('Content-Type', 'image/jpeg') 19 | const response = new Response(null, { headers }) 20 | const filteredResponse = returnHttpResponse(response) 21 | expect(filteredResponse.headers.get('Content-Type')).toBe('image/jpeg') 22 | expect(filteredResponse.headers.get('Strict-Transport-Security')).toBeNull() 23 | }) 24 | }) 25 | -------------------------------------------------------------------------------- /test/utils/routing.test.ts: -------------------------------------------------------------------------------- 1 | import { removeTrailingSlashesAndMultiSlashes, addTrailingWildcard, replaceDot, createRoute } from '../../src/utils' 2 | import { addEndingTrailingSlashToRoute, addPathnameMatchBeforeRoute } from '../../src/utils' 3 | 4 | describe('removeTrailingSlashesAndMultiSlashes', () => { 5 | it('returns /path for /path', () => { 6 | expect(removeTrailingSlashesAndMultiSlashes('/path')).toBe('/path') 7 | }) 8 | it('returns /path for /path/', () => { 9 | expect(removeTrailingSlashesAndMultiSlashes('/path/')).toBe('/path') 10 | }) 11 | it('returns /path for /path//////', () => { 12 | expect(removeTrailingSlashesAndMultiSlashes('/path//////')).toBe('/path') 13 | }) 14 | it('returns /path/path2 for /path/path2', () => { 15 | expect(removeTrailingSlashesAndMultiSlashes('/path/path2')).toBe('/path/path2') 16 | }) 17 | it('returns /path/path2 for /path/path2/', () => { 18 | expect(removeTrailingSlashesAndMultiSlashes('/path/path2/')).toBe('/path/path2') 19 | }) 20 | it('returns /path/path2 for /path/path2//', () => { 21 | expect(removeTrailingSlashesAndMultiSlashes('/path/path2//')).toBe('/path/path2') 22 | }) 23 | it('returns /path/path2/path3 for /path//path2/path3/', () => { 24 | expect(removeTrailingSlashesAndMultiSlashes('/path//path2/path3/')).toBe('/path/path2/path3') 25 | }) 26 | it('returns /path for ///path', () => { 27 | expect(removeTrailingSlashesAndMultiSlashes('///path')).toBe('/path') 28 | }) 29 | it('returns empty string for empty string', () => { 30 | expect(removeTrailingSlashesAndMultiSlashes('')).toBe('') 31 | }) 32 | }) 33 | 34 | describe('addTrailingWildcard', () => { 35 | it('returns /a for /a', () => { 36 | expect(addTrailingWildcard('/a')).toBe('/a') 37 | }) 38 | it('returns /a(/.*)? for /a/*', () => { 39 | expect(addTrailingWildcard('/a/*')).toBe('/a(/.*)?') 40 | }) 41 | it('returns /a/b(.*)? for /a/b*', () => { 42 | expect(addTrailingWildcard('/a/b*')).toBe('/a/b(.*)?') 43 | }) 44 | it('returns empty string for empty string', () => { 45 | expect(addTrailingWildcard('')).toBe('') 46 | }) 47 | }) 48 | 49 | describe('replaceDot', () => { 50 | it('returns /a for /a', () => { 51 | expect(replaceDot('/a')).toBe('/a') 52 | }) 53 | it('returns /a\\.b/c for /a.b/c', () => { 54 | expect(replaceDot('/a.b/c')).toBe('/a\\.b/c') 55 | }) 56 | it('returns /a/b. for /a/b.', () => { 57 | expect(replaceDot('/a/b.')).toBe('/a/b.') 58 | }) 59 | it('returns empty string for empty string', () => { 60 | expect(replaceDot('')).toBe('') 61 | }) 62 | }) 63 | 64 | describe('addEndingTrailingSlashToRoute', () => { 65 | it('returns /status\\/* for /status', () => { 66 | expect(addEndingTrailingSlashToRoute('/status')).toBe('/status\\/*') 67 | }) 68 | it('returns \\/* for empty string', () => { 69 | expect(addEndingTrailingSlashToRoute('')).toBe('\\/*') 70 | }) 71 | }) 72 | 73 | describe('addPathnameMatchBeforeRoute', () => { 74 | it('returns [\\/[A-Za-z0-9:._-]*/status for /status', () => { 75 | expect(addPathnameMatchBeforeRoute('/status')).toBe('[\\/[A-Za-z0-9:._-]*/status') 76 | }) 77 | it('returns [\\/[A-Za-z0-9:._-]* for empty string', () => { 78 | expect(addPathnameMatchBeforeRoute('')).toBe('[\\/[A-Za-z0-9:._-]*') 79 | }) 80 | }) 81 | 82 | describe('createRoute prefix', () => { 83 | const matchingRouteCases = [ 84 | '/fpjs-worker-path-0123456789/status', 85 | '/fpjsworker/status', 86 | '/status', 87 | '/status/', 88 | '//status', 89 | '//status//', 90 | '/path/path2/path3/path4/status', 91 | '/path/path2//path3/path4/status', 92 | '/worker_path/status', 93 | '/status/worker_path/status', 94 | ] 95 | it.each(matchingRouteCases)('%s matches /status', (route) => { 96 | expect(createRoute('/status').test(route)).toBe(true) 97 | }) 98 | const unMatchingRouteCases = ['/status/some-path'] 99 | it.each(unMatchingRouteCases)("%s doesn't match /status", (route) => { 100 | expect(createRoute('/status').test(route)).toBe(false) 101 | }) 102 | }) 103 | 104 | describe('createRoute suffix', () => { 105 | const suffixPath = '/ingress(/.*)?' 106 | const toBeMatched = ['/ingress', '/ingress/', '/ingress/foo', '/ingress/foo/bar', '/ingress/foo/bar/baz'] 107 | const toBeFail = ['/ingressfoo', '/ingressfoo/', '/ingressfoo/bar'] 108 | 109 | it.each(toBeMatched)('should match with suffix', (suffix) => { 110 | expect(createRoute(suffixPath).test(suffix)).toBe(true) 111 | }) 112 | it.each(toBeFail)('should not match with suffix', (suffix) => { 113 | expect(createRoute(suffixPath).test(suffix)).toBe(false) 114 | }) 115 | }) 116 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./node_modules/@fingerprintjs/tsconfig-dx-team/tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "dist/src", 5 | "module": "es6", 6 | "moduleResolution": "node", 7 | "target": "es2020", 8 | "noEmitOnError": true, 9 | "strict": true, 10 | "esModuleInterop": true, 11 | "types": [ 12 | "@cloudflare/workers-types", 13 | "@types/jest", 14 | "node" 15 | ] 16 | }, 17 | "files": ["src/index.ts"], 18 | "exclude": [ 19 | "dist", 20 | "node_modules", 21 | "**/*.test.ts" 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /wrangler.toml: -------------------------------------------------------------------------------- 1 | account_id = "" 2 | route = "" 3 | name = "" 4 | main = "./dist/fingerprintjs-pro-cloudflare-worker.esm.js" 5 | workers_dev = true 6 | compatibility_date = "2024-04-01" 7 | 8 | [build] 9 | command = "npm install -g pnpm && pnpm install && pnpm build" 10 | 11 | [[rules]] 12 | type = "ESModule" 13 | globs = ["**/*.js"] 14 | --------------------------------------------------------------------------------