UNPKG

cloudflare-turnstile-widget

Version:

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

344 lines (301 loc) 13.3 kB
import { TurnstileWidgetFrame } from './TurnstileWidgetFrame.js'; import { VERSION } from './version.js'; const SCRIPT_URL = 'https://challenges.cloudflare.com/turnstile/v0/api.js'; /** * * @element turnstile-widget * * @fires frame-load - Dispatched from the component after the frame initially loads. * @fires success - Dispatched when a success callback has been invoked. Event `.detail.content` property will contain the token. * @fires error - Dispatched when an error callback has been invoked. Event `.detail.content` property will contain the error code. * @fires expired - Dispatched when an expired callback has been invoked. * @fires unsupported - Dispatched when an unsupported callback has been invoked. * * @see https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/#configurations * */ export class TurnstileWidget extends HTMLElement { /** * Internal instance of `HTMLIFrameElement`. */ public iframe!: HTMLIFrameElement; /** * Unique identifier for widget with which to track widget specific events. */ public readonly identifier: string = TurnstileWidgetFrame.uuidv4(); /** * 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 * @attr site-key */ 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); } } /** * 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' * @attr */ 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); } } /** * 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' * @attr */ 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); } } /** * 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' * @attr */ 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); } } /** * 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' * @attr refresh-expired */ 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); } } private get widgetFrameURL(): string | undefined { const explicitFrameURL = window['turnstile-widget-frame-module-url']; if (explicitFrameURL) { return explicitFrameURL; } return `https://www.unpkg.com/cloudflare-turnstile-widget@${VERSION}/dist/TurnstileWidgetFrame.js`; } private componentMessageListener = ((event: MessageEvent): void => { const eventData = event.data as BridgeInfo<unknown>; // Check if received message is a widget message from this wrapper's hosted widget if (typeof event.data === `object` && eventData.bridgeEvent && eventData.identifier === this.identifier && eventData.fromApplication !== true) { this.frameMessageReceived(event as MessageEvent<BridgeInfo<unknown>>); } }).bind(this); /** * Initializes the component. * * @hideconstructor */ constructor() { super(); this.attachShadow({ mode: 'open' }); } /** * Setup the component once added to the DOM. */ connectedCallback(): void { // Listen for messages on the application window. Used for communication with the child widget. window.addEventListener('message', this.componentMessageListener); this.iframe = document.createElement('iframe'); const style = document.createElement('style'); const height = this.size === 'compact' ? '120px;' : '65px;'; const width = this.size === 'compact' ? '130px;' : '300px;'; style.textContent = ` .body { border: none; height: ${height} width: ${width} margin: 0; overflow: hidden; } :host { position: relative; } `; this.shadowRoot?.appendChild(style); this.iframe.classList.add('body'); this.iframe.srcdoc = ` <head> <script src="${SCRIPT_URL}?render=explicit"></script> <script type="module" src="${this.widgetFrameURL}"></script> </head> <body style="border: none; height: ${height} width: ${width} margin: 0; overflow: hidden;"> <turnstile-widget-frame site-key="${this.siteKey}" 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'}"> </turnstile-widget-frame> </body> `; // eslint-disable-next-line @typescript-eslint/no-misused-promises this.iframe.addEventListener('load', () => this.frameLoaded()); this.shadowRoot?.appendChild(this.iframe); } /** * Clean up the component once removed from the DOM. */ disconnectedCallback(): void { // Cleanup listeners window.removeEventListener(`message`, this.componentMessageListener); const children = Array.from([...(this.shadowRoot?.children ?? [])]); children.forEach((c) => c.remove()); } /** * Dispatch a custom event to say the iframe is loaded, the TurnstileWidgetFrame component listens to this event. */ // eslint-disable-next-line @typescript-eslint/require-await private async frameLoaded(): Promise<void> { // Raise frame load event this.dispatchEvent( new CustomEvent('frame-load', { bubbles: true, composed: true, cancelable: false, detail: this.identifier }) ); this.messageFrame('frame-load', this.identifier); } private frameMessageReceived<T>(event: MessageEvent<BridgeInfo<T>>): void { // Create and dispatch custom event based on bridge event info const evt: CustomEventInit<WidgetEventDetail<T>> = { detail: { content: event.data.detail, // Check whether a temporary callback event has been provided, and if so, setup function to return the callback response. callback: event.data.callbackId ? (detail: unknown): void => { this.messageFrame(event.data.callbackId as string, detail); } : undefined } as WidgetEventDetail<T>, bubbles: true, composed: true, cancelable: true }; this.dispatchEvent( new CustomEvent('bridge-message', { bubbles: true, cancelable: true, composed: true, detail: event.data }) ); this.dispatchEvent(new CustomEvent(event.data.bridgeEvent, evt)); } /** * Send a bridged message to the hosted widget frame. * @param eventName The event this message correlates to. * @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 messageFrame<T = any, U = any>(eventName: string, detail?: T, callback?: (detail: U) => void, timeout?: number, timeoutCallback?: () => void, callbackIdentifierPrefix = 'widget-callback'): void { const frameWindow = this.iframe.contentWindow; let callbackId: string | undefined = undefined; if (callback) { let resolved: boolean | null = null; // If callback function is specified, setup temporary event for callback response callbackId = `${callbackIdentifierPrefix}|${TurnstileWidgetFrame.uuidv4()}`; let callbackHandler = (e: Event): void => { if (resolved === null) { resolved = true; window.removeEventListener(callbackId as string, callbackHandler); // eslint-disable-next-line callback-return, @typescript-eslint/no-unsafe-argument callback((e as CustomEvent).detail); } }; callbackHandler = callbackHandler.bind(this); if (timeout) { setTimeout(() => { if (resolved === null) { resolved = false; window.removeEventListener(callbackId as string, callbackHandler); if (timeoutCallback) { timeoutCallback(); } } }, timeout); } window.addEventListener(callbackId, callbackHandler); } const bridgeEvent: BridgeInfo<T> = { bridgeEvent: eventName, detail: detail, identifier: this.identifier, fromApplication: true, callbackId: callbackId }; frameWindow?.postMessage(bridgeEvent, `*`); } /** * Asynchronously sends a bridged message to the hosted widget frame, then awaits and returns its response. * @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 messageFrameAsync<T = any, U = any>(eventName: string, detail?: T, timeout?: number, callbackIdentifierPrefix = 'widget-callback'): Promise<U> { return new Promise<U>((resolve, reject) => { try { this.messageFrame(eventName, detail, resolve, timeout, timeout ? (): void => reject(new Error(`No response received for '${eventName}' in the given timeout: ${timeout}ms`)) : undefined, callbackIdentifierPrefix); } catch (error) { reject(error); } }); } } // eslint-disable-next-line @typescript-eslint/no-explicit-any export type BridgeInfo<T = any> = { callbackId?: string; detail?: T; bridgeEvent: string; identifier: string; fromApplication?: boolean; }; // eslint-disable-next-line @typescript-eslint/no-explicit-any export type WidgetEventDetail<T = any, U = any> = { content: T; callback?: (detail: U) => void; }; customElements.define('turnstile-widget', TurnstileWidget); declare global { interface HTMLElementTagNameMap { 'turnstile-widget': TurnstileWidget; } interface Window { 'turnstile-widget-frame-module-url': string | undefined; } }