@ultimat3/scraping 22.15.0 → 24.0.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.
package/src/secrets.ts CHANGED
@@ -7,13 +7,21 @@
7
7
  // logger. The third is not, because pixels cannot be redacted after the fact.
8
8
 
9
9
  import type { Secret } from '@ultimat3/core';
10
- import { revealSecret, secret, UltimateError } from '@ultimat3/core';
10
+ import { escapeHtml, revealSecret, secret, UltimateError } from '@ultimat3/core';
11
11
  import type { ConsoleLine, NetworkEntry, PageError } from './rings';
12
12
 
13
13
  export interface ScrapeSecrets {
14
14
  readonly names: readonly string[];
15
15
  /** The boxed value. Reading it out is `revealSecret()`, one greppable call site. */
16
16
  get(name: string): Secret;
17
+ /**
18
+ * Add a value this run learned MID-RUN to the redaction set: a prompt answer, the credentials
19
+ * of the exit it dials, the connect URL of the browser it rented, a bearer the body read off a
20
+ * page. It has no name and cannot be read back — it exists only so `page.html()`, the rings and
21
+ * the failure artifact never carry it. The same floor applies: shorter than
22
+ * `MIN_REDACTABLE_LENGTH` is not redacted.
23
+ */
24
+ conceal(value: string): void;
17
25
  }
18
26
 
19
27
  /**
@@ -30,6 +38,7 @@ export function createSecretBag(
30
38
  resolve: SecretResolver = fromEnvironment,
31
39
  ): ScrapeSecrets {
32
40
  const boxed = new Map<string, Secret>();
41
+ const learned = new Set<string>();
33
42
  for (const name of names) {
34
43
  const value = resolve(name);
35
44
  if (value === undefined || value === '') {
@@ -42,8 +51,11 @@ export function createSecretBag(
42
51
  }
43
52
  boxed.set(name, secret(value, name));
44
53
  }
45
- return {
54
+ const bag: ScrapeSecrets = {
46
55
  names: [...names],
56
+ conceal(value: string): void {
57
+ if (value !== '') learned.add(value);
58
+ },
47
59
  get(name: string): Secret {
48
60
  const found = boxed.get(name);
49
61
  if (found === undefined) {
@@ -57,8 +69,17 @@ export function createSecretBag(
57
69
  return found;
58
70
  },
59
71
  };
72
+ concealed.set(bag, learned);
73
+ return bag;
60
74
  }
61
75
 
76
+ /**
77
+ * The values `conceal()` was handed, by bag — module-private, so the only way out of a bag is
78
+ * still `revealSecret(secrets.get(name))` for a DECLARED name. A `ScrapeSecrets` built by hand has
79
+ * no entry here and redacts its declared names alone.
80
+ */
81
+ const concealed = new WeakMap<ScrapeSecrets, ReadonlySet<string>>();
82
+
62
83
  export const SECRET_PLACEHOLDER = '[redacted]';
63
84
 
64
85
  /**
@@ -74,6 +95,56 @@ export const SECRET_PLACEHOLDER = '[redacted]';
74
95
  */
75
96
  export const MIN_REDACTABLE_LENGTH = 4;
76
97
 
98
+ /**
99
+ * The SUBSET a serialiser escaped, each character spelt by core's one table — a page escapes fewer
100
+ * characters than `escapeHtml` does (a browser's text serialiser leaves quotes alone), so the
101
+ * spelling is the table applied to that serialiser's set, never a second table.
102
+ */
103
+ const escapeOnly = (value: string, chars: RegExp): string => value.replace(chars, escapeHtml);
104
+
105
+ /**
106
+ * Every spelling a page or a URL writes one value in. The raw value alone missed the case
107
+ * `safeNetwork` exists for: a password in a query string arrives percent-encoded, or form-encoded
108
+ * (`+` for a space, `'` and `(` escaped), and a server-rendered page writes it HTML-escaped — as
109
+ * text (`&amp; &lt; &gt;`, what a browser's serialiser emits), as an attribute (`&amp; &quot;`),
110
+ * or through a template that escapes all five (`&#39;` or `&#x27;` for the apostrophe).
111
+ * Each spelling is at least as long as the value, so the length floor is decided on the value.
112
+ */
113
+ function spellingsOf(value: string): readonly string[] {
114
+ const text = escapeOnly(value, /[&<>]/g);
115
+ const attribute = escapeOnly(value, /[&"]/g);
116
+ const full = escapeOnly(value, /[&<>"]/g);
117
+ return [
118
+ value,
119
+ encodeURIComponent(value),
120
+ new URLSearchParams([['', value]]).toString().slice(1),
121
+ text,
122
+ attribute,
123
+ full.replaceAll("'", '&#39;'),
124
+ full.replaceAll("'", '&#x27;'),
125
+ ];
126
+ }
127
+
128
+ const REGEX_SPECIAL = /[.*+?^${}()|[\]\\/]/g;
129
+
130
+ /**
131
+ * One spelling as a pattern: literal throughout, except that the hex digits of a `%XX` escape
132
+ * match in either case — `%2F` and `%2f` are the same byte, and clients emit both. The value's own
133
+ * letters stay case-sensitive: `Hunter2` is not `hunter2`.
134
+ */
135
+ const spellingPattern = (spelling: string): RegExp =>
136
+ new RegExp(
137
+ spelling
138
+ .split(/(%[0-9A-Fa-f]{2})/)
139
+ .map((part, index) =>
140
+ index % 2 === 1
141
+ ? part.replace(/[A-Fa-f]/g, (digit) => `[${digit.toUpperCase()}${digit.toLowerCase()}]`)
142
+ : part.replace(REGEX_SPECIAL, '\\$&'),
143
+ )
144
+ .join(''),
145
+ 'g',
146
+ );
147
+
77
148
  /**
78
149
  * Redaction BY VALUE, over the text this package hands back or persists. Name-based redaction only
79
150
  * catches a secret travelling under a name somebody remembered to list, and a password pasted into
@@ -90,16 +161,22 @@ export const MIN_REDACTABLE_LENGTH = 4;
90
161
  * `MIN_REDACTABLE_LENGTH`, and pixels — which is why a secret TAINTS the page and captures are
91
162
  * refused outright.
92
163
  *
93
- * Longest first, so a secret that contains another one does not leave its tail behind.
164
+ * Every spelling of every value (`spellingsOf`), longest first, so a secret that contains another
165
+ * one does not leave its tail behind.
94
166
  */
95
167
  export function redactSecrets(text: string, secrets: ScrapeSecrets | undefined): string {
96
- if (secrets === undefined || secrets.names.length === 0) return text;
97
- const values = secrets.names
98
- .map((name) => revealSecret(secrets.get(name)))
99
- .filter((value) => value.length >= MIN_REDACTABLE_LENGTH)
100
- .sort((a, b) => b.length - a.length);
168
+ if (secrets === undefined) return text;
169
+ const learned = concealed.get(secrets);
170
+ if (secrets.names.length === 0 && (learned === undefined || learned.size === 0)) return text;
171
+ const values = [
172
+ ...new Set(
173
+ [...secrets.names.map((name) => revealSecret(secrets.get(name))), ...(learned ?? [])]
174
+ .filter((value) => value.length >= MIN_REDACTABLE_LENGTH)
175
+ .flatMap(spellingsOf),
176
+ ),
177
+ ].sort((a, b) => b.length - a.length);
101
178
  let out = text;
102
- for (const value of values) out = out.split(value).join(SECRET_PLACEHOLDER);
179
+ for (const value of values) out = out.replace(spellingPattern(value), SECRET_PLACEHOLDER);
103
180
  return out;
104
181
  }
105
182
 
@@ -10,6 +10,8 @@
10
10
  // event field, an artifact or a screenshot, and it is summarised for logs by `sessionDigest()` —
11
11
  // counts and an origin, never a value.
12
12
 
13
+ import type { SealKeySource } from '@ultimat3/core';
14
+ import { isJsonObject, isSealed, openText, resolveSealKeys, seal } from '@ultimat3/core';
13
15
  import type { StorageDriver } from '@ultimat3/storage';
14
16
  import { assertSafeKey } from '@ultimat3/storage';
15
17
  import type { ScrapeCookie } from './target';
@@ -36,6 +38,13 @@ export interface SessionState extends SessionSnapshot {
36
38
  readonly key: string;
37
39
  /** ISO 8601. What a `maxAge` on reuse is measured against. */
38
40
  readonly savedAt: string;
41
+ /**
42
+ * Unique per save — what a burn or a tombstone compares to decide whether the record is still
43
+ * the one a run saw (`auth.ts`'s `stillSeen`). Never `savedAt`: two saves in one millisecond, or
44
+ * on one frozen clock, share it. Absent on a record written before it existed; `recordVersion`
45
+ * reads `savedAt` for that one.
46
+ */
47
+ readonly version?: string | undefined;
39
48
  /**
40
49
  * ISO 8601, set when this site REFUSED these credentials — and the reason the record survives
41
50
  * the failure instead of being deleted with it.
@@ -168,42 +177,103 @@ export function memorySessionStore(
168
177
 
169
178
  export const DEFAULT_SESSION_PREFIX = 'scrape-session';
170
179
 
180
+ /** What a stored session is sealed FOR. Bound into the tag: nothing else sealed opens as one. */
181
+ export const SESSION_SEAL_PURPOSE = 'scrape-session';
182
+
183
+ export interface StorageSessionStoreOptions {
184
+ readonly prefix?: string | undefined;
185
+ /**
186
+ * Where the app's master key is read from — `seal()`'s own two fields, with its defaults: the
187
+ * process environment, then `.secrets.key` under the working directory.
188
+ */
189
+ readonly keySource?: SealKeySource | undefined;
190
+ }
191
+
192
+ /** The sealed string out of a stored object, or `undefined` for anything that is not one. */
193
+ const sealedIn = (text: string): string | undefined => {
194
+ try {
195
+ const parsed: unknown = JSON.parse(text);
196
+ return isJsonObject(parsed) && isSealed(parsed['sealed']) ? parsed['sealed'] : undefined;
197
+ } catch {
198
+ return undefined;
199
+ }
200
+ };
201
+
171
202
  /**
172
203
  * Sessions on the app's own disk, through `@ultimat3/storage` — the seam that already knows about
173
204
  * tenant-scoped keys and refuses one that escapes its prefix. This package owns no upload path.
174
205
  *
175
- * NOT encrypted at rest: the bytes are as sensitive as the bucket they sit in, so the bucket must
176
- * be private. Sealing them under core's secrets envelope is the obvious next step and is
177
- * deliberately not guessed at here — it needs a key the app declares, and a wrong guess about
178
- * where that key lives is worse than the honest note.
206
+ * SEALED at rest, under the app's master key — the one `x secrets init` writes — through core's
207
+ * `seal()`, purpose `SESSION_SEAL_PURPOSE`. The stored object is `{ "sealed": "x1.…" }`: no cookie
208
+ * value, no `localStorage` token and no origin is readable from the bucket. With no master key the
209
+ * store refuses (`X_SEAL_KEY_MISSING`) on `load()`, which a run calls BEFORE its browser opens —
210
+ * a login that could not be kept is a login repeated on every attempt.
211
+ *
212
+ * A session is a CACHE, so nothing is migrated: an object that is not a sealed session, does not
213
+ * open under a key this process declares, or opens to a record written for another key is burned
214
+ * and the run authenticates again.
215
+ *
216
+ * `storage` is a THUNK — `storageSessionStore(() => disk('sessions'))` — read on every call and
217
+ * never captured. A `scrape()` is declared when its module is evaluated; the app's disks exist
218
+ * once boot has run `defineStorage()`, which is later. A driver VALUE here could only be a
219
+ * `declare const` in a README.
179
220
  */
180
221
  export function storageSessionStore(
181
- storage: StorageDriver,
182
- options: { readonly prefix?: string | undefined } = {},
222
+ storage: () => StorageDriver,
223
+ options: StorageSessionStoreOptions = {},
183
224
  ): ScrapeSessionStore {
184
225
  const keyFor = (key: string): string => {
185
226
  const path = `${options.prefix ?? DEFAULT_SESSION_PREFIX}/${key}.json`;
186
227
  assertSafeKey(path);
187
228
  return path;
188
229
  };
230
+ /** Best effort: an object that could not be deleted is refused again by the next `load()`. */
231
+ const discard = async (path: string): Promise<undefined> => {
232
+ await storage()
233
+ .delete(path)
234
+ .catch(() => undefined);
235
+ return undefined;
236
+ };
189
237
  return {
190
238
  async load(key: string): Promise<SessionState | undefined> {
239
+ // The key ring FIRST, and outside every catch below: "no master key" is a deploy that cannot
240
+ // keep a session at all, and answering "no session" for it would log in, fail to save, and
241
+ // log in again on the retry.
242
+ const keys = await resolveSealKeys(options.keySource);
243
+ const path = keyFor(key);
244
+ let stored: string;
191
245
  try {
192
- const read = await storage.get(keyFor(key));
193
- return parseSessionState(JSON.parse(new TextDecoder().decode(read.bytes)) as unknown, key);
246
+ stored = new TextDecoder().decode((await storage().get(path)).bytes);
194
247
  } catch {
195
248
  // A session that cannot be read is a session that does not exist. Refusing the run over a
196
249
  // missing cache would make reuse — the fast path — the fragile path.
197
250
  return undefined;
198
251
  }
252
+ const sealed = sealedIn(stored);
253
+ if (sealed === undefined) return discard(path);
254
+ try {
255
+ const opened: unknown = JSON.parse(
256
+ await openText(sealed, { purpose: SESSION_SEAL_PURPOSE, keys }),
257
+ );
258
+ // The tag covers the record, and the record names the key it was saved under: an object
259
+ // copied onto another tenant's path opens, and is still not that tenant's session.
260
+ if (!isJsonObject(opened) || opened['key'] !== key) return discard(path);
261
+ return parseSessionState(opened, key) ?? discard(path);
262
+ } catch {
263
+ return discard(path);
264
+ }
199
265
  },
200
266
  async save(state: SessionState): Promise<void> {
201
- await storage.put(keyFor(state.key), new TextEncoder().encode(JSON.stringify(state)), {
267
+ const sealed = await seal(JSON.stringify(state), {
268
+ purpose: SESSION_SEAL_PURPOSE,
269
+ ...options.keySource,
270
+ });
271
+ await storage().put(keyFor(state.key), new TextEncoder().encode(JSON.stringify({ sealed })), {
202
272
  contentType: 'application/json',
203
273
  });
204
274
  },
205
275
  async burn(key: string): Promise<void> {
206
- await storage.delete(keyFor(key));
276
+ await storage().delete(keyFor(key));
207
277
  },
208
278
  };
209
279
  }
@@ -246,6 +316,7 @@ export function parseSessionState(raw: unknown, key: string): SessionState | und
246
316
  return {
247
317
  key,
248
318
  savedAt: value.savedAt,
319
+ ...(typeof value.version === 'string' ? { version: value.version } : {}),
249
320
  ...(typeof value.refusedAt === 'string' ? { refusedAt: value.refusedAt } : {}),
250
321
  cookies: value.cookies.flatMap((cookie: unknown) => toCookie(cookie) ?? []),
251
322
  headers: value.headers ?? {},
@@ -254,3 +325,6 @@ export function parseSessionState(raw: unknown, key: string): SessionState | und
254
325
  origin: value.origin ?? '',
255
326
  };
256
327
  }
328
+
329
+ /** The identity a conditional burn or tombstone compares: the record's version, else its `savedAt`. */
330
+ export const recordVersion = (state: SessionState): string => state.version ?? state.savedAt;
@@ -0,0 +1,139 @@
1
+ // What a URL that carries a credential may say about itself, and what it must hand the run's
2
+ // secret set. A proxy URL holds its password in the userinfo; a provider's CDP connect URL holds
3
+ // its access token in the query or the path. Both are named in errors and both pass through text
4
+ // this package persists, so the split is made once, here.
5
+
6
+ /**
7
+ * Scheme and host — `wss://connect.provider.test:443` — and nothing a credential can live in: no
8
+ * userinfo, no path, no query. A string that is not a URL answers a fixed phrase rather than
9
+ * itself, because the caller is about to print the answer.
10
+ */
11
+ export function endpointLabel(url: string): string {
12
+ try {
13
+ const parsed = new URL(url);
14
+ return `${parsed.protocol}//${parsed.host}`;
15
+ } catch {
16
+ return 'an endpoint that is not a URL';
17
+ }
18
+ }
19
+
20
+ /** A lone `%` survives `URL` parsing and makes `decodeURIComponent` throw; the raw text is the answer then. */
21
+ const decoded = (part: string): string => {
22
+ try {
23
+ return decodeURIComponent(part);
24
+ } catch {
25
+ return part;
26
+ }
27
+ };
28
+
29
+ /** True when the userinfo carries anything — the half `--proxy-server` cannot be handed. */
30
+ export function hasCredentials(url: string): boolean {
31
+ try {
32
+ const parsed = new URL(url);
33
+ return parsed.username !== '' || parsed.password !== '';
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
39
+ /** `http://user:pass@exit:8080` → `http://exit:8080` plus the pair, decoded as the proxy expects. */
40
+ export function splitCredentials(url: string): {
41
+ readonly bare: string;
42
+ readonly username: string;
43
+ readonly password: string;
44
+ } {
45
+ const parsed = new URL(url);
46
+ const username = decoded(parsed.username);
47
+ const password = decoded(parsed.password);
48
+ parsed.username = '';
49
+ parsed.password = '';
50
+ // `URL` renders a trailing slash for an empty path; a proxy argument never had one.
51
+ return { bare: parsed.toString().replace(/\/$/, ''), username, password };
52
+ }
53
+
54
+ /**
55
+ * The words that name a credential, matched against a query key's COMPONENTS — split on `_`, `-`,
56
+ * `.` and camelCase — never as substrings: `keyboard` holds `key` and `monkey` holds it too, and
57
+ * concealing their values blanked ordinary words out of every artifact. Read off the key and not
58
+ * the value, because a provider's token can be short and the key is the URL's own statement.
59
+ */
60
+ const CREDENTIAL_WORDS = new Set([
61
+ 'token',
62
+ 'key',
63
+ 'apikey',
64
+ 'secret',
65
+ 'auth',
66
+ 'authorization',
67
+ 'sig',
68
+ 'signature',
69
+ 'pass',
70
+ 'password',
71
+ 'passwd',
72
+ 'pwd',
73
+ 'cred',
74
+ 'credential',
75
+ 'credentials',
76
+ 'session',
77
+ 'sessionid',
78
+ 'sid',
79
+ 'jwt',
80
+ 'bearer',
81
+ ]);
82
+
83
+ const credentialKey = (key: string): boolean =>
84
+ key
85
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
86
+ .toLowerCase()
87
+ .split(/[\s_.-]+/)
88
+ .some((part) => CREDENTIAL_WORDS.has(part));
89
+
90
+ /**
91
+ * A value that is a capability whatever it is called: long, one unbroken run, letters and digits
92
+ * mixed — a browser id, an access token. A word (`residential`, `true`, `eu`) is none of those.
93
+ */
94
+ const credentialShaped = (value: string): boolean =>
95
+ value.length >= 16 && !/\s/.test(value) && /[0-9]/.test(value) && /[a-z]/i.test(value);
96
+
97
+ /**
98
+ * Every value in the URL that is, or may be, a credential — for the run's redaction set.
99
+ *
100
+ * The whole URL first, then the parts a message quotes on their own: a launcher's error says
101
+ * `401 for /devtools/browser/<id>?token=…` as readily as it repeats the URL. Encoded and decoded
102
+ * forms both, since a message may carry either. The host is left out on purpose: it is what an
103
+ * error is allowed to say.
104
+ *
105
+ * A query VALUE or a path SEGMENT only when it is credential-shaped or sits under a
106
+ * credential-named key — never every value. Concealing `stealth=true&proxy=residential` blanked
107
+ * every `true` and `residential` in the page, the console and the network ring of the run: the
108
+ * unreadable artifact `MIN_REDACTABLE_LENGTH` exists to prevent.
109
+ */
110
+ export function urlSecretValues(url: string): readonly string[] {
111
+ const values = new Set<string>([url]);
112
+ try {
113
+ const parsed = new URL(url);
114
+ // The password and not the username: a proxy username is an account and a zone, and redacting
115
+ // `admin` by value would blank the word out of every page artifact.
116
+ values.add(parsed.password);
117
+ values.add(decoded(parsed.password));
118
+ if (parsed.search !== '') {
119
+ values.add(`${parsed.pathname}${parsed.search}`);
120
+ values.add(parsed.search.slice(1));
121
+ }
122
+ const segments = parsed.pathname.split('/');
123
+ for (const [index, segment] of segments.entries()) {
124
+ // By POSITION as well as by shape: Chrome's `/devtools/<browser|page>/<id>` id IS the
125
+ // capability, and an id of letters alone is not credential-shaped.
126
+ const capability = index >= 2 && segments[index - 2] === 'devtools';
127
+ if (capability || credentialShaped(decoded(segment))) {
128
+ values.add(decoded(segment)).add(segment);
129
+ }
130
+ }
131
+ for (const [key, value] of parsed.searchParams) {
132
+ if (credentialKey(key) || credentialShaped(value)) values.add(value);
133
+ }
134
+ } catch {
135
+ // Not a URL: the whole string above is all there is to redact.
136
+ }
137
+ values.delete('');
138
+ return [...values];
139
+ }
package/src/usage.ts ADDED
@@ -0,0 +1,101 @@
1
+ // What one run USED, counted where the work happens and reported with its result. A measurement
2
+ // and nothing else: no quota, no plan, no ledger — what a number here is worth, and what happens
3
+ // when it is too high, is the app's (`docs/idea/19-mechanism-not-convention.md`).
4
+
5
+ import type { MoneyValue } from '@ultimat3/schema';
6
+ import type { ScrapeClock } from './clock';
7
+
8
+ export interface ScrapeUsage {
9
+ /** Milliseconds the browser session was held, from before `driver.open()` to this report. */
10
+ readonly browserMs: number;
11
+ /** `page.goto()` calls that reached the driver — a refused host or a robots refusal is not one. */
12
+ readonly navigations: number;
13
+ /** Requests the HTTP leg put on the wire, one per redirect hop. */
14
+ readonly httpRequests: number;
15
+ /**
16
+ * Response-body bytes the HTTP leg read. The BROWSER leg's traffic is not in it: this package's
17
+ * CDP port subscribes to requests, not to their sizes, so a figure for it would be a guess.
18
+ */
19
+ readonly bytesIn: number;
20
+ readonly promptsAnswered: number;
21
+ /**
22
+ * What the rented browser cost, as the `CdpResolver` stated it — a `Money` value, never a float.
23
+ * Absent when the driver rented nothing or the provider prices at release.
24
+ */
25
+ readonly browserCost?: MoneyValue | undefined;
26
+ }
27
+
28
+ /**
29
+ * The counting half, handed to a driver as `SessionInit.usage`. `pageOverTarget`, `httpOverFetch`
30
+ * and `recordedHttp` each call it where they already report activity, so a third-party driver
31
+ * built on them counts by passing the meter through and one that is not counts by calling it.
32
+ */
33
+ export interface UsageMeter {
34
+ navigation(): void;
35
+ /** One request on the wire. `bytes` is the body read for it — 0 for a hop that was followed. */
36
+ httpRequest(bytes: number): void;
37
+ }
38
+
39
+ export interface RunUsageMeter extends UsageMeter {
40
+ promptAnswered(): void;
41
+ /** The counts so far, with the session's age read from the clock this meter was built on. */
42
+ snapshot(browserCost?: MoneyValue | undefined): ScrapeUsage;
43
+ }
44
+
45
+ /** Built BEFORE `driver.open()`, so the time a rented browser took to arrive is time it was held. */
46
+ export function createUsageMeter(clock: ScrapeClock): RunUsageMeter {
47
+ const startedAt = clock.monotonic();
48
+ let navigations = 0;
49
+ let httpRequests = 0;
50
+ let bytesIn = 0;
51
+ let promptsAnswered = 0;
52
+ return {
53
+ navigation(): void {
54
+ navigations += 1;
55
+ },
56
+ httpRequest(bytes: number): void {
57
+ httpRequests += 1;
58
+ bytesIn += bytes;
59
+ },
60
+ promptAnswered(): void {
61
+ promptsAnswered += 1;
62
+ },
63
+ snapshot(browserCost?: MoneyValue | undefined): ScrapeUsage {
64
+ return {
65
+ browserMs: Math.round(clock.monotonic() - startedAt),
66
+ navigations,
67
+ httpRequests,
68
+ bytesIn,
69
+ promptsAnswered,
70
+ ...(browserCost === undefined ? {} : { browserCost }),
71
+ };
72
+ },
73
+ };
74
+ }
75
+
76
+ /** Failed attempts remembered at once. A bound, not a ledger: the oldest goes first. */
77
+ const MAX_FAILED_ATTEMPTS = 256;
78
+
79
+ const failedAttempts = new Map<string, ScrapeUsage>();
80
+
81
+ /**
82
+ * What a FAILED attempt used, held until its run is settled. A failed run has no report — the
83
+ * job's return value is the only thing the queue hands `onSettled` — and it was billed all the
84
+ * same, so the counts are kept by run id in the worker's own process, which is where the hook runs.
85
+ */
86
+ export function rememberFailedUsage(runId: string, usage: ScrapeUsage): void {
87
+ failedAttempts.delete(runId);
88
+ failedAttempts.set(runId, usage);
89
+ while (failedAttempts.size > MAX_FAILED_ATTEMPTS) {
90
+ const oldest = failedAttempts.keys().next();
91
+ if (oldest.done === true) return;
92
+ failedAttempts.delete(oldest.value);
93
+ }
94
+ }
95
+
96
+ /** The last failed attempt's usage for this run, read once: taking it forgets it. */
97
+ export function takeFailedUsage(runId: string): ScrapeUsage | undefined {
98
+ const usage = failedAttempts.get(runId);
99
+ failedAttempts.delete(runId);
100
+ return usage;
101
+ }
File without changes