├── .nvmrc ├── pnpm-workspace.yaml ├── lib ├── __test__ │ ├── __mocks__ │ │ ├── index.ts │ │ └── dom.mocks.ts │ ├── sentry.test.ts │ ├── utils.test.ts │ ├── loader.test.ts │ └── script.test.ts ├── src │ ├── index.ts │ ├── polyfills.ts │ ├── constants.ts │ ├── sentry.ts │ ├── types.ts │ ├── script.ts │ ├── utils.ts │ └── loader.ts ├── jest.setup.ts ├── .eslintrc.json ├── tsconfig.types.json ├── tsconfig.json ├── jest.config.ts ├── README.md ├── package.json └── esbuild.config.js ├── .env.example ├── .npmrc ├── playwright ├── __snapshots__ │ └── loader.spec.ts │ │ └── hcaptcha-loaded.png └── __test__ │ └── loader.spec.ts ├── demo ├── .eslintrc.json ├── package.json └── src │ ├── index.html │ └── assets │ └── css │ └── index.css ├── .github ├── actions │ ├── setup │ │ └── action.yaml │ ├── environment │ │ └── action.yaml │ ├── dependencies │ │ └── action.yaml │ └── playwright │ │ └── action.yaml ├── CODEOWNERS └── workflows │ ├── cd.yaml │ ├── ci.yaml │ └── pull_request.yaml ├── .eslintrc.json ├── tsconfig.json ├── LICENSE ├── MAINTENANCE.md ├── package.json ├── playwright.config.ts ├── .gitignore └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | v20.13 2 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - lib 3 | - demo 4 | -------------------------------------------------------------------------------- /lib/__test__/__mocks__/index.ts: -------------------------------------------------------------------------------- 1 | export * from './dom.mocks'; -------------------------------------------------------------------------------- /lib/src/index.ts: -------------------------------------------------------------------------------- 1 | export { hCaptchaLoader } from './loader'; 2 | 3 | -------------------------------------------------------------------------------- /lib/jest.setup.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/dom'; 2 | import '@testing-library/jest-dom'; -------------------------------------------------------------------------------- /lib/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": false, 3 | "extends": [ 4 | "../.eslintrc.json" 5 | ] 6 | } -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | DEBUG="false" 2 | WATCH="false" 3 | BUILD="production" 4 | 5 | SENTRY_DSN_TOKEN="" 6 | NPM_AUTH_TOKEN="" -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | public-hoist-pattern[]=*eslint* 2 | public-hoist-pattern[]=*prettier* 3 | public-hoist-pattern[]=@types* 4 | 5 | auto-install-peers=true 6 | -------------------------------------------------------------------------------- /playwright/__snapshots__/loader.spec.ts/hcaptcha-loaded.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hcaptcha/hcaptcha-loader/main/playwright/__snapshots__/loader.spec.ts/hcaptcha-loaded.png -------------------------------------------------------------------------------- /demo/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": false, 3 | "extends": [ 4 | "../.eslintrc.json" 5 | ], 6 | "env": { 7 | "browser": true 8 | }, 9 | "plugins": [ 10 | "html" 11 | ] 12 | } -------------------------------------------------------------------------------- /lib/tsconfig.types.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | 4 | "compilerOptions": { 5 | "noEmit": false, 6 | "declaration": true, 7 | "isolatedModules": false, 8 | "emitDeclarationOnly": true, 9 | "outDir": "../dist/types", 10 | }, 11 | "exclude": [ 12 | "src/polyfills.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /.github/actions/setup/action.yaml: -------------------------------------------------------------------------------- 1 | name: Setup environment and dependencies 2 | 3 | runs: 4 | using: "composite" 5 | 6 | steps: 7 | - name: Setup environment 8 | uses: ./.github/actions/environment 9 | - name: Install dependencies 10 | uses: ./.github/actions/dependencies 11 | - name: Install playwright browsers 12 | uses: ./.github/actions/playwright 13 | -------------------------------------------------------------------------------- /demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@loader/demo", 3 | "version": "1.0.0", 4 | "type": "module", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "fix:lint": "eslint -c .eslintrc.json --fix --ext .html,.js ./src", 8 | "test:lint": "eslint -c .eslintrc.json --ext .html,.js ./src" 9 | }, 10 | "devDependencies": { 11 | "@hcaptcha/types": "^1.0.3", 12 | "@types/node": "^18.11.18", 13 | "eslint": "^8.51.0", 14 | "eslint-plugin-html": "^7.1.0" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /lib/src/polyfills.ts: -------------------------------------------------------------------------------- 1 | import 'core-js/es/array/find'; 2 | import 'core-js/es/array/includes'; 3 | import 'core-js/es/object/assign'; 4 | import 'core-js/es/object/entries'; 5 | import 'core-js/es/object/get-own-property-descriptors'; 6 | import 'core-js/es/map'; 7 | import 'core-js/es/set'; 8 | import 'core-js/es/symbol'; 9 | import 'core-js/es/weak-map'; 10 | import 'core-js/es/weak-set'; 11 | import 'core-js/es/string'; 12 | import 'core-js/es/promise'; 13 | import 'core-js/es/array/find-index'; 14 | -------------------------------------------------------------------------------- /lib/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | 4 | "compilerOptions": { 5 | "target": "esnext", 6 | "module": "esnext", 7 | "moduleResolution": "node", 8 | "isolatedModules": true, 9 | "noEmit": true, 10 | }, 11 | "types": [ 12 | "node", 13 | "jest", 14 | "@hcaptcha/types" 15 | ], 16 | 17 | "include": [ 18 | "./src/**/*", 19 | ], 20 | 21 | "ts-node": { 22 | "transpileOnly": true, 23 | "swc": true, 24 | "esm": true 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /lib/__test__/__mocks__/dom.mocks.ts: -------------------------------------------------------------------------------- 1 | type ValueOf = T[keyof T]; 2 | 3 | interface ISpyScriptAccessors { 4 | get: jest.SpiedGetter> 5 | set: jest.SpiedSetter> 6 | } 7 | 8 | export function spyOnScriptMethod(method:keyof HTMLScriptElement): ISpyScriptAccessors { 9 | return { 10 | get: jest.spyOn(HTMLScriptElement.prototype, method, 'get'), 11 | set: jest.spyOn(HTMLScriptElement.prototype, method, 'set') 12 | }; 13 | } -------------------------------------------------------------------------------- /lib/jest.config.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | preset: 'ts-jest', 3 | testEnvironment: 'jsdom', 4 | testPathIgnorePatterns: ['/node_modules/'], 5 | coverageDirectory: './coverage', 6 | moduleNameMapper: { 7 | "^(\\.\\.?\\/.+)\\.js$": "$1" 8 | }, 9 | transform: { 10 | '^.+\\.tsx?$': [ 11 | 'ts-jest', 12 | { 13 | diagnostics: false, // Jest should only test, let typescript validate types separately 14 | }, 15 | ], 16 | }, 17 | setupFilesAfterEnv: ['/jest.setup.ts'] 18 | }; 19 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # These owners will be the default owners for everything in 2 | # the repo. Unless a later match takes precedence,they will 3 | # be requested for review when someone opens a pull request. 4 | * @hCaptcha/hcaptcha-reviewers @hCaptcha/react-reviewers 5 | 6 | # Javascript Owners 7 | *.js @hCaptcha/hcaptcha-reviewers @hCaptcha/react-reviewers 8 | 9 | # Github Action Owners 10 | .github/actions @hCaptcha/hcaptcha-reviewers @hCaptcha/react-reviewers 11 | .github/workflow @hCaptcha/hcaptcha-reviewers @hCaptcha/react-reviewers 12 | -------------------------------------------------------------------------------- /.github/actions/environment/action.yaml: -------------------------------------------------------------------------------- 1 | name: Setup environment 2 | 3 | runs: 4 | using: "composite" 5 | 6 | steps: 7 | - name: Setup pnpm 8 | uses: pnpm/action-setup@7088e561eb65bb68695d245aa206f005ef30921d # v4.1.0 9 | with: 10 | version: 8.x 11 | run_install: false 12 | 13 | - name: Setup node 14 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 15 | with: 16 | node-version-file: '.nvmrc' 17 | cache: 'pnpm' 18 | registry-url: 'https://registry.npmjs.org' 19 | -------------------------------------------------------------------------------- /lib/src/constants.ts: -------------------------------------------------------------------------------- 1 | export const SCRIPT_ID = 'hCaptcha-script'; 2 | export const HCAPTCHA_LOAD_FN_NAME = 'hCaptchaOnLoad'; 3 | export const SCRIPT_ERROR = 'script-error'; 4 | export const SCRIPT_COMPLETE = 'script-loaded'; 5 | 6 | export const SENTRY_TAG = '@hCaptcha/loader'; 7 | 8 | export const MAX_RETRIES = 2; 9 | export const RETRY_DELAY = 1000; 10 | 11 | export enum SentryContext { 12 | ANDROID = 'Android', 13 | IOS = 'iOS', 14 | LINUX = 'Linux', 15 | MAC = 'Mac', 16 | WINDOWS = 'Windows', 17 | CHROME = 'Chrome', 18 | FIREFOX = 'Firefox', 19 | UNKNOWN = 'Unknown', 20 | SAFARI = 'Safari', 21 | OPERA = 'Opera', 22 | IE = 'Internet Explorer', 23 | EDGE = 'Microsoft Edge', 24 | } 25 | -------------------------------------------------------------------------------- /.github/actions/dependencies/action.yaml: -------------------------------------------------------------------------------- 1 | name: Install dependencies 2 | 3 | runs: 4 | using: "composite" 5 | 6 | steps: 7 | - name: Get pnpm store directory 8 | id: pnpm-cache 9 | shell: bash 10 | run: | 11 | echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT 12 | 13 | - name: Setup dependency cache 14 | uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 15 | with: 16 | path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} 17 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 18 | restore-keys: | 19 | ${{ runner.os }}-pnpm-store- 20 | 21 | - name: Install dependencies 22 | shell: bash 23 | run: pnpm install -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true, 4 | "es2020": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:@typescript-eslint/recommended" 9 | ], 10 | "parser": "@typescript-eslint/parser", 11 | "parserOptions": { 12 | "ecmaVersion": 12, 13 | "sourceType": "module" 14 | }, 15 | "plugins": [ 16 | "@typescript-eslint" 17 | ], 18 | "rules": { 19 | "indent": [ 20 | "error", 21 | 2 22 | ], 23 | "linebreak-style": [ 24 | "error", 25 | "unix" 26 | ], 27 | "quotes": [ 28 | "error", 29 | "single" 30 | ], 31 | "semi": [ 32 | "error", 33 | "always" 34 | ], 35 | "@typescript-eslint/no-explicit-any": [ 36 | "off" 37 | ] 38 | } 39 | } -------------------------------------------------------------------------------- /playwright/__test__/loader.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect, Page } from '@playwright/test'; 2 | 3 | test.describe('hCaptchaLoader', async () => { 4 | let page: Page; 5 | 6 | test.beforeAll(async ({ browser }) => { 7 | page = await browser.newPage(); 8 | }); 9 | 10 | test.describe('per view', () => { 11 | 12 | test.beforeAll(async () => { 13 | await page.goto('http://localhost:8080/demo/src', { waitUntil: 'networkidle' }); 14 | }); 15 | 16 | test('should load hCaptcha checkbox', async () => { 17 | const element = await page.getByTestId('hCaptcha').click(); 18 | await expect(page).toHaveScreenshot('hcaptcha-loaded.png', { maxDiffPixelRatio: 0.03, fullPage: true }); 19 | }); 20 | }); 21 | 22 | }); 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | 4 | "composite": true, 5 | "declaration": true, 6 | "strict": false, 7 | "allowJs": true, 8 | "skipLibCheck": true, 9 | "esModuleInterop": true, 10 | "incremental": true, 11 | 12 | "allowSyntheticDefaultImports": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "noFallthroughCasesInSwitch": true, 15 | 16 | "target": "esnext", 17 | "module": "nodenext", 18 | "moduleResolution": "nodenext", 19 | "resolveJsonModule": true, 20 | 21 | "typeRoots": [ 22 | "node_modules/@hcaptcha", 23 | "node_modules/@types" 24 | ], 25 | 26 | "lib": [ 27 | "dom", 28 | "dom.iterable", 29 | "esnext" 30 | ] 31 | 32 | }, 33 | 34 | "exclude": [ 35 | "node_modules", 36 | "build", 37 | "dist", 38 | "scripts" 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /.github/actions/playwright/action.yaml: -------------------------------------------------------------------------------- 1 | name: Install playwright 2 | 3 | runs: 4 | using: "composite" 5 | 6 | steps: 7 | - name: Store Playwright's Version 8 | shell: bash 9 | run: | 10 | PLAYWRIGHT_VERSION=$(pnpm ls @playwright/test --depth 0 | grep @playwright | sed 's/.*@//') 11 | echo "Playwright's Version: $PLAYWRIGHT_VERSION" 12 | echo "PLAYWRIGHT_VERSION=$PLAYWRIGHT_VERSION" >> $GITHUB_ENV 13 | 14 | - name: Cache playwright browsers per version 15 | id: cache-playwright-browsers 16 | uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 17 | with: 18 | path: ~/.cache/ms-playwright 19 | key: playwright-browsers-${{ env.PLAYWRIGHT_VERSION }} 20 | 21 | - name: Setup playwright 22 | if: steps.cache-playwright-browsers.outputs.cache-hit != 'true' 23 | shell: bash 24 | run: npx playwright install --with-deps 25 | -------------------------------------------------------------------------------- /lib/README.md: -------------------------------------------------------------------------------- 1 | # Lib 2 | This folder contains all of the source code for @hcaptcha/loader, along with testing. 3 | 4 | Installation 5 | > pnpm add @loader/lib 6 | 7 | 8 | #### Scripts 9 | 10 | * `pnpm run compile:build`: Builds distributed version 11 | * `pnpm run compile:build`: Builds distributed version in watch mode 12 | * `pnpm run fix:lint`: Fixes lint issues that are automatically solvable 13 | * `pnpm run test:lint`: Runs lint checking 14 | * `pnpm run test:type`: Runs type checking 15 | 16 | 17 | #### Publishing 18 | 19 | To publish a new version, follow the next steps: 20 | 1. Bump the version in `package.json` 21 | 2. Create a [Github Release](https://docs.github.com/en/free-pro-team@latest/github/administering-a-repository/managing-releases-in-a-repository#creating-a-release) with version from step 1 **without** a prefix such as `v` (e.g. `1.0.3`) 22 | * `publish` workflow will be triggered which will: build, test and deploy the package to the [npm @hcaptcha/loader](https://www.npmjs.com/package/@hcaptcha/loader). -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 hCaptcha 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. 22 | -------------------------------------------------------------------------------- /.github/workflows/cd.yaml: -------------------------------------------------------------------------------- 1 | name: CD 2 | 3 | on: 4 | release: 5 | types: [ released ] 6 | 7 | permissions: 8 | contents: read 9 | 10 | jobs: 11 | 12 | publish: 13 | name: Publish 14 | environment: npm 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Check out branch 19 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 20 | with: 21 | persist-credentials: false 22 | - name: Setup environment and dependencies 23 | uses: ./.github/actions/setup 24 | - name: Create .env file 25 | uses: SpicyPizza/create-envfile@ace6d4f5d7802b600276c23ca417e669f1a06f6f # v2.0 26 | with: 27 | envkey_BUILD: production 28 | envkey_DEBUG: false 29 | envkey_SENTRY_DSN_TOKEN: ${{ secrets.SENTRY_DSN_TOKEN }} 30 | file_name: .env 31 | - name: Build lib 32 | shell: bash 33 | run: pnpm run build:lib 34 | - name: Publish package 35 | shell: bash 36 | run: pnpm publish --no-git-checks --access public 37 | env: 38 | NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} -------------------------------------------------------------------------------- /lib/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@loader/lib", 3 | "version": "1.0.0", 4 | "type": "module", 5 | "main": "src/index.ts", 6 | "scripts": { 7 | "compile:build": "rm -rf ../dist && node ./esbuild.config.js && tsc -p tsconfig.types.json", 8 | "compile:watch": "WATCH=true node ./esbuild.config.js", 9 | "fix:lint": "eslint -c .eslintrc.json --fix ./src", 10 | "test:lint": "eslint -c .eslintrc.json ./src", 11 | "test:types": "tsc -p tsconfig.json", 12 | "test:unit": "jest" 13 | }, 14 | "dependencies": { 15 | "@hcaptcha/sentry": "^0.0.4", 16 | "core-js": "^3.35.1" 17 | }, 18 | "devDependencies": { 19 | "@hcaptcha/types": "^1.0.3", 20 | "@jest/globals": "^29.7.0", 21 | "@swc/core": "^1.3.36", 22 | "@testing-library/dom": "^9.3.3", 23 | "@testing-library/jest-dom": "^6.1.3", 24 | "@types/node": "^18.11.18", 25 | "@typescript-eslint/eslint-plugin": "^6.7.4", 26 | "dotenv": "^16.3.1", 27 | "esbuild": "0.17.18", 28 | "expect": "^29.5.0", 29 | "jest": "^29.5.0", 30 | "jest-environment-jsdom": "^29.5.0", 31 | "ts-jest": "^29.0.5", 32 | "ts-node": "^10.9.1", 33 | "typescript": "^4.9.5" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /lib/src/sentry.ts: -------------------------------------------------------------------------------- 1 | import { Scope, Sentry } from '@hcaptcha/sentry'; 2 | 3 | import { RequestContext, SentryHub } from './types'; 4 | import { setContext } from './utils'; 5 | 6 | const SENTRY_DSN = process.env.SENTRY_DSN_TOKEN; 7 | const VERSION = process.env.VERSION; 8 | const BUILD = process.env.BUILD; 9 | 10 | export function initSentry(sentry: boolean = true) { 11 | if (!sentry) { 12 | return getSentry(); 13 | } 14 | Sentry.init({ 15 | dsn: SENTRY_DSN, 16 | release: VERSION, 17 | environment: BUILD 18 | }); 19 | 20 | const scope = Sentry.scope; 21 | setContext(scope); 22 | 23 | return getSentry(scope); 24 | } 25 | 26 | function getSentry(scope: Scope | null = null): SentryHub { 27 | 28 | return { 29 | addBreadcrumb: (breadcrumb) => { 30 | if (!scope) { 31 | return; 32 | } 33 | scope.addBreadcrumb(breadcrumb); 34 | }, 35 | captureRequest: (request: RequestContext) => { 36 | if (!scope) { 37 | return; 38 | } 39 | scope.setRequest(request); 40 | }, 41 | captureException: (error: string | any | Error) => { 42 | if (!scope) { 43 | return; 44 | } 45 | Sentry.captureException(error, scope); 46 | } 47 | }; 48 | } 49 | -------------------------------------------------------------------------------- /MAINTENANCE.md: -------------------------------------------------------------------------------- 1 | ## MAINTENANCE 2 | The below document outlines steps to develop and deploy the hCaptcha loader library. 3 | 4 | #### Requirements: 5 | 1. NodeJS v18.16+ 6 | 2. PNPM v8.0+ 7 | 8 | #### Installation & Setup 9 | 1.Install packages by running `pnpm install` 10 | 2.In separate tabs, running the following: 11 | - `pnpm run dev:lib` 12 | - `pnpm run display:demo` 13 | 14 | 15 | ### Scripts 16 | 17 | * `pnpm run build:lib`: Builds distributed version 18 | * `pnpm run dev:lib`: Creates watcher that rebuilds library when a change occurs 19 | * `pnpm run display:demo`: Starts server and opens demo page 20 | * `pnpm run fix:lint`: Fixes lint issues that are automatically solvable 21 | * `pnpm run test:lint`: Runs lint checking 22 | * `pnpm run test:type`: Runs type checking 23 | * `pnpm run test:unit`: Runs unit tests in @loader/lib 24 | * `pnpm run test:integration`: Runs integrations tests with playwright 25 | 26 | ### Publishing 27 | To publish a new version, follow the next steps: 28 | 29 | - Bump the version in package.json 30 | - Create Pull Request for approval 31 | - Merge to main branch 32 | - Create a Github Release with version number specified in package.json 33 | - Workflow will be triggered, deploying code to [npm @hcaptcha/loader](https://www.npmjs.com/package/@hcaptcha/loader). -------------------------------------------------------------------------------- /lib/src/types.ts: -------------------------------------------------------------------------------- 1 | export interface IScriptParams { 2 | scriptLocation?: HTMLElement; 3 | secureApi?: boolean; 4 | scriptSource?: string; 5 | apihost?: string; 6 | loadAsync?: boolean; 7 | cleanup?: boolean; 8 | query?: string; 9 | crossOrigin?: string; 10 | uj?: boolean; 11 | } 12 | 13 | export interface ILoaderParams extends IScriptParams { 14 | render?: 'explicit'; 15 | sentry?: boolean; 16 | custom?: boolean; 17 | assethost?: string; 18 | imghost?: string; 19 | reportapi?: string; 20 | endpoint?: string; 21 | host?: string; 22 | recaptchacompat?: string; 23 | hl?: string; 24 | cleanup?: boolean; 25 | uj?: boolean; 26 | maxRetries?: number; 27 | retryDelay?: number; 28 | } 29 | 30 | export interface SentryHub { 31 | addBreadcrumb: (breadcrumb: object) => void; 32 | captureException: (error: any) => void; 33 | captureRequest: (context: RequestContext) => void; 34 | } 35 | 36 | export interface ScopeTag { 37 | key: string; 38 | value: string; 39 | } 40 | 41 | export interface BrowserContext { 42 | name: string; 43 | version: string; 44 | } 45 | 46 | export interface DeviceContext { 47 | device: string; 48 | model: string; 49 | family: string; 50 | } 51 | 52 | export interface RequestContext { 53 | method: string; 54 | url: string; 55 | headers?: string[]; 56 | } 57 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | permissions: 9 | contents: read 10 | 11 | env: 12 | CI: true 13 | 14 | jobs: 15 | 16 | setup: 17 | name: Setup 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - name: Check out branch 22 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 23 | with: 24 | persist-credentials: false 25 | - name: Setup environment and dependencies 26 | uses: ./.github/actions/setup 27 | 28 | test_integration: 29 | name: Test Integration 30 | runs-on: ubuntu-latest 31 | 32 | needs: [Setup] 33 | 34 | steps: 35 | - name: Check out branch 36 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 37 | with: 38 | persist-credentials: false 39 | - name: Setup environment and dependencies 40 | uses: ./.github/actions/setup 41 | - name: Run Build 42 | run: pnpm run build:lib 43 | env: 44 | BUILD: production 45 | DEBUG: false 46 | - run: pnpm run test:integration 47 | env: 48 | CI: true 49 | - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 50 | if: always() 51 | with: 52 | name: playwright-results 53 | path: playwright/__test__/test-results/ 54 | retention-days: 30 55 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hcaptcha/loader", 3 | "description": "This is a JavaScript library to easily configure the loading of the hCaptcha JS client SDK with built-in error handling.", 4 | "version": "2.3.0", 5 | "author": "hCaptcha team and contributors", 6 | "license": "MIT", 7 | "keywords": [ 8 | "hcaptcha", 9 | "captcha" 10 | ], 11 | "type": "module", 12 | "main": "./dist/index.mjs", 13 | "types": "./dist/types/src/index.d.ts", 14 | "exports": { 15 | "import": "./dist/index.mjs", 16 | "require": "./dist/index.cjs" 17 | }, 18 | "files": [ 19 | "README.md", 20 | "dist", 21 | "!**/*.tsbuildinfo" 22 | ], 23 | "repository": { 24 | "type": "git", 25 | "url": "https://github.com/hCaptcha/hcaptcha-loader.git" 26 | }, 27 | "scripts": { 28 | "prepare": "pnpm run build:lib", 29 | "build:lib": "pnpm -r --stream run compile:build", 30 | "dev:lib": "pnpm -r --stream run compile:watch", 31 | "display:demo": "http-server ./ --cors -d -o ./demo/src", 32 | "serve:demo": "http-server ./", 33 | "fix:lint": "pnpm -r --stream run fix:lint", 34 | "test:lint": "pnpm -r --stream run test:lint", 35 | "test:types": "pnpm -r --stream run test:types", 36 | "test:unit": "pnpm -r --stream run test:unit", 37 | "test:integration": "playwright test -c playwright.config.ts" 38 | }, 39 | "devDependencies": { 40 | "@loader/demo": "workspace:^", 41 | "@loader/lib": "workspace:^", 42 | "@playwright/experimental-ct-react": "^1.33.0", 43 | "@playwright/test": "^1.33.0", 44 | "@types/node": "^18.11.18", 45 | "http-server": "^14.1.1" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /.github/workflows/pull_request.yaml: -------------------------------------------------------------------------------- 1 | name: Pull Request 2 | 3 | on: 4 | push: 5 | branches: 6 | - '**' 7 | - '!main' 8 | pull_request: 9 | branches: 10 | - '**' 11 | - '!main' 12 | 13 | permissions: 14 | contents: read 15 | 16 | jobs: 17 | 18 | setup: 19 | name: Setup 20 | runs-on: ubuntu-latest 21 | 22 | steps: 23 | - name: Check out branch 24 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 25 | with: 26 | persist-credentials: false 27 | - name: Setup environment and dependencies 28 | uses: ./.github/actions/setup 29 | 30 | test_style: 31 | name: Lint & Typecheck 32 | runs-on: ubuntu-latest 33 | 34 | needs: [setup] 35 | 36 | steps: 37 | - name: Check out branch 38 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 39 | with: 40 | persist-credentials: false 41 | - name: Setup environment and dependencies 42 | uses: ./.github/actions/setup 43 | - name: Check Lint 44 | run: pnpm run test:lint 45 | - name: Check Types 46 | run: pnpm run test:types 47 | 48 | test_unit: 49 | name: Unit Tests 50 | runs-on: ubuntu-latest 51 | 52 | needs: [setup] 53 | 54 | steps: 55 | - name: Check out branch 56 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 57 | with: 58 | persist-credentials: false 59 | - name: Setup environment and dependencies 60 | uses: ./.github/actions/setup 61 | - name: Run Unit Tests 62 | run: pnpm run test:unit -------------------------------------------------------------------------------- /lib/src/script.ts: -------------------------------------------------------------------------------- 1 | import { HCAPTCHA_LOAD_FN_NAME, SCRIPT_ID } from './constants'; 2 | import { getFrame, getMountElement } from './utils'; 3 | 4 | import type { IScriptParams } from './types'; 5 | 6 | export function fetchScript({ 7 | scriptLocation, 8 | query, 9 | loadAsync = true, 10 | crossOrigin = 'anonymous', 11 | apihost = 'https://js.hcaptcha.com', 12 | cleanup = false, 13 | secureApi = false, 14 | scriptSource = '' 15 | }: IScriptParams = {}, 16 | onError?: (message) => void 17 | ) { 18 | const element = getMountElement(scriptLocation); 19 | const frame: any = getFrame(element); 20 | 21 | return new Promise((resolve, reject) => { 22 | const script = frame.document.createElement('script'); 23 | 24 | script.id = SCRIPT_ID; 25 | if (scriptSource) { 26 | script.src = `${scriptSource}?onload=${HCAPTCHA_LOAD_FN_NAME}`; 27 | } else { 28 | if (secureApi) { 29 | script.src = `${apihost}/1/secure-api.js?onload=${HCAPTCHA_LOAD_FN_NAME}`; 30 | } else { 31 | script.src = `${apihost}/1/api.js?onload=${HCAPTCHA_LOAD_FN_NAME}`; 32 | } 33 | } 34 | script.crossOrigin = crossOrigin; 35 | script.async = loadAsync; 36 | 37 | const onComplete = (event, callback) => { 38 | try { 39 | if (!secureApi && cleanup) { 40 | element.removeChild(script); 41 | } 42 | callback(event); 43 | } catch (error) { 44 | reject(error); 45 | } 46 | }; 47 | 48 | script.onload = (event) => onComplete(event, resolve); 49 | script.onerror = (event) => { 50 | if (onError) { 51 | onError(script.src); 52 | } 53 | onComplete(event, reject); 54 | }; 55 | 56 | script.src += query !== '' ? `&${query}` : ''; 57 | 58 | element.appendChild(script); 59 | } 60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from '@playwright/test'; 2 | 3 | /** 4 | * See https://playwright.dev/docs/test-configuration. 5 | */ 6 | export default defineConfig({ 7 | testDir: './playwright/__test__/', 8 | testMatch: '*.spec.ts', 9 | testIgnore: './lib/__test__/', 10 | /* The base directory, relative to the config file, for snapshot files created with toMatchSnapshot and toHaveScreenshot. */ 11 | snapshotDir: './playwright/__snapshots__/', 12 | snapshotPathTemplate: './playwright/__snapshots__/{testFilePath}/{arg}{ext}', 13 | outputDir: './playwright/__test__/test-results', 14 | /* Maximum time one test can run for. */ 15 | timeout: 10 * 1000, 16 | /* Run tests in files in parallel */ 17 | fullyParallel: false, 18 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 19 | forbidOnly: !!process.env.CI, 20 | /* Retry on CI only */ 21 | retries: process.env.CI ? 1 : 0, 22 | /* Opt out of parallel tests on CI. */ 23 | workers: process.env.CI ? 1 : undefined, 24 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 25 | reporter: [ 26 | process.env.CI? ['github', 'html'] : ['list'], 27 | ], 28 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 29 | use: { 30 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 31 | trace: 'on-first-retry', 32 | }, 33 | webServer: { 34 | command: 'pnpm serve:demo', 35 | url: 'http://127.0.0.1:8080/demo/src', 36 | timeout: 120 * 1000, 37 | reuseExistingServer: !process.env.CI, 38 | }, 39 | /* Configure projects for major browsers */ 40 | projects: [ 41 | { 42 | name: 'chromium', 43 | use: { ...devices['Desktop Chrome'] }, 44 | }, 45 | ], 46 | }); 47 | -------------------------------------------------------------------------------- /lib/__test__/sentry.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, jest, expect, afterEach } from '@jest/globals'; 2 | import { Sentry } from '@hcaptcha/sentry'; 3 | 4 | import { initSentry } from '../src/sentry'; 5 | 6 | jest.mock('@hcaptcha/sentry', () => ({ 7 | Sentry: { 8 | init: jest.fn(), 9 | captureException: jest.fn(), 10 | scope: { 11 | addBreadcrumb: jest.fn(), 12 | setTag: jest.fn(), 13 | setContext: jest.fn(), 14 | setRequest: jest.fn(), 15 | }, 16 | }, 17 | })); 18 | 19 | describe('Sentry', () => { 20 | 21 | afterEach(() => { 22 | jest.clearAllMocks(); 23 | }); 24 | 25 | it('should initialize Sentry Hub and return wrapper', () => { 26 | const wrapper = initSentry(true); 27 | 28 | expect(Sentry.init).toHaveBeenCalledTimes(1); 29 | expect(Sentry.scope.setTag).toHaveBeenCalledTimes(2); 30 | expect(Sentry.scope.setContext).toHaveBeenCalledTimes(3); 31 | 32 | expect(wrapper).toBeTruthy(); 33 | }); 34 | 35 | it('should initialize sentry and call api', () => { 36 | const wrapper = initSentry(true); 37 | 38 | wrapper.addBreadcrumb({ category: 'test' }); 39 | wrapper.captureRequest({ method: 'GET', url: 'test' }); 40 | wrapper.captureException('test error'); 41 | 42 | expect(Sentry.init).toHaveBeenCalledTimes(1); 43 | expect(Sentry.scope.addBreadcrumb).toHaveBeenCalledTimes(1); 44 | expect(Sentry.scope.setRequest).toHaveBeenCalledTimes(1); 45 | expect(Sentry.captureException).toHaveBeenCalledTimes(1); 46 | 47 | expect(wrapper).toBeTruthy(); 48 | }); 49 | 50 | 51 | it('should not throw when Sentry is false', () => { 52 | const wrapper = initSentry(false); 53 | 54 | const testWrapperCall = () => { 55 | wrapper.addBreadcrumb({ category: 'test' }); 56 | wrapper.captureException('test error'); 57 | }; 58 | 59 | expect(testWrapperCall).not.toThrow(); 60 | }); 61 | 62 | }); 63 | 64 | -------------------------------------------------------------------------------- /demo/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | hCAPTCHA Loader Demo 5 | 6 | 7 | 8 | 9 | 10 | 25 | 26 | 27 |
28 |
29 |
30 | 31 |
32 | 33 | 34 |
35 | 36 |
37 | 38 | 39 |
40 | 41 |
42 | 43 | 44 |
45 | 46 |
47 |
48 | 49 |
50 |
Submit
51 |
52 | 53 |
54 |
55 |
56 | 57 | 58 | -------------------------------------------------------------------------------- /demo/src/assets/css/index.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | padding: 0; 3 | margin: 0 auto; 4 | width: 100%; 5 | height: 100%; 6 | } 7 | 8 | * { 9 | -moz-user-select: none; 10 | -webkit-user-select: none; 11 | -o-user-select: none; 12 | -ms-user-select: none; 13 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0); 14 | -webkit-text-size-adjust: none; 15 | outline: none; 16 | -webkit-font-smoothing: antialiased; 17 | } 18 | 19 | input:focus, 20 | select:focus, 21 | textarea:focus, 22 | button:focus { 23 | outline: none; 24 | -moz-user-select: auto; 25 | -webkit-user-select: auto; 26 | -o-user-select: auto; 27 | -ms-user-select: auto; 28 | } 29 | 30 | 31 | #Page-Container { 32 | position: absolute; 33 | 34 | width: 100%; 35 | height: 100%; 36 | 37 | overflow: hidden; 38 | background: #fafafa; 39 | } 40 | 41 | #Form-Container { 42 | position: absolute; 43 | 44 | width: 320px; 45 | height: 400px; 46 | 47 | top: 50%; 48 | left: 50%; 49 | margin-left: -160px; 50 | margin-top: -200px; 51 | 52 | border-radius: 5px; 53 | 54 | color: #333; 55 | font-family: canada-type-gibson, sans-serif; 56 | background-color: #fff; 57 | } 58 | 59 | #Form-Example { 60 | 61 | border-radius: 10px; 62 | width: 320px; 63 | height: 380px; 64 | 65 | position: absolute; 66 | top: 50%; 67 | margin-top: -190px; 68 | 69 | } 70 | 71 | input { 72 | border-radius: 4px; 73 | background-color: #fafafa; 74 | font-size: 16px; 75 | font-weight: 300; 76 | padding: 8px 12px; 77 | color: #333; 78 | border: 1px solid #ccc; 79 | 80 | box-sizing: border-box; 81 | font-family: canada-type-gibson, sans-serif; 82 | } 83 | 84 | #form-submit { 85 | width: 125px; 86 | height: 50px; 87 | background-color: #6549c7; 88 | border-radius: 5px; 89 | cursor: pointer; 90 | 91 | position: absolute; 92 | left: 50%; 93 | margin-left: -62.5px; 94 | } 95 | 96 | #form-submit:hover { 97 | background-color: #464252; 98 | } 99 | 100 | #form-submit .text { 101 | font-weight: 700; 102 | width: 100%; 103 | text-align: center; 104 | height: 50px; 105 | line-height: 50px; 106 | } 107 | 108 | .white { 109 | color: #fff; 110 | } 111 | 112 | .input-wrapper { 113 | position: relative; 114 | 115 | height: 60px; 116 | width: 300px; 117 | margin: 0 auto; 118 | margin-bottom: 15px; 119 | } 120 | 121 | .input-wrapper label { 122 | position: absolute; 123 | top: 0; 124 | } 125 | 126 | .input-wrapper input { 127 | position: absolute; 128 | bottom: 0; 129 | width: 100%; 130 | } 131 | 132 | #captcha-wrapper { 133 | width: 302px; 134 | height: 75px; 135 | margin: 0 auto; 136 | margin-bottom: 25px; 137 | } 138 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # MacOS 123 | .DS_Store 124 | **/.DS_Store 125 | 126 | # Stores VSCode versions used for testing VSCode extensions 127 | .vscode-test 128 | 129 | # JetBrains IDE 130 | .idea 131 | 132 | # yarn v2 133 | .yarn/cache 134 | .yarn/unplugged 135 | .yarn/build-state.yml 136 | .yarn/install-state.gz 137 | .pnp.* 138 | 139 | 140 | # playwright 141 | playwright/__snapshots__/**/playwright-report/ 142 | playwright/__snapshots__/**/.cache/ 143 | playwright/__test__/test-results/ 144 | -------------------------------------------------------------------------------- /lib/__test__/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from '@jest/globals'; 2 | 3 | import { generateQuery, getFrame, getMountElement } from '../src/utils'; 4 | 5 | describe('generateQuery', () => { 6 | 7 | it('Property foo to equal bar as string foo=bar:', () => { 8 | const params = { 9 | foo: 'bar' 10 | }; 11 | expect(generateQuery(params)).toBe('foo=bar'); 12 | }); 13 | 14 | it('Spaces to be encoded with %20', () => { 15 | const params = { 16 | foo: 'bar baz bah' 17 | }; 18 | expect(generateQuery(params)).toBe('foo=bar%20baz%20bah'); 19 | }); 20 | 21 | it('Chain multiple parameters', () => { 22 | const params = { 23 | foo: 'bar', 24 | baz: true 25 | }; 26 | expect(generateQuery(params)).toBe('foo=bar&baz=true'); 27 | }); 28 | 29 | it('false should be a valid query value', () => { 30 | const params = { 31 | foo: false 32 | }; 33 | expect(generateQuery(params)).toBe('foo=false'); 34 | }); 35 | 36 | it('Null, undefined, and empty string values should be removed', () => { 37 | const params = { 38 | foo: '', 39 | bar: null, 40 | baz: undefined, 41 | bah: true 42 | }; 43 | expect(generateQuery(params)).toBe('bah=true'); 44 | }); 45 | 46 | }); 47 | 48 | 49 | describe('getFrame', () => { 50 | 51 | it('should return the default document and window for the root application', () => { 52 | const frame = getFrame(); 53 | expect(frame.document).toEqual(document); 54 | expect(frame.window).toEqual(global); 55 | }); 56 | 57 | it('should return the root document and window for the supplied element in the root application', () => { 58 | const element = document.createElement('div'); 59 | document.body.appendChild(element); 60 | 61 | const frame = getFrame(element); 62 | expect(frame.document).toEqual(document); 63 | expect(frame.window).toEqual(global); 64 | 65 | // clean up 66 | document.body.removeChild(element); 67 | }); 68 | 69 | it('should return the corresponding frame document and window for the an element found in another document', () => { 70 | const iframe = document.createElement('iframe'); 71 | document.body.appendChild(iframe); 72 | 73 | const frameWindow = iframe.contentWindow; 74 | const frameDocument = frameWindow.document; 75 | 76 | const element = frameDocument.createElement('div'); 77 | frameDocument.body.appendChild(element); 78 | 79 | const frame = getFrame(element); 80 | expect(frame.document).toEqual(frameDocument); 81 | expect(frame.window).toEqual(frameWindow); 82 | 83 | expect(frame.document).not.toEqual(document); 84 | expect(frame.window).not.toEqual(global); 85 | 86 | // clean up 87 | document.body.removeChild(iframe); 88 | }); 89 | 90 | }); 91 | 92 | describe('getMountElement', () => { 93 | 94 | it('should return document.head by default', () => { 95 | const mountElement = getMountElement(); 96 | expect(mountElement).toEqual(document.head); 97 | }); 98 | 99 | it('should return element passed in', () => { 100 | const element = document.createElement('div'); 101 | const mountElement = getMountElement(element); 102 | expect(mountElement).toEqual(element); 103 | }); 104 | 105 | }); 106 | -------------------------------------------------------------------------------- /lib/esbuild.config.js: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'fs'; 2 | import path, { resolve } from 'path'; 3 | import { fileURLToPath } from 'url'; 4 | 5 | import { build, context, analyzeMetafile } from 'esbuild'; 6 | import * as dotenv from 'dotenv'; 7 | import swc from '@swc/core'; 8 | 9 | const __filename = fileURLToPath(import.meta.url); 10 | const __dirname = path.dirname(__filename); 11 | 12 | 13 | const ROOT = resolve(__dirname, '..'); 14 | const DIST = resolve(ROOT, 'dist'); 15 | const SRC = resolve(__dirname, 'src'); 16 | 17 | const VERSION = JSON.parse(readFileSync(resolve(ROOT, 'package.json'), 'utf-8')).version; 18 | 19 | dotenv.config({ 20 | path: `${ROOT}/.env` 21 | }); 22 | 23 | const BUILD = process.env.BUILD || 'production'; 24 | const DEBUG = process.env.DEBUG === 'true'; 25 | const WATCH = process.env.WATCH === 'true'; 26 | const SENTRY = process.env.SENTRY_DSN_TOKEN || '__DSN__'; 27 | 28 | const config = { 29 | /* Setup */ 30 | bundle: true, 31 | entryPoints: [resolve(SRC, 'index.ts')], 32 | external: ['@hcaptcha/types'], 33 | tsconfig: 'tsconfig.json', 34 | define: { 35 | 'process.env.SENTRY_DSN_TOKEN': JSON.stringify(SENTRY), 36 | 'process.env.VERSION': JSON.stringify(VERSION), 37 | 'process.env.BUILD': JSON.stringify(BUILD), 38 | 39 | }, 40 | 41 | /* Output */ 42 | minify: true, 43 | 44 | /* CI */ 45 | color: true, 46 | allowOverwrite: true, 47 | logLevel: 'info', 48 | 49 | /* Source Maps */ 50 | metafile: DEBUG, 51 | sourcemap: BUILD === 'development', 52 | }; 53 | 54 | const swcOptions = { 55 | minify: true, 56 | sourceMaps: BUILD === 'development', 57 | jsc: { 58 | target: 'es5', 59 | }, 60 | }; 61 | 62 | 63 | if (WATCH) { 64 | const ctx = await context({ 65 | ...config, 66 | format: 'esm', 67 | outfile: resolve(DIST, 'index.mjs'), 68 | treeShaking: true, 69 | target: [ 70 | 'es6' 71 | ] 72 | }); 73 | await ctx.watch(); 74 | } else { 75 | // Transpile TypeScript to ESM 76 | const resultESM = await build({ 77 | ...config, 78 | format: 'esm', 79 | outfile: resolve(DIST, 'index.mjs'), 80 | treeShaking: true, 81 | target: [ 82 | 'es6' 83 | ] 84 | }); 85 | 86 | // Transpile TypeScript to CommonJS 87 | const resultCJS = await build({ 88 | ...config, 89 | format: 'cjs', 90 | outfile: resolve(DIST, 'index.cjs'), 91 | treeShaking: true, 92 | }); 93 | 94 | // Transform to ES5 95 | const transformedESM = await swc.transformFile(resolve(DIST, 'index.mjs'), swcOptions); 96 | 97 | // Build ES5 bundle 98 | const resultES5 = await build({ 99 | ...config, 100 | entryPoints: undefined, 101 | globalName: 'hCaptchaLoaderPkg', 102 | stdin: { 103 | contents: transformedESM.code, 104 | resolveDir: DIST, 105 | sourcefile: 'index.es5.js', 106 | }, 107 | outfile: resolve(DIST, 'index.es5.js'), 108 | footer: { 109 | js: 'window.hCaptchaLoader = hCaptchaLoaderPkg.hCaptchaLoader;', 110 | }, 111 | treeShaking: true, 112 | target: [ 113 | 'es5', 114 | ] 115 | }); 116 | 117 | // Add Polyfills 118 | await build({ 119 | ...config, 120 | entryPoints: [resolve(SRC, 'polyfills.ts')], 121 | outfile: resolve(DIST, 'polyfills.js'), 122 | treeShaking: true, 123 | target: [ 124 | 'es5', 125 | ] 126 | }); 127 | 128 | if (DEBUG) { 129 | const analyzeESM = await analyzeMetafile(resultESM.metafile, { 130 | verbose: false 131 | }); 132 | const analyzeCJS = await analyzeMetafile(resultCJS.metafile, { 133 | verbose: false 134 | }); 135 | const analyzeES5 = await analyzeMetafile(resultES5.metafile, { 136 | verbose: false 137 | }); 138 | 139 | console.log(analyzeESM); 140 | console.log(analyzeCJS); 141 | console.log(analyzeES5); 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /lib/src/utils.ts: -------------------------------------------------------------------------------- 1 | import { BrowserContext, DeviceContext } from './types'; 2 | import { SENTRY_TAG, SentryContext } from './constants'; 3 | 4 | export function generateQuery(params) { 5 | return Object.entries(params) 6 | .filter(([, value]) => value || value === false) 7 | .map(([key, value]) => { 8 | return `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`; 9 | }).join('&'); 10 | } 11 | 12 | export function getFrame(element?: Element) { 13 | const doc: any = (element && element.ownerDocument) || document; 14 | const win = doc.defaultView || doc.parentWindow || window; 15 | 16 | return { document: doc, window: win }; 17 | } 18 | 19 | export function getMountElement(element?: Element) { 20 | return element || document.head; 21 | } 22 | 23 | export function setContext(scope): void { 24 | scope.setTag('source', SENTRY_TAG); 25 | scope.setTag('url', document.URL); 26 | 27 | scope.setContext('os', { 28 | UA: navigator.userAgent, 29 | }); 30 | 31 | scope.setContext('browser', { 32 | ...getBrowser(), 33 | }); 34 | scope.setContext('device', { 35 | ...getDevice(), 36 | screen_width_pixels: screen.width, 37 | screen_height_pixels: screen.height, 38 | language: navigator.language, 39 | orientation: screen.orientation?.type || SentryContext.UNKNOWN, 40 | processor_count: navigator.hardwareConcurrency, 41 | platform: navigator.platform, 42 | }); 43 | } 44 | 45 | function getBrowser(): BrowserContext { 46 | const userAgent = navigator.userAgent; 47 | let name, version; 48 | 49 | if (userAgent.indexOf('Firefox') !== -1) { 50 | name = SentryContext.FIREFOX; 51 | version = userAgent.match(/Firefox\/([\d.]+)/)?.[1]; 52 | } else if (userAgent.indexOf('Edg') !== -1) { 53 | name = SentryContext.EDGE; 54 | version = userAgent.match(/Edg\/([\d.]+)/)?.[1]; 55 | } else if (userAgent.indexOf('Chrome') !== -1 && userAgent.indexOf('Safari') !== -1) { 56 | name = SentryContext.CHROME; 57 | version = userAgent.match(/Chrome\/([\d.]+)/)?.[1]; 58 | } else if (userAgent.indexOf('Safari') !== -1 && userAgent.indexOf('Chrome') === -1) { 59 | name = SentryContext.SAFARI; 60 | version = userAgent.match(/Version\/([\d.]+)/)?.[1]; 61 | } else if (userAgent.indexOf('Opera') !== -1 || userAgent.indexOf('OPR') !== -1) { 62 | name = SentryContext.OPERA; 63 | version = userAgent.match(/(Opera|OPR)\/([\d.]+)/)?.[2]; 64 | } else if (userAgent.indexOf('MSIE') !== -1 || userAgent.indexOf('Trident') !== -1) { 65 | name = SentryContext.IE; 66 | version = userAgent.match(/(MSIE |rv:)([\d.]+)/)?.[2]; 67 | } else { 68 | name = SentryContext.UNKNOWN; 69 | version = SentryContext.UNKNOWN; 70 | } 71 | 72 | return { name, version }; 73 | } 74 | 75 | export function delay(ms: number): Promise { 76 | return new Promise(resolve => setTimeout(resolve, ms)); 77 | } 78 | 79 | function getDevice(): DeviceContext { 80 | const userAgent = navigator.userAgent; 81 | 82 | let model; 83 | if (userAgent.indexOf('Win') !== -1) { 84 | model = SentryContext.WINDOWS; 85 | 86 | } else if (userAgent.indexOf(SentryContext.MAC) !== -1) { 87 | model = SentryContext.MAC; 88 | } else if (userAgent.indexOf(SentryContext.LINUX) !== -1) { 89 | model = SentryContext.LINUX; 90 | } else if (userAgent.indexOf(SentryContext.ANDROID) !== -1) { 91 | model = SentryContext.ANDROID; 92 | } else if ( 93 | userAgent.indexOf('like Mac') !== -1 || 94 | userAgent.indexOf('iPhone') !== -1 || 95 | userAgent.indexOf('iPad') !== -1 96 | ) { 97 | model = SentryContext.IOS; 98 | } else { 99 | model = SentryContext.UNKNOWN; 100 | } 101 | 102 | let device; 103 | if (/Mobile|iPhone|iPod|Android/i.test(userAgent)) { 104 | device = 'Mobile'; 105 | } else if (/Tablet|iPad/i.test(userAgent)) { 106 | device = 'Tablet'; 107 | } else { 108 | device = 'Desktop'; 109 | } 110 | 111 | return { model, family: model, device }; 112 | } 113 | -------------------------------------------------------------------------------- /lib/src/loader.ts: -------------------------------------------------------------------------------- 1 | import { delay, generateQuery, getFrame, getMountElement } from './utils'; 2 | import { HCAPTCHA_LOAD_FN_NAME, MAX_RETRIES, RETRY_DELAY, SCRIPT_ERROR, SENTRY_TAG } from './constants'; 3 | import { initSentry } from './sentry'; 4 | import { fetchScript } from './script'; 5 | 6 | import type { ILoaderParams, SentryHub } from './types'; 7 | 8 | // Prevent loading API script multiple times 9 | export const hCaptchaScripts = []; 10 | 11 | // Generate hCaptcha API script 12 | export function hCaptchaApi(params: ILoaderParams = { cleanup: false }, sentry: SentryHub): Promise { 13 | 14 | try { 15 | 16 | sentry.addBreadcrumb({ 17 | category: SENTRY_TAG, 18 | message: 'hCaptcha loader params', 19 | data: params, 20 | }); 21 | 22 | const element = getMountElement(params.scriptLocation); 23 | const frame: any = getFrame(element); 24 | const script = hCaptchaScripts.find(({ scope }) => scope === frame.window); 25 | 26 | if (script) { 27 | sentry.addBreadcrumb({ 28 | category: SENTRY_TAG, 29 | message: 'hCaptcha already loaded', 30 | }); 31 | 32 | // API was already requested 33 | return script.promise; 34 | } 35 | 36 | const promise = new Promise( 37 | // eslint-disable-next-line no-async-promise-executor 38 | async (resolve: (value: any) => void, reject: (value: Error) => void) => { 39 | try { 40 | 41 | // Create global onload callback for the hCaptcha library to call 42 | frame.window[HCAPTCHA_LOAD_FN_NAME] = () => { 43 | sentry.addBreadcrumb({ 44 | category: SENTRY_TAG, 45 | message: 'hCaptcha script called onload function', 46 | }); 47 | 48 | // Resolve loader once hCaptcha library says its ready 49 | resolve(frame.window.hcaptcha); 50 | }; 51 | 52 | const query = generateQuery({ 53 | custom: params.custom, 54 | render: params.render, 55 | sentry: params.sentry, 56 | assethost: params.assethost, 57 | imghost: params.imghost, 58 | reportapi: params.reportapi, 59 | endpoint: params.endpoint, 60 | host: params.host, 61 | recaptchacompat: params.recaptchacompat, 62 | hl: params.hl, 63 | uj: params.uj, 64 | }); 65 | 66 | await fetchScript( 67 | { query, ...params }, 68 | (src) => { 69 | sentry.captureRequest({ 70 | url: src, 71 | method: 'GET', 72 | }); 73 | }); 74 | 75 | sentry.addBreadcrumb({ 76 | category: SENTRY_TAG, 77 | message: 'hCaptcha loaded', 78 | data: script 79 | }); 80 | 81 | } catch(error) { 82 | sentry.addBreadcrumb({ 83 | category: SENTRY_TAG, 84 | message: 'hCaptcha failed to load', 85 | }); 86 | 87 | 88 | const scriptIndex = hCaptchaScripts.findIndex(script => script.scope === frame.window); 89 | 90 | if (scriptIndex !== -1) { 91 | hCaptchaScripts.splice(scriptIndex, 1); 92 | } 93 | 94 | reject(new Error(SCRIPT_ERROR)); 95 | } 96 | } 97 | ); 98 | 99 | hCaptchaScripts.push({ promise, scope: frame.window }); 100 | return promise; 101 | } catch (error) { 102 | sentry.captureException(error); 103 | return Promise.reject(new Error(SCRIPT_ERROR)); 104 | } 105 | } 106 | 107 | export async function loadScript( 108 | params: ILoaderParams, 109 | sentry: SentryHub, 110 | retries = 0 111 | ): Promise { 112 | const maxRetries = params.maxRetries ?? MAX_RETRIES; 113 | const retryDelay = params.retryDelay ?? RETRY_DELAY; 114 | const message = retries < maxRetries ? 'Retry loading hCaptcha Api' : 'Exceeded maximum retries'; 115 | 116 | try { 117 | return await hCaptchaApi(params, sentry); 118 | } catch (error) { 119 | 120 | sentry.addBreadcrumb({ 121 | category: SENTRY_TAG, 122 | message, 123 | }); 124 | 125 | if (retries >= maxRetries) { 126 | sentry.captureException(error); 127 | return Promise.reject(error); 128 | } else { 129 | sentry.addBreadcrumb({ 130 | category: SENTRY_TAG, 131 | message: `Waiting ${retryDelay}ms before retry attempt ${retries + 1}`, 132 | }); 133 | 134 | await delay(retryDelay); 135 | retries += 1; 136 | 137 | return loadScript(params, sentry, retries); 138 | } 139 | } 140 | } 141 | 142 | export async function hCaptchaLoader(params: ILoaderParams = {}) { 143 | const sentry = initSentry(params.sentry); 144 | 145 | return await loadScript(params, sentry); 146 | } 147 | -------------------------------------------------------------------------------- /lib/__test__/loader.test.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, afterAll, beforeAll, describe, it, jest, expect } from '@jest/globals'; 2 | import { waitFor } from '@testing-library/dom'; 3 | 4 | import { fetchScript } from '../src/script'; 5 | import { hCaptchaLoader, hCaptchaScripts } from '../src/loader'; 6 | import { HCAPTCHA_LOAD_FN_NAME, SCRIPT_COMPLETE, SCRIPT_ERROR } from '../src/constants'; 7 | 8 | jest.mock('@hcaptcha/sentry', () => ({ 9 | Sentry: { 10 | init: jest.fn(), 11 | captureException: jest.fn(), 12 | scope: { 13 | addBreadcrumb: jest.fn(), 14 | setTag: jest.fn(), 15 | setContext: jest.fn(), 16 | setRequest: jest.fn(), 17 | }, 18 | }, 19 | })); 20 | 21 | jest.mock('../src/script'); 22 | 23 | const mockFetchScript = fetchScript as jest.MockedFunction; 24 | 25 | function cleanupScripts() { 26 | let i = hCaptchaScripts.length; 27 | while (--i > -1) { 28 | hCaptchaScripts.splice(i, 1); 29 | } 30 | } 31 | 32 | describe('hCaptchaLoader', () => { 33 | 34 | describe('script success', () => { 35 | 36 | beforeAll(() => { 37 | (window as any).hcaptcha = 'hcaptcha-test'; 38 | }); 39 | 40 | afterEach(() => { 41 | jest.resetAllMocks(); 42 | }); 43 | 44 | afterAll(() => { 45 | cleanupScripts(); 46 | }); 47 | 48 | it('should fetch script by default', async () => { 49 | mockFetchScript.mockResolvedValueOnce(SCRIPT_COMPLETE); 50 | 51 | const promise = hCaptchaLoader({ sentry: false }); 52 | 53 | await waitFor(() => { 54 | expect(mockFetchScript).toHaveBeenCalledTimes(1); 55 | 56 | // Trigger script onload callback to resolve promise 57 | window[HCAPTCHA_LOAD_FN_NAME](); 58 | expect(promise).resolves.toEqual((window as any).hcaptcha); 59 | }); 60 | }); 61 | 62 | it('should not fetch script since it was already loaded', async () => { 63 | const result = await hCaptchaLoader({ sentry: false }); 64 | expect(result).toEqual((window as any).hcaptcha); 65 | expect(mockFetchScript).not.toHaveBeenCalled(); 66 | }); 67 | }); 68 | 69 | describe('script retry', () => { 70 | 71 | beforeAll(() => { 72 | (window as any).hcaptcha = 'hcaptcha-test'; 73 | }); 74 | 75 | afterEach(() => { 76 | jest.resetAllMocks(); 77 | cleanupScripts(); 78 | }); 79 | 80 | it('should retry and load after fetch script error', async () => { 81 | mockFetchScript.mockRejectedValueOnce(SCRIPT_ERROR); 82 | mockFetchScript.mockResolvedValueOnce(SCRIPT_COMPLETE); 83 | 84 | const promise = hCaptchaLoader({ sentry: false, retryDelay: 0 }); 85 | 86 | await waitFor(() => { 87 | expect(mockFetchScript).toHaveBeenCalledTimes(2); 88 | 89 | // Trigger script onload callback to resolve promise 90 | window[HCAPTCHA_LOAD_FN_NAME](); 91 | expect(promise).resolves.toEqual((window as any).hcaptcha); 92 | }); 93 | }); 94 | 95 | it('should try loading 2 times and succeed on final try', async () => { 96 | mockFetchScript.mockRejectedValueOnce(SCRIPT_ERROR); 97 | mockFetchScript.mockRejectedValueOnce(SCRIPT_ERROR); 98 | mockFetchScript.mockResolvedValueOnce(SCRIPT_COMPLETE); 99 | 100 | const promise = hCaptchaLoader({ sentry: false, retryDelay: 0 }); 101 | 102 | await waitFor(() => { 103 | expect(mockFetchScript).toHaveBeenCalledTimes(3); 104 | 105 | // Trigger script onload callback to resolve promise 106 | window[HCAPTCHA_LOAD_FN_NAME](); 107 | expect(promise).resolves.toEqual((window as any).hcaptcha); 108 | }); 109 | }); 110 | 111 | it('should try loading 3 times and throw', async () => { 112 | mockFetchScript.mockRejectedValue('test error'); 113 | 114 | try { 115 | await hCaptchaLoader({ sentry: false, cleanup: true, retryDelay: 0 }); 116 | } catch (error) { 117 | expect(mockFetchScript).toBeCalledTimes(3); 118 | expect(error.message).toBe(SCRIPT_ERROR); 119 | } 120 | }); 121 | 122 | it('should wait retryDelay milliseconds between retry attempts', (done) => { 123 | mockFetchScript.mockRejectedValue('test error'); 124 | 125 | const retryDelay = 100; 126 | const startTime = Date.now(); 127 | 128 | hCaptchaLoader({ sentry: false, retryDelay }).catch(() => { 129 | const elapsed = Date.now() - startTime; 130 | 131 | // With 2 retries and 100ms delay each, then it should take at least 200ms 132 | expect(elapsed).toBeGreaterThanOrEqual(200); 133 | expect(mockFetchScript).toBeCalledTimes(3); 134 | 135 | done(); 136 | }); 137 | }); 138 | 139 | it('should respect custom maxRetries value', (done) => { 140 | mockFetchScript.mockRejectedValue('test error'); 141 | 142 | hCaptchaLoader({ sentry: false, maxRetries: 5, retryDelay: 0 }).catch((error: Error) => { 143 | // 1 initial + 5 retries = 6 total calls 144 | expect(mockFetchScript).toBeCalledTimes(6); 145 | expect(error.message).toBe(SCRIPT_ERROR); 146 | 147 | done(); 148 | }); 149 | }); 150 | }); 151 | 152 | describe('script error', () => { 153 | 154 | afterEach(() => { 155 | cleanupScripts(); 156 | jest.resetAllMocks(); 157 | }); 158 | 159 | it('should reject with script-error when error while loading occurs', async () => { 160 | mockFetchScript.mockRejectedValue(SCRIPT_ERROR); 161 | 162 | try { 163 | await hCaptchaLoader({ sentry: false, retryDelay: 0 }); 164 | } catch(error) { 165 | expect(error.message).toEqual(SCRIPT_ERROR); 166 | } 167 | }); 168 | 169 | }); 170 | 171 | }); 172 | -------------------------------------------------------------------------------- /lib/__test__/script.test.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, afterAll, beforeAll, describe, it, jest, expect } from '@jest/globals'; 2 | import { spyOnScriptMethod } from './__mocks__'; 3 | 4 | import { fetchScript } from '../src/script'; 5 | import { HCAPTCHA_LOAD_FN_NAME, SCRIPT_ERROR, SCRIPT_ID } from '../src/constants'; 6 | import { generateQuery } from '../src/utils'; 7 | 8 | 9 | type Node = HTMLScriptElement; 10 | 11 | function removeNode(node: Node, nodes: Node[]) { 12 | let i = nodes.length; 13 | let found = false; 14 | while (--i > -1 && !found) { 15 | found = nodes[i] === node; 16 | if (found) { 17 | nodes.splice(i, 1); 18 | } 19 | } 20 | return node; 21 | } 22 | 23 | function cleanupNodes(nodes: Node[]) { 24 | let i = nodes.length; 25 | while (--i > -1) { 26 | nodes.splice(i, 1); 27 | } 28 | } 29 | 30 | describe('fetchScript', () => { 31 | const nodes: Node[] = []; 32 | 33 | const spyOnAppend = jest.spyOn(document.head, 'appendChild') 34 | .mockImplementation((node: Node): Node => { 35 | nodes.push(node); 36 | return node; 37 | }); 38 | 39 | const spyOnRemove = jest.spyOn(document.head, 'removeChild') 40 | .mockImplementation(node => removeNode(node as any, nodes)); 41 | 42 | 43 | const spyOnLoad = spyOnScriptMethod('onload'); 44 | const spyOnError = spyOnScriptMethod('onerror'); 45 | 46 | describe('load', () => { 47 | 48 | afterEach(() => { 49 | cleanupNodes(nodes); 50 | }); 51 | 52 | it('should resolve when onload is called', async () => { 53 | const eventOnLoad = new Event('Loaded Script'); 54 | 55 | spyOnLoad.set.mockImplementationOnce((callback: (any) => void) => { 56 | callback(eventOnLoad); 57 | }); 58 | 59 | await expect(fetchScript()).resolves.toBe(eventOnLoad); 60 | 61 | expect(spyOnAppend).toHaveBeenCalled(); 62 | expect(spyOnRemove).not.toHaveBeenCalled(); 63 | }); 64 | 65 | it('should reject when onerror is called', async () => { 66 | spyOnError.set.mockImplementationOnce((callback: (any) => any) => { 67 | callback('Invalid Script'); 68 | }); 69 | 70 | await expect(fetchScript()).rejects.toBe('Invalid Script'); 71 | 72 | expect(spyOnAppend).toHaveBeenCalled(); 73 | expect(spyOnRemove).not.toHaveBeenCalled(); 74 | }); 75 | 76 | it('should reject when cleanup script encounters internal error', async () => { 77 | const errorInternal = new Error(SCRIPT_ERROR); 78 | 79 | spyOnLoad.set.mockImplementationOnce((callback: (any) => void) => { 80 | callback(new Event('Loaded Script')); 81 | }); 82 | 83 | spyOnRemove.mockImplementationOnce(() => { throw errorInternal; }); 84 | 85 | await expect(fetchScript({ cleanup: true })).rejects.toThrow(errorInternal.message); 86 | }); 87 | 88 | }); 89 | 90 | describe('setup', () => { 91 | beforeAll(() => { 92 | const eventOnLoad = new Event('Loaded Script'); 93 | 94 | spyOnLoad.set.mockImplementation((callback: (any) => void) => { 95 | callback(eventOnLoad); 96 | }); 97 | }); 98 | 99 | afterEach(() => { 100 | cleanupNodes(nodes); 101 | }); 102 | 103 | afterAll(() => { 104 | jest.resetAllMocks(); 105 | }); 106 | 107 | it('should set default parameters', async () => { 108 | await fetchScript(); 109 | 110 | const [script] = nodes; 111 | expect(script.src).toMatch('https://js.hcaptcha.com/1/api.js'); 112 | expect(script.async).toBeTruthy(); 113 | expect(script.crossOrigin).toEqual('anonymous'); 114 | expect(script.id).toEqual(SCRIPT_ID); 115 | }); 116 | 117 | it('should set query parameters', async () => { 118 | const query = generateQuery({ custom: true, render: 'explicit', sentry: false }); 119 | await fetchScript({ query }); 120 | 121 | const [script] = nodes; 122 | expect(script.src).toMatch(`https://js.hcaptcha.com/1/api.js?onload=${HCAPTCHA_LOAD_FN_NAME}&${query}`); 123 | }); 124 | 125 | it('should not set async when loadAsync is passed in as false', async () => { 126 | await fetchScript({ loadAsync:false }); 127 | 128 | const [script] = nodes; 129 | expect(script.async).toBeFalsy(); 130 | }); 131 | 132 | 133 | it('should set crossOrigin when it is passed in', async () => { 134 | await fetchScript({ crossOrigin: 'anonymous' }); 135 | 136 | const [script] = nodes; 137 | expect(script.crossOrigin).toEqual('anonymous'); 138 | }); 139 | 140 | it('should change hCaptcha JS domain if apihost is specified', async () => { 141 | const apihost = 'https://example.com'; 142 | await fetchScript({ apihost }); 143 | 144 | const [script] = nodes; 145 | expect(script.src).toMatch(`${apihost}/1/api.js?onload=${HCAPTCHA_LOAD_FN_NAME}`); 146 | }); 147 | 148 | it('should change hCaptcha JS API if secureApi is specified', async () => { 149 | const secureApi = true; 150 | await fetchScript({ secureApi }); 151 | 152 | const [script] = nodes; 153 | expect(script.src).toMatch(`https://js.hcaptcha.com/1/secure-api.js?onload=${HCAPTCHA_LOAD_FN_NAME}`); 154 | }); 155 | 156 | it('should change hCaptcha JS API if scriptSource is specified', async () => { 157 | const scriptSource = 'hcaptcha.com/1/api.js'; 158 | await fetchScript({ scriptSource }); 159 | 160 | const [script] = nodes; 161 | expect(script.src).toMatch(`${scriptSource}?onload=${HCAPTCHA_LOAD_FN_NAME}`); 162 | }); 163 | }); 164 | 165 | describe('cleanup', () => { 166 | 167 | beforeAll(() => { 168 | const eventOnLoad = new Event('Loaded Script'); 169 | 170 | spyOnLoad.set.mockImplementation((callback: (any) => void) => { 171 | callback(eventOnLoad); 172 | }); 173 | }); 174 | 175 | afterEach(() => { 176 | cleanupNodes(nodes); 177 | }); 178 | 179 | afterAll(() => { 180 | jest.resetAllMocks(); 181 | }); 182 | 183 | it('should not remove script node by default', async () => { 184 | await fetchScript(); 185 | const element = document.getElementById(SCRIPT_ID); 186 | expect(element).toBeDefined(); 187 | }); 188 | 189 | it('should remove script node if clean is set to true', async () => { 190 | await fetchScript({ cleanup: true }); 191 | const element = document.getElementById(SCRIPT_ID); 192 | expect(element).toBeNull(); 193 | }); 194 | 195 | it('should not remove script node if clean is set to false', async () => { 196 | await fetchScript({ cleanup: false }); 197 | const element = document.getElementById(SCRIPT_ID); 198 | expect(element).toBeDefined(); 199 | }); 200 | }); 201 | }); 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hCaptcha Loader 2 | 3 | This is a JavaScript library to easily configure the loading of the [hCaptcha](https://www.hcaptcha.com) JS client SDK with built-in error handling. It also includes a retry mechanism that will attempt to load the hCaptcha script several times in the event it fails to load due to a network or unforeseen issue. 4 | 5 | > [hCaptcha](https://www.hcaptcha.com) is a drop-replacement for reCAPTCHA that protects user privacy. 6 | > 7 | > Sign up at [hCaptcha](https://www.hcaptcha.com) to get your sitekey today. **You need a sitekey to use this library.** 8 | 9 | - [hCaptcha Loader](#hcaptcha-loader) 10 | - [Installation](#installation) 11 | - [Implementation](#implementation) 12 | - [Props](#props) 13 | - [Legacy Support](#legacy-support) 14 | - [Import Bundle(s)](#import-bundles) 15 | - [TypeScript](#typescript) 16 | - [CDN](#cdn) 17 | - [CSP](#csp) 18 | - [Sentry](#sentry) 19 | 20 | ## Installation 21 | ``` 22 | npm install @hcaptcha/loader 23 | ``` 24 | 25 | Or use UNPKG to load via CDN, [as described below](#CDN). 26 | 27 | ## Implementation 28 | 29 | ```js 30 | import { hCaptchaLoader } from '@hcaptcha/loader'; 31 | 32 | await hCaptchaLoader(); 33 | 34 | hcaptcha.render({ 35 | sitekey: '' 36 | }); 37 | 38 | const { response } = await hcaptcha.execute({ async: true }); 39 | ``` 40 | 41 | ## Props 42 | | Name | Values/Type | Required | Default | Description | 43 | |-------------------|-------------|----------|------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------| 44 | | `loadAsync` | Boolean | No | `true` | Set if the script should be loaded asynchronously. | 45 | | `cleanup` | Boolean | No | `false` | Remove script tag after setup. | 46 | | `crossOrigin` | String | No | `anonymous` | Set script cross origin attribute such as "anonymous". | 47 | | `scriptSource` | String | No | `https://js.hcaptcha.com/1/api.js` | Set script source URI. Takes precedence over `secureApi`. | 48 | | `scriptLocation` | HTMLElement | No | `document.head` | Location of where to append the script tag. Make sure to add it to an area that will persist to prevent loading multiple times in the same document view. | 49 | | `secureApi` | Boolean | No | `false` | See enterprise docs. | 50 | | `apihost` | String | No | `-` | See enterprise docs. | 51 | | `assethost` | String | No | `-` | See enterprise docs. | 52 | | `endpoint` | String | No | `-` | See enterprise docs. | 53 | | `hl` | String | No | `-` | See enterprise docs. | 54 | | `host` | String | No | `-` | See enterprise docs. | 55 | | `imghost` | String | No | `-` | See enterprise docs. | 56 | | `recaptchacompat` | String | No | `-` | See enterprise docs. | 57 | | `reportapi` | String | No | `-` | See enterprise docs. | 58 | | `sentry` | Boolean | No | `-` | See enterprise docs. | 59 | | `uj` | Boolean | No | `-` | See enterprise docs. | 60 | | `custom` | Boolean | No | `-` | See enterprise docs. | 61 | 62 | 63 | 64 | ## Legacy Support 65 | In order to support older browsers, a separate bundle is generated in which all ES6 code is compiled down to ES5 along with an optional polyfill bundle. 66 | 67 | - `polyfills.js`: Provides polyfills for features not supported in older browsers. 68 | - `index.es5.js`: **@hcaptcha/loader** package compiled for ES5 environments. 69 | 70 | ### Import Bundle(s) 71 | Both bundles generated use IIFE format rather than a more modern import syntax such as `require` or `esm`. 72 | 73 | ```js 74 | // Optional polyfill import 75 | import '@hCaptcha/loader/dist/polyfills.js'; 76 | // ES5 version of hCaptcha Loader 77 | import '@hCaptcha/loader/dist/index.es5.js'; 78 | 79 | hCaptchaLoader().then(function(hcaptcha) { 80 | var element = document.createElement('div'); 81 | // hCaptcha API is ready 82 | hcaptcha.render(element, { 83 | sitekey: 'YOUR_SITE_KEY', 84 | // Additional options here 85 | }); 86 | }); 87 | 88 | ``` 89 | ### TypeScript 90 | To handle typescript with ES5 version, use the following statement. 91 | ```ts 92 | declare global { 93 | interface Window { 94 | hCaptchaLoader: any; 95 | } 96 | } 97 | ``` 98 | 99 | ### CDN 100 | The hCaptcha Loader targeted for older browsers can also be imported via CDN by using [UNPKG](https://www.unpkg.com/), see example below. 101 | 102 | 103 | ```html 104 | 105 | 106 | 107 | 108 | 109 | 110 |
111 | 120 | 121 | 122 | ``` 123 | 124 | ## CSP 125 | 126 | Note that you should use the `strict-dynamic` policy for this loader, as it needs to load the SDK via `appendChild()`. 127 | 128 | ## Sentry 129 | 130 | You can disable Sentry error tracking by setting the `sentry` flag to false, which will prevent client-side error reports from being sent to us. 131 | 132 | ```js 133 | hCaptchaLoader({ sentry: false }); 134 | ``` 135 | --------------------------------------------------------------------------------