UNPKG

wxt

Version:

⚡ Next-gen Web Extension Framework

177 lines (176 loc) 5.42 kB
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)); } }