213 lines
6.0 KiB
TypeScript
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 };
|