From 1c3ba32cc958c0523b925d7475cacf0aa13136f8 Mon Sep 17 00:00:00 2001 From: wanhose Date: Sat, 5 Oct 2024 17:21:50 +0200 Subject: [PATCH] feat(browser-extension): add request batching, issue details fetching and minor refactors to scripts --- .../src/scripts/background.js | 206 ++++++++++++++---- .../browser-extension/src/scripts/content.js | 34 +-- .../browser-extension/src/scripts/options.js | 16 +- 3 files changed, 195 insertions(+), 61 deletions(-) diff --git a/packages/browser-extension/src/scripts/background.js b/packages/browser-extension/src/scripts/background.js index 069ddc2..362111d 100644 --- a/packages/browser-extension/src/scripts/background.js +++ b/packages/browser-extension/src/scripts/background.js @@ -1,7 +1,49 @@ +/** + * @typedef {Object} ExtensionIssue + * @property {number} [expiresIn] + * @property {string[]} [flags] + * @property {string} [url] + */ + +/** + * @typedef {Object} ExtensionState + * @property {ExtensionIssue} issue + * @property {boolean} on + */ + if (typeof browser === 'undefined') { browser = chrome; } +/** + * @description Class for request batching + */ +class RequestManager { + constructor() { + this.requests = new Map(); // Store ongoing requests + } + + /** + * @description Fetch wrapper to play with the request map + * @param {string} input + * @param {RequestInit} [init] + * @returns {Promise} + */ + fetchData(input, init) { + if (this.requests.has(input)) { + return this.requests.get(input); + } + + const promise = fetch(input, init) + .then((response) => response.json()) + .finally(() => this.requests.delete(input)); + + this.requests.set(input, promise); + + return promise; + } +} + /** * @description API URL * @type {string} @@ -20,6 +62,11 @@ const extensionMenuItemId = 'CDM-MENU'; */ const reportMenuItemId = 'CDM-REPORT'; +/** + * @description Request manager instance + */ +const requestManager = new RequestManager(); + /** * @description Context menu identifier * @type {string} @@ -32,6 +79,12 @@ const settingsMenuItemId = 'CDM-SETTINGS'; */ const script = browser.scripting; +/** + * @description Default value for extension state + * @type {ExtensionState} + */ +const stateByDefault = { issue: { expiresIn: 0 }, on: true }; + /** * @description The storage to use * @type {browser.storage.LocalStorageArea} @@ -43,6 +96,34 @@ const storage = browser.storage.local; */ const suppressLastError = () => void browser.runtime.lastError; +/** + * @async + * @description Enable extension icon + * @param {number} tabId + * @returns {Promise} + */ +async function enableIcon(hostname, tabId) { + const state = await getState(hostname); + const path = state.issue.url ? '/assets/icons/warn.png' : '/assets/icons/on.png'; + + await browser.action.setIcon({ path, tabId }, suppressLastError); +} + +/** + * @async + * @description Get database + * @returns {Promise} + */ +async function getData() { + const { data } = await storage.get('data'); + + if (!data) { + return await refreshData(); + } + + return data; +} + /** * @description Calculate current hostname * @param {string} url @@ -52,6 +133,22 @@ function getHostname(url) { return new URL(url).hostname.split('.').slice(-3).join('.').replace('www.', ''); } +/** + * @async + * @description Get state for the given hostname + * @param {string} hostname + * @returns {Promise} + */ +async function getState(hostname) { + const { [hostname]: state = stateByDefault } = await storage.get(hostname); + + if ((state.issue && Date.now() > state.issue.expiresIn) || !state.issue?.expiresIn) { + state.issue = await refreshIssue(hostname); + } + + return state; +} + /** * @description Format number to avoid errors * @param {number} [value] @@ -81,20 +178,45 @@ function matchToPattern(match) { } /** + * @async * @description Refresh data - * @param {void?} callback - * @returns {void} + * @returns {Promise} */ -function refreshData(callback) { +async function refreshData() { try { - fetch(`${apiUrl}/data/`).then((result) => { - result.json().then(({ data }) => { - storage.set({ data }, suppressLastError); - callback?.(data); - }); - }); + const { data } = await requestManager.fetchData(`${apiUrl}/data/`); + + await triggerStoreUpdate('data', data); + + return data; } catch { - refreshData(callback); + return await refreshData(); + } +} + +/** + * @async + * @description Refresh issues for the given hostname + * @param {string} hostname + * @returns {Promise} + */ +async function refreshIssue(hostname) { + try { + const { data } = await requestManager.fetchData(`${apiUrl}/issues/${hostname}`); + + if (Object.keys(data).length === 0) { + await triggerStoreUpdate(hostname, { issue: { expiresIn: Date.now() + 8 * 60 * 60 * 1000 } }); + + return undefined; + } + + const issue = { expiresIn: Date.now() + 4 * 60 * 60 * 1000, flags: data.flags, url: data.url }; + + await triggerStoreUpdate(hostname, { issue }); + + return data; + } catch { + return await refreshData(); } } @@ -104,9 +226,9 @@ function refreshData(callback) { * @param {any} message * @param {browser.tabs.Tab} tab * @param {void?} callback - * @returns {void} + * @returns {Promise} */ -async function report(message, tab, callback) { +async function report(message) { try { const reason = message.reason; const url = message.url; @@ -114,14 +236,29 @@ async function report(message, tab, callback) { const version = browser.runtime.getManifest().version; const body = JSON.stringify({ reason, url, userAgent, version }); const headers = { 'Cache-Control': 'no-cache', 'Content-type': 'application/json' }; + const requestInit = { body, headers, method: 'POST' }; - const response = await fetch(`${apiUrl}/report/`, { body, headers, method: 'POST' }); - callback?.((await response.json()).data); + return (await requestManager.fetchData(`${apiUrl}/report/`, requestInit)).data; } catch { console.error("Can't send report"); } } +/** + * @async + * @description Update extension store for a given key + * @param {string} [key] + * @param {Object} value + * @returns {Promise} + */ +async function triggerStoreUpdate(key, value) { + if (key) { + const { [key]: prev } = await storage.get(key); + + await storage.set({ [key]: { ...prev, ...value } }, suppressLastError); + } +} + /** * @description Listen to context menus clicked */ @@ -153,13 +290,11 @@ browser.runtime.onMessage.addListener((message, sender, callback) => { switch (message.type) { case 'DISABLE_ICON': if (isPage && tabId !== undefined) { - browser.action.setIcon({ path: '/assets/icons/disabled.png', tabId }, suppressLastError); + browser.action.setIcon({ path: '/assets/icons/off.png', tabId }, suppressLastError); } break; case 'ENABLE_ICON': - if (isPage && tabId !== undefined) { - browser.action.setIcon({ path: '/assets/icons/enabled.png', tabId }, suppressLastError); - } + if (isPage && tabId !== undefined) enableIcon(hostname, tabId); break; case 'ENABLE_POPUP': if (isPage && tabId !== undefined) { @@ -167,24 +302,19 @@ browser.runtime.onMessage.addListener((message, sender, callback) => { } break; case 'GET_DATA': - storage.get('data', ({ data }) => { - if (data) callback(data); - else refreshData(callback); - }); + getData().then(callback); return true; case 'GET_EXCLUSION_LIST': storage.get(null, (exclusions) => { const exclusionList = Object.entries(exclusions || {}).flatMap((exclusion) => - exclusion[0] !== 'data' && !exclusion[1]?.enabled ? [exclusion[0]] : [] + exclusion[0] !== 'data' && !exclusion[1]?.on ? [exclusion[0]] : [] ); callback(exclusionList); }); return true; - case 'GET_HOSTNAME_STATE': + case 'GET_STATE': if (hostname) { - storage.get(hostname, (state) => { - callback(state[hostname] ?? { enabled: true }); - }); + getState(hostname).then(callback); return true; } break; @@ -199,25 +329,22 @@ browser.runtime.onMessage.addListener((message, sender, callback) => { } break; case 'REFRESH_DATA': - refreshData(callback); + refreshData().then(callback); return true; case 'REPORT': if (tabId !== undefined) { - report(message, sender.tab, callback); + report(message).then(callback); return true; } break; - case 'SET_BADGE': + case 'UPDATE_BADGE': if (isPage && tabId !== undefined) { browser.action.setBadgeBackgroundColor({ color: '#6b7280' }); browser.action.setBadgeText({ tabId, text: formatNumber(message.value) }); } break; - case 'SET_HOSTNAME_STATE': - if (hostname) { - if (message.state.enabled === false) storage.set({ [hostname]: message.state }); - else storage.remove(hostname); - } + case 'UPDATE_STORE': + triggerStoreUpdate(hostname, message.state); break; default: break; @@ -285,8 +412,9 @@ browser.webRequest.onBeforeRequest.addListener( return; } + const data = await getData(); const hostname = getHostname(url); - const { data, [hostname]: state = { enabled: true } } = await storage.get(['data', hostname]); + const state = await getState(hostname); if (data?.rules?.length) { const rules = data.rules.map((rule) => ({ @@ -295,7 +423,7 @@ browser.webRequest.onBeforeRequest.addListener( })); await browser.declarativeNetRequest.updateSessionRules({ - addRules: state.enabled ? rules : undefined, + addRules: state.on ? rules : undefined, removeRuleIds: data.rules.map((rule) => rule.id), }); } @@ -313,9 +441,9 @@ browser.webRequest.onErrorOccurred.addListener( if (error === 'net::ERR_BLOCKED_BY_CLIENT' && tabId > -1) { const hostname = getHostname(url); - const { [hostname]: state = { enabled: true } } = await storage.get(hostname); + const state = await getState(hostname); - if (state.enabled) { + if (state.on) { await browser.tabs.sendMessage(tabId, { type: 'INCREASE_ACTIONS_COUNT' }); } } diff --git a/packages/browser-extension/src/scripts/content.js b/packages/browser-extension/src/scripts/content.js index 5f4a0fc..582a4c0 100644 --- a/packages/browser-extension/src/scripts/content.js +++ b/packages/browser-extension/src/scripts/content.js @@ -6,6 +6,12 @@ * @property {{ backdrops: string[], classes: string[], containers: string[], selectors: string[] }} tokens */ +/** + * @typedef {Object} ExtensionState + * @property {boolean} on + * @property {ExtensionIssue} [issue] + */ + /** * @typedef {Object} Fix * @property {string} action @@ -91,9 +97,9 @@ const seen = new Set(); /** * @description Extension state - * @type {{ enabled: boolean }} + * @type {ExtensionState} */ -let state = { enabled: true }; +let state = { on: true }; /** * @description Clean DOM @@ -115,8 +121,8 @@ function clean(elements, skipMatch) { if (element instanceof HTMLDialogElement) element.close(); hide(element); - actions.add(new Date().getTime().toString()); - dispatch({ type: 'SET_BADGE', value: actions.size }); + actions.add(`${Date.now()}`); + dispatch({ type: 'UPDATE_BADGE', value: actions.size }); } seen.add(element); @@ -295,7 +301,7 @@ function fix() { for (const backdrop of backdrops) { if (backdrop.children.length === 0 && !seen.has(backdrop)) { - actions.add(new Date().getTime().toString()); + actions.add(`${Date.now()}`); seen.add(backdrop); hide(backdrop); } @@ -366,7 +372,7 @@ function fix() { t4Wrapper.removeAttribute('inert'); } - dispatch({ type: 'SET_BADGE', value: actions.size }); + dispatch({ type: 'UPDATE_BADGE', value: actions.size }); } /** @@ -390,7 +396,7 @@ function hide(element) { function run(params = {}) { const { containers, elements, skipMatch } = params; - if (document.body?.children.length && state.enabled && tokens.selectors.length) { + if (document.body?.children.length && state.on && tokens.selectors.length) { fix(); if (elements?.length) { @@ -409,10 +415,10 @@ function run(params = {}) { * @param {SetUpParams} [params] */ async function setUp(params = {}) { - state = (await dispatch({ hostname, type: 'GET_HOSTNAME_STATE' })) ?? state; + state = (await dispatch({ hostname, type: 'GET_STATE' })) ?? state; dispatch({ type: 'ENABLE_POPUP' }); - if (state.enabled) { + if (state.on) { const data = await dispatch({ hostname, type: 'GET_DATA' }); commonWords = data?.commonWords ?? commonWords; @@ -420,13 +426,13 @@ async function setUp(params = {}) { skips = data?.skips ?? skips; tokens = data?.tokens ?? tokens; - dispatch({ type: 'ENABLE_ICON' }); - dispatch({ type: 'SET_BADGE', value: actions.size }); + dispatch({ hostname, type: 'ENABLE_ICON' }); + dispatch({ type: 'UPDATE_BADGE', value: actions.size }); observer.observe(document.body ?? document.documentElement, options); if (!params.skipRunFn) run({ containers: tokens.containers }); } else { dispatch({ type: 'DISABLE_ICON' }); - dispatch({ type: 'SET_BADGE', value: actions.size }); + dispatch({ type: 'UPDATE_BADGE', value: actions.size }); observer.disconnect(); } } @@ -449,7 +455,7 @@ async function setUpAfterWaitForBody() { * @type {MutationObserver} */ const observer = new MutationObserver((mutations) => { - if (!state.enabled || !tokens.selectors.length) { + if (!state.on || !tokens.selectors.length) { return; } @@ -466,7 +472,7 @@ const observer = new MutationObserver((mutations) => { browser.runtime.onMessage.addListener(async (message) => { switch (message.type) { case 'INCREASE_ACTIONS_COUNT': { - actions.add(new Date().getTime().toString()); + actions.add(`${Date.now()}`); break; } } diff --git a/packages/browser-extension/src/scripts/options.js b/packages/browser-extension/src/scripts/options.js index 619772f..3e5247b 100644 --- a/packages/browser-extension/src/scripts/options.js +++ b/packages/browser-extension/src/scripts/options.js @@ -62,8 +62,8 @@ async function handleAddClick() { if (exclusionValue?.trim() && (domainRx.test(exclusionValue) || exclusionValue === 'localhost')) { const filterInputElement = document.getElementById('filter-input'); - const state = { enabled: false }; - await dispatch({ hostname: exclusionValue, state, type: 'SET_HOSTNAME_STATE' }); + const state = { on: false }; + await dispatch({ hostname: exclusionValue, state, type: 'UPDATE_STORE' }); exclusionList = [...new Set([...exclusionList, exclusionValue])].sort(); createList(); @@ -80,8 +80,8 @@ async function handleClearClick() { const filterInputElement = document.getElementById('filter-input'); for (const exclusionValue of exclusionList) { - const state = { enabled: true }; - await dispatch({ hostname: exclusionValue, state, type: 'SET_HOSTNAME_STATE' }); + const state = { on: true }; + await dispatch({ hostname: exclusionValue, state, type: 'UPDATE_STORE' }); } exclusionList = []; @@ -128,9 +128,9 @@ async function handleDeleteClick(event) { const filterInputElement = document.getElementById('filter-input'); const { value } = event.currentTarget.parentElement.dataset; const itemElement = document.querySelector(`[data-value="${value}"]`); - const state = { enabled: true }; + const state = { on: true }; - await dispatch({ hostname: value, state, type: 'SET_HOSTNAME_STATE' }); + await dispatch({ hostname: value, state, type: 'UPDATE_STORE' }); exclusionList = exclusionList.filter((exclusionValue) => exclusionValue !== value); itemElement?.remove(); updateList(filterInputElement.value.trim()); @@ -172,8 +172,8 @@ function handleFileChange(event) { const newExclusionList = event.currentTarget.result.split('\n').filter((x) => x.trim()); for (const exclusionValue of newExclusionList) { - const state = { enabled: false }; - await dispatch({ hostname: exclusionValue, state, type: 'SET_HOSTNAME_STATE' }); + const state = { on: false }; + await dispatch({ hostname: exclusionValue, state, type: 'UPDATE_STORE' }); } if (newExclusionList.length) {