Files
Kazuki YamadaandClaude Opus 4.7 54c6a3d238 fix(website): Address claude third-pass review on siteverify metric
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>
2026-05-06 21:59:53 +09:00

348 lines
13 KiB
TypeScript

import { computed, onMounted, ref } from 'vue';
import type { DisplayProgressStage, FileInfo, 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. Tuned for the typical paste-then-click
// cadence: long enough that single-keystroke typing doesn't burn a token,
// short enough that a paste-and-click within a normal reaction window
// (~500ms+) usually finds a ready token in the cache.
const PRE_MINT_DEBOUNCE_MS = 300;
export type InputMode = 'url' | 'file' | 'folder';
export function usePackRequest() {
const packOptionsComposable = usePackOptions();
const { packOptions, getPackRequestOptions, resetOptions, applyUrlParameters, DEFAULT_PACK_OPTIONS } =
packOptionsComposable;
const turnstile = useTurnstile();
// Input states
const inputUrl = ref('');
const inputRepositoryUrl = ref('');
const mode = ref<InputMode>('url');
const uploadedFile = ref<File | null>(null);
// True once the user has signalled real intent: typed/pasted a URL,
// uploaded a file/folder, switched modes, or tweaked options. Used to
// gate the Turnstile pre-mint so URL-parameter hydration (`?repo=...`),
// browser autofill, form restoration, and JS-executing link unfurlers
// don't trigger background challenges. Set-only — once true, it stays
// true for the session.
const userTouched = ref(false);
// Request states
const loading = ref(false);
const error = ref<string | null>(null);
const errorType = ref<'error' | 'warning'>('error');
const result = ref<PackResult | null>(null);
const hasExecuted = ref(false);
const progressStage = ref<DisplayProgressStage | null>(null);
const progressMessage = ref<string | null>(null);
// Request controller for cancellation
let requestController: AbortController | null = null;
const TIMEOUT_MS = 30_000;
// Computed validation
const isSubmitValid = computed(() => {
switch (mode.value) {
case 'url':
return !!inputUrl.value && isValidRemoteValue(inputUrl.value.trim());
case 'file':
case 'folder':
return !!uploadedFile.value;
default:
return false;
}
});
function setMode(newMode: InputMode) {
mode.value = newMode;
// Mode tab clicks are unambiguous user interactions, so they're a safe
// intent signal even before any input has been entered.
userTouched.value = true;
}
function handleFileUpload(file: File) {
uploadedFile.value = file;
userTouched.value = true;
}
// Wired to DOM-level input events (paste / IME / drop / typing) by
// TryItUrlInput, and to TryItPackOptions option-change handlers.
// Watching `inputUrl` / `packOptions` directly would also fire on URL-
// parameter hydration in onMounted(), which we want to opt into
// explicitly (see `?repo=` handling in onMounted) rather than implicitly.
function markUserTouched() {
userTouched.value = true;
}
const preMint = usePreMintDebounce({
isSubmitValid,
userTouched,
loading,
onTrigger: () => {
turnstile.preMintToken().catch(() => {
/* errors surface on the actual submit path */
});
},
delayMs: PRE_MINT_DEBOUNCE_MS,
});
function resetRequest() {
error.value = null;
errorType.value = 'error';
result.value = null;
hasExecuted.value = false;
}
async function submitRequest() {
if (!isSubmitValid.value) return;
// Drop any pending pre-mint debounce. Without an explicit clear here a
// debounce that's about to fire *this microtask* could still mint an
// extra token alongside the click path's mint.
preMint.clear();
// Cancel any pending request
if (requestController) {
requestController.abort();
}
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.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.
const controller = requestController;
loading.value = true;
error.value = null;
errorType.value = 'error';
result.value = null;
hasExecuted.value = true;
// Show a meaningful loading step while the server runs Turnstile
// siteverify (typically 100-1000ms before the first SSE 'cache-check'
// event arrives). The first onProgress callback from handlePackRequest
// overwrites this with the real server-reported stage.
progressStage.value = 'verifying';
progressMessage.value = null;
inputRepositoryUrl.value = inputUrl.value;
// Set up automatic timeout
// Use .bind() to avoid capturing the surrounding scope in the closure
const timeoutId = setTimeout(controller.abort.bind(controller, 'timeout'), TIMEOUT_MS);
// 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
// mid-flight (e.g. an old onAbort firing "Request was cancelled" while a
// fresh pack is still loading). Anchoring to the local AbortController
// identity is the cleanest way to detect supersession.
const isCurrent = () => requestController === controller;
// 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;
// Clear progressStage and progressMessage so a subsequent submit's
// brief verifying window doesn't pick up the previous run's stale
// state. Mirrors the initialization at the top of submitRequest.
progressStage.value = null;
progressMessage.value = null;
error.value = abortMessage(tokenResult.reason);
errorType.value = 'warning';
}
return;
}
if (tokenResult.kind === 'error') {
clearTimeout(timeoutId);
if (isCurrent()) {
loading.value = false;
requestController = null;
progressStage.value = null;
progressMessage.value = null;
error.value = tokenResult.message;
errorType.value = 'error';
}
return;
}
const turnstileToken = tokenResult.token;
try {
await handlePackRequest(
mode.value === 'url' ? inputUrl.value : '',
packOptions.format,
getPackRequestOptions.value,
{
onSuccess: (response) => {
if (!isCurrent()) return;
result.value = response;
},
onError: (errorMessage) => {
if (!isCurrent()) return;
error.value = errorMessage;
},
onAbort: (message) => {
if (!isCurrent()) return;
error.value = message;
errorType.value = 'warning';
},
onProgress: (stage, message) => {
if (!isCurrent()) return;
progressStage.value = stage;
progressMessage.value = message ?? null;
},
signal: controller.signal,
file: mode.value === 'file' || mode.value === 'folder' ? uploadedFile.value || undefined : undefined,
turnstileToken,
},
);
} finally {
clearTimeout(timeoutId);
// Only reset shared state if no newer submitRequest() has taken over the
// slot. Without this guard, a slow finally from a cancelled (or
// superseded) request would clobber a fresh in-flight request: setting
// loading=false hides the spinner, and nulling requestController breaks
// a subsequent cancelRequest() call.
if (requestController === controller) {
loading.value = false;
requestController = null;
// Repeat-pack convenience: warm the cache for a likely follow-up
// submission (option tweak + repack, or `repackWithSelectedFiles`
// triggered from the result view). Skipped on abort/cancel since
// the user may have given up, and on invalid form (user may have
// cleared the URL mid-request). userTouched is necessarily true
// here — it was a precondition for isSubmitValid to be true at
// submit start. Failures swallow silently — they surface on the
// next click via takeToken's cold path.
if (!controller.signal.aborted && isSubmitValid.value) {
turnstile.preMintToken().catch(() => {});
}
}
}
}
async function repackWithSelectedFiles(selectedFiles: FileInfo[]) {
if (!result.value || selectedFiles.length === 0) return;
// Generate include patterns from selected files
const selectedPaths = selectedFiles.map((file) => file.path);
const includePatterns = selectedPaths.join(',');
// Temporarily update pack options with include patterns
const originalIncludePatterns = packOptions.includePatterns;
const originalIgnorePatterns = packOptions.ignorePatterns;
packOptions.includePatterns = includePatterns;
packOptions.ignorePatterns = ''; // Clear ignore patterns to ensure selected files are included
try {
// Use the same loading state as normal pack processing
await submitRequest();
// Update file selection state in the new result
if (result.value?.metadata?.allFiles) {
for (const file of result.value.metadata.allFiles) {
file.selected = selectedPaths.includes(file.path);
}
}
} finally {
// Restore original pack options
packOptions.includePatterns = originalIncludePatterns;
packOptions.ignorePatterns = originalIgnorePatterns;
}
}
function cancelRequest() {
if (requestController) {
requestController.abort('cancel');
// The downstream onAbort callback would normally surface the
// "Request was cancelled" warning, but since we're about to null
// requestController the isCurrent() guard inside onAbort treats it
// as stale and skips the message. Set it here directly so the user
// gets immediate feedback.
error.value = 'Request was cancelled.';
errorType.value = 'warning';
requestController = null;
}
loading.value = false;
}
// Apply URL parameters after component mounts
// This must be done here (not during setup) because during SSR/hydration,
// browser globals like `window.location.search` are not available.
// Accessing them before mounting would cause errors in SSR environments.
onMounted(() => {
const urlParams = parseUrlParameters();
// Apply pack options from URL parameters
applyUrlParameters(urlParams);
// 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;
}
});
return {
// Pack options (re-exported for convenience)
...packOptionsComposable,
// Input states
inputUrl,
inputRepositoryUrl,
mode,
uploadedFile,
// Request states
loading,
error,
errorType,
result,
hasExecuted,
progressStage,
progressMessage,
// Computed
isSubmitValid,
// Actions
setMode,
handleFileUpload,
resetRequest,
submitRequest,
repackWithSelectedFiles,
cancelRequest,
markUserTouched,
// Turnstile widget container (Vue ref callback consumer)
setTurnstileContainer: turnstile.setContainer,
// Pack option actions
resetOptions,
DEFAULT_PACK_OPTIONS,
};
}