@ultimat3/scraping 29.3.1 → 29.3.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/scraping",
3
- "version": "29.3.1",
3
+ "version": "29.3.2",
4
4
  "description": "Browser automation as a job: scrape() returns a JobHandle",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,9 +31,9 @@
31
31
  "test": "bun test"
32
32
  },
33
33
  "dependencies": {
34
- "@ultimat3/core": "29.3.1",
35
- "@ultimat3/jobs": "29.3.1",
36
- "@ultimat3/schema": "29.3.1",
37
- "@ultimat3/storage": "29.3.1"
34
+ "@ultimat3/core": "29.3.2",
35
+ "@ultimat3/jobs": "29.3.2",
36
+ "@ultimat3/schema": "29.3.2",
37
+ "@ultimat3/storage": "29.3.2"
38
38
  }
39
39
  }
package/src/cdp-arm.ts CHANGED
@@ -16,7 +16,7 @@ import type {
16
16
  PageErrorRing,
17
17
  ResourceType,
18
18
  } from './rings';
19
- import { pageErrorEntry, RESOURCE_TYPES } from './rings';
19
+ import { clamped, pageErrorEntry, RESOURCE_TYPES } from './rings';
20
20
 
21
21
  /** What `arm()` reads: the page it subscribes to, the rules it judges by, the clock it stamps with. */
22
22
  export interface CdpArmInit {
@@ -134,7 +134,7 @@ export async function arm(init: CdpArmInit, sinks: CdpSinks): Promise<void> {
134
134
  const verdict = interceptVerdict(url, type, init.rules);
135
135
  const at = init.clock.now().getTime();
136
136
  if (verdict === 'allow') {
137
- network.push({ method, url, resourceType: type, at });
137
+ network.push({ method, url: clamped(url), resourceType: type, at });
138
138
  // Caught, never floated: both reject when the target closed mid-request (and on any other
139
139
  // protocol error), and a floated rejection is one Bun ends the process on. The request is
140
140
  // already recorded; there is nothing left to do for a target that is gone.
@@ -147,7 +147,8 @@ export async function arm(init: CdpArmInit, sinks: CdpSinks): Promise<void> {
147
147
  init.page.on('console', (payload) => {
148
148
  console_.push({
149
149
  level: consoleLevel(readStringFrom(payload, 'type') ?? ''),
150
- text: readStringFrom(payload, 'text') ?? '',
150
+ // Clamped: the ring bounds the count, and one `console.log(JSON.stringify(state))` is megabytes.
151
+ text: clamped(readStringFrom(payload, 'text') ?? ''),
151
152
  at: init.clock.now().getTime(),
152
153
  });
153
154
  });
package/src/clock.ts CHANGED
@@ -131,3 +131,18 @@ export function deadline(clock: Clock, totalMs: number, subject = 'a scrape wait
131
131
  const remainingMs = (): number => Math.max(0, budgetMs - (clock.monotonic() - startedAt));
132
132
  return { totalMs: budgetMs, remainingMs, expired: () => remainingMs() <= 0 };
133
133
  }
134
+
135
+ /**
136
+ * The timer a request DEADLINE is armed on (`deadline-signal.ts`) — not a wait this package
137
+ * performs, but a timer all the same, so it lives here. Cleared when the request settles, where
138
+ * `AbortSignal.timeout` stayed armed for its whole span after the answer had landed.
139
+ */
140
+ export interface DeadlineTimer {
141
+ set(fire: () => void, ms: number): unknown;
142
+ clear(handle: unknown): void;
143
+ }
144
+
145
+ export const platformDeadlineTimer: DeadlineTimer = Object.freeze({
146
+ set: (fire: () => void, ms: number): unknown => setTimeout(fire, ms),
147
+ clear: (handle: unknown): void => clearTimeout(handle as ReturnType<typeof setTimeout>),
148
+ });
@@ -0,0 +1,46 @@
1
+ // One request's signal: a deadline plus the caller's own signal, undone when the request settles.
2
+ // `AbortSignal.any([AbortSignal.timeout(ms), outer])` composed the same thing and could not be
3
+ // undone — every read hung one listener (and one composite) on a session-long `outer` and armed a
4
+ // timer that outlived the answer. The shape is `@ultimat3/jobs`' `createRunSignal`.
5
+
6
+ import { type DeadlineTimer, platformDeadlineTimer } from './clock';
7
+
8
+ export type { DeadlineTimer } from './clock';
9
+
10
+ export interface DeadlineSignal extends Disposable {
11
+ /** Aborts at the deadline (a `TimeoutError`, as `AbortSignal.timeout`'s) or with `outer`. */
12
+ readonly signal: AbortSignal;
13
+ /** Whether the DEADLINE fired — not the caller cancelling. What the timeout code is chosen by. */
14
+ timedOut(): boolean;
15
+ }
16
+
17
+ /**
18
+ * `ms` is the caller's already-screened deadline (a positive finite count). `[Symbol.dispose]`
19
+ * clears the timer and detaches from `outer`; idempotent, and it never aborts.
20
+ */
21
+ export function deadlineSignal(
22
+ ms: number,
23
+ outer?: AbortSignal,
24
+ timer: DeadlineTimer = platformDeadlineTimer,
25
+ ): DeadlineSignal {
26
+ const controller = new AbortController();
27
+ let fired = false;
28
+ const handle = timer.set(() => {
29
+ fired = true;
30
+ controller.abort(new DOMException('The operation timed out.', 'TimeoutError'));
31
+ }, ms);
32
+ const forward = (): void => controller.abort(outer?.reason);
33
+ if (outer?.aborted === true) forward();
34
+ else outer?.addEventListener('abort', forward, { once: true });
35
+ let open = true;
36
+ return {
37
+ signal: controller.signal,
38
+ timedOut: () => fired,
39
+ [Symbol.dispose]: () => {
40
+ if (!open) return;
41
+ open = false;
42
+ timer.clear(handle);
43
+ outer?.removeEventListener('abort', forward);
44
+ },
45
+ };
46
+ }
package/src/http.ts CHANGED
@@ -15,6 +15,7 @@ import type { StandardSchemaV1 } from '@ultimat3/schema';
15
15
  import { parse } from '@ultimat3/schema';
16
16
  import type { ScrapeClock } from './clock';
17
17
  import { cookieHeaderFor } from './cookie-scope';
18
+ import { type DeadlineTimer, deadlineSignal } from './deadline-signal';
18
19
  import { bodyTooLarge, hostBlocked, httpFailed, redirectLoop, scrapeTimeout } from './error-throws';
19
20
  import type { RedirectHop } from './http-redirect';
20
21
  import { MAX_REDIRECT_HOPS, redirectHop } from './http-redirect';
@@ -123,6 +124,8 @@ export interface HttpTransportInit {
123
124
  readonly fetch?: ScrapeFetch | undefined;
124
125
  /** The resolver a wildcard-admitted name is checked and pinned with. Defaults to `Bun.dns`. */
125
126
  readonly resolve?: HostResolve | undefined;
127
+ /** The deadline's timer. Defaults to the platform's; a test hands its own (`deadline-signal.ts`). */
128
+ readonly timer?: DeadlineTimer | undefined;
126
129
  }
127
130
 
128
131
  /**
@@ -320,15 +323,16 @@ export function httpOverFetch(init: HttpTransportInit): ScrapeHttp {
320
323
  init.onActivity?.();
321
324
  let dial = await screen(url);
322
325
  const session = await init.session();
323
- // `AbortSignal.timeout` and NOT `clock.sleep`: this is a deadline handed to the platform's
326
+ // A platform timer and NOT `clock.sleep`: this is a deadline handed to the platform's
324
327
  // own fetch, not a wait this package performs — and under a test clock a slept deadline
325
328
  // would fire on the microtask after it was armed, cancelling every request instantly.
326
329
  // The offline transport (`http-recorded.ts`) is what a test runs, and it has no deadline.
327
330
  //
328
331
  // ONE deadline for the whole chain, not one per hop: the caller declared a budget for
329
332
  // getting an answer, and ten hops each allowed the full budget is ten times the wait.
330
- const deadlineSignal = AbortSignal.timeout(timeoutMs);
331
- const signals = init.signal === undefined ? [deadlineSignal] : [deadlineSignal, init.signal];
333
+ // Disposed when the request settles: the timer cleared and the session-long `init.signal`
334
+ // let go — `AbortSignal.any` per hop hung a listener on it for every hop of every request.
335
+ using deadline = deadlineSignal(timeoutMs, init.signal, init.timer);
332
336
  try {
333
337
  let hop: RedirectHop = {
334
338
  url,
@@ -365,7 +369,7 @@ export function httpOverFetch(init: HttpTransportInit): ScrapeHttp {
365
369
  ),
366
370
  ...(dial.serverName === undefined ? {} : { tls: { serverName: dial.serverName } }),
367
371
  ...(hop.body === undefined ? {} : { body: hop.body }),
368
- signal: AbortSignal.any(signals),
372
+ signal: deadline.signal,
369
373
  // The whole point: the platform's own `follow` is what made the four lines above
370
374
  // decorative, because it dials the target itself and hands back one Response.
371
375
  redirect: 'manual',
@@ -404,7 +408,7 @@ export function httpOverFetch(init: HttpTransportInit): ScrapeHttp {
404
408
  } catch (thrown) {
405
409
  // A deadline that fired is this package's own timeout, with its own code and fix — never
406
410
  // the platform's bare `TimeoutError` reaching a job's retry classifier unclassified.
407
- if (deadlineSignal.aborted) throw scrapeTimeout(`http ${url}`, timeoutMs);
411
+ if (deadline.timedOut()) throw scrapeTimeout(`http ${url}`, timeoutMs);
408
412
  throw thrown;
409
413
  }
410
414
  },
package/src/intercept.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  import type { HostRule } from '@ultimat3/core';
9
9
  import { hostDecision } from '@ultimat3/core';
10
10
  import type { NetworkEntry, ResourceType } from './rings';
11
+ import { clamped } from './rings';
11
12
 
12
13
  export interface InterceptRules {
13
14
  readonly allowHosts: readonly HostRule[];
@@ -38,4 +39,4 @@ export const refusalEntry = (
38
39
  verdict: Exclude<InterceptVerdict, 'allow'>,
39
40
  at: number,
40
41
  method = 'GET',
41
- ): NetworkEntry => ({ method, url, resourceType, at, refused: verdict });
42
+ ): NetworkEntry => ({ method, url: clamped(url), resourceType, at, refused: verdict });
package/src/rings.ts CHANGED
@@ -44,10 +44,13 @@ export interface PageError {
44
44
  * The per-entry cap, which the ring itself cannot give: a ring bounds the COUNT, and one
45
45
  * `RangeError: Maximum call stack size exceeded` carries thousands of frames — 200 of those is
46
46
  * megabytes held per page, which is the same OOM this file exists to prevent, one level down.
47
+ * The same cap holds a console line's text and a network entry's URL: a page that logs a
48
+ * serialised state blob, or requests a `data:` URL, is the same megabytes per entry.
47
49
  */
48
50
  export const MAX_PAGE_ERROR_CHARS = 4_000;
49
51
 
50
- const clamped = (text: string): string =>
52
+ /** One entry's text, held to `MAX_PAGE_ERROR_CHARS` — every ring entry a page controls goes through it. */
53
+ export const clamped = (text: string): string =>
51
54
  text.length <= MAX_PAGE_ERROR_CHARS ? text : `${text.slice(0, MAX_PAGE_ERROR_CHARS - 1)}…`;
52
55
 
53
56
  /**
@@ -10,6 +10,7 @@
10
10
 
11
11
  import type { HostRule } from '@ultimat3/core';
12
12
  import { finiteCount, isUltimateError, readWithinLimit } from '@ultimat3/core';
13
+ import { type DeadlineTimer, deadlineSignal } from './deadline-signal';
13
14
  import type { ScrapeFetch } from './http';
14
15
  import { redirectHop } from './http-redirect';
15
16
  import { interceptVerdict } from './intercept';
@@ -58,6 +59,8 @@ export interface RobotsFetchInit {
58
59
  readonly allowHosts?: readonly HostRule[] | undefined;
59
60
  /** The resolver a wildcard-admitted name is checked and pinned with. Defaults to `Bun.dns`. */
60
61
  readonly resolve?: HostResolve | undefined;
62
+ /** The deadline's timer. Defaults to the platform's; a test hands its own (`deadline-signal.ts`). */
63
+ readonly timer?: DeadlineTimer | undefined;
61
64
  /**
62
65
  * The platform `fetch`, injectable so the default path itself is testable. `ScrapeFetch` and not
63
66
  * `typeof fetch`: the latter also carries `preconnect`, so nothing a caller can write satisfies
@@ -92,7 +95,12 @@ const answerOf = async (response: Response, limit: number): Promise<RobotsAnswer
92
95
  await response.body?.cancel().catch(() => undefined);
93
96
  return { unreachable: `status ${String(response.status)}` };
94
97
  }
95
- if (!response.ok) return undefined;
98
+ if (!response.ok) {
99
+ // A 4xx's body is never read: cancelled, so its connection is not held until collection (the
100
+ // deadline that used to abort it is disposed with the read).
101
+ await response.body?.cancel().catch(() => undefined);
102
+ return undefined;
103
+ }
96
104
  // Counted as it arrives rather than `.text()`, which materialises the whole body first: a
97
105
  // multi-gigabyte robots.txt is a heap the worker never gets back.
98
106
  const capped = await readWithinLimit(response.body, limit);
@@ -131,8 +139,10 @@ export function robotsFetcher(init: RobotsFetchInit = {}): RobotsFetch {
131
139
  return async (robotsUrl: string): Promise<RobotsAnswer> => {
132
140
  // Armed per read, not per gate: the gate is long-lived and reads once per origin, so a
133
141
  // deadline created alongside it would already have expired by the second origin.
134
- const deadline = AbortSignal.timeout(timeoutMs);
135
- const signal = init.signal === undefined ? deadline : AbortSignal.any([deadline, init.signal]);
142
+ // Disposed when the read settles: the timer cleared and the run-long `init.signal` let go.
143
+ // `AbortSignal.any` here hung one listener on it per origin read, for the life of the run.
144
+ using deadline = deadlineSignal(timeoutMs, init.signal, init.timer);
145
+ const signal = deadline.signal;
136
146
  // Resolved here, at the read, because the session that owns the exit did not exist when this
137
147
  // fetcher was built. An empty string is not an exit and is dropped with the absent one.
138
148
  const proxy = init.proxy?.();
@@ -176,7 +186,7 @@ export function robotsFetcher(init: RobotsFetchInit = {}): RobotsFetch {
176
186
  // The pin refusing an inward name is a POLICY answer about the host, and the navigation is
177
187
  // refused by the same rule — it is not evidence the origin is down.
178
188
  if (isUltimateError(error) && error.code === 'X_SCRAPE_HOST_BLOCKED') return undefined;
179
- if (deadline.aborted) return { unreachable: 'timeout' };
189
+ if (deadline.timedOut()) return { unreachable: 'timeout' };
180
190
  if (init.signal?.aborted === true) return { unreachable: 'cancelled' };
181
191
  return { unreachable: 'network' };
182
192
  }