wxt
Version:
⚡ Next-gen Web Extension Framework
177 lines (176 loc) • 5.42 kB
JavaScript
import { browser } from "wxt/browser";
import { logger } from "../utils/internal/logger.mjs";
import {
getUniqueEventName
} from "./internal/custom-events.mjs";
import { createLocationWatcher } from "./internal/location-watcher.mjs";
export class ContentScriptContext {
constructor(contentScriptName, options) {
this.contentScriptName = contentScriptName;
this.options = options;
this.abortController = new AbortController();
if (this.isTopFrame) {
this.listenForNewerScripts({ ignoreFirstEvent: true });
this.stopOldScripts();
} else {
this.listenForNewerScripts();
}
}
static SCRIPT_STARTED_MESSAGE_TYPE = getUniqueEventName(
"wxt:content-script-started"
);
isTopFrame = window.self === window.top;
abortController;
locationWatcher = createLocationWatcher(this);
receivedMessageIds = /* @__PURE__ */ new Set();
get signal() {
return this.abortController.signal;
}
abort(reason) {
return this.abortController.abort(reason);
}
get isInvalid() {
if (browser.runtime.id == null) {
this.notifyInvalidated();
}
return this.signal.aborted;
}
get isValid() {
return !this.isInvalid;
}
/**
* Add a listener that is called when the content script's context is invalidated.
*
* @returns A function to remove the listener.
*
* @example
* browser.runtime.onMessage.addListener(cb);
* const removeInvalidatedListener = ctx.onInvalidated(() => {
* browser.runtime.onMessage.removeListener(cb);
* })
* // ...
* removeInvalidatedListener();
*/
onInvalidated(cb) {
this.signal.addEventListener("abort", cb);
return () => this.signal.removeEventListener("abort", cb);
}
/**
* Return a promise that never resolves. Useful if you have an async function that shouldn't run
* after the context is expired.
*
* @example
* const getValueFromStorage = async () => {
* if (ctx.isInvalid) return ctx.block();
*
* // ...
* }
*/
block() {
return new Promise(() => {
});
}
/**
* Wrapper around `window.setInterval` that automatically clears the interval when invalidated.
*
* Intervals can be cleared by calling the normal `clearInterval` function.
*/
setInterval(handler, timeout) {
const id = setInterval(() => {
if (this.isValid) handler();
}, timeout);
this.onInvalidated(() => clearInterval(id));
return id;
}
/**
* Wrapper around `window.setTimeout` that automatically clears the interval when invalidated.
*
* Timeouts can be cleared by calling the normal `setTimeout` function.
*/
setTimeout(handler, timeout) {
const id = setTimeout(() => {
if (this.isValid) handler();
}, timeout);
this.onInvalidated(() => clearTimeout(id));
return id;
}
/**
* Wrapper around `window.requestAnimationFrame` that automatically cancels the request when
* invalidated.
*
* Callbacks can be canceled by calling the normal `cancelAnimationFrame` function.
*/
requestAnimationFrame(callback) {
const id = requestAnimationFrame((...args) => {
if (this.isValid) callback(...args);
});
this.onInvalidated(() => cancelAnimationFrame(id));
return id;
}
/**
* Wrapper around `window.requestIdleCallback` that automatically cancels the request when
* invalidated.
*
* Callbacks can be canceled by calling the normal `cancelIdleCallback` function.
*/
requestIdleCallback(callback, options) {
const id = requestIdleCallback((...args) => {
if (!this.signal.aborted) callback(...args);
}, options);
this.onInvalidated(() => cancelIdleCallback(id));
return id;
}
addEventListener(target, type, handler, options) {
if (type === "wxt:locationchange") {
if (this.isValid) this.locationWatcher.run();
}
target.addEventListener?.(
type.startsWith("wxt:") ? getUniqueEventName(type) : type,
handler,
{
...options,
signal: this.signal
}
);
}
/**
* @internal
* Abort the abort controller and execute all `onInvalidated` listeners.
*/
notifyInvalidated() {
this.abort("Content script context invalidated");
logger.debug(
`Content script "${this.contentScriptName}" context invalidated`
);
}
stopOldScripts() {
window.postMessage(
{
type: ContentScriptContext.SCRIPT_STARTED_MESSAGE_TYPE,
contentScriptName: this.contentScriptName,
messageId: Math.random().toString(36).slice(2)
},
"*"
);
}
verifyScriptStartedEvent(event) {
const isScriptStartedEvent = event.data?.type === ContentScriptContext.SCRIPT_STARTED_MESSAGE_TYPE;
const isSameContentScript = event.data?.contentScriptName === this.contentScriptName;
const isNotDuplicate = !this.receivedMessageIds.has(event.data?.messageId);
return isScriptStartedEvent && isSameContentScript && isNotDuplicate;
}
listenForNewerScripts(options) {
let isFirst = true;
const cb = (event) => {
if (this.verifyScriptStartedEvent(event)) {
this.receivedMessageIds.add(event.data.messageId);
const wasFirst = isFirst;
isFirst = false;
if (wasFirst && options?.ignoreFirstEvent) return;
this.notifyInvalidated();
}
};
addEventListener("message", cb);
this.onInvalidated(() => removeEventListener("message", cb));
}
}