Merge pull request #1544 from yamadashy/perf/turnstile-premint

perf(website): Pre-mint Turnstile token on user intent
This commit is contained in:
Kazuki Yamada
2026-05-05 23:07:38 +09:00
committed by GitHub
9 changed files with 437 additions and 166 deletions
+3 -1
View File
@@ -45,6 +45,7 @@
:loading="loading"
@keydown="handleKeydown"
@submit="handleSubmit"
@user-input="markUserTouched"
:show-button="false"
/>
</div>
@@ -85,7 +86,7 @@
v-model:show-line-numbers="packOptions.showLineNumbers"
v-model:output-parsable="packOptions.outputParsable"
v-model:compress="packOptions.compress"
@user-input="markUserTouched"
/>
<div v-if="hasExecuted">
@@ -157,6 +158,7 @@ const {
resetOptions,
cancelRequest,
setTurnstileContainer,
markUserTouched,
} = usePackRequest();
// Wire the template ref into useTurnstile so the widget renders into the
@@ -27,55 +27,70 @@ const emit = defineEmits<{
'update:showLineNumbers': [value: boolean];
'update:outputParsable': [value: boolean];
'update:compress': [value: boolean];
// Emitted on every real user-driven option change so usePackRequest can
// gate the Turnstile pre-mint. Mirrors TryItUrlInput's `userInput` —
// emitted from DOM-level handlers only, never from URL-parameter
// hydration / form restoration paths.
userInput: [];
}>();
function handleFormatChange(newFormat: 'xml' | 'markdown' | 'plain') {
emit('update:format', newFormat);
emit('userInput');
handleOptionChange(newFormat, AnalyticsAction.FORMAT_CHANGE);
}
function handleIncludePatternsUpdate(patterns: string) {
emit('update:includePatterns', patterns);
emit('userInput');
handleOptionChange(patterns, AnalyticsAction.UPDATE_INCLUDE_PATTERNS);
}
function handleIgnorePatternsUpdate(patterns: string) {
emit('update:ignorePatterns', patterns);
emit('userInput');
handleOptionChange(patterns, AnalyticsAction.UPDATE_IGNORE_PATTERNS);
}
function handleFileSummaryToggle(enabled: boolean) {
emit('update:fileSummary', enabled);
emit('userInput');
handleOptionChange(enabled, AnalyticsAction.TOGGLE_FILE_SUMMARY);
}
function handleDirectoryStructureToggle(enabled: boolean) {
emit('update:directoryStructure', enabled);
emit('userInput');
handleOptionChange(enabled, AnalyticsAction.TOGGLE_DIRECTORY_STRUCTURE);
}
function handleRemoveCommentsToggle(enabled: boolean) {
emit('update:removeComments', enabled);
emit('userInput');
handleOptionChange(enabled, AnalyticsAction.TOGGLE_REMOVE_COMMENTS);
}
function handleRemoveEmptyLinesToggle(enabled: boolean) {
emit('update:removeEmptyLines', enabled);
emit('userInput');
handleOptionChange(enabled, AnalyticsAction.TOGGLE_REMOVE_EMPTY_LINES);
}
function handleShowLineNumbersToggle(enabled: boolean) {
emit('update:showLineNumbers', enabled);
emit('userInput');
handleOptionChange(enabled, AnalyticsAction.TOGGLE_LINE_NUMBERS);
}
function handleOutputParsableToggle(enabled: boolean) {
emit('update:outputParsable', enabled);
emit('userInput');
handleOptionChange(enabled, AnalyticsAction.TOGGLE_OUTPUT_PARSABLE);
}
function handleCompressToggle(enabled: boolean) {
emit('update:compress', enabled);
emit('userInput');
handleOptionChange(enabled, AnalyticsAction.TOGGLE_COMPRESS);
}
</script>
@@ -15,6 +15,11 @@ const emit = defineEmits<{
submit: [];
keydown: [event: KeyboardEvent];
cancel: [];
// Emitted on real DOM input — typing, paste, IME compose end, datalist
// selection. Used by usePackRequest to gate the Turnstile pre-mint so
// URL-parameter hydration / form restoration don't trigger background
// challenges.
userInput: [];
}>();
const isValidUrl = computed(() => {
@@ -68,6 +73,7 @@ function saveUrlToHistory(url: string) {
function handleUrlInput(event: Event) {
const input = event.target as HTMLInputElement;
emit('update:url', input.value);
emit('userInput');
}
// Process and save valid URL
@@ -0,0 +1,63 @@
// Helpers for translating Turnstile token-acquisition outcomes into the
// shape usePackRequest's `submitRequest` consumes. Splitting these out keeps
// usePackRequest under the 250-line file-size guideline and centralises the
// user-facing error copy.
import type { useTurnstile } from './useTurnstile';
export type TurnstileTokenResult =
// Token acquired (or dev/preview fallthrough where the server skips
// verification when TURNSTILE_SECRET_KEY is unset).
| { kind: 'token'; token: string | undefined }
// The pack-request controller was aborted while the Turnstile challenge
// was in flight. `reason` mirrors AbortSignal.reason so the caller can
// distinguish user cancel from the 30s timeout.
| { kind: 'aborted'; reason: AbortSignal['reason'] }
// Production verification failure — surface a user-visible error instead
// of calling /api/pack since the server-side middleware would 403 anyway.
| { kind: 'error'; message: string };
// Acquire a Turnstile token for the click path. The signal aborts an
// in-flight challenge when the surrounding pack request is cancelled.
export async function acquireTurnstileToken(
turnstile: ReturnType<typeof useTurnstile>,
signal: AbortSignal,
): Promise<TurnstileTokenResult> {
try {
return { kind: 'token', token: await turnstile.takeToken(signal) };
} catch (err) {
// Abort is a normal flow (user cancel, 30s timeout). Don't log it as
// a failure — only log genuine challenge / script-load errors.
if (signal.aborted) {
return { kind: 'aborted', reason: signal.reason };
}
console.warn('Turnstile token acquisition failed:', err);
if (import.meta.env.PROD) {
return { kind: 'error', message: turnstileFailureMessage(err) };
}
// Dev/preview: continue without a token. The server skips verification
// when TURNSTILE_SECRET_KEY is unset, so contributors without a
// Cloudflare account can still exercise the pack flow.
return { kind: 'token', token: undefined };
}
}
// Distinguish "Turnstile script blocked" (likely an extension) from generic
// verification failure so the user has a path to recovery instead of just
// being told "try again".
function turnstileFailureMessage(err: unknown): string {
const msg = err instanceof Error ? err.message : '';
const isScriptIssue = /script|load|missing/i.test(msg);
return isScriptIssue
? 'Bot protection failed to load. Please disable ad blockers or privacy extensions blocking challenges.cloudflare.com and reload, or use the CLI: npx repomix --remote owner/repo.'
: 'Verification failed. Please reload the page and try again.';
}
// Mirror handlePackRequest's onAbort messaging. Used when the Turnstile
// challenge is aborted before /api/pack is reached, so we short-circuit
// rather than calling handlePackRequest at all.
export function abortMessage(reason: AbortSignal['reason']): string {
return reason === 'timeout'
? 'Request timed out.\nPlease consider using Include Patterns or Ignore Patterns to reduce the scope.'
: 'Request was cancelled.';
}
+91 -53
View File
@@ -3,9 +3,18 @@ import type { FileInfo, PackProgressStage, PackResult } from '../components/api/
import { handlePackRequest } from '../components/utils/requestHandlers';
import { isValidRemoteValue } from '../components/utils/validation';
import { parseUrlParameters } from '../utils/urlParams';
import { abortMessage, acquireTurnstileToken } from './turnstileSubmit';
import { usePackOptions } from './usePackOptions';
import { usePreMintDebounce } from './usePreMintDebounce';
import { useTurnstile } from './useTurnstile';
// Delay between the user's last interaction and when we kick off the
// background Turnstile pre-mint. Tuned for the typical paste-then-click
// cadence: long enough that single-keystroke typing doesn't burn a token,
// short enough that a paste-and-click within a normal reaction window
// (~500ms+) usually finds a ready token in the cache.
const PRE_MINT_DEBOUNCE_MS = 300;
export type InputMode = 'url' | 'file' | 'folder';
export function usePackRequest() {
@@ -20,6 +29,13 @@ export function usePackRequest() {
const inputRepositoryUrl = ref('');
const mode = ref<InputMode>('url');
const uploadedFile = ref<File | null>(null);
// True once the user has signalled real intent: typed/pasted a URL,
// uploaded a file/folder, switched modes, or tweaked options. Used to
// gate the Turnstile pre-mint so URL-parameter hydration (`?repo=...`),
// browser autofill, form restoration, and JS-executing link unfurlers
// don't trigger background challenges. Set-only — once true, it stays
// true for the session.
const userTouched = ref(false);
// Request states
const loading = ref(false);
@@ -49,12 +65,37 @@ export function usePackRequest() {
function setMode(newMode: InputMode) {
mode.value = newMode;
// Mode tab clicks are unambiguous user interactions, so they're a safe
// intent signal even before any input has been entered.
userTouched.value = true;
}
function handleFileUpload(file: File) {
uploadedFile.value = file;
userTouched.value = true;
}
// Wired to DOM-level input events (paste / IME / drop / typing) by
// TryItUrlInput, and to TryItPackOptions option-change handlers.
// Watching `inputUrl` / `packOptions` directly would also fire on URL-
// parameter hydration in onMounted(), which we want to opt into
// explicitly (see `?repo=` handling in onMounted) rather than implicitly.
function markUserTouched() {
userTouched.value = true;
}
const preMint = usePreMintDebounce({
isSubmitValid,
userTouched,
loading,
onTrigger: () => {
turnstile.preMintToken().catch(() => {
/* errors surface on the actual submit path */
});
},
delayMs: PRE_MINT_DEBOUNCE_MS,
});
function resetRequest() {
error.value = null;
errorType.value = 'error';
@@ -65,6 +106,11 @@ export function usePackRequest() {
async function submitRequest() {
if (!isSubmitValid.value) return;
// Drop any pending pre-mint debounce. Without an explicit clear here a
// debounce that's about to fire *this microtask* could still mint an
// extra token alongside the click path's mint.
preMint.clear();
// Cancel any pending request
if (requestController) {
requestController.abort();
@@ -72,7 +118,7 @@ export function usePackRequest() {
requestController = new AbortController();
// Capture the controller in a local const before any await. cancelRequest()
// can null out the shared `requestController` while we're awaiting
// turnstile.getToken(); reading `requestController.signal` after that
// turnstile.takeToken(); reading `requestController.signal` after that
// would throw TypeError. The local reference still points to the original
// (already-aborted) controller, so the downstream signal check in
// handlePackRequest still works correctly.
@@ -91,15 +137,6 @@ export function usePackRequest() {
// Use .bind() to avoid capturing the surrounding scope in the closure
const timeoutId = setTimeout(controller.abort.bind(controller, 'timeout'), TIMEOUT_MS);
// Obtain a 1-shot Turnstile token before issuing the pack request. If the
// widget fails (e.g. script blocked by an ad blocker, network error) the
// policy is environment-specific:
// - In production: surface a user-facing error and skip the request.
// The server-side middleware would 403 anyway, so calling /api/pack
// without a token only wastes a server round-trip.
// - In dev/preview: continue without a token. The server skips
// verification when TURNSTILE_SECRET_KEY is unset, so contributors
// without a Cloudflare account can still exercise the pack flow.
// All UI mutations from this point forward are guarded by `isCurrent()`.
// Without the guard, a slow request whose user hit cancel-and-resubmit
// could clobber the new request's `loading` / `result` / `error` state
@@ -108,50 +145,31 @@ export function usePackRequest() {
// identity is the cleanest way to detect supersession.
const isCurrent = () => requestController === controller;
let turnstileToken: string | undefined;
try {
// Pass the controller signal so cancelling the pack request also
// aborts an in-flight Turnstile challenge — otherwise a hung widget
// would delay the cancel response by up to 15s.
turnstileToken = await turnstile.getToken(controller.signal);
} catch (turnstileError) {
console.warn('Turnstile token acquisition failed:', turnstileError);
if (controller.signal.aborted) {
// The user (or the 30s timeout) cancelled while the challenge was
// in flight. Mirror handlePackRequest's onAbort messaging since we
// short-circuit before calling it.
clearTimeout(timeoutId);
if (isCurrent()) {
loading.value = false;
requestController = null;
if (controller.signal.reason === 'timeout') {
error.value =
'Request timed out.\nPlease consider using Include Patterns or Ignore Patterns to reduce the scope.';
} else {
error.value = 'Request was cancelled.';
}
errorType.value = 'warning';
}
return;
}
if (import.meta.env.PROD) {
clearTimeout(timeoutId);
if (isCurrent()) {
loading.value = false;
requestController = null;
// Distinguish "Turnstile script blocked" (likely an extension) from
// generic verification failure so the user has a path to recovery
// instead of just being told "try again".
const msg = turnstileError instanceof Error ? turnstileError.message : '';
const isScriptIssue = /script|load|missing/i.test(msg);
error.value = isScriptIssue
? 'Bot protection failed to load. Please disable ad blockers or privacy extensions blocking challenges.cloudflare.com and reload, or use the CLI: npx repomix --remote owner/repo.'
: 'Verification failed. Please reload the page and try again.';
errorType.value = 'error';
}
return;
// Obtain a 1-shot Turnstile token before issuing the pack request. The
// controller signal aborts an in-flight challenge when the pack request
// is cancelled, so a hung widget can't delay the cancel response.
const tokenResult = await acquireTurnstileToken(turnstile, controller.signal);
if (tokenResult.kind === 'aborted') {
clearTimeout(timeoutId);
if (isCurrent()) {
loading.value = false;
requestController = null;
error.value = abortMessage(tokenResult.reason);
errorType.value = 'warning';
}
return;
}
if (tokenResult.kind === 'error') {
clearTimeout(timeoutId);
if (isCurrent()) {
loading.value = false;
requestController = null;
error.value = tokenResult.message;
errorType.value = 'error';
}
return;
}
const turnstileToken = tokenResult.token;
try {
await handlePackRequest(
@@ -192,6 +210,17 @@ export function usePackRequest() {
if (requestController === controller) {
loading.value = false;
requestController = null;
// Repeat-pack convenience: warm the cache for a likely follow-up
// submission (option tweak + repack, or `repackWithSelectedFiles`
// triggered from the result view). Skipped on abort/cancel since
// the user may have given up, and on invalid form (user may have
// cleared the URL mid-request). userTouched is necessarily true
// here — it was a precondition for isSubmitValid to be true at
// submit start. Failures swallow silently — they surface on the
// next click via takeToken's cold path.
if (!controller.signal.aborted && isSubmitValid.value) {
turnstile.preMintToken().catch(() => {});
}
}
}
}
@@ -252,7 +281,15 @@ export function usePackRequest() {
// Apply pack options from URL parameters
applyUrlParameters(urlParams);
// Apply repo URL from URL parameters
// Apply repo URL from URL parameters. Intentionally do NOT flip
// `userTouched` here even when the value is valid: third-party pages
// driving traffic to `https://repomix.com/?repo=<...>` would otherwise
// amplify dashboard counters via link unfurlers (Slack / Discord /
// Twitter card validators that execute JS) — re-creating the
// page-view-shaped inflation this PR is meant to fix. Permalink
// visitors still pay the cold mint on click; the user's first real
// form interaction (typing, mode click, option tweak, file upload)
// is what gates the pre-mint.
if (urlParams.repo) {
inputUrl.value = urlParams.repo;
}
@@ -287,6 +324,7 @@ export function usePackRequest() {
submitRequest,
repackWithSelectedFiles,
cancelRequest,
markUserTouched,
// Turnstile widget container (Vue ref callback consumer)
setTurnstileContainer: turnstile.setContainer,
@@ -0,0 +1,56 @@
import { onBeforeUnmount, type Ref, watch } from 'vue';
// Debounced trigger for the Turnstile pre-mint. Watches a (valid + touched)
// gate and fires `onTrigger` after `delayMs` of quiet — short enough that
// the token is usually ready by the time the user reaches for the Pack
// button, long enough that rapid typing or quick mode-switches don't
// trigger multiple mints.
//
// `loading` is intentionally NOT a watch source — only a guard inside the
// callback. Including it in the deps would re-fire the watch on
// `loading: true → false`, scheduling a fresh pre-mint immediately after
// every pack completion even though the user hasn't done anything new,
// re-introducing dashboard counter inflation through a different path.
// The `clear()` callers (e.g. `submitRequest`'s start) are what stop a
// pending debounce from firing mid-submit.
export interface PreMintDebounceOptions {
// Source refs the watch should react to. Pre-mint fires when both are
// truthy AND `loading.value` is false at the time the timer fires.
isSubmitValid: Readonly<Ref<boolean>>;
userTouched: Readonly<Ref<boolean>>;
loading: Readonly<Ref<boolean>>;
// Called when the debounce window elapses. Should kick off the actual
// background mint — errors should be swallowed by the caller since
// failures surface on the explicit submit path.
onTrigger: () => void;
delayMs: number;
}
export function usePreMintDebounce(opts: PreMintDebounceOptions) {
const { isSubmitValid, userTouched, loading, onTrigger, delayMs } = opts;
let timer: ReturnType<typeof setTimeout> | undefined;
function clear() {
if (timer !== undefined) {
clearTimeout(timer);
timer = undefined;
}
}
watch(
[isSubmitValid, userTouched],
([valid, touched]) => {
clear();
if (!valid || !touched || loading.value) return;
timer = setTimeout(() => {
timer = undefined;
onTrigger();
}, delayMs);
},
{ flush: 'post' },
);
onBeforeUnmount(() => clear());
return { clear };
}
+66 -107
View File
@@ -1,13 +1,17 @@
import { onBeforeUnmount, ref } from 'vue';
import { loadTurnstileScript, type TurnstileGlobal } from './useTurnstileScript';
import { createTurnstileTokenCache } from './useTurnstileTokenCache';
// Cloudflare Turnstile integration. Used by usePackRequest to obtain a 1-shot
// verification token that the server-side turnstileMiddleware verifies before
// running /api/pack.
//
// The script-loading mechanics (script tag injection, READY_CALLBACK,
// retry-on-failure) live in `useTurnstileScript.ts` so this file stays
// focused on widget lifecycle / token requests / abort propagation.
// Layering:
// - `useTurnstileScript.ts` — script tag injection / READY_CALLBACK / retry.
// - `useTurnstileTokenCache.ts` — token cache, single-flight mint,
// atomic one-shot consumption.
// - this file — widget lifecycle (render/execute/reset), abort propagation
// into the underlying iframe, supersede / generation-counter logic.
//
// Site key resolution:
// - Build-time env var `VITE_TURNSTILE_SITE_KEY` overrides the default
@@ -19,29 +23,29 @@ import { loadTurnstileScript, type TurnstileGlobal } from './useTurnstileScript'
// server, or set both to real values together.
const FALLBACK_TEST_SITE_KEY = '1x00000000000000000000AA';
// Upper bound on how long getToken() will wait for a callback. Cloudflare's
// Upper bound on how long the widget callback can take. Cloudflare's
// `timeout-callback` only fires for interactive challenges, so an invisible
// widget that hangs (CDN stall, iframe never resolves) would otherwise leave
// the caller's promise pending forever and freeze the loading spinner.
const GET_TOKEN_TIMEOUT_MS = 15_000;
const MINT_TIMEOUT_MS = 15_000;
export function useTurnstile() {
const widgetId = ref<string | null>(null);
const containerEl = ref<HTMLElement | null>(null);
const error = ref<string | null>(null);
// Resolved when the next render of the widget produces a token. Reassigned
// on each `getToken()` call so back-to-back submits don't share state.
// Resolved when the next widget callback produces a token. Reassigned on
// every mint so back-to-back submits don't share state.
let pendingResolve: ((token: string) => void) | null = null;
let pendingReject: ((error: Error) => void) | null = null;
// Monotonic generation counter. Each getToken() call captures a local copy
// and the timeout/callback closures verify it before mutating shared state.
// This neutralises three otherwise-leaky scenarios:
// - a stale timeout from a previous call clearing the next call's pending
// Monotonic generation counter. Each mintToken() call captures a local
// copy and the timeout/callback closures verify it before mutating shared
// state. This neutralises three otherwise-leaky scenarios:
// - a stale timeout from a previous mint clearing the next call's pending
// handlers,
// - a delayed widget callback resolving the next call with a stale token,
// - a back-to-back submit reusing handlers before the previous timeout
// has fired.
// - a delayed widget callback resolving a later request with a stale
// token,
// - back-to-back mints reusing handlers before the previous timeout has
// fired.
let currentGen = 0;
// Site key resolution. The production-only safety net lives in
@@ -59,14 +63,16 @@ export function useTurnstile() {
// somehow shipped the test sitekey would still 403 every pack.
const siteKey = import.meta.env.VITE_TURNSTILE_SITE_KEY ?? FALLBACK_TEST_SITE_KEY;
// Single-flight cache for the in-flight ensureWidget promise. With pack
// pre-warm now restricted to `loadTurnstileScript()`, only back-to-back
// `getToken()` calls can race here — but two concurrent submits would
// still both pass the `widgetId.value` null check after the awaited
// script load resolves and call `turnstile.render()` twice, leaking the
// first widget id (onBeforeUnmount can only remove the surviving one).
// Single-flight cache for the in-flight ensureWidget promise. Shared by
// every code path that needs the widget (preMintToken, click-time mint),
// so concurrent calls can't both pass the `widgetId.value` null check
// after `await loadTurnstileScript()` resolves and call `turnstile.render()`
// twice — the first widget id would be overwritten and leak.
let ensureWidgetPromise: Promise<TurnstileGlobal> | null = null;
// Forward declaration — set after cache is created below.
let resetCache: () => void = () => {};
async function ensureWidget(el: HTMLElement): Promise<TurnstileGlobal> {
if (ensureWidgetPromise) return ensureWidgetPromise;
ensureWidgetPromise = (async () => {
@@ -83,12 +89,6 @@ export function useTurnstile() {
sitekey: siteKey,
size: 'invisible',
action: 'pack',
// Defer the actual challenge until execute() is called below.
// Caveat per PR #1541: render() itself still inflates the
// Cloudflare dashboard's challenge counters even with this
// option, which is why `setContainer()` no longer pre-warms by
// calling render(). We still pass `execute` here so render() at
// least doesn't auto-mint a token before getToken() is ready.
execution: 'execute',
callback: (token: string) => {
if (pendingResolve) {
@@ -98,17 +98,25 @@ export function useTurnstile() {
}
},
'error-callback': (errorCode: string) => {
const message = `Turnstile error: ${errorCode}`;
error.value = message;
if (pendingReject) {
pendingReject(new Error(message));
pendingReject(new Error(`Turnstile error: ${errorCode}`));
pendingResolve = null;
pendingReject = null;
}
},
'expired-callback': () => {
// Token expired before being used. The widget will issue a fresh
// one on the next execute() call.
// Token expired before being used. Drop the cache so the next
// takeToken() refreshes; the widget will issue a fresh token on
// the next execute() call.
//
// Intentionally do NOT auto-rearm pre-mint here. A user who
// fills the form and then leaves the tab idle would otherwise
// burn a challenge every TOKEN_TTL_MS (~4 minutes) for the
// entire lifetime of the page, re-creating dashboard counter
// inflation. The trade-off is that an idle-then-return user
// pays the cold mint latency on their next click; for a tab
// left open for hours, that's the right call.
resetCache();
if (widgetId.value) turnstile.reset(widgetId.value);
},
'timeout-callback': () => {
@@ -136,41 +144,15 @@ export function useTurnstile() {
}
}
// Ask the (invisible) widget for a fresh verification token. Each call
// resets the widget first because Turnstile tokens are 1-shot.
//
// The optional `signal` lets the caller (usePackRequest's submit flow)
// abort the challenge mid-flight when the user cancels — without it, a
// hung Turnstile iframe would block the cancel response for up to
// GET_TOKEN_TIMEOUT_MS even though the surrounding pack request was
// already aborted.
async function getToken(signal?: AbortSignal): Promise<string> {
error.value = null;
const checkAborted = () => {
if (signal?.aborted) throw new Error('Turnstile challenge aborted');
};
checkAborted();
// Run the widget challenge and return a fresh token. Internal primitive
// wrapped by the token cache's preMintToken / takeToken.
async function mintToken(): Promise<string> {
if (!containerEl.value) {
throw new Error('Turnstile container element not registered');
}
// Race the script-load step against the caller's abort signal so a
// user-initiated cancel during a slow script load (CDN stall, ad
// blocker, network blip) doesn't have to wait for the surrounding 30s
// pack timeout. The signal is also re-checked before listener setup
// below to cover the race where the abort fired during the await.
const widgetPromise = ensureWidget(containerEl.value);
const turnstile = signal
? await Promise.race([
widgetPromise,
new Promise<never>((_, reject) => {
const onPreAbort = () => reject(new Error('Turnstile challenge aborted'));
if (signal.aborted) onPreAbort();
else signal.addEventListener('abort', onPreAbort, { once: true });
}),
])
: await widgetPromise;
checkAborted();
if (!widgetId.value) {
const turnstile = await ensureWidget(containerEl.value);
const renderedWidgetId = widgetId.value;
if (!renderedWidgetId) {
throw new Error('Turnstile widget failed to render');
}
@@ -184,7 +166,6 @@ export function useTurnstile() {
const myGen = ++currentGen;
let timeoutId: ReturnType<typeof setTimeout> | undefined;
let onAbort: (() => void) | undefined;
const tokenPromise = new Promise<string>((resolve, reject) => {
// Wrap in gen-checked closures so a delayed widget callback can't
@@ -193,7 +174,6 @@ export function useTurnstile() {
pendingResolve = (token) => {
if (myGen !== currentGen) return;
if (timeoutId !== undefined) clearTimeout(timeoutId);
if (onAbort && signal) signal.removeEventListener('abort', onAbort);
pendingResolve = null;
pendingReject = null;
resolve(token);
@@ -201,60 +181,39 @@ export function useTurnstile() {
pendingReject = (err) => {
if (myGen !== currentGen) return;
if (timeoutId !== undefined) clearTimeout(timeoutId);
if (onAbort && signal) signal.removeEventListener('abort', onAbort);
pendingResolve = null;
pendingReject = null;
reject(err);
};
// The widget retains its previous token until reset(); explicit reset
// forces a new challenge on every getToken() call.
if (widgetId.value) turnstile.reset(widgetId.value);
if (widgetId.value) turnstile.execute(widgetId.value);
// Tokens are 1-shot, so reset() before each execute() to clear any
// stale challenge state inside the widget itself.
turnstile.reset(renderedWidgetId);
turnstile.execute(renderedWidgetId);
});
if (signal) {
onAbort = () => {
if (pendingReject) pendingReject(new Error('Turnstile challenge aborted'));
};
signal.addEventListener('abort', onAbort, { once: true });
}
// Bounded race against a hung widget. The gen check ensures a stale timer
// from a previous call (whose tokenPromise already resolved) cannot clear
// the current request's handlers.
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => {
if (myGen !== currentGen) return;
if (onAbort && signal) signal.removeEventListener('abort', onAbort);
pendingResolve = null;
pendingReject = null;
reject(new Error('Turnstile challenge timed out'));
}, GET_TOKEN_TIMEOUT_MS);
}, MINT_TIMEOUT_MS);
});
return Promise.race([tokenPromise, timeoutPromise]);
}
const cache = createTurnstileTokenCache(mintToken);
resetCache = cache.reset;
function setContainer(el: HTMLElement | null) {
containerEl.value = el;
// Pre-warm scope: ONLY load the Turnstile script, do NOT render the
// widget here. Production telemetry showed that calling
// `turnstile.render()` at form-mount time inflated the Cloudflare
// dashboard's "challenge issued / solved" counters far beyond the GA
// pack_start volume — every visitor (humans, crawlers, ad-blocker
// failures) was being counted into Turnstile analytics even though the
// widget was configured with `execution: 'execute'`. The docs say
// render() shouldn't fire a challenge in that mode, but the analytics
// disagree, so we no longer trust render() to be side-effect free at
// mount time. Render is now deferred to the first `getToken()` call.
//
// Errors are intentionally swallowed: a failed pre-warm doesn't block
// page rendering, and the same `loadTurnstileScript` path will retry
// (with full error propagation) when `getToken()` is eventually called.
if (el) {
loadTurnstileScript().catch(() => {
// pre-warm failures surface on the actual submit path
});
}
// Intentionally do NOT pre-warm the script here. Production telemetry
// (PR #1541 follow-up) showed that simply loading api.js inflates the
// Cloudflare dashboard's "challenge issued" counter to roughly the
// page-view count, regardless of whether `render()` is ever called.
// Pre-warm now happens only when usePackRequest sees a real intent
// signal (valid input + user interaction), which gates both the script
// load and the challenge to visitors who actually plan to submit.
}
onBeforeUnmount(() => {
@@ -264,14 +223,14 @@ export function useTurnstile() {
// the form was unmounted and bind a new widget to a detached DOM node
// with no remove() left to clean it up.
containerEl.value = null;
// Reject any in-flight getToken() promise so the awaiting caller doesn't
// hang forever after the form unmounts (e.g. user navigates away mid-
// challenge).
// Reject any in-flight mint so the awaiting caller doesn't hang forever
// after the form unmounts (e.g. user navigates away mid-challenge).
if (pendingReject) {
pendingReject(new Error('Turnstile widget unmounted'));
pendingResolve = null;
pendingReject = null;
}
cache.reset();
if (widgetId.value && window.turnstile) {
window.turnstile.remove(widgetId.value);
widgetId.value = null;
@@ -280,7 +239,7 @@ export function useTurnstile() {
return {
setContainer,
getToken,
error,
preMintToken: cache.preMintToken,
takeToken: cache.takeToken,
};
}
@@ -14,7 +14,6 @@ export interface TurnstileGlobal {
execute: (widgetId: string) => void;
reset: (widgetId: string) => void;
remove: (widgetId: string) => void;
getResponse: (widgetId: string) => string | undefined;
}
export interface TurnstileRenderOptions {
@@ -31,9 +30,10 @@ export interface TurnstileRenderOptions {
// NOTE: production telemetry (PR #1539 → #1541) showed that even with
// `execution: 'execute'` the dashboard still counts every render() call
// toward "challenges issued / solved", contradicting the public docs. The
// useTurnstile composable now defers render() to the first getToken()
// call instead of pre-warming at form mount, which is the only reliable
// way to keep the dashboard counters aligned with real submissions.
// useTurnstile composable now defers render() to the first takeToken() /
// preMintToken() call instead of pre-warming at form mount, which is the
// only reliable way to keep the dashboard counters aligned with real
// submissions.
execution?: 'render' | 'execute';
callback?: (token: string) => void;
'error-callback'?: (errorCode: string) => void;
@@ -58,7 +58,7 @@ export function loadTurnstileScript(): Promise<TurnstileGlobal> {
// Reset state on rejection so a transient CDN failure (ad blocker, network
// blip) doesn't permanently lock the page out of Turnstile. Without this,
// the rejected promise would be cached forever and every subsequent
// getToken() call would inherit the same stale rejection.
// takeToken() call would inherit the same stale rejection.
//
// Belt-and-suspenders: also drop the global onload callback so a late-
// arriving script load (e.g. extension interference resolving after
@@ -0,0 +1,132 @@
// Token cache for Cloudflare Turnstile. Decoupled from widget lifecycle so
// useTurnstile.ts stays focused on script loading / widget rendering / abort
// propagation.
//
// Responsibilities:
// - Stash a freshly minted token between preMintToken() and the next
// takeToken() so the click path skips the challenge round-trip.
// - Single-flight the mint: a debounced pre-mint that fires while a click
// is already in flight must NOT call mint() twice on the same widget
// (the supersede logic in mintToken would otherwise reject the older
// call and surface "Verification failed" on a perfectly valid challenge).
// - Make takeToken's cache claim atomic so two concurrent callers awaiting
// the same shared mint promise can't both walk away with the same
// one-shot token (siteverify would reject the second as
// `timeout-or-duplicate`).
// Cached tokens are treated as expired before Cloudflare's hard 300s ceiling,
// to leave a safety margin for clock skew and network round-trips. A user
// who starts a pack just inside the window won't get a `timeout-or-duplicate`
// from siteverify because they were 1 second from the cliff.
const TOKEN_TTL_MS = 240_000;
interface CachedToken {
token: string;
mintedAt: number;
}
export interface TurnstileTokenCache {
preMintToken(): Promise<string>;
takeToken(signal?: AbortSignal): Promise<string>;
reset(): void;
}
export function createTurnstileTokenCache(mint: () => Promise<string>): TurnstileTokenCache {
let cachedToken: CachedToken | null = null;
let mintPromise: Promise<string> | null = null;
function isExpired(entry: CachedToken): boolean {
return Date.now() - entry.mintedAt > TOKEN_TTL_MS;
}
// Single in-flight mint. The signal is intentionally NOT threaded through
// — pre-mint is unaware of any submit lifecycle. takeToken() races the
// shared promise against the caller's signal so a click-then-cancel
// unblocks the awaiter without aborting the underlying mint, leaving
// the resolved token in the cache for the next submit.
function startMint(): Promise<string> {
if (mintPromise) return mintPromise;
mintPromise = mint()
.then((token) => {
cachedToken = { token, mintedAt: Date.now() };
return token;
})
.catch((err) => {
// Don't cache failures — let the next takeToken/preMintToken retry.
cachedToken = null;
throw err;
})
.finally(() => {
mintPromise = null;
});
// Swallow rejections at the boundary so an unawaited preMintToken() (the
// common case) doesn't trigger an unhandled rejection in the console;
// errors surface on the actual submit path via takeToken.
mintPromise.catch(() => {});
return mintPromise;
}
function preMintToken(): Promise<string> {
if (cachedToken && !isExpired(cachedToken)) {
return Promise.resolve(cachedToken.token);
}
return startMint();
}
// Tokens are 1-shot, so claim the cache atomically (synchronous read +
// null-out before any await). The shared mint's resolution value is
// intentionally ignored — two concurrent callers awaiting the same
// promise would otherwise both receive the same token. If a concurrent
// caller already drained the cache, loop and start a fresh mint instead
// of returning a duplicate that siteverify would reject with
// `timeout-or-duplicate`.
async function takeToken(signal?: AbortSignal): Promise<string> {
while (true) {
if (cachedToken && !isExpired(cachedToken)) {
const token = cachedToken.token;
cachedToken = null;
return token;
}
const sharedMint = startMint();
await waitWithAbort(sharedMint, signal);
// Loop back: the mint resolved into the cache via startMint's `.then`,
// but a concurrent takeToken may have claimed it first. The cache
// check at the top of the loop is the single source of truth for
// whether we got the token or need to mint another one.
}
}
// Drop any cached token. Called from useTurnstile on widget
// `expired-callback` (so the next take re-mints) and on unmount.
// mintPromise stays — if a mint is currently running, its resolution
// will populate the new cache; we just lost the previous unused token.
function reset(): void {
cachedToken = null;
}
return { preMintToken, takeToken, reset };
}
// Race a promise against an AbortSignal. Used by takeToken so a user-
// initiated cancel unblocks the await without cancelling the shared
// mint behind it (which may still cache its token for the next submit).
function waitWithAbort<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
if (!signal) return promise;
if (signal.aborted) {
return Promise.reject(new Error('Turnstile challenge aborted'));
}
return new Promise<T>((resolve, reject) => {
const onAbort = () => reject(new Error('Turnstile challenge aborted'));
signal.addEventListener('abort', onAbort, { once: true });
promise.then(
(value) => {
signal.removeEventListener('abort', onAbort);
resolve(value);
},
(err) => {
signal.removeEventListener('abort', onAbort);
reject(err);
},
);
});
}