From 7c9e44616b108867b4a3e5f8cc260e34ac8ad645 Mon Sep 17 00:00:00 2001 From: Kazuki Yamada Date: Tue, 5 May 2026 19:16:54 +0900 Subject: [PATCH 1/8] perf(website): Pre-mint Turnstile token on user intent, not on mount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit intent(latency-and-counter): The Cloudflare Turnstile dashboard's "challenges issued" counter sits at roughly 1.25× of GA page_view, which means simply loading `api.js` on every visitor (the PR #1541 pre-warm) is already inflating the counter — render() and execute() are not the only trigger. At the same time, click→token latency is the main remaining UX cost. This PR reshapes pre-warm so script load + challenge happen only when the user has shown real intent (filled a valid URL or chose a file *and* interacted with the form), achieving both a lower counter and a near-zero click→token latency. fix(useTurnstile-api): Replace the single `getToken()` entry point with a `preMintToken()` / `takeToken()` pair backed by an in-memory `{ token, mintedAt, consumed }` cache. `preMintToken()` runs the challenge in the background and stashes the resulting token; `takeToken()` consumes the cache synchronously (instant submit) or awaits the in-flight mint, falling back to a cold mint with the supplied AbortSignal. `invalidateCache()` lets the caller drop the cache without minting a new token. TTL is bounded at 240 s — Cloudflare hard-caps tokens at 300 s, the margin absorbs clock skew and network round-trips so a token that's "almost expired" is never sent to siteverify. fix(useTurnstile-no-mount-prewarm): Stop calling `loadTurnstileScript()` from `setContainer()`. The mount-time script load was the source of the page-view-shaped counter inflation. Pre-warm now only runs from the intent-gated trigger in `usePackRequest`, so visitors who never interact with the form never appear on the dashboard. fix(usePackRequest-intent-gate): Add a `userTouched` ref that flips on real user interaction (input event, file upload, mode switch) and never goes back. A debounced (500 ms) `watch(isSubmitValid && userTouched)` calls `preMintToken()`, so URL-parameter hydration (`?repo=`), browser form restoration, and autofill never pre-mint. `submitRequest()` switches from `getToken()` to `takeToken()` so the cached token is consumed on the first click, with the cold mint path as a transparent fallback. fix(TryItUrlInput-user-input-event): Emit a new `userInput` event from the URL field's actual `@input` handler. `TryIt.vue` wires it to `markUserTouched()`. Watching `inputUrl` directly would have re-fired during onMounted hydration and defeated the gate. learned(cloudflare-counter-includes-script-load): Even with `execution: 'execute'`, the Cloudflare Turnstile dashboard counts api.js loads toward "challenges issued" (verified by comparing GA page_view ≈ 106 with CF issued ≈ 132 in the same 30 min window after the PR #1541 deploy). Treat any new place that loads api.js as a billable analytics side effect. Co-Authored-By: Claude Opus 4.7 (1M context) --- website/client/components/Home/TryIt.vue | 2 + .../client/components/Home/TryItUrlInput.vue | 6 + website/client/composables/usePackRequest.ts | 57 ++++- website/client/composables/useTurnstile.ts | 203 ++++++++++++------ 4 files changed, 197 insertions(+), 71 deletions(-) diff --git a/website/client/components/Home/TryIt.vue b/website/client/components/Home/TryIt.vue index c9ddb566..158f97e9 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" /> @@ -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/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/usePackRequest.ts b/website/client/composables/usePackRequest.ts index 326eef0f..48b3cc07 100644 --- a/website/client/composables/usePackRequest.ts +++ b/website/client/composables/usePackRequest.ts @@ -1,4 +1,4 @@ -import { computed, onMounted, ref } from 'vue'; +import { computed, onMounted, ref, watch } from 'vue'; import type { FileInfo, PackProgressStage, PackResult } from '../components/api/client'; import { handlePackRequest } from '../components/utils/requestHandlers'; import { isValidRemoteValue } from '../components/utils/validation'; @@ -6,6 +6,12 @@ import { parseUrlParameters } from '../utils/urlParams'; import { usePackOptions } from './usePackOptions'; import { useTurnstile } from './useTurnstile'; +// Delay between the user's last interaction and when we kick off the +// background Turnstile pre-mint. Short enough that the token is usually +// ready by the time the user reaches for the Pack button, long enough that +// rapid typing or a quick mode-switch doesn't trigger multiple mints. +const PRE_MINT_DEBOUNCE_MS = 500; + export type InputMode = 'url' | 'file' | 'folder'; export function usePackRequest() { @@ -20,6 +26,12 @@ export function usePackRequest() { const inputRepositoryUrl = ref(''); const mode = ref('url'); const uploadedFile = ref(null); + // True once the user has interacted with the form (typed/pasted a URL, + // uploaded a file/folder, or switched modes). Used to gate the Turnstile + // pre-mint so URL-parameter hydration (e.g. `?repo=...`), browser form + // restoration, or autofill don't trigger background challenges. Resets + // back to false would defeat the gate, so it is set-only. + const userTouched = ref(false); // Request states const loading = ref(false); @@ -49,12 +61,43 @@ 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. Watching `inputUrl` directly would also fire on URL- + // parameter hydration in onMounted(), which is exactly the case we need + // to exclude. + function markUserTouched() { + userTouched.value = true; + } + + // Background pre-mint trigger. Only fires when the form is actually + // submittable AND the user has interacted with it — so `?repo=` hydration + // and form restoration won't cause a wasted Cloudflare challenge. + // Debounced to avoid burning a token on every keystroke. + let preMintDebounceTimer: ReturnType | undefined; + watch( + [isSubmitValid, userTouched], + ([valid, touched]) => { + if (preMintDebounceTimer !== undefined) clearTimeout(preMintDebounceTimer); + if (!valid || !touched) return; + preMintDebounceTimer = setTimeout(() => { + turnstile.preMintToken().catch(() => { + /* errors surface on the actual submit path */ + }); + }, PRE_MINT_DEBOUNCE_MS); + }, + { flush: 'post' }, + ); + function resetRequest() { error.value = null; errorType.value = 'error'; @@ -110,10 +153,13 @@ export function usePackRequest() { 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); + // Prefer a cached token from the background pre-mint (kicked off when + // the form first became submittable). takeToken() consumes the cache + // synchronously and falls through to a fresh mint if there's no + // usable token. The controller signal aborts an in-flight challenge + // when the pack request is cancelled, so a hung widget can't delay + // the cancel response. + turnstileToken = await turnstile.takeToken(controller.signal); } catch (turnstileError) { console.warn('Turnstile token acquisition failed:', turnstileError); if (controller.signal.aborted) { @@ -287,6 +333,7 @@ export function usePackRequest() { submitRequest, repackWithSelectedFiles, cancelRequest, + markUserTouched, // Turnstile widget container (Vue ref callback consumer) setTurnstileContainer: turnstile.setContainer, diff --git a/website/client/composables/useTurnstile.ts b/website/client/composables/useTurnstile.ts index 94680841..a3b73d95 100644 --- a/website/client/composables/useTurnstile.ts +++ b/website/client/composables/useTurnstile.ts @@ -19,31 +19,50 @@ 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; + +// 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; + consumed: boolean; +} 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; + // Pre-mint cache. `mintPromise` is the in-flight challenge; `cachedToken` + // is the resolved token waiting to be consumed. Both clear on consumption, + // expiry, error, and component unmount. + let mintPromise: Promise | null = null; + let cachedToken: CachedToken | null = null; + // Site key resolution. The production-only safety net lives in // `.vitepress/config.ts` (it throws at build time when the Cloudflare Pages // production deploy is missing VITE_TURNSTILE_SITE_KEY). We deliberately do @@ -59,12 +78,11 @@ 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; async function ensureWidget(el: HTMLElement): Promise { @@ -83,12 +101,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) { @@ -107,8 +119,10 @@ export function useTurnstile() { } }, '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. + cachedToken = null; if (widgetId.value) turnstile.reset(widgetId.value); }, 'timeout-callback': () => { @@ -136,15 +150,12 @@ 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 { + // Run the widget challenge and return a fresh token. Internal primitive + // shared by preMintToken (background) and takeToken (click-path fallback). + // The optional `signal` aborts the challenge mid-flight when the surrounding + // pack request is cancelled — without it, a hung Turnstile iframe would + // block the cancel response for up to MINT_TIMEOUT_MS. + async function mintToken(signal?: AbortSignal): Promise { error.value = null; const checkAborted = () => { if (signal?.aborted) throw new Error('Turnstile challenge aborted'); @@ -155,9 +166,7 @@ export function useTurnstile() { } // 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. + // blocker, network blip) doesn't have to wait for MINT_TIMEOUT_MS. const widgetPromise = ensureWidget(containerEl.value); const turnstile = signal ? await Promise.race([ @@ -206,8 +215,8 @@ export function useTurnstile() { pendingReject = null; reject(err); }; - // The widget retains its previous token until reset(); explicit reset - // forces a new challenge on every getToken() call. + // Tokens are 1-shot, so reset() before each execute() to clear any + // stale challenge state inside the widget itself. if (widgetId.value) turnstile.reset(widgetId.value); if (widgetId.value) turnstile.execute(widgetId.value); }); @@ -219,9 +228,6 @@ export function useTurnstile() { 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; @@ -229,32 +235,94 @@ export function useTurnstile() { pendingResolve = null; pendingReject = null; reject(new Error('Turnstile challenge timed out')); - }, GET_TOKEN_TIMEOUT_MS); + }, MINT_TIMEOUT_MS); }); return Promise.race([tokenPromise, timeoutPromise]); } + // Background pre-mint: kicks off a challenge and stashes the resulting + // token for the next takeToken() to consume synchronously. Caller-supplied + // signals from the submit flow are deliberately *not* threaded here — + // pre-mint runs in the background of the form, divorced from any single + // pack lifecycle. + // + // Idempotent across repeat calls: if a mint is already in flight, return + // the existing promise; if a fresh token is already cached, no-op. + function preMintToken(): Promise { + if (cachedToken && !cachedToken.consumed && !isExpired(cachedToken)) { + return Promise.resolve(cachedToken.token); + } + if (mintPromise) return mintPromise; + mintPromise = mintToken() + .then((token) => { + cachedToken = { token, mintedAt: Date.now(), consumed: false }; + 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. + mintPromise.catch(() => { + /* surfaces on the actual submit path via takeToken */ + }); + return mintPromise; + } + + // Drop any cached token without minting a new one. Called explicitly by + // usePackRequest after a token has been handed to a submit so the same + // token can never be reused, regardless of how the request resolved. + function invalidateCache(): void { + cachedToken = null; + } + + function isExpired(entry: CachedToken): boolean { + return Date.now() - entry.mintedAt > TOKEN_TTL_MS; + } + + // Acquire a token for an immediate /api/pack submission. Order of + // preference: + // 1. Fresh, unconsumed cache from a recent preMintToken() — instant. + // 2. Currently-in-flight pre-mint — await the same promise; no + // additional execute() call. + // 3. Cold path — mint synchronously with the supplied abort signal. + // + // The returned token is marked consumed before this function returns, so + // double-clicks can't replay the same token (Cloudflare siteverify would + // reject it as `timeout-or-duplicate` anyway, but consuming on the client + // side avoids the wasted server round-trip). + async function takeToken(signal?: AbortSignal): Promise { + if (cachedToken && !cachedToken.consumed && !isExpired(cachedToken)) { + const token = cachedToken.token; + cachedToken = null; + return token; + } + if (mintPromise) { + const token = await mintPromise; + // The pre-mint that filled the cache may have been consumed by a + // concurrent caller; drop our copy regardless and return the awaited + // token to this submit. Clearing the cache here prevents the same + // token from being handed out twice. + cachedToken = null; + return token; + } + return mintToken(signal); + } + 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 +332,15 @@ 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; } + cachedToken = null; + mintPromise = null; if (widgetId.value && window.turnstile) { window.turnstile.remove(widgetId.value); widgetId.value = null; @@ -280,7 +349,9 @@ export function useTurnstile() { return { setContainer, - getToken, + preMintToken, + takeToken, + invalidateCache, error, }; } From bef4c4a805038a3428d8602714ba3428a754aff3 Mon Sep 17 00:00:00 2001 From: Kazuki Yamada Date: Tue, 5 May 2026 20:32:09 +0900 Subject: [PATCH 2/8] fix(website): Share mint promise between pre-mint and click paths intent(fast-click-race): When the user clicked Pack within the 500 ms pre-mint debounce window, takeToken() cold-pathed into mintToken() *and* the debounce timer later fired preMintToken() which started a second mintToken(). The generation-counter supersede logic in mintToken() rejected the first call as "Superseded by new Turnstile request", so the user's own click surfaced as a verification failure even though Turnstile would have happily issued a token. fix(unified-startMint): Extract a single `startMint()` that both takeToken (cold path) and preMintToken share. Concurrent calls return the same in-flight promise, so only one `turnstile.execute()` ever runs and the supersede branch only triggers when there is genuinely a stale request. fix(takeToken-abort-race): The signal threading is now via a `waitWithAbort` helper that races the awaiter against the abort signal but lets the underlying mint keep going. If the user cancels mid-mint, the underlying challenge still runs to completion and the token lands in the cache for whoever submits next, instead of being thrown away. Co-Authored-By: Claude Opus 4.7 (1M context) --- website/client/composables/useTurnstile.ts | 90 +++++++++++++++------- 1 file changed, 63 insertions(+), 27 deletions(-) diff --git a/website/client/composables/useTurnstile.ts b/website/client/composables/useTurnstile.ts index a3b73d95..102fdd84 100644 --- a/website/client/composables/useTurnstile.ts +++ b/website/client/composables/useTurnstile.ts @@ -240,18 +240,21 @@ export function useTurnstile() { return Promise.race([tokenPromise, timeoutPromise]); } - // Background pre-mint: kicks off a challenge and stashes the resulting - // token for the next takeToken() to consume synchronously. Caller-supplied - // signals from the submit flow are deliberately *not* threaded here — - // pre-mint runs in the background of the form, divorced from any single - // pack lifecycle. + // Single in-flight mint, shared by both pre-mint (background) and + // takeToken (click path). Without this sharing, a debounced pre-mint + // that fires while the user has already clicked Pack would call + // `turnstile.execute()` a second time on the same widget, the + // generation-counter supersede logic in mintToken() would reject the + // first call as "Superseded", and the user-initiated submit would + // surface a `Verification failed` error despite a perfectly valid + // challenge being in flight. // - // Idempotent across repeat calls: if a mint is already in flight, return - // the existing promise; if a fresh token is already cached, no-op. - function preMintToken(): Promise { - if (cachedToken && !cachedToken.consumed && !isExpired(cachedToken)) { - return Promise.resolve(cachedToken.token); - } + // The signal is intentionally not threaded into the shared mint — 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 = mintToken() .then((token) => { @@ -274,6 +277,17 @@ export function useTurnstile() { return mintPromise; } + // Background pre-mint: kicks off a challenge and stashes the resulting + // token for the next takeToken() to consume synchronously. Idempotent — + // if a mint is already in flight or a fresh token is cached, no extra + // work is done. + function preMintToken(): Promise { + if (cachedToken && !cachedToken.consumed && !isExpired(cachedToken)) { + return Promise.resolve(cachedToken.token); + } + return startMint(); + } + // Drop any cached token without minting a new one. Called explicitly by // usePackRequest after a token has been handed to a submit so the same // token can never be reused, regardless of how the request resolved. @@ -288,30 +302,52 @@ export function useTurnstile() { // Acquire a token for an immediate /api/pack submission. Order of // preference: // 1. Fresh, unconsumed cache from a recent preMintToken() — instant. - // 2. Currently-in-flight pre-mint — await the same promise; no - // additional execute() call. - // 3. Cold path — mint synchronously with the supplied abort signal. + // 2. In-flight mint (own or pre-mint's) — await the shared promise, + // racing against the caller's abort signal so a cancel unblocks + // the awaiter without killing the underlying mint. + // 3. Cold path — start a new shared mint via startMint(). // - // The returned token is marked consumed before this function returns, so - // double-clicks can't replay the same token (Cloudflare siteverify would - // reject it as `timeout-or-duplicate` anyway, but consuming on the client - // side avoids the wasted server round-trip). + // The returned token is marked consumed before this function returns, + // so double-clicks can't replay the same token (Cloudflare siteverify + // would reject it as `timeout-or-duplicate` anyway, but consuming on + // the client side avoids the wasted server round-trip). async function takeToken(signal?: AbortSignal): Promise { if (cachedToken && !cachedToken.consumed && !isExpired(cachedToken)) { const token = cachedToken.token; cachedToken = null; return token; } - if (mintPromise) { - const token = await mintPromise; - // The pre-mint that filled the cache may have been consumed by a - // concurrent caller; drop our copy regardless and return the awaited - // token to this submit. Clearing the cache here prevents the same - // token from being handed out twice. - cachedToken = null; - return token; + const sharedMint = startMint(); + const token = await waitWithAbort(sharedMint, signal); + // The mint resolved into the cache via startMint's `.then`; drop the + // cache here so a concurrent takeToken (or a follow-up preMintToken) + // doesn't hand out the same token twice. + cachedToken = null; + return token; + } + + // 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 mintToken(signal); + 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); + }, + ); + }); } function setContainer(el: HTMLElement | null) { From 01a1d237b99d78f2e5029c473b89620292194157 Mon Sep 17 00:00:00 2001 From: Kazuki Yamada Date: Tue, 5 May 2026 20:54:28 +0900 Subject: [PATCH 3/8] fix(website): Address codex review on Turnstile pre-mint flow - useTurnstile: Make takeToken() one-shot under concurrency. Two callers awaiting the same shared mintPromise both received the same token, which siteverify rejects as `timeout-or-duplicate`. Claim the cache atomically post-await and loop into a fresh mint if another caller won. - usePackRequest: Drop pending pre-mint debounce timer at submitRequest start and on unmount, and skip scheduling while loading is true. Stops a debounce-firing-during-submit from minting an extra Turnstile challenge alongside the click path's mint. - TryItPackOptions: Emit userInput from option handlers and wire to markUserTouched in TryIt. Without this, users hydrating via `?repo=` who only tweak format/include patterns/checkboxes never tripped the pre-mint gate, so their click path always cold-minted. Co-Authored-By: Claude Opus 4.7 (1M context) --- website/client/components/Home/TryIt.vue | 2 +- .../components/Home/TryItPackOptions.vue | 15 ++++++++ website/client/composables/usePackRequest.ts | 36 ++++++++++++++++--- website/client/composables/useTurnstile.ts | 34 ++++++++++-------- 4 files changed, 67 insertions(+), 20 deletions(-) diff --git a/website/client/components/Home/TryIt.vue b/website/client/components/Home/TryIt.vue index 158f97e9..6f81187b 100644 --- a/website/client/components/Home/TryIt.vue +++ b/website/client/components/Home/TryIt.vue @@ -86,7 +86,7 @@ v-model:show-line-numbers="packOptions.showLineNumbers" v-model:output-parsable="packOptions.outputParsable" v-model:compress="packOptions.compress" - + @user-input="markUserTouched" />
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/composables/usePackRequest.ts b/website/client/composables/usePackRequest.ts index 48b3cc07..9f093004 100644 --- a/website/client/composables/usePackRequest.ts +++ b/website/client/composables/usePackRequest.ts @@ -1,4 +1,4 @@ -import { computed, onMounted, ref, watch } from 'vue'; +import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'; import type { FileInfo, PackProgressStage, PackResult } from '../components/api/client'; import { handlePackRequest } from '../components/utils/requestHandlers'; import { isValidRemoteValue } from '../components/utils/validation'; @@ -82,14 +82,31 @@ export function usePackRequest() { // Background pre-mint trigger. Only fires when the form is actually // submittable AND the user has interacted with it — so `?repo=` hydration // and form restoration won't cause a wasted Cloudflare challenge. - // Debounced to avoid burning a token on every keystroke. + // Debounced to avoid burning a token on every keystroke. Suppressed while + // a request is in flight, since a debounce-firing-during-submit would + // burn an extra Turnstile challenge on top of the click path's mint and + // inflate the dashboard counter. let preMintDebounceTimer: ReturnType | undefined; + function clearPreMintTimer() { + if (preMintDebounceTimer !== undefined) { + clearTimeout(preMintDebounceTimer); + preMintDebounceTimer = undefined; + } + } + // `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 counter inflation through a different path. The + // clearPreMintTimer() call at the top of submitRequest is what stops a + // pending debounce from firing mid-submit. watch( [isSubmitValid, userTouched], ([valid, touched]) => { - if (preMintDebounceTimer !== undefined) clearTimeout(preMintDebounceTimer); - if (!valid || !touched) return; + clearPreMintTimer(); + if (!valid || !touched || loading.value) return; preMintDebounceTimer = setTimeout(() => { + preMintDebounceTimer = undefined; turnstile.preMintToken().catch(() => { /* errors surface on the actual submit path */ }); @@ -98,6 +115,10 @@ export function usePackRequest() { { flush: 'post' }, ); + onBeforeUnmount(() => { + clearPreMintTimer(); + }); + function resetRequest() { error.value = null; errorType.value = 'error'; @@ -108,6 +129,13 @@ export function usePackRequest() { async function submitRequest() { if (!isSubmitValid.value) return; + // Drop any pending pre-mint debounce. The watch already de-schedules on + // `loading=true` flush, but `loading.value = true` is set further down, + // so 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. + clearPreMintTimer(); + // Cancel any pending request if (requestController) { requestController.abort(); diff --git a/website/client/composables/useTurnstile.ts b/website/client/composables/useTurnstile.ts index 102fdd84..c9b4668e 100644 --- a/website/client/composables/useTurnstile.ts +++ b/website/client/composables/useTurnstile.ts @@ -307,23 +307,27 @@ export function useTurnstile() { // the awaiter without killing the underlying mint. // 3. Cold path — start a new shared mint via startMint(). // - // The returned token is marked consumed before this function returns, - // so double-clicks can't replay the same token (Cloudflare siteverify - // would reject it as `timeout-or-duplicate` anyway, but consuming on - // the client side avoids the wasted server round-trip). + // Tokens are 1-shot, so claim the cache atomically (synchronous read + + // null-out before any await) — the resolution value of the shared mint + // is intentionally ignored, since 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 { - if (cachedToken && !cachedToken.consumed && !isExpired(cachedToken)) { - const token = cachedToken.token; - cachedToken = null; - return token; + while (true) { + if (cachedToken && !cachedToken.consumed && !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. } - const sharedMint = startMint(); - const token = await waitWithAbort(sharedMint, signal); - // The mint resolved into the cache via startMint's `.then`; drop the - // cache here so a concurrent takeToken (or a follow-up preMintToken) - // doesn't hand out the same token twice. - cachedToken = null; - return token; } // Race a promise against an AbortSignal. Used by takeToken so a user- From b9a9e719f8d2c66d042a9027993250bba32e0f58 Mon Sep 17 00:00:00 2001 From: Kazuki Yamada Date: Tue, 5 May 2026 21:41:48 +0900 Subject: [PATCH 4/8] fix(website): Address PR review feedback on Turnstile pre-mint - Drop the unused `invalidateCache` export from useTurnstile. Both call paths (takeToken cache claim, expired-callback) already null cachedToken inline, so the helper had no callers. - Update stale `turnstile.getToken()` references in usePackRequest and useTurnstileScript comments to match the renamed `takeToken()` / `preMintToken()` API. Co-Authored-By: Claude Opus 4.7 (1M context) --- website/client/composables/usePackRequest.ts | 2 +- website/client/composables/useTurnstile.ts | 8 -------- website/client/composables/useTurnstileScript.ts | 9 +++++---- 3 files changed, 6 insertions(+), 13 deletions(-) diff --git a/website/client/composables/usePackRequest.ts b/website/client/composables/usePackRequest.ts index 9f093004..84ac7c94 100644 --- a/website/client/composables/usePackRequest.ts +++ b/website/client/composables/usePackRequest.ts @@ -143,7 +143,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. diff --git a/website/client/composables/useTurnstile.ts b/website/client/composables/useTurnstile.ts index c9b4668e..21bd15b9 100644 --- a/website/client/composables/useTurnstile.ts +++ b/website/client/composables/useTurnstile.ts @@ -288,13 +288,6 @@ export function useTurnstile() { return startMint(); } - // Drop any cached token without minting a new one. Called explicitly by - // usePackRequest after a token has been handed to a submit so the same - // token can never be reused, regardless of how the request resolved. - function invalidateCache(): void { - cachedToken = null; - } - function isExpired(entry: CachedToken): boolean { return Date.now() - entry.mintedAt > TOKEN_TTL_MS; } @@ -391,7 +384,6 @@ export function useTurnstile() { setContainer, preMintToken, takeToken, - invalidateCache, error, }; } diff --git a/website/client/composables/useTurnstileScript.ts b/website/client/composables/useTurnstileScript.ts index 33bed5f0..fc9b7089 100644 --- a/website/client/composables/useTurnstileScript.ts +++ b/website/client/composables/useTurnstileScript.ts @@ -31,9 +31,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 +59,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 From 2ee93b3214a900b8c969dcbb3482bec74e7e06b9 Mon Sep 17 00:00:00 2001 From: Kazuki Yamada Date: Tue, 5 May 2026 22:01:25 +0900 Subject: [PATCH 5/8] refactor(website): Address remaining PR feedback on Turnstile pre-mint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Mark `userTouched=true` when arriving via a valid `?repo=` permalink so the visitor's click path uses a pre-minted token. Browser autofill and malformed `?repo=` values still don't burn a challenge. - After a non-aborted submit completes, schedule a fresh `preMintToken()` in the finally block. Warms the cache for the typical "view result → tweak options → repack" flow and for `repackWithSelectedFiles`. - Reduce the pre-mint debounce from 500ms to 300ms. Tightens the window where a paste-and-click cadence misses the cache. - Split composables to fit the 250-line file-size guideline: * Extract token cache (cache state + single-flight mint + atomic one-shot consumption) into `useTurnstileTokenCache.ts`. Shrinks `useTurnstile.ts` from 358 → 241 lines and lets the widget file focus on render lifecycle / supersede logic. * Extract pre-mint debounce trigger into `usePreMintDebounce.ts`. * Extract Turnstile token acquisition + user-facing failure copy into `turnstileSubmit.ts`. Drops `usePackRequest.ts` from 345 → 331 lines; `submitRequest` is a single cohesive request lifecycle that resists further splitting. - Drop the unused `consumed` flag on `CachedToken` (claude review). The cache nulls the entry on consumption instead, which is what the takeToken atomic-claim loop already relies on. Co-Authored-By: Claude Opus 4.7 (1M context) --- website/client/composables/turnstileSubmit.ts | 61 ++++++ website/client/composables/usePackRequest.ts | 178 +++++++---------- .../client/composables/usePreMintDebounce.ts | 56 ++++++ website/client/composables/useTurnstile.ts | 186 ++---------------- .../composables/useTurnstileTokenCache.ts | 132 +++++++++++++ 5 files changed, 336 insertions(+), 277 deletions(-) create mode 100644 website/client/composables/turnstileSubmit.ts create mode 100644 website/client/composables/usePreMintDebounce.ts create mode 100644 website/client/composables/useTurnstileTokenCache.ts diff --git a/website/client/composables/turnstileSubmit.ts b/website/client/composables/turnstileSubmit.ts new file mode 100644 index 00000000..4a27ee3c --- /dev/null +++ b/website/client/composables/turnstileSubmit.ts @@ -0,0 +1,61 @@ +// 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) { + console.warn('Turnstile token acquisition failed:', err); + if (signal.aborted) { + return { kind: 'aborted', reason: signal.reason }; + } + 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 84ac7c94..c9b5d9fc 100644 --- a/website/client/composables/usePackRequest.ts +++ b/website/client/composables/usePackRequest.ts @@ -1,16 +1,19 @@ -import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'; +import { computed, onMounted, ref } from 'vue'; import type { FileInfo, PackProgressStage, PackResult } from '../components/api/client'; 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. Short enough that the token is usually -// ready by the time the user reaches for the Pack button, long enough that -// rapid typing or a quick mode-switch doesn't trigger multiple mints. -const PRE_MINT_DEBOUNCE_MS = 500; +// 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'; @@ -26,11 +29,11 @@ export function usePackRequest() { const inputRepositoryUrl = ref(''); const mode = ref('url'); const uploadedFile = ref(null); - // True once the user has interacted with the form (typed/pasted a URL, - // uploaded a file/folder, or switched modes). Used to gate the Turnstile - // pre-mint so URL-parameter hydration (e.g. `?repo=...`), browser form - // restoration, or autofill don't trigger background challenges. Resets - // back to false would defeat the gate, so it is set-only. + // True once the user has signalled real intent: typed/pasted a URL, + // uploaded a file/folder, switched modes, tweaked options, or arrived + // via a `?repo=...` permalink. Used to gate the Turnstile pre-mint so + // browser autofill / form restoration don't trigger background + // challenges. Set-only — once true, it stays true for the session. const userTouched = ref(false); // Request states @@ -72,51 +75,24 @@ export function usePackRequest() { } // Wired to DOM-level input events (paste / IME / drop / typing) by - // TryItUrlInput. Watching `inputUrl` directly would also fire on URL- - // parameter hydration in onMounted(), which is exactly the case we need - // to exclude. + // 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; } - // Background pre-mint trigger. Only fires when the form is actually - // submittable AND the user has interacted with it — so `?repo=` hydration - // and form restoration won't cause a wasted Cloudflare challenge. - // Debounced to avoid burning a token on every keystroke. Suppressed while - // a request is in flight, since a debounce-firing-during-submit would - // burn an extra Turnstile challenge on top of the click path's mint and - // inflate the dashboard counter. - let preMintDebounceTimer: ReturnType | undefined; - function clearPreMintTimer() { - if (preMintDebounceTimer !== undefined) { - clearTimeout(preMintDebounceTimer); - preMintDebounceTimer = undefined; - } - } - // `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 counter inflation through a different path. The - // clearPreMintTimer() call at the top of submitRequest is what stops a - // pending debounce from firing mid-submit. - watch( - [isSubmitValid, userTouched], - ([valid, touched]) => { - clearPreMintTimer(); - if (!valid || !touched || loading.value) return; - preMintDebounceTimer = setTimeout(() => { - preMintDebounceTimer = undefined; - turnstile.preMintToken().catch(() => { - /* errors surface on the actual submit path */ - }); - }, PRE_MINT_DEBOUNCE_MS); + const preMint = usePreMintDebounce({ + isSubmitValid, + userTouched, + loading, + onTrigger: () => { + turnstile.preMintToken().catch(() => { + /* errors surface on the actual submit path */ + }); }, - { flush: 'post' }, - ); - - onBeforeUnmount(() => { - clearPreMintTimer(); + delayMs: PRE_MINT_DEBOUNCE_MS, }); function resetRequest() { @@ -129,12 +105,10 @@ export function usePackRequest() { async function submitRequest() { if (!isSubmitValid.value) return; - // Drop any pending pre-mint debounce. The watch already de-schedules on - // `loading=true` flush, but `loading.value = true` is set further down, - // so 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. - clearPreMintTimer(); + // 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) { @@ -162,15 +136,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 @@ -179,53 +144,31 @@ export function usePackRequest() { // identity is the cleanest way to detect supersession. const isCurrent = () => requestController === controller; - let turnstileToken: string | undefined; - try { - // Prefer a cached token from the background pre-mint (kicked off when - // the form first became submittable). takeToken() consumes the cache - // synchronously and falls through to a fresh mint if there's no - // usable token. The controller signal aborts an in-flight challenge - // when the pack request is cancelled, so a hung widget can't delay - // the cancel response. - turnstileToken = await turnstile.takeToken(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( @@ -266,6 +209,14 @@ 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. Failures swallow silently — they + // surface on the next click via takeToken's cold path. + if (!controller.signal.aborted && isSubmitValid.value && userTouched.value) { + turnstile.preMintToken().catch(() => {}); + } } } } @@ -329,6 +280,13 @@ export function usePackRequest() { // Apply repo URL from URL parameters if (urlParams.repo) { inputUrl.value = urlParams.repo; + // A valid `?repo=` permalink is itself an intent signal — the visitor + // navigated here specifically to pack this repo, so warm the cache + // for the click path. We still gate on validity so a malformed + // `?repo=` doesn't burn a challenge for a form that won't submit. + if (isValidRemoteValue(urlParams.repo.trim())) { + userTouched.value = true; + } } }); 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 21bd15b9..13f2d8fd 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 @@ -25,18 +29,6 @@ const FALLBACK_TEST_SITE_KEY = '1x00000000000000000000AA'; // the caller's promise pending forever and freeze the loading spinner. const MINT_TIMEOUT_MS = 15_000; -// 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; - consumed: boolean; -} - export function useTurnstile() { const widgetId = ref(null); const containerEl = ref(null); @@ -57,12 +49,6 @@ export function useTurnstile() { // fired. let currentGen = 0; - // Pre-mint cache. `mintPromise` is the in-flight challenge; `cachedToken` - // is the resolved token waiting to be consumed. Both clear on consumption, - // expiry, error, and component unmount. - let mintPromise: Promise | null = null; - let cachedToken: CachedToken | null = null; - // Site key resolution. The production-only safety net lives in // `.vitepress/config.ts` (it throws at build time when the Cloudflare Pages // production deploy is missing VITE_TURNSTILE_SITE_KEY). We deliberately do @@ -85,6 +71,9 @@ export function useTurnstile() { // 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 () => { @@ -122,7 +111,7 @@ export function useTurnstile() { // 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. - cachedToken = null; + resetCache(); if (widgetId.value) turnstile.reset(widgetId.value); }, 'timeout-callback': () => { @@ -151,34 +140,13 @@ export function useTurnstile() { } // Run the widget challenge and return a fresh token. Internal primitive - // shared by preMintToken (background) and takeToken (click-path fallback). - // The optional `signal` aborts the challenge mid-flight when the surrounding - // pack request is cancelled — without it, a hung Turnstile iframe would - // block the cancel response for up to MINT_TIMEOUT_MS. - async function mintToken(signal?: AbortSignal): Promise { + // wrapped by the token cache's preMintToken / takeToken. + async function mintToken(): Promise { error.value = null; - const checkAborted = () => { - if (signal?.aborted) throw new Error('Turnstile challenge aborted'); - }; - checkAborted(); 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 MINT_TIMEOUT_MS. - 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(); + const turnstile = await ensureWidget(containerEl.value); if (!widgetId.value) { throw new Error('Turnstile widget failed to render'); } @@ -193,7 +161,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 @@ -202,7 +169,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); @@ -210,7 +176,6 @@ 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); @@ -221,17 +186,9 @@ export function useTurnstile() { if (widgetId.value) turnstile.execute(widgetId.value); }); - if (signal) { - onAbort = () => { - if (pendingReject) pendingReject(new Error('Turnstile challenge aborted')); - }; - signal.addEventListener('abort', onAbort, { once: true }); - } - 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')); @@ -240,112 +197,8 @@ export function useTurnstile() { return Promise.race([tokenPromise, timeoutPromise]); } - // Single in-flight mint, shared by both pre-mint (background) and - // takeToken (click path). Without this sharing, a debounced pre-mint - // that fires while the user has already clicked Pack would call - // `turnstile.execute()` a second time on the same widget, the - // generation-counter supersede logic in mintToken() would reject the - // first call as "Superseded", and the user-initiated submit would - // surface a `Verification failed` error despite a perfectly valid - // challenge being in flight. - // - // The signal is intentionally not threaded into the shared mint — 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 = mintToken() - .then((token) => { - cachedToken = { token, mintedAt: Date.now(), consumed: false }; - 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. - mintPromise.catch(() => { - /* surfaces on the actual submit path via takeToken */ - }); - return mintPromise; - } - - // Background pre-mint: kicks off a challenge and stashes the resulting - // token for the next takeToken() to consume synchronously. Idempotent — - // if a mint is already in flight or a fresh token is cached, no extra - // work is done. - function preMintToken(): Promise { - if (cachedToken && !cachedToken.consumed && !isExpired(cachedToken)) { - return Promise.resolve(cachedToken.token); - } - return startMint(); - } - - function isExpired(entry: CachedToken): boolean { - return Date.now() - entry.mintedAt > TOKEN_TTL_MS; - } - - // Acquire a token for an immediate /api/pack submission. Order of - // preference: - // 1. Fresh, unconsumed cache from a recent preMintToken() — instant. - // 2. In-flight mint (own or pre-mint's) — await the shared promise, - // racing against the caller's abort signal so a cancel unblocks - // the awaiter without killing the underlying mint. - // 3. Cold path — start a new shared mint via startMint(). - // - // Tokens are 1-shot, so claim the cache atomically (synchronous read + - // null-out before any await) — the resolution value of the shared mint - // is intentionally ignored, since 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 && !cachedToken.consumed && !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. - } - } - - // 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); - }, - ); - }); - } + const cache = createTurnstileTokenCache(mintToken); + resetCache = cache.reset; function setContainer(el: HTMLElement | null) { containerEl.value = el; @@ -372,8 +225,7 @@ export function useTurnstile() { pendingResolve = null; pendingReject = null; } - cachedToken = null; - mintPromise = null; + cache.reset(); if (widgetId.value && window.turnstile) { window.turnstile.remove(widgetId.value); widgetId.value = null; @@ -382,8 +234,8 @@ export function useTurnstile() { return { setContainer, - preMintToken, - takeToken, + preMintToken: cache.preMintToken, + takeToken: cache.takeToken, error, }; } 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); + }, + ); + }); +} From 2a0922d1141670d534826589d49ecf8a3094d666 Mon Sep 17 00:00:00 2001 From: Kazuki Yamada Date: Tue, 5 May 2026 22:28:33 +0900 Subject: [PATCH 6/8] refactor(website): Address claude follow-up review on Turnstile pre-mint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop the unused `error` ref from `useTurnstile`. The widget-level error-callback writes had no observer (only `usePackRequest.error` feeds the UI), so the export and writes were vestigial. - Drop `getResponse` from `TurnstileGlobal`. Never called anywhere in the codebase; clearer to leave it off the typed surface. - Don't `console.warn` on normal cancel/timeout flows in `acquireTurnstileToken`. Move the warn after the `signal.aborted` check so the dev console only logs genuine challenge / script-load failures. - Hoist the consecutive `if (widgetId.value)` guards in `mintToken` by capturing the rendered widget id into a local const after the throw. - Drop the redundant `userTouched.value` check in the post-pack pre-mint guard. `userTouched` is necessarily true at this point — it was a precondition for `isSubmitValid` being true when the submit started. Co-Authored-By: Claude Opus 4.7 (1M context) --- website/client/composables/turnstileSubmit.ts | 4 +++- website/client/composables/usePackRequest.ts | 9 ++++++--- website/client/composables/useTurnstile.ts | 12 +++++------- website/client/composables/useTurnstileScript.ts | 1 - 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/website/client/composables/turnstileSubmit.ts b/website/client/composables/turnstileSubmit.ts index 4a27ee3c..8b2fdc39 100644 --- a/website/client/composables/turnstileSubmit.ts +++ b/website/client/composables/turnstileSubmit.ts @@ -26,10 +26,12 @@ export async function acquireTurnstileToken( try { return { kind: 'token', token: await turnstile.takeToken(signal) }; } catch (err) { - console.warn('Turnstile token acquisition failed:', 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) }; } diff --git a/website/client/composables/usePackRequest.ts b/website/client/composables/usePackRequest.ts index c9b5d9fc..97d45769 100644 --- a/website/client/composables/usePackRequest.ts +++ b/website/client/composables/usePackRequest.ts @@ -212,9 +212,12 @@ export function usePackRequest() { // 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. Failures swallow silently — they - // surface on the next click via takeToken's cold path. - if (!controller.signal.aborted && isSubmitValid.value && userTouched.value) { + // 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(() => {}); } } diff --git a/website/client/composables/useTurnstile.ts b/website/client/composables/useTurnstile.ts index 13f2d8fd..74c27b42 100644 --- a/website/client/composables/useTurnstile.ts +++ b/website/client/composables/useTurnstile.ts @@ -32,7 +32,6 @@ 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 widget callback produces a token. Reassigned on // every mint so back-to-back submits don't share state. @@ -99,10 +98,8 @@ 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; } @@ -147,7 +144,8 @@ export function useTurnstile() { throw new Error('Turnstile container element not registered'); } const turnstile = await ensureWidget(containerEl.value); - if (!widgetId.value) { + const renderedWidgetId = widgetId.value; + if (!renderedWidgetId) { throw new Error('Turnstile widget failed to render'); } @@ -182,8 +180,8 @@ export function useTurnstile() { }; // Tokens are 1-shot, so reset() before each execute() to clear any // stale challenge state inside the widget itself. - if (widgetId.value) turnstile.reset(widgetId.value); - if (widgetId.value) turnstile.execute(widgetId.value); + turnstile.reset(renderedWidgetId); + turnstile.execute(renderedWidgetId); }); const timeoutPromise = new Promise((_, reject) => { diff --git a/website/client/composables/useTurnstileScript.ts b/website/client/composables/useTurnstileScript.ts index fc9b7089..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 { From 23494e5c8d21848c7c1201e0451845a34da8973c Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 13:34:07 +0000 Subject: [PATCH 7/8] fix(website): Remove leftover useTurnstile.error references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit 2a0922d dropped the `error` ref declaration from `useTurnstile` but left two references behind — `error.value = null` at the top of `mintToken()` and `error` in the returned object. The first throws ReferenceError at runtime; the second is a tsc error. No external caller reads `useTurnstile().error` (TryIt.vue only consumes `usePackRequest.error`), so the export was already vestigial. Co-Authored-By: Kazuki Yamada --- website/client/composables/useTurnstile.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/website/client/composables/useTurnstile.ts b/website/client/composables/useTurnstile.ts index 74c27b42..c9891aa5 100644 --- a/website/client/composables/useTurnstile.ts +++ b/website/client/composables/useTurnstile.ts @@ -139,7 +139,6 @@ export function useTurnstile() { // Run the widget challenge and return a fresh token. Internal primitive // wrapped by the token cache's preMintToken / takeToken. async function mintToken(): Promise { - error.value = null; if (!containerEl.value) { throw new Error('Turnstile container element not registered'); } @@ -234,6 +233,5 @@ export function useTurnstile() { setContainer, preMintToken: cache.preMintToken, takeToken: cache.takeToken, - error, }; } From 6fe66f172a19726eaee3e80680e923af460a09ec Mon Sep 17 00:00:00 2001 From: Kazuki Yamada Date: Tue, 5 May 2026 22:37:24 +0900 Subject: [PATCH 8/8] revert(website): Drop `?repo=` -> userTouched flip to avoid amplification A valid `?repo=` permalink was treated as an intent signal so the visitor's click path used a pre-minted token. claude's follow-up review flagged that this re-creates the dashboard counter inflation this PR is meant to fix: any third-party page driving traffic to `https://repomix.com/?repo=` (Slack / Discord / Twitter card validators that execute JS) would mint a token per visit, regardless of whether the visitor ever submits. Permalink visitors now pay the cold mint on their first click; the user's first real form interaction (typing, mode click, option tweak, file upload) is what gates the pre-mint. Also document the idle-tab cliff in `expired-callback`: re-arming pre-mint there would burn a challenge every 240s for the lifetime of an open tab, which is worse than the cold-mint-on-return cost it would save. Co-Authored-By: Claude Opus 4.7 (1M context) --- website/client/composables/usePackRequest.ts | 26 +++++++++++--------- website/client/composables/useTurnstile.ts | 8 ++++++ 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/website/client/composables/usePackRequest.ts b/website/client/composables/usePackRequest.ts index 97d45769..32743516 100644 --- a/website/client/composables/usePackRequest.ts +++ b/website/client/composables/usePackRequest.ts @@ -30,10 +30,11 @@ export function usePackRequest() { 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, tweaked options, or arrived - // via a `?repo=...` permalink. Used to gate the Turnstile pre-mint so - // browser autofill / form restoration don't trigger background - // challenges. Set-only — once true, it stays true for the session. + // 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 @@ -280,16 +281,17 @@ 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; - // A valid `?repo=` permalink is itself an intent signal — the visitor - // navigated here specifically to pack this repo, so warm the cache - // for the click path. We still gate on validity so a malformed - // `?repo=` doesn't burn a challenge for a form that won't submit. - if (isValidRemoteValue(urlParams.repo.trim())) { - userTouched.value = true; - } } }); diff --git a/website/client/composables/useTurnstile.ts b/website/client/composables/useTurnstile.ts index c9891aa5..80d6cc00 100644 --- a/website/client/composables/useTurnstile.ts +++ b/website/client/composables/useTurnstile.ts @@ -108,6 +108,14 @@ export function useTurnstile() { // 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); },