@ultimat3/scraping 22.15.0 → 23.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
@@ -10,14 +10,22 @@
10
10
  // The library is passed IN (`launcher`), never imported — see `cdp-port.ts` for why that seam is
11
11
  // what keeps a puppeteer type out of the vocabulary and this package free of a dependency.
12
12
 
13
+ import type { Logger } from '@ultimat3/core';
14
+ import type { MoneyValue } from '@ultimat3/schema';
13
15
  import type { CdpBrowserLike, CdpLauncherLike } from './cdp-port';
16
+ import type { CdpResolver } from './cdp-resolver';
17
+ import { acquireCdp } from './cdp-resolver';
14
18
  import { CDP_DRIVER, cdpTarget } from './cdp-target';
15
19
  import type { ScrapeDriver, ScrapeSession, SessionInit } from './driver';
16
20
  import { browserUnreachable, cdpAttachFailed, remoteRequired } from './error-throws';
21
+ import { egressUnsupported } from './error-throws-session';
17
22
  import { isScrapeError } from './errors';
23
+ import type { ScrapeEventFields } from './events';
24
+ import { errorCode } from './failures';
18
25
  import { httpOverFetch } from './http';
19
26
  import { pageOverTarget } from './page-over-target';
20
27
  import type { ScrapeTarget } from './target';
28
+ import { endpointLabel, hasCredentials, splitCredentials, urlSecretValues } from './url-secrets';
21
29
  import { createWedgeGuard } from './watchdog';
22
30
 
23
31
  export { CDP_DRIVER } from './cdp-target';
@@ -25,6 +33,10 @@ export { CDP_DRIVER } from './cdp-target';
25
33
  export interface BrowserOptions {
26
34
  /** `puppeteer` itself, or anything with the same two methods. */
27
35
  readonly launcher: CdpLauncherLike;
36
+ /**
37
+ * The exit every session of this driver dials when the run names none. A scrape's own
38
+ * `egress(input)` — `SessionInit.proxy` — wins over it, per session.
39
+ */
28
40
  readonly proxy?: string | undefined;
29
41
  /** Extra launch/connect arguments, passed through untouched. */
30
42
  readonly options?: Record<string, unknown> | undefined;
@@ -43,8 +55,23 @@ export interface LocalBrowserOptions extends BrowserOptions {
43
55
  }
44
56
 
45
57
  export interface RemoteBrowserOptions extends BrowserOptions {
46
- /** The `webSocketDebuggerUrl` from `/json/version`, or a provider's connect URL. */
47
- readonly cdpUrl: string;
58
+ /**
59
+ * The `webSocketDebuggerUrl` from `/json/version` or a provider's connect URL — or a
60
+ * `CdpResolver`, for a browser rented PER SESSION: called once per `open()`, told the run's
61
+ * exit, and its `release()` runs exactly once when the session ends. A fixed URL is one browser
62
+ * already bound to one exit, so a run asking for another is `X_SCRAPE_EGRESS_UNSUPPORTED`.
63
+ */
64
+ readonly cdpUrl: string | CdpResolver;
65
+ }
66
+
67
+ /** What one session was opened WITH, decided before the browser exists. */
68
+ interface SessionWiring {
69
+ /** The exit both legs dial: the run's, else the driver's. */
70
+ readonly proxy: string | undefined;
71
+ /** True when THIS driver must answer the proxy's auth challenge — a launched browser only. */
72
+ readonly authenticate: boolean;
73
+ readonly release?: (() => Promise<void>) | undefined;
74
+ readonly cost?: MoneyValue | undefined;
48
75
  }
49
76
 
50
77
  /**
@@ -53,9 +80,27 @@ export interface RemoteBrowserOptions extends BrowserOptions {
53
80
  * outside `cdpTarget`'s own `guard()`, so a bare library `Error` would otherwise reach the job's
54
81
  * retry classifier with no code at all.
55
82
  */
56
- async function opened(browser: CdpBrowserLike, init: SessionInit): Promise<ScrapeTarget> {
83
+ async function opened(
84
+ browser: CdpBrowserLike,
85
+ init: SessionInit,
86
+ wiring: SessionWiring,
87
+ ): Promise<ScrapeTarget> {
57
88
  try {
58
89
  const page = await browser.newPage();
90
+ if (wiring.authenticate && wiring.proxy !== undefined) {
91
+ // Before the first navigation, and through its owner: the challenge arrives with the first
92
+ // request, and `--proxy-server` was deliberately handed the exit WITHOUT its userinfo.
93
+ if (typeof page.authenticate !== 'function') {
94
+ throw egressUnsupported({
95
+ driver: CDP_DRIVER,
96
+ egress: wiring.proxy,
97
+ reason:
98
+ 'the exit carries credentials and this launcher has no page.authenticate() to answer the proxy with',
99
+ });
100
+ }
101
+ const { username, password } = splitCredentials(wiring.proxy);
102
+ await page.authenticate({ username, password });
103
+ }
59
104
  const target = await cdpTarget({ page, browser, rules: init.rules, clock: init.clock });
60
105
  if (init.restore !== undefined) await target.restore(init.restore);
61
106
  return target;
@@ -84,13 +129,14 @@ async function sessionOver(
84
129
  browser: CdpBrowserLike,
85
130
  init: SessionInit,
86
131
  options: BrowserOptions,
132
+ wiring: SessionWiring,
87
133
  ): Promise<ScrapeSession> {
88
134
  // Acquire, then roll back on ANY throw — the shape `releaseBoot` uses in `packages/cli/src/
89
135
  // serve.ts`. Between the launch and the `WedgeGuard` below, nothing else holds this browser:
90
136
  // `runScrape`'s `finally { session.close() }` never runs for a session `open()` did not return,
91
137
  // so a tab limit, a refused interception or a restore that threw left a real Chrome process —
92
138
  // or a remote session somebody is billing for — running per attempt, unattributed.
93
- const target = await opened(browser, init);
139
+ const target = await opened(browser, init, wiring);
94
140
  const guard = createWedgeGuard({
95
141
  clock: init.clock,
96
142
  what: `scrape "${init.name}"`,
@@ -116,10 +162,11 @@ async function sessionOver(
116
162
  return {
117
163
  driver: CDP_DRIVER,
118
164
  // The exit the browser was launched or attached with, handed back so the run's robots read
119
- // presents the SAME client identity to the origin the page loads from. `options.proxy` and
120
- // not `init.proxy`: this is what the launch args and the HTTP leg below actually carry, and a
121
- // reported exit that nothing dialled would be worse than none.
122
- ...(options.proxy === undefined ? {} : { proxy: options.proxy }),
165
+ // presents the SAME client identity to the origin the page loads from. `wiring.proxy` is what
166
+ // the launch args and the HTTP leg below actually carry — the run's exit when it named one,
167
+ // the driver's otherwise — and a reported exit that nothing dialled would be worse than none.
168
+ ...(wiring.proxy === undefined ? {} : { proxy: wiring.proxy }),
169
+ ...(wiring.cost === undefined ? {} : { browserCost: wiring.cost }),
123
170
  page: pageOverTarget(target, {
124
171
  clock: init.clock,
125
172
  allowHosts: init.rules.allowHosts,
@@ -129,6 +176,7 @@ async function sessionOver(
129
176
  signal,
130
177
  onActivity,
131
178
  pace: init.pace,
179
+ usage: init.usage,
132
180
  }),
133
181
  http: httpOverFetch({
134
182
  rules: init.rules,
@@ -142,16 +190,66 @@ async function sessionOver(
142
190
  pace: init.pace,
143
191
  signal,
144
192
  onActivity,
145
- proxy: options.proxy,
193
+ proxy: wiring.proxy,
194
+ usage: init.usage,
146
195
  // The run's secrets, so the ONE thing this leg quotes from the site — the first 200 bytes
147
196
  // of a non-2xx body, in `X_SCRAPE_HTTP_FAILED`'s cause — cannot carry the password the
148
197
  // login endpoint echoed back. The bag was in scope here and unread.
149
198
  secrets: init.secrets,
150
199
  }),
151
- close: () => guard.shutdown(),
200
+ // Both halves, in order, and neither can throw: the browser is quit under its ceiling, THEN
201
+ // the rental is handed back — a provider told "done" while the socket is still open bills the
202
+ // tail to nobody. `release` is latched, so a second `close()` does nothing.
203
+ close: async (): Promise<void> => {
204
+ await guard.shutdown();
205
+ await wiring.release?.();
206
+ },
152
207
  };
153
208
  }
154
209
 
210
+ /** The line a failed hand-back of a rented browser writes. Stable: alerts are keyed on it. */
211
+ const RELEASE_FAILED_EVENT = 'scrape.browser.release_failed';
212
+
213
+ /**
214
+ * The resolver with its `release` WATCHED. `acquireCdp` swallows a rejected hand-back on purpose
215
+ * — it runs on the way out of a run whose own outcome is the one the reader needs — and swallowed
216
+ * silently, a browser its provider is still billing for left no line anywhere. The error's CODE
217
+ * and the endpoint's scheme and host, nothing else: a provider's failure text quotes its connect
218
+ * URL, and that URL is its access token. Rethrown, so `acquireCdp`'s contract is untouched.
219
+ */
220
+ const watched =
221
+ (resolver: CdpResolver, logger: Logger): CdpResolver =>
222
+ async (request) => {
223
+ const resolution = await resolver(request);
224
+ if (resolution.release === undefined) return resolution;
225
+ return {
226
+ cdpUrl: resolution.cdpUrl,
227
+ cost: resolution.cost,
228
+ release: async (): Promise<void> => {
229
+ try {
230
+ await resolution.release?.();
231
+ } catch (thrown) {
232
+ const fields: ScrapeEventFields = {
233
+ code: errorCode(thrown),
234
+ driver: CDP_DRIVER,
235
+ origin: endpointLabel(resolution.cdpUrl),
236
+ };
237
+ logger.warn(RELEASE_FAILED_EVENT, { ...fields });
238
+ throw thrown;
239
+ }
240
+ },
241
+ };
242
+ };
243
+
244
+ /** The run's exit over the driver's, and its credential-bearing parts into the redaction set. */
245
+ function exitFor(init: SessionInit, options: BrowserOptions): string | undefined {
246
+ const exit = init.proxy ?? options.proxy;
247
+ if (exit !== undefined && hasCredentials(exit)) {
248
+ for (const value of urlSecretValues(exit)) init.secrets?.conceal(value);
249
+ }
250
+ return exit;
251
+ }
252
+
155
253
  /**
156
254
  * A browser in this container. `executablePath` is required by every puppeteer-core build — it
157
255
  * ships no browser — so it is passed through rather than guessed at.
@@ -164,6 +262,11 @@ export function localBrowser(options: LocalBrowserOptions): ScrapeDriver {
164
262
  if (launch === undefined) {
165
263
  throw remoteRequired('local browser: the launcher has no launch()');
166
264
  }
265
+ const proxy = exitFor(init, options);
266
+ const authenticate = proxy !== undefined && hasCredentials(proxy);
267
+ // The userinfo never reaches the argument list: Chrome ignores it there, and an argument is
268
+ // readable by every process on the box. `opened()` answers the challenge instead.
269
+ const proxyArg = proxy !== undefined && authenticate ? splitCredentials(proxy).bare : proxy;
167
270
  let browser: CdpBrowserLike;
168
271
  try {
169
272
  browser = await launch.call(options.launcher, {
@@ -172,13 +275,13 @@ export function localBrowser(options: LocalBrowserOptions): ScrapeDriver {
172
275
  ? {}
173
276
  : { executablePath: options.executablePath }),
174
277
  ...(options.profileDir === undefined ? {} : { userDataDir: options.profileDir }),
175
- ...(options.proxy === undefined ? {} : { args: [`--proxy-server=${options.proxy}`] }),
278
+ ...(proxyArg === undefined ? {} : { args: [`--proxy-server=${proxyArg}`] }),
176
279
  ...options.options,
177
280
  });
178
281
  } catch (thrown) {
179
282
  throw browserUnreachable(CDP_DRIVER, thrown);
180
283
  }
181
- return sessionOver(browser, init, options);
284
+ return sessionOver(browser, init, options, { proxy, authenticate });
182
285
  },
183
286
  };
184
287
  }
@@ -192,16 +295,59 @@ export function remoteBrowser(options: RemoteBrowserOptions): ScrapeDriver {
192
295
  if (connect === undefined || options.cdpUrl === '') {
193
296
  throw remoteRequired(CDP_DRIVER);
194
297
  }
195
- let browser: CdpBrowserLike;
298
+ // Refused BEFORE anything is rented or attached. A fixed URL is a browser that already
299
+ // exists on whatever exit it was started with; attaching and dialling the HTTP leg through
300
+ // the run's exit would be one session presenting two client identities.
301
+ if (
302
+ typeof options.cdpUrl === 'string' &&
303
+ init.proxy !== undefined &&
304
+ init.proxy !== options.proxy
305
+ ) {
306
+ throw egressUnsupported({
307
+ driver: CDP_DRIVER,
308
+ egress: init.proxy,
309
+ reason: `remoteBrowser({ cdpUrl }) was given a fixed URL, and the browser behind it is already bound to ${
310
+ options.proxy === undefined ? 'a direct connection' : endpointLabel(options.proxy)
311
+ }`,
312
+ });
313
+ }
314
+ const proxy = exitFor(init, options);
315
+ const source =
316
+ typeof options.cdpUrl === 'string' ? options.cdpUrl : watched(options.cdpUrl, init.logger);
317
+ const acquired = await acquireCdp(source, {
318
+ scrape: init.name,
319
+ runId: init.runId,
320
+ egress: proxy,
321
+ signal: init.signal,
322
+ });
323
+ // From here the rental exists, and `runScrape`'s `finally` cannot hand back a session
324
+ // `open()` never returned — so every throw below releases it first. `sessionOver` rolls the
325
+ // BROWSER back on its own; this is the half only the resolver knows how to end.
196
326
  try {
197
- browser = await connect.call(options.launcher, {
198
- browserWSEndpoint: options.cdpUrl,
199
- ...options.options,
327
+ if (acquired.cdpUrl === '') throw remoteRequired(CDP_DRIVER);
328
+ // A provider's connect URL is its access token. Into the run's secret set before the
329
+ // attach, so not even the failure artifact of a run that died here can carry it.
330
+ for (const value of urlSecretValues(acquired.cdpUrl)) init.secrets?.conceal(value);
331
+ let browser: CdpBrowserLike;
332
+ try {
333
+ browser = await connect.call(options.launcher, {
334
+ browserWSEndpoint: acquired.cdpUrl,
335
+ ...options.options,
336
+ });
337
+ } catch (thrown) {
338
+ throw cdpAttachFailed(acquired.cdpUrl, thrown);
339
+ }
340
+ return await sessionOver(browser, init, options, {
341
+ proxy,
342
+ // The provider launched this browser; answering its proxy is the provider's.
343
+ authenticate: false,
344
+ release: acquired.release,
345
+ cost: acquired.cost,
200
346
  });
201
347
  } catch (thrown) {
202
- throw cdpAttachFailed(options.cdpUrl, thrown);
348
+ await acquired.release();
349
+ throw thrown;
203
350
  }
204
- return sessionOver(browser, init, options);
205
351
  },
206
352
  };
207
353
  }
package/src/driver.ts CHANGED
@@ -5,6 +5,8 @@
5
5
  // Two methods. `open` hands back a session; `close` ends it. Everything else a scraper does goes
6
6
  // through `ScrapePage`, which is driver-blind by construction.
7
7
 
8
+ import type { Logger } from '@ultimat3/core';
9
+ import type { MoneyValue } from '@ultimat3/schema';
8
10
  import type { ScrapeClock } from './clock';
9
11
  import type { ScrapeHttp } from './http';
10
12
  import type { InterceptRules } from './intercept';
@@ -12,12 +14,20 @@ import type { ScrapePage } from './page';
12
14
  import type { RobotsGate } from './robots';
13
15
  import type { ScrapeSecrets } from './secrets';
14
16
  import type { SessionSnapshot } from './session-state';
17
+ import type { UsageMeter } from './usage';
15
18
 
16
19
  export interface SessionInit {
17
20
  /** The scrape's name — every error cause raised inside the session carries it. */
18
21
  readonly name: string;
19
22
  readonly rules: InterceptRules;
20
23
  readonly clock: ScrapeClock;
24
+ /**
25
+ * The run's logger — `scrapeLogger()`'s child, so every line carries the scrape and the run.
26
+ * Required, because `close()` never throws: a teardown that failed (a rented browser its
27
+ * provider would not take back) has no other way to be seen, and a session with nowhere to say
28
+ * so is how that bill went unattributed. Fields stay inside `ScrapeEventFields`.
29
+ */
30
+ readonly logger: Logger;
21
31
  /** Per-operation default, in ms. A `waitFor` with its own `timeout` overrides it. */
22
32
  readonly timeoutMs: number;
23
33
  readonly secrets?: ScrapeSecrets | undefined;
@@ -26,8 +36,17 @@ export interface SessionInit {
26
36
  readonly signal?: AbortSignal | undefined;
27
37
  /** A previously captured session, put back before the first navigation. */
28
38
  readonly restore?: SessionSnapshot | undefined;
29
- /** BOTH transports dial through it. A different exit IP mid-session is a different client. */
39
+ /**
40
+ * The exit THIS run asked for — `egress(input)` on the definition. It wins over a driver's own
41
+ * `proxy` option, BOTH transports dial through it, and a driver that cannot is required to
42
+ * refuse with `X_SCRAPE_EGRESS_UNSUPPORTED` rather than dial another: a different exit IP
43
+ * mid-session is a different client. May carry credentials; never print it.
44
+ */
30
45
  readonly proxy?: string | undefined;
46
+ /** The job run this session belongs to. A `CdpResolver` tags the browser it rents with it. */
47
+ readonly runId?: string | undefined;
48
+ /** The run's usage meter. Pass it to `pageOverTarget` and the HTTP transport; see `usage.ts`. */
49
+ readonly usage?: UsageMeter | undefined;
31
50
  /** Awaited before every navigation AND every HTTP request — one budget across both legs. */
32
51
  readonly pace?: ((signal?: AbortSignal) => Promise<void>) | undefined;
33
52
  /** Called on every operation on either transport. The wedge watchdog measures the gaps. */
@@ -51,6 +70,11 @@ export interface ScrapeSession {
51
70
  * as allow-everything. A driver that omits it is asked for its rules directly, as before.
52
71
  */
53
72
  readonly proxy?: string | undefined;
73
+ /**
74
+ * What the browser behind this session cost, when the driver rented it and the provider said
75
+ * so at acquire time. `Money`, never a float. It reaches `ScrapeReport.usage.browserCost`.
76
+ */
77
+ readonly browserCost?: MoneyValue | undefined;
54
78
  readonly page: ScrapePage;
55
79
  /**
56
80
  * The second transport, bound to the SAME session as the page: the browser's cookies, headers
@@ -0,0 +1,74 @@
1
+ // The failure modes of a SESSION a service is built on: an exit the driver cannot dial, and a
2
+ // prompt answered over the event bus. Split from `error-throws.ts`, which stands at its size
3
+ // ceiling; the codes themselves stay declared in `errors.ts` — one registry, one place.
4
+
5
+ import { ScrapeError } from './errors';
6
+ import { endpointLabel } from './url-secrets';
7
+
8
+ /**
9
+ * A driver handed an exit it cannot dial. Scheme and host of both exits and nothing else: an exit
10
+ * URL carries the proxy account in its userinfo, and this cause is written to the dead-letter row.
11
+ */
12
+ export const egressUnsupported = (input: {
13
+ readonly driver: string;
14
+ readonly egress: string;
15
+ readonly reason: string;
16
+ }): ScrapeError =>
17
+ new ScrapeError({
18
+ code: 'X_SCRAPE_EGRESS_UNSUPPORTED',
19
+ cause: `the ${input.driver} driver cannot give this session the exit ${endpointLabel(input.egress)}: ${input.reason}`,
20
+ fix: 'pass remoteBrowser({ cdpUrl: resolver }) so each run rents a browser on its own exit, or drop egress: from the scrape() definition and set the exit once on the driver — remoteBrowser({ proxy })',
21
+ meta: { driver: input.driver, egress: endpointLabel(input.egress) },
22
+ });
23
+
24
+ /**
25
+ * The exit `egress` answered carries a credential, and that credential is in the job's own input —
26
+ * so it rode the queue payload and sits in `x_jobs` in the clear (`redactInput` hides a key by
27
+ * NAME for display; the column is untouched). Scheme and host only, like every exit this package
28
+ * prints: the cause is written to the dead-letter row, beside the payload it is about.
29
+ */
30
+ export const egressInPayload = (input: {
31
+ readonly scrape: string;
32
+ readonly egress: string;
33
+ }): ScrapeError =>
34
+ new ScrapeError({
35
+ code: 'X_SCRAPE_EGRESS_IN_PAYLOAD',
36
+ cause: `scrape "${input.scrape}" was given the exit ${endpointLabel(input.egress)} with a credential that is also in the run's input, so the credential is stored in the queue row in the clear`,
37
+ fix: `enqueue the id of the row that holds the exit, and resolve it in the worker: egress: async ({ connectionId }) => (await repo.connectionById(connectionId))?.exit on scrape("${input.scrape}") — a .sealed() column keeps it encrypted at rest — then rotate the exposed credential`,
38
+ meta: { scrape: input.scrape, egress: endpointLabel(input.egress) },
39
+ });
40
+
41
+ /**
42
+ * `eventPrompt()` waited its whole budget. The event name is in the cause because it is the one
43
+ * fact the answering side needs and cannot guess: the run id and the prompt's index within it.
44
+ */
45
+ export const promptTimedOut = (input: {
46
+ readonly scrape: string;
47
+ readonly label: string;
48
+ readonly event: string;
49
+ readonly waitedMs: number;
50
+ }): ScrapeError =>
51
+ new ScrapeError({
52
+ code: 'X_SCRAPE_PROMPT_UNANSWERED',
53
+ cause: `scrape "${input.scrape}" asked for "${input.label}" and no answer was published to ${input.event} within ${String(input.waitedMs)}ms`,
54
+ fix: 'call answerPrompt({ runId, index, answer }) with the run id and index the event name in the cause ends with, from a process on the same STORED event bus — setEventBus(createPgEventBus({ executor })) — or raise eventPrompt({ timeout })',
55
+ meta: {
56
+ scrape: input.scrape,
57
+ label: input.label,
58
+ event: input.event,
59
+ waitedMs: input.waitedMs,
60
+ },
61
+ });
62
+
63
+ /** Something was published to the prompt's event that `answerPrompt()` did not write. */
64
+ export const promptAnswerInvalid = (input: {
65
+ readonly scrape: string;
66
+ readonly label: string;
67
+ readonly event: string;
68
+ }): ScrapeError =>
69
+ new ScrapeError({
70
+ code: 'X_SCRAPE_PROMPT_UNANSWERED',
71
+ cause: `scrape "${input.scrape}" asked for "${input.label}" and the event published to ${input.event} does not carry a sealed answer`,
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
+ meta: { scrape: input.scrape, label: input.label, event: input.event },
74
+ });
@@ -12,6 +12,8 @@ import {
12
12
  } from '@ultimat3/core';
13
13
  import type { CaptureClip } from './capture-clip';
14
14
  import { ScrapeError } from './errors';
15
+ import { SECRET_PLACEHOLDER } from './secrets';
16
+ import { endpointLabel, urlSecretValues } from './url-secrets';
15
17
 
16
18
  /**
17
19
  * Two shapes of the same failure, one code. `name` is the driver a definition ASKED for; `scrape`
@@ -34,12 +36,43 @@ export const driverUnknown = (
34
36
  meta: { driver: name ?? 'none', ...(scrape === undefined ? {} : { scrape }) },
35
37
  });
36
38
 
39
+ /**
40
+ * The launcher's own words with every credential-bearing part of `url` cut out. A library's
41
+ * connect error repeats the URL it was handed — `Unexpected server response: 401 for wss://…
42
+ * ?token=…` — so naming scheme and host in the cause is not enough on its own.
43
+ */
44
+ const renderWithout = (thrown: unknown, url: string): string => {
45
+ let text = renderThrowable(thrown);
46
+ const values = [...urlSecretValues(url)].sort((a, b) => b.length - a.length);
47
+ for (const value of values) text = text.split(value).join(SECRET_PLACEHOLDER);
48
+ return text;
49
+ };
50
+
51
+ /**
52
+ * SCHEME AND HOST ONLY, for every CDP URL — a fixed one included. A provider's connect URL carries
53
+ * its access token in the query or the path, this error's cause is written to the dead-letter row
54
+ * and printed by `x jobs show`, and a `cdpUrl` read from the environment is that same URL. The
55
+ * host is what tells a reader which endpoint refused; nothing after it is theirs to print.
56
+ */
37
57
  export const cdpAttachFailed = (cdpUrl: string, thrown: unknown): ScrapeError =>
38
58
  new ScrapeError({
39
59
  code: 'X_SCRAPE_CDP_ATTACH_FAILED',
40
- cause: `the CDP endpoint ${cdpUrl} refused the attach: ${renderThrowable(thrown)}`,
60
+ cause: `the CDP endpoint ${endpointLabel(cdpUrl)} refused the attach: ${renderWithout(thrown, cdpUrl)}`,
41
61
  fix: 'curl "$CDP_URL/json/version" to confirm the endpoint answers, then pass that webSocketDebuggerUrl to remoteBrowser({ cdpUrl })',
42
- meta: { cdpUrl },
62
+ meta: { cdpUrl: endpointLabel(cdpUrl) },
63
+ });
64
+
65
+ /**
66
+ * The resolver itself failed — the provider's API, not the browser it would have handed over. The
67
+ * same code and the same retryable classification: "no browser to attach to yet" is one fact,
68
+ * whichever call discovered it.
69
+ */
70
+ export const cdpResolveFailed = (scrape: string, thrown: unknown): ScrapeError =>
71
+ new ScrapeError({
72
+ code: 'X_SCRAPE_CDP_ATTACH_FAILED',
73
+ cause: `the CdpResolver on remoteBrowser({ cdpUrl }) could not rent a browser for scrape "${scrape}": ${renderThrowable(thrown)}`,
74
+ fix: "run the resolver's own request by hand against the provider to see its answer, then correct the credential or the endpoint the resolver passed to remoteBrowser({ cdpUrl }) uses",
75
+ meta: { scrape },
43
76
  });
44
77
 
45
78
  /**
package/src/errors.ts CHANGED
@@ -39,6 +39,8 @@ export const SCRAPE_OWNED_ERROR_CODES = [
39
39
  'X_SCRAPE_PROMPT_UNANSWERED',
40
40
  'X_SCRAPE_BLOCKED',
41
41
  'X_SCRAPE_KEY_INVALID',
42
+ 'X_SCRAPE_EGRESS_UNSUPPORTED',
43
+ 'X_SCRAPE_EGRESS_IN_PAYLOAD',
42
44
  ] as const;
43
45
 
44
46
  /**
@@ -91,6 +93,8 @@ export const SCRAPE_ERROR_TITLES: Readonly<Record<ScrapeOwnedErrorCode, string>>
91
93
  X_SCRAPE_PROMPT_UNANSWERED: 'a login step asked for a code and nothing answered',
92
94
  X_SCRAPE_BLOCKED: 'the site refused this client — the identity is spent',
93
95
  X_SCRAPE_KEY_INVALID: 'the key chord names no key a browser can press',
96
+ X_SCRAPE_EGRESS_UNSUPPORTED: 'this driver cannot give the session the exit the run asked for',
97
+ X_SCRAPE_EGRESS_IN_PAYLOAD: 'the run exit carries a credential that is also in the job payload',
94
98
  };
95
99
 
96
100
  // One unconditional call, so a second package claiming one of these codes throws
@@ -192,6 +196,12 @@ export const SCRAPE_ERROR_RETRY = {
192
196
  // literal, attempt 2 passes the identical string, and a retry is a browser launch for no chance
193
197
  // of a different parse. Refused before any key goes down, so no modifier is left held.
194
198
  X_SCRAPE_KEY_INVALID: 'terminal',
199
+ // The exit is a function of the run's INPUT and the driver is a constant of the deploy: attempt
200
+ // 2 computes the same exit and meets the same driver. Dialling the driver's own exit instead
201
+ // would be the retry that "works", as a different client — which is the failure this code names.
202
+ X_SCRAPE_EGRESS_UNSUPPORTED: 'terminal',
203
+ // The payload is the row: every remaining attempt reads the same credential out of it.
204
+ X_SCRAPE_EGRESS_IN_PAYLOAD: 'terminal',
195
205
  } as const satisfies Readonly<Record<ScrapeOwnedErrorCode, 'retryable' | 'terminal'>>;
196
206
 
197
207
  registerErrorRetry(SCRAPE_ERROR_RETRY);
@@ -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. */