mirror of
https://github.com/yamadashy/repomix.git
synced 2026-05-30 11:18:53 +02:00
Merge pull request #1544 from yamadashy/perf/turnstile-premint
perf(website): Pre-mint Turnstile token on user intent
This commit is contained in:
@@ -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.';
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user