@bluefields/fetcher 0.2.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.
Files changed (44) hide show
  1. package/LICENSE +661 -0
  2. package/dist/asset-block-measure.d.ts +14 -0
  3. package/dist/asset-block-measure.js +82 -0
  4. package/dist/asset-block-measure.js.map +1 -0
  5. package/dist/browser-pool.d.ts +65 -0
  6. package/dist/browser-pool.js +161 -0
  7. package/dist/browser-pool.js.map +1 -0
  8. package/dist/cost.d.ts +51 -0
  9. package/dist/cost.js +54 -0
  10. package/dist/cost.js.map +1 -0
  11. package/dist/fetcher-brightdata.d.ts +52 -0
  12. package/dist/fetcher-brightdata.js +135 -0
  13. package/dist/fetcher-brightdata.js.map +1 -0
  14. package/dist/fetcher-escalating.d.ts +100 -0
  15. package/dist/fetcher-escalating.js +489 -0
  16. package/dist/fetcher-escalating.js.map +1 -0
  17. package/dist/fetcher-http.d.ts +81 -0
  18. package/dist/fetcher-http.js +395 -0
  19. package/dist/fetcher-http.js.map +1 -0
  20. package/dist/fetcher-patchright.d.ts +212 -0
  21. package/dist/fetcher-patchright.js +505 -0
  22. package/dist/fetcher-patchright.js.map +1 -0
  23. package/dist/fetcher-robots-gate.d.ts +60 -0
  24. package/dist/fetcher-robots-gate.js +87 -0
  25. package/dist/fetcher-robots-gate.js.map +1 -0
  26. package/dist/index.d.ts +93 -0
  27. package/dist/index.js +219 -0
  28. package/dist/index.js.map +1 -0
  29. package/dist/internal-options.d.ts +59 -0
  30. package/dist/internal-options.js +16 -0
  31. package/dist/internal-options.js.map +1 -0
  32. package/dist/perf-harness.d.ts +105 -0
  33. package/dist/perf-harness.js +176 -0
  34. package/dist/perf-harness.js.map +1 -0
  35. package/dist/storage-memory.d.ts +18 -0
  36. package/dist/storage-memory.js +45 -0
  37. package/dist/storage-memory.js.map +1 -0
  38. package/dist/storage-r2.d.ts +44 -0
  39. package/dist/storage-r2.js +209 -0
  40. package/dist/storage-r2.js.map +1 -0
  41. package/dist/types.d.ts +231 -0
  42. package/dist/types.js +22 -0
  43. package/dist/types.js.map +1 -0
  44. package/package.json +54 -0
@@ -0,0 +1,87 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ /**
3
+ * Robots-gated fetcher — the central robots.txt choke point (see ROBOTS_POLICY.md).
4
+ *
5
+ * Wraps any FetcherAdapter so every route that fetches through it gets uniform
6
+ * robots handling (instead of the per-path drift the audit found). When
7
+ * robots-respect is enabled it:
8
+ * - refuses URLs the target's robots.txt disallows for our product token →
9
+ * throws `RobotsDisallowedError` (routes map it to 403 `robots_disallowed`);
10
+ * - paces same-host requests by `max(Crawl-delay, politeness floor)`.
11
+ *
12
+ * Authorized opt-out: when the request carries the internal
13
+ * `__robotsBypassAuthorized` flag (resolved UPSTREAM at the API layer from the
14
+ * customer's `ignoreRobots` + `robotsAttestation` AND an account capability), the
15
+ * Disallow check is skipped and the bypass is logged for audit. The gate never
16
+ * decides authorization itself — that's an API/control concern (four-layer rule).
17
+ *
18
+ * Phase-in: respect is OFF by default; enable per-environment via
19
+ * `ROBOTS_RESPECT=1` or the explicit `respectRobots` option. When off, the gate
20
+ * is a transparent pass-through (zero behavior change). robots.txt evaluation is
21
+ * fail-open (a target's robots outage never blocks the fetch) — see
22
+ * `evaluateRobots`. robots.txt URLs are SSRF-validated inside `evaluateRobots`;
23
+ * this gate adds no new trust-boundary crossing.
24
+ */
25
+ import { RobotsDisallowedError, createLayerLogger, evaluateRobots } from '@bluefields/primitives';
26
+ const log = createLayerLogger('fetcher');
27
+ /** The product token we match robots.txt against — NOT the (possibly spoofed) fetch UA. */
28
+ const ROBOTS_USER_AGENT = 'BlueFields-Fetcher';
29
+ /** Minimum spacing between requests to the same host, even absent a Crawl-delay. */
30
+ const DEFAULT_POLITENESS_FLOOR_MS = 1_000;
31
+ /**
32
+ * Build a robots-gated fetcher wrapping `opts.inner`. Drop-in `FetcherAdapter`,
33
+ * so adopting it is just wrapping the fetcher a route already constructs.
34
+ */
35
+ export function createRobotsGatedFetcher(opts) {
36
+ const env = opts.env ?? process.env;
37
+ const respectRobots = opts.respectRobots ?? env.ROBOTS_RESPECT === '1';
38
+ const floorMs = opts.politenessFloorMs ?? DEFAULT_POLITENESS_FLOOR_MS;
39
+ const evaluate = opts.evaluate ?? evaluateRobots;
40
+ const now = opts.nowMsFn ?? Date.now;
41
+ const sleep = opts.sleepFn ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
42
+ /** Per-instance last-request time per host, for politeness pacing. */
43
+ const lastFetchByHost = new Map();
44
+ return {
45
+ async fetch(url, options) {
46
+ // Phase-in off → transparent pass-through (current behavior, no robots fetch).
47
+ if (!respectRobots)
48
+ return opts.inner.fetch(url, options);
49
+ const host = hostOf(url);
50
+ // Authorized opt-out: skip the Disallow check, log for audit. Resolved
51
+ // upstream — we only honor a resolved boolean here.
52
+ if (options?.__robotsBypassAuthorized === true) {
53
+ const audit = { url, host: host ?? '', atMs: now() };
54
+ opts.onBypass?.(audit);
55
+ log.info({ outcome: 'ok', url, host, robots_bypass: true }, 'robots opt-out honored (authorized)');
56
+ return opts.inner.fetch(url, options);
57
+ }
58
+ const { allowed, crawlDelaySeconds } = await evaluate(url, ROBOTS_USER_AGENT, {
59
+ ...(opts.robotsFetchImpl ? { fetchImpl: opts.robotsFetchImpl } : {}),
60
+ });
61
+ if (!allowed) {
62
+ log.info({ outcome: 'blocked', url, host, reason: 'robots_disallowed' }, 'robots.txt disallows fetch');
63
+ throw new RobotsDisallowedError(url);
64
+ }
65
+ // Politeness: space same-host requests by max(Crawl-delay, floor). For a
66
+ // one-shot scrape the host is unseen → no wait; binds on crawl/batch.
67
+ if (host) {
68
+ const delayMs = Math.max(floorMs, (crawlDelaySeconds ?? 0) * 1_000);
69
+ const last = lastFetchByHost.get(host);
70
+ const wait = last === undefined ? 0 : delayMs - (now() - last);
71
+ if (wait > 0)
72
+ await sleep(wait);
73
+ lastFetchByHost.set(host, now());
74
+ }
75
+ return opts.inner.fetch(url, options);
76
+ },
77
+ };
78
+ }
79
+ function hostOf(url) {
80
+ try {
81
+ return new URL(url).host.toLowerCase();
82
+ }
83
+ catch {
84
+ return null;
85
+ }
86
+ }
87
+ //# sourceMappingURL=fetcher-robots-gate.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fetcher-robots-gate.js","sourceRoot":"","sources":["../src/fetcher-robots-gate.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,93 @@
1
+ /**
2
+ * @bluefields/fetcher — public surface (chunks 8 + 9).
3
+ *
4
+ * Per the four-layer rule:
5
+ * - Fetcher returns raw HTML + metadata.
6
+ * - It does NOT extract, diff, or notify.
7
+ *
8
+ * Production wiring:
9
+ * - Fetcher: HTTP (default) → Patchright (when IPROYAL_* env set + dep
10
+ * installed).
11
+ * - Storage: in-memory (default) → R2 (when R2_* env set).
12
+ *
13
+ * `fetchAndStore` orchestrates one end-to-end fetch: robots-check →
14
+ * fetch via adapter → store content-addressed → sign the attestation
15
+ * manifest (when a signer is configured — A3, Review #3) → insert
16
+ * snapshot row → write cost_records.
17
+ *
18
+ * The return is a discriminated outcome — chunk 21's poll handler
19
+ * switches on `outcome.ok` to decide whether to feed the extractor or
20
+ * record a blocked-poll event.
21
+ */
22
+ import { type Signer } from '@bluefields/attest';
23
+ import { type DbClient } from '@bluefields/db';
24
+ import type { FetchOptions, FetchResult, FetcherAdapter, StorageAdapter } from './types.js';
25
+ export { type FetchOptions, type FetchResult, type FetcherAdapter, type PageAction, type RequestCookie, type StorageAdapter, type StoredSnapshot, type WaitCondition, buildR2Key, } from './types.js';
26
+ export { createHttpFetcherAdapter } from './fetcher-http.js';
27
+ export { PATCHRIGHT_REQUIRED_ENV, tryCreatePatchrightAdapter } from './fetcher-patchright.js';
28
+ export { type EscalatingFetcherOptions, type FetchOutcome, classifyFetchResult, createEscalatingFetcherAdapter, } from './fetcher-escalating.js';
29
+ export { type RobotsBypassAudit, type RobotsGateOptions, createRobotsGatedFetcher, } from './fetcher-robots-gate.js';
30
+ export { createInMemoryStorageAdapter } from './storage-memory.js';
31
+ export { R2_REQUIRED_ENV, type R2Config, createR2StorageAdapter, tryCreateR2StorageAdapter, } from './storage-r2.js';
32
+ export { COMPUTE_BLOCKED_COST_MICRO_USD, type FetchCostType, type RecordFetchCostArgs, TIER1_FIXED_COST_MICRO_USD, recordBlockedFetch, recordFetchCost, } from './cost.js';
33
+ /**
34
+ * Default fetcher factory — Patchright when env-configured, otherwise
35
+ * the plain HTTP fetcher.
36
+ */
37
+ export declare function getDefaultFetcher(env?: NodeJS.ProcessEnv): FetcherAdapter;
38
+ /**
39
+ * Default storage factory — R2 when env-configured, otherwise in-memory.
40
+ */
41
+ export declare function getDefaultStorage(env?: NodeJS.ProcessEnv): StorageAdapter;
42
+ export interface FetchAndStoreArgs {
43
+ db: DbClient;
44
+ fetcher: FetcherAdapter;
45
+ storage: StorageAdapter;
46
+ url: string;
47
+ customerId: string;
48
+ subscriptionId: string | null;
49
+ fetchOptions?: FetchOptions;
50
+ /**
51
+ * When true (default), check robots.txt before fetching. Set false in
52
+ * tests that don't want to mock robots fetches; production callers
53
+ * should leave it on.
54
+ */
55
+ respectRobotsTxt?: boolean;
56
+ /** Test seam: override the robots.txt fetch. */
57
+ robotsFetchImpl?: typeof fetch;
58
+ /**
59
+ * Attestation signer (Review #3) — KMS-backed in production via
60
+ * `tryCreateKmsSigner`. When set, every snapshot row carries a signature
61
+ * over the JCS-canonical v1 manifest, and a signing failure fails the
62
+ * whole fetch: a configured signer is a provenance promise, and silently
63
+ * persisting unsigned rows would fake it. When null/absent, rows keep the
64
+ * explicit no-attestation placeholders (pre-KMS deployments, tests).
65
+ */
66
+ signer?: Signer | null;
67
+ }
68
+ export type FetchAndStoreOutcome = {
69
+ ok: true;
70
+ snapshotId: string;
71
+ contentHash: string;
72
+ r2Key: string;
73
+ fetchResult: FetchResult;
74
+ } | {
75
+ ok: false;
76
+ reason: 'robots_disallowed';
77
+ urlCanonical: string;
78
+ };
79
+ /**
80
+ * Orchestrate one full fetch with robots-check + cost recording.
81
+ *
82
+ * 1. Canonicalize URL
83
+ * 2. Check robots.txt (skipped when respectRobotsTxt=false)
84
+ * - Disallowed: update host_records.robots_status='disallowed',
85
+ * write a compute cost row, return ok=false
86
+ * 3. Fetch via the adapter (SSRF validation lives in the HTTP adapter)
87
+ * 4. Store content-addressed in R2 / memory
88
+ * 5. Insert a snapshot row
89
+ * 6. Write a cost_records row (fetch_tier1, http_only)
90
+ *
91
+ * Returns a discriminated outcome — callers switch on `outcome.ok`.
92
+ */
93
+ export declare function fetchAndStore(args: FetchAndStoreArgs): Promise<FetchAndStoreOutcome>;
package/dist/index.js ADDED
@@ -0,0 +1,219 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ /**
3
+ * @bluefields/fetcher — public surface (chunks 8 + 9).
4
+ *
5
+ * Per the four-layer rule:
6
+ * - Fetcher returns raw HTML + metadata.
7
+ * - It does NOT extract, diff, or notify.
8
+ *
9
+ * Production wiring:
10
+ * - Fetcher: HTTP (default) → Patchright (when IPROYAL_* env set + dep
11
+ * installed).
12
+ * - Storage: in-memory (default) → R2 (when R2_* env set).
13
+ *
14
+ * `fetchAndStore` orchestrates one end-to-end fetch: robots-check →
15
+ * fetch via adapter → store content-addressed → sign the attestation
16
+ * manifest (when a signer is configured — A3, Review #3) → insert
17
+ * snapshot row → write cost_records.
18
+ *
19
+ * The return is a discriminated outcome — chunk 21's poll handler
20
+ * switches on `outcome.ok` to decide whether to feed the extractor or
21
+ * record a blocked-poll event.
22
+ */
23
+ import { createHash } from 'node:crypto';
24
+ import { MANIFEST_VERSION, buildManifest, canonicalize, signManifest, } from '@bluefields/attest';
25
+ import { hostRecords, snapshots } from '@bluefields/db';
26
+ import { canonicalizeUrl, createLayerLogger, isAllowedByRobotsTxt } from '@bluefields/primitives';
27
+ import { eq } from 'drizzle-orm';
28
+ const log = createLayerLogger('fetcher');
29
+ import { TIER1_FIXED_COST_MICRO_USD, recordBlockedFetch, recordFetchCost } from './cost.js';
30
+ import { createHttpFetcherAdapter } from './fetcher-http.js';
31
+ import { tryCreatePatchrightAdapter } from './fetcher-patchright.js';
32
+ import { createInMemoryStorageAdapter } from './storage-memory.js';
33
+ import { tryCreateR2StorageAdapter } from './storage-r2.js';
34
+ export { buildR2Key, } from './types.js';
35
+ export { createHttpFetcherAdapter } from './fetcher-http.js';
36
+ export { PATCHRIGHT_REQUIRED_ENV, tryCreatePatchrightAdapter } from './fetcher-patchright.js';
37
+ export { classifyFetchResult, createEscalatingFetcherAdapter, } from './fetcher-escalating.js';
38
+ export { createRobotsGatedFetcher, } from './fetcher-robots-gate.js';
39
+ export { createInMemoryStorageAdapter } from './storage-memory.js';
40
+ export { R2_REQUIRED_ENV, createR2StorageAdapter, tryCreateR2StorageAdapter, } from './storage-r2.js';
41
+ export { COMPUTE_BLOCKED_COST_MICRO_USD, TIER1_FIXED_COST_MICRO_USD, recordBlockedFetch, recordFetchCost, } from './cost.js';
42
+ /**
43
+ * Default fetcher factory — Patchright when env-configured, otherwise
44
+ * the plain HTTP fetcher.
45
+ */
46
+ export function getDefaultFetcher(env = process.env) {
47
+ return tryCreatePatchrightAdapter(env) ?? createHttpFetcherAdapter();
48
+ }
49
+ /**
50
+ * Default storage factory — R2 when env-configured, otherwise in-memory.
51
+ */
52
+ export function getDefaultStorage(env = process.env) {
53
+ return tryCreateR2StorageAdapter(env) ?? createInMemoryStorageAdapter();
54
+ }
55
+ /**
56
+ * Orchestrate one full fetch with robots-check + cost recording.
57
+ *
58
+ * 1. Canonicalize URL
59
+ * 2. Check robots.txt (skipped when respectRobotsTxt=false)
60
+ * - Disallowed: update host_records.robots_status='disallowed',
61
+ * write a compute cost row, return ok=false
62
+ * 3. Fetch via the adapter (SSRF validation lives in the HTTP adapter)
63
+ * 4. Store content-addressed in R2 / memory
64
+ * 5. Insert a snapshot row
65
+ * 6. Write a cost_records row (fetch_tier1, http_only)
66
+ *
67
+ * Returns a discriminated outcome — callers switch on `outcome.ok`.
68
+ */
69
+ export async function fetchAndStore(args) {
70
+ const canonical = canonicalizeUrl(args.url);
71
+ const respectRobots = args.respectRobotsTxt ?? true;
72
+ const userAgent = args.fetchOptions?.userAgent ?? 'BlueFields-Fetcher/1.0';
73
+ // ── Robots-check ──
74
+ if (respectRobots) {
75
+ const allowed = await isAllowedByRobotsTxt(args.url, userAgent, {
76
+ ...(args.robotsFetchImpl ? { fetchImpl: args.robotsFetchImpl } : {}),
77
+ });
78
+ if (!allowed) {
79
+ const host = hostnameOf(args.url);
80
+ if (host) {
81
+ // Mark the host as disallowed so dashboards / future polls can
82
+ // short-circuit. Don't fail the request if this UPDATE doesn't
83
+ // affect a row — host_records may not exist yet (first time we
84
+ // see this host); the chunk-7 host-records repo will populate
85
+ // on the next call.
86
+ const updated = await args.db
87
+ .update(hostRecords)
88
+ .set({ robotsStatus: 'disallowed' })
89
+ .where(eq(hostRecords.host, host))
90
+ .returning({ host: hostRecords.host });
91
+ if (updated.length === 0) {
92
+ log.debug({
93
+ outcome: 'partial',
94
+ subscription_id: args.subscriptionId,
95
+ customer_id: args.customerId,
96
+ host,
97
+ }, 'robots_disallowed flag not set: host_records row missing (first-seen host)');
98
+ }
99
+ }
100
+ await recordBlockedFetch({
101
+ db: args.db,
102
+ customerId: args.customerId,
103
+ subscriptionId: args.subscriptionId,
104
+ urlCanonical: canonical.url_canonical,
105
+ reason: 'robots_disallowed',
106
+ });
107
+ log.info({
108
+ outcome: 'blocked',
109
+ subscription_id: args.subscriptionId,
110
+ customer_id: args.customerId,
111
+ url_canonical: canonical.url_canonical,
112
+ }, 'fetch blocked by robots.txt');
113
+ return { ok: false, reason: 'robots_disallowed', urlCanonical: canonical.url_canonical };
114
+ }
115
+ }
116
+ // ── Fetch + store + sign + snapshot ──
117
+ const fetchResult = await args.fetcher.fetch(args.url, args.fetchOptions);
118
+ const stored = await args.storage.storeSnapshot(fetchResult.html);
119
+ // Sign BEFORE persisting (spec §8 order) so a row never exists in a
120
+ // configured-but-unsigned state. The manifest carries a HASH of the
121
+ // JCS-canonicalized response headers, never the headers themselves —
122
+ // Set-Cookie and friends must not enter the permanent manifest (Review #3).
123
+ const attested = args.signer
124
+ ? await signManifest(buildManifest({
125
+ urlCanonical: canonical.url_canonical,
126
+ canonVersion: canonical.canon_version,
127
+ contentHash: stored.contentHash,
128
+ rawHtmlSizeBytes: fetchResult.rawHtmlSizeBytes,
129
+ fetchedAt: fetchResult.fetchedAt.toISOString(),
130
+ fetchDurationMs: fetchResult.fetchDurationMs,
131
+ fetcherId: fetchResult.fetcherId,
132
+ proxyEgressIp: fetchResult.proxyEgressIp,
133
+ userAgent: fetchResult.userAgent,
134
+ responseStatus: fetchResult.responseStatus,
135
+ responseHeadersHash: createHash('sha256')
136
+ .update(canonicalize(fetchResult.responseHeaders))
137
+ .digest('hex'),
138
+ }), args.signer)
139
+ : null;
140
+ const inserted = await args.db
141
+ .insert(snapshots)
142
+ .values({
143
+ urlCanonical: canonical.url_canonical,
144
+ urlSurt: canonical.url_surt,
145
+ canonVersion: canonical.canon_version,
146
+ contentHash: stored.contentHash,
147
+ fetchedAt: fetchResult.fetchedAt,
148
+ fetcherId: fetchResult.fetcherId,
149
+ proxyEgressIp: fetchResult.proxyEgressIp,
150
+ userAgent: fetchResult.userAgent,
151
+ responseStatus: fetchResult.responseStatus,
152
+ responseHeaders: fetchResult.responseHeaders,
153
+ rawHtmlR2Key: stored.r2Key,
154
+ rawHtmlSizeBytes: fetchResult.rawHtmlSizeBytes,
155
+ fetchDurationMs: fetchResult.fetchDurationMs,
156
+ // Signed when a signer is configured; explicit placeholders otherwise.
157
+ attestationSignature: attested?.signature.signature ?? '',
158
+ attestationKeyId: attested?.signature.keyId ?? 'v0-no-attestation',
159
+ attestationAlgorithm: attested?.signature.algorithm ?? 'EdDSA',
160
+ manifestVersion: MANIFEST_VERSION,
161
+ // Per-snapshot cost = the flat Tier 1 cost so the snapshot row is
162
+ // self-describing; cost_records is the authoritative billing row.
163
+ costMicroUsd: TIER1_FIXED_COST_MICRO_USD,
164
+ })
165
+ .returning({ id: snapshots.id });
166
+ const row = inserted[0];
167
+ if (!row)
168
+ throw new Error('snapshot insert returned no row');
169
+ // ── cost_records (chunk 9) ──
170
+ // Failure here means the snapshot is in DB but no billing row was
171
+ // written — partial success. Log audibly so a runaway billing gap
172
+ // is visible; don't fail the request (caller already has a valid
173
+ // snapshot to extract from).
174
+ try {
175
+ await recordFetchCost({
176
+ db: args.db,
177
+ customerId: args.customerId,
178
+ subscriptionId: args.subscriptionId,
179
+ urlCanonical: canonical.url_canonical,
180
+ units: fetchResult.rawHtmlSizeBytes,
181
+ });
182
+ }
183
+ catch (err) {
184
+ log.error({
185
+ outcome: 'partial',
186
+ subscription_id: args.subscriptionId,
187
+ customer_id: args.customerId,
188
+ snapshot_id: row.id,
189
+ error: err.message,
190
+ }, 'snapshot inserted but cost_records write failed — billing gap');
191
+ }
192
+ log.info({
193
+ outcome: 'ok',
194
+ subscription_id: args.subscriptionId,
195
+ customer_id: args.customerId,
196
+ snapshot_id: row.id,
197
+ content_hash: stored.contentHash,
198
+ response_status: fetchResult.responseStatus,
199
+ raw_html_size_bytes: fetchResult.rawHtmlSizeBytes,
200
+ duration_ms: fetchResult.fetchDurationMs,
201
+ attestation_key_id: attested?.signature.keyId ?? null,
202
+ }, 'fetch ok');
203
+ return {
204
+ ok: true,
205
+ snapshotId: row.id,
206
+ contentHash: stored.contentHash,
207
+ r2Key: stored.r2Key,
208
+ fetchResult,
209
+ };
210
+ }
211
+ function hostnameOf(url) {
212
+ try {
213
+ return new URL(url).hostname.toLowerCase();
214
+ }
215
+ catch {
216
+ return null;
217
+ }
218
+ }
219
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Fetcher-internal escalation hints.
3
+ *
4
+ * These are NOT customer-facing. The API route and the SDKs build a public
5
+ * `FetchOptions` (see `types.ts`); they never set or expose anything here.
6
+ * `InternalFetchOptions` exists only inside `packages/fetcher` so the
7
+ * escalating fetcher (`fetcher-escalating.ts`) can tune how a higher tier
8
+ * behaves on a re-fetch, without changing the public `FetcherAdapter` contract
9
+ * (`fetch(url, options?)`) or the customer-visible option shape.
10
+ *
11
+ * Adapters read these defensively (a missing field means "behave as before"),
12
+ * so passing them to an adapter that doesn't understand them is a no-op.
13
+ */
14
+ import type { FetchOptions } from './types.js';
15
+ export interface InternalFetchOptions extends FetchOptions {
16
+ /**
17
+ * Fully render a JS app before reading content: best-effort `networkidle`,
18
+ * then a bounded autoscroll + content-settle loop (see `fullyRenderPage` in
19
+ * `fetcher-patchright.ts`). Honored only by browser tiers; the HTTP and
20
+ * unlocker tiers ignore it.
21
+ *
22
+ * Set by `createEscalatingFetcherAdapter` when it escalates a JS shell /
23
+ * anti-bot block to a higher tier AND the customer gave no explicit
24
+ * `waitFor` (an explicit `waitFor` always wins). The whole settle sequence
25
+ * is clamped to `timeoutMs`, so it never extends a request past its deadline.
26
+ */
27
+ __fullRender?: boolean;
28
+ /**
29
+ * Authorized robots.txt opt-out. When true, the robots-gated fetcher skips the
30
+ * Disallow check (and logs an audit entry). Resolved UPSTREAM at the API layer
31
+ * from the customer's `ignoreRobots` + `robotsAttestation` AND an account-level
32
+ * capability — the fetcher never decides authorization itself (four-layer rule).
33
+ * Absent/false → robots is enforced normally. See `ROBOTS_POLICY.md`.
34
+ */
35
+ __robotsBypassAuthorized?: boolean;
36
+ /**
37
+ * Abandonment signal. `createEscalatingFetcherAdapter` aborts this when a tier
38
+ * exceeds its wall-clock budget (`raceTierBudget`) so the abandoned tier can
39
+ * release resources instead of running on. The browser tier (Patchright)
40
+ * honors it by closing its context — without this, an abandoned browser fetch
41
+ * leaks headless Chromium. The HTTP / unlocker tiers ignore it (they leak at
42
+ * most a socket, bounded by undici's own timeouts).
43
+ */
44
+ __abortSignal?: AbortSignal;
45
+ /**
46
+ * Tier-0 early-block escalation (perf round, L1). Set ONLY by the escalating
47
+ * fetcher on its tier-0 stage. When true, the HTTP adapter — on a DECISIVE block
48
+ * status (403/429/503, the statuses classifyFetchResult always maps to
49
+ * challenge|blocked, both `needsEscalation`) — skips draining the doomed response
50
+ * body and returns an empty-body result immediately, so the escalating layer
51
+ * escalates at ~time-to-headers instead of after the full (often slow-rolled) block
52
+ * body or the tier-0 fast-fail wall. Coverage-SAFE by construction: it never emits
53
+ * an early `success` and fires only on statuses that already escalate regardless of
54
+ * body. Absent/false → today's full-drain behavior, so a standalone HTTP-adapter
55
+ * caller (which never sets this) is byte-for-byte unchanged. Browser/unlocker tiers
56
+ * ignore it.
57
+ */
58
+ __earlyClassify?: boolean;
59
+ }
@@ -0,0 +1,16 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ /**
3
+ * Fetcher-internal escalation hints.
4
+ *
5
+ * These are NOT customer-facing. The API route and the SDKs build a public
6
+ * `FetchOptions` (see `types.ts`); they never set or expose anything here.
7
+ * `InternalFetchOptions` exists only inside `packages/fetcher` so the
8
+ * escalating fetcher (`fetcher-escalating.ts`) can tune how a higher tier
9
+ * behaves on a re-fetch, without changing the public `FetcherAdapter` contract
10
+ * (`fetch(url, options?)`) or the customer-visible option shape.
11
+ *
12
+ * Adapters read these defensively (a missing field means "behave as before"),
13
+ * so passing them to an adapter that doesn't understand them is a no-op.
14
+ */
15
+ export {};
16
+ //# sourceMappingURL=internal-options.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"internal-options.js","sourceRoot":"","sources":["../src/internal-options.ts"],"names":[],"mappings":"AAAA,6CAA6C;AAC7C;;;;;;;;;;;;GAYG"}
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Deterministic fixture perf harness for the escalating fetcher (perf round).
3
+ *
4
+ * The mission requires a before/after p50/p95/cost/coverage number PER LEVER on a
5
+ * FIXTURE corpus (never live sites). This library scripts each tier's latency +
6
+ * outcome per fixture URL, drives the real `createEscalatingFetcherAdapter` against
7
+ * those mocks, and reports the distribution. It is deliberately dependency-free and
8
+ * vitest-free: the CALLER injects the clock (a fake-timer test drives `now` +
9
+ * `advanceAndFlush`), so the whole run is deterministic and fast — no real waiting,
10
+ * no network. The runnable entrypoint is `fetcher-escalating.perf.test.ts`
11
+ * (FETCH_PERF=1-gated, inert in the normal suite); `perf-harness.test.ts` guards the
12
+ * metrics math in CI.
13
+ *
14
+ * Fetcher-layer only: it exercises tier selection + timing, nothing else.
15
+ */
16
+ import type { EscalatingFetcherOptions } from './fetcher-escalating.js';
17
+ import type { FetcherAdapter } from './types.js';
18
+ /** Billed credits per tier (mirrors billing: tier-0 HTTP=1, tier-1 browser=3, tier-2 unlocker=8). */
19
+ export declare const TIER_CREDITS: Readonly<Record<number, number>>;
20
+ /** fetcherId each tier's mock reports — matches the real adapters so we can map result→tier. */
21
+ export declare const TIER_FETCHER_ID: Readonly<Record<number, string>>;
22
+ /** Scripted behavior of one tier for one fixture. */
23
+ export type TierScript =
24
+ /** Resolves with `status`+`html` after `latencyMs` of (fake) time. */
25
+ {
26
+ latencyMs: number;
27
+ status: number;
28
+ html: string;
29
+ }
30
+ /** Never resolves on its own — models a slow-roll/hung tier bounded only by the budget/deadline. */
31
+ | {
32
+ latencyMs?: number;
33
+ kind: 'hang';
34
+ }
35
+ /** Tier not configured (adapter is null). */
36
+ | {
37
+ kind: 'absent';
38
+ };
39
+ export interface FixtureSpec {
40
+ url: string;
41
+ /** Short human label for reports (e.g. 'fast-success', 'slow-block', 'shell→browser'). */
42
+ label: string;
43
+ tier0: TierScript;
44
+ tier1?: TierScript;
45
+ tier2?: TierScript;
46
+ }
47
+ /**
48
+ * A mock tier adapter honoring the abort seam the escalating fetcher uses: a hung
49
+ * tier stays pending until aborted (then rejects, like a real abandoned tier); a
50
+ * scripted tier resolves after `latencyMs` via setTimeout (fake-clock driven). Returns
51
+ * null for an absent tier so the ladder omits it.
52
+ */
53
+ export declare function mockTierAdapter(tier: number, script: TierScript): FetcherAdapter | null;
54
+ export interface PerRequest {
55
+ url: string;
56
+ label: string;
57
+ elapsedMs: number;
58
+ /** Winning tier (0/1/2) or -1 if the fetch threw. */
59
+ tier: number;
60
+ credits: number;
61
+ outcome: string;
62
+ covered: boolean;
63
+ }
64
+ export interface CorpusReport {
65
+ n: number;
66
+ /** Latency percentiles (ms) over end-to-end fake-clock elapsed. */
67
+ p50: number;
68
+ p95: number;
69
+ max: number;
70
+ /** Coverage = fraction whose final outcome is `success`. This is the HARD FLOOR metric. */
71
+ successRate: number;
72
+ /** Cost per successful scrape = total credits billed / number covered. */
73
+ creditsPerSuccess: number;
74
+ /** Mean credits per request (blended tier mix). */
75
+ meanCredits: number;
76
+ /** Count of requests won by each tier. */
77
+ perTier: Record<number, number>;
78
+ requests: PerRequest[];
79
+ }
80
+ /** Nearest-rank percentile (no interpolation) — same convention as the SLA harness. */
81
+ export declare function percentile(sorted: number[], q: number): number;
82
+ export interface RunOptions {
83
+ /** Escalating-fetcher config under test (tier0TimeoutMs, hedgeDelayMs, …). nowMsFn is injected below. */
84
+ escalatingOpts?: Partial<Omit<EscalatingFetcherOptions, 'tier0' | 'tier1' | 'tier2' | 'nowMsFn'>>;
85
+ /** Repeat the corpus this many times (default 1). */
86
+ iterations?: number;
87
+ /** Read the current (fake) clock — pass the test's faked Date.now. */
88
+ now: () => number;
89
+ /** Advance the (fake) clock by `ms` AND flush microtasks/timers. Pass `vi.advanceTimersByTimeAsync`. */
90
+ advanceAndFlush: (ms: number) => Promise<void>;
91
+ /** Advance step (ms). Smaller = finer latency resolution, more iterations. Default 25. */
92
+ stepMs?: number;
93
+ /** Safety cap on total advance per request (ms). Default 120000. */
94
+ capMs?: number;
95
+ }
96
+ /**
97
+ * Run the fixture corpus through the real escalating fetcher under the caller's fake
98
+ * clock, returning the latency/cost/coverage distribution. For each fixture it builds
99
+ * `createEscalatingFetcherAdapter` with the scripted mock tiers + the injected
100
+ * `nowMsFn`, starts the fetch, and drives `advanceAndFlush(stepMs)` until it settles —
101
+ * measuring end-to-end elapsed on the fake clock (quantized to stepMs).
102
+ */
103
+ export declare function runCorpus(fixtures: FixtureSpec[], o: RunOptions): Promise<CorpusReport>;
104
+ /** Pretty one-line summary for a config's report (for the perf entrypoint's console output). */
105
+ export declare function formatReport(name: string, r: CorpusReport): string;