@ultimat3/scraping 23.0.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/driver-cdp.ts CHANGED
@@ -23,6 +23,7 @@ import { isScrapeError } from './errors';
23
23
  import type { ScrapeEventFields } from './events';
24
24
  import { errorCode } from './failures';
25
25
  import { httpOverFetch } from './http';
26
+ import { launchArgs } from './launch-args';
26
27
  import { pageOverTarget } from './page-over-target';
27
28
  import type { ScrapeTarget } from './target';
28
29
  import { endpointLabel, hasCredentials, splitCredentials, urlSecretValues } from './url-secrets';
@@ -38,7 +39,10 @@ export interface BrowserOptions {
38
39
  * `egress(input)` — `SessionInit.proxy` — wins over it, per session.
39
40
  */
40
41
  readonly proxy?: string | undefined;
41
- /** Extra launch/connect arguments, passed through untouched. */
42
+ /**
43
+ * Extra launch/connect options, passed through — except `args`, which `localBrowser()` puts
44
+ * BEFORE the exit's `--proxy-server` and screens for a second route (`launch-args.ts`).
45
+ */
42
46
  readonly options?: Record<string, unknown> | undefined;
43
47
  /** How long a graceful `close()` may take before the process is killed. */
44
48
  readonly graceMs?: number | undefined;
@@ -267,6 +271,11 @@ export function localBrowser(options: LocalBrowserOptions): ScrapeDriver {
267
271
  // The userinfo never reaches the argument list: Chrome ignores it there, and an argument is
268
272
  // readable by every process on the box. `opened()` answers the challenge instead.
269
273
  const proxyArg = proxy !== undefined && authenticate ? splitCredentials(proxy).bare : proxy;
274
+ // The caller's args FIRST and the exit appended after them, never a spread that lets one
275
+ // replace the other: `{ args: ['--no-sandbox'] }` — every container deploy's — used to
276
+ // overwrite the proxy flag, and the browser dialled direct while the session reported the exit.
277
+ const { args: callerArgs, ...passthrough } = options.options ?? {};
278
+ const args = launchArgs(callerArgs, proxyArg);
270
279
  let browser: CdpBrowserLike;
271
280
  try {
272
281
  browser = await launch.call(options.launcher, {
@@ -275,8 +284,8 @@ export function localBrowser(options: LocalBrowserOptions): ScrapeDriver {
275
284
  ? {}
276
285
  : { executablePath: options.executablePath }),
277
286
  ...(options.profileDir === undefined ? {} : { userDataDir: options.profileDir }),
278
- ...(proxyArg === undefined ? {} : { args: [`--proxy-server=${proxyArg}`] }),
279
- ...options.options,
287
+ ...passthrough,
288
+ ...(args === undefined ? {} : { args }),
280
289
  });
281
290
  } catch (thrown) {
282
291
  throw browserUnreachable(CDP_DRIVER, thrown);
package/src/driver.ts CHANGED
@@ -102,7 +102,7 @@ export interface ScrapeDriver {
102
102
 
103
103
  let ambient: ScrapeDriver | undefined;
104
104
 
105
- /** Set once at boot from `app.config.ts`. A scrape's own `driver:` overrides it per definition. */
105
+ /** Set once by the app at boot — no config key names it. A scrape's own `driver:` overrides it. */
106
106
  export function setScrapeDriver(driver: ScrapeDriver): void {
107
107
  ambient = driver;
108
108
  }
@@ -72,3 +72,40 @@ export const promptAnswerInvalid = (input: {
72
72
  fix: 'publish the answer with answerPrompt({ runId, index, answer }) and never publishEvent() by hand — the answer is sealed for this one prompt, so a plain payload is refused rather than typed into the site',
73
73
  meta: { scrape: input.scrape, label: input.label, event: input.event },
74
74
  });
75
+
76
+ /** Lists every `localBrowser(` call site and every proxy switch in the app — where the edit goes. */
77
+ export const LAUNCH_ARGS_FIX =
78
+ "grep -rnE --include='*.ts' -e 'localBrowser\\(' -e '--(no-)?proxy-' .";
79
+
80
+ /**
81
+ * The caller's launch args set the exit themselves, or are not a list a launcher can take. The
82
+ * switch NAME only, never its value: `--proxy-server=http://user:pass@…` is a credential a caller
83
+ * may well have written, and this cause is written to the dead-letter row.
84
+ */
85
+
86
+ export const launchArgsInvalid = (reason: string, route: boolean): ScrapeError =>
87
+ new ScrapeError({
88
+ code: 'X_SCRAPE_LAUNCH_ARGS_INVALID',
89
+ cause: `localBrowser({ options: { args } }) ${reason} — ${
90
+ route
91
+ ? 'delete the switch from args and pass the exit as localBrowser({ proxy }) or scrape({ egress })'
92
+ : "pass args as a list of strings, e.g. options: { args: ['--no-sandbox'] }"
93
+ }`,
94
+ // One command, the same for both shapes: it lists every call site and every proxy switch.
95
+ fix: LAUNCH_ARGS_FIX,
96
+ meta: { reason },
97
+ });
98
+
99
+ /**
100
+ * A name a WILDCARD admitted, resolved to an address inside the network. The address CLASS and
101
+ * never the address: the cause reaches the dead-letter row, and the hostname is enough to act on.
102
+ */
103
+ export const hostResolvesInward = (url: string, hostname: string, kind: string): ScrapeError =>
104
+ new ScrapeError({
105
+ code: 'X_SCRAPE_HOST_BLOCKED',
106
+ cause: `${url} is admitted by a wildcard in allowHosts, and ${hostname} resolves to ${
107
+ kind === 'unresolvable' ? 'no address' : `a ${kind} address`
108
+ } — a wildcard admits public addresses only`,
109
+ fix: `name the host exactly on the scrape() definition — allowHosts: [${JSON.stringify(hostname)}] — if it is meant to be reached inside your network`,
110
+ meta: { url, host: hostname, addressClass: kind },
111
+ });
@@ -8,7 +8,6 @@ import {
8
8
  isRetryableStatus,
9
9
  renderFixShellArg,
10
10
  renderThrowable,
11
- UltimateError,
12
11
  } from '@ultimat3/core';
13
12
  import type { CaptureClip } from './capture-clip';
14
13
  import { ScrapeError } from './errors';
@@ -303,7 +302,11 @@ export const fixtureMissing = (url: string, dir: string): ScrapeError =>
303
302
  export const fixtureStale = (url: string, ageMs: number, maxAgeMs: number): ScrapeError =>
304
303
  new ScrapeError({
305
304
  code: 'X_SCRAPE_FIXTURE_STALE',
306
- cause: `the recording of ${url} is ${String(Math.round(ageMs / 86_400_000))} days old and fixtureBrowser declares maxAge ${String(Math.round(maxAgeMs / 86_400_000))} days`,
305
+ cause: `the recording of ${url} ${
306
+ Number.isFinite(ageMs)
307
+ ? `is ${String(Math.round(ageMs / 86_400_000))} days old`
308
+ : 'has a recordedAt that is not a date'
309
+ } and fixtureBrowser declares maxAge ${String(Math.round(maxAgeMs / 86_400_000))} days`,
307
310
  fix: 're-record the fixture directory, or raise maxAge on fixtureBrowser({ maxAge }) with the reason an old recording still proves something',
308
311
  meta: { url, ageMs, maxAgeMs },
309
312
  });
@@ -426,19 +429,6 @@ export const blocked = (scrape: string, url: string, detail: string): ScrapeErro
426
429
  meta: { scrape, url },
427
430
  });
428
431
 
429
- /**
430
- * The honest stub, in the shape `packages/jobs/src/driver-redis.ts` uses: correct types so an app
431
- * can be written against the seam, and one labelled throw so nobody discovers the gap from a
432
- * silently-skipped recovery.
433
- */
434
- export const scrapeNotImplemented = (feature: string, fix: string): UltimateError =>
435
- new UltimateError({
436
- code: 'X_NOT_IMPLEMENTED',
437
- cause: `${feature} is declared and not implemented in @ultimat3/scraping`,
438
- fix,
439
- meta: { feature },
440
- });
441
-
442
432
  /**
443
433
  * The crop rectangle's three refusals, one code. Each is a caller's DECLARATION being impossible
444
434
  * rather than a site behaving badly, so each names the edit that fixes it — and the alternative in
package/src/errors.ts CHANGED
@@ -41,11 +41,12 @@ export const SCRAPE_OWNED_ERROR_CODES = [
41
41
  'X_SCRAPE_KEY_INVALID',
42
42
  'X_SCRAPE_EGRESS_UNSUPPORTED',
43
43
  'X_SCRAPE_EGRESS_IN_PAYLOAD',
44
+ 'X_SCRAPE_LAUNCH_ARGS_INVALID',
44
45
  ] as const;
45
46
 
46
47
  /**
47
- * `X_NOT_IMPLEMENTED` is `@ultimat3/core`'s and `recover.ts` throws it for the agent seam, the
48
- * way `packages/jobs/src/driver-redis.ts` does for its stub. `X_ENV_MISSING` is core's too: a
48
+ * `X_NOT_IMPLEMENTED` is `@ultimat3/core`'s: `recover.ts`, `cdp-target.ts` and `html-target.ts`
49
+ * throw it as core's own `NotImplementedError`, never a constructor of this package's. `X_ENV_MISSING` is core's too: a
49
50
  * declared secret with no value in the environment is a missing environment variable, not a
50
51
  * scraping concept needing its own code. No title is kept here for either — one code, one owner,
51
52
  * or the two copies drift.
@@ -95,6 +96,8 @@ export const SCRAPE_ERROR_TITLES: Readonly<Record<ScrapeOwnedErrorCode, string>>
95
96
  X_SCRAPE_KEY_INVALID: 'the key chord names no key a browser can press',
96
97
  X_SCRAPE_EGRESS_UNSUPPORTED: 'this driver cannot give the session the exit the run asked for',
97
98
  X_SCRAPE_EGRESS_IN_PAYLOAD: 'the run exit carries a credential that is also in the job payload',
99
+ X_SCRAPE_LAUNCH_ARGS_INVALID:
100
+ 'the launch options carry arguments this driver cannot hand the browser',
98
101
  };
99
102
 
100
103
  // One unconditional call, so a second package claiming one of these codes throws
@@ -202,6 +205,9 @@ export const SCRAPE_ERROR_RETRY = {
202
205
  X_SCRAPE_EGRESS_UNSUPPORTED: 'terminal',
203
206
  // The payload is the row: every remaining attempt reads the same credential out of it.
204
207
  X_SCRAPE_EGRESS_IN_PAYLOAD: 'terminal',
208
+ // The args are the deploy's own literal: attempt 2 launches with the same list and is refused
209
+ // the same way. Launching anyway is the retry that "works" from the worker's own address.
210
+ X_SCRAPE_LAUNCH_ARGS_INVALID: 'terminal',
205
211
  } as const satisfies Readonly<Record<ScrapeOwnedErrorCode, 'retryable' | 'terminal'>>;
206
212
 
207
213
  registerErrorRetry(SCRAPE_ERROR_RETRY);
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
@@ -64,7 +64,9 @@ export function recordedHttp(init: RecordedHttpInit): ScrapeHttp {
64
64
  if (found === undefined) throw fixtureMissing(`${method} ${url}`, init.source);
65
65
  if (init.maxAgeMs !== undefined && found.recordedAt !== undefined) {
66
66
  const age = init.clock.now().getTime() - new Date(found.recordedAt).getTime();
67
- 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);
68
70
  }
69
71
  init.network.push({
70
72
  method,
package/src/http.ts CHANGED
@@ -20,6 +20,8 @@ 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';
@@ -43,6 +45,11 @@ export type ScrapeFetch = (input: string, init: ScrapeFetchInit) => Promise<Resp
43
45
  export interface ScrapeFetchInit extends RequestInit {
44
46
  /** The session's exit. A different exit IP mid-session is a different client to an anti-bot. */
45
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;
46
53
  }
47
54
 
48
55
  export interface HttpRequestInit {
@@ -114,6 +121,8 @@ export interface HttpTransportInit {
114
121
  /** The run's meter: one `httpRequest` per hop on the wire, with the body bytes read for it. */
115
122
  readonly usage?: UsageMeter | undefined;
116
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;
117
126
  }
118
127
 
119
128
  /**
@@ -196,6 +205,22 @@ const withoutCredentials = (headers: Readonly<Record<string, string>>): Record<s
196
205
  Object.entries(headers).filter(([name]) => !CROSS_ORIGIN_STRIPPED.has(name.toLowerCase())),
197
206
  );
198
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
+
199
224
  /** Unparseable is not same-origin: a URL this package cannot read is one it cannot vouch for. */
200
225
  const sameOrigin = (left: string, right: string): boolean => {
201
226
  try {
@@ -251,12 +276,21 @@ export function httpOverFetch(init: HttpTransportInit): ScrapeHttp {
251
276
  * The three gates, in one place, so the initial URL and hop seven are screened by the same code
252
277
  * in the same order. A second copy for redirects is how the two drift.
253
278
  */
254
- const screen = async (target: string): Promise<void> => {
279
+ const resolve = init.resolve ?? resolveHost;
280
+ const screen = async (target: string): Promise<DialTarget> => {
255
281
  if (interceptVerdict(target, 'fetch', init.rules) !== 'allow') {
256
282
  throw hostBlocked(target, init.rules.allowHosts);
257
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 };
258
291
  await init.robots?.assertAllowed(target);
259
292
  await init.pace?.(init.signal);
293
+ return dial;
260
294
  };
261
295
  return {
262
296
  async request(url: string, request: HttpRequestInit = {}): Promise<ScrapeResponse> {
@@ -284,7 +318,7 @@ export function httpOverFetch(init: HttpTransportInit): ScrapeHttp {
284
318
  1,
285
319
  );
286
320
  init.onActivity?.();
287
- await screen(url);
321
+ let dial = await screen(url);
288
322
  const session = await init.session();
289
323
  // `AbortSignal.timeout` and NOT `clock.sleep`: this is a deadline handed to the platform's
290
324
  // own fetch, not a wait this package performs — and under a test clock a slept deadline
@@ -319,14 +353,17 @@ export function httpOverFetch(init: HttpTransportInit): ScrapeHttp {
319
353
  const declared = credentialsInScope
320
354
  ? request.headers
321
355
  : withoutCredentials(request.headers ?? {});
322
- const response = await call(hop.url, {
356
+ const response = await call(dial.url, {
323
357
  method: hop.method,
324
- headers: {
325
- ...carried,
326
- ...(session.userAgent === '' ? {} : { 'user-agent': session.userAgent }),
327
- ...(cookies === undefined ? {} : { cookie: cookies }),
328
- ...declared,
329
- },
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 } }),
330
367
  ...(hop.body === undefined ? {} : { body: hop.body }),
331
368
  signal: AbortSignal.any(signals),
332
369
  // The whole point: the platform's own `follow` is what made the four lines above
@@ -360,7 +397,7 @@ export function httpOverFetch(init: HttpTransportInit): ScrapeHttp {
360
397
  await discardHopBody(response);
361
398
  if (followed >= MAX_REDIRECT_HOPS) throw redirectLoop(url, next.url, followed + 1);
362
399
  init.onActivity?.();
363
- await screen(next.url);
400
+ dial = await screen(next.url);
364
401
  if (!sameOrigin(hop.url, next.url)) credentialsInScope = false;
365
402
  hop = next;
366
403
  }
package/src/index.ts CHANGED
@@ -22,6 +22,8 @@ export type { CaptureClip, CaptureFraming } from './capture-clip';
22
22
  export { assertCaptureFraming } from './capture-clip';
23
23
  export type {
24
24
  CdpBrowserLike,
25
+ CdpBrowserSessionLike,
26
+ CdpBrowserTargetLike,
25
27
  CdpFrameLike,
26
28
  CdpKeyboardLike,
27
29
  CdpLauncherLike,
@@ -79,7 +81,6 @@ export {
79
81
  redirectLoop,
80
82
  remoteRequired,
81
83
  robotsDisallowed,
82
- scrapeNotImplemented,
83
84
  scrapeTimeout,
84
85
  secretExposed,
85
86
  selectorMissing,
@@ -213,6 +214,7 @@ export type {
213
214
  export {
214
215
  memorySessionStore,
215
216
  parseSessionState,
217
+ recordVersion,
216
218
  SESSION_SEAL_PURPOSE,
217
219
  sessionDigest,
218
220
  sessionKeyFor,
@@ -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
+ }
@@ -0,0 +1,65 @@
1
+ // The DNS half of `allowHosts`, for the two legs this package dials itself — the HTTP leg and the
2
+ // robots read. Core's `hostDecision` is synchronous and judges NAMES: a wildcard never admits an
3
+ // address literal inside the network, but `allowHosts: ['*']` still admits a hostname that
4
+ // resolves inward. So a name admitted by a wildcard alone is resolved here, every address
5
+ // classified by core's one classifier, and the connection PINNED to the approved address — the
6
+ // shape `@ultimat3/jobs`' `webhook-target.ts` takes for a webhook.
7
+
8
+ import type { HostRule } from '@ultimat3/core';
9
+ import { classifyAddress, hostMatches } from '@ultimat3/core';
10
+ import { hostResolvesInward } from './error-throws-session';
11
+
12
+ /** Every address a hostname answers. Injected in tests: the network is sealed in this repo. */
13
+ export type HostResolve = (hostname: string) => Promise<readonly string[]>;
14
+
15
+ /** `Bun.dns.lookup` — the runtime's own resolver, every family, every address. */
16
+ export const resolveHost: HostResolve = async (hostname) =>
17
+ (await Bun.dns.lookup(hostname)).map((entry) => entry.address);
18
+
19
+ /** What the transport connects to: the URL to dial, and the name it proves when pinned. */
20
+ export interface DialTarget {
21
+ readonly url: string;
22
+ /** The original `host[:port]`, sent as the `Host` header of a pinned request. */
23
+ readonly host?: string | undefined;
24
+ /** For `https:` — the certificate is verified against the NAME, not the address. */
25
+ readonly serverName?: string | undefined;
26
+ }
27
+
28
+ const isWildcard = (rule: HostRule): boolean => {
29
+ const cleaned = rule.trim();
30
+ return cleaned === '*' || cleaned.startsWith('*.');
31
+ };
32
+
33
+ /**
34
+ * The connection for `url`, which `interceptVerdict` has ALREADY allowed. Unchanged when an exact
35
+ * rule names the host — the app said that host, internal or not, in a line a reviewer can see —
36
+ * or when the host is an address literal, which core's floor already judged. Otherwise resolved:
37
+ * one address that is not public refuses the request (`X_SCRAPE_HOST_BLOCKED`), and the dial is
38
+ * pinned to the first approved address, so a name that answers public here and private to the
39
+ * socket (DNS rebinding) is never reached. A resolver failure propagates: nothing unscreened is
40
+ * dialled, and the same name would not have resolved for `fetch` either.
41
+ */
42
+ export async function dialTarget(
43
+ url: string,
44
+ allowHosts: readonly HostRule[],
45
+ resolve: HostResolve,
46
+ ): Promise<DialTarget> {
47
+ const parsed = new URL(url);
48
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return { url };
49
+ const hostname = parsed.hostname.replace(/^\[|\]$/g, '');
50
+ if (classifyAddress(hostname) !== undefined) return { url };
51
+ if (allowHosts.some((rule) => !isWildcard(rule) && hostMatches(hostname, rule))) return { url };
52
+ const addresses = await resolve(hostname);
53
+ const inward = addresses.map((address) => classifyAddress(address) ?? 'unclassifiable');
54
+ const refused =
55
+ addresses.length === 0 ? 'unresolvable' : inward.find((kind) => kind !== 'public');
56
+ if (refused !== undefined) throw hostResolvesInward(url, hostname, refused);
57
+ const pinned = addresses[0] ?? hostname;
58
+ const target = new URL(parsed.href);
59
+ target.hostname = pinned.includes(':') ? `[${pinned}]` : pinned;
60
+ return {
61
+ url: target.href,
62
+ host: parsed.host,
63
+ ...(parsed.protocol === 'https:' ? { serverName: hostname } : {}),
64
+ };
65
+ }
package/src/recover.ts CHANGED
@@ -6,7 +6,8 @@
6
6
  // `X_NOT_IMPLEMENTED` today, in the shape `packages/jobs/src/driver-redis.ts` uses: correct types
7
7
  // so an app can be written against it, one labelled throw, no silent no-op.
8
8
 
9
- import { recoverRefused, scrapeNotImplemented } from './error-throws';
9
+ import { NotImplementedError } from '@ultimat3/core';
10
+ import { recoverRefused } from './error-throws';
10
11
  import type { ScrapePage } from './page';
11
12
 
12
13
  export interface RecoveryAttempt {
@@ -41,7 +42,7 @@ export async function runRecovery(recovery: Recovery, attempt: RecoveryAttempt):
41
42
  // The agent path lands with `@ultimat3/ai`'s `llm()` behind it: page HTML in, a selector out,
42
43
  // re-run once. Until it does, this throws rather than answering `false` — a recovery that
43
44
  // silently declines is indistinguishable from one that was never configured.
44
- throw scrapeNotImplemented("recover: 'agent'", AGENT_FIX);
45
+ throw new NotImplementedError({ cause: "recover: 'agent' is not implemented", fix: AGENT_FIX });
45
46
  }
46
47
  const verdict: unknown = await recovery(attempt);
47
48
  if (typeof verdict !== 'boolean') {