UNPKG

cloudflare-turnstile-widget

Version:

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

279 lines (278 loc) 11.7 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`. */ iframe; /** * Unique identifier for widget with which to track widget specific events. */ identifier = 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 */ get siteKey() { return this.getAttribute('site-key'); } set siteKey(value) { 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 */ get theme() { return this.getAttribute('theme'); } set theme(value) { 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 */ get size() { return this.getAttribute('size'); } set size(value) { 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 */ get retry() { return this.getAttribute('retry'); } set retry(value) { 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 */ get refreshExpired() { return this.getAttribute('refresh-expired'); } set refreshExpired(value) { if (this.getAttribute('refresh-expired') !== value && value) { this.setAttribute('refresh-expired', value); } } get widgetFrameURL() { const explicitFrameURL = window['turnstile-widget-frame-module-url']; if (explicitFrameURL) { return explicitFrameURL; } return `https://www.unpkg.com/cloudflare-turnstile-widget@${VERSION}/dist/TurnstileWidgetFrame.js`; } componentMessageListener = ((event) => { const eventData = event.data; // 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); } }).bind(this); /** * Initializes the component. * * @hideconstructor */ constructor() { super(); this.attachShadow({ mode: 'open' }); } /** * Setup the component once added to the DOM. */ connectedCallback() { // 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() { // 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 async frameLoaded() { // 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); } frameMessageReceived(event) { // Create and dispatch custom event based on bridge event info const evt = { 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) => { this.messageFrame(event.data.callbackId, detail); } : undefined }, 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(eventName, detail, callback, timeout, timeoutCallback, callbackIdentifierPrefix = 'widget-callback') { const frameWindow = this.iframe.contentWindow; let callbackId = undefined; if (callback) { let resolved = null; // If callback function is specified, setup temporary event for callback response callbackId = `${callbackIdentifierPrefix}|${TurnstileWidgetFrame.uuidv4()}`; let callbackHandler = (e) => { if (resolved === null) { resolved = true; window.removeEventListener(callbackId, callbackHandler); // eslint-disable-next-line callback-return, @typescript-eslint/no-unsafe-argument callback(e.detail); } }; callbackHandler = callbackHandler.bind(this); if (timeout) { setTimeout(() => { if (resolved === null) { resolved = false; window.removeEventListener(callbackId, callbackHandler); if (timeoutCallback) { timeoutCallback(); } } }, timeout); } window.addEventListener(callbackId, callbackHandler); } const bridgeEvent = { 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(eventName, detail, timeout, callbackIdentifierPrefix = 'widget-callback') { return new Promise((resolve, reject) => { try { this.messageFrame(eventName, detail, resolve, timeout, timeout ? () => reject(new Error(`No response received for '${eventName}' in the given timeout: ${timeout}ms`)) : undefined, callbackIdentifierPrefix); } catch (error) { reject(error); } }); } } customElements.define('turnstile-widget', TurnstileWidget); //# sourceMappingURL=TurnstileWidget.js.map