@arraypress/extension-kit 1.11.1 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,130 @@
1
+ /**
2
+ * One request from a service worker that follows nothing, with what `fetch`
3
+ * hides of a redirect read through webRequest: for anything that must see
4
+ * each hop before it's made (a link's redirects shown one by one, a page
5
+ * fetched on a schedule that must never be sent on into the person's own
6
+ * network).
7
+ *
8
+ * What MV3 allows, checked in Chromium 156 (Clean Links' `test/e2e/where.mjs`,
9
+ * Better Alerts' `test/e2e/background.mjs`):
10
+ * - `fetch(url, {redirect: 'manual'})` in the worker answers a redirect with an
11
+ * `opaqueredirect` response: status 0, no headers, so no `Location`, even
12
+ * with host access. `redirect: 'follow'` hides the hops, and the request to
13
+ * the next site is made before anyone can look at where it goes.
14
+ * - webRequest does see the worker's own requests (tab -1, initiator the
15
+ * extension) when it has host access to them: `onHeadersReceived` gives the
16
+ * status line and every header, `Location` included, even for a manual
17
+ * redirect. So each hop is one manual fetch, and its redirect is read there.
18
+ * - `webRequest` adds no install warning, and must be a required permission:
19
+ * a listener added after Chrome grants an optional webRequest at run time
20
+ * gets no events until the worker restarts. It sees nothing without host
21
+ * access, which can stay optional.
22
+ *
23
+ * Requests carry no cookies (`credentials: 'omit'`), no referrer, and skip the cache.
24
+ *
25
+ * @module net/requests
26
+ */
27
+ /** How long to wait for webRequest's headers after the response is in (they come first). */
28
+ export declare const HEADERS_WAIT_MS = 2000;
29
+ /** The filter for the worker's own requests: outside any tab, as `fetch` makes them. */
30
+ export declare const OWN_REQUESTS: chrome.webRequest.RequestFilter;
31
+ export type SeenRequest = {
32
+ statusCode: number;
33
+ responseHeaders?: {
34
+ name: string;
35
+ value?: string;
36
+ }[];
37
+ } | {
38
+ error: string;
39
+ };
40
+ export type OwnRequestEvents = {
41
+ /**
42
+ * Response headers.
43
+ */
44
+ onHeadersReceived: {
45
+ addListener(listener: (details: any) => void, filter: chrome.webRequest.RequestFilter, extra?: string[]): void;
46
+ removeListener(listener: (details: any) => void): void;
47
+ };
48
+ /**
49
+ * Failed requests.
50
+ */
51
+ onErrorOccurred: {
52
+ addListener(listener: (details: any) => void, filter: chrome.webRequest.RequestFilter): void;
53
+ removeListener(listener: (details: any) => void): void;
54
+ };
55
+ };
56
+ export type OwnAnswer = {
57
+ /**
58
+ * The HTTP status; a redirect's own (301…), read through webRequest; 0
59
+ * when nothing could be read.
60
+ */
61
+ status: number;
62
+ /**
63
+ * The response's headers, by lower-case
64
+ * name; a redirect's `location` among them.
65
+ */
66
+ headers: {
67
+ get(name: string): string | null;
68
+ };
69
+ /**
70
+ * The response, to read a body from; null for a redirect.
71
+ */
72
+ response: Response | null;
73
+ /**
74
+ * It redirected to something that isn't a web address, and where
75
+ * can't be read (Chrome refuses such a redirect, `net::ERR_UNSAFE_REDIRECT`, before its headers show).
76
+ */
77
+ leavesWeb: boolean;
78
+ };
79
+ export type OwnRequestOptions = {
80
+ /**
81
+ * `'GET'` (the default) or `'HEAD'`.
82
+ */
83
+ method?: string;
84
+ /**
85
+ * Request headers (`If-None-Match`…).
86
+ */
87
+ headers?: Record<string, string>;
88
+ /**
89
+ * Stops it.
90
+ */
91
+ signal?: AbortSignal;
92
+ };
93
+ export type OwnRequests = {
94
+ /**
95
+ * Start listening to webRequest for as long as requests are
96
+ * being made; the function it returns stops. Not at the worker's top level: the filter can't
97
+ * name the extension, only “outside any tab”, which is also every website's own service worker,
98
+ * and with access to many sites each of their requests would wake this worker to be thrown away.
99
+ * Work that makes requests keeps the worker awake by itself, so nothing is missed.
100
+ */
101
+ listen: () => () => void;
102
+ /**
103
+ * One request,
104
+ * following nothing. Rejects as `fetch` does (the network, an abort).
105
+ */
106
+ request: (url: string, options?: OwnRequestOptions) => Promise<OwnAnswer>;
107
+ };
108
+ /**
109
+ * The worker's own requests, one at a time and following nothing, a redirect's
110
+ * status and `Location` read through webRequest.
111
+ *
112
+ * @param {object} edges - Chrome's side.
113
+ * @param {OwnRequestEvents} edges.webRequest - `chrome.webRequest`.
114
+ * @param {string} edges.extensionId - `chrome.runtime.id`: only this extension's requests are read.
115
+ * @param {typeof fetch} [edges.fetch] - `fetch`. Default the global one.
116
+ * @param {number} [edges.headersWaitMs] - Default {@link HEADERS_WAIT_MS}.
117
+ * @returns {OwnRequests} `listen` and `request`.
118
+ * @example
119
+ * const own = ownRequests({ webRequest: chrome.webRequest, extensionId: chrome.runtime.id });
120
+ * const stop = own.listen();
121
+ * const { status, headers } = await own.request('https://bit.ly/abc', { method: 'HEAD' });
122
+ * // 301, headers.get('location') === 'https://example.com/'
123
+ * stop();
124
+ */
125
+ export declare function ownRequests({ webRequest, extensionId, fetch: ask, headersWaitMs }: {
126
+ webRequest: OwnRequestEvents;
127
+ extensionId: string;
128
+ fetch?: typeof fetch;
129
+ headersWaitMs?: number;
130
+ }): OwnRequests;
@@ -0,0 +1,199 @@
1
+ /**
2
+ * One request from a service worker that follows nothing, with what `fetch`
3
+ * hides of a redirect read through webRequest: for anything that must see
4
+ * each hop before it's made (a link's redirects shown one by one, a page
5
+ * fetched on a schedule that must never be sent on into the person's own
6
+ * network).
7
+ *
8
+ * What MV3 allows, checked in Chromium 156 (Clean Links' `test/e2e/where.mjs`,
9
+ * Better Alerts' `test/e2e/background.mjs`):
10
+ * - `fetch(url, {redirect: 'manual'})` in the worker answers a redirect with an
11
+ * `opaqueredirect` response: status 0, no headers, so no `Location`, even
12
+ * with host access. `redirect: 'follow'` hides the hops, and the request to
13
+ * the next site is made before anyone can look at where it goes.
14
+ * - webRequest does see the worker's own requests (tab -1, initiator the
15
+ * extension) when it has host access to them: `onHeadersReceived` gives the
16
+ * status line and every header, `Location` included, even for a manual
17
+ * redirect. So each hop is one manual fetch, and its redirect is read there.
18
+ * - `webRequest` adds no install warning, and must be a required permission:
19
+ * a listener added after Chrome grants an optional webRequest at run time
20
+ * gets no events until the worker restarts. It sees nothing without host
21
+ * access, which can stay optional.
22
+ *
23
+ * Requests carry no cookies (`credentials: 'omit'`), no referrer, and skip the cache.
24
+ *
25
+ * @module net/requests
26
+ */
27
+
28
+ /** How long to wait for webRequest's headers after the response is in (they come first). */
29
+ export const HEADERS_WAIT_MS = 2000;
30
+
31
+ /** The filter for the worker's own requests: outside any tab, as `fetch` makes them. */
32
+ export const OWN_REQUESTS = /** @type {chrome.webRequest.RequestFilter} */ (Object.freeze({ urls: ['<all_urls>'], tabId: -1, types: ['xmlhttprequest'] }));
33
+
34
+ /**
35
+ * What webRequest reports of one of the worker's requests: its response's status and headers, or
36
+ * the error that ended it.
37
+ *
38
+ * @typedef {{statusCode: number, responseHeaders?: {name: string, value?: string}[]} | {error: string}} SeenRequest
39
+ */
40
+
41
+ /**
42
+ * The parts of `chrome.webRequest` used (fakes in tests).
43
+ *
44
+ * @typedef {object} OwnRequestEvents
45
+ * @property {{addListener(listener: (details: any) => void, filter: chrome.webRequest.RequestFilter, extra?: string[]): void, removeListener(listener: (details: any) => void): void}} onHeadersReceived
46
+ * Response headers.
47
+ * @property {{addListener(listener: (details: any) => void, filter: chrome.webRequest.RequestFilter): void, removeListener(listener: (details: any) => void): void}} onErrorOccurred
48
+ * Failed requests.
49
+ */
50
+
51
+ /**
52
+ * One answer.
53
+ *
54
+ * @typedef {object} OwnAnswer
55
+ * @property {number} status The HTTP status; a redirect's own (301…), read through webRequest; 0
56
+ * when nothing could be read.
57
+ * @property {{get(name: string): string | null}} headers The response's headers, by lower-case
58
+ * name; a redirect's `location` among them.
59
+ * @property {Response | null} response The response, to read a body from; null for a redirect.
60
+ * @property {boolean} leavesWeb It redirected to something that isn't a web address, and where
61
+ * can't be read (Chrome refuses such a redirect, `net::ERR_UNSAFE_REDIRECT`, before its headers show).
62
+ */
63
+
64
+ /**
65
+ * What a request may set.
66
+ *
67
+ * @typedef {object} OwnRequestOptions
68
+ * @property {string} [method] `'GET'` (the default) or `'HEAD'`.
69
+ * @property {Record<string, string>} [headers] Request headers (`If-None-Match`…).
70
+ * @property {AbortSignal} [signal] Stops it.
71
+ */
72
+
73
+ /**
74
+ * The worker's own requests.
75
+ *
76
+ * @typedef {object} OwnRequests
77
+ * @property {() => () => void} listen Start listening to webRequest for as long as requests are
78
+ * being made; the function it returns stops. Not at the worker's top level: the filter can't
79
+ * name the extension, only “outside any tab”, which is also every website's own service worker,
80
+ * and with access to many sites each of their requests would wake this worker to be thrown away.
81
+ * Work that makes requests keeps the worker awake by itself, so nothing is missed.
82
+ * @property {(url: string, options?: OwnRequestOptions) => Promise<OwnAnswer>} request One request,
83
+ * following nothing. Rejects as `fetch` does (the network, an abort).
84
+ */
85
+
86
+ /**
87
+ * The key a request is matched by: method and URL without its fragment (webRequest never sees one).
88
+ *
89
+ * @param {string} method - 'HEAD' or 'GET'.
90
+ * @param {string} url - The address.
91
+ * @returns {string} The key.
92
+ */
93
+ const requestKey = (method, url) => `${method} ${new URL(url).href.replace(/#.*$/, '')}`;
94
+
95
+ /**
96
+ * One header from webRequest's list (names are case-insensitive).
97
+ *
98
+ * @param {SeenRequest | undefined} seen - What webRequest saw.
99
+ * @param {string} name - The header's name.
100
+ * @returns {string | null} Its value.
101
+ */
102
+ const seenHeader = (seen, name) => (seen && 'responseHeaders' in seen ? seen.responseHeaders : [])
103
+ ?.find((header) => header.name.toLowerCase() === name.toLowerCase())?.value ?? null;
104
+
105
+ /**
106
+ * A wait that gives nothing.
107
+ *
108
+ * @param {number} ms - How long.
109
+ * @returns {Promise<undefined>} Resolves after it.
110
+ */
111
+ const pause = (ms) => new Promise((resolve) => { setTimeout(() => resolve(undefined), ms); });
112
+
113
+ /**
114
+ * The worker's own requests, one at a time and following nothing, a redirect's
115
+ * status and `Location` read through webRequest.
116
+ *
117
+ * @param {object} edges - Chrome's side.
118
+ * @param {OwnRequestEvents} edges.webRequest - `chrome.webRequest`.
119
+ * @param {string} edges.extensionId - `chrome.runtime.id`: only this extension's requests are read.
120
+ * @param {typeof fetch} [edges.fetch] - `fetch`. Default the global one.
121
+ * @param {number} [edges.headersWaitMs] - Default {@link HEADERS_WAIT_MS}.
122
+ * @returns {OwnRequests} `listen` and `request`.
123
+ * @example
124
+ * const own = ownRequests({ webRequest: chrome.webRequest, extensionId: chrome.runtime.id });
125
+ * const stop = own.listen();
126
+ * const { status, headers } = await own.request('https://bit.ly/abc', { method: 'HEAD' });
127
+ * // 301, headers.get('location') === 'https://example.com/'
128
+ * stop();
129
+ */
130
+ export function ownRequests({ webRequest, extensionId, fetch: ask = fetch, headersWaitMs = HEADERS_WAIT_MS }) {
131
+ /** Requests waiting for webRequest's report, by {@link requestKey}. @type {Map<string, (seen: SeenRequest) => void>} */
132
+ const waiting = new Map();
133
+ let listeners = 0;
134
+
135
+ /**
136
+ * Whether a report is of this extension's own request.
137
+ *
138
+ * @param {{tabId: number, initiator?: string}} details - What Chrome reports.
139
+ * @returns {boolean} It is.
140
+ */
141
+ const own = (details) => details.tabId === -1 && details.initiator === `chrome-extension://${extensionId}`;
142
+ /** @param {{tabId: number, initiator?: string, method: string, url: string, statusCode: number, responseHeaders?: {name: string, value?: string}[]}} details */
143
+ const onHeaders = (details) => {
144
+ if (own(details)) waiting.get(requestKey(details.method, details.url))?.(details);
145
+ };
146
+ /** @param {{tabId: number, initiator?: string, method: string, url: string, error: string}} details */
147
+ const onError = (details) => {
148
+ if (own(details)) waiting.get(requestKey(details.method, details.url))?.({ error: details.error });
149
+ };
150
+
151
+ return {
152
+ listen() {
153
+ if (listeners++ === 0) {
154
+ webRequest.onHeadersReceived.addListener(onHeaders, OWN_REQUESTS, ['responseHeaders']);
155
+ webRequest.onErrorOccurred.addListener(onError, OWN_REQUESTS);
156
+ }
157
+ let stopped = false;
158
+ return () => {
159
+ if (stopped) return;
160
+ stopped = true;
161
+ if (--listeners > 0) return;
162
+ webRequest.onHeadersReceived.removeListener(onHeaders);
163
+ webRequest.onErrorOccurred.removeListener(onError);
164
+ };
165
+ },
166
+
167
+ async request(url, { method = 'GET', headers = {}, signal } = {}) {
168
+ const key = requestKey(method, url);
169
+ /** @type {Promise<SeenRequest | undefined>} */
170
+ const seen = new Promise((resolve) => {
171
+ waiting.set(key, resolve);
172
+ signal?.addEventListener('abort', () => resolve(undefined), { once: true });
173
+ });
174
+ try {
175
+ /** @type {Response} */
176
+ let response;
177
+ try {
178
+ response = await ask(url, { method, headers, redirect: 'manual', credentials: 'omit', cache: 'no-store', referrerPolicy: 'no-referrer', signal });
179
+ } catch (error) {
180
+ // Chrome fails even a manual fetch whose redirect leaves the web (Location: mailto:…),
181
+ // reporting only the error: that redirect is still an answer, though where it goes can't be read.
182
+ const details = await Promise.race([seen, pause(200)]);
183
+ if (signal?.aborted || !details || !('error' in details) || details.error !== 'net::ERR_UNSAFE_REDIRECT') throw error;
184
+ return { status: 0, headers: { get: () => null }, response: null, leavesWeb: true };
185
+ }
186
+ if (response.type !== 'opaqueredirect') return { status: response.status, headers: response.headers, response, leavesWeb: false };
187
+ const details = await Promise.race([seen, pause(headersWaitMs)]);
188
+ return {
189
+ status: details && 'statusCode' in details ? details.statusCode : 0,
190
+ headers: { get: (name) => seenHeader(details, name) },
191
+ response: null,
192
+ leavesWeb: false,
193
+ };
194
+ } finally {
195
+ waiting.delete(key);
196
+ }
197
+ },
198
+ };
199
+ }
@@ -4,7 +4,8 @@
4
4
  * the origin an extension asks Chrome for access to, the host a per-site
5
5
  * on/off list keeps, whether a page is on a listed site (subdomains
6
6
  * included), the match patterns for both, and a list with a site added,
7
- * taken out or edited. Pure.
7
+ * taken out or edited; and whether an address is a web page's, and whether
8
+ * it is on this computer or its own network rather than the internet. Pure.
8
9
  *
9
10
  * A site is a web host: http or https, with a dot in its name (or
10
11
  * `localhost`). Text without a scheme is read as https. Only a real host
@@ -117,3 +118,33 @@ export declare function withoutSite(sites: readonly string[], site: string): str
117
118
  * withSiteReplaced(['a.com', 'b.com'], 'a.com', 'b.com'); // ['b.com']
118
119
  */
119
120
  export declare function withSiteReplaced(sites: readonly string[], site: string, edited: string): string[];
121
+ /**
122
+ * Whether an address is a web page (http or https), the only kind an
123
+ * extension can ask Chrome for access to, or fetch.
124
+ *
125
+ * @param {string} url - Any address, valid or not.
126
+ * @returns {boolean} True for http(s).
127
+ * @example
128
+ * isWebUrl('https://example.com/'); // true
129
+ * isWebUrl('mailto:a@example.com'); // false
130
+ */
131
+ export declare function isWebUrl(url: string): boolean;
132
+ /**
133
+ * Whether a host is this computer or on its own network rather than the internet: `localhost`
134
+ * and names under it, `.local`, `.lan`, `.internal` and `.home.arpa` names, a name without a dot, and the addresses
135
+ * kept for loopback, private networks and link-local use (IPv4 0/8, 10/8, 100.64/10, 127/8,
136
+ * 169.254/16, 172.16/12, 192.168/16; IPv6 `::`, `::1`, `fc00::/7`, `fe80::/10`, and IPv4 ones
137
+ * written as IPv6). Told from the address alone: a public name that resolves to a private
138
+ * address can't be seen from here.
139
+ *
140
+ * For anything that follows a link on a person's behalf (a redirect check, a link checker): a hop
141
+ * from a site on the internet to a local address is where to stop, since a request there can
142
+ * change something on a router, a printer or a development server.
143
+ *
144
+ * @param {string} url - A web address (`new URL` writes "0x7f.1" and "2130706433" as 127.0.0.1).
145
+ * @returns {boolean} True for a local address; false for the internet, or what isn't an address.
146
+ * @example
147
+ * isLocalUrl('http://192.168.1.1/admin'); // true
148
+ * isLocalUrl('https://example.com/'); // false
149
+ */
150
+ export declare function isLocalUrl(url: string): boolean;
@@ -4,7 +4,8 @@
4
4
  * the origin an extension asks Chrome for access to, the host a per-site
5
5
  * on/off list keeps, whether a page is on a listed site (subdomains
6
6
  * included), the match patterns for both, and a list with a site added,
7
- * taken out or edited. Pure.
7
+ * taken out or edited; and whether an address is a web page's, and whether
8
+ * it is on this computer or its own network rather than the internet. Pure.
8
9
  *
9
10
  * A site is a web host: http or https, with a dot in its name (or
10
11
  * `localhost`). Text without a scheme is read as https. Only a real host
@@ -184,3 +185,65 @@ export function withoutSite(sites, site) {
184
185
  export function withSiteReplaced(sites, site, edited) {
185
186
  return [...new Set(sites.map((s) => (s === site ? edited : s)))];
186
187
  }
188
+
189
+ /**
190
+ * Whether an address is a web page (http or https), the only kind an
191
+ * extension can ask Chrome for access to, or fetch.
192
+ *
193
+ * @param {string} url - Any address, valid or not.
194
+ * @returns {boolean} True for http(s).
195
+ * @example
196
+ * isWebUrl('https://example.com/'); // true
197
+ * isWebUrl('mailto:a@example.com'); // false
198
+ */
199
+ export function isWebUrl(url) {
200
+ try {
201
+ return ['http:', 'https:'].includes(new URL(url).protocol);
202
+ } catch {
203
+ return false;
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Whether a host is this computer or on its own network rather than the internet: `localhost`
209
+ * and names under it, `.local`, `.lan`, `.internal` and `.home.arpa` names, a name without a dot, and the addresses
210
+ * kept for loopback, private networks and link-local use (IPv4 0/8, 10/8, 100.64/10, 127/8,
211
+ * 169.254/16, 172.16/12, 192.168/16; IPv6 `::`, `::1`, `fc00::/7`, `fe80::/10`, and IPv4 ones
212
+ * written as IPv6). Told from the address alone: a public name that resolves to a private
213
+ * address can't be seen from here.
214
+ *
215
+ * For anything that follows a link on a person's behalf (a redirect check, a link checker): a hop
216
+ * from a site on the internet to a local address is where to stop, since a request there can
217
+ * change something on a router, a printer or a development server.
218
+ *
219
+ * @param {string} url - A web address (`new URL` writes "0x7f.1" and "2130706433" as 127.0.0.1).
220
+ * @returns {boolean} True for a local address; false for the internet, or what isn't an address.
221
+ * @example
222
+ * isLocalUrl('http://192.168.1.1/admin'); // true
223
+ * isLocalUrl('https://example.com/'); // false
224
+ */
225
+ export function isLocalUrl(url) {
226
+ let host;
227
+ try {
228
+ host = new URL(url).hostname.toLowerCase().replace(/\.$/, '');
229
+ } catch {
230
+ return false;
231
+ }
232
+ if (!host) return false;
233
+ if (host.startsWith('[')) {
234
+ const address = host.slice(1, -1);
235
+ const mapped = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(address);
236
+ if (mapped) {
237
+ const [high, low] = [parseInt(mapped[1], 16), parseInt(mapped[2], 16)];
238
+ return isLocalUrl(`http://${high >> 8}.${high & 255}.${low >> 8}.${low & 255}/`);
239
+ }
240
+ return address === '::' || address === '::1' || /^f[cd][0-9a-f]{2}:/.test(address) || /^fe[89ab][0-9a-f]:/.test(address);
241
+ }
242
+ const ipv4 = /^(\d+)\.(\d+)\.\d+\.\d+$/.exec(host);
243
+ if (ipv4) {
244
+ const [a, b] = [Number(ipv4[1]), Number(ipv4[2])];
245
+ return a === 0 || a === 10 || a === 127 || (a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254)
246
+ || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168);
247
+ }
248
+ return host === 'localhost' || !host.includes('.') || /\.(?:localhost|local|internal|lan|home\.arpa)$/.test(host);
249
+ }
@@ -135,9 +135,11 @@ export type OffscreenApi = {
135
135
  * @param {string} [justification] - What the document is for, when it does more than watch the
136
136
  * scheme (it answers for Chrome's on-device model too, `serveModelOffscreen`): Chrome keeps it
137
137
  * with the document, so it has to be the whole truth.
138
+ * @param {readonly string[]} [reasons] - Chrome's reasons for whatever else the document does
139
+ * (`'DOM_PARSER'` for one that also reads fetched HTML), after MATCH_MEDIA. Default none.
138
140
  * @returns {Promise<boolean>} Whether it opened one (false: already open).
139
141
  */
140
- export declare function watchFromOffscreen({ offscreen, runtime }: OffscreenApi, url?: string, justification?: string): Promise<boolean>;
142
+ export declare function watchFromOffscreen({ offscreen, runtime }: OffscreenApi, url?: string, justification?: string, reasons?: readonly string[]): Promise<boolean>;
141
143
  export type ToolbarWorkerApi = {
142
144
  /**
143
145
  * `chrome.storage`.
@@ -182,14 +184,16 @@ export type ToolbarWorkerApi = {
182
184
  * worker wakes.
183
185
  *
184
186
  * @param {ToolbarWorkerApi} api - `chrome`, or a fake.
185
- * @param {{url?: string, justification?: string}} [document] - The offscreen document and what
186
- * it's for, when it isn't the kit's defaults ({@link watchFromOffscreen}).
187
+ * @param {{url?: string, justification?: string, reasons?: readonly string[]}} [document] - The offscreen
188
+ * document, what it's for and Chrome's reasons for what else it does, when they aren't the kit's
189
+ * defaults ({@link watchFromOffscreen}).
187
190
  * @returns {ToolbarTheme} The theme, for tests.
188
191
  * @example
189
192
  * // background.js
190
193
  * startToolbarTheme(chrome);
191
194
  */
192
- export declare function startToolbarTheme(api: ToolbarWorkerApi, { url, justification }?: {
195
+ export declare function startToolbarTheme(api: ToolbarWorkerApi, { url, justification, reasons }?: {
193
196
  url?: string;
194
197
  justification?: string;
198
+ reasons?: readonly string[];
195
199
  }): ToolbarTheme;
@@ -131,15 +131,17 @@ const SCHEME_JUSTIFICATION = 'Reads whether Chrome is in light or dark mode, to
131
131
  * @param {string} [justification] - What the document is for, when it does more than watch the
132
132
  * scheme (it answers for Chrome's on-device model too, `serveModelOffscreen`): Chrome keeps it
133
133
  * with the document, so it has to be the whole truth.
134
+ * @param {readonly string[]} [reasons] - Chrome's reasons for whatever else the document does
135
+ * (`'DOM_PARSER'` for one that also reads fetched HTML), after MATCH_MEDIA. Default none.
134
136
  * @returns {Promise<boolean>} Whether it opened one (false: already open).
135
137
  */
136
- export function watchFromOffscreen({ offscreen, runtime }, url = OFFSCREEN_PATH, justification = SCHEME_JUSTIFICATION) {
138
+ export function watchFromOffscreen({ offscreen, runtime }, url = OFFSCREEN_PATH, justification = SCHEME_JUSTIFICATION, reasons = []) {
137
139
  opening ??= (async () => {
138
140
  const open = await runtime.getContexts({ contextTypes: ['OFFSCREEN_DOCUMENT'] });
139
141
  if (open.length) return false;
140
142
  await offscreen.createDocument({
141
143
  url,
142
- reasons: [offscreen.Reason.MATCH_MEDIA],
144
+ reasons: [offscreen.Reason.MATCH_MEDIA, ...reasons],
143
145
  justification,
144
146
  });
145
147
  return true;
@@ -165,17 +167,18 @@ export function watchFromOffscreen({ offscreen, runtime }, url = OFFSCREEN_PATH,
165
167
  * worker wakes.
166
168
  *
167
169
  * @param {ToolbarWorkerApi} api - `chrome`, or a fake.
168
- * @param {{url?: string, justification?: string}} [document] - The offscreen document and what
169
- * it's for, when it isn't the kit's defaults ({@link watchFromOffscreen}).
170
+ * @param {{url?: string, justification?: string, reasons?: readonly string[]}} [document] - The offscreen
171
+ * document, what it's for and Chrome's reasons for what else it does, when they aren't the kit's
172
+ * defaults ({@link watchFromOffscreen}).
170
173
  * @returns {ToolbarTheme} The theme, for tests.
171
174
  * @example
172
175
  * // background.js
173
176
  * startToolbarTheme(chrome);
174
177
  */
175
- export function startToolbarTheme(api, { url, justification } = {}) {
178
+ export function startToolbarTheme(api, { url, justification, reasons } = {}) {
176
179
  const theme = createToolbarTheme({ storage: api.storage.local, action: api.action, extensionId: api.runtime.id });
177
180
  theme.restore();
178
- watchFromOffscreen(api, url, justification).catch((error) => console.error('Opening the colour scheme watcher:', error));
181
+ watchFromOffscreen(api, url, justification, reasons).catch((error) => console.error('Opening the colour scheme watcher:', error));
179
182
  api.runtime.onMessage.addListener((message, sender) => {
180
183
  theme.onMessage(message, sender);
181
184
  return false;