├── .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 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
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 |
--------------------------------------------------------------------------------