@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.
@@ -0,0 +1,133 @@
1
+ // A prompt answered from ANOTHER process while the browser stays open: `eventPrompt()` is a
2
+ // `PromptHandler` that polls the stored job event bus, and `answerPrompt()` is the one way an
3
+ // answer is published to it.
4
+ //
5
+ // It polls IN PROCESS and never uses `step.waitForEvent`: that suspends the run, the attempt ends,
6
+ // `runScrape`'s `finally` closes the session — and the site is waiting for the code inside that
7
+ // session. So the run keeps its claim (the worker's heartbeat is a timer, untouched by a poll),
8
+ // keeps the browser alive between polls, and gives up on the run's own cancellation.
9
+ //
10
+ // The event name is DERIVED — `scrape-prompt:<runId>:<n>` — and it is not an authorization: the
11
+ // app's answering action is policy-checked like any action and calls `answerPrompt()`.
12
+
13
+ import type { SealKeySource } from '@ultimat3/core';
14
+ import { finiteCount, isLocal, isSealed, openText, seal } from '@ultimat3/core';
15
+ import type { EventBus, JobEvent } from '@ultimat3/jobs';
16
+ import { DriverUnavailableError, eventBus } from '@ultimat3/jobs';
17
+ import type { PromptHandler, PromptRequest } from './auth';
18
+ import { deadline, throwIfAborted } from './clock';
19
+ import { promptAnswerInvalid, promptTimedOut } from './error-throws-session';
20
+
21
+ /** How often the bus is asked. One indexed read and one browser round trip per interval. */
22
+ export const DEFAULT_PROMPT_POLL_MS = 1_000;
23
+
24
+ /**
25
+ * How long a published answer stays matchable. Short on purpose: an answer is a one-time code,
26
+ * and the stored bus's own default keeps an event for a week.
27
+ */
28
+ export const DEFAULT_PROMPT_ANSWER_TTL_MS = 600_000;
29
+
30
+ /** The one spelling. `index` is `PromptRequest.index`: 1 for the first prompt of an attempt. */
31
+ export const promptEventName = (runId: string, index: number): string =>
32
+ `scrape-prompt:${runId}:${String(index)}`;
33
+
34
+ export interface EventPromptOptions {
35
+ /** Milliseconds one prompt waits for its answer before `X_SCRAPE_PROMPT_UNANSWERED`. */
36
+ readonly timeout: number;
37
+ /** Milliseconds between two looks at the bus. Defaults to `DEFAULT_PROMPT_POLL_MS`. */
38
+ readonly pollMs?: number | undefined;
39
+ /**
40
+ * The bus to read. Defaults to the ambient one, read when the prompt is ASKED — boot installs
41
+ * the stored bus after this module is evaluated. It has to be the stored one in any deployment
42
+ * with more than one process: the answer is published by a web process, never by this worker.
43
+ */
44
+ readonly bus?: EventBus | undefined;
45
+ /** Where the master key is read from, for opening the answer. `seal()`'s own two fields. */
46
+ readonly keySource?: SealKeySource | undefined;
47
+ /** Where the environment is read from. Defaults to the process's own. */
48
+ readonly env?: Readonly<Record<string, string | undefined>> | undefined;
49
+ }
50
+
51
+ /**
52
+ * `prompt: eventPrompt({ timeout: 300_000 })` on a `scrape()` definition.
53
+ *
54
+ * Only an answer published AFTER the prompt was asked is consumed. The run id is the same on
55
+ * every attempt, so a code left over from attempt 1 would otherwise be typed into the site by
56
+ * attempt 2 — and a stale code is a refused login. "After" is decided on ONE clock, the bus's
57
+ * (`bus.now()`); the timeout and the poll stay on the run's own.
58
+ */
59
+ export function eventPrompt(options: EventPromptOptions): PromptHandler {
60
+ // Floors of 1, both: a zero timeout is a prompt that can never be answered and a zero interval
61
+ // is the poll loop spinning on the worker's only thread.
62
+ const timeoutMs = finiteCount('eventPrompt', 'timeout', options.timeout, 1);
63
+ const pollMs = finiteCount('eventPrompt', 'pollMs', options.pollMs ?? DEFAULT_PROMPT_POLL_MS, 1);
64
+ return async (request: PromptRequest): Promise<string> => {
65
+ const bus = options.bus ?? eventBus();
66
+ // Refused when the prompt is ASKED, before the wait: the answer is published by a web process
67
+ // and read by this worker, so an in-memory bus can only ever time out — minutes later, as
68
+ // `X_SCRAPE_PROMPT_UNANSWERED`, blaming the human who did answer. Development and test run
69
+ // every role in one process, where the in-memory bus is the right one. `=== false`, never
70
+ // `!bus.stored`: only a bus that SAYS it is not stored is refused.
71
+ if (bus.stored === false && !isLocal({ env: options.env, fallback: 'production' })) {
72
+ throw new DriverUnavailableError({
73
+ driver: 'event bus',
74
+ cause:
75
+ 'eventPrompt() was asked on the in-memory event bus — the answer is published by another process and can never reach this one',
76
+ fix: 'call setEventBus(createPgEventBus({ executor })) at boot — x dev and the served roles do — or pass bus: createPgEventBus({ executor }) to eventPrompt()',
77
+ });
78
+ }
79
+ const event = promptEventName(request.runId, request.index);
80
+ // The BUS's clock, read once: an answer's `publishedAt` is stamped by it — the database, for
81
+ // the stored bus — and `request.clock` is this worker's. Comparing the two let a few seconds
82
+ // of skew between pods decide that an answer given after the ask had come before it.
83
+ const askedAt = await bus.now();
84
+ const budget = deadline(request.clock, timeoutMs, 'eventPrompt');
85
+ for (;;) {
86
+ if (request.signal !== undefined) throwIfAborted(request.signal);
87
+ const found = await bus.find(event, undefined, askedAt);
88
+ if (found !== undefined) {
89
+ if (!isSealed(found.payload)) {
90
+ throw promptAnswerInvalid({ scrape: request.scrape, label: request.label, event });
91
+ }
92
+ // The purpose IS the event name, so an answer sealed for another run or another prompt of
93
+ // this one fails its tag instead of being typed.
94
+ return await openText(found.payload, { purpose: event, ...options.keySource });
95
+ }
96
+ if (budget.expired()) {
97
+ throw promptTimedOut({
98
+ scrape: request.scrape,
99
+ label: request.label,
100
+ event,
101
+ waitedMs: timeoutMs,
102
+ });
103
+ }
104
+ await request.keepAlive();
105
+ await request.clock.sleep(Math.min(pollMs, budget.remainingMs()), request.signal);
106
+ }
107
+ };
108
+ }
109
+
110
+ export interface AnswerPromptInput {
111
+ readonly runId: string;
112
+ /** Which prompt of the run: `PromptRequest.index`. */
113
+ readonly index: number;
114
+ readonly answer: string;
115
+ /** Milliseconds the answer stays matchable. Defaults to `DEFAULT_PROMPT_ANSWER_TTL_MS`. */
116
+ readonly ttl?: number | undefined;
117
+ readonly bus?: EventBus | undefined;
118
+ readonly keySource?: SealKeySource | undefined;
119
+ }
120
+
121
+ /**
122
+ * Publish one prompt's answer. Called from the app's own action, after its policy passed.
123
+ *
124
+ * The answer is SEALED before it is published: the stored bus is a table, and an OTP or a
125
+ * security answer written there in the clear is a credential in a queue row.
126
+ */
127
+ export async function answerPrompt(input: AnswerPromptInput): Promise<JobEvent> {
128
+ const event = promptEventName(input.runId, input.index);
129
+ const sealed = await seal(input.answer, { purpose: event, ...input.keySource });
130
+ return (input.bus ?? eventBus()).publish(event, sealed, {
131
+ ttl: finiteCount('answerPrompt', 'ttl', input.ttl ?? DEFAULT_PROMPT_ANSWER_TTL_MS, 1),
132
+ });
133
+ }
package/src/events.ts CHANGED
@@ -33,6 +33,12 @@ export interface ScrapeEventFields {
33
33
  readonly refused?: number | undefined;
34
34
  readonly reused?: boolean | undefined;
35
35
  readonly burned?: boolean | undefined;
36
+ /** `ScrapeUsage`'s counts — numbers only, so a failed run still says what it spent. */
37
+ readonly browserMs?: number | undefined;
38
+ readonly navigations?: number | undefined;
39
+ readonly httpRequests?: number | undefined;
40
+ readonly bytesIn?: number | undefined;
41
+ readonly promptsAnswered?: number | undefined;
36
42
  }
37
43
 
38
44
  /** One child logger per run, so every line downstream carries the run's identity unasked. */
package/src/expect.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // where the data went. `expect` turns that into a red run: a yield under `minRows`, or under
4
4
  // `maxDrop` of what this scrape normally returns, throws.
5
5
 
6
- import { finiteCount, finiteOption } from '@ultimat3/core';
6
+ import { assert, finiteCount, finiteOption } from '@ultimat3/core';
7
7
  import { yieldCollapsed } from './error-throws';
8
8
  import type { ScrapeError } from './errors';
9
9
 
@@ -57,6 +57,23 @@ export interface YieldCheck {
57
57
  readonly history: readonly number[];
58
58
  }
59
59
 
60
+ /**
61
+ * `maxDrop`, screened: finite, then a FRACTION in `[0, 1)`. `1` and anything above it — `50`, the
62
+ * percent habit — make `baseline * (1 - maxDrop)` zero or negative, so no yield is ever under it
63
+ * and the alarm is silent forever; a negative one fires on a run that matched its baseline. Asked
64
+ * by the rule and by `scrape()`, where the number is written.
65
+ */
66
+ export function maxDropFraction(scrape: string, value: number): number {
67
+ const fraction = finiteOption('the scrape expect', 'maxDrop', value);
68
+ assert(
69
+ fraction >= 0 && fraction < 1,
70
+ `scrape "${scrape}" declares expect.maxDrop: ${String(fraction)}, and maxDrop is a fraction of the trailing median from 0 up to but not including 1 — at 1 or above no yield is ever under the line and the alarm can never fire; write it as a fraction, expect: { maxDrop: 0.5 } allows half`,
71
+ // Lists every declaration outside [0, 1) — static, so no declared value reaches a shell.
72
+ "grep -rnE --include='*.ts' 'maxDrop: *(-|[1-9]|1\\.)' .",
73
+ );
74
+ return fraction;
75
+ }
76
+
60
77
  /**
61
78
  * The whole rule, pure: the error this yield earns, or `undefined`. Pure so a test can hand it
62
79
  * fifty histories without a queue, a browser or a clock.
@@ -69,8 +86,8 @@ export function yieldProblem(check: YieldCheck): ScrapeError | undefined {
69
86
  // instead, and an alarm that always fires is an alarm somebody turns off.
70
87
  //
71
88
  // `minRows: 0` is legal and stays legal: this file asks an author whose answer is legitimately
72
- // sometimes zero to declare exactly that. `maxDrop` is a FRACTION of a median, so `finiteOption`
73
- // and not `finiteCount` — `0.5` is the documented value.
89
+ // sometimes zero to declare exactly that. `maxDrop` is a FRACTION of a median, so not
90
+ // `finiteCount` — `0.5` is the documented value (`maxDropFraction`).
74
91
  const minRows =
75
92
  check.expect.minRows === undefined
76
93
  ? undefined
@@ -78,7 +95,7 @@ export function yieldProblem(check: YieldCheck): ScrapeError | undefined {
78
95
  const maxDrop =
79
96
  check.expect.maxDrop === undefined
80
97
  ? undefined
81
- : finiteOption('the scrape expect', 'maxDrop', check.expect.maxDrop);
98
+ : maxDropFraction(check.scrape, check.expect.maxDrop);
82
99
  if (minRows !== undefined && check.rows < minRows) {
83
100
  return yieldCollapsed({ scrape: check.scrape, rows: check.rows, reason: 'min-rows', minRows });
84
101
  }
@@ -5,16 +5,11 @@
5
5
  // through to the network would make a green offline suite that is secretly hitting production —
6
6
  // the exact failure an offline driver exists to prevent.
7
7
 
8
+ import { NotImplementedError } from '@ultimat3/core';
8
9
  import type { CaptureClip } from './capture-clip';
9
10
  import type { ScrapeClock } from './clock';
10
11
  import type { ColorScheme } from './color-scheme';
11
- import {
12
- browserUnreachable,
13
- downloadTimeout,
14
- fixtureMissing,
15
- fixtureStale,
16
- scrapeNotImplemented,
17
- } from './error-throws';
12
+ import { browserUnreachable, downloadTimeout, fixtureMissing, fixtureStale } from './error-throws';
18
13
  import { queryHtml } from './html-query';
19
14
  import { markupRequests } from './html-requests';
20
15
  import type { InterceptRules } from './intercept';
@@ -358,10 +353,10 @@ export function htmlTarget(init: HtmlTargetInit): ScrapeTarget {
358
353
  * attribute would pass every test against the element a screen reader cannot reach.
359
354
  */
360
355
  async accessibility(_selector: string, _max: number): Promise<readonly AxNode[]> {
361
- throw scrapeNotImplemented(
362
- `accessibility() on the ${init.driver} driver`,
363
- 'run this assertion on localBrowser()/remoteBrowser(), whose accessibility() reads the tree the browser computed — an offline driver parses markup and computes no role',
364
- );
356
+ throw new NotImplementedError({
357
+ cause: `accessibility() on the ${init.driver} driver is not implemented by this driver`,
358
+ fix: 'run this assertion on localBrowser()/remoteBrowser(), whose accessibility() reads the tree the browser computed — an offline driver parses markup and computes no role',
359
+ });
365
360
  },
366
361
  evaluate(expression: string): Promise<unknown> {
367
362
  live();
@@ -381,10 +376,10 @@ export function htmlTarget(init: HtmlTargetInit): ScrapeTarget {
381
376
  // `async`, so the refusal REJECTS: the method is typed `Promise<void>` and a synchronous
382
377
  // `throw` from one jumps straight over `page.offline(true).catch(…)` at every caller.
383
378
  async setOfflineMode(_enabled: boolean): Promise<void> {
384
- throw scrapeNotImplemented(
385
- `setOfflineMode() on the ${init.driver} driver`,
386
- 'run this assertion on localBrowser()/remoteBrowser(), whose setOfflineMode() reaches a real browser — an offline driver has no network to cut, so it cannot prove an offline behaviour',
387
- );
379
+ throw new NotImplementedError({
380
+ cause: `setOfflineMode() on the ${init.driver} driver is not implemented by this driver`,
381
+ fix: 'run this assertion on localBrowser()/remoteBrowser(), whose setOfflineMode() reaches a real browser — an offline driver has no network to cut, so it cannot prove an offline behaviour',
382
+ });
388
383
  },
389
384
  /**
390
385
  * ACCEPTED and recorded, where `setOfflineMode` refuses — and the line between them is which
@@ -15,6 +15,7 @@ import type { HttpRecording } from './recording';
15
15
  import type { NetworkRing } from './rings';
16
16
  import type { RobotsGate } from './robots';
17
17
  import type { ScrapeSecrets } from './secrets';
18
+ import type { UsageMeter } from './usage';
18
19
 
19
20
  export type HttpRecordingLookup = (
20
21
  method: string,
@@ -36,6 +37,8 @@ export interface RecordedHttpInit {
36
37
  readonly maxAgeMs?: number | undefined;
37
38
  /** The SAME bag the live leg holds, so a recorded 4xx body redacts the way a real one does. */
38
39
  readonly secrets?: ScrapeSecrets | undefined;
40
+ /** The SAME meter the live leg counts into, so a fixture run reports the usage a real one would. */
41
+ readonly usage?: UsageMeter | undefined;
39
42
  }
40
43
 
41
44
  /** `GET https://api.example.com/v1/orders?page=2` -> `http-get-api-example-com-v1-orders-page-2`. */
@@ -61,7 +64,9 @@ export function recordedHttp(init: RecordedHttpInit): ScrapeHttp {
61
64
  if (found === undefined) throw fixtureMissing(`${method} ${url}`, init.source);
62
65
  if (init.maxAgeMs !== undefined && found.recordedAt !== undefined) {
63
66
  const age = init.clock.now().getTime() - new Date(found.recordedAt).getTime();
64
- if (age > init.maxAgeMs) throw fixtureStale(`${method} ${url}`, age, init.maxAgeMs);
67
+ // `!(age <= limit)`, never `age > limit`: an unparseable `recordedAt` makes `age` NaN, and
68
+ // `NaN > limit` is false — a recording of unknown age replayed as fresh. `auth.ts` the same.
69
+ if (!(age <= init.maxAgeMs)) throw fixtureStale(`${method} ${url}`, age, init.maxAgeMs);
65
70
  }
66
71
  init.network.push({
67
72
  method,
@@ -70,6 +75,7 @@ export function recordedHttp(init: RecordedHttpInit): ScrapeHttp {
70
75
  resourceType: 'fetch',
71
76
  at: init.clock.now().getTime(),
72
77
  });
78
+ init.usage?.httpRequest(new TextEncoder().encode(found.body).length);
73
79
  return responseOver(
74
80
  url,
75
81
  found.status,
package/src/http.ts CHANGED
@@ -20,11 +20,14 @@ import type { RedirectHop } from './http-redirect';
20
20
  import { MAX_REDIRECT_HOPS, redirectHop } from './http-redirect';
21
21
  import type { InterceptRules } from './intercept';
22
22
  import { interceptVerdict } from './intercept';
23
+ import type { DialTarget, HostResolve } from './pinned-host';
24
+ import { dialTarget, resolveHost } from './pinned-host';
23
25
  import type { NetworkRing } from './rings';
24
26
  import type { RobotsGate } from './robots';
25
27
  import type { ScrapeSecrets } from './secrets';
26
28
  import { redactSecrets } from './secrets';
27
29
  import type { SessionSnapshot } from './session-state';
30
+ import type { UsageMeter } from './usage';
28
31
 
29
32
  /**
30
33
  * Just the call. `typeof fetch` also carries `preconnect`, which no test double and no app wrapper
@@ -42,6 +45,11 @@ export type ScrapeFetch = (input: string, init: ScrapeFetchInit) => Promise<Resp
42
45
  export interface ScrapeFetchInit extends RequestInit {
43
46
  /** The session's exit. A different exit IP mid-session is a different client to an anti-bot. */
44
47
  readonly proxy?: string | undefined;
48
+ /**
49
+ * The NAME a pinned connection proves (`pinned-host.ts`): the url names the approved address,
50
+ * and Bun's `fetch` verifies the certificate against this instead.
51
+ */
52
+ readonly tls?: { readonly serverName?: string | undefined } | undefined;
45
53
  }
46
54
 
47
55
  export interface HttpRequestInit {
@@ -110,7 +118,11 @@ export interface HttpTransportInit {
110
118
  * until 2026-08-24, so nothing on this leg COULD redact.
111
119
  */
112
120
  readonly secrets?: ScrapeSecrets | undefined;
121
+ /** The run's meter: one `httpRequest` per hop on the wire, with the body bytes read for it. */
122
+ readonly usage?: UsageMeter | undefined;
113
123
  readonly fetch?: ScrapeFetch | undefined;
124
+ /** The resolver a wildcard-admitted name is checked and pinned with. Defaults to `Bun.dns`. */
125
+ readonly resolve?: HostResolve | undefined;
114
126
  }
115
127
 
116
128
  /**
@@ -193,6 +205,22 @@ const withoutCredentials = (headers: Readonly<Record<string, string>>): Record<s
193
205
  Object.entries(headers).filter(([name]) => !CROSS_ORIGIN_STRIPPED.has(name.toLowerCase())),
194
206
  );
195
207
 
208
+ /**
209
+ * The request's headers from every source, LATER sources winning, keyed by the lower-cased name.
210
+ * HTTP names are case-insensitive and `Headers` APPENDS two spellings of one name: a caller's
211
+ * `User-Agent: Mine` beside the session's `user-agent` went out as `BrowserUA, Mine`, and a
212
+ * declared `Cookie` was joined to the jar's instead of replacing it.
213
+ */
214
+ const composeHeaders = (
215
+ ...sources: readonly Readonly<Record<string, string>>[]
216
+ ): Record<string, string> => {
217
+ const out = new Map<string, string>();
218
+ for (const source of sources) {
219
+ for (const [name, value] of Object.entries(source)) out.set(name.toLowerCase(), value);
220
+ }
221
+ return Object.fromEntries(out);
222
+ };
223
+
196
224
  /** Unparseable is not same-origin: a URL this package cannot read is one it cannot vouch for. */
197
225
  const sameOrigin = (left: string, right: string): boolean => {
198
226
  try {
@@ -213,8 +241,11 @@ const readResponse = async (
213
241
  response: Response,
214
242
  maxBytes: number,
215
243
  secrets: ScrapeSecrets | undefined,
244
+ usage: UsageMeter | undefined,
216
245
  ): Promise<ScrapeResponse> => {
217
246
  const capped = await readWithinLimit(response.body, maxBytes);
247
+ // Counted on BOTH branches: the bytes a refused body cost were read before the refusal.
248
+ usage?.httpRequest('over' in capped ? capped.over : capped.bytes.length);
218
249
  if ('over' in capped) throw bodyTooLarge(url, capped.over, maxBytes);
219
250
  const body = new TextDecoder().decode(capped.bytes);
220
251
  return responseOver(
@@ -245,12 +276,21 @@ export function httpOverFetch(init: HttpTransportInit): ScrapeHttp {
245
276
  * The three gates, in one place, so the initial URL and hop seven are screened by the same code
246
277
  * in the same order. A second copy for redirects is how the two drift.
247
278
  */
248
- const screen = async (target: string): Promise<void> => {
279
+ const resolve = init.resolve ?? resolveHost;
280
+ const screen = async (target: string): Promise<DialTarget> => {
249
281
  if (interceptVerdict(target, 'fetch', init.rules) !== 'allow') {
250
282
  throw hostBlocked(target, init.rules.allowHosts);
251
283
  }
284
+ // Through a proxy the PROXY resolves the name, from its own network, so an answer from this
285
+ // box's resolver says nothing about where the request lands — and pinning would hand the
286
+ // proxy an address where it expects the name.
287
+ const dial =
288
+ init.proxy === undefined
289
+ ? await dialTarget(target, init.rules.allowHosts, resolve)
290
+ : { url: target };
252
291
  await init.robots?.assertAllowed(target);
253
292
  await init.pace?.(init.signal);
293
+ return dial;
254
294
  };
255
295
  return {
256
296
  async request(url: string, request: HttpRequestInit = {}): Promise<ScrapeResponse> {
@@ -278,7 +318,7 @@ export function httpOverFetch(init: HttpTransportInit): ScrapeHttp {
278
318
  1,
279
319
  );
280
320
  init.onActivity?.();
281
- await screen(url);
321
+ let dial = await screen(url);
282
322
  const session = await init.session();
283
323
  // `AbortSignal.timeout` and NOT `clock.sleep`: this is a deadline handed to the platform's
284
324
  // own fetch, not a wait this package performs — and under a test clock a slept deadline
@@ -313,14 +353,17 @@ export function httpOverFetch(init: HttpTransportInit): ScrapeHttp {
313
353
  const declared = credentialsInScope
314
354
  ? request.headers
315
355
  : withoutCredentials(request.headers ?? {});
316
- const response = await call(hop.url, {
356
+ const response = await call(dial.url, {
317
357
  method: hop.method,
318
- headers: {
319
- ...carried,
320
- ...(session.userAgent === '' ? {} : { 'user-agent': session.userAgent }),
321
- ...(cookies === undefined ? {} : { cookie: cookies }),
322
- ...declared,
323
- },
358
+ headers: composeHeaders(
359
+ carried,
360
+ session.userAgent === '' ? {} : { 'user-agent': session.userAgent },
361
+ cookies === undefined ? {} : { cookie: cookies },
362
+ declared ?? {},
363
+ // Last: a pinned request is reached AS the name that was screened, whatever was declared.
364
+ dial.host === undefined ? {} : { host: dial.host },
365
+ ),
366
+ ...(dial.serverName === undefined ? {} : { tls: { serverName: dial.serverName } }),
324
367
  ...(hop.body === undefined ? {} : { body: hop.body }),
325
368
  signal: AbortSignal.any(signals),
326
369
  // The whole point: the platform's own `follow` is what made the four lines above
@@ -345,14 +388,16 @@ export function httpOverFetch(init: HttpTransportInit): ScrapeHttp {
345
388
  hop.body,
346
389
  );
347
390
  if (next === undefined)
348
- return await readResponse(hop.url, response, maxBytes, init.secrets);
391
+ return await readResponse(hop.url, response, maxBytes, init.secrets, init.usage);
392
+ // A followed hop was a request on the wire too; its body is discarded unread.
393
+ init.usage?.httpRequest(0);
349
394
  // Discarded BEFORE the refusal, not after it: a throw over an unread stream holds that
350
395
  // hop's socket until the collector reaches it, and the refusal path is exactly the one a
351
396
  // hostile chain drives ten times per request.
352
397
  await discardHopBody(response);
353
398
  if (followed >= MAX_REDIRECT_HOPS) throw redirectLoop(url, next.url, followed + 1);
354
399
  init.onActivity?.();
355
- await screen(next.url);
400
+ dial = await screen(next.url);
356
401
  if (!sameOrigin(hop.url, next.url)) credentialsInScope = false;
357
402
  hop = next;
358
403
  }
package/src/index.ts CHANGED
@@ -12,6 +12,7 @@ export {
12
12
  export type {
13
13
  AuthContext,
14
14
  PromptHandler,
15
+ PromptInit,
15
16
  PromptRequest,
16
17
  ScrapeAuth,
17
18
  } from './auth';
@@ -21,6 +22,8 @@ export type { CaptureClip, CaptureFraming } from './capture-clip';
21
22
  export { assertCaptureFraming } from './capture-clip';
22
23
  export type {
23
24
  CdpBrowserLike,
25
+ CdpBrowserSessionLike,
26
+ CdpBrowserTargetLike,
24
27
  CdpFrameLike,
25
28
  CdpKeyboardLike,
26
29
  CdpLauncherLike,
@@ -29,11 +32,21 @@ export type {
29
32
  CdpScreenshotOptions,
30
33
  CdpSessionLike,
31
34
  } from './cdp-port';
35
+ export type { CdpResolution, CdpResolver, CdpResolverRequest } from './cdp-resolver';
32
36
  export { snapshotExpression } from './cdp-snapshot';
33
37
  export type { CdpTargetInit } from './cdp-target';
34
38
  export { CDP_DRIVER, cdpTarget } from './cdp-target';
35
39
  export type { Deadline, ScrapeClock, TestScrapeClock } from './clock';
36
- export { deadline, systemScrapeClock, testClock, throwIfAborted } from './clock';
40
+ export {
41
+ deadline,
42
+ noWaitClock,
43
+ resetScrapeClock,
44
+ scrapeClock,
45
+ setScrapeClock,
46
+ systemScrapeClock,
47
+ testClock,
48
+ throwIfAborted,
49
+ } from './clock';
37
50
  export type { ColorScheme } from './color-scheme';
38
51
  export { COLOR_SCHEMES, isColorScheme } from './color-scheme';
39
52
  export { cookieHeaderFor } from './cookie-scope';
@@ -43,14 +56,15 @@ export type { BrowserOptions, LocalBrowserOptions, RemoteBrowserOptions } from '
43
56
  export { localBrowser, remoteBrowser } from './driver-cdp';
44
57
  export type { FakeBrowserOptions, FakePageOptions, FakePages } from './driver-fake';
45
58
  export { FAKE_DRIVER, FAKE_PAGE_URL, fakeBrowser, fakePage, recordingsOf } from './driver-fake';
46
- export type { FixtureBrowserOptions } from './driver-fixture';
47
- export { FIXTURE_DRIVER, fixtureBrowser } from './driver-fixture';
59
+ export type { FixtureBrowserOptions } from './driver-recorded';
60
+ export { FIXTURE_DRIVER, fixtureBrowser } from './driver-recorded';
48
61
  export {
49
62
  authFailed,
50
63
  blocked,
51
64
  bodyTooLarge,
52
65
  browserUnreachable,
53
66
  cdpAttachFailed,
67
+ cdpResolveFailed,
54
68
  downloadTimeout,
55
69
  driverUnknown,
56
70
  fixtureMissing,
@@ -67,7 +81,6 @@ export {
67
81
  redirectLoop,
68
82
  remoteRequired,
69
83
  robotsDisallowed,
70
- scrapeNotImplemented,
71
84
  scrapeTimeout,
72
85
  secretExposed,
73
86
  selectorMissing,
@@ -75,6 +88,12 @@ export {
75
88
  wedged,
76
89
  yieldCollapsed,
77
90
  } from './error-throws';
91
+ export {
92
+ egressInPayload,
93
+ egressUnsupported,
94
+ promptAnswerInvalid,
95
+ promptTimedOut,
96
+ } from './error-throws-session';
78
97
  export type { ScrapeErrorCode, ScrapeErrorInit, ScrapeOwnedErrorCode } from './errors';
79
98
  export {
80
99
  isRetryableScrapeError,
@@ -86,6 +105,14 @@ export {
86
105
  SCRAPE_OWNED_ERROR_CODES,
87
106
  ScrapeError,
88
107
  } from './errors';
108
+ export type { AnswerPromptInput, EventPromptOptions } from './event-prompt';
109
+ export {
110
+ answerPrompt,
111
+ DEFAULT_PROMPT_ANSWER_TTL_MS,
112
+ DEFAULT_PROMPT_POLL_MS,
113
+ eventPrompt,
114
+ promptEventName,
115
+ } from './event-prompt';
89
116
  export type { ScrapeEventFields, StepEvent } from './events';
90
117
  export type { YieldCheck, YieldExpectation, YieldGuardInput, YieldHistory } from './expect';
91
118
  export {
@@ -164,6 +191,7 @@ export type {
164
191
  ScrapeDefinition,
165
192
  ScrapeReport,
166
193
  ScrapeRunArgs,
194
+ ScrapeSettled,
167
195
  } from './scrape';
168
196
  export { scrape } from './scrape';
169
197
  export { DEFAULT_PAGE_TIMEOUT_MS, runScrape } from './scrape-run';
@@ -177,10 +205,17 @@ export {
177
205
  safeNetwork,
178
206
  safePageErrors,
179
207
  } from './secrets';
180
- export type { ScrapeSessionStore, SessionSnapshot, SessionState } from './session-state';
208
+ export type {
209
+ ScrapeSessionStore,
210
+ SessionSnapshot,
211
+ SessionState,
212
+ StorageSessionStoreOptions,
213
+ } from './session-state';
181
214
  export {
182
215
  memorySessionStore,
183
216
  parseSessionState,
217
+ recordVersion,
218
+ SESSION_SEAL_PURPOSE,
184
219
  sessionDigest,
185
220
  sessionKeyFor,
186
221
  storageSessionStore,
@@ -196,5 +231,8 @@ export type {
196
231
  ScrapeDownloadFile,
197
232
  ScrapeTarget,
198
233
  } from './target';
234
+ export { endpointLabel, urlSecretValues } from './url-secrets';
235
+ export type { RunUsageMeter, ScrapeUsage, UsageMeter } from './usage';
236
+ export { createUsageMeter } from './usage';
199
237
  export type { WedgeGuard, WedgeGuardInit } from './watchdog';
200
238
  export { DEFAULT_GRACE_MS } from './watchdog';
@@ -0,0 +1,51 @@
1
+ // The argument list a launched browser gets: the caller's own, then the exit. The exit is the
2
+ // session's — the driver's `proxy` or the run's `egress` — so it is appended LAST and a caller
3
+ // switch that would decide the route instead is refused rather than ranked.
4
+
5
+ import { launchArgsInvalid } from './error-throws-session';
6
+
7
+ /**
8
+ * Every switch Chrome reads a proxy decision from. Matched on the switch NAME, case-folded and
9
+ * with either prefix POSIX Chrome accepts (`--` and `-`), so a value that merely mentions a proxy
10
+ * (`--user-agent=proxy-server-test`) is not refused.
11
+ */
12
+ const ROUTE_SWITCHES = new Set([
13
+ 'proxy-server',
14
+ 'proxy-bypass-list',
15
+ 'proxy-pac-url',
16
+ 'proxy-auto-detect',
17
+ 'no-proxy-server',
18
+ ]);
19
+
20
+ const switchName = (arg: string): string | undefined => {
21
+ const match = /^--?([^=]+)/.exec(arg);
22
+ return match?.[1]?.toLowerCase();
23
+ };
24
+
25
+ /**
26
+ * `undefined` when the launch carries no args at all — the launcher's own defaults stay untouched
27
+ * then. Refused with `X_SCRAPE_LAUNCH_ARGS_INVALID` before anything starts: a browser launched on a
28
+ * second route is a session that reports one exit and leaves the worker by another.
29
+ */
30
+ export function launchArgs(
31
+ callerArgs: unknown,
32
+ proxyArg: string | undefined,
33
+ ): string[] | undefined {
34
+ if (callerArgs !== undefined) {
35
+ if (!Array.isArray(callerArgs) || !callerArgs.every((arg) => typeof arg === 'string')) {
36
+ throw launchArgsInvalid('is not an array of strings', false);
37
+ }
38
+ for (const arg of callerArgs as readonly string[]) {
39
+ const name = switchName(arg);
40
+ if (name !== undefined && ROUTE_SWITCHES.has(name)) {
41
+ throw launchArgsInvalid(
42
+ `sets --${name}, which decides the route this session leaves by`,
43
+ true,
44
+ );
45
+ }
46
+ }
47
+ }
48
+ const args = [...((callerArgs as readonly string[] | undefined) ?? [])];
49
+ if (proxyArg !== undefined) args.push(`--proxy-server=${proxyArg}`);
50
+ return args.length === 0 ? undefined : args;
51
+ }
@@ -42,6 +42,10 @@ export async function openOfflineSession(init: OfflineSessionInit): Promise<Scra
42
42
  if (init.session.restore !== undefined) await target.restore(init.session.restore);
43
43
  return {
44
44
  driver: init.driver,
45
+ // An offline session dials nothing — except the run's own `/robots.txt` read, which is a real
46
+ // request. Reporting the run's exit is what makes that one request leave through it, exactly
47
+ // as it would on the driver this recording stands in for.
48
+ ...(init.session.proxy === undefined ? {} : { proxy: init.session.proxy }),
45
49
  page: pageOverTarget(target, {
46
50
  clock: init.session.clock,
47
51
  allowHosts: init.session.rules.allowHosts,
@@ -51,6 +55,7 @@ export async function openOfflineSession(init: OfflineSessionInit): Promise<Scra
51
55
  signal: init.session.signal,
52
56
  onActivity: init.session.onActivity,
53
57
  pace: init.session.pace,
58
+ usage: init.session.usage,
54
59
  }),
55
60
  http: recordedHttp({
56
61
  lookup: init.http,
@@ -63,6 +68,7 @@ export async function openOfflineSession(init: OfflineSessionInit): Promise<Scra
63
68
  // The same bag, for the same reason: a redaction only the live leg performs is one no
64
69
  // fixture can prove.
65
70
  secrets: init.session.secrets,
71
+ usage: init.session.usage,
66
72
  maxAgeMs: init.maxAgeMs,
67
73
  }),
68
74
  close: () => target.close(),
@@ -35,6 +35,7 @@ import type {
35
35
  ScrapeTarget,
36
36
  } from './target';
37
37
  import { ROOT_SELECTOR } from './target';
38
+ import type { UsageMeter } from './usage';
38
39
 
39
40
  export interface PageContext {
40
41
  readonly clock: ScrapeClock;
@@ -51,6 +52,8 @@ export interface PageContext {
51
52
  readonly secrets?: ScrapeSecrets | undefined;
52
53
  readonly robots?: RobotsGate | undefined;
53
54
  readonly signal?: AbortSignal | undefined;
55
+ /** The run's meter. Counts a navigation where the wedge watchdog is told about one. */
56
+ readonly usage?: UsageMeter | undefined;
54
57
  }
55
58
 
56
59
  /** Mutable, shared by the page and every frame under it: a taint is a property of the SESSION. */
@@ -251,6 +254,7 @@ export function pageOverTarget(target: ScrapeTarget, ctx: PageContext): ScrapePa
251
254
  await guardNavigation(url, ctx);
252
255
  await ctx.pace?.(ctx.signal);
253
256
  ctx.onActivity?.();
257
+ ctx.usage?.navigation();
254
258
  await target.goto(url, {
255
259
  timeoutMs: options?.timeout ?? ctx.defaultTimeoutMs,
256
260
  signal: ctx.signal,