cookiebanner: address PR review feedback

- Detectors: keep batched entries in `pending` until the POST succeeds
  and guard against concurrent flushes, so transient network errors no
  longer silently drop detection reports.
- Worker: add stable tie-breakers to the merge-candidate sort so the
  greedy assignment produces deterministic groups across runs.
- Handler: skip resource entries with an empty URL (zero-value `uri.URI`
  when the `url` field is missing) before persisting them.
- Third-party detector: allow same-origin service worker scripts through
  `processResource` -- service workers are always same-origin by spec,
  so the previous filter made `wrapServiceWorker` unreachable.
- Resource row edit: bump the description cell `colSpan` to 3 so the
  edit row spans all five table columns.
- Resolver: handle `ErrSameResourceCategoryMove` explicitly so the no-op
  move returns a validation error instead of an internal one.

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-05-11 12:12:47 +04:00
parent 8e2b8e6288
commit c8f130ed45
7 changed files with 103 additions and 41 deletions

View File

@@ -41,6 +41,7 @@ export class CookieDetector implements Detector {
private readonly reported: Set<string> = new Set();
private readonly pending: Map<string, DetectedCookieEntry> = new Map();
private timer: ReturnType<typeof setTimeout> | null = null;
private flushing = false;
private originalDescriptor: PropertyDescriptor | null = null;
private cookieStoreHandler: ((event: CookieChangeEvent) => void) | null = null;
@@ -165,36 +166,46 @@ export class CookieDetector implements Detector {
}
private scheduleFlush(): void {
if (this.timer) return;
if (this.timer || this.flushing) return;
this.timer = setTimeout(() => {
this.timer = null;
this.flush();
}, DEBOUNCE_MS);
}
// flush sends one batch from `pending` and only removes entries on
// success. Transient failures leave entries in `pending` so they are
// retried on the next flush. `flushing` guards against re-sending an
// in-flight batch when new entries arrive mid-request.
private flush(): void {
if (this.flushing) return;
if (this.pending.size === 0) return;
const iter = this.pending.entries();
const batchKeys: string[] = [];
const entries: DetectedCookieEntry[] = [];
for (const [key, entry] of iter) {
for (const [key, entry] of this.pending) {
batchKeys.push(key);
entries.push(entry);
this.pending.delete(key);
if (entries.length >= MAX_COOKIES_PER_REQUEST) break;
}
this.flushing = true;
void fetchJSON(this.reportUrl, {
method: "POST",
body: { cookies: entries },
}).catch((err) => {
if (err instanceof NotFoundError) {
this.pending.clear();
this.stop();
}
});
if (this.pending.size > 0) {
this.scheduleFlush();
}
})
.then(() => {
for (const key of batchKeys) this.pending.delete(key);
})
.catch((err) => {
if (err instanceof NotFoundError) {
this.pending.clear();
this.stop();
}
})
.finally(() => {
this.flushing = false;
if (this.pending.size > 0) this.scheduleFlush();
});
}
}

View File

@@ -44,6 +44,7 @@ export class StorageDetector implements Detector {
private readonly reported: Set<string> = new Set();
private readonly pending: Map<string, DetectedStorageEntry> = new Map();
private timer: ReturnType<typeof setTimeout> | null = null;
private flushing = false;
private originalSetItem: typeof Storage.prototype.setItem | null = null;
private originalIDBOpen: typeof IDBFactory.prototype.open | null = null;
private originalCachesOpen: typeof CacheStorage.prototype.open | null = null;
@@ -230,35 +231,46 @@ export class StorageDetector implements Detector {
}
private scheduleFlush(): void {
if (this.timer) return;
if (this.timer || this.flushing) return;
this.timer = setTimeout(() => {
this.timer = null;
this.flush();
}, DEBOUNCE_MS);
}
// flush sends one batch from `pending` and only removes entries on
// success. Transient failures leave entries in `pending` so they are
// retried on the next flush. `flushing` guards against re-sending an
// in-flight batch when new entries arrive mid-request.
private flush(): void {
if (this.flushing) return;
if (this.pending.size === 0) return;
const batchKeys: string[] = [];
const entries: DetectedStorageEntry[] = [];
for (const [key, entry] of this.pending) {
batchKeys.push(key);
entries.push(entry);
this.pending.delete(key);
if (entries.length >= MAX_ITEMS_PER_REQUEST) break;
}
this.flushing = true;
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();
}
})
.then(() => {
for (const key of batchKeys) this.pending.delete(key);
})
.catch((err) => {
if (err instanceof NotFoundError) {
this.pending.clear();
this.stop();
}
})
.finally(() => {
this.flushing = false;
if (this.pending.size > 0) this.scheduleFlush();
});
}
}

View File

@@ -79,6 +79,7 @@ export class ThirdPartyDetector implements Detector {
private readonly reported: Set<string> = new Set();
private readonly pending: Map<string, DetectedResourceEntry> = new Map();
private timer: ReturnType<typeof setTimeout> | null = null;
private flushing = false;
private observer: MutationObserver | null = null;
private perfObserver: PerformanceObserver | null = null;
private originalSWRegister: typeof ServiceWorkerContainer.prototype.register | null = null;
@@ -247,7 +248,12 @@ export class ThirdPartyDetector implements Detector {
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return;
if (parsed.origin === this.pageOrigin || parsed.origin === this.proboOrigin) return;
// Service workers are always same-origin per browser security rules,
// so we never drop them based on the page origin -- they are tracked
// regardless of where the script lives. The proboOrigin guard still
// applies so we never report our own SDK assets.
if (parsed.origin === this.proboOrigin) return;
if (resourceType !== "service_worker" && parsed.origin === this.pageOrigin) return;
const identifier = parsed.origin + parsed.pathname;
const reportKey = `${resourceType}:${identifier}`;
@@ -259,35 +265,46 @@ export class ThirdPartyDetector implements Detector {
}
private scheduleFlush(): void {
if (this.timer) return;
if (this.timer || this.flushing) return;
this.timer = setTimeout(() => {
this.timer = null;
this.flush();
}, DEBOUNCE_MS);
}
// flush sends one batch from `pending` and only removes entries on
// success. Transient failures leave entries in `pending` so they are
// retried on the next flush. `flushing` guards against re-sending an
// in-flight batch when new entries arrive mid-request.
private flush(): void {
if (this.flushing) return;
if (this.pending.size === 0) return;
const batchKeys: string[] = [];
const entries: DetectedResourceEntry[] = [];
for (const [key, entry] of this.pending) {
batchKeys.push(key);
entries.push(entry);
this.pending.delete(key);
if (entries.length >= MAX_ITEMS_PER_REQUEST) break;
}
this.flushing = true;
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();
}
})
.then(() => {
for (const key of batchKeys) this.pending.delete(key);
})
.catch((err) => {
if (err instanceof NotFoundError) {
this.pending.clear();
this.stop();
}
})
.finally(() => {
this.flushing = false;
if (this.pending.size > 0) this.scheduleFlush();
});
}
}