Previous memoization stored a single boolean at module scope. In any
Node context where the same module instance might be reused across
requests (VitePress SSG, dev server, preview server with `navigator`
polyfilled per request), the first request's UA would silently leak
into subsequent calls.
In production this code is browser-only — Cloud Run's Hono server
doesn't import `botDetect.ts`, and Cloudflare Pages serves the bundle
as static files with one fresh module instance per browser tab — so
the bug was theoretical. But the UA-keyed memo costs nothing extra
and removes the foot-gun: a long-lived process now invalidates the
cache automatically when a different UA shows up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four items from gemini and claude reviews:
- botDetect.ts: Memoize isBot() result. navigator.userAgent is immutable
for the page lifetime, so re-running the isbot regex on every Turnstile
pre-mint debounce and post-submit re-mint check is wasted work. SSR
fallback is intentionally not cached so a module instance reused across
SSR/CSR still reaches the real UA check on first CSR call.
- usePackRequest.ts: Disambiguate the "submit-path NOT gated" comment —
it was confusing because the new post-submit re-mint also lives inside
submitRequest's finally. Reworded to "click-path acquireTurnstileToken"
to make clear which call site is intentionally skipped.
- usePackRequest.ts: Update the userTouched comment to reflect autofill
reality — modern Chromium/Firefox DO fire input events on autofill, so
the rationale ("autofill doesn't trigger") was already stale. The new
isBot() guard covers the gap for well-behaved crawler UAs.
- usePackRequest.ts: Add English glosses for the Japanese CF dashboard
labels (提示チャレンジ / 未解決) so non-Japanese-reading maintainers can
follow the comment.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The CF Turnstile dashboard shows ~17k unsolved challenges over the past
7 days vs ~10k solved — a 2:1 unsolved:solved ratio that's larger than
the post-submit auto re-mint can explain. Most of those are JS-executing
crawlers (Slackbot, Discord card validator, Twitter card validator, X,
Apple link preview, Googlebot, etc.) that render the page, somehow trigger
a DOM input event on the URL field (autofill / accessibility tools /
focus tricks), and pay a CF challenge they can't solve.
Add an `isBot()` short-circuit at the two pre-mint call sites in
`usePackRequest`:
- the debounced `onTrigger` after `markUserTouched` flips
- the post-submit auto re-mint in `submitRequest`'s finally block
The actual security gate is the server-side `siteverify` in
`turnstileMiddleware` — that stays the only authoritative check, so a
crawler that spoofs UA past `isBot()` still gets blocked there. The
submit-path `takeToken()` is intentionally NOT gated to avoid
false-positive lockouts of legitimate users with unusual UAs (e.g. older
clients, accessibility tools).
Net effect:
- "提示チャレンジ" / "未解決" CF dashboard counters drop sharply
(well-behaved crawlers stop minting unsolvable tokens)
- `pack_completed` server logs unaffected (legit users don't change
paths; bots couldn't reach `/api/pack` either way)
- server-side spend on siteverify unchanged
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The hi/vi/id translations of the GitHub Actions guide still showed
`actions/checkout@v3`, `actions/setup-node@v3`,
`actions/upload-artifact@v3`, and `softprops/action-gh-release@v1`,
which are stale relative to both the English doc (already on @v4) and
the project's own CI workflows.
Bump these illustrative examples to @v4 (and @v2 for action-gh-release)
to match the English source and avoid pointing readers at deprecated
action majors.
Follow up to commit 042750c which only converted workflow-level
invocations. With Node.js 22 as the floor, the chained scripts inside
each package.json can also use `node --run` directly, dropping the
intermediate npm process when these scripts run.
- root `prepare`
- browser `build-all`, `lint`
- website/client `lint`
- website/server `lint`
- Migrate the build-and-run job in ci.yml to `node --run build --
--sourceMap --declaration` so the inline command matches the
`node --run` style of the test job (claude review round 2 #1)
- Update the hi github-actions.md matrix example from `[22, 24]` to
`[22, 24, 26]` so the doc mirrors the project's actual CI matrix
(gemini-code-assist / coderabbitai inline comment)
- Bump the stale "(Node 20+)" baseline in reviewer-performance.md to
"(Node 22+)" to track the new engines floor (claude review round 2
minor)
Sweep the multilingual website docs for remaining Node.js 20 references
that survived the initial English-only update:
- installation.md: System Requirements `≥ 20.0.0` → `≥ 22.0.0` across
all 12 languages that include this section (incl. en, which was
missed earlier)
- development/index.md: Prerequisites `≥ 20.0.0` / `v20` / `versi 20`
/ `phiên bản 20` → 22 across all 14 languages
- faq.md: programming-language Q&A narrative "Node.js 20 or later" → 22
across all 14 languages
The version digit substitutions are mechanical and identical across
locales, so updating them in this PR keeps the docs consistent with the
new minimum without requiring the usual translation handoff.
Now that the minimum supported Node.js version is 22, `node --run` is
available everywhere. It avoids the npm process-spawn overhead and
matches the style already used in package.json scripts.
Affects all GitHub Actions workflows that invoke npm scripts and the
website/server Dockerfile bundle step. `npm ci` is left as-is since it
is npm-specific.
Node.js 20 reaches end-of-life on 2026-04-30, so raise the minimum
supported version to 22 (the next active LTS) and add Node.js 26 to the
CI matrix as the current release line.
- Bump engines.node to >=22.0.0 in package.json and scripts/memory
- Update CI matrix to [22.x, 24.x, 26.x] (drop 20.x and 25.x; 25.x EOL 2026-06)
- Update test-action.yml matrix to [22, 24, 26]
- Drop the obsolete `node --run` workaround comment in ci.yml since
`node --run` is supported on all matrix versions
- Update Node.js version mentions in English docs, llms-install.md,
configShard, bug report template, and code samples in hi/vi
github-actions guides
Dockerfile (node:22-slim) is intentionally left at the minimum supported
version so the published image confirms Repomix runs on the floor.
Six items from claude's incremental review (`12:48:43Z`):
- monitoring/dashboard.json: Group the outcomes widget by both
`metric.label.outcome` and `metric.label.reason`. Previously all
failures collapsed into a single `turnstile_failed` series, which
contradicted the README claim that the `reason` label drives the
breakdown.
- monitoring/metrics/*.yaml: Narrow the metric filter to
`jsonPayload.event=("turnstile_siteverify" OR "pack_completed")`.
Without this anchor, any future code path attaching
`siteverifyDurationMs` to an unrelated log silently joins the
distribution and creates new metric label values.
- usePackRequest.ts: Mirror `progressMessage.value = null` alongside
the `progressStage.value = null` clear on token-acquisition aborted /
error branches. Prevents a future edit setting a verifying message
from leaking prior-run state.
- turnstile.test.ts: Add a focused `describe` block with five tests
asserting `siteverifyDurationMs` is attached to every post-siteverify
log (one success path + four reject branches). The metric YAML
filters on field presence, so a refactor that drops the field on any
branch would silently break the metric without other tests failing.
Uses the existing `vi.spyOn(logger, ...)` pattern; no clock injection
needed.
- monitoring/README.md: Note that the metric filter pins
`service_name="repomix-server-us"`, so future regions (`-eu`,
`-asia`) silently drop out until the filter is broadened or
per-region counterparts applied.
- monitoring/README.md: Add a `gcloud logging metrics describe` snippet
for verifying a YAML edit was actually applied (gcloud update is
silent on no-op vs effective change).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Six items from gemini, claude initial review, and claude follow-up:
- turnstile.ts: Update misleading comment that claimed the metric filters
on `event=turnstile_siteverify` and `outcome=success`. The actual
Cloud Monitoring metrics in `monitoring/metrics/` filter on
`siteverifyDurationMs` field presence, which uniformly captures both
the parallel success log (event=turnstile_siteverify) and the four
rejectAndLog failure paths (event=pack_completed). The comment
contradicted README and YAML and would mislead future readers.
- turnstile.ts: Wrap rejectAndLog in a local `rejectWithDuration` helper
so every post-siteverify branch automatically carries
`siteverifyDurationMs`. Prevents drift if a fifth reject reason gets
added later.
- client.ts: Split the wire-protocol `PackProgressStage` (server-emitted
SSE values) from the display-only `DisplayProgressStage` superset that
adds `verifying`. Keeping the synthetic stage out of the wire type
prevents silent divergence with the server's `PackProgressStage`.
- usePackRequest.ts, TryItLoading.vue, TryItResult.vue: Switch the
display-side type to `DisplayProgressStage`. `onProgress` callbacks
still take the wire `PackProgressStage`.
- usePackRequest.ts: Clear `progressStage` on token-acquisition failure
branches (aborted / error). Functionally invisible since loading=false
hides the loading UI, but prevents the next submit's verifying flash
from briefly showing the previous run's stale state.
- monitoring/metrics/turnstile_siteverify_duration.yaml: Retune the
exponential bucket layout for the 100ms-1s SLO band where decisions
get made. Doubling buckets only placed ~3 boundaries between 100ms
and 1s; growthFactor=1.5 with scale=10 places ~8 boundaries there.
18 finite buckets cover 10ms to ~9.85s, comfortably above the 5s
siteverify timeout so timeouts don't land in overflow.
- monitoring/README.md: Document that pre-network rejections
(secret_missing, missing_token, token_too_long) intentionally don't
carry siteverifyDurationMs, so they're excluded from both metrics
but still appear in the existing pack_requests metric.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move the metric setup from prose-only README instructions to checked-in
YAML files under `monitoring/metrics/` so the dashboard, the metrics it
depends on, and the apply commands all live next to each other.
- `turnstile_siteverify_duration.yaml`: distribution metric on
`jsonPayload.siteverifyDurationMs`, exponential buckets 1ms-32s.
- `turnstile_siteverify_outcomes.yaml`: counter metric with `outcome`
and `reason` labels for the success-vs-failure breakdown widget.
README updated with the gcloud commands.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wire up two new tiles to surface the `siteverifyDurationMs` field added
in the previous commit:
- "Turnstile siteverify latency P50 / P95 / P99" — line chart with a
1s threshold marker so a steady regression jumps off the chart.
- "Turnstile siteverify outcomes (by outcome)" — stacked area
breaking down success vs turnstile_failed counts over time.
Both depend on log-based metrics `turnstile_siteverify_duration`
(distribution) and `turnstile_siteverify_outcomes` (counter) that need
to be created once in the GCP Console — README documents the filter,
field, and label extractors so the setup is reproducible.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two changes targeting the visible "..." gap between Pack click and the
first SSE progress event observed after PR #1544 landed:
- Client: add a synthetic `verifying` PackProgressStage so the loading
UI displays "Verifying request..." while the server runs Turnstile
siteverify (typically 100-1000ms before the first 'cache-check' SSE
event arrives). The first onProgress callback from handlePackRequest
overwrites it with the real server-reported stage.
- Server: time the siteverify round-trip in `turnstileMiddleware` and
emit `siteverifyDurationMs` on every outcome (success / network
failure / rejected / action mismatch / hostname mismatch). Success
path adds a structured log with `event: turnstile_siteverify` so
Cloud Monitoring can build a log-based distribution metric for
p50/p95/p99 latency and alert on regressions during Cloudflare
incidents.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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=<owner/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) <noreply@anthropic.com>
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 <yamadashy@users.noreply.github.com>
- 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) <noreply@anthropic.com>
- 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) <noreply@anthropic.com>
- 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) <noreply@anthropic.com>
- 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
intent(comment-drift): Claude's review surfaced three stale comments that no longer match the code after pre-warm was narrowed to the script-load step. No behaviour change — the comments were lying about what the code actually does now.
fix(useTurnstileScript-jsdoc): The JSDoc on `TurnstileRenderOptions.execution` claimed `'execute'` was chosen so the pre-warm path could render() without firing a challenge. PR #1541 proved that's not what `'execute'` does in practice — render() itself counts toward the dashboard's challenge counters, regardless of `execution`. Rewrote the comment to explain why we still pass `'execute'` (token-mint guardrail) and why we no longer pre-warm by rendering.
fix(useTurnstile-render-comment): The inline comment at the render() call site said this option is "what makes the pre-warm in setContainer() free of side-effects". setContainer() no longer calls render(), so the rationale is obsolete. Updated to describe the current role: a guardrail against an accidental render() minting a token before getToken() is ready.
fix(useTurnstile-race-comment): The single-flight cache comment said "pre-warm and getToken() can both race past the widgetId.value null check". Pre-warm doesn't enter ensureWidget anymore, so only back-to-back getToken() submits can race. Updated to reflect the narrower scope.
perf(preconnect-crossorigin): Add a `crossorigin` companion to the existing `<link rel="preconnect">`. Turnstile's `api.js` is fetched anonymously, but the Turnstile iframe issues CORS sub-requests on a separate browser connection pool. Without both hints, the iframe's first handshake still happens on click. Cheap defensive addition.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
intent(token-waste): Production telemetry (12-hour Cloudflare dashboard sample) showed 2,620 challenges issued and 986 solved against only ~150-200 actual pack clicks tracked in GA. Despite the widget being configured with `execution: 'execute'`, calling `turnstile.render()` at form-mount time was inflating the dashboard's challenge counters by every visitor — humans, crawlers, ad-blocked browsers, abandoned tabs. Cloudflare's docs say render() should be side-effect free in execute mode, but the analytics disagree.
fix(prewarm-scope): Drop the widget render from `setContainer()`. Pre-warm now only calls `loadTurnstileScript()` so the script is cached before the user clicks; the actual `turnstile.render()` happens on the first `getToken()` call. This restores the documented 1:1 relationship between solved challenges and actual pack submissions.
perf(preconnect): Add `preconnect` and `dns-prefetch` hints to challenges.cloudflare.com so the DNS / TLS / HTTP/2 handshake is warm before the click. Compensates for losing the render() pre-warm — the script load and the challenge round-trip both reuse the warmed connection.
learned(don't-trust-docs-when-telemetry-disagrees): When the dashboard says one thing and the docs say another, the dashboard wins. The previous PR added pre-warm believing render() was inert in execute mode; the analytics showed it wasn't. Going forward, treat any new render() call site as a billable side effect until proved otherwise on the dashboard.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
intent(review): Address CodeRabbit and Gemini feedback on SEO metadata and FAQ wording.
decision(metadata): Remove static per-page OG and Twitter fields so transformHead remains the only source of canonical and page-specific social metadata.
decision(i18n): Correct English Tree-sitter hyphenation and French FAQ wording without changing the FAQ structure.
intent(naming): user clarified that Hermes Agent should be written with its formal product name.
decision(naming): updated README and localized Repomix Explorer Skill docs to use Hermes Agent in prose while keeping the hermes CLI command and ~/.hermes path unchanged.