@ultimat3/scraping 30.8.0 → 31.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 +73 -8
- package/package.json +9 -6
- package/src/actionability.ts +2 -2
- package/src/adapter.ts +22 -0
- package/src/auth.ts +4 -5
- package/src/browser-dial.ts +55 -0
- package/src/cdp-arm.ts +81 -22
- package/src/cdp-connection.ts +290 -0
- package/src/cdp-error-codes.ts +28 -0
- package/src/cdp-errors.ts +179 -0
- package/src/cdp-launch-attempt.ts +234 -0
- package/src/cdp-launch-candidates.ts +62 -0
- package/src/cdp-launch-probe.ts +75 -0
- package/src/cdp-launch-profile.ts +106 -0
- package/src/cdp-launch-reap.ts +68 -0
- package/src/cdp-launch-wire.ts +171 -0
- package/src/cdp-launch.ts +216 -0
- package/src/cdp-pipe.ts +77 -0
- package/src/cdp-wire-watch.ts +113 -0
- package/src/cdp.ts +46 -0
- package/src/clock.ts +4 -111
- package/src/driver-cdp.ts +3 -0
- package/src/driver-fake.ts +4 -5
- package/src/driver.ts +16 -11
- package/src/error-throws-pdf.ts +45 -0
- package/src/error-throws-session.ts +1 -1
- package/src/error-throws.ts +4 -4
- package/src/errors.ts +17 -1
- package/src/event-prompt.ts +10 -4
- package/src/events.ts +2 -3
- package/src/html-target.ts +2 -2
- package/src/http-recorded.ts +2 -2
- package/src/http.ts +2 -2
- package/src/index.ts +21 -75
- package/src/launch-args.ts +10 -1
- package/src/page-over-target.ts +2 -3
- package/src/pdf-action.ts +116 -0
- package/src/pdf-cdp-params.ts +65 -0
- package/src/pdf-fence.ts +86 -0
- package/src/pdf-render.ts +241 -0
- package/src/pdf-slots.ts +68 -0
- package/src/rate.ts +2 -2
- package/src/scrape-run.ts +3 -4
- package/src/scrape.ts +5 -5
- package/src/target.ts +1 -1
- package/src/usage.ts +2 -2
- package/src/watchdog.ts +3 -3
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// One page printed to PDF over raw CDP: open a target, fence its requests to the route's origin,
|
|
2
|
+
// navigate, wait for the load event and the fonts, refuse an error page, `Page.printToPDF`. The
|
|
3
|
+
// browser is `cdp-launch.ts`'s — one launch per render, at most `maxConcurrent` alive, killed when
|
|
4
|
+
// the request is aborted, closed in a `finally` — and injectable, so `bun test` needs no Chrome.
|
|
5
|
+
|
|
6
|
+
import type { SleepClock } from '@ultimat3/core';
|
|
7
|
+
import { ambientClock, finiteCount, stringField } from '@ultimat3/core';
|
|
8
|
+
import type { CdpConnection } from './cdp-connection';
|
|
9
|
+
import { CdpCallFailedError, CdpTimeoutError } from './cdp-errors';
|
|
10
|
+
import type { FoundChromeOptions } from './cdp-launch';
|
|
11
|
+
import { LAUNCH_TIMEOUT_MS, launchFoundChrome } from './cdp-launch';
|
|
12
|
+
import { pdfRouteFailed } from './error-throws-pdf';
|
|
13
|
+
import { printToPdfParams } from './pdf-cdp-params';
|
|
14
|
+
import { raisePdfFence } from './pdf-fence';
|
|
15
|
+
import type { PdfLayout } from './pdf-request';
|
|
16
|
+
import { pdfSlots } from './pdf-slots';
|
|
17
|
+
|
|
18
|
+
/** One render's every deadline: the launch's first answer, each CDP call, the load event. */
|
|
19
|
+
export const DEFAULT_PDF_TIMEOUT_MS = 30_000;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Sequential deadlines one render spends after the launch: create, attach, `Page.enable`, the two
|
|
23
|
+
* header calls, the load, the settled read, the print, the close — and one spare.
|
|
24
|
+
*/
|
|
25
|
+
export const PDF_RENDER_DEADLINES = 10;
|
|
26
|
+
|
|
27
|
+
/** The launched browser's OS-level bound: a cold start, then every deadline a render spends. */
|
|
28
|
+
export const pdfBrowserLifetimeMs = (timeoutMs: number): number =>
|
|
29
|
+
Math.max(timeoutMs, LAUNCH_TIMEOUT_MS) + PDF_RENDER_DEADLINES * timeoutMs;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Live print browsers per renderer, by default. Each Chrome holds 150-300 MB; two keeps a burst of
|
|
33
|
+
* prints (or a retry storm) from taking the web role's memory with it, and queues the rest.
|
|
34
|
+
*/
|
|
35
|
+
export const DEFAULT_PDF_MAX_CONCURRENT = 2;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* How a production render launches. The SANDBOX STAYS ON: a print renders the app's own pages, and
|
|
39
|
+
* those carry user content — the sandbox is what stands between a renderer exploit and the web
|
|
40
|
+
* role. The test launcher's `--no-sandbox` is for CI runners; the framework's runtime image is
|
|
41
|
+
* distroless and ships no Chrome, so the app's own image supplies it and runs it as non-root, where
|
|
42
|
+
* Chrome's namespace sandbox needs unprivileged user namespaces (or its setuid helper). A container
|
|
43
|
+
* that cannot grant them fails the launch with `X_CDP_LAUNCH_FAILED` quoting Chrome's "No usable
|
|
44
|
+
* sandbox"; that operator opts out in one visible line, `chromePdfRenderer({ sandbox: false })`.
|
|
45
|
+
*/
|
|
46
|
+
export const pdfLaunchOptions = (timeoutMs: number, sandbox = true): FoundChromeOptions => ({
|
|
47
|
+
lifetimeMs: pdfBrowserLifetimeMs(timeoutMs),
|
|
48
|
+
sandbox,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
export interface PdfRenderRequest {
|
|
52
|
+
/** Absolute. `pdf()` builds it from the app's own origin and a route path, never a caller's URL. */
|
|
53
|
+
readonly url: string;
|
|
54
|
+
readonly layout: PdfLayout;
|
|
55
|
+
/**
|
|
56
|
+
* Added to the requests the page makes to the ROUTE'S OWN ORIGIN — how a private route learns
|
|
57
|
+
* who is printing it. Never sent anywhere else: every other origin is blocked (`pdf-fence.ts`).
|
|
58
|
+
*/
|
|
59
|
+
readonly headers?: Readonly<Record<string, string>> | undefined;
|
|
60
|
+
/** The request's cancellation: aborted, a queued render leaves the queue, a running one its browser. */
|
|
61
|
+
readonly signal?: AbortSignal | undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The seam `pdf()` prints through. `chromePdfRenderer()` is the one this package ships. */
|
|
65
|
+
export interface PdfRenderer {
|
|
66
|
+
render(request: PdfRenderRequest): Promise<Uint8Array>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A browser one render owns: its connection, and the close that ends it. `LaunchedBrowser` fits. */
|
|
70
|
+
export interface PdfBrowser {
|
|
71
|
+
readonly connection: CdpConnection;
|
|
72
|
+
close(): Promise<void>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface ChromePdfRendererOptions {
|
|
76
|
+
/** Defaults to `launchFoundChrome` with `pdfLaunchOptions`: `CHROME_PATH`, then the known paths. */
|
|
77
|
+
readonly launch?: (() => Promise<PdfBrowser>) | undefined;
|
|
78
|
+
readonly timeoutMs?: number | undefined;
|
|
79
|
+
/** Browsers alive at once. Default `DEFAULT_PDF_MAX_CONCURRENT`. */
|
|
80
|
+
readonly maxConcurrent?: number | undefined;
|
|
81
|
+
/** How long a render waits for a slot before `X_SCRAPE_PDF_BUSY`. Default `timeoutMs`. */
|
|
82
|
+
readonly queueTimeoutMs?: number | undefined;
|
|
83
|
+
/** What the queue waits on. Default `ambientClock()`, read per wait. */
|
|
84
|
+
readonly clock?: SleepClock | undefined;
|
|
85
|
+
/** Chrome's sandbox for the default launch — on unless the container cannot grant it. */
|
|
86
|
+
readonly sandbox?: boolean | undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Read in the page once it loaded: the fonts settled (a PDF printed before a webfont arrives prints
|
|
91
|
+
* the fallback face), then the document's own HTTP status — `0` where the browser cannot say.
|
|
92
|
+
*/
|
|
93
|
+
const SETTLED_STATUS = `(async () => {
|
|
94
|
+
await document.fonts.ready;
|
|
95
|
+
const entry = performance.getEntriesByType('navigation')[0];
|
|
96
|
+
return entry && typeof entry.responseStatus === 'number' ? entry.responseStatus : 0;
|
|
97
|
+
})()`;
|
|
98
|
+
|
|
99
|
+
const required = (value: string | undefined, method: string, field: string): string => {
|
|
100
|
+
if (value === undefined || value === '') {
|
|
101
|
+
throw new CdpCallFailedError({ method, detail: `the browser answered no ${field}` });
|
|
102
|
+
}
|
|
103
|
+
return value;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/** Navigate and wait for `load` — the event, not the reply, which Chrome drops on a process swap. */
|
|
107
|
+
async function navigate(
|
|
108
|
+
connection: CdpConnection,
|
|
109
|
+
sessionId: string,
|
|
110
|
+
url: string,
|
|
111
|
+
timeoutMs: number,
|
|
112
|
+
): Promise<void> {
|
|
113
|
+
const loaded = connection.once('Page.loadEventFired', sessionId, timeoutMs);
|
|
114
|
+
const refused = connection.send('Page.navigate', { url }, sessionId).then(
|
|
115
|
+
(answer) => {
|
|
116
|
+
const failed = answer.result?.['errorText'];
|
|
117
|
+
return typeof failed === 'string' && failed !== '' ? failed : undefined;
|
|
118
|
+
},
|
|
119
|
+
() => undefined,
|
|
120
|
+
);
|
|
121
|
+
const failed = await Promise.race([loaded.then(() => undefined), refused]);
|
|
122
|
+
if (failed !== undefined) {
|
|
123
|
+
throw new CdpCallFailedError({ method: `Page.navigate to ${url}`, detail: failed });
|
|
124
|
+
}
|
|
125
|
+
if (!(await loaded)) throw new CdpTimeoutError({ method: 'Page.loadEventFired', timeoutMs });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function printOnce(
|
|
129
|
+
connection: CdpConnection,
|
|
130
|
+
request: PdfRenderRequest,
|
|
131
|
+
timeoutMs: number,
|
|
132
|
+
): Promise<Uint8Array> {
|
|
133
|
+
const created = await connection.send('Target.createTarget', { url: 'about:blank' });
|
|
134
|
+
const targetId = required(
|
|
135
|
+
stringField(created.result, 'targetId'),
|
|
136
|
+
'Target.createTarget',
|
|
137
|
+
'targetId',
|
|
138
|
+
);
|
|
139
|
+
try {
|
|
140
|
+
const attached = await connection.send('Target.attachToTarget', { targetId, flatten: true });
|
|
141
|
+
const sessionId = required(
|
|
142
|
+
stringField(attached.result, 'sessionId'),
|
|
143
|
+
'Target.attachToTarget',
|
|
144
|
+
'sessionId',
|
|
145
|
+
);
|
|
146
|
+
await connection.send('Page.enable', {}, sessionId);
|
|
147
|
+
await raisePdfFence({
|
|
148
|
+
connection,
|
|
149
|
+
sessionId,
|
|
150
|
+
origin: new URL(request.url).origin,
|
|
151
|
+
headers: request.headers,
|
|
152
|
+
});
|
|
153
|
+
await navigate(connection, sessionId, request.url, timeoutMs);
|
|
154
|
+
const settled = await connection.send(
|
|
155
|
+
'Runtime.evaluate',
|
|
156
|
+
{ expression: SETTLED_STATUS, awaitPromise: true, returnByValue: true },
|
|
157
|
+
sessionId,
|
|
158
|
+
);
|
|
159
|
+
const inner = settled.result?.['result'];
|
|
160
|
+
const status =
|
|
161
|
+
typeof inner === 'object' && inner !== null && 'value' in inner ? inner.value : 0;
|
|
162
|
+
if (typeof status === 'number' && status >= 400) throw pdfRouteFailed(request.url, status);
|
|
163
|
+
const printed = await connection.send(
|
|
164
|
+
'Page.printToPDF',
|
|
165
|
+
printToPdfParams(request.layout),
|
|
166
|
+
sessionId,
|
|
167
|
+
);
|
|
168
|
+
return Uint8Array.fromBase64(
|
|
169
|
+
required(stringField(printed.result, 'data'), 'Page.printToPDF', 'data'),
|
|
170
|
+
);
|
|
171
|
+
} finally {
|
|
172
|
+
await connection.send('Target.closeTarget', { targetId }).catch(() => undefined);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** One render on a browser this call owns: killed the moment the request is aborted. */
|
|
177
|
+
async function renderOn(
|
|
178
|
+
browser: PdfBrowser,
|
|
179
|
+
request: PdfRenderRequest,
|
|
180
|
+
timeoutMs: number,
|
|
181
|
+
): Promise<Uint8Array> {
|
|
182
|
+
const { signal } = request;
|
|
183
|
+
const kill = (): void => {
|
|
184
|
+
void browser.close();
|
|
185
|
+
};
|
|
186
|
+
signal?.addEventListener('abort', kill, { once: true });
|
|
187
|
+
try {
|
|
188
|
+
signal?.throwIfAborted();
|
|
189
|
+
return await printOnce(browser.connection, request, timeoutMs);
|
|
190
|
+
} catch (error) {
|
|
191
|
+
// A call cut off by the kill reports the dead socket; the reason the request was dropped is
|
|
192
|
+
// the truth, and is what the caller's own catch expects.
|
|
193
|
+
throw signal?.aborted === true ? signal.reason : error;
|
|
194
|
+
} finally {
|
|
195
|
+
signal?.removeEventListener('abort', kill);
|
|
196
|
+
await browser.close();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Prints through a Chrome launched for the one render and closed after it, success or not: no
|
|
202
|
+
* browser outlives a request, and nothing is shared between two tenants' documents. At most
|
|
203
|
+
* `maxConcurrent` are alive; the rest queue, bounded. A launch costs a second or two; a pool is the
|
|
204
|
+
* measured next step, not a guess (axiom 9).
|
|
205
|
+
*/
|
|
206
|
+
export function chromePdfRenderer(options: ChromePdfRendererOptions = {}): PdfRenderer {
|
|
207
|
+
const timeoutMs = finiteCount(
|
|
208
|
+
'chromePdfRenderer',
|
|
209
|
+
'timeoutMs',
|
|
210
|
+
options.timeoutMs ?? DEFAULT_PDF_TIMEOUT_MS,
|
|
211
|
+
1,
|
|
212
|
+
);
|
|
213
|
+
const slots = pdfSlots({
|
|
214
|
+
maxConcurrent: finiteCount(
|
|
215
|
+
'chromePdfRenderer',
|
|
216
|
+
'maxConcurrent',
|
|
217
|
+
options.maxConcurrent ?? DEFAULT_PDF_MAX_CONCURRENT,
|
|
218
|
+
1,
|
|
219
|
+
),
|
|
220
|
+
queueTimeoutMs: finiteCount(
|
|
221
|
+
'chromePdfRenderer',
|
|
222
|
+
'queueTimeoutMs',
|
|
223
|
+
options.queueTimeoutMs ?? timeoutMs,
|
|
224
|
+
0,
|
|
225
|
+
),
|
|
226
|
+
clock: () => options.clock ?? ambientClock(),
|
|
227
|
+
});
|
|
228
|
+
const launch =
|
|
229
|
+
options.launch ??
|
|
230
|
+
(() => launchFoundChrome(process.env, timeoutMs, pdfLaunchOptions(timeoutMs, options.sandbox)));
|
|
231
|
+
return {
|
|
232
|
+
async render(request) {
|
|
233
|
+
const release = await slots.acquire(request.signal);
|
|
234
|
+
try {
|
|
235
|
+
return await renderOn(await launch(), request, timeoutMs);
|
|
236
|
+
} finally {
|
|
237
|
+
release();
|
|
238
|
+
}
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
}
|
package/src/pdf-slots.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// The bound on live print browsers: a counting semaphore, first come first served. A queued render
|
|
2
|
+
// waits on the CLOCK (never a timer of its own — `clock-discipline.test.ts`) for a bounded time and
|
|
3
|
+
// then refuses with a code; an aborted request leaves the queue at once and takes no slot.
|
|
4
|
+
|
|
5
|
+
import type { SleepClock } from '@ultimat3/core';
|
|
6
|
+
import { pdfBusy } from './error-throws-pdf';
|
|
7
|
+
|
|
8
|
+
export interface PdfSlotsOptions {
|
|
9
|
+
readonly maxConcurrent: number;
|
|
10
|
+
readonly queueTimeoutMs: number;
|
|
11
|
+
/** Read per wait, so an `installClock` made after the renderer still governs it. */
|
|
12
|
+
readonly clock: () => SleepClock;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface PdfSlots {
|
|
16
|
+
/** Answers the release — idempotent — once a slot is this caller's. */
|
|
17
|
+
acquire(signal?: AbortSignal): Promise<() => void>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface Waiter {
|
|
21
|
+
readonly grant: () => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function pdfSlots(options: PdfSlotsOptions): PdfSlots {
|
|
25
|
+
let busy = 0;
|
|
26
|
+
const queue: Waiter[] = [];
|
|
27
|
+
const releaser = (): (() => void) => {
|
|
28
|
+
let released = false;
|
|
29
|
+
return () => {
|
|
30
|
+
if (released) return;
|
|
31
|
+
released = true;
|
|
32
|
+
const next = queue.shift();
|
|
33
|
+
// The slot passes straight to the next waiter: `busy` never dips, so no newcomer jumps it.
|
|
34
|
+
if (next === undefined) busy -= 1;
|
|
35
|
+
else next.grant();
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
return {
|
|
39
|
+
async acquire(signal) {
|
|
40
|
+
signal?.throwIfAborted();
|
|
41
|
+
if (busy < options.maxConcurrent) {
|
|
42
|
+
busy += 1;
|
|
43
|
+
return releaser();
|
|
44
|
+
}
|
|
45
|
+
const granted = new AbortController();
|
|
46
|
+
const waiter: Waiter = { grant: () => granted.abort() };
|
|
47
|
+
queue.push(waiter);
|
|
48
|
+
const leave = (): void => {
|
|
49
|
+
const at = queue.indexOf(waiter);
|
|
50
|
+
if (at !== -1) queue.splice(at, 1);
|
|
51
|
+
};
|
|
52
|
+
try {
|
|
53
|
+
const wake =
|
|
54
|
+
signal === undefined ? granted.signal : AbortSignal.any([granted.signal, signal]);
|
|
55
|
+
await options.clock().sleep(options.queueTimeoutMs, wake);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
// Woken: either the slot is ours, or the caller gave up — the request's reason, verbatim.
|
|
58
|
+
if (granted.signal.aborted) return releaser();
|
|
59
|
+
leave();
|
|
60
|
+
throw signal?.aborted === true ? signal.reason : error;
|
|
61
|
+
}
|
|
62
|
+
// Slept the whole deadline. A grant racing the wake-up still owns the slot: hand it back.
|
|
63
|
+
if (granted.signal.aborted) return releaser();
|
|
64
|
+
leave();
|
|
65
|
+
throw pdfBusy(options);
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
package/src/rate.ts
CHANGED
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
// scraper that looks like a denial of service to the site it depends on, and the ban is charged
|
|
3
3
|
// to whoever owns the IP — usually the whole fleet, not the one run.
|
|
4
4
|
|
|
5
|
-
import type {
|
|
5
|
+
import type { SleepClock } from '@ultimat3/core';
|
|
6
6
|
|
|
7
7
|
/** Deliberately slow. Raising it is a decision; there is no way to turn pacing off. */
|
|
8
8
|
export const DEFAULT_NAVIGATION_RATE = 1;
|
|
9
9
|
|
|
10
10
|
export type Pacer = (signal?: AbortSignal) => Promise<void>;
|
|
11
11
|
|
|
12
|
-
export function scrapePacer(rate: number, clock:
|
|
12
|
+
export function scrapePacer(rate: number, clock: SleepClock): Pacer {
|
|
13
13
|
const intervalMs = 1_000 / rate;
|
|
14
14
|
let nextAt = 0;
|
|
15
15
|
return async (signal?: AbortSignal): Promise<void> => {
|
package/src/scrape-run.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// A step record saying "logged in" would be a checkpoint asserting something about a session that
|
|
10
10
|
// may have expired an hour ago.
|
|
11
11
|
|
|
12
|
-
import { finiteCount, finiteOption } from '@ultimat3/core';
|
|
12
|
+
import { ambientClock, finiteCount, finiteOption } from '@ultimat3/core';
|
|
13
13
|
import type { JobRunArgs } from '@ultimat3/jobs';
|
|
14
14
|
import { parse } from '@ultimat3/schema';
|
|
15
15
|
import { artifactWriter } from './artifacts';
|
|
@@ -22,7 +22,6 @@ import {
|
|
|
22
22
|
persistSession,
|
|
23
23
|
scrapePrompt,
|
|
24
24
|
} from './auth';
|
|
25
|
-
import { scrapeClock } from './clock';
|
|
26
25
|
import type { ScrapeSession } from './driver';
|
|
27
26
|
import { scrapeDriver } from './driver';
|
|
28
27
|
import { driverUnknown, outputInvalid } from './error-throws';
|
|
@@ -88,7 +87,7 @@ export async function runScrape<I, Row>(
|
|
|
88
87
|
definition: ScrapeDefinition<I, Row>,
|
|
89
88
|
args: JobRunArgs<I>,
|
|
90
89
|
): Promise<ScrapeReport<Row>> {
|
|
91
|
-
const clock = definition.clock ??
|
|
90
|
+
const clock = definition.clock ?? ambientClock();
|
|
92
91
|
const driver = definition.driver ?? scrapeDriver();
|
|
93
92
|
// The scrape's name goes in the SCRAPE slot: there is no driver here to name, which is the
|
|
94
93
|
// whole failure.
|
|
@@ -342,7 +341,7 @@ async function bodyWithRecovery<I, Row>(
|
|
|
342
341
|
});
|
|
343
342
|
try {
|
|
344
343
|
return await withStepEvent(
|
|
345
|
-
{ name: 'body', logger, clock: definition.clock ??
|
|
344
|
+
{ name: 'body', logger, clock: definition.clock ?? ambientClock(), attempt: args.attempt },
|
|
346
345
|
body,
|
|
347
346
|
);
|
|
348
347
|
} catch (thrown) {
|
package/src/scrape.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// the account. So this file is a FACTORY over `job()`, and a scrape inherits `.enqueue()`, the
|
|
10
10
|
// worker's cancellation, the dead-letter path, `x jobs show` and its manifest row for free.
|
|
11
11
|
|
|
12
|
-
import type { Ctx, HostRule } from '@ultimat3/core';
|
|
12
|
+
import type { Ctx, HostRule, SleepClock } from '@ultimat3/core';
|
|
13
13
|
import { assert } from '@ultimat3/core';
|
|
14
14
|
import type {
|
|
15
15
|
JobCompleted,
|
|
@@ -26,7 +26,6 @@ import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
|
26
26
|
import type { StorageDriver } from '@ultimat3/storage';
|
|
27
27
|
import type { ArtifactWriter } from './artifacts';
|
|
28
28
|
import type { PromptHandler, ScrapeAuth } from './auth';
|
|
29
|
-
import type { ScrapeClock } from './clock';
|
|
30
29
|
import type { ScrapeDriver } from './driver';
|
|
31
30
|
import { yieldHistoryMissing } from './error-throws';
|
|
32
31
|
import type { YieldExpectation, YieldHistory } from './expect';
|
|
@@ -140,10 +139,11 @@ export interface ScrapeDefinition<I, Row> {
|
|
|
140
139
|
readonly concurrency?: JobConcurrency<I> | undefined;
|
|
141
140
|
readonly queue?: string | undefined;
|
|
142
141
|
/**
|
|
143
|
-
* Pins this definition's clock. Omit it: a run waits on the process's (`
|
|
144
|
-
* is the system clock
|
|
142
|
+
* Pins this definition's clock. Omit it: a run waits on the process's (`ambientClock()`), which
|
|
143
|
+
* is the system clock unless `clock` in `apps/<app>/runtime.ts` (or a test's
|
|
144
|
+
* `installClock`, `@ultimat3/core/adapter`) replaced it.
|
|
145
145
|
*/
|
|
146
|
-
readonly clock?:
|
|
146
|
+
readonly clock?: SleepClock | undefined;
|
|
147
147
|
run(args: ScrapeRunArgs<I>): Promise<readonly unknown[]>;
|
|
148
148
|
/**
|
|
149
149
|
* The job's own `onSettled`, with what a scrape adds: a `completed` run hands over its
|
package/src/target.ts
CHANGED
|
@@ -118,7 +118,7 @@ export interface GotoOptions {
|
|
|
118
118
|
* so `page.screenshot({ timeout })` was a documented deadline that bounded nothing. Deleted
|
|
119
119
|
* rather than implemented: the CDP port's own `screenshot({ fullPage })` has no timeout slot to
|
|
120
120
|
* forward it to, and a deadline enforced in `page-over-target.ts` would have to race
|
|
121
|
-
* `
|
|
121
|
+
* `SleepClock.sleep`, which under `frozenClock` resolves on the first microtask and would time
|
|
122
122
|
* out every capture in every test. A driver's own default is the honest bound.
|
|
123
123
|
*/
|
|
124
124
|
export type CaptureOptions = CaptureFraming;
|
package/src/usage.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
// and nothing else: no quota, no plan, no ledger — what a number here is worth, and what happens
|
|
3
3
|
// when it is too high, is the app's (`docs/idea/19-mechanism-not-convention.md`).
|
|
4
4
|
|
|
5
|
+
import type { SleepClock } from '@ultimat3/core';
|
|
5
6
|
import type { MoneyValue } from '@ultimat3/schema';
|
|
6
|
-
import type { ScrapeClock } from './clock';
|
|
7
7
|
|
|
8
8
|
export interface ScrapeUsage {
|
|
9
9
|
/** Milliseconds the browser session was held, from before `driver.open()` to this report. */
|
|
@@ -43,7 +43,7 @@ export interface RunUsageMeter extends UsageMeter {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
/** Built BEFORE `driver.open()`, so the time a rented browser took to arrive is time it was held. */
|
|
46
|
-
export function usageMeter(clock:
|
|
46
|
+
export function usageMeter(clock: SleepClock): RunUsageMeter {
|
|
47
47
|
const startedAt = clock.monotonic();
|
|
48
48
|
let navigations = 0;
|
|
49
49
|
let httpRequests = 0;
|
package/src/watchdog.ts
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
// socket die, which is what turns an infinite await into a catchable `X_SCRAPE_WEDGED` — and a
|
|
11
11
|
// graceful-quit CEILING on shutdown, past which the same kill runs.
|
|
12
12
|
|
|
13
|
+
import type { SleepClock } from '@ultimat3/core';
|
|
13
14
|
import { finiteCount } from '@ultimat3/core';
|
|
14
|
-
import type { ScrapeClock } from './clock';
|
|
15
15
|
import { watchdogStopped, wedged } from './error-throws';
|
|
16
16
|
|
|
17
17
|
export const DEFAULT_IDLE_MS = 120_000;
|
|
@@ -19,7 +19,7 @@ export const DEFAULT_GRACE_MS = 5_000;
|
|
|
19
19
|
const WATCH_POLL_MS = 250;
|
|
20
20
|
|
|
21
21
|
export interface WedgeGuardInit {
|
|
22
|
-
readonly clock:
|
|
22
|
+
readonly clock: SleepClock;
|
|
23
23
|
/** Longest gap between two browser operations before the process is killed. */
|
|
24
24
|
readonly idleMs?: number | undefined;
|
|
25
25
|
/** How long a graceful quit may take before the same kill runs. */
|
|
@@ -129,7 +129,7 @@ export function createWedgeGuard(init: WedgeGuardInit): WedgeGuard {
|
|
|
129
129
|
}
|
|
130
130
|
}
|
|
131
131
|
};
|
|
132
|
-
// Never floating. `
|
|
132
|
+
// Never floating. `SleepClock` is a seam an app implements and `init.kill()` comes from a
|
|
133
133
|
// driver, so this loop can reject on somebody else's code — and a bare `void` turned that into
|
|
134
134
|
// an unhandled rejection, a process-level event belonging to no run, with the guard silently
|
|
135
135
|
// dead behind it. A guard that stopped measuring is incident #1 with nothing armed, so the run
|