213 lines
6.0 KiB
TypeScript

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<string, { fingerprint: string; success: boolean }>();
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<T extends History["pushState"] | History["replaceState"]>(
originalMethod: T
) {
return ((...args: Parameters<T>) => {
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<typeof createContentController>;
};
if (scopedWindow[marker]) {
return;
}
scopedWindow[marker] = createContentController({
chromeRuntime: runtime,
document,
logger: console,
window
});
}
bootstrapContentScript();
export { RESULT_MESSAGE_TYPE };