🧜

SvelteフロントエンドでCloudflare Turnstileを導入するメモ

に公開


サーバーサイド(TypeScript)でTurnstileを導入するのは公式がわかりやすく迷うことがなかったのですが、クライアントサイドからTurnstileのチャレンジを実行する方法がスッと理解できなかったため、簡易的なSvelteコンポーネントを作成しつつ理解したメモです。

Svelteコンポーネント

コード

TurnstileParametersTurnstileAPI型は公式文書から独自に作成したため、誤りがある場合があります。

Turnstile.svelte
<script module lang="ts">
  export interface TurnstileProps {
    sitekey: string;
    getToken?: (reset?: boolean) => string;
    options?: TurnstileParameters;
  };
  export type TurnstileParameters = {
    sitekey?: string;
    action?: string;
    cData?: string;
    execution?: "render" | "execute"; // "render"
    theme?: "auto" | "light" | "dark";
    language?: string; // "auto"
    tabindex?: number; // 0
    "response-field"?: boolean; // true
    "response-field-name"?: string; // "cf-turnstile-response"
    size?: "normal" | "flexible" | "compact";
    retry?: "auto" | "never"; // "auto"
    "retry-interval"?: number; // 8000ms
    "refresh-expired"?: "auto" | "manual" | "never"; // "auto"
    "refresh-timeout"?: "auto" | "manual" | "never"; // "auto"
    appearance?: "always" | "execute" | "interaction-only"; // "always"
    "feedback-enabled"?: boolean; // true
    callback?: (token: string) => void; // after success of the challenge
    "error-callback"?: () => void; // after fail or error of the challenge
    "expired-callback"?: () => void; // after token expires and does not reset
    "before-interactive-callback"?: () => void; // before the challenge enters interactive mode
    "after-interactive-callback"?: () => void; // challenge has left interactive mode
    "unsupported-callback"?: () => void; // when client/browser is not supported
    "timeout-callback"?: () => void; // when interactive challenge but was not solved within a given time
  };
  interface TurnstileAPI {
    ready: () => void;
    render: (container: string | HTMLElement, params: TurnstileParameters) => string | undefined; // widgetId
    execute: (container: string | HTMLElement, params: TurnstileParameters) => string | undefined;
    reset: (widgetId: string) => void;
    remove: (widgetId: string) => void;
    getResponse: (widgetId?: string) => string;
    isExpired: () => boolean;
  };

  const CLIENT_API = "https://challenges.cloudflare.com/turnstile/v0/api.js";
  let api: TurnstileAPI | undefined = $state(undefined);

  import { onDestroy, untrack } from "svelte";
</script>

<!---------------------------------------->

<script lang="ts">
  let { sitekey, getToken = $bindable(), options }: TurnstileProps = $props();
  let loaded = $state(false);
  let target = $state<HTMLDivElement>();
  let widget = "";
  getToken = (reset?: boolean) => {
    if (reset) api?.reset(widget);
    return api?.getResponse(widget) ?? "";
  }

  function onload() {
    loaded = true;
    if (!target) return;
    if (typeof window !== "undefined" && "turnstile" in window) api = window.turnstile as TurnstileAPI;
    widget = api?.render(target, { sitekey, ...options }) ?? "";
  }
  onDestroy(() => api?.remove(widget));
  $effect(() => untrack(() => {
    if (!target || !api) return;
    widget = api.render(target, { sitekey, ...options }) ?? "";
  }));
</script>

<!---------------------------------------->
<svelte:head>
  {#if !loaded && !api}
    <script src={CLIENT_API} {onload} defer></script>
  {/if}
</svelte:head>

<div bind:this={target}></div>

使い方

  1. sitekeybind:getTokenとしてコンポーネントを設置する
  2. 画面レンダリング後にgetToken()を実行し、サーバー送信用トークンを取得する

補足

  • 何も指定しない場合、トークンを保持するフォームフィールドが自動で文書に追加される
    • 不要であればTurnstileParametersresponse-fieldfalseを指定する
  • api.resetを呼び出さない限り、getResponseで取得するトークンは変更されない
    • 手動で新規トークンを取得する場合はapi.reset後に再取得する
    • 上記コンポーネントの場合はgetToken(true)を実行する
  • レンダリングされた後、以下のように要素が追加される
    <div> <!-- bind:this={target} -->
      <div> <!-- appended by cloudflare script -->
        <iframe>...</iframe>
      </div>
    </div>
    
  • TurnstileParametersの詳細は公式文書に記載がある
  • TurnstileAPIの詳細は公式文書に記載があるが、まとまっていない
    • 知りたいメソッド名で文書内検索をする必要がある

雑記

似たようなライブラリがあるのですが、Svelte5記法ではないので自分で作成しました。公式文書の中でRenderParametersという型を明示しているにも関わらず、型内容の具体的な記載が無いのは残念な気持ちになりました。

参考文献

Discussion