Add StorageDetector, ThirdPartyDetector, and Detector interface
Introduce a common Detector interface (start/stop) implemented by CookieDetector, StorageDetector, and ThirdPartyDetector. The client manages them as a uniform array, simplifying lifecycle management. StorageDetector wraps Storage.prototype.setItem and indexedDB.open to detect localStorage, sessionStorage, and IndexedDB usage. ThirdPartyDetector uses MutationObserver to detect cross-origin script and iframe elements, reporting at origin level. Both report to POST /detected-trackers with 2s debounce and max 100 items per batch. Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
@@ -18,7 +18,10 @@ import {
|
|||||||
} from "./activation";
|
} from "./activation";
|
||||||
import { COOKIE_NAME, getConsentCookie, setConsentCookie } from "./cookie";
|
import { COOKIE_NAME, getConsentCookie, setConsentCookie } from "./cookie";
|
||||||
import { CookieDetector } from "./detector";
|
import { CookieDetector } from "./detector";
|
||||||
|
import type { Detector } from "./detector-interface";
|
||||||
import { NotFoundError } from "./errors";
|
import { NotFoundError } from "./errors";
|
||||||
|
import { StorageDetector } from "./storage-detector";
|
||||||
|
import { ThirdPartyDetector } from "./third-party-detector";
|
||||||
import { fetchJSON } from "./http";
|
import { fetchJSON } from "./http";
|
||||||
import { detectLanguage } from "./i18n";
|
import { detectLanguage } from "./i18n";
|
||||||
import type { ConsentIntegration } from "./integrations";
|
import type { ConsentIntegration } from "./integrations";
|
||||||
@@ -56,7 +59,7 @@ export class CookieBannerClient {
|
|||||||
private bannerConfig: BannerConfig | null = null;
|
private bannerConfig: BannerConfig | null = null;
|
||||||
private consent: VisitorConsent | null = null;
|
private consent: VisitorConsent | null = null;
|
||||||
private observer: MutationObserver | null = null;
|
private observer: MutationObserver | null = null;
|
||||||
private detector: CookieDetector | null = null;
|
private detectors: Detector[] = [];
|
||||||
private _gpcApplied = false;
|
private _gpcApplied = false;
|
||||||
|
|
||||||
constructor(config: CookieBannerClientOptions) {
|
constructor(config: CookieBannerClientOptions) {
|
||||||
@@ -295,6 +298,8 @@ export class CookieBannerClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private startDetector(config?: BannerConfig): void {
|
private startDetector(config?: BannerConfig): void {
|
||||||
|
this.stopDetectors();
|
||||||
|
|
||||||
const knownNames = new Set<string>();
|
const knownNames = new Set<string>();
|
||||||
knownNames.add(COOKIE_NAME);
|
knownNames.add(COOKIE_NAME);
|
||||||
if (config) {
|
if (config) {
|
||||||
@@ -305,18 +310,26 @@ export class CookieBannerClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.detector) {
|
this.detectors = [
|
||||||
this.detector.stop();
|
new CookieDetector(this.baseUrl, this.bannerId, knownNames),
|
||||||
|
new StorageDetector(this.baseUrl, this.bannerId),
|
||||||
|
new ThirdPartyDetector(this.baseUrl, this.bannerId),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const d of this.detectors) {
|
||||||
|
d.start();
|
||||||
}
|
}
|
||||||
this.detector = new CookieDetector(this.baseUrl, this.bannerId, knownNames);
|
}
|
||||||
this.detector.start();
|
|
||||||
|
private stopDetectors(): void {
|
||||||
|
for (const d of this.detectors) {
|
||||||
|
d.stop();
|
||||||
|
}
|
||||||
|
this.detectors = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy(): void {
|
destroy(): void {
|
||||||
if (this.detector) {
|
this.stopDetectors();
|
||||||
this.detector.stop();
|
|
||||||
this.detector = null;
|
|
||||||
}
|
|
||||||
if (this.observer) {
|
if (this.observer) {
|
||||||
this.observer.disconnect();
|
this.observer.disconnect();
|
||||||
this.observer = null;
|
this.observer = null;
|
||||||
|
|||||||
18
packages/cookie-banner/src/detector-interface.ts
Normal file
18
packages/cookie-banner/src/detector-interface.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
export interface Detector {
|
||||||
|
start(): void;
|
||||||
|
stop(): void;
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
import { isDeletion, parseCookieName, parseMaxAgeSeconds } from "./cookie-utils";
|
import { isDeletion, parseCookieName, parseMaxAgeSeconds } from "./cookie-utils";
|
||||||
|
import type { Detector } from "./detector-interface";
|
||||||
import { NotFoundError } from "./errors";
|
import { NotFoundError } from "./errors";
|
||||||
import { fetchJSON } from "./http";
|
import { fetchJSON } from "./http";
|
||||||
|
|
||||||
@@ -31,7 +32,7 @@ function isExtensionCaller(): boolean {
|
|||||||
return EXTENSION_URL_RE.test(stack);
|
return EXTENSION_URL_RE.test(stack);
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CookieDetector {
|
export class CookieDetector implements Detector {
|
||||||
private readonly reportUrl: URL;
|
private readonly reportUrl: URL;
|
||||||
private readonly knownNames: Set<string>;
|
private readonly knownNames: Set<string>;
|
||||||
private readonly reported: Set<string> = new Set();
|
private readonly reported: Set<string> = new Set();
|
||||||
|
|||||||
205
packages/cookie-banner/src/storage-detector.ts
Normal file
205
packages/cookie-banner/src/storage-detector.ts
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
import type { Detector } from "./detector-interface";
|
||||||
|
import { NotFoundError } from "./errors";
|
||||||
|
import { fetchJSON } from "./http";
|
||||||
|
|
||||||
|
interface DetectedStorageEntry {
|
||||||
|
key: string;
|
||||||
|
storage_type: "local_storage" | "session_storage" | "indexed_db";
|
||||||
|
value_size: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEBOUNCE_MS = 2_000;
|
||||||
|
const MAX_ITEMS_PER_REQUEST = 100;
|
||||||
|
const OWN_KEY_PREFIX = "probo_consent:";
|
||||||
|
const EXTENSION_URL_RE = /(?:chrome|moz|safari-web)-extension:\/\//;
|
||||||
|
|
||||||
|
function isExtensionCaller(): boolean {
|
||||||
|
const stack = new Error().stack ?? "";
|
||||||
|
return EXTENSION_URL_RE.test(stack);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StorageDetector implements Detector {
|
||||||
|
private readonly reportUrl: URL;
|
||||||
|
private readonly reported: Set<string> = new Set();
|
||||||
|
private readonly pending: Map<string, DetectedStorageEntry> = new Map();
|
||||||
|
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private originalLocalSetItem: typeof Storage.prototype.setItem | null = null;
|
||||||
|
private originalSessionSetItem: typeof Storage.prototype.setItem | null = null;
|
||||||
|
private originalIDBOpen: typeof IDBFactory.prototype.open | null = null;
|
||||||
|
|
||||||
|
constructor(baseUrl: URL, bannerId: string) {
|
||||||
|
this.reportUrl = new URL(`${bannerId}/detected-trackers`, baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
this.wrapStorage();
|
||||||
|
this.wrapIndexedDB();
|
||||||
|
this.scanExisting();
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
if (this.timer) {
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
this.timer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.pending.size > 0) {
|
||||||
|
this.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.originalLocalSetItem) {
|
||||||
|
Storage.prototype.setItem = this.originalLocalSetItem;
|
||||||
|
this.originalLocalSetItem = null;
|
||||||
|
}
|
||||||
|
if (this.originalSessionSetItem) {
|
||||||
|
Storage.prototype.setItem = this.originalLocalSetItem ?? this.originalSessionSetItem!;
|
||||||
|
this.originalSessionSetItem = null;
|
||||||
|
}
|
||||||
|
if (this.originalIDBOpen) {
|
||||||
|
IDBFactory.prototype.open = this.originalIDBOpen;
|
||||||
|
this.originalIDBOpen = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private wrapStorage(): void {
|
||||||
|
const originalSetItem = Storage.prototype.setItem;
|
||||||
|
this.originalLocalSetItem = originalSetItem;
|
||||||
|
|
||||||
|
const self = this;
|
||||||
|
|
||||||
|
Storage.prototype.setItem = function (key: string, value: string) {
|
||||||
|
originalSetItem.call(this, key, value);
|
||||||
|
|
||||||
|
if (isExtensionCaller()) return;
|
||||||
|
|
||||||
|
const storageType: "local_storage" | "session_storage" =
|
||||||
|
this === localStorage ? "local_storage" : "session_storage";
|
||||||
|
|
||||||
|
self.onStorageWrite(key, value, storageType);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private wrapIndexedDB(): void {
|
||||||
|
if (typeof indexedDB === "undefined") return;
|
||||||
|
|
||||||
|
const originalOpen = IDBFactory.prototype.open;
|
||||||
|
this.originalIDBOpen = originalOpen;
|
||||||
|
|
||||||
|
const self = this;
|
||||||
|
|
||||||
|
IDBFactory.prototype.open = function (name: string, version?: number) {
|
||||||
|
const request = originalOpen.call(this, name, version);
|
||||||
|
self.onIndexedDBOpen(name);
|
||||||
|
return request;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private onStorageWrite(
|
||||||
|
key: string,
|
||||||
|
value: string,
|
||||||
|
storageType: "local_storage" | "session_storage",
|
||||||
|
): void {
|
||||||
|
if (key.startsWith(OWN_KEY_PREFIX)) return;
|
||||||
|
|
||||||
|
const reportKey = `${storageType}:${key}`;
|
||||||
|
if (this.reported.has(reportKey)) return;
|
||||||
|
|
||||||
|
this.reported.add(reportKey);
|
||||||
|
this.pending.set(reportKey, {
|
||||||
|
key,
|
||||||
|
storage_type: storageType,
|
||||||
|
value_size: value.length * 2,
|
||||||
|
});
|
||||||
|
this.scheduleFlush();
|
||||||
|
}
|
||||||
|
|
||||||
|
private onIndexedDBOpen(name: string): void {
|
||||||
|
const reportKey = `indexed_db:${name}`;
|
||||||
|
if (this.reported.has(reportKey)) return;
|
||||||
|
|
||||||
|
this.reported.add(reportKey);
|
||||||
|
this.pending.set(reportKey, {
|
||||||
|
key: name,
|
||||||
|
storage_type: "indexed_db",
|
||||||
|
value_size: null,
|
||||||
|
});
|
||||||
|
this.scheduleFlush();
|
||||||
|
}
|
||||||
|
|
||||||
|
private scanExisting(): void {
|
||||||
|
this.scanStorage(localStorage, "local_storage");
|
||||||
|
this.scanStorage(sessionStorage, "session_storage");
|
||||||
|
}
|
||||||
|
|
||||||
|
private scanStorage(
|
||||||
|
storage: Storage,
|
||||||
|
storageType: "local_storage" | "session_storage",
|
||||||
|
): void {
|
||||||
|
for (let i = 0; i < storage.length; i++) {
|
||||||
|
const key = storage.key(i);
|
||||||
|
if (!key || key.startsWith(OWN_KEY_PREFIX)) continue;
|
||||||
|
|
||||||
|
const reportKey = `${storageType}:${key}`;
|
||||||
|
if (this.reported.has(reportKey)) continue;
|
||||||
|
|
||||||
|
const value = storage.getItem(key);
|
||||||
|
this.reported.add(reportKey);
|
||||||
|
this.pending.set(reportKey, {
|
||||||
|
key,
|
||||||
|
storage_type: storageType,
|
||||||
|
value_size: value ? value.length * 2 : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.pending.size > 0) {
|
||||||
|
this.scheduleFlush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleFlush(): void {
|
||||||
|
if (this.timer) return;
|
||||||
|
this.timer = setTimeout(() => {
|
||||||
|
this.timer = null;
|
||||||
|
this.flush();
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private flush(): void {
|
||||||
|
if (this.pending.size === 0) return;
|
||||||
|
|
||||||
|
const entries: DetectedStorageEntry[] = [];
|
||||||
|
for (const [key, entry] of this.pending) {
|
||||||
|
entries.push(entry);
|
||||||
|
this.pending.delete(key);
|
||||||
|
if (entries.length >= MAX_ITEMS_PER_REQUEST) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
void fetchJSON(this.reportUrl, {
|
||||||
|
method: "POST",
|
||||||
|
body: { storage: entries },
|
||||||
|
}).catch((err) => {
|
||||||
|
if (err instanceof NotFoundError) {
|
||||||
|
this.pending.clear();
|
||||||
|
this.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.pending.size > 0) {
|
||||||
|
this.scheduleFlush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
157
packages/cookie-banner/src/third-party-detector.ts
Normal file
157
packages/cookie-banner/src/third-party-detector.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
import type { Detector } from "./detector-interface";
|
||||||
|
import { NotFoundError } from "./errors";
|
||||||
|
import { fetchJSON } from "./http";
|
||||||
|
|
||||||
|
interface DetectedResourceEntry {
|
||||||
|
origin: string;
|
||||||
|
resource_type: "script" | "iframe";
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEBOUNCE_MS = 2_000;
|
||||||
|
const MAX_ITEMS_PER_REQUEST = 100;
|
||||||
|
const EXTENSION_URL_RE = /(?:chrome|moz|safari-web)-extension:\/\//;
|
||||||
|
|
||||||
|
export class ThirdPartyDetector implements Detector {
|
||||||
|
private readonly reportUrl: URL;
|
||||||
|
private readonly pageOrigin: string;
|
||||||
|
private readonly proboOrigin: string;
|
||||||
|
private readonly reported: Set<string> = new Set();
|
||||||
|
private readonly pending: Map<string, DetectedResourceEntry> = new Map();
|
||||||
|
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private observer: MutationObserver | null = null;
|
||||||
|
|
||||||
|
constructor(baseUrl: URL, bannerId: string) {
|
||||||
|
this.reportUrl = new URL(`${bannerId}/detected-trackers`, baseUrl);
|
||||||
|
this.pageOrigin = location.origin;
|
||||||
|
this.proboOrigin = baseUrl.origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
this.scanExisting();
|
||||||
|
this.observeMutations();
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
if (this.timer) {
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
this.timer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.pending.size > 0) {
|
||||||
|
this.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.observer) {
|
||||||
|
this.observer.disconnect();
|
||||||
|
this.observer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private scanExisting(): void {
|
||||||
|
for (const script of document.querySelectorAll<HTMLScriptElement>("script[src]")) {
|
||||||
|
this.processElement(script.src, "script");
|
||||||
|
}
|
||||||
|
for (const iframe of document.querySelectorAll<HTMLIFrameElement>("iframe[src]")) {
|
||||||
|
this.processElement(iframe.src, "iframe");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.pending.size > 0) {
|
||||||
|
this.scheduleFlush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private observeMutations(): void {
|
||||||
|
this.observer = new MutationObserver((mutations) => {
|
||||||
|
for (const mutation of mutations) {
|
||||||
|
for (const node of mutation.addedNodes) {
|
||||||
|
if (!(node instanceof HTMLElement)) continue;
|
||||||
|
|
||||||
|
if (node instanceof HTMLScriptElement && node.src) {
|
||||||
|
this.processElement(node.src, "script");
|
||||||
|
} else if (node instanceof HTMLIFrameElement && node.src) {
|
||||||
|
this.processElement(node.src, "iframe");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const script of node.querySelectorAll<HTMLScriptElement>("script[src]")) {
|
||||||
|
this.processElement(script.src, "script");
|
||||||
|
}
|
||||||
|
for (const iframe of node.querySelectorAll<HTMLIFrameElement>("iframe[src]")) {
|
||||||
|
this.processElement(iframe.src, "iframe");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.observer.observe(document.documentElement, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private processElement(src: string, resourceType: "script" | "iframe"): void {
|
||||||
|
if (EXTENSION_URL_RE.test(src)) return;
|
||||||
|
|
||||||
|
let origin: string;
|
||||||
|
try {
|
||||||
|
origin = new URL(src).origin;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (origin === this.pageOrigin || origin === this.proboOrigin) return;
|
||||||
|
|
||||||
|
const reportKey = `${resourceType}:${origin}`;
|
||||||
|
if (this.reported.has(reportKey)) return;
|
||||||
|
|
||||||
|
this.reported.add(reportKey);
|
||||||
|
this.pending.set(reportKey, { origin, resource_type: resourceType });
|
||||||
|
this.scheduleFlush();
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleFlush(): void {
|
||||||
|
if (this.timer) return;
|
||||||
|
this.timer = setTimeout(() => {
|
||||||
|
this.timer = null;
|
||||||
|
this.flush();
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private flush(): void {
|
||||||
|
if (this.pending.size === 0) return;
|
||||||
|
|
||||||
|
const entries: DetectedResourceEntry[] = [];
|
||||||
|
for (const [key, entry] of this.pending) {
|
||||||
|
entries.push(entry);
|
||||||
|
this.pending.delete(key);
|
||||||
|
if (entries.length >= MAX_ITEMS_PER_REQUEST) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
void fetchJSON(this.reportUrl, {
|
||||||
|
method: "POST",
|
||||||
|
body: { resources: entries },
|
||||||
|
}).catch((err) => {
|
||||||
|
if (err instanceof NotFoundError) {
|
||||||
|
this.pending.clear();
|
||||||
|
this.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.pending.size > 0) {
|
||||||
|
this.scheduleFlush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user