@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/README.md CHANGED
@@ -6,10 +6,8 @@ no ninth primitive, and no ordinal here to go stale when the next factory lands.
6
6
 
7
7
  ```ts
8
8
  import { t } from '@ultimat3/schema';
9
- import type { StorageDriver } from '@ultimat3/storage';
10
9
  import { scrape, storageSessionStore } from '@ultimat3/scraping';
11
-
12
- declare const disk: StorageDriver;
10
+ import { disk } from '@ultimat3/storage';
13
11
 
14
12
  const orderPage = t.object({
15
13
  rows: t.array(t.object({ id: t.string, total: t.number })),
@@ -29,7 +27,8 @@ export const dailyOrders = scrape({
29
27
  secrets: ['SHOP_PASSWORD'],
30
28
  expect: { minRows: 1, maxDrop: 0.5 },
31
29
  auth: {
32
- store: storageSessionStore(disk),
30
+ // A thunk: the app's disks exist once boot ran `defineStorage()`, after this module loaded.
31
+ store: storageSessionStore(() => disk('sessions')),
33
32
  login: async ({ page, secrets, prompt }) => {
34
33
  await page.goto('https://shop.example.com/login');
35
34
  await page.fill('#user', 'ops@example.com');
@@ -85,6 +84,168 @@ Both legs replay from **one** fixture directory (`fixtureBrowser(dir)`), so a hy
85
84
  login, session handoff, HTTP bulk fetch — is tested end to end. Both legs apply the same robots
86
85
  gate, offline included.
87
86
 
87
+ ## A session a service is built on
88
+
89
+ One connection, one run at a time, on its own exit, in a browser rented for that run — with a
90
+ person or an OTP source answering the site mid-run and a `usage` block on the result. Every piece
91
+ is a field on `scrape()` or on its driver; there is no second factory.
92
+
93
+ ```ts
94
+ import { t } from '@ultimat3/schema';
95
+ import type {
96
+ CdpLauncherLike,
97
+ CdpResolver,
98
+ PromptRequest,
99
+ ScrapeDriver,
100
+ ScrapeUsage,
101
+ } from '@ultimat3/scraping';
102
+ import {
103
+ answerPrompt,
104
+ eventPrompt,
105
+ fixtureBrowser,
106
+ remoteBrowser,
107
+ scrape,
108
+ storageSessionStore,
109
+ } from '@ultimat3/scraping';
110
+ import { disk } from '@ultimat3/storage';
111
+
112
+ declare const puppeteer: CdpLauncherLike;
113
+ /** Set where recordings stand in for the browser: `bun test`, `x dev`, the app's own gate. */
114
+ declare const fixtures: string | undefined;
115
+ /** The app's own provider client, and its own "a prompt is pending" row. Neither ships. */
116
+ declare const provider: {
117
+ create(request: {
118
+ tag: string | undefined;
119
+ proxy: string | undefined;
120
+ signal: AbortSignal | undefined;
121
+ }): Promise<{ id: string; connectUrl: string; priceCents: number }>;
122
+ end(id: string): Promise<void>;
123
+ };
124
+ declare function promptPending(request: PromptRequest): Promise<void>;
125
+ /** The app's own rows: the exit is a `.sealed()` column on the connection, read by id. */
126
+ declare function exitOf(connectionId: string): Promise<string | undefined>;
127
+ declare function recordRun(run: {
128
+ runId: string;
129
+ connectionId: string;
130
+ outcome: string;
131
+ code?: string | undefined;
132
+ usage?: ScrapeUsage | undefined;
133
+ }): Promise<void>;
134
+
135
+ // A browser rented per session. `release` runs exactly once, whatever the run did.
136
+ const rent: CdpResolver = async ({ runId, egress, signal }) => {
137
+ const rented = await provider.create({ tag: runId, proxy: egress, signal });
138
+ return {
139
+ cdpUrl: rented.connectUrl,
140
+ release: () => provider.end(rented.id),
141
+ cost: { minor: rented.priceCents, currency: 'USD' },
142
+ };
143
+ };
144
+
145
+ const browser: ScrapeDriver =
146
+ fixtures === undefined
147
+ ? remoteBrowser({ launcher: puppeteer, cdpUrl: rent })
148
+ : fixtureBrowser(fixtures);
149
+
150
+ const waitForAnswer = eventPrompt({ timeout: 300_000 });
151
+
152
+ export const syncAccounts = scrape({
153
+ name: 'accounts.sync',
154
+ // IDS only. A proxy URL carries its account, and the input is the queue row.
155
+ input: t.object({ connectionId: t.uuid, orgId: t.uuid }),
156
+ extract: t.object({ id: t.string }),
157
+ idempotencyKey: ({ connectionId }) => `accounts:${connectionId}`,
158
+ tenant: ({ orgId }) => orgId,
159
+ allowHosts: ['bank.example'],
160
+ driver: browser,
161
+ // The exit THIS run leaves through, looked up IN THE WORKER under the job's tenant. Both legs
162
+ // and the robots read dial it.
163
+ egress: ({ connectionId }) => exitOf(connectionId),
164
+ // One session per connection: a second run of the same one is `failed`, X_JOB_KEY_BUSY.
165
+ concurrency: { key: ({ connectionId }) => connectionId, limit: 1, whenBusy: 'fail' },
166
+ auth: {
167
+ // Declaring a store IS what persists the session — sealed under the app's master key.
168
+ store: storageSessionStore(() => disk('sessions')),
169
+ key: ({ connectionId }) => connectionId,
170
+ login: async ({ page, prompt }) => {
171
+ await page.goto('https://bank.example/login');
172
+ await page.fill('#otp', await prompt('sms code'));
173
+ },
174
+ },
175
+ // Say a prompt is pending, then wait for its answer on the job event bus — browser open.
176
+ prompt: async (request) => {
177
+ await promptPending(request);
178
+ return waitForAnswer(request);
179
+ },
180
+ async run({ page, http }) {
181
+ await page.goto('https://bank.example/accounts');
182
+ const body = await (await http.request('https://bank.example/api/accounts')).parse(
183
+ t.object({ rows: t.array(t.object({ id: t.string })) }),
184
+ );
185
+ return body.rows;
186
+ },
187
+ // How the run ENDED, told once: the report with its usage, or the code it failed with.
188
+ async onSettled(settled) {
189
+ if (settled.input === undefined) return;
190
+ const run = { runId: settled.runId, connectionId: settled.input.connectionId };
191
+ await recordRun(
192
+ settled.outcome === 'completed'
193
+ ? { ...run, outcome: settled.outcome, usage: settled.result.usage }
194
+ : { ...run, outcome: settled.outcome, code: settled.code, usage: settled.usage },
195
+ );
196
+ },
197
+ });
198
+
199
+ /** The body of the app's own policy-checked action: the event name is not an authorization. */
200
+ export const answer = (input: { runId: string; index: number; answer: string }) =>
201
+ answerPrompt(input);
202
+ ```
203
+
204
+ | Field | Rule |
205
+ |---|---|
206
+ | `egress` | `(input, ctx) => string \| undefined`, sync or async, called in the WORKER under the job's tenant — so the input carries an id and a row carries the exit. An exit whose password is also in the input rode the queue payload and is refused before the browser opens: `X_SCRAPE_EGRESS_IN_PAYLOAD`, terminal. Reaches the driver as `SessionInit.proxy` and wins over the driver's `proxy`. A URL with credentials never reaches the process arguments: a launched browser answers the proxy through `page.authenticate()`, and a launcher without it is `X_SCRAPE_EGRESS_UNSUPPORTED` |
207
+ | `remoteBrowser({ cdpUrl })` | a fixed URL, or a `CdpResolver`: called once per `open()` with `{ scrape, runId, egress, signal }`; `release()` runs once — in `close()` after the browser is quit, or at once when the attach fails. A `release()` that throws is logged `scrape.browser.release_failed` (`code`, `driver`, `origin` as scheme and host) through `SessionInit.logger`, which every driver now requires. A fixed URL handed a different exit is `X_SCRAPE_EGRESS_UNSUPPORTED` |
208
+ | `concurrency` | the job's own field, passed through: a number, or `{ key, limit, whenBusy }` |
209
+ | `auth.store` | `storageSessionStore(() => disk('sessions'), { prefix?, keySource? })` — a THUNK, read per call: a scrape is declared when its module loads and the app's disks exist after boot. Seals with core's `seal()`, purpose `SESSION_SEAL_PURPOSE`. Anything in the bucket that is not its own sealed record for that key is burned; no master key is `X_SEAL_KEY_MISSING` before the browser opens. `artifacts.storage` is the same thunk |
210
+ | `onSettled` | the job's own hook (`@ultimat3/jobs`), with what a scrape adds: `completed` hands over the `ScrapeReport`; `dead-lettered` / `dropped` / `refused` carry `code` and the last attempt's `usage` (`undefined` for `refused` — the body never ran). After the row is settled, under the job's tenant, **at most once** across a crash, and a hook that throws changes nothing |
211
+ | `run({ progress, finalAttempt })` | the job's own, passed through |
212
+ | `prompt` | `eventPrompt({ timeout, pollMs?, bus?, keySource?, env? })` polls the event bus in process — never `step.waitForEvent`, which would close the browser. An in-memory bus (`stored: false`) is refused outside development and test (`X_DRIVER_UNAVAILABLE`): the answer comes from another process. Event `promptEventName(runId, index)`; answered by `answerPrompt({ runId, index, answer })`, sealed for that one name. Timeout is `X_SCRAPE_PROMPT_UNANSWERED` |
213
+ | the bus | the **stored** one (`createPgEventBus`) wherever the worker and the answering process differ. Every `x` boot installs it |
214
+
215
+ `PromptRequest` carries the run's `input` and `runId` — what ties a prompt to the app's own row
216
+ for the run — and `index`, `clock`, `signal` and `keepAlive()` beside `label`, `scrape` and `url`:
217
+ what any handler that waits needs: wait on the run's clock, stop on the run's
218
+ signal, and call `keepAlive()` between polls so the wedge watchdog and a rented browser both keep
219
+ seeing activity. `auth.login` and `auth.validate` are handed `runId` beside `input`.
220
+
221
+ `ScrapeReport.usage` is `{ browserMs, navigations, httpRequests, bytesIn, promptsAnswered,
222
+ browserCost? }`. `bytesIn` is the HTTP leg's response bodies; `browserCost` is the resolver's
223
+ `cost`, a `Money` value parsed at the boundary, so a float is refused. The counts ride the
224
+ `scrape.ok` and `scrape.failed` log lines too — a failed run has no report and was billed anyway,
225
+ which is why `onSettled` hands a failed run its last attempt's `usage`. No quota reads them.
226
+
227
+ ### The clock, and a test's
228
+
229
+ A run waits on the PROCESS's clock — `scrapeClock()`, the system's — unless its definition pins
230
+ one with `clock:`. A test replaces it the way it replaces the queue driver:
231
+
232
+ ```ts
233
+ import { noWaitClock, resetScrapeClock, setScrapeClock } from '@ultimat3/scraping';
234
+
235
+ setScrapeClock(noWaitClock); // in a beforeEach
236
+ resetScrapeClock(); // in the afterEach
237
+ ```
238
+
239
+ | Clock | Sleeping | For |
240
+ |---|---|---|
241
+ | `systemScrapeClock` | waits | production |
242
+ | `noWaitClock` | one turn of the event loop, whatever was asked; `now()` and deadlines are real | a test whose run waits on something that really happens in the same process — an answer the test publishes |
243
+ | `testClock()` | advances virtual time and yields a microtask | a test of a timeout: thirty seconds pass in none |
244
+
245
+ A third-party driver takes part by reading three `SessionInit` fields: dial `proxy` or throw
246
+ `egressUnsupported()`, pass `usage` to `pageOverTarget` and its HTTP transport, and answer
247
+ `browserCost` on the session it returns.
248
+
88
249
  ## Reading elements, frames, and the network condition
89
250
 
90
251
  | Read | Answers |
@@ -199,14 +360,17 @@ on a machine with no browser — the case CI is.
199
360
  | `page.ts` / `page-over-target.ts` | the driver-blind vocabulary, implemented once |
200
361
  | `target.ts` / `driver.ts` | the two seams: what a driver answers, and what a session is |
201
362
  | `driver-cdp.ts` / `cdp-*.ts` | the real browser, over a structural CDP port |
202
- | `driver-fake.ts` / `driver-fixture.ts` / `html-*.ts` | the offline drivers, on Bun's `HTMLRewriter` |
363
+ | `driver-fake.ts` / `driver-recorded.ts` / `html-*.ts` | the offline drivers, on Bun's `HTMLRewriter` |
203
364
  | `http.ts` / `http-recorded.ts` | the second transport, live and replayed |
204
- | `auth.ts` / `session-state.ts` | acquire → persist → reuse → validate → burn. The session key encodes each part (`<sanitised>.<digest>`), so two account names that differ only outside `[a-zA-Z0-9._-]` are two sessions |
205
- | `secrets.ts` / `browser-record.ts` | what may leave this package, and how a browser's own string map is read |
365
+ | `auth.ts` / `session-state.ts` | acquire → persist → reuse → validate → burn. The session key encodes each part (`<sanitised>.<digest>`), so two account names that differ only outside `[a-zA-Z0-9._-]` are two sessions. A stored session is sealed |
366
+ | `event-prompt.ts` | `eventPrompt()` and `answerPrompt()`: a prompt answered from another process, over the job event bus, with the browser open |
367
+ | `cdp-resolver.ts` | a browser rented per session: acquired once, released once |
368
+ | `usage.ts` | what one run used, counted where the work happens |
369
+ | `secrets.ts` / `url-secrets.ts` / `browser-record.ts` | what may leave this package — declared secrets, values learned mid-run (`conceal`), the credential-bearing parts of a URL — and how a browser's own string map is read |
206
370
  | `expect.ts` | the silent-green alarm |
207
371
  | `watchdog.ts` | the wedge and zombie discipline |
208
372
  | `capture-clip.ts` | the one framing rule — crop, whole page, or a refusal — checked before any driver sees it |
209
- | `errors.ts` / `error-throws.ts` | this package's `X_*` codes and their retry classification |
373
+ | `errors.ts` / `error-throws.ts` / `error-throws-session.ts` | this package's `X_*` codes and their retry classification |
210
374
 
211
375
  ## Extending it — there is no plugin API, and none is needed
212
376
 
@@ -247,6 +411,7 @@ export const myBrowser = (options: MyOptions): ScrapeDriver => ({
247
411
  secrets: init.secrets,
248
412
  robots: init.robots,
249
413
  signal: init.signal,
414
+ usage: init.usage,
250
415
  }),
251
416
  http: myHttp(target, init),
252
417
  close: () => target.close(),
@@ -260,10 +425,8 @@ export const myBrowser = (options: MyOptions): ScrapeDriver => ({
260
425
  ```ts
261
426
  // apps/web/shared/base/bank-scrape.ts — the app's convention, written once
262
427
  import type { JobHandle } from '@ultimat3/jobs';
263
- import type { StorageDriver } from '@ultimat3/storage';
264
428
  import { scrape, type ScrapeDefinition } from '@ultimat3/scraping';
265
-
266
- declare const disk: StorageDriver;
429
+ import { disk } from '@ultimat3/storage';
267
430
 
268
431
  export const bankScrape = <I, R>(over: ScrapeDefinition<I, R>): JobHandle<I> =>
269
432
  scrape<I, R>({
@@ -271,7 +434,7 @@ export const bankScrape = <I, R>(over: ScrapeDefinition<I, R>): JobHandle<I> =>
271
434
  rate: 0.5,
272
435
  block: ['image', 'media', 'font'],
273
436
  expect: { minRows: 1, maxDrop: 0.5 },
274
- artifacts: { storage: disk },
437
+ artifacts: { storage: () => disk('artifacts') },
275
438
  ...over,
276
439
  });
277
440
  ```
@@ -298,6 +461,15 @@ Every code carries a cause, a runnable `fix:` and a retry classification — see
298
461
  `X_SCRAPE_YIELD_COLLAPSED`, `X_SCRAPE_AUTH_FAILED`, `X_SCRAPE_BLOCKED` and `X_SCRAPE_PAGE_CRASHED`
299
462
  are the four worth knowing by heart.
300
463
 
464
+ `X_SCRAPE_EGRESS_IN_PAYLOAD` is the one that is about the DECLARATION rather than the site: the
465
+ exit's password was readable out of the run's own input, so it is in `x_jobs` in the clear.
466
+ Enqueue the id of the row that holds the exit, resolve it in `egress`, and rotate the credential.
467
+
468
+ `X_SCRAPE_CDP_ATTACH_FAILED` names the endpoint by **scheme and host only**, for a fixed `cdpUrl` as
469
+ for a resolved one, and cuts every credential-bearing part of the URL out of the launcher's own
470
+ message: a provider's connect URL is its access token, and this cause is written to the dead-letter
471
+ row.
472
+
301
473
  ### Error classes
302
474
 
303
475
  Every error class `src/index.ts` exports, for `instanceof` inside one process. Across a wire or
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/scraping",
3
- "version": "22.15.0",
3
+ "version": "23.0.0",
4
4
  "description": "Browser automation as a job: scrape() returns a JobHandle",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -19,6 +19,7 @@
19
19
  "files": [
20
20
  "src",
21
21
  "!src/**/*.test.ts",
22
+ "!src/**/*-fixture.ts",
22
23
  "README.md",
23
24
  "LICENSE"
24
25
  ],
@@ -30,9 +31,9 @@
30
31
  "test": "bun test"
31
32
  },
32
33
  "dependencies": {
33
- "@ultimat3/core": "22.15.0",
34
- "@ultimat3/jobs": "22.15.0",
35
- "@ultimat3/schema": "22.15.0",
36
- "@ultimat3/storage": "22.15.0"
34
+ "@ultimat3/core": "23.0.0",
35
+ "@ultimat3/jobs": "23.0.0",
36
+ "@ultimat3/schema": "23.0.0",
37
+ "@ultimat3/storage": "23.0.0"
37
38
  }
38
39
  }
package/src/artifacts.ts CHANGED
@@ -21,7 +21,8 @@ export interface ArtifactWriter {
21
21
  }
22
22
 
23
23
  export interface ArtifactWriterInit {
24
- readonly storage: StorageDriver | undefined;
24
+ /** A thunk, read per save: the app's disk exists only after boot (`storageSessionStore`). */
25
+ readonly storage: (() => StorageDriver) | undefined;
25
26
  readonly scrape: string;
26
27
  readonly runId: string;
27
28
  /** Optional prefix, so an app can keep scrape artifacts out of its user-upload namespace. */
@@ -69,7 +70,7 @@ export function createArtifactWriter(init: ArtifactWriterInit): ArtifactWriter {
69
70
  contentType: contentType ?? contentTypeFor(name),
70
71
  };
71
72
  if (init.storage !== undefined) {
72
- await init.storage.put(ref.key, bytes, { contentType: ref.contentType });
73
+ await init.storage().put(ref.key, bytes, { contentType: ref.contentType });
73
74
  }
74
75
  saved.push(ref);
75
76
  return ref;
package/src/auth.ts CHANGED
@@ -25,21 +25,42 @@ import type { ScrapePage } from './page';
25
25
  import type { ScrapeSecrets } from './secrets';
26
26
  import { type ScrapeSessionStore, type SessionState, sessionDigest } from './session-state';
27
27
 
28
- export interface PromptRequest {
28
+ export interface PromptRequest<I = unknown> {
29
+ /**
30
+ * The run's own input — what ties this prompt to the app's identity for the run (a connection
31
+ * id, an org). A handler typed on the definition reads it typed; a shared one reads `unknown`.
32
+ */
33
+ readonly input: I;
29
34
  /** What the site is asking for: `'sms code'`, `'authenticator code'`, `'security question 3'`. */
30
35
  readonly label: string;
31
36
  readonly scrape: string;
32
37
  readonly url: string;
38
+ /** The job run asking. With `index` it names this one prompt: `promptEventName(runId, index)`. */
39
+ readonly runId: string;
40
+ /** 1 for the first prompt of this attempt, 2 for the next. */
41
+ readonly index: number;
42
+ /** The run's clock. A handler that waits, waits on this — never on a timer of its own. */
43
+ readonly clock: ScrapeClock;
44
+ /** The run's cancellation. A handler that waits past it holds a browser nobody is using. */
45
+ readonly signal: AbortSignal | undefined;
46
+ /**
47
+ * One cheap round trip to the browser. A handler that waits calls it between polls: the site is
48
+ * waiting INSIDE this session, so the wedge watchdog must keep seeing activity and a rented
49
+ * browser must not idle out — and a browser that died is found now, not after the answer.
50
+ */
51
+ keepAlive(): Promise<void>;
33
52
  }
34
53
 
35
54
  /**
36
55
  * Where an out-of-band code comes from. A declared callback field on the definition — typed,
37
56
  * discoverable, one per concern — and never a global "register a plugin" call.
38
57
  */
39
- export type PromptHandler = (request: PromptRequest) => Promise<string> | string;
58
+ export type PromptHandler<I = unknown> = (request: PromptRequest<I>) => Promise<string> | string;
40
59
 
41
60
  export interface AuthContext<I> {
42
61
  readonly input: I;
62
+ /** The job run logging in — the id the run's events, steps and prompts are keyed by. */
63
+ readonly runId: string;
43
64
  readonly page: ScrapePage;
44
65
  /**
45
66
  * The declared secrets, boxed. A login body needs the credential it is about to type, and
@@ -78,15 +99,50 @@ export interface ScrapeAuth<I> {
78
99
  readonly maxAge?: number | undefined;
79
100
  }
80
101
 
81
- export function createPrompt(
82
- scrape: string,
83
- handler: PromptHandler | undefined,
84
- page: ScrapePage,
85
- ): (label: string) => Promise<string> {
102
+ export interface PromptInit<I = unknown> {
103
+ readonly scrape: string;
104
+ readonly handler: PromptHandler<I> | undefined;
105
+ readonly input: I;
106
+ readonly page: ScrapePage;
107
+ readonly runId: string;
108
+ readonly clock: ScrapeClock;
109
+ readonly signal?: AbortSignal | undefined;
110
+ /** The run's secret set. Every answer is concealed in it before the body sees the answer. */
111
+ readonly secrets?: ScrapeSecrets | undefined;
112
+ /** Called once per answered prompt — `ScrapeReport.usage.promptsAnswered`. */
113
+ readonly onAnswered?: (() => void) | undefined;
114
+ }
115
+
116
+ /**
117
+ * Matches no element on any page, which is the point: the read costs one round trip and carries
118
+ * nothing back. It goes through the page vocabulary, so it reports activity like any other verb.
119
+ */
120
+ const KEEPALIVE_SELECTOR = 'ultimate-keepalive';
121
+
122
+ export function createPrompt<I>(init: PromptInit<I>): (label: string) => Promise<string> {
123
+ let asked = 0;
86
124
  return async (label: string): Promise<string> => {
125
+ const { scrape, handler, page } = init;
87
126
  if (handler === undefined) throw promptUnanswered(scrape, label);
88
- const answer = await handler({ label, scrape, url: page.url() });
127
+ asked += 1;
128
+ const answer = await handler({
129
+ input: init.input,
130
+ label,
131
+ scrape,
132
+ url: page.url(),
133
+ runId: init.runId,
134
+ index: asked,
135
+ clock: init.clock,
136
+ signal: init.signal,
137
+ keepAlive: async (): Promise<void> => {
138
+ await page.count(KEEPALIVE_SELECTOR);
139
+ },
140
+ });
89
141
  if (typeof answer !== 'string' || answer === '') throw promptUnanswered(scrape, label);
142
+ // Before the body can type it: an OTP echoed into the page it was typed on is in `page.html()`,
143
+ // and the failure artifact is written from exactly that.
144
+ init.secrets?.conceal(answer);
145
+ init.onAnswered?.();
90
146
  return answer;
91
147
  };
92
148
  }
@@ -163,6 +219,7 @@ export async function burnSession<I>(plan: AuthPlanInput<I>): Promise<void> {
163
219
 
164
220
  export interface EnsureAuthInput<I> extends AuthPlanInput<I> {
165
221
  readonly input: I;
222
+ readonly runId: string;
166
223
  readonly page: ScrapePage;
167
224
  readonly secrets: ScrapeSecrets;
168
225
  readonly restored: SessionState | undefined;
@@ -182,6 +239,7 @@ export async function ensureAuthenticated<I>(args: EnsureAuthInput<I>): Promise<
182
239
  if (auth === undefined) return false;
183
240
  const context: AuthContext<I> = {
184
241
  input: args.input,
242
+ runId: args.runId,
185
243
  page: args.page,
186
244
  secrets: args.secrets,
187
245
  prompt: args.prompt,
package/src/cdp-port.ts CHANGED
@@ -143,6 +143,16 @@ export interface CdpPageLike {
143
143
  * reason, and refused by name when absent.
144
144
  */
145
145
  createCDPSession?(): Promise<CdpSessionLike>;
146
+ /**
147
+ * `page.authenticate(credentials)`: answers the PROXY's own auth challenge. It is the only way a
148
+ * launched browser dials a credentialed exit — `--proxy-server` takes no userinfo, and one
149
+ * written there would sit in the process list. OPTIONAL for `keyboard`'s reason; a credentialed
150
+ * exit on a launcher without it is `X_SCRAPE_EGRESS_UNSUPPORTED`, never a direct connection.
151
+ */
152
+ authenticate?(credentials: {
153
+ readonly username: string;
154
+ readonly password: string;
155
+ }): Promise<void>;
146
156
  frames(): readonly CdpFrameLike[];
147
157
  close(): Promise<void>;
148
158
  }
@@ -0,0 +1,81 @@
1
+ // A browser RENTED per session: `remoteBrowser({ cdpUrl })` takes a function instead of a string,
2
+ // and what the function hands back is acquired once and released exactly once. The framework
3
+ // ships no vendor — a provider wrapper is about twenty lines of app code that returns this shape.
4
+
5
+ import { isUltimateError } from '@ultimat3/core';
6
+ import type { MoneyValue } from '@ultimat3/schema';
7
+ import { parse, t } from '@ultimat3/schema';
8
+ import { cdpResolveFailed } from './error-throws';
9
+
10
+ export interface CdpResolverRequest {
11
+ /** The scrape's name. */
12
+ readonly scrape: string;
13
+ /** The job run asking — what a provider session is tagged with. Absent outside a job. */
14
+ readonly runId: string | undefined;
15
+ /** The exit this run dials, when it declared one: rent the browser ON it. May carry credentials. */
16
+ readonly egress: string | undefined;
17
+ /** The run's cancellation. A rental still in flight when it aborts is the resolver's to cancel. */
18
+ readonly signal: AbortSignal | undefined;
19
+ }
20
+
21
+ export interface CdpResolution {
22
+ /** The connect URL. Usually carries the provider's access token: it is redacted, never printed. */
23
+ readonly cdpUrl: string;
24
+ /**
25
+ * Hand the browser back. Runs ONCE — in the session's `close()` on success, failure and abort,
26
+ * and straight away when the attach itself fails. A rejection is swallowed: it runs on the way
27
+ * out of a run whose own outcome is the one the reader needs.
28
+ */
29
+ release?(): Promise<void>;
30
+ /** What the rental cost, when the provider says so up front. `Money`, never a float. */
31
+ readonly cost?: MoneyValue | undefined;
32
+ }
33
+
34
+ export type CdpResolver = (request: CdpResolverRequest) => Promise<CdpResolution>;
35
+
36
+ export interface AcquiredCdp {
37
+ readonly cdpUrl: string;
38
+ readonly cost: MoneyValue | undefined;
39
+ /** The resolution's `release`, latched: the second call does nothing. Never throws. */
40
+ release(): Promise<void>;
41
+ }
42
+
43
+ /** A fixed URL and a resolver, as one shape — so the driver has one attach path, not two. */
44
+ export async function acquireCdp(
45
+ source: string | CdpResolver,
46
+ request: CdpResolverRequest,
47
+ ): Promise<AcquiredCdp> {
48
+ if (typeof source === 'string') {
49
+ return { cdpUrl: source, cost: undefined, release: () => Promise.resolve() };
50
+ }
51
+ let resolution: CdpResolution;
52
+ try {
53
+ resolution = await source(request);
54
+ } catch (thrown) {
55
+ // The resolver's own coded failure keeps its code and its classification; anything else is
56
+ // "no browser to attach to", which is this package's retryable attach failure.
57
+ throw isUltimateError(thrown) ? thrown : cdpResolveFailed(request.scrape, thrown);
58
+ }
59
+ let released = false;
60
+ const release = async (): Promise<void> => {
61
+ if (released) return;
62
+ released = true;
63
+ try {
64
+ await resolution.release?.();
65
+ } catch {
66
+ // See `CdpResolution.release`: a failed hand-back never replaces the run's own outcome.
67
+ }
68
+ };
69
+ try {
70
+ return {
71
+ cdpUrl: resolution.cdpUrl,
72
+ // Parsed, not trusted: the type says `minor: number` and `0.35` satisfies it. A float cost
73
+ // is refused here, before it is added to anything.
74
+ cost: resolution.cost === undefined ? undefined : parse(t.money, resolution.cost),
75
+ release,
76
+ };
77
+ } catch (thrown) {
78
+ await release();
79
+ throw thrown;
80
+ }
81
+ }
package/src/clock.ts CHANGED
@@ -48,6 +48,40 @@ export const systemScrapeClock: ScrapeClock = Object.freeze({
48
48
  },
49
49
  });
50
50
 
51
+ /**
52
+ * The system clock with the waiting taken out: `now()` and `monotonic()` are real, and every
53
+ * `sleep` is ONE turn of the event loop whatever was asked for. For a test whose run waits on
54
+ * something that really happens in the same process — an answer the test publishes, a job another
55
+ * worker holds — where `testClock()`'s "sleeping is advancing" would run a five-minute budget out
56
+ * in microtasks before the test's next line. A deadline is still a real one.
57
+ */
58
+ export const noWaitClock: ScrapeClock = Object.freeze({
59
+ now: () => systemScrapeClock.now(),
60
+ monotonic: () => systemScrapeClock.monotonic(),
61
+ sleep: (_ms: number, signal?: AbortSignal) => systemScrapeClock.sleep(0, signal),
62
+ });
63
+
64
+ let ambient: ScrapeClock | undefined;
65
+
66
+ /**
67
+ * The clock every run in this process waits on, unless its definition pins one — the counterpart
68
+ * of `setScrapeDriver()`, and the seam a TEST takes: `setScrapeClock(noWaitClock)` in a
69
+ * `beforeEach`, and a declaration under test needs no `clock:` field written for the test's sake.
70
+ */
71
+ export function setScrapeClock(clock: ScrapeClock): void {
72
+ ambient = clock;
73
+ }
74
+
75
+ /** The process's scrape clock: the installed one, or the system's. */
76
+ export function scrapeClock(): ScrapeClock {
77
+ return ambient ?? systemScrapeClock;
78
+ }
79
+
80
+ /** Test seam: back to the system clock, so the next file runs on real time again. */
81
+ export function resetScrapeClock(): void {
82
+ ambient = undefined;
83
+ }
84
+
51
85
  /** The abort reason, unwrapped — a caller's `AbortSignal.reason` is whatever they put there. */
52
86
  export function throwIfAborted(signal: AbortSignal): void {
53
87
  if (signal.aborted) throw signal.reason;