UNPKG

cloudflare-turnstile-widget

Version:

Framework agnostic widget for Cloudflare's free-to-use CAPTCHA service, Cloudflare Turnstile.

375 lines (326 loc) 16.2 kB
/* eslint-disable @typescript-eslint/no-explicit-any */ import type { BridgeInfo, WidgetEventDetail } from './TurnstileWidget.js'; export class TurnstileWidgetFrame extends HTMLElement { private static _currentIdentifier?: string; public static get currentIdentifier(): string | undefined { return TurnstileWidgetFrame._currentIdentifier; } private static set currentIdentifier(identifier: string | undefined) { TurnstileWidgetFrame._currentIdentifier = identifier; } public get siteKey(): string | null { return this.getAttribute('site-key'); } public set siteKey(value: string | null) { if (this.getAttribute('site-key') !== value && value) { this.setAttribute('site-key', value); } } public get theme(): TurnstileTheme | null { return this.getAttribute('theme') as TurnstileTheme; } public set theme(value: TurnstileTheme | null) { if (this.getAttribute('theme') !== value && value) { this.setAttribute('theme', value); } } public get size(): TurnstileSize | null { return this.getAttribute('size') as TurnstileSize; } public set size(value: TurnstileSize | null) { if (this.getAttribute('size') !== value && value) { this.setAttribute('size', value); } } public get retry(): TurnstileRetry | null { return this.getAttribute('retry') as TurnstileRetry; } public set retry(value: TurnstileRetry | null) { if (this.getAttribute('retry') !== value && value) { this.setAttribute('retry', value); } } public get refreshExpired(): RefreshExpired | null { return this.getAttribute('refresh-expired') as RefreshExpired; } public set refreshExpired(value: RefreshExpired | null) { if (this.getAttribute('refresh-expired') !== value && value) { this.setAttribute('refresh-expired', value); } } /** * Initializes the component. * * @hideconstructor */ constructor() { super(); } /** * Setup the component once added to the DOM. */ connectedCallback(): void { const div = document.createElement('div'); div.style.height = '100%'; this.appendChild(div); const widgetLoad = (identifier: string): void => { div.id = identifier; turnstile.ready(() => { turnstile.render(div, { sitekey: this.siteKey!, callback: (token: string) => { TurnstileWidgetFrame.messageApplication(TurnstileWidgetFrame.currentIdentifier!, 'success', token); }, 'error-callback': (errorCode: unknown) => { TurnstileWidgetFrame.messageApplication(TurnstileWidgetFrame.currentIdentifier!, 'error', errorCode); }, 'expired-callback': () => { TurnstileWidgetFrame.messageApplication(TurnstileWidgetFrame.currentIdentifier!, 'expired'); }, 'unsupported-callback': () => { TurnstileWidgetFrame.messageApplication(TurnstileWidgetFrame.currentIdentifier!, 'unsupported'); }, theme: this.theme ? this.theme : 'auto', size: this.size ? this.size : 'normal', retry: this.retry ? this.retry : 'auto', 'refresh-expired': this.refreshExpired ? this.refreshExpired : 'auto' }); }); }; TurnstileWidgetFrame.initialize(widgetLoad); } /** * Send a bridged message to the hosting application frame as a widget. * * @param identifier Identifier to represent widget. * @param eventName Name of event to send to application. * @param detail Custom detail to attach as event detail. * @param callback (Optional) Callback function to invoke on widget response. * @param timeout (Optional) Duration to wait for callback. * @param timeoutCallback (Optional) Callback function to invoke if response timeout is exceeded * @param callbackIdentifierPrefix (Optional) Prefix to apply on generated widget callback id */ // eslint-disable-next-line @typescript-eslint/no-explicit-any public static messageApplication<T, U = any>(identifier: string, eventName: string, detail?: T, callback?: (detail: U) => void, timeout?: number, timeoutCallback?: () => void, callbackIdentifierPrefix = 'widget-callback'): void { if (window === window.parent) { throw new Error('No parent application to message!'); } // Build up bridge event with provided detail const bridgeEvent: BridgeInfo = { bridgeEvent: eventName, detail: detail, fromApplication: false, identifier: identifier, callbackId: undefined as string | undefined }; if (callback) { let resolved: boolean | null = null; // If callback function is specified, setup temporary event for callback response const callbackId = `${callbackIdentifierPrefix}|${TurnstileWidgetFrame.uuidv4()}`; bridgeEvent.callbackId = callbackId; const callbackHandler = (e: WidgetEventDetail): void => { if (resolved === null) { resolved = true; TurnstileWidgetFrame.removeEventListener(callbackListener); // eslint-disable-next-line callback-return, @typescript-eslint/no-unsafe-argument callback(e.content); } }; if (timeout) { setTimeout(() => { if (resolved === null) { resolved = false; TurnstileWidgetFrame.removeEventListener(callbackListener); if (timeoutCallback) { timeoutCallback(); } } }, timeout); } const callbackListener = TurnstileWidgetFrame.addEventListener(callbackId, callbackHandler); } window.parent.postMessage(JSON.parse(JSON.stringify(bridgeEvent)), `*`); } /** * Generate a unique identifier string */ static uuidv4(): string { if (crypto && crypto.randomUUID) { try { const uuid = crypto.randomUUID(); if (uuid) { return uuid; } } catch (error) { console.warn(error); } } // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/restrict-plus-operands, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, (c) => (c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16)); } /** * Asynchronously sends a bridged message to the hosting application frame as a widget, then awaits and returns its response. * @param identifier Identifier to represent widget. * @param eventName The event this message correlates to. * @param detail Custom detail to attach as event detail. * @param timeout (Optional) Duration to wait for before rejecting the promise. If not specified, will wait indefinitely. * @param callbackIdentifierPrefix (Optional) Prefix to apply on generated widget callback id */ // eslint-disable-next-line @typescript-eslint/no-explicit-any public static messageApplicationAsync<T, U = any>(identifier: string, eventName: string, detail: T, timeout = 3000, callbackIdentifierPrefix = 'widget-callback'): Promise<U> { return new Promise<U>((resolve, reject) => { try { TurnstileWidgetFrame.messageApplication(identifier, eventName, detail, resolve, timeout, () => reject(new Error(`No response received for '${eventName}' in the given timeout: ${timeout}ms`)), callbackIdentifierPrefix); } catch (error) { reject(error); } }); } /** * Set up a callback for the `frame-load` event from the widget component in a hosting application. * Can be used by a widget application to retrieve its identifier from the widget component. * @param frameLoaded The callback to invoke when the `frame-load` event occurs. */ public static initialize(frameLoaded: (identifier: string) => void): void { // Listen for messages on the widget window. Used for communication with the parent application const listener = TurnstileWidgetFrame.addEventListener(`frame-load`, (event: WidgetEventDetail<string>) => { TurnstileWidgetFrame.currentIdentifier = event.content; TurnstileWidgetFrame.removeEventListener(listener); frameLoaded(event.content); }); } /** * Set up a callback for the specified event from the widget component in a hosting application. * @param eventName The event name to listen for when receiving bridged messages. * @param listener The callback to invoke when the specified event occurs. * @returns Event listener instance that can be used to remove the listener via `Widget.removeEventListener` */ // eslint-disable-next-line @typescript-eslint/no-explicit-any public static addEventListener<T = any, U = any>(eventName: string, listener: (event: WidgetEventDetail<T, U>) => void): (event: MessageEvent<BridgeInfo<T>>) => void { // Listen for messages on the widget window. Used for communication with the parent application const messageListener = (event: MessageEvent<BridgeInfo<T>>): void => { // Check if received message is a parent application message if (typeof event.data === `object` && event.data.bridgeEvent && event.data.fromApplication === true && event.data.bridgeEvent === eventName) { TurnstileWidgetFrame.currentIdentifier = event.data.identifier; listener({ content: event.data.detail as T, callback: event.data.callbackId ? (detail: U): void => { TurnstileWidgetFrame.messageApplication(event.data.identifier, event.data.callbackId as string, detail); } : undefined }); } }; window.addEventListener(`message`, messageListener); return messageListener; } /** * Remove the listener from the widget component in a hosting application. * @param messageListener The listener instance created by `Widget.addEventListener` to remove */ // eslint-disable-next-line @typescript-eslint/no-explicit-any public static removeEventListener<T = any>(messageListener: (event: MessageEvent<BridgeInfo<T>>) => void): void { window.removeEventListener(`message`, messageListener); } } customElements.define('turnstile-widget-frame', TurnstileWidgetFrame); declare global { const turnstile: Turnstile; interface HTMLElementTagNameMap { 'turnstile-widget-frame': TurnstileWidgetFrame; } interface Window { turnstile?: Turnstile; } interface Turnstile { /** * The ready function will be invoked once the DOM is ready, at this point the turnstile can be rendered. * @param callback * @see https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/#explicitly-render-the-turnstile-widget */ ready(callback: () => void): void; /** * The render function takes an argument to a HTML widget. * If the invocation is successful, the function returns a widgetId (string). * If the invocation is unsuccessful, the function returns undefined. * @param container * @param params * @returns * @see https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/#explicitly-render-the-turnstile-widget */ render: (container?: string | HTMLElement, params?: RenderOptions) => string | undefined; } interface RenderOptions { /** * Every widget has a sitekey. This sitekey is associated with the corresponding widget configuration and is created upon the widget creation. * @see https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/#configurations */ sitekey: string; /** * A callback invoked upon success of the turnstile challenge. The callback is passed a token that can be validated. * @param token * @returns * @see https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/#configurations */ callback?: (token: string) => void; /** * A callback invoked when there is an error (e.g. network error or the challenge failed). Refer to {@link https://developers.cloudflare.com/turnstile/reference/client-side-errors client-side errors}. * @param errorCode * @returns * @see https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/#configurations */ 'error-callback'?: (errorCode: unknown) => void; /** * A callback invoked when the token expires and does not reset the widget. * @returns * @see https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/#configurations */ 'expired-callback'?: () => void; /** * A callback invoked when a given client/browser is not supported by Turnstile. * @returns * @see https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/#configurations */ 'unsupported-callback'?: () => void; /** * The widget theme. Can take the following values: light, dark, auto. * The default is auto, which respects the user preference. This can be forced to light or dark by setting the theme accordingly. * @default 'auto' */ theme?: TurnstileTheme; /** * The widget size. Can take the following values: normal, compact. * Normal is 300px by 65px, and compact is 130px by 120px. * @see https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/#widget-size * @default 'normal' */ size?: TurnstileSize; /** * Controls whether the widget should automatically retry to obtain a token if it did not succeed. * The default is auto, which will retry automatically. This can be set to never to disable retry upon failure. * @see https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/#configurations * @default 'auto' */ retry?: TurnstileRetry; /** * When retry is set to auto, retry-interval controls the time between retry attempts in milliseconds. * Value must be a positive integer less than 900000, defaults to 8000. * @see https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/#configurations * @default 8000 */ 'retry-interval'?: number; /** * Automatically refreshes the token when it expires. Can take auto, manual or never, defaults to auto. * @see https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/#configurations * @default 'auto' */ 'refresh-expired'?: RefreshExpired; } type TurnstileTheme = 'auto' | 'light' | 'dark'; type TurnstileSize = 'normal' | 'compact'; type TurnstileRetry = 'auto' | 'never'; type RefreshExpired = 'auto' | 'manual' | 'never'; }