@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.
Files changed (47) hide show
  1. package/README.md +73 -8
  2. package/package.json +9 -6
  3. package/src/actionability.ts +2 -2
  4. package/src/adapter.ts +22 -0
  5. package/src/auth.ts +4 -5
  6. package/src/browser-dial.ts +55 -0
  7. package/src/cdp-arm.ts +81 -22
  8. package/src/cdp-connection.ts +290 -0
  9. package/src/cdp-error-codes.ts +28 -0
  10. package/src/cdp-errors.ts +179 -0
  11. package/src/cdp-launch-attempt.ts +234 -0
  12. package/src/cdp-launch-candidates.ts +62 -0
  13. package/src/cdp-launch-probe.ts +75 -0
  14. package/src/cdp-launch-profile.ts +106 -0
  15. package/src/cdp-launch-reap.ts +68 -0
  16. package/src/cdp-launch-wire.ts +171 -0
  17. package/src/cdp-launch.ts +216 -0
  18. package/src/cdp-pipe.ts +77 -0
  19. package/src/cdp-wire-watch.ts +113 -0
  20. package/src/cdp.ts +46 -0
  21. package/src/clock.ts +4 -111
  22. package/src/driver-cdp.ts +3 -0
  23. package/src/driver-fake.ts +4 -5
  24. package/src/driver.ts +16 -11
  25. package/src/error-throws-pdf.ts +45 -0
  26. package/src/error-throws-session.ts +1 -1
  27. package/src/error-throws.ts +4 -4
  28. package/src/errors.ts +17 -1
  29. package/src/event-prompt.ts +10 -4
  30. package/src/events.ts +2 -3
  31. package/src/html-target.ts +2 -2
  32. package/src/http-recorded.ts +2 -2
  33. package/src/http.ts +2 -2
  34. package/src/index.ts +21 -75
  35. package/src/launch-args.ts +10 -1
  36. package/src/page-over-target.ts +2 -3
  37. package/src/pdf-action.ts +116 -0
  38. package/src/pdf-cdp-params.ts +65 -0
  39. package/src/pdf-fence.ts +86 -0
  40. package/src/pdf-render.ts +241 -0
  41. package/src/pdf-slots.ts +68 -0
  42. package/src/rate.ts +2 -2
  43. package/src/scrape-run.ts +3 -4
  44. package/src/scrape.ts +5 -5
  45. package/src/target.ts +1 -1
  46. package/src/usage.ts +2 -2
  47. package/src/watchdog.ts +3 -3
package/src/index.ts CHANGED
@@ -14,7 +14,6 @@ export type {
14
14
  ScrapeAuth,
15
15
  } from './auth';
16
16
  export { burnSession, ensureAuthenticated, restorableSession, scrapePrompt } from './auth';
17
- export { browserRecord } from './browser-record';
18
17
  export type { CaptureClip, CaptureFraming } from './capture-clip';
19
18
  export { assertCaptureFraming } from './capture-clip';
20
19
  export type {
@@ -26,92 +25,46 @@ export type {
26
25
  CdpLauncherLike,
27
26
  CdpPageLike,
28
27
  CdpPdfOptions,
29
- CdpRequestLike,
30
28
  CdpScreenshotOptions,
31
29
  CdpSessionLike,
32
30
  } from './cdp-port';
33
31
  export type { CdpResolution, CdpResolver, CdpResolverRequest } from './cdp-resolver';
34
32
  export { snapshotExpression } from './cdp-snapshot';
35
33
  export type { CdpTargetInit } from './cdp-target';
36
- export { CDP_DRIVER, cdpTarget } from './cdp-target';
37
- export type { Deadline, ScrapeClock, TestScrapeClock } from './clock';
38
- export {
39
- deadline,
40
- noWaitClock,
41
- resetScrapeClock,
42
- scrapeClock,
43
- setScrapeClock,
44
- systemScrapeClock,
45
- testScrapeClock,
46
- } from './clock';
34
+ export { cdpTarget } from './cdp-target';
35
+ export type { Deadline } from './clock';
36
+ export { deadline } from './clock';
47
37
  export type { ColorScheme } from './color-scheme';
48
- export { COLOR_SCHEMES, isColorScheme } from './color-scheme';
49
38
  export { cookieHeaderFor } from './cookie-scope';
50
39
  export type { ScrapeDriver, ScrapeSession, SessionInit } from './driver';
51
- export { resetScrapeDriver, scrapeDriver, setScrapeDriver } from './driver';
40
+ export { scrapeDriver } from './driver';
52
41
  export type { BrowserOptions, LocalBrowserOptions, RemoteBrowserOptions } from './driver-cdp';
53
42
  export { localBrowser, remoteBrowser } from './driver-cdp';
54
43
  export type { FakeBrowserOptions, FakePageOptions, FakePages } from './driver-fake';
55
- export { FAKE_DRIVER, FAKE_PAGE_URL, fakeBrowser, fakePage, recordingsOf } from './driver-fake';
44
+ export { fakeBrowser, fakePage } from './driver-fake';
56
45
  export type { FixtureBrowserOptions } from './driver-recorded';
57
- export { FIXTURE_DRIVER, fixtureBrowser } from './driver-recorded';
46
+ export { fixtureBrowser } from './driver-recorded';
58
47
  export {
59
- authFailed,
60
48
  blocked,
61
- bodyTooLarge,
62
49
  browserUnreachable,
63
- cdpAttachFailed,
64
- cdpResolveFailed,
65
- downloadTimeout,
66
50
  driverUnknown,
67
- fixtureMissing,
68
- fixtureStale,
69
- hostBlocked,
70
51
  httpFailed,
71
- keyInvalid,
72
- notActionable,
73
- outputInvalid,
74
- pageCrashed,
75
- profileLocked,
76
52
  promptUnanswered,
77
- recoverRefused,
78
- redirectLoop,
79
53
  remoteRequired,
80
- robotsDisallowed,
81
54
  scrapeSessionExpired,
82
- scrapeTimeout,
83
- secretExposed,
84
- selectorMissing,
85
55
  wedged,
86
- yieldCollapsed,
87
56
  } from './error-throws';
57
+ export type { ScrapeErrorCode, ScrapeErrorInit } from './errors';
88
58
  export {
89
- egressInPayload,
90
- egressUnsupported,
91
- promptAnswerInvalid,
92
- promptTimedOut,
93
- } from './error-throws-session';
94
- export type { ScrapeErrorCode, ScrapeErrorInit, ScrapeOwnedErrorCode } from './errors';
95
- export {
96
- isRetryableScrapeError,
97
59
  isScrapeError,
98
- SCRAPE_BORROWED_ERROR_CODES,
99
60
  SCRAPE_ERROR_CODES,
100
61
  SCRAPE_ERROR_RETRY,
101
- SCRAPE_ERROR_TITLES,
102
62
  SCRAPE_OWNED_ERROR_CODES,
103
63
  ScrapeError,
104
64
  } from './errors';
105
65
  export type { AnswerPromptInput, EventPromptOptions } from './event-prompt';
106
- export {
107
- answerPrompt,
108
- DEFAULT_PROMPT_ANSWER_TTL_MS,
109
- DEFAULT_PROMPT_POLL_MS,
110
- eventPrompt,
111
- promptEventName,
112
- } from './event-prompt';
113
- export type { ScrapeEventFields, StepEvent } from './events';
114
- export type { YieldCheck, YieldExpectation, YieldGuardInput, YieldHistory } from './expect';
66
+ export { answerPrompt, eventPrompt, promptEventName } from './event-prompt';
67
+ export type { YieldExpectation, YieldGuardInput, YieldHistory } from './expect';
115
68
  export {
116
69
  guardYield,
117
70
  MIN_BASELINE_RUNS,
@@ -120,21 +73,14 @@ export {
120
73
  } from './expect';
121
74
  export { BURNS_SESSION, errorCode, NEVER_RETRIED } from './failures';
122
75
  export { queryHtml } from './html-query';
123
- export type { MarkupRequest } from './html-requests';
124
- export type { HtmlTargetInit, RecordingLookup } from './html-target';
125
- export { htmlTarget } from './html-target';
126
76
  export type { HttpRequestInit, HttpTransportInit, ScrapeHttp, ScrapeResponse } from './http';
127
77
  export { DEFAULT_HTTP_MAX_BYTES, httpOverFetch, responseOver } from './http';
128
78
  export type { HttpRecordingLookup, RecordedHttpInit } from './http-recorded';
129
- export { httpRecordingsOf, recordedHttp } from './http-recorded';
130
- export type { RedirectHop } from './http-redirect';
131
79
  export { MAX_REDIRECT_HOPS } from './http-redirect';
132
80
  export type { InterceptRules, InterceptVerdict } from './intercept';
133
81
  export { interceptVerdict } from './intercept';
134
82
  export type { KeyChord, KeyModifier } from './key-chord';
135
83
  export { KEY_MODIFIERS, parseKeyChord } from './key-chord';
136
- export type { OfflineSessionInit } from './offline-session';
137
- export { openOfflineSession } from './offline-session';
138
84
  export type {
139
85
  AccessibilityOptions,
140
86
  CaptureRequest,
@@ -146,19 +92,21 @@ export type {
146
92
  } from './page';
147
93
  export type { PageContext } from './page-over-target';
148
94
  export { pageOverTarget } from './page-over-target';
95
+ export type { PdfActionArgs, PdfActionOptions, PdfActionOutput } from './pdf-action';
96
+ export { pdf } from './pdf-action';
97
+ export type {
98
+ ChromePdfRendererOptions,
99
+ PdfBrowser,
100
+ PdfRenderer,
101
+ PdfRenderRequest,
102
+ } from './pdf-render';
103
+ export { chromePdfRenderer } from './pdf-render';
149
104
  export type { PdfFormat, PdfLayout, PdfMarginMm, PdfRequest } from './pdf-request';
150
105
  export { PDF_FORMATS } from './pdf-request';
151
106
  export type { Pacer } from './rate';
152
107
  export { scrapePacer } from './rate';
153
108
  export type { HttpRecording, PageRecording } from './recording';
154
- export {
155
- httpRecordingSchema,
156
- pageRecordingSchema,
157
- parseHttpRecording,
158
- parseRecording,
159
- } from './recording';
160
109
  export type { RecoveryAttempt, RecoveryHook } from './recover';
161
- export { runRecovery } from './recover';
162
110
  export type {
163
111
  ConsoleLine,
164
112
  ConsoleRing,
@@ -181,10 +129,9 @@ export type {
181
129
  RobotsGate,
182
130
  RobotsGateInit,
183
131
  RobotsPolicy,
184
- RobotsRules,
185
132
  RobotsUnreachable,
186
133
  } from './robots';
187
- export { parseRobots, robotsAllows, robotsGate } from './robots';
134
+ export { robotsGate } from './robots';
188
135
  export type { RobotsFetchInit } from './robots-fetch';
189
136
  export {
190
137
  DEFAULT_ROBOTS_MAX_BYTES,
@@ -220,7 +167,6 @@ export {
220
167
  memorySessionStore,
221
168
  parseSessionState,
222
169
  recordVersion,
223
- SESSION_SEAL_PURPOSE,
224
170
  sessionDigest,
225
171
  sessionKeyFor,
226
172
  storageSessionStore,
@@ -238,8 +184,8 @@ export type {
238
184
  ScrapeDownloadFile,
239
185
  ScrapeTarget,
240
186
  } from './target';
241
- export { endpointLabel, urlSecretValues } from './url-secrets';
187
+ export { urlSecretValues } from './url-secrets';
242
188
  export type { RunUsageMeter, ScrapeUsage, UsageMeter } from './usage';
243
189
  export { usageMeter } from './usage';
244
- export type { WedgeGuard, WedgeGuardInit } from './watchdog';
190
+ export type { WedgeGuard } from './watchdog';
245
191
  export { DEFAULT_GRACE_MS } from './watchdog';
@@ -17,6 +17,9 @@ const ROUTE_SWITCHES = new Set([
17
17
  'no-proxy-server',
18
18
  ]);
19
19
 
20
+ /** Removes Chrome's implicit loopback bypass, so loopback goes through the exit like any host. */
21
+ const PROXY_LOOPBACK_TOO = '--proxy-bypass-list=<-loopback>';
22
+
20
23
  const switchName = (arg: string): string | undefined => {
21
24
  const match = /^--?([^=]+)/.exec(arg);
22
25
  return match?.[1]?.toLowerCase();
@@ -46,6 +49,12 @@ export function launchArgs(
46
49
  }
47
50
  }
48
51
  const args = [...((callerArgs as readonly string[] | undefined) ?? [])];
49
- if (proxyArg !== undefined) args.push(`--proxy-server=${proxyArg}`);
52
+ // `<-loopback>` beside every exit: Chrome ≥ 72 sends localhost, `*.localhost` and loopback
53
+ // literals around `--proxy-server` by default, so a page reached the worker's own loopback
54
+ // services DIRECT while the session reported an exit — and the browser leg's DNS screen stands
55
+ // down for a proxied session. Subtracting the implicit rule sends them through the exit too.
56
+ if (proxyArg !== undefined) {
57
+ args.push(`--proxy-server=${proxyArg}`, PROXY_LOOPBACK_TOO);
58
+ }
50
59
  return args.length === 0 ? undefined : args;
51
60
  }
@@ -3,12 +3,11 @@
3
3
  // re-resolution, host enforcement and secret taint are written once and cannot drift between the
4
4
  // driver a test uses and the driver production uses.
5
5
 
6
- import type { Secret } from '@ultimat3/core';
6
+ import type { Secret, SleepClock } from '@ultimat3/core';
7
7
  import { finiteCount, hostDecision, isSecret, revealSecret } from '@ultimat3/core';
8
8
  import type { ActionabilityState } from './actionability';
9
9
  import { awaitActionable } from './actionability';
10
10
  import { assertCaptureFraming } from './capture-clip';
11
- import type { ScrapeClock } from './clock';
12
11
  import { deadline } from './clock';
13
12
  import type { ColorScheme } from './color-scheme';
14
13
  import { hostBlocked, secretExposed, selectorMissing } from './error-throws';
@@ -39,7 +38,7 @@ import { ROOT_SELECTOR } from './target';
39
38
  import type { UsageMeter } from './usage';
40
39
 
41
40
  export interface PageContext {
42
- readonly clock: ScrapeClock;
41
+ readonly clock: SleepClock;
43
42
  /**
44
43
  * Called before EVERY operation, poll included. This is what the wedge watchdog measures: the
45
44
  * gap between two of these is the definition of "the browser stopped answering", and putting
@@ -0,0 +1,116 @@
1
+ // `pdf()` — a factory over ACTION (`PRIMITIVE_FACTORIES`), never a ninth primitive: render one of
2
+ // the app's own routes to PDF through the CDP browser, write it to a disk, answer its key. Input
3
+ // schema, policy, rate limit, meter and audit are the action's own, so an invoice PDF is gated
4
+ // exactly like the invoice: HTTP, OpenAPI, MCP, `.job()` and `.as()` come with it.
5
+
6
+ import type { Action, ActionMeter, ActionPolicy, ActionRateLimit } from '@ultimat3/action';
7
+ import { action } from '@ultimat3/action';
8
+ import type { Ctx } from '@ultimat3/core';
9
+ import type { InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
10
+ import { t } from '@ultimat3/schema';
11
+ import type { StorageDriver } from '@ultimat3/storage';
12
+ import { pdfOriginMissing, pdfRouteInvalid } from './error-throws-pdf';
13
+ import type { PdfRenderer } from './pdf-render';
14
+ import { chromePdfRenderer } from './pdf-render';
15
+ import type { PdfLayout } from './pdf-request';
16
+ import { pdfLayoutOf } from './pdf-request';
17
+
18
+ export const PDF_CONTENT_TYPE = 'application/pdf';
19
+
20
+ /** What every `pdf()` answers: where the document is, and how big. Never the bytes themselves. */
21
+ const pdfOutput = () => t.object({ key: t.string, bytes: t.number, contentType: t.string });
22
+
23
+ export type PdfActionOutput = ReturnType<typeof pdfOutput>;
24
+
25
+ /** What `route`, `key` and `headers` are handed: the parsed input and the call's context. */
26
+ export interface PdfActionArgs<TInput extends StandardSchemaV1> {
27
+ readonly input: InferOutput<TInput>;
28
+ readonly ctx: Ctx;
29
+ }
30
+
31
+ export interface PdfActionOptions<TInput extends StandardSchemaV1, TRow = unknown> {
32
+ readonly input: TInput;
33
+ readonly policy: ActionPolicy<TRow>;
34
+ /** A PATH on this app (`/invoices/42/print`). Another origin, or a `//` path, is refused. */
35
+ route(args: PdfActionArgs<TInput>): string;
36
+ /** Where the document lands on `storage`. Tenant-scoped by the app: the key is its to choose. */
37
+ key(args: PdfActionArgs<TInput>): string;
38
+ /** A THUNK, read per call: the app's disks exist after boot, the action at module evaluation. */
39
+ readonly storage: () => StorageDriver;
40
+ /** Checked once, here, by `pdfLayoutOf` — a layout no engine honours never reaches a browser. */
41
+ readonly layout?: PdfLayout | undefined;
42
+ /**
43
+ * Headers the browser sends with every request the page makes — how a private route learns who
44
+ * is printing it (a short-lived token the app signs). Absent: the route must be readable bare.
45
+ */
46
+ readonly headers?:
47
+ | ((args: PdfActionArgs<TInput>) => Readonly<Record<string, string>>)
48
+ | undefined;
49
+ /** The origin the route is served on. Defaults to `APP_URL`, read per call. */
50
+ readonly origin?: (() => string | undefined) | undefined;
51
+ /**
52
+ * Defaults to one `chromePdfRenderer()` the whole process shares — its `maxConcurrent` bounds every
53
+ * `pdf()` that names none. A test hands in one over a fake CDP connection.
54
+ */
55
+ readonly renderer?: PdfRenderer | undefined;
56
+ readonly row?: ((args: PdfActionArgs<TInput>) => TRow | null | Promise<TRow | null>) | undefined;
57
+ readonly rateLimit?: ActionRateLimit | undefined;
58
+ readonly meter?: ActionMeter | undefined;
59
+ readonly audit?: boolean | undefined;
60
+ }
61
+
62
+ const appOrigin = (): string | undefined => process.env['APP_URL'];
63
+
64
+ let shared: PdfRenderer | undefined;
65
+ /**
66
+ * ONE default renderer per process, built on first use: its `maxConcurrent` bounds every `pdf()`
67
+ * that names none, so ten declarations do not mean twenty live Chromes.
68
+ */
69
+ const sharedRenderer = (): PdfRenderer => {
70
+ shared ??= chromePdfRenderer();
71
+ return shared;
72
+ };
73
+
74
+ /** The route joined onto the origin, refused unless the result is still that origin. */
75
+ function routeUrl(origin: string, path: string): string {
76
+ if (!path.startsWith('/') || path.startsWith('//') || path.includes('\\')) {
77
+ throw pdfRouteInvalid(path);
78
+ }
79
+ const base = new URL(origin);
80
+ const url = new URL(path, base);
81
+ if (url.origin !== base.origin) throw pdfRouteInvalid(path);
82
+ return url.href;
83
+ }
84
+
85
+ export function pdf<TInput extends StandardSchemaV1, TRow = unknown>(
86
+ options: PdfActionOptions<TInput, TRow>,
87
+ ): Action<TInput, PdfActionOutput> {
88
+ const layout = pdfLayoutOf(options.layout ?? {});
89
+ const renderer = options.renderer ?? sharedRenderer();
90
+ const origin = options.origin ?? appOrigin;
91
+ const { row } = options;
92
+ return action<TInput, PdfActionOutput, TRow>({
93
+ input: options.input,
94
+ output: pdfOutput(),
95
+ policy: options.policy,
96
+ ...(row === undefined ? {} : { row: (args) => row(args) }),
97
+ ...(options.rateLimit === undefined ? {} : { rateLimit: options.rateLimit }),
98
+ ...(options.meter === undefined ? {} : { meter: options.meter }),
99
+ ...(options.audit === undefined ? {} : { audit: options.audit }),
100
+ async handle(args) {
101
+ const served = origin()?.trim();
102
+ if (served === undefined || served === '') throw pdfOriginMissing();
103
+ const url = routeUrl(served, options.route(args));
104
+ const bytes = await renderer.render({
105
+ url,
106
+ layout,
107
+ // A client that went away, or a request past its deadline, frees its slot and its browser.
108
+ signal: args.ctx.signal,
109
+ ...(options.headers === undefined ? {} : { headers: options.headers(args) }),
110
+ });
111
+ const key = options.key(args);
112
+ await options.storage().put(key, bytes, { contentType: PDF_CONTENT_TYPE });
113
+ return { key, bytes: bytes.byteLength, contentType: PDF_CONTENT_TYPE };
114
+ },
115
+ });
116
+ }
@@ -0,0 +1,65 @@
1
+ // A checked PDF layout in the PROTOCOL's own words — `Page.printToPDF`'s parameters — for the raw-CDP
2
+ // printer `pdf()` drives. `pdf-print.ts` is the same layout in puppeteer's words; the two exist
3
+ // because the two callers speak to different things. Inches, because the protocol counts in them.
4
+
5
+ import type { PdfFormat, PdfLayout } from './pdf-request';
6
+
7
+ const MM_PER_INCH = 25.4;
8
+
9
+ /** Width × height in millimetres, portrait — ISO 216 and the ANSI sizes, as every engine has them. */
10
+ const PAPER_MM: ReadonlyMap<PdfFormat, readonly [number, number]> = new Map([
11
+ ['letter', [215.9, 279.4]],
12
+ ['legal', [215.9, 355.6]],
13
+ ['tabloid', [279.4, 431.8]],
14
+ ['ledger', [431.8, 279.4]],
15
+ ['a0', [841, 1189]],
16
+ ['a1', [594, 841]],
17
+ ['a2', [420, 594]],
18
+ ['a3', [297, 420]],
19
+ ['a4', [210, 297]],
20
+ ['a5', [148, 210]],
21
+ ['a6', [105, 148]],
22
+ ]);
23
+
24
+ /** An empty band rather than an absent one, for `pdf-print.ts`'s reason: Chrome's own date and URL. */
25
+ const BLANK_TEMPLATE = '<span></span>';
26
+
27
+ const inches = (mm: number): number => mm / MM_PER_INCH;
28
+
29
+ /** The paper, in millimetres, when the layout names one. `undefined` is the engine's letter. */
30
+ const paperOf = (layout: PdfLayout): readonly [number, number] | undefined => {
31
+ if (layout.format !== undefined) return PAPER_MM.get(layout.format);
32
+ if (layout.widthMm !== undefined && layout.heightMm !== undefined) {
33
+ return [layout.widthMm, layout.heightMm];
34
+ }
35
+ return undefined;
36
+ };
37
+
38
+ /**
39
+ * `Page.printToPDF`'s params for a layout `pdfLayoutOf` already checked. Only the fields the caller
40
+ * gave are sent; the bytes come back base64 in the reply (`transferMode: 'ReturnAsBase64'`).
41
+ */
42
+ export function printToPdfParams(layout: PdfLayout): Record<string, unknown> {
43
+ const paper = paperOf(layout);
44
+ const margin = layout.marginMm;
45
+ const band = layout.headerHtml !== undefined || layout.footerHtml !== undefined;
46
+ return {
47
+ transferMode: 'ReturnAsBase64',
48
+ ...(paper === undefined ? {} : { paperWidth: inches(paper[0]), paperHeight: inches(paper[1]) }),
49
+ ...(margin?.top === undefined ? {} : { marginTop: inches(margin.top) }),
50
+ ...(margin?.right === undefined ? {} : { marginRight: inches(margin.right) }),
51
+ ...(margin?.bottom === undefined ? {} : { marginBottom: inches(margin.bottom) }),
52
+ ...(margin?.left === undefined ? {} : { marginLeft: inches(margin.left) }),
53
+ ...(layout.printBackground === undefined ? {} : { printBackground: layout.printBackground }),
54
+ ...(layout.preferCssPageSize === undefined
55
+ ? {}
56
+ : { preferCSSPageSize: layout.preferCssPageSize }),
57
+ ...(band
58
+ ? {
59
+ displayHeaderFooter: true,
60
+ headerTemplate: layout.headerHtml ?? BLANK_TEMPLATE,
61
+ footerTemplate: layout.footerHtml ?? BLANK_TEMPLATE,
62
+ }
63
+ : {}),
64
+ };
65
+ }
@@ -0,0 +1,86 @@
1
+ // The print's request fence, on the page's own session: every request pauses (`Fetch`); one to the
2
+ // route's ORIGIN continues with the app's headers, every other — an off-origin redirect too — is
3
+ // failed before it leaves. Never `Network.setExtraHTTPHeaders`: it sent the print token to any
4
+ // `<img src>` a post carried, and let the page dial internal hosts.
5
+
6
+ import type { CdpConnection } from './cdp-connection';
7
+
8
+ export interface PdfFenceInit {
9
+ readonly connection: CdpConnection;
10
+ readonly sessionId: string;
11
+ /** `new URL(route).origin` — scheme, host and port, compared whole. */
12
+ readonly origin: string;
13
+ readonly headers?: Readonly<Record<string, string>> | undefined;
14
+ }
15
+
16
+ interface Paused {
17
+ readonly requestId: string;
18
+ readonly url: string;
19
+ readonly headers: Readonly<Record<string, unknown>>;
20
+ }
21
+
22
+ const readPaused = (params: Record<string, unknown>): Paused | undefined => {
23
+ const request = params['request'];
24
+ const requestId = params['requestId'];
25
+ if (typeof requestId !== 'string' || typeof request !== 'object' || request === null) {
26
+ return undefined;
27
+ }
28
+ const url = 'url' in request && typeof request.url === 'string' ? request.url : '';
29
+ const headers =
30
+ 'headers' in request && typeof request.headers === 'object' && request.headers !== null
31
+ ? Object.fromEntries(Object.entries(request.headers))
32
+ : {};
33
+ return { requestId, url, headers };
34
+ };
35
+
36
+ const originOf = (url: string): string | undefined => {
37
+ try {
38
+ return new URL(url).origin;
39
+ } catch {
40
+ return undefined;
41
+ }
42
+ };
43
+
44
+ /** The request's own headers with the app's laid over them, case-insensitively, as CDP's list. */
45
+ function mergedHeaders(
46
+ own: Readonly<Record<string, unknown>>,
47
+ extra: Readonly<Record<string, string>>,
48
+ ): { readonly name: string; readonly value: string }[] {
49
+ const overridden = new Set(Object.keys(extra).map((name) => name.toLowerCase()));
50
+ const kept = Object.entries(own)
51
+ .filter(([name, value]) => typeof value === 'string' && !overridden.has(name.toLowerCase()))
52
+ .map(([name, value]) => ({ name, value: String(value) }));
53
+ return [...kept, ...Object.entries(extra).map(([name, value]) => ({ name, value }))];
54
+ }
55
+
56
+ /**
57
+ * Raise the fence before the page navigates. Answers its removal. A decision the browser refuses
58
+ * (the target already gone) is dropped: the page's own load then fails, which is the error to read.
59
+ */
60
+ export async function raisePdfFence(init: PdfFenceInit): Promise<() => void> {
61
+ const { connection, sessionId } = init;
62
+ const extra = init.headers ?? {};
63
+ const decide = (paused: Paused): Promise<unknown> => {
64
+ if (originOf(paused.url) !== init.origin) {
65
+ return connection.send(
66
+ 'Fetch.failRequest',
67
+ { requestId: paused.requestId, errorReason: 'BlockedByClient' },
68
+ sessionId,
69
+ );
70
+ }
71
+ return connection.send(
72
+ 'Fetch.continueRequest',
73
+ Object.keys(extra).length === 0
74
+ ? { requestId: paused.requestId }
75
+ : { requestId: paused.requestId, headers: mergedHeaders(paused.headers, extra) },
76
+ sessionId,
77
+ );
78
+ };
79
+ const off = connection.on('Fetch.requestPaused', (params, from) => {
80
+ if (from !== sessionId) return;
81
+ const paused = readPaused(params);
82
+ if (paused !== undefined) void decide(paused).catch(() => undefined);
83
+ });
84
+ await connection.send('Fetch.enable', { patterns: [{ urlPattern: '*' }] }, sessionId);
85
+ return off;
86
+ }