diff --git a/website/client/components/Home/TryIt.vue b/website/client/components/Home/TryIt.vue
index c9ddb566..6f81187b 100644
--- a/website/client/components/Home/TryIt.vue
+++ b/website/client/components/Home/TryIt.vue
@@ -45,6 +45,7 @@
:loading="loading"
@keydown="handleKeydown"
@submit="handleSubmit"
+ @user-input="markUserTouched"
:show-button="false"
/>
@@ -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"
/>
@@ -157,6 +158,7 @@ const {
resetOptions,
cancelRequest,
setTurnstileContainer,
+ markUserTouched,
} = usePackRequest();
// Wire the template ref into useTurnstile so the widget renders into the
diff --git a/website/client/components/Home/TryItPackOptions.vue b/website/client/components/Home/TryItPackOptions.vue
index dfebb240..48fdf560 100644
--- a/website/client/components/Home/TryItPackOptions.vue
+++ b/website/client/components/Home/TryItPackOptions.vue
@@ -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);
}
diff --git a/website/client/components/Home/TryItUrlInput.vue b/website/client/components/Home/TryItUrlInput.vue
index c66a95e0..f3597611 100644
--- a/website/client/components/Home/TryItUrlInput.vue
+++ b/website/client/components/Home/TryItUrlInput.vue
@@ -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
diff --git a/website/client/composables/turnstileSubmit.ts b/website/client/composables/turnstileSubmit.ts
new file mode 100644
index 00000000..8b2fdc39
--- /dev/null
+++ b/website/client/composables/turnstileSubmit.ts
@@ -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,
+ signal: AbortSignal,
+): Promise {
+ 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.';
+}
diff --git a/website/client/composables/usePackRequest.ts b/website/client/composables/usePackRequest.ts
index 326eef0f..32743516 100644
--- a/website/client/composables/usePackRequest.ts
+++ b/website/client/composables/usePackRequest.ts
@@ -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('url');
const uploadedFile = ref(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,
diff --git a/website/client/composables/usePreMintDebounce.ts b/website/client/composables/usePreMintDebounce.ts
new file mode 100644
index 00000000..0ed0e2de
--- /dev/null
+++ b/website/client/composables/usePreMintDebounce.ts
@@ -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[>;
+ userTouched: Readonly][>;
+ loading: Readonly][>;
+ // 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 | 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 };
+}
diff --git a/website/client/composables/useTurnstile.ts b/website/client/composables/useTurnstile.ts
index 94680841..80d6cc00 100644
--- a/website/client/composables/useTurnstile.ts
+++ b/website/client/composables/useTurnstile.ts
@@ -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(null);
const containerEl = ref(null);
- const error = ref(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 | null = null;
+ // Forward declaration — set after cache is created below.
+ let resetCache: () => void = () => {};
+
async function ensureWidget(el: HTMLElement): Promise {
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 {
- 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 {
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((_, 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 | undefined;
- let onAbort: (() => void) | undefined;
const tokenPromise = new Promise((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((_, 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,
};
}
diff --git a/website/client/composables/useTurnstileScript.ts b/website/client/composables/useTurnstileScript.ts
index 33bed5f0..d76236be 100644
--- a/website/client/composables/useTurnstileScript.ts
+++ b/website/client/composables/useTurnstileScript.ts
@@ -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 {
// 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
diff --git a/website/client/composables/useTurnstileTokenCache.ts b/website/client/composables/useTurnstileTokenCache.ts
new file mode 100644
index 00000000..ba985cfd
--- /dev/null
+++ b/website/client/composables/useTurnstileTokenCache.ts
@@ -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;
+ takeToken(signal?: AbortSignal): Promise;
+ reset(): void;
+}
+
+export function createTurnstileTokenCache(mint: () => Promise): TurnstileTokenCache {
+ let cachedToken: CachedToken | null = null;
+ let mintPromise: Promise | 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 {
+ 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 {
+ 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 {
+ 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(promise: Promise, signal: AbortSignal | undefined): Promise {
+ if (!signal) return promise;
+ if (signal.aborted) {
+ return Promise.reject(new Error('Turnstile challenge aborted'));
+ }
+ return new Promise((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);
+ },
+ );
+ });
+}
]