diff --git a/packages/adblocker-electron-example/index.ts b/packages/adblocker-electron-example/index.ts index 92e2966db7..5e08cfeaa1 100644 --- a/packages/adblocker-electron-example/index.ts +++ b/packages/adblocker-electron-example/index.ts @@ -1,8 +1,15 @@ -import { app, BrowserWindow } from 'electron'; import fetch from 'cross-fetch'; +import { app, BrowserWindow } from 'electron'; import { readFileSync, writeFileSync } from 'fs'; -import { ElectronBlocker, fullLists, Request } from '@cliqz/adblocker-electron'; +import { + CosmeticFilter, + ElectronBlocker, + fullLists, + NetworkFilter, + Request, +} from '@cliqz/adblocker-electron'; +import { MatchingContext } from '@cliqz/adblocker'; function getUrlToLoad(): string { let url = 'https://google.com'; @@ -41,6 +48,10 @@ async function createWindow() { blocker.enableBlockingInSession(mainWindow.webContents.session); + blocker.on('request-allowed', (request: Request) => { + console.log('allow', request.tabId, request.url); + }); + blocker.on('request-blocked', (request: Request) => { console.log('blocked', request.tabId, request.url); }); @@ -53,8 +64,8 @@ async function createWindow() { console.log('whitelisted', request.tabId, request.url); }); - blocker.on('csp-injected', (request: Request) => { - console.log('csp', request.url); + blocker.on('csp-injected', (csps: string, request: Request) => { + console.log('csp', csps, request.url); }); blocker.on('script-injected', (script: string, url: string) => { @@ -65,6 +76,13 @@ async function createWindow() { console.log('style', style.length, url); }); + blocker.on( + 'filter-matched', + (filter: CosmeticFilter | NetworkFilter, context: MatchingContext) => { + console.log('filter-matched', filter, context); + }, + ); + mainWindow.loadURL(getUrlToLoad()); mainWindow.webContents.openDevTools(); diff --git a/packages/adblocker-electron/adblocker.ts b/packages/adblocker-electron/adblocker.ts index c743c7a8a8..bdb07a4222 100644 --- a/packages/adblocker-electron/adblocker.ts +++ b/packages/adblocker-electron/adblocker.ts @@ -186,6 +186,11 @@ export class ElectronBlocker extends FiltersEngine { getExtendedRules: true, getRulesFromHostname: true, getRulesFromDOM: false, // Only done on updates (see `onGetCosmeticFiltersUpdated`) + + callerContext: { + frameId: event.frameId, + processId: event.processId, + }, }); if (active === false) { @@ -233,6 +238,13 @@ export class ElectronBlocker extends FiltersEngine { // This will be done every time we get information about DOM mutation getRulesFromDOM: true, + + callerContext: { + frameId: event.frameId, + processId: event.processId, + + lifecycle: msg.lifecycle, + }, }); if (active === false) { diff --git a/packages/adblocker-playwright-example/index.ts b/packages/adblocker-playwright-example/index.ts index c8bdbf5e09..0d81067c8f 100644 --- a/packages/adblocker-playwright-example/index.ts +++ b/packages/adblocker-playwright-example/index.ts @@ -17,6 +17,10 @@ import * as pw from 'playwright'; await blocker.enableBlockingInPage(page); + blocker.on('request-allowed', (request: Request) => { + console.log('allow', request.url); + }); + blocker.on('request-blocked', (request: Request) => { console.log('blocked', request.url); }); @@ -29,19 +33,26 @@ import * as pw from 'playwright'; console.log('whitelisted', request.url); }); - blocker.on('csp-injected', (request: Request) => { - console.log('csp', request.url); + blocker.on('csp-injected', (csps: string, request: Request) => { + console.log('csp', request.url, csps); }); blocker.on('script-injected', (script: string, url: string) => { - console.log('script', script.length, url); + console.log('script', url, script.length); }); blocker.on('style-injected', (style: string, url: string) => { - console.log('style', style.length, url); + console.log('style', url, style.length); }); - await page.goto('https://www.mangareader.to/'); + blocker.on( + 'filter-matched', + (filter: CosmeticFilter | NetworkFilter, context: MatchingContext) => { + console.log('filter-matched', filter, context); + }, + ); + + await page.goto('https://www.mangareader.net/'); await page.screenshot({ path: 'output.png' }); await blocker.disableBlockingInPage(page); await browser.close(); diff --git a/packages/adblocker-puppeteer-example/index.ts b/packages/adblocker-puppeteer-example/index.ts index 414c7e5cfc..015406400e 100644 --- a/packages/adblocker-puppeteer-example/index.ts +++ b/packages/adblocker-puppeteer-example/index.ts @@ -1,7 +1,7 @@ import { fullLists, PuppeteerBlocker, Request } from '@cliqz/adblocker-puppeteer'; import fetch from 'cross-fetch'; +import fs from 'fs/promises'; import * as puppeteer from 'puppeteer'; -import { promises as fs } from 'fs'; function getUrlToLoad(): string { let url = 'https://www.mangareader.to/'; @@ -35,6 +35,10 @@ function getUrlToLoad(): string { const page = await browser.newPage(); await blocker.enableBlockingInPage(page); + blocker.on('request-allowed', (request: Request) => { + console.log('allow', request.url); + }); + blocker.on('request-blocked', (request: Request) => { console.log('blocked', request.url); }); @@ -47,17 +51,24 @@ function getUrlToLoad(): string { console.log('whitelisted', request.url); }); - blocker.on('csp-injected', (request: Request) => { - console.log('csp', request.url); + blocker.on('csp-injected', (csps: string, request: Request) => { + console.log('csp', request.url, csps.length); }); blocker.on('script-injected', (script: string, url: string) => { - console.log('script', script.length, url); + console.log('script', url, script.length); }); blocker.on('style-injected', (style: string, url: string) => { - console.log('style', style.length, url); + console.log('style', url, style.length); }); + blocker.on( + 'filter-matched', + (filter: CosmeticFilter | NetworkFilter, context: MatchingContext) => { + console.log('filter-matched', filter, context); + }, + ); + await page.goto(getUrlToLoad()); })(); diff --git a/packages/adblocker-webextension-example/background.ts b/packages/adblocker-webextension-example/background.ts index a269233fb8..6603121e17 100644 --- a/packages/adblocker-webextension-example/background.ts +++ b/packages/adblocker-webextension-example/background.ts @@ -10,11 +10,14 @@ import { browser } from 'webextension-polyfill-ts'; import { BlockingResponse, + CosmeticFilter, fullLists, HTMLSelector, + NetworkFilter, Request, WebExtensionBlocker, } from '@cliqz/adblocker-webextension'; +import { MatchingContext } from '@cliqz/adblocker'; /** * Keep track of number of network requests altered for each tab @@ -62,6 +65,11 @@ WebExtensionBlocker.fromLists(fetch, fullLists, { }).then((blocker: WebExtensionBlocker) => { blocker.enableBlockingInBrowser(browser); + blocker.on('request-allowed', (request: Request, result: BlockingResponse) => { + incrementBlockedCounter(request, result); + console.log('allow', request.url); + }); + blocker.on('request-blocked', (request: Request, result: BlockingResponse) => { incrementBlockedCounter(request, result); console.log('block', request.url); @@ -72,21 +80,33 @@ WebExtensionBlocker.fromLists(fetch, fullLists, { console.log('redirect', request.url, result); }); - blocker.on('csp-injected', (request: Request) => { - console.log('csp', request.url); + blocker.on('request-whitelisted', (request: Request, result: BlockingResponse) => { + incrementBlockedCounter(request, result); + console.log('whitelist', request.url, result); + }); + + blocker.on('html-filtered', (htmlSelectors: HTMLSelector[], url: string) => { + console.log('html selectors', url, htmlSelectors); + }); + + blocker.on('csp-injected', (csps: string, request: Request) => { + console.log('csp', request.url, csps.length); }); blocker.on('script-injected', (script: string, url: string) => { - console.log('script', script.length, url); + console.log('script', url, script.length); }); blocker.on('style-injected', (style: string, url: string) => { console.log('style', url, style.length); }); - blocker.on('html-filtered', (htmlSelectors: HTMLSelector[]) => { - console.log('html selectors', htmlSelectors); - }); + blocker.on( + 'filter-matched', + (filter: CosmeticFilter | NetworkFilter, context: MatchingContext) => { + console.log('filter-matched', filter, context); + }, + ); console.log('Ready to roll!'); }); diff --git a/packages/adblocker-webextension/adblocker.ts b/packages/adblocker-webextension/adblocker.ts index dc788548b3..afeab74deb 100644 --- a/packages/adblocker-webextension/adblocker.ts +++ b/packages/adblocker-webextension/adblocker.ts @@ -347,6 +347,11 @@ export class WebExtensionBlocker extends FiltersEngine { getExtendedRules: false, getRulesFromDOM: false, getRulesFromHostname: true, + + callerContext: { + tabId: details.tabId, + frameId: details.frameId, + }, }); if (active === false) { return; @@ -460,6 +465,11 @@ export class WebExtensionBlocker extends FiltersEngine { getExtendedRules: false, getRulesFromDOM: false, getRulesFromHostname: false, + + callerContext: { + tabId: sender.tab?.id, + frameId: sender.frameId, + }, }); if (active === false) { @@ -497,6 +507,11 @@ export class WebExtensionBlocker extends FiltersEngine { // This will be done every time we get information about DOM mutation getRulesFromDOM: msg.lifecycle === 'dom-update', + + callerContext: { + tabId: sender.tab?.id, + frameId: sender.frameId, + }, }); if (active === false) { diff --git a/packages/adblocker/adblocker.ts b/packages/adblocker/adblocker.ts index f531d056aa..d9583c6828 100644 --- a/packages/adblocker/adblocker.ts +++ b/packages/adblocker/adblocker.ts @@ -6,7 +6,12 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -export { default as FiltersEngine, ENGINE_VERSION, BlockingResponse } from './src/engine/engine'; +export { + default as FiltersEngine, + ENGINE_VERSION, + BlockingResponse, + MatchingContext, +} from './src/engine/engine'; export { default as ReverseIndex } from './src/engine/reverse-index'; export { default as Request, diff --git a/packages/adblocker/src/engine/bucket/cosmetic.ts b/packages/adblocker/src/engine/bucket/cosmetic.ts index ac203b6432..a7a9d4e058 100644 --- a/packages/adblocker/src/engine/bucket/cosmetic.ts +++ b/packages/adblocker/src/engine/bucket/cosmetic.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/unbound-method */ /*! * Copyright (c) 2017-present Cliqz GmbH. All rights reserved. * @@ -352,33 +351,77 @@ export default class CosmeticFilterBucket { hostname: string; isFilterExcluded?: (filter: CosmeticFilter) => boolean; - }): CosmeticFilter[] { + }): { + // "rules" is an array of the final result. + // If filter has an exception, the filter will not be here. + rules: CosmeticFilter[]; + // "candidates" is the results in the initial stage of matching process before exceptions are applied. + // This array contains all filters even there's an exception to help debugging the filtering process in the adblocker library. + candidates: CosmeticFilter[]; + // "exceptions" is the mapping of a filter and an exception. + // This data structure allows easy finding of an exception for a filter candidate. + // This allows an efficient sequential event emission of a filter and its exception. + exceptions: Map; + } { // Tokens from `hostname` and `domain` which will be used to lookup filters // from the reverse index. The same tokens are re-used for multiple indices. const hostnameTokens = createLookupTokens(hostname, domain); - const rules: CosmeticFilter[] = []; + const candidates: CosmeticFilter[] = []; this.htmlIndex.iterMatchingFilters(hostnameTokens, (rule: CosmeticFilter) => { if (rule.match(hostname, domain) && !isFilterExcluded?.(rule)) { - rules.push(rule); + candidates.push(rule); } return true; }); + const exceptions: Map = new Map(); + + if (candidates.length === 0) { + return { + rules: [], + candidates: [], + exceptions, + }; + } + // If we found at least one candidate, check if we have unhidden rules. - const disabledRules: Set = new Set(); - if (rules.length !== 0) { - this.unhideIndex.iterMatchingFilters(hostnameTokens, (rule: CosmeticFilter) => { - if (rule.match(hostname, domain) && !isFilterExcluded?.(rule)) { - disabledRules.add(rule.getSelector()); - } + const disabledRules: Map = new Map(); + this.unhideIndex.iterMatchingFilters(hostnameTokens, (rule: CosmeticFilter) => { + if (rule.match(hostname, domain) && !isFilterExcluded?.(rule)) { + disabledRules.set(rule.getSelector(), rule); + } - return true; - }); + return true; + }); + + if (disabledRules.size === 0) { + return { + rules: candidates, + candidates, + exceptions, + }; } - return rules.filter( - (rule) => disabledRules.size === 0 || disabledRules.has(rule.getSelector()) === false, - ); + const rules: CosmeticFilter[] = []; + for ( + let i = 0, exception: CosmeticFilter | undefined = undefined; + i < candidates.length; + i++ + ) { + exception = disabledRules.get(candidates[i].getSelector()); + + if (exception !== undefined) { + exceptions.set(candidates[i], exception); + } else { + rules.push(candidates[i]); + } + } + + return { + rules, + candidates, + exceptions, + }; } /** @@ -425,11 +468,13 @@ export default class CosmeticFilterBucket { injections: CosmeticFilter[]; extended: IMessageFromBackground['extended']; stylesheet: string; + candidates: CosmeticFilter[]; + exceptions: Map; } { // Tokens from `hostname` and `domain` which will be used to lookup filters // from the reverse index. The same tokens are re-used for multiple indices. const hostnameTokens = createLookupTokens(hostname, domain); - const rules: CosmeticFilter[] = []; + const candidates: CosmeticFilter[] = []; // ======================================================================= // Rules: hostname-specific @@ -445,7 +490,7 @@ export default class CosmeticFilterBucket { rule.match(hostname, domain) && !isFilterExcluded?.(rule) ) { - rules.push(rule); + candidates.push(rule); } return true; }); @@ -461,7 +506,7 @@ export default class CosmeticFilterBucket { const genericRules = this.getGenericRules(); for (const rule of genericRules) { if (rule.match(hostname, domain) === true && !isFilterExcluded?.(rule)) { - rules.push(rule); + candidates.push(rule); } } } @@ -472,7 +517,7 @@ export default class CosmeticFilterBucket { if (allowGenericHides === true && getRulesFromDOM === true && classes.length !== 0) { this.classesIndex.iterMatchingFilters(hashStrings(classes), (rule: CosmeticFilter) => { if (rule.match(hostname, domain) && !isFilterExcluded?.(rule)) { - rules.push(rule); + candidates.push(rule); } return true; }); @@ -484,7 +529,7 @@ export default class CosmeticFilterBucket { if (allowGenericHides === true && getRulesFromDOM === true && ids.length !== 0) { this.idsIndex.iterMatchingFilters(hashStrings(ids), (rule: CosmeticFilter) => { if (rule.match(hostname, domain) && !isFilterExcluded?.(rule)) { - rules.push(rule); + candidates.push(rule); } return true; }); @@ -498,13 +543,16 @@ export default class CosmeticFilterBucket { compactTokens(concatTypedArrays(hrefs.map((href) => tokenizeNoSkip(href)))), (rule: CosmeticFilter) => { if (rule.match(hostname, domain) && !isFilterExcluded?.(rule)) { - rules.push(rule); + candidates.push(rule); } return true; }, ); } + // Additional data for engine events + const exceptions: Map = new Map(); + const extended: CosmeticFilter[] = []; const injections: CosmeticFilter[] = []; const styles: CosmeticFilter[] = []; @@ -512,17 +560,19 @@ export default class CosmeticFilterBucket { // If we found at least one candidate, check if we have unhidden rules, // apply them and dispatch rules into `injections` (i.e.: '+js(...)'), // `extended` (i.e. :not(...)), and `styles` (i.e.: '##rule'). - if (rules.length !== 0) { + if (candidates.length !== 0) { // ======================================================================= // Rules: unhide // ======================================================================= // Collect unhidden selectors. They will be used to filter-out canceled // rules from other indices. let injectionsDisabled = false; - const disabledRules: Set = new Set(); + // Create a binding from here to provide a map of rules with exception rules + // with minimal performance impact + const disabledRules: Map = new Map(); this.unhideIndex.iterMatchingFilters(hostnameTokens, (rule: CosmeticFilter) => { if (rule.match(hostname, domain) && !isFilterExcluded?.(rule)) { - disabledRules.add(rule.getSelector()); + disabledRules.set(rule.getSelector(), rule); // Detect special +js() rules to disable scriptlet injections if ( @@ -538,9 +588,11 @@ export default class CosmeticFilterBucket { }); // Apply unhide rules + dispatch - for (const rule of rules) { + for (const rule of candidates) { // Make sure `rule` is not un-hidden by a #@# filter if (disabledRules.size !== 0 && disabledRules.has(rule.getSelector())) { + exceptions.set(rule, disabledRules.get(rule.getSelector())!); + continue; } @@ -606,6 +658,8 @@ export default class CosmeticFilterBucket { extended: extendedProcessed, injections, stylesheet, + candidates, + exceptions, }; } diff --git a/packages/adblocker/src/engine/engine.ts b/packages/adblocker/src/engine/engine.ts index a117071e50..a7b657ca12 100644 --- a/packages/adblocker/src/engine/engine.ts +++ b/packages/adblocker/src/engine/engine.ts @@ -23,7 +23,7 @@ import { HTMLSelector } from '../html-filtering'; import CosmeticFilter from '../filters/cosmetic'; import NetworkFilter from '../filters/network'; import { block } from '../filters/dsl'; -import { IListDiff, IPartialRawDiff, parseFilters } from '../lists'; +import { FilterType, IListDiff, IPartialRawDiff, parseFilters } from '../lists'; import Request from '../request'; import Resources from '../resources'; import CosmeticFilterBucket from './bucket/cosmetic'; @@ -86,16 +86,61 @@ export interface Caching { write: (path: string, buffer: Uint8Array) => Promise; } -export default class FilterEngine extends EventEmitter< - | 'csp-injected' - | 'html-filtered' - | 'request-allowed' - | 'request-blocked' - | 'request-redirected' - | 'request-whitelisted' - | 'script-injected' - | 'style-injected' -> { +// We do have a full context in case of the network filter matching. +// It's because network filter matching does rely on an argument of the request. +export type NetworkFilterMatchingContext = { + request: Request; + + filterType: FilterType.NETWORK; +}; + +// Cosmetic rule is commonly used word in the project, +// but cosmetic filter is used for better documentation. +type CosmeticFilterMatchingContextBase = { + url: string; + domain: string; + hostname: string | undefined; + + filterType: FilterType.COSMETIC; + + // Additional context given from user + callerContext: any; +}; + +export type CosmeticFilterMatchingContext = CosmeticFilterMatchingContextBase & + Partial< + Omit[0], 'callerContext'> & + Omit[0], 'isFilterExcluded'> + >; + +export type MatchingContext = CosmeticFilterMatchingContext | NetworkFilterMatchingContext; + +type NetworkFilterMatchEvent = (request: Request, result: BlockingResponse) => void; +type CosmeticInjectionEvent = (script: string, url: string) => void; + +export type EngineEventHandlers = { + 'request-allowed': NetworkFilterMatchEvent; + 'request-blocked': NetworkFilterMatchEvent; + 'request-redirected': NetworkFilterMatchEvent; + 'request-whitelisted': NetworkFilterMatchEvent; + 'html-filtered': ( + htmlSelectors: HTMLSelector[], + url: string, + context: CosmeticFilterMatchingContext, + ) => void; + 'csp-injected': (csps: string, request: Request) => void; + 'script-injected': CosmeticInjectionEvent; + 'style-injected': CosmeticInjectionEvent; + + // 'filter-matched' event is fired on the match of both cosmetic and network filter. + // This event aims to demonstrate the matching process rather the final behavior. + // Therefore, using the above events would be helpful if you want to know the final action. + // An exception filter event will be always fired right after a corresponding filter. + // However, exceptions without a corresponding filter will not handled by this event. + 'filter-matched': (filter: CosmeticFilter | NetworkFilter, context: MatchingContext) => any; +}; + +export default class FilterEngine extends EventEmitter { private static fromCached( this: T, init: () => Promise>, @@ -691,10 +736,14 @@ export default class FilterEngine extends EventEmitter< url, hostname, domain, + + callerContext, }: { url: string; hostname: string; domain: string | null | undefined; + + callerContext?: any | undefined; }): HTMLSelector[] { const htmlSelectors: HTMLSelector[] = []; @@ -702,8 +751,10 @@ export default class FilterEngine extends EventEmitter< return htmlSelectors; } - const rules = this.cosmetics.getHtmlRules({ - domain: domain || '', + domain ||= ''; + + const { rules, candidates, exceptions } = this.cosmetics.getHtmlRules({ + domain, hostname, isFilterExcluded: this.isFilterExcluded.bind(this), }); @@ -715,8 +766,28 @@ export default class FilterEngine extends EventEmitter< } } - if (htmlSelectors.length !== 0) { - this.emit('html-filtered', htmlSelectors, url); + const context: CosmeticFilterMatchingContext = { + url, + hostname, + domain, + + filterType: FilterType.COSMETIC, + callerContext, + }; + + if (this.hasListeners('filter-matched')) { + for (const match of candidates) { + this.emit('filter-matched', match, context); + + const exception = exceptions.get(match); + if (exception !== undefined) { + this.emit('filter-matched', exception, context); + } + } + } + + if (htmlSelectors.length !== 0 && this.hasListeners('html-filtered')) { + this.emit('html-filtered', htmlSelectors, url, context); } return htmlSelectors; @@ -743,6 +814,8 @@ export default class FilterEngine extends EventEmitter< getExtendedRules = true, getRulesFromDOM = true, getRulesFromHostname = true, + + callerContext, }: { url: string; hostname: string; @@ -757,6 +830,8 @@ export default class FilterEngine extends EventEmitter< getExtendedRules?: boolean; getRulesFromDOM?: boolean; getRulesFromHostname?: boolean; + + callerContext?: any | undefined; }): IMessageFromBackground { if (this.config.loadCosmeticFilters === false) { return { @@ -767,12 +842,14 @@ export default class FilterEngine extends EventEmitter< }; } + domain ||= ''; + let allowGenericHides = true; let allowSpecificHides = true; const exceptions = this.hideExceptions.matchAll( Request.fromRawDetails({ - domain: domain || '', + domain, hostname, url, @@ -807,9 +884,8 @@ export default class FilterEngine extends EventEmitter< allowSpecificHides = shouldApplyHideException(specificHides) === false; } - // Lookup injections as well as stylesheets - const { injections, stylesheet, extended } = this.cosmetics.getCosmeticsFilters({ - domain: domain || '', + const argumentBase = { + domain, hostname, classes, @@ -824,10 +900,41 @@ export default class FilterEngine extends EventEmitter< getExtendedRules, getRulesFromDOM, getRulesFromHostname, + }; + + // Lookup injections as well as stylesheets + const { + injections, + stylesheet, + extended, + candidates, + exceptions: exceptionMatches, + } = this.cosmetics.getCosmeticsFilters({ + ...argumentBase, isFilterExcluded: this.isFilterExcluded.bind(this), }); + if (this.hasListeners('filter-matched')) { + const context: CosmeticFilterMatchingContext = { + url, + + ...argumentBase, + + filterType: FilterType.COSMETIC, + callerContext, + }; + + for (const match of candidates) { + this.emit('filter-matched', match, context); + + const exception = exceptionMatches.get(match); + if (exception !== undefined) { + this.emit('filter-matched', exception, context); + } + } + } + // Perform interpolation for injected scripts const scripts: string[] = []; for (const injection of injections) { @@ -884,6 +991,16 @@ export default class FilterEngine extends EventEmitter< ); } + if (this.hasListeners('filter-matched')) { + for (const filter of filters) { + this.emit('filter-matched', filter, { + request, + + filterType: FilterType.NETWORK, + }); + } + } + return new Set(filters); } @@ -911,6 +1028,11 @@ export default class FilterEngine extends EventEmitter< const disabledCsp = new Set(); const enabledCsp = new Set(); for (const filter of matches) { + this.emit('filter-matched', filter, { + request, + + filterType: FilterType.NETWORK, + }); if (filter.isException()) { if (filter.csp === undefined) { // All CSP directives are disabled for this site @@ -953,6 +1075,12 @@ export default class FilterEngine extends EventEmitter< return result; } + const context: NetworkFilterMatchingContext = { + request, + + filterType: FilterType.NETWORK, + }; + if (request.isSupported) { // Check the filters in the following order: // 1. $important (not subject to exceptions) @@ -1003,8 +1131,14 @@ export default class FilterEngine extends EventEmitter< // If we found either a redirection rule or a normal match, then check // for exceptions which could apply on the request and un-block it. if (result.filter !== undefined) { + // Emit if a filter matched + this.emit('filter-matched', result.filter, context); + result.exception = this.exceptions.match(request, this.isFilterExcluded.bind(this)); } + } else { + // Emit if an important flagged filter matched + this.emit('filter-matched', result.filter, context); } // If there was a redirect match and no exception was found, then we @@ -1027,15 +1161,17 @@ export default class FilterEngine extends EventEmitter< result.match = result.exception === undefined && result.filter !== undefined; - // Emit events if we found a match - if (result.exception !== undefined) { - this.emit('request-whitelisted', request, result); - } else if (result.redirect !== undefined) { - this.emit('request-redirected', request, result); - } else if (result.filter !== undefined) { - this.emit('request-blocked', request, result); - } else { - this.emit('request-allowed', request, result); + if (this.hasListeners()) { + if (result.exception !== undefined) { + this.emit('filter-matched', result.exception, context); // Emit if an exception matched + this.emit('request-whitelisted', request, result); + } else if (result.redirect !== undefined) { + this.emit('request-redirected', request, result); + } else if (result.filter !== undefined) { + this.emit('request-blocked', request, result); + } else { + this.emit('request-allowed', request, result); + } } if (withMetadata === true && result.filter !== undefined && this.metadata) { diff --git a/packages/adblocker/src/events.ts b/packages/adblocker/src/events.ts index d171bc4202..d9dae63d6c 100644 --- a/packages/adblocker/src/events.ts +++ b/packages/adblocker/src/events.ts @@ -52,6 +52,9 @@ function unregisterCallback( if (indexOfCallback !== -1) { listenersForEvent.splice(indexOfCallback, 1); } + if (listenersForEvent.length === 0) { + listeners.delete(event); + } } } @@ -87,14 +90,20 @@ function triggerCallback( * Node.js) allowing partially typed event emitting. The set of event names is * specified as a type parameter while instantiating the event emitter. */ -export class EventEmitter { +export class EventEmitter< + EventHandlers extends Record any>, + EventNames extends keyof EventHandlers = keyof EventHandlers, +> { private onceListeners: EventListeners = new Map(); private onListeners: EventListeners = new Map(); /** * Register an event listener for `event`. */ - public on(event: EventNames, callback: EventListener): void { + public on( + event: EventName, + callback: EventHandlers[EventName], + ): void { registerCallback(event, callback, this.onListeners); } @@ -102,14 +111,20 @@ export class EventEmitter { * Register an event listener for `event`; but only listen to first instance * of this event. The listener is automatically deleted afterwards. */ - public once(event: EventNames, callback: EventListener): void { + public once( + event: EventName, + callback: EventHandlers[EventName], + ): void { registerCallback(event, callback, this.onceListeners); } /** * Remove `callback` from list of listeners for `event`. */ - public unsubscribe(event: EventNames, callback: EventListener): void { + public unsubscribe( + event: EventName, + callback: EventHandlers[EventName], + ): void { unregisterCallback(event, callback, this.onListeners); unregisterCallback(event, callback, this.onceListeners); } @@ -117,10 +132,24 @@ export class EventEmitter { /** * Emit an event. Call all registered listeners to this event. */ - public emit(event: EventNames, ...args: any[]): void { + public emit( + event: EventName, + ...args: Parameters + ): void { triggerCallback(event, args, this.onListeners); if (triggerCallback(event, args, this.onceListeners) === true) { this.onceListeners.delete(event); } } + + /** + * Check if there's at least one active listener. + */ + public hasListeners(eventName?: EventNames): boolean { + if (eventName === undefined) { + return this.onListeners.size + this.onceListeners.size !== 0; + } + + return this.onListeners.has(eventName) || this.onceListeners.has(eventName); + } } diff --git a/packages/adblocker/test/engine/engine.test.ts b/packages/adblocker/test/engine/engine.test.ts index b15c84c335..53f0877f51 100644 --- a/packages/adblocker/test/engine/engine.test.ts +++ b/packages/adblocker/test/engine/engine.test.ts @@ -11,7 +11,7 @@ import 'mocha'; import { getDomain } from 'tldts-experimental'; -import Engine from '../../src/engine/engine'; +import Engine, { EngineEventHandlers } from '../../src/engine/engine'; import NetworkFilter from '../../src/filters/network'; import Request, { RequestType } from '../../src/request'; import Resources from '../../src/resources'; @@ -1401,3 +1401,70 @@ describe('diff updates', () => { testUpdates('empty engine', []); testUpdates('easylist engine', loadEasyListFilters()); }); + +describe('events', () => { + async function createEventAwaiter< + Name extends keyof EngineEventHandlers, + Handler extends EngineEventHandlers[Name], + Arguments extends Parameters, + >(engine: Engine, name: Name, limit = 1) { + return new Promise((resolve, reject) => { + const timeout: NodeJS.Timeout = setTimeout(() => { + engine.unsubscribe(name, handler); + + if (callbacks.length === 0) { + reject(`Timeout reached before catching an event type of "${name}" within a second!`); + } + + resolve(callbacks); + }, 1000); + + const callbacks: Arguments[] = []; + const handler = (...args: any) => { + callbacks.push(args as Arguments); + + if (callbacks.length === limit) { + engine.unsubscribe(name, handler); + + clearTimeout(timeout); + resolve(callbacks); + } + }; + + engine.on(name, handler); + }); + } + + const engine = createEngine(`||foo.com +||bar.com +@@||bar.com`); + + it('emits filter-matched', async () => { + const awaiter = createEventAwaiter(engine, 'filter-matched'); + + engine.match( + Request.fromRawDetails({ + url: 'http://foo.com', + }), + ); + + const [[filter]] = await awaiter; + + expect(filter.toString()).to.be.equal('||foo.com'); + }); + + it('emits exception in filter-matched', async () => { + const awaiter = createEventAwaiter(engine, 'filter-matched', 2); + + engine.match( + Request.fromRawDetails({ + url: 'http://bar.com', + }), + ); + + const [[filter], [exception]] = await awaiter; + + expect(filter.toString()).to.be.equal('||bar.com'); + expect(exception.toString()).to.be.equal('@@||bar.com'); // The exception filter is always emitted later + }); +});