import { createRouteState } from "./route-state"; import { isCandidateAnalysisMessage, isCandidateRequestMessage, isHookReadyMessage, isAfterSearchRateResultMessage, RESULT_MESSAGE_TYPE } from "../shared/message-types"; import type { AfterSearchRateResult } from "../shared/result-types"; const LOG_PREFIX = "[star-chart-search-enhancer]"; const PAGE_HOOK_SCRIPT_ID = "star-chart-search-enhancer-page-hook"; interface ChromeRuntimeLike { getURL(path: string): string; } interface LoggerLike { debug(...args: unknown[]): void; info(...args: unknown[]): void; warn(...args: unknown[]): void; } interface ContentControllerOptions { chromeRuntime: ChromeRuntimeLike; document: Document; logger: LoggerLike; window: Window; } export function createContentController(options: ContentControllerOptions) { const routeState = createRouteState(options.window.location.href); let currentSnapshot = routeState.getSnapshot(); const loggedResults = new Map(); const originalPushState = options.window.history.pushState.bind( options.window.history ); const originalReplaceState = options.window.history.replaceState.bind( options.window.history ); const onMessage = (event: MessageEvent) => { if (event.source !== options.window) { return; } if (isHookReadyMessage(event.data)) { if (isSameRouteIdentity(event.data.payload.routeKey, currentSnapshot.routeKey)) { options.logger.info(LOG_PREFIX, "hook-ready", event.data.payload); } else { options.logger.debug( LOG_PREFIX, "stale-hook-ready", event.data.payload.routeKey ); } return; } if (isCandidateRequestMessage(event.data)) { if (isSameRouteIdentity(event.data.payload.routeKey, currentSnapshot.routeKey)) { options.logger.info(LOG_PREFIX, "candidate-request", event.data.payload); } else { options.logger.debug( LOG_PREFIX, "stale-candidate-request", event.data.payload.routeKey ); } return; } if (isCandidateAnalysisMessage(event.data)) { if (isSameRouteIdentity(event.data.payload.routeKey, currentSnapshot.routeKey)) { options.logger.info(LOG_PREFIX, "candidate-analysis", event.data.payload); } else { options.logger.debug( LOG_PREFIX, "stale-candidate-analysis", event.data.payload.routeKey ); } return; } if (!isAfterSearchRateResultMessage(event.data)) { return; } const payload = event.data.payload; if (!isSameRouteIdentity(payload.routeKey, currentSnapshot.routeKey)) { options.logger.debug(LOG_PREFIX, "stale-result", payload.routeKey); return; } logFinalResult(payload); }; options.window.addEventListener("message", onMessage); options.window.history.pushState = wrapHistoryMethod(originalPushState); options.window.history.replaceState = wrapHistoryMethod(originalReplaceState); options.window.addEventListener("popstate", handleNavigation); injectPageHook(options.document, options.chromeRuntime); options.logger.info(LOG_PREFIX, "route", currentSnapshot); return { dispose() { options.window.removeEventListener("message", onMessage); options.window.removeEventListener("popstate", handleNavigation); options.window.history.pushState = originalPushState; options.window.history.replaceState = originalReplaceState; }, getSnapshot() { return currentSnapshot; } }; function wrapHistoryMethod( originalMethod: T ) { return ((...args: Parameters) => { const result = originalMethod(...args); handleNavigation(); return result; }) as T; } function handleNavigation() { currentSnapshot = routeState.advance(options.window.location.href); options.logger.info(LOG_PREFIX, "route", currentSnapshot); } function logFinalResult(payload: AfterSearchRateResult) { const fingerprint = JSON.stringify({ matchedRequestUrl: payload.matchedRequestUrl ?? null, rates: payload.rates ?? null, reason: payload.reason ?? null, routeKey: payload.routeKey, stage: payload.stage, success: payload.success }); const previousResult = loggedResults.get(payload.routeKey); if (previousResult?.fingerprint === fingerprint) { return; } if (previousResult?.success && !payload.success) { return; } loggedResults.set(payload.routeKey, { fingerprint, success: payload.success }); options.logger.info(LOG_PREFIX, "result", payload); } } function isSameRouteIdentity(leftRouteKey: string, rightRouteKey: string): boolean { return stripNavigationSeq(leftRouteKey) === stripNavigationSeq(rightRouteKey); } function stripNavigationSeq(routeKey: string): string { return routeKey.replace(/::\d+$/, ""); } function injectPageHook(document: Document, chromeRuntime: ChromeRuntimeLike) { if (document.getElementById(PAGE_HOOK_SCRIPT_ID)) { return; } const script = document.createElement("script"); script.id = PAGE_HOOK_SCRIPT_ID; script.src = chromeRuntime.getURL("page/hook.global.js"); script.async = false; (document.head ?? document.documentElement).appendChild(script); } function bootstrapContentScript() { const runtime = ( globalThis as typeof globalThis & { chrome?: { runtime?: ChromeRuntimeLike }; } ).chrome?.runtime; if (!runtime || typeof window === "undefined" || typeof document === "undefined") { return; } const marker = "__starChartSearchEnhancerContentController"; const scopedWindow = window as Window & { [marker]?: ReturnType; }; if (scopedWindow[marker]) { return; } scopedWindow[marker] = createContentController({ chromeRuntime: runtime, document, logger: console, window }); } bootstrapContentScript(); export { RESULT_MESSAGE_TYPE };