Files

118 lines
3.2 KiB
TypeScript

"use client";
const TURNSTILE_SCRIPT_SRC = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
const API_READY_TIMEOUT_MS = 15_000;
const API_POLL_INTERVAL_MS = 50;
export type TurnstileWidgetId = string;
export type TurnstileApi = {
render: (
container: HTMLElement,
options: {
sitekey: string;
action: string;
callback: (token: string) => void;
"expired-callback": () => void;
"error-callback": () => void;
},
) => TurnstileWidgetId;
reset: (widgetId: TurnstileWidgetId) => void;
};
declare global {
interface Window {
turnstile?: TurnstileApi;
}
}
let turnstilePromise: Promise<TurnstileApi> | null = null;
function getReadyApi(): TurnstileApi | null {
if (typeof window === "undefined") return null;
const api = window.turnstile;
if (!api || typeof api.render !== "function" || typeof api.reset !== "function") return null;
return api;
}
function findTurnstileScript(): HTMLScriptElement | null {
return (
Array.from(document.scripts).find((script) => {
try {
return new URL(script.src, document.baseURI).href === TURNSTILE_SCRIPT_SRC;
} catch {
return false;
}
}) ?? null
);
}
function waitForReadyApi(script: HTMLScriptElement): Promise<TurnstileApi> {
return new Promise((resolve, reject) => {
let settled = false;
const cleanup = () => {
if (intervalId !== undefined) window.clearInterval(intervalId);
if (timeoutId !== undefined) window.clearTimeout(timeoutId);
script.removeEventListener("error", onError);
};
const finish = (error?: Error) => {
if (settled) return;
settled = true;
cleanup();
if (error) reject(error);
else resolve(getReadyApi() as TurnstileApi);
};
const check = () => {
const api = getReadyApi();
if (api) finish();
};
const onError = () => {
finish(new Error("Cloudflare Turnstile script failed to load"));
};
const intervalId = window.setInterval(check, API_POLL_INTERVAL_MS);
const timeoutId = window.setTimeout(() => {
finish(new Error("Cloudflare Turnstile API did not become ready in time"));
}, API_READY_TIMEOUT_MS);
script.addEventListener("error", onError, { once: true });
check();
});
}
/**
* Loads Turnstile once and resolves only after window.turnstile.render/reset exist.
* This also adopts a script already inserted by Next.js or another component.
*/
export function loadTurnstile(): Promise<TurnstileApi> {
if (typeof window === "undefined" || typeof document === "undefined") {
return Promise.reject(new Error("Cloudflare Turnstile requires a browser"));
}
const readyApi = getReadyApi();
if (readyApi) return Promise.resolve(readyApi);
if (turnstilePromise) return turnstilePromise;
const script = findTurnstileScript() ?? document.createElement("script");
if (!script.src) {
script.src = TURNSTILE_SCRIPT_SRC;
script.async = true;
script.defer = true;
script.dataset.turnstileLoader = "true";
document.head.appendChild(script);
}
turnstilePromise = waitForReadyApi(script).catch((error: unknown) => {
turnstilePromise = null;
throw error;
});
return turnstilePromise;
}