diff --git a/README.md b/README.md index 3771f9c..b923c22 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # hCaptcha Loader -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. +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 if fails to load due to a network or unforeseen issue. > [hCaptcha](https://www.hcaptcha.com) is a drop-replacement for reCAPTCHA that protects user privacy. > diff --git a/lib/__test__/loader.test.ts b/lib/__test__/loader.test.ts index 7558924..69b9a59 100644 --- a/lib/__test__/loader.test.ts +++ b/lib/__test__/loader.test.ts @@ -3,7 +3,7 @@ import { waitFor } from '@testing-library/dom'; import { fetchScript } from "../src/script"; import { hCaptchaLoader, hCaptchaScripts } from "../src/loader"; -import { HCAPTCHA_LOAD_FN_NAME, SCRIPT_COMPLETE, SCRIPT_ERROR} from "../src/constants"; +import { HCAPTCHA_LOAD_FN_NAME, SCRIPT_COMPLETE, SCRIPT_ERROR } from "../src/constants"; jest.mock('../src/script'); @@ -38,7 +38,7 @@ describe('hCaptchaLoader', () => { const promise = hCaptchaLoader({ sentry: false }); await waitFor(() => { - expect(mockFetchScript).toHaveBeenCalled(); + expect(mockFetchScript).toHaveBeenCalledTimes(1); // Trigger script onload callback to resolve promise window[HCAPTCHA_LOAD_FN_NAME](); @@ -47,26 +47,80 @@ describe('hCaptchaLoader', () => { }); it('should not fetch script since it was already loaded', async () => { - const result = await hCaptchaLoader({ sentry: false }); - expect(result).toEqual(window.hcaptcha); - expect(mockFetchScript).not.toHaveBeenCalled(); + const result = await hCaptchaLoader({ sentry: false }); + expect(result).toEqual(window.hcaptcha); + expect(mockFetchScript).not.toHaveBeenCalled(); }); - }); + describe('script retry', () => { + + beforeAll(() => { + window.hcaptcha = 'hcaptcha-test'; + }) + + afterEach(() => { + jest.resetAllMocks(); + cleanupScripts(); + }); + + it('should retry and load after fetch script error', async () => { + mockFetchScript.mockRejectedValueOnce(SCRIPT_ERROR); + mockFetchScript.mockResolvedValueOnce(SCRIPT_COMPLETE); + + const promise = hCaptchaLoader({ sentry: false }); + + await waitFor(() => { + expect(mockFetchScript).toHaveBeenCalledTimes(2); + + // Trigger script onload callback to resolve promise + window[HCAPTCHA_LOAD_FN_NAME](); + expect(promise).resolves.toEqual(window.hcaptcha); + }); + }); + + it('should try loading 2 times and succeed on final try', async () => { + mockFetchScript.mockRejectedValueOnce(SCRIPT_ERROR); + mockFetchScript.mockRejectedValueOnce(SCRIPT_ERROR); + mockFetchScript.mockResolvedValueOnce(SCRIPT_COMPLETE); + + const promise = hCaptchaLoader({ sentry: false }); + + await waitFor(() => { + expect(mockFetchScript).toHaveBeenCalledTimes(3); + + // Trigger script onload callback to resolve promise + window[HCAPTCHA_LOAD_FN_NAME](); + expect(promise).resolves.toEqual(window.hcaptcha); + }); + }); + + it('should try loading 3 times and throw', async () => { + mockFetchScript.mockRejectedValue('test error'); + + try { + await hCaptchaLoader({ sentry: false, cleanup: true }); + } catch (error) { + expect(mockFetchScript).toBeCalledTimes(3); + expect(error.message).toBe(SCRIPT_ERROR); + } + }); + }) + describe('script error', () => { afterEach(() => { cleanupScripts(); + jest.resetAllMocks(); }) it('should reject with script-error when error while loading occurs', async () => { - mockFetchScript.mockRejectedValueOnce(SCRIPT_ERROR); + mockFetchScript.mockRejectedValue(SCRIPT_ERROR); try { await hCaptchaLoader({ sentry: false }); } catch(error) { - expect(error.message).toEqual(SCRIPT_ERROR) + expect(error.message).toEqual(SCRIPT_ERROR); } }); diff --git a/lib/src/constants.ts b/lib/src/constants.ts index b31f27f..0ff1712 100644 --- a/lib/src/constants.ts +++ b/lib/src/constants.ts @@ -1,4 +1,8 @@ export const SCRIPT_ID = 'hCaptcha-script'; export const HCAPTCHA_LOAD_FN_NAME = 'hCaptchaOnLoad'; export const SCRIPT_ERROR = 'script-error'; -export const SCRIPT_COMPLETE = 'script-loaded'; \ No newline at end of file +export const SCRIPT_COMPLETE = 'script-loaded'; + +export const SENTRY_TAG = '@hCaptcha/loader'; + +export const MAX_RETRIES = 2; diff --git a/lib/src/loader.ts b/lib/src/loader.ts index a036ae2..832325f 100644 --- a/lib/src/loader.ts +++ b/lib/src/loader.ts @@ -1,21 +1,20 @@ import { generateQuery, getFrame, getMountElement } from './utils'; -import { HCAPTCHA_LOAD_FN_NAME, SCRIPT_ERROR } from './constants'; +import { HCAPTCHA_LOAD_FN_NAME, MAX_RETRIES, SCRIPT_ERROR, SENTRY_TAG } from './constants'; import { initSentry } from './sentry'; import { fetchScript } from './script'; -import type { ILoaderParams } from './types'; +import type { ILoaderParams, SentryHub } from './types'; // Prevent loading API script multiple times export const hCaptchaScripts = []; // Generate hCaptcha API script -export function hCaptchaLoader(params: ILoaderParams = { cleanup: true }): Promise { - const sentry = initSentry(params.sentry); +export function hCaptchaApi(params: ILoaderParams = { cleanup: true }, sentry: SentryHub): Promise { try { sentry.addBreadcrumb({ - category: 'script', + category: SENTRY_TAG, message: 'hCaptcha loader params', data: params, }); @@ -26,7 +25,7 @@ export function hCaptchaLoader(params: ILoaderParams = { cleanup: true }): Promi if (script) { sentry.addBreadcrumb({ - category: 'script', + category: SENTRY_TAG, message: 'hCaptcha already loaded', }); @@ -42,7 +41,7 @@ export function hCaptchaLoader(params: ILoaderParams = { cleanup: true }): Promi // Create global onload callback for the hCaptcha library to call frame.window[HCAPTCHA_LOAD_FN_NAME] = () => { sentry.addBreadcrumb({ - category: 'hCaptcha:script', + category: SENTRY_TAG, message: 'hCaptcha script called onload function', }); @@ -66,12 +65,15 @@ export function hCaptchaLoader(params: ILoaderParams = { cleanup: true }): Promi await fetchScript({ query, ...params }); sentry.addBreadcrumb({ - category: 'hCaptcha:script', + category: SENTRY_TAG, message: 'hCaptcha loaded', + data: script }); + + hCaptchaScripts.push({ promise, scope: frame.window }); } catch(error) { sentry.addBreadcrumb({ - category: 'hCaptcha:script', + category: SENTRY_TAG, message: 'hCaptcha failed to load', data: error, }); @@ -82,11 +84,40 @@ export function hCaptchaLoader(params: ILoaderParams = { cleanup: true }): Promi } ); - hCaptchaScripts.push({ promise, scope: frame.window }); - return promise; } catch (error) { sentry.captureException(error); return Promise.reject(new Error(SCRIPT_ERROR)); } } + +export async function loadScript(params, retries = 0) { + const message = retries < MAX_RETRIES ? 'Retry loading hCaptcha Api' : 'Exceeded maximum retries'; + + const sentry = initSentry(params.sentry); + + try { + + return await hCaptchaApi(params, sentry); + } catch (error) { + + sentry.addBreadcrumb({ + SENTRY_SOURCE: SENTRY_TAG, + message, + data: { error } + }); + + if (retries >= MAX_RETRIES) { + sentry.captureException(error); + return Promise.reject(error); + } else { + retries += 1; + return loadScript(params, retries); + } + } +} + + +export function hCaptchaLoader(params) { + return loadScript(params); +} diff --git a/package.json b/package.json index 27c7b65..69cfed3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@hcaptcha/loader", "description": "This is a JavaScript library to easily configure the loading of the hCaptcha JS client SDK with built-in error handling.", - "version": "1.0.10", + "version": "1.1.0", "author": "hCaptcha team and contributors", "license": "MIT", "keywords": [